diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 5dafa2e..5724435 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -1,13 +1,20 @@ -name: Lint workflows +name: Lint -# 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. +# The repo's cheap-checks workflow: everything that can be verified WITHOUT +# building an image. Runs on every push and PR, in ~30s, independent of the +# build pipeline. Three jobs: workflow lint (actionlint + the Gitea shell +# guard), Dockerfile lint (hadolint), and the DOCKER_HUB.md docs-sync check. +# +# Deliberately the ONLY workflow that runs on a push to main. validate.yml +# (amd64 build + smoke) is PR/dispatch-only and docker-publish-split.yml is +# tag-only, so pushing work-in-progress to main never starts an image build. +# +# Its original purpose, still the most important one: 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. on: push: pull_request: @@ -90,3 +97,20 @@ jobs: - name: Run hadolint run: hadolint Dockerfile.base Dockerfile.variant + + docs-check: + # Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py + # would produce from HUB_TEMPLATE. Keeps the two docs from drifting. + # + # Lives here rather than in validate.yml because it needs no image: keeping + # it in the cheap workflow means it still runs on every push to main now + # that validate.yml is PR/dispatch-only. Reproduce locally with + # `python3 scripts/generate-dockerhub-md.py --check`. + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + + - name: Check DOCKER_HUB.md is in sync with HUB_TEMPLATE + run: python3 scripts/generate-dockerhub-md.py --check diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 7288c3b..46671be 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -1,9 +1,24 @@ name: Validate -# Lightweight validation on pushes to main. Builds single-arch (amd64), -# runs the smoke test, and checks image size — without pushing anything -# to Docker Hub. Tag pushes are handled by docker-publish-split.yml which -# does the full multi-arch split-base build-and-push. +# Image build + smoke test. This workflow has NO push trigger at all — it fires +# only on pull_request and on explicit workflow_dispatch. +# +# Division of labour across this repo's three workflows: +# lint.yml — cheap checks, every push/PR: workflow lint, +# Dockerfile lint, DOCKER_HUB.md sync. No builds. +# validate.yml (this file) — amd64 build + smoke test. PR / manual only. +# docker-publish-split.yml — the release path, tag-only: multi-arch build, +# smoke gates, then push + promote. +# +# Net effect: no image build can start from a push to main. A tag — or an +# explicit dispatch — is required. That is safe because the release path fails +# closed: variant tags are pushed only after smoke-base/smoke-omos pass, and +# base-latest is promoted last, so an aborted release leaves at worst an +# unreferenced base- blob on Hub, never a half-published version tag. +# +# To validate before tagging: open a PR, dispatch this workflow, or dispatch +# docker-publish-split.yml against a throwaway tag with promote_latest=false +# (the only route that also exercises a CHANGED BASE — see the trade-off below). # # Trade-off: variant builds here use the published `base-latest` image # from Docker Hub as their parent, NOT a locally-built base. This is @@ -22,18 +37,10 @@ name: Validate # blind-spot applies. on: - push: - branches: - - main - paths-ignore: - - 'CHANGELOG.md' - - 'README.md' - - 'DOCKER_HUB.md' - - 'deploy/**' - - '.gitleaks.toml' pull_request: branches: - main + workflow_dispatch: # 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 @@ -44,20 +51,6 @@ defaults: shell: bash jobs: - docs-check: - # Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py - # would produce from README.md. Keeps the two docs from drifting. - runs-on: ubuntu-latest - container: - image: catthehacker/ubuntu:act-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check DOCKER_HUB.md is in sync with README.md - run: | - python3 scripts/generate-dockerhub-md.py --check - base-change-warning: # Surfaces a warning when this commit changes base-image inputs # (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so @@ -97,12 +90,21 @@ jobs: printf '%s\n' "$changed" else echo "base_changed=false" >> "$GITHUB_OUTPUT" - echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest." + echo "No base-image inputs changed in this commit — validate-base/validate-omos will build against the published base-latest." fi validate-base: needs: [base-change-warning] - if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }} + # Two gates, both must hold for this job to run: + # 1. base_changed != true — the documented blind spot: this workflow builds + # variants on top of Hub's base-latest, so a commit changing base inputs + # would either not exercise the change or hard-fail against a stale + # base-latest (see base-change-warning). + # 2. event_name != push — belt-and-braces. Redundant today (this workflow + # has no push trigger), kept deliberately so that re-adding a push + # trigger later cannot silently re-enable image builds on every push to + # main. If you intend that, remove this clause explicitly. + if: ${{ needs.base-change-warning.outputs.base_changed != 'true' && github.event_name != 'push' }} runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -165,7 +167,8 @@ jobs: validate-omos: needs: [base-change-warning] - if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }} + # Same two gates as validate-base — see the comment there. + if: ${{ needs.base-change-warning.outputs.base_changed != 'true' && github.event_name != 'push' }} runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest diff --git a/AGENTS.md b/AGENTS.md index 0a5f15d..175a7a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,9 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d - `DOCKER_HUB.md` — **auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes. - `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth. - `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan. -- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check. +- `.gitea/workflows/validate.yml` — amd64 build + smoke test for both variants. **No push trigger at all**: `pull_request` and `workflow_dispatch` only, so it never runs on a push to main. See the trigger-model convention below. - `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description. -- `.gitea/workflows/lint.yml` — cheap (~10s) workflow-lint on every push/PR/dispatch: a Gitea-accurate shell guard (`scripts/check-workflow-shell.sh`) plus pinned actionlint + shellcheck. The guard asserts every `run:` step resolves to `bash` under Gitea's `sh` default, closing the actionlint blind spot (actionlint models GitHub, whose default `run` shell is bash). Ported from pi-devbox. +- `.gitea/workflows/lint.yml` — the repo's cheap-checks workflow (~30s) and the **only** workflow that runs on a push to main. Three jobs: (1) `actionlint` — a Gitea-accurate shell guard (`scripts/check-workflow-shell.sh`) plus pinned actionlint + shellcheck, asserting every `run:` step resolves to `bash` under Gitea's `sh` default and closing the actionlint blind spot (actionlint models GitHub, whose default `run` shell is bash); (2) `hadolint` — pinned 2.14.0 against both Dockerfiles, config in `.hadolint.yaml` so a local run reproduces CI; (3) `docs-check` — `DOCKER_HUB.md` vs `HUB_TEMPLATE` sync, moved here from `validate.yml` so it survives that workflow becoming PR/dispatch-only. Ported from pi-devbox. ## Versioning scheme @@ -122,7 +122,8 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | - Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners. - `update-description` job runs when the base variant published (`needs: [build-variant-base, build-variant-omos]`, gated with `always()` + an explicit `build-variant-base.result == 'success'` check so a partial-publish run still refreshes the Hub description). -- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs. +- **No image build happens on a push — builds are tag-triggered or explicitly requested.** The three workflows divide cleanly: `lint.yml` is cheap checks on every push/PR (actionlint + shell guard, hadolint, `docs-check`) and is the **only** workflow a push to main triggers; `validate.yml` is the amd64 build + smoke test and has **no push trigger** (pull_request / workflow_dispatch only); `docker-publish-split.yml` is the release path (tag-only). So pushing work-in-progress to main is free. It is safe because the release path already fails closed: `docker-publish-split.yml` pushes variant tags only after `smoke-base`/`smoke-omos` pass and promotes `base-latest` last, so an aborted release leaves at worst an unreferenced `base-` blob on Hub — never a half-published version tag. `validate-base`/`validate-omos` additionally keep a now-redundant `github.event_name != 'push'` clause as belt-and-braces, so re-adding a push trigger can't silently re-enable builds. **To validate before tagging**, open a PR, `workflow_dispatch` the Validate workflow, or `workflow_dispatch` `docker-publish-split.yml` against a throwaway tag with `promote_latest=false` (the only option that also exercises a changed base, which `validate.yml` structurally cannot — it builds variants on top of Hub's `base-latest`). **Keep cheap checks in `lint.yml`, not `validate.yml`** — anything that needs no image belongs in the workflow that actually runs on push; that is why `docs-check` lives there. If you change this model, update this bullet, both workflow header comments, and the two *File roles* entries together. +- Tags must be pushed to trigger the publish workflow. A push to main triggers `lint.yml` only. - Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes. - **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner. - **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).