diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index c8a84f6..828cf9d 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -14,13 +14,16 @@ on: # with a stale `:dev` ml or agent is a worse trap than no dev channel at # all, since the mismatch only shows up as a runtime failure. branches: [main, dev] - # Tag-push triggers an immutable per-version image build (e.g. - # `:v26.05.26.5`) — gives a real rollback story alongside the floating - # `:main` / `:latest`. Layer reuse keeps the registry-storage cost - # negligible per tag. Doesn't overlap with the push-to-main build (that - # one publishes `:main` + `:latest`; the tag-push build publishes only - # `:`). - tags: ['v*'] + # + # NO tag trigger (milestone 318 step 2). A `v*` tag names a commit `main` + # already built and published; rebuilding it produces the same source under + # the same names and RE-PUSHES `:c-`, which rule 145 forbids even when + # the bytes match — image configs carry timestamps, so "same source" does + # not mean "same manifest". The release build was publishing nothing new + # and violating an immutability rule to do it. + # + # Releases still happen (rule 148, on explicit request per rule 2). They + # produce a changelog, not an image. # Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes: # - write:package, read:package (for docker push to git.fabledsword.com) @@ -54,10 +57,11 @@ jobs: # have hit the existing ext-1.0.11 cache and bundled MAIN's stale XPI into # `:dev` — a dev channel confidently serving old code. # - # Tags stay excluded: the tag path deliberately skips signing and polls for - # the release instead (see build-web's race note, 2026-05-27). + # Unconditional since milestone 318 step 2: main and dev are now the only + # triggers, so the branch gate that used to exclude tag pushes matched + # everything. A condition that is always true reads as if some path avoids + # it, which is worse than no condition. sign-extension: - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -323,12 +327,12 @@ jobs: # to. Same source of truth; no double-store. build-web: + # A plain `needs` — no `always()`. That expression existed to let a + # SKIPPED sign-extension through on a tag push while still blocking a + # FAILED one. With no tag trigger, sign-extension always runs, so the + # default behaviour is exactly what we want: a failed sign skips build-web + # rather than shipping an image without its XPI. needs: [sign-extension] - # sign-extension runs on main and dev, and is skipped on a tag push (which - # polls for the release instead). Either is fine to build on; a FAILED sign - # is not — this condition lets success and skipped through, so a failure - # skips build-web rather than shipping an image without the XPI. - if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 @@ -373,15 +377,7 @@ jobs: - name: Determine tag id: tag run: | - # Three trigger shapes: - # refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD, - # plus `.N` when the day already carries a tag — - # family rule 148, amended 2026-08-24 after a - # same-day tag was retargeted and a release - # deleted to make room, note 2813). - # Publish ONLY the immutable version tag; - # don't touch :latest (the main-push build - # for the merge commit already did that). + # Two trigger shapes: # refs/heads/main → push to main: publish :main + :latest # (floating) AND :c- (immutable # per-commit rollback substrate, per family @@ -411,14 +407,8 @@ jobs: # reads 2026.8.27 — and the reuse step below turns that into a # skipped build rather than a rebuild of bytes that already exist. # `channel` is baked into the image as FC_CHANNEL and reported by - # /api/extension/manifest (milestone 271 step 7). A tag-push counts as - # `main`: a vYY.MM.DD tag is cut from main, so that image is a - # main-channel artifact wearing an immutable name. - if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then - TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" - echo "channel=main" >> "$GITHUB_OUTPUT" - elif [ "${GITHUB_REF##*/}" = "main" ]; then + # /api/extension/manifest (milestone 271 step 7). + if [ "${GITHUB_REF##*/}" = "main" ]; then CALVER=$(sh scripts/artifacts.sh tag web) # Guarded, and computed only on this path. There is no `set -e` in # this step, so a failed derivation would otherwise leave CALVER @@ -493,22 +483,16 @@ jobs: IMAGE: git.fabledsword.com/bvandeusen/fabledcurator CHANNEL: ${{ steps.tag.outputs.channel }} TAGS: ${{ steps.tag.outputs.tags }} - IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }} run: | set -eu ID=$(sh scripts/artifacts.sh identity web "$CHANNEL") echo "identity=$ID" >> "$GITHUB_OUTPUT" - # A tag-push builds a revision that main already published, so it - # must NOT claim the identity: image configs are not bit-reproducible - # (embedded timestamps), so re-pushing r- would point an - # immutable tag at fresh bytes — rule 145's exact prohibition. It - # publishes only its own :v… label and otherwise reuses. - if [ "$IS_TAG_PUSH" = "true" ]; then - echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT" - else - echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" - fi + # Every build that runs now claims the identity. The carve-out here + # existed only for tag pushes, which rebuilt an already-published + # revision and so had to be stopped from re-pointing an immutable + # tag at fresh bytes. No tag trigger, nothing to carve out. + echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then echo "hit=true" >> "$GITHUB_OUTPUT" @@ -519,28 +503,26 @@ jobs: fi - name: Download signed XPI from Forgejo release asset - # Fires on every trigger shape. dev and main each bundle the XPI their - # own sign-extension just published — that is the whole point of the - # channel work (milestone 271 step 6): the dev image carries the - # extension being developed, rather than requiring a merge to try it. - # Tag-push builds re-package the same source as the preceding main-push - # build but with an immutable version tag — they need the XPI too, - # otherwise the versioned image ships without the signed extension. + # dev and main each bundle the XPI their own sign-extension just + # published — the point of the channel work (milestone 271 step 6): the + # dev image carries the extension being developed, rather than + # requiring a merge to try it. # - # Tag-push vs main-push race (operator-flagged 2026-05-27 after - # v26.05.27.0 hit it): a release cut fires BOTH workflows almost - # simultaneously. Main-push runs sign-extension (1-5min AMO round - # trip) before publishing the ext- release; tag-push - # skips sign-extension (gated to main) and races straight to - # this download step. Tag-push lost every time. Fix: poll the - # ext- release endpoint with a sleep+retry loop (30s - # for up to 10min total) before giving up. Main-push's signing - # eventually wins and tag-push picks the release up on a later - # iteration. - # Gated on the reuse miss as well: if the image is already published it - # already contains its XPI, so this would download (and on a tag-push, - # poll up to 10 minutes for) a file nothing then reads. - if: steps.reuse.outputs.hit != 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) + # The 10-minute polling loop that used to live here is gone with the + # tag trigger (milestone 318 step 2). It existed for one shape only: a + # release cut fired the tag build and the main build together, the tag + # build skipped sign-extension and raced straight here, and it lost + # every time (operator-flagged 2026-05-27 after v26.05.27.0). Polling + # was the fix for a build that should not have been running. + # + # sign-extension is a `needs` dependency and it succeeded, so the + # release exists. A single fetch is correct, and a 404 now means a real + # disagreement about the derived version rather than a race — which is + # exactly what should fail loudly instead of being slept through. + # + # Still gated on the reuse miss: a published image already contains its + # XPI, so this would fetch a file nothing then reads. + if: steps.reuse.outputs.hit != 'true' env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | @@ -552,25 +534,21 @@ jobs: # didn't, this download 404s and the build fails loudly instead of # shipping a stale XPI. VERSION=$(sh extension/scripts/packaging.sh version) - # Poll for the ext- release. main-push's sign-extension - # step (AMO round-trip, 1-5min) needs to finish + upload before - # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail. - for attempt in $(seq 1 20); do - STATUS=$(curl -s -o release.json -w "%{http_code}" \ - -H "Authorization: token $TOKEN" \ - "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) - if [ "$STATUS" = "200" ]; then - echo "Found ext-$VERSION release on attempt $attempt" - break - fi - if [ "$attempt" = "20" ]; then - echo "ERROR: ext-$VERSION release not available after 10min of polling" - echo "Last HTTP status: $STATUS" - exit 1 - fi - echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s" - sleep 30 - done + # One fetch, no retry. sign-extension ran to success in this same + # workflow and published ext-$VERSION; both jobs derive $VERSION from + # the same commit, so they agree by construction. A 404 here means + # they did NOT agree, and sleeping on that would only delay the + # report. + STATUS=$(curl -s -o release.json -w "%{http_code}" \ + -H "Authorization: token $TOKEN" \ + "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) + if [ "$STATUS" != "200" ]; then + echo "ERROR: ext-$VERSION release not found (HTTP $STATUS)." + echo "sign-extension succeeded in this run, so it published some" + echo "other version — the two jobs derived different values for one" + echo "commit. Check that both checked out with fetch-depth: 0." + exit 1 + fi # Extract the .xpi asset's browser_download_url (Forgejo's # /releases/assets/ endpoint returns ASSET METADATA, not # the binary blob — operator-flagged 2026-05-26: my prior @@ -687,10 +665,9 @@ jobs: - name: Determine tag id: tag run: | - # Mirrors build-web's three-shape logic (tag-push / main-push / - # safety-net dev) including the per-commit :c- tag - # on main-push per the family release-posture rule. The -ml - # image follows the same release cadence as the web image. + # Mirrors build-web's two-shape logic (main-push / dev-push), + # including the per-commit :c- tag on main — the rollback + # unit per rule 145. The -ml image follows the same cadence as web. # POSIX-safe substring (the runner shell is dash/BusyBox sh, not # bash — `${var:0:7}` errors with "Bad substitution"; cut works # everywhere). Operator-flagged 2026-06-01 after first :c- @@ -706,11 +683,7 @@ jobs: # the tag it already had: the agent reads 2026.7.17 today while web # reads 2026.8.27 — and the reuse step below turns that into a # skipped build rather than a rebuild of bytes that already exist. - if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then - TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" - echo "channel=main" >> "$GITHUB_OUTPUT" - elif [ "${GITHUB_REF##*/}" = "main" ]; then + if [ "${GITHUB_REF##*/}" = "main" ]; then CALVER=$(sh scripts/artifacts.sh tag ml) # Guarded, and computed only on this path. There is no `set -e` in # this step, so a failed derivation would otherwise leave CALVER @@ -768,22 +741,16 @@ jobs: IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml CHANNEL: ${{ steps.tag.outputs.channel }} TAGS: ${{ steps.tag.outputs.tags }} - IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }} run: | set -eu ID=$(sh scripts/artifacts.sh identity ml "$CHANNEL") echo "identity=$ID" >> "$GITHUB_OUTPUT" - # A tag-push builds a revision that main already published, so it - # must NOT claim the identity: image configs are not bit-reproducible - # (embedded timestamps), so re-pushing r- would point an - # immutable tag at fresh bytes — rule 145's exact prohibition. It - # publishes only its own :v… label and otherwise reuses. - if [ "$IS_TAG_PUSH" = "true" ]; then - echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT" - else - echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" - fi + # Every build that runs now claims the identity. The carve-out here + # existed only for tag pushes, which rebuilt an already-published + # revision and so had to be stopped from re-pointing an immutable + # tag at fresh bytes. No tag trigger, nothing to carve out. + echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then echo "hit=true" >> "$GITHUB_OUTPUT" @@ -889,11 +856,7 @@ jobs: # the tag it already had: the agent reads 2026.7.17 today while web # reads 2026.8.27 — and the reuse step below turns that into a # skipped build rather than a rebuild of bytes that already exist. - if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then - TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:${TAG_NAME}" >> "$GITHUB_OUTPUT" - echo "channel=main" >> "$GITHUB_OUTPUT" - elif [ "${GITHUB_REF##*/}" = "main" ]; then + if [ "${GITHUB_REF##*/}" = "main" ]; then CALVER=$(sh scripts/artifacts.sh tag agent) # Guarded, and computed only on this path. There is no `set -e` in # this step, so a failed derivation would otherwise leave CALVER @@ -951,22 +914,16 @@ jobs: IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent CHANNEL: ${{ steps.tag.outputs.channel }} TAGS: ${{ steps.tag.outputs.tags }} - IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }} run: | set -eu ID=$(sh scripts/artifacts.sh identity agent "$CHANNEL") echo "identity=$ID" >> "$GITHUB_OUTPUT" - # A tag-push builds a revision that main already published, so it - # must NOT claim the identity: image configs are not bit-reproducible - # (embedded timestamps), so re-pushing r- would point an - # immutable tag at fresh bytes — rule 145's exact prohibition. It - # publishes only its own :v… label and otherwise reuses. - if [ "$IS_TAG_PUSH" = "true" ]; then - echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT" - else - echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" - fi + # Every build that runs now claims the identity. The carve-out here + # existed only for tag pushes, which rebuilt an already-published + # revision and so had to be stopped from re-pointing an immutable + # tag at fresh bytes. No tag trigger, nothing to carve out. + echo "build_tags=$TAGS,$IMAGE:$ID" >> "$GITHUB_OUTPUT" if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then echo "hit=true" >> "$GITHUB_OUTPUT"