diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml index c567396..65b385c 100644 --- a/.gitea/workflows/docker-publish-split.yml +++ b/.gitea/workflows/docker-publish-split.yml @@ -34,6 +34,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects +# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the +# default to bash workflow-wide eliminates the whole class of "forgot +# `shell: bash` on this step" bugs. (Ported from pi-devbox, where this class +# bit twice: ed49b8d resolve-versions, b7197e8/b33e9dc promote-base-latest, +# run 418.) All existing dash steps use only POSIX syntax, so bash (a +# superset) runs them unchanged. Enforced by lint.yml's shell guard. +defaults: + run: + shell: bash + env: BUILDKIT_PROGRESS: plain IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox @@ -519,11 +530,16 @@ jobs: - base-decide - build-variant-base - build-variant-omos - # Skip on cache-hit base builds: when need_build=false, base-latest - # already points at the same digest as base-, so the retag is - # a tautology and any transient failure of it is purely cosmetic. - # Manual workflow_dispatch with promote_latest=true overrides this - # gate as an escape hatch (e.g., if base-latest got hand-deleted). + # Run on every tag release (and promote_latest=true dispatch). The gate + # deliberately does NOT key off need_build anymore: the no-op optimization + # for genuine cache-hit releases moved INTO the step as a crane digest + # compare (see below). Keying the gate on need_build was wrong because a + # prior dry-run dispatch (promote_latest=false) can pre-build+push + # base-, making need_build=false on the subsequent tag run even + # though base-latest is still stale — the old gate then skipped promotion + # and left base-latest pointing at the PREVIOUS base. (Ported from + # pi-devbox b7197e8, which hit exactly this on its v1.2.3 dry-run-first + # release, 2026-06-27.) # # `always()` wrapper + explicit base-variant success check protects # against the gitea-Actions default of "skipped need => skip dependent": @@ -532,8 +548,7 @@ jobs: if: | always() && needs.build-variant-base.result == 'success' && - (inputs.promote_latest == 'true' || - (github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true')) + (inputs.promote_latest == 'true' || github.ref_type == 'tag') runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -558,11 +573,31 @@ jobs: crane auth login docker.io \ -u ${{ vars.DOCKERHUB_USERNAME }} \ -p "${{ secrets.DOCKERHUB_TOKEN }}" - - name: Re-tag base- as base-latest + - name: Re-tag base- as base-latest (only if stale) + env: + BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest run: | - crane copy \ - ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \ - ${{ env.IMAGE }}:base-latest + set -euo pipefail + # Correctness invariant: after a release, base-latest must resolve to + # the SAME digest as the base- the just-built variants were + # FROM. Compare digests rather than trusting need_build — a prior + # dry-run dispatch can pre-build base-, so need_build=false on + # the tag run does NOT imply base-latest is already current. When the + # digests already match (genuine cache-hit release) this is a no-op, + # so we skip the crane copy entirely — preserving the original + # "don't do a tautological retag" intent and avoiding any cosmetic + # transient-failure exposure on releases that change nothing. + want=$(crane digest "${BASE_HASH_REF}") + have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "") + echo "base- digest: ${want}" + echo "base-latest digest: ${have:-}" + if [ "${want}" = "${have}" ]; then + echo "base-latest already current; nothing to promote." + else + echo "Promoting base-latest -> ${BASE_HASH_REF}" + crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}" + fi # ── Phase 6: update Hub description (only on real release runs) ──── update-description: diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..d6f2075 --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -0,0 +1,68 @@ +name: Lint workflows + +# Durable guard against CI-workflow bugs — most importantly the "bash-only +# syntax under the default `sh`/dash shell" footgun. Ported from pi-devbox, +# where this class broke resolve-versions (ed49b8d) and promote-base-latest +# (b7197e8 → run 418). actionlint runs shellcheck against each `run:` step +# using its *effective* shell, so `set -o pipefail` under dash is flagged as +# SC3040 before any expensive build runs. This is cheap (~10s) and independent +# of the build pipeline, so it fires on every push/PR — not just on release +# tags, which is where docker-publish-split.yml is otherwise only triggered. +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + actionlint: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: | + apt-get update + apt-get install -y --no-install-recommends shellcheck python3-yaml + + - name: Gitea shell guard (catches the actionlint blind spot) + # actionlint models GitHub Actions, where the default run shell is + # bash, so it does NOT flag bash syntax in a step that merely OMITS + # `shell:` — which is exactly how ed49b8d and b7197e8 manifested on + # Gitea (default sh/dash). This guard enforces that every run: step + # resolves to bash under Gitea's real defaults. Run it BEFORE + # actionlint so the more precise diagnostic surfaces first. + run: bash scripts/check-workflow-shell.sh .gitea/workflows + + - name: Install actionlint (pinned) + env: + ACTIONLINT_VERSION: 1.7.7 + run: | + curl -fsSL \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin actionlint + actionlint --version + + - name: Run actionlint + # SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions) + # so the guard stays focused on correctness bugs — crucially the + # SC3xxx "not POSIX / wrong shell" family that catches the pipefail + # footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any + # other SC3xxx code. + env: + SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312" + # Pass explicit paths: actionlint's no-arg mode auto-detects a + # project by looking for `.github/workflows`, which doesn't exist in + # this `.gitea/workflows` repo and hard-fails with exit 3 + # ("no project was found"). Globbing the workflow files is the + # supported way to lint a non-GitHub layout. + run: actionlint -color .gitea/workflows/*.yml diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 6a562fb..635b5bc 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -35,6 +35,14 @@ on: branches: - main +# Gitea Actions' default step shell is `sh` (dash); force bash workflow-wide so +# no run: step silently falls through to dash. Enforced by lint.yml's +# scripts/check-workflow-shell.sh guard, which scans ALL .gitea/workflows/*.yml +# (so this file must resolve to bash too, not just docker-publish-split.yml). +defaults: + run: + shell: bash + jobs: docs-check: # Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 248f2d0..c35e286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,40 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image now ignored across all repos in the container without per-repo `.gitignore` entries. The `core.excludesFile` wiring is skipped if the user already set one. +- **Workflow-lint guard (`.gitea/workflows/lint.yml` + `scripts/check-workflow-shell.sh`).** + New cheap (~10s) lint workflow that runs on every push/PR (not just release + tags): a Gitea-accurate shell guard plus pinned `actionlint` + `shellcheck`. + The custom guard asserts every `run:` step in every `.gitea/workflows/*.yml` + resolves to an effective shell of `bash`, closing the actionlint blind spot + (actionlint models GitHub, whose default `run` shell is bash, so it does not + flag bash syntax in a step that merely omits `shell:` — the exact way the + sh-vs-bash footgun manifests on Gitea, whose default is `sh`/dash). Ported + from pi-devbox. + +### Changed (CI) + +- **Workflow-wide `defaults: run: shell: bash`** added to + `docker-publish-split.yml` and `validate.yml`. Gitea Actions' default step + shell is `sh` (dash), so bash-only syntax (`set -o pipefail`, `[[ ]]`, + arrays) in a step that forgets `shell: bash` fails silently. Setting the + default workflow-wide eliminates the whole class. All pre-existing steps use + only POSIX syntax, so bash (a superset) runs them unchanged — no behavioural + change. Preventive port from pi-devbox, where this class bit twice. + +### Fixed (CI) + +- **`promote-base-latest` re-points `base-latest` by digest, not `need_build`.** + The job gate keyed off `need_build == 'true'`, assuming `need_build == false` + meant `base-latest` was already current. A dry-run dispatch + (`promote_latest=false`) that pre-builds `base-` falsifies that: the + later tag run sees `need_build == false`, skips promotion, and leaves + `base-latest` one base behind. The gate now runs on every tag release / + promote dispatch, and the no-op optimization moved into the step as a `crane + digest` compare — it re-tags only when `base-latest` actually differs from the + released `base-` (genuine cache-hit releases stay a no-op). Workflow-only + change; base hash unaffected (no base rebuild). Ported from pi-devbox b7197e8 + (which hit this on its v1.2.3 release, 2026-06-27). + --- ## v2.3.0 — 2026-06-25 diff --git a/scripts/check-workflow-shell.sh b/scripts/check-workflow-shell.sh new file mode 100755 index 0000000..f59a6e9 --- /dev/null +++ b/scripts/check-workflow-shell.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Gitea-accurate guard against the "bash syntax under the default sh/dash +# shell" footgun. Ported from pi-devbox, where this class bit twice +# (ed49b8d resolve-versions; b7197e8/b33e9dc promote-base-latest, run 418). +# opencode-devbox has not been bitten yet — this is a PREVENTIVE guard so a +# future author can't reintroduce the class. +# +# WHY A CUSTOM CHECK AND NOT JUST actionlint: +# actionlint models *GitHub* Actions, whose default `run` shell is bash. It +# therefore assumes a step that omits `shell:` runs under bash, and does NOT +# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the +# exact bug (omit `shell:`, use bash syntax) is invisible to actionlint. +# actionlint only fires when a step *explicitly* declares `shell: sh`. +# +# THE INVARIANT THIS ENFORCES: +# Every `run:` step in every .gitea/workflows/*.yml must resolve to an +# effective shell of `bash` — via the step's own `shell:`, a job-level +# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step +# that would fall through to Gitea's `sh` default is a FAILURE, because a +# future author adding bash syntax to it fails silently in CI. +# +# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax, +# expression errors, and much else). Together they cover the class on Gitea. +set -euo pipefail + +WF_DIR="${1:-.gitea/workflows}" + +python3 - "$WF_DIR" <<'PY' +import sys, glob, os +try: + import yaml +except ImportError: + sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n") + sys.exit(2) + +wf_dir = sys.argv[1] +files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml"))) +if not files: + sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n") + sys.exit(2) + +problems = [] +for f in files: + with open(f) as fh: + doc = yaml.safe_load(fh) or {} + wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell")) + jobs = doc.get("jobs") or {} + for jname, job in jobs.items(): + job = job or {} + job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell")) + steps = job.get("steps") or [] + for i, step in enumerate(steps): + step = step or {} + if "run" not in step: + continue # `uses:` steps have no shell + eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh + if eff != "bash": + name = step.get("name") or f"step[{i}]" + problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)") + +if problems: + sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n") + for p in problems: + sys.stderr.write(f" - {p}\n") + sys.exit(1) +print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.") +PY