ci: a release names a build, it does not make one (milestone 318 step 2)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m50s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m50s
Removes the `v*` image-build trigger. A release tag names a commit `main` already built and published; rebuilding it produced the same source under the same names and RE-PUSHED `:c-<sha>` — which rule 145 forbids even when the bytes match, because image configs carry timestamps and "same source" does not mean "same manifest". The tag build was publishing nothing new and violating an immutability rule to do it. Three constructs go with it, all of which existed only to manage that build: The 10-minute XPI polling loop. A release cut fired the tag build and the main build together; the tag build skipped sign-extension and raced straight to the download, losing every time (operator-flagged 2026-05-27 after v26.05.27.0). Polling was the fix for a build that should not have run. It is now a single fetch, and a 404 means the two jobs derived different versions for one commit — which should fail loudly rather than be slept through. The IS_TAG_PUSH carve-out from milestone 313 step 4, which stopped a tag build from claiming an identity tag it would have re-pointed at fresh bytes. build-web's `always() && (success || skipped)` gate, which existed to let a SKIPPED sign-extension through on a tag push while still blocking a failed one. sign-extension now always runs, so a plain `needs` gives exactly the wanted behaviour. Its own branch condition goes too: main and dev are the only triggers, so a gate naming both matched everything, and a condition that is always true reads as though some path avoids it. Releases still happen — rule 148 is untouched and tags are still cut on explicit request per rule 2. They stop building images and start carrying a changelog (step 7). Net 115 lines deleted, 73 added, most of that comments explaining races that can no longer occur. Nothing in the repo referenced the tag build, so no doc changes were needed; the broader doc pass is step 9.
This commit is contained in:
+76
-119
@@ -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
|
||||
# `:<tag>`).
|
||||
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-<sha>`, 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-<short_sha> (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-<rev> 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-<version> 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-<version> 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-<version> 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/<id> 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-<short_sha> 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-<short_sha> 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-<sha>
|
||||
@@ -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-<rev> 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-<rev> 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"
|
||||
|
||||
Reference in New Issue
Block a user