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 }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
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:
|
env:
|
||||||
BUILDKIT_PROGRESS: plain
|
BUILDKIT_PROGRESS: plain
|
||||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
||||||
@@ -519,11 +530,16 @@ jobs:
|
|||||||
- base-decide
|
- base-decide
|
||||||
- build-variant-base
|
- build-variant-base
|
||||||
- build-variant-omos
|
- build-variant-omos
|
||||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
# Run on every tag release (and promote_latest=true dispatch). The gate
|
||||||
# already points at the same digest as base-<hash>, so the retag is
|
# deliberately does NOT key off need_build anymore: the no-op optimization
|
||||||
# a tautology and any transient failure of it is purely cosmetic.
|
# for genuine cache-hit releases moved INTO the step as a crane digest
|
||||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
# compare (see below). Keying the gate on need_build was wrong because a
|
||||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
# 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
|
# `always()` wrapper + explicit base-variant success check protects
|
||||||
# against the gitea-Actions default of "skipped need => skip dependent":
|
# against the gitea-Actions default of "skipped need => skip dependent":
|
||||||
@@ -532,8 +548,7 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
always() &&
|
always() &&
|
||||||
needs.build-variant-base.result == 'success' &&
|
needs.build-variant-base.result == 'success' &&
|
||||||
(inputs.promote_latest == 'true' ||
|
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
|
||||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -558,11 +573,31 @@ jobs:
|
|||||||
crane auth login docker.io \
|
crane auth login docker.io \
|
||||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
-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: |
|
run: |
|
||||||
crane copy \
|
set -euo pipefail
|
||||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
# Correctness invariant: after a release, base-latest must resolve to
|
||||||
${{ env.IMAGE }}:base-latest
|
# 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) ────
|
# ── Phase 6: update Hub description (only on real release runs) ────
|
||||||
update-description:
|
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:
|
branches:
|
||||||
- main
|
- 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:
|
jobs:
|
||||||
docs-check:
|
docs-check:
|
||||||
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
|
# 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`
|
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.
|
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
|
## 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