Stop minting identifiers nothing reads: moving channel tags, :c-<sha> on main, and a label-keyed build cache #239

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