ci: no image build on push — split cheap checks from builds
Lint / hadolint (push) Successful in 8s
Lint / docs-check (push) Successful in 14s
Lint / actionlint (push) Successful in 6m41s

A push to main used to trigger validate.yml, which builds an amd64 variant and
runs the smoke test. Pushing work-in-progress to main therefore cost a build.
Restructure so the three workflows divide by cost, matching pi-devbox:

  lint.yml                 cheap checks, every push/PR — the ONLY workflow a
                           push to main triggers
  validate.yml             amd64 build + smoke test — pull_request and
                           workflow_dispatch only, no push trigger at all
  docker-publish-split.yml the release path, tag-only (unchanged)

docs-check (DOCKER_HUB.md vs HUB_TEMPLATE) moves from validate.yml to lint.yml.
It needs no image, and anything that needs no image belongs in the workflow that
actually runs on push — otherwise the doc-drift guard would have been silently
lost when validate.yml stopped running on pushes. That guard earns its keep: it
has caught real drift.

validate-base/validate-omos additionally keep a `github.event_name != 'push'`
clause. It is redundant now that the trigger is gone, and deliberately so:
re-adding a push trigger later cannot silently re-enable builds on every push.

Renamed lint.yml `Lint workflows` -> `Lint`, since it now covers Dockerfiles and
docs as well as workflows. No references to the old name existed.

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-<hash> blob on Hub — never a half-published version tag. Pre-tag
validation remains available three ways: open a PR, dispatch Validate, or
dispatch docker-publish-split.yml against a throwaway tag with
promote_latest=false — the only route that also exercises a CHANGED base, which
validate.yml structurally cannot (it builds variants on Hub's base-latest).

Coverage lost is narrower than it looks: validate-base/validate-omos were
already skipped whenever a commit touched Dockerfile.base, rootfs/, or
entrypoint*.sh, so they only ever ran for variant-only changes — most usefully a
bare OPENCODE_VERSION bump, for which an explicit dispatch is now the
equivalent.

Verified with the CI-pinned actionlint 1.7.7 and scripts/check-workflow-shell.sh.
AGENTS.md updated in the same commit (file roles for both workflows + the
trigger-model convention). The CHANGELOG entry lands with the v2.9.0 changeset
in the following commit, which is one unreleased block covering all of v2.9.0.
This commit is contained in:
pi
2026-08-04 16:23:03 +02:00
parent d2f2396c4a
commit 5fb07e0a39
3 changed files with 70 additions and 42 deletions
+33 -9
View File
@@ -1,13 +1,20 @@
name: Lint workflows name: Lint
# Durable guard against CI-workflow bugs — most importantly the "bash-only # The repo's cheap-checks workflow: everything that can be verified WITHOUT
# syntax under the default `sh`/dash shell" footgun. Ported from pi-devbox, # building an image. Runs on every push and PR, in ~30s, independent of the
# where this class broke resolve-versions (ed49b8d) and promote-base-latest # build pipeline. Three jobs: workflow lint (actionlint + the Gitea shell
# (b7197e8 → run 418). actionlint runs shellcheck against each `run:` step # guard), Dockerfile lint (hadolint), and the DOCKER_HUB.md docs-sync check.
# 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 # Deliberately the ONLY workflow that runs on a push to main. validate.yml
# of the build pipeline, so it fires on every push/PR — not just on release # (amd64 build + smoke) is PR/dispatch-only and docker-publish-split.yml is
# tags, which is where docker-publish-split.yml is otherwise only triggered. # 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: on:
push: push:
pull_request: pull_request:
@@ -90,3 +97,20 @@ jobs:
- name: Run hadolint - name: Run hadolint
run: hadolint Dockerfile.base Dockerfile.variant 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
+33 -30
View File
@@ -1,9 +1,24 @@
name: Validate name: Validate
# Lightweight validation on pushes to main. Builds single-arch (amd64), # Image build + smoke test. This workflow has NO push trigger at all — it fires
# runs the smoke test, and checks image size — without pushing anything # only on pull_request and on explicit workflow_dispatch.
# to Docker Hub. Tag pushes are handled by docker-publish-split.yml which #
# does the full multi-arch split-base build-and-push. # 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-<hash> 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 # Trade-off: variant builds here use the published `base-latest` image
# from Docker Hub as their parent, NOT a locally-built base. This is # from Docker Hub as their parent, NOT a locally-built base. This is
@@ -22,18 +37,10 @@ name: Validate
# blind-spot applies. # blind-spot applies.
on: on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
- 'README.md'
- 'DOCKER_HUB.md'
- 'deploy/**'
- '.gitleaks.toml'
pull_request: pull_request:
branches: branches:
- main - main
workflow_dispatch:
# Gitea Actions' default step shell is `sh` (dash); force bash workflow-wide so # 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 # no run: step silently falls through to dash. Enforced by lint.yml's
@@ -44,20 +51,6 @@ defaults:
shell: bash shell: bash
jobs: 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: base-change-warning:
# Surfaces a warning when this commit changes base-image inputs # Surfaces a warning when this commit changes base-image inputs
# (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so # (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so
@@ -97,12 +90,21 @@ jobs:
printf '%s\n' "$changed" printf '%s\n' "$changed"
else else
echo "base_changed=false" >> "$GITHUB_OUTPUT" 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 fi
validate-base: validate-base:
needs: [base-change-warning] 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 runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -165,7 +167,8 @@ jobs:
validate-omos: validate-omos:
needs: [base-change-warning] 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 runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
+4 -3
View File
@@ -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. - `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. - `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/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/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 ## 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. - 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). - `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-<hash>` 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. - 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. - **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). - **`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).