ci: port pi-devbox CI hardening — bash-default footgun guard + base-latest digest promote
Two CI-only changes ported from pi-devbox (no runtime/image impact), adapted to opencode-devbox's split-base 2-variant pipeline. Rides the next release. C — eliminate the sh-vs-bash footgun class: - Add `defaults: run: shell: bash` workflow-wide to docker-publish-split.yml and validate.yml. Gitea's default step shell is sh/dash, so bash-only syntax in a step that omits `shell: bash` fails silently. All pre-existing steps are POSIX, so bash runs them unchanged (no behavioural change). - New .gitea/workflows/lint.yml (push/PR/dispatch): a Gitea-accurate shell guard (scripts/check-workflow-shell.sh) + pinned actionlint + shellcheck. The guard closes the actionlint blind spot: actionlint models GitHub (default shell bash) so it does NOT flag bash syntax in a shell-less step. Guard scans ALL .gitea/workflows/*.yml (hence the validate.yml default too). Ported from pi-devbox 26384fe/d1db595. B — promote-base-latest re-points base-latest by digest, not need_build: The gate keyed off need_build=='true', assuming need_build==false meant base-latest was current. A dry-run dispatch that pre-builds base-<hash> falsifies that, leaving base-latest one base behind. Gate now runs on every tag release / promote dispatch; the no-op optimization moved into the step as a crane digest compare (re-tags only when base-latest != released base-<hash>). Ported from pi-devbox b7197e8. Validated locally: all 3 workflows YAML-parse; shell guard passes real workflows and correctly fails a synthetic omit-shell+pipefail workflow; actionlint (pinned 1.7.7) passes with explicit .gitea/workflows/*.yml glob.
This commit is contained in:
@@ -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-<hash>, 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-<hash>, 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-<hash> as base-latest
|
||||
- name: Re-tag base-<hash> 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-<hash> the just-built variants were
|
||||
# FROM. Compare digests rather than trusting need_build — a prior
|
||||
# dry-run dispatch can pre-build base-<hash>, 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-<hash> digest: ${want}"
|
||||
echo "base-latest digest: ${have:-<absent>}"
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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-<hash>` 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-<hash>` (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
|
||||
|
||||
Executable
+67
@@ -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
|
||||
Reference in New Issue
Block a user