CI / extension-version (push) Successful in 4s
CI / lint (push) Failing after 4s
Build images / sign-extension (push) Successful in 4s
CI / backend-lint-and-test (push) Failing after 13s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 22s
CI / integration (push) Failing after 2m24s
Build images / build-web (push) Successful in 2m44s
Build images / build-ml (push) Successful in 3m13s
Build images / build-agent (push) Successful in 8m56s
The shadow (dee93fa, run 4732) answered the gate: `imagetools inspect
--format` reads `.Image.Config.Labels` against this registry on buildx
v0.36.1. So the reuse check now asks the moving channel tag whether the image
it already points at carries this commit's `fc.revision`, and the r-<rev>
identity tags stop being published.
Three things this removes rather than manages:
A name minted per build that one thing read. Rule 145's narrowing is aimed
exactly there — "a third name for the same thing is upkeep for a model we do
not run."
The -main/-dev qualifier, and the CHANNELLED list behind it. Which tag you
inspect IS the channel, so the distinction has nowhere to live. cmd_identity
goes with it.
A silent expiry nobody wrote down. r-<rev> matches no branch of the
registry's keep_pattern (#3157), so identity tags were prunable past the
newest 10 — a pruned one costs a rebuild, in the safe direction and entirely
invisibly. A label rides inside a tag that has to exist anyway.
It also dissolves #3154 instead of deferring it: a scheduled base refresh
rebuilds :latest with the same revision label, the next unrelated push sees a
match and skips, and the refreshed base survives. Under the tag scheme that
push repointed :latest back to the older base.
The measured detail that shapes the code: a missing label returns an EMPTY
STRING and exits 0. Branching on the exit code would read "no label yet" as
success and skip a build that was needed. So it compares values, and every
uncertain case — absent label, unreachable tag, older image — lands as empty,
never equals a 12-char revision, and falls through to a build.
Reading the specific key matters too. The map carries the base image's labels,
and org.opencontainers.image.version sits right beside ours reading 24.04 on
the agent — a plausible-looking wrong answer.
Expect every artifact to rebuild once on this push: nothing carries a label
yet and it cannot be backfilled, since the reuse path copies a manifest and
config labels are not manifest annotations. One rebuild per artifact, ever,
self-healing after.
test_artifact_identity.py is rewritten around what is now load-bearing. The
CHANNELLED drift test had nothing left to guard; in its place the revision is
asked of git directly, so the file fails if the derivation ever stops being
"the commit this artifact's own shipped files last changed in".
1073 lines
57 KiB
YAML
1073 lines
57 KiB
YAML
name: Build images
|
|
|
|
on:
|
|
push:
|
|
# `:dev` builds were dropped 2026-05-26 to save a docker build per dev
|
|
# push, on the reasoning that "operator tests from `:latest` after
|
|
# merge-to-main". Restored 2026-08-27: that is testing by shipping, and
|
|
# family rules 146/147 now name it directly — `main` IS production, and a
|
|
# channel that can only be refreshed by shipping is not a channel. The
|
|
# pressure to merge in order to try something does not come from
|
|
# carelessness; it comes from `:dev` being unable to carry the build.
|
|
#
|
|
# All three images build on dev, deliberately: a `:dev` web image paired
|
|
# 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]
|
|
#
|
|
# 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)
|
|
# - write:release (for ext-<version> release asset cache)
|
|
# - write:issue (for future issue-management automation)
|
|
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.
|
|
|
|
jobs:
|
|
# Sign-or-fetch-from-cache: signs the extension via AMO if no ext-<version>
|
|
# Forgejo release exists yet, otherwise downloads the cached signed XPI.
|
|
# Result is uploaded as an Actions artifact for build-web to consume.
|
|
#
|
|
# Why this lives in build.yml (not a separate workflow): the image a push
|
|
# publishes MUST carry the XPI. A separate sign workflow racing build.yml
|
|
# leaves that image without one for ~5min (until the commit-back triggers
|
|
# another build). Inline ordering eliminates the race.
|
|
# Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic
|
|
# Packages (cleaner API surface) and commit-back-to-side-branch (no extra
|
|
# branch to manage). AMO blocks re-signing the same version (returns 409),
|
|
# so signing is intentionally one-shot per version.
|
|
#
|
|
# BOTH branches sign (milestone 271 step 6, 2026-08-27). Not two signatures:
|
|
# the version is the commit TIME of the newest packaged-extension change, so
|
|
# dev and main derive the SAME number for the same extension source. A dev
|
|
# push that changes the extension signs it; the merge to main then finds the
|
|
# ext-<version> release already there, hits the cache, and bundles the
|
|
# byte-identical XPI into `:latest` with no second AMO call. One signature
|
|
# per extension CHANGE, shared by both channels — that is what makes two
|
|
# channels affordable, and it is why step 4 (derived version) had to land
|
|
# first. Ungating this while the version was still the hand-set 1.0.11 would
|
|
# have hit the existing ext-1.0.11 cache and bundled MAIN's stale XPI into
|
|
# `:dev` — a dev channel confidently serving old code.
|
|
#
|
|
# 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:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Full history is load-bearing, not a convenience: the version this
|
|
# job signs is derived from the commit TIME of the newest packaged
|
|
# extension change. A depth-1 clone sees one commit and derives a
|
|
# wrong, too-low value rather than failing (ci-requirements.md).
|
|
fetch-depth: 0
|
|
|
|
# The version is DERIVED, not read from the repo (milestone 271 step 4,
|
|
# cut over 2026-08-27). `packaging.sh version` returns MAJOR.MINOR from
|
|
# manifest.json plus a patch component that is the commit TIME of the
|
|
# newest change to a PACKAGED extension file, in minutes since
|
|
# 2020-01-01 — family rule 149, never a commit count, which orders by
|
|
# branch rather than by recency.
|
|
#
|
|
# The committed "version" in manifest.json / package.json no longer
|
|
# decides anything: the stamp step below overwrites it in the working
|
|
# tree before web-ext ever reads it. It is deliberately NOT committed
|
|
# back — the commit carrying the bump would itself be a change to the
|
|
# extension and would move the version again. The repo holds the source;
|
|
# the build derives the label.
|
|
- name: Derive extension version
|
|
id: extver
|
|
run: |
|
|
set -eu
|
|
VERSION=$(sh extension/scripts/packaging.sh version)
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
echo "Derived extension version: $VERSION"
|
|
|
|
# Firefox refuses a downgrade and AMO never releases a burned version,
|
|
# so a version that moves BACKWARDS is unrecoverable: it strands every
|
|
# install that already took the higher one. Two ways it could happen —
|
|
# a checkout without full history (derives too low), or a rewritten
|
|
# history that drops the newest packaged commit.
|
|
#
|
|
# The test is `derived < highest already signed`, strictly. Equality is
|
|
# the ORDINARY case, not a fault: an unchanged extension derives the same
|
|
# version it did last build, which is exactly what lets the ext-<version>
|
|
# cache hit and holds AMO to one call per extension CHANGE. Only moving
|
|
# backwards is a failure, so this runs on every path — cache hit
|
|
# included — rather than only before a sign.
|
|
# --- shadow mode (milestone 313, step 2) -----------------------------
|
|
# Informational ONLY. Nothing reads this and it must never fail the
|
|
# build — no `set -e`, and every derivation falls back to UNAVAILABLE.
|
|
#
|
|
# What to watch across pushes, because this is what step 3 will trust:
|
|
# * a push touching only agent/ moves the agent and leaves web and ml
|
|
# STILL. If web moves, its path set is too wide.
|
|
# * a push touching only docs moves nothing.
|
|
# * a push touching the extension moves the extension AND web, since
|
|
# web bakes in the XPI. If web does not move, its set is too narrow
|
|
# — the direction that serves stale bytes on a pin.
|
|
# * dev and main derive the same values for the same source.
|
|
- name: Shadow — derived artifact version (informational)
|
|
run: |
|
|
set -u
|
|
A=extension
|
|
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
V=$(sh scripts/artifacts.sh version "$A" 2>&1 || echo UNAVAILABLE)
|
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
|
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
|
|
|
- name: Guard — the derived version must never go backwards
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
DERIVED: ${{ steps.extver.outputs.version }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json, os, sys, urllib.request
|
|
|
|
API = ("https://git.fabledsword.com/api/v1/repos/"
|
|
"bvandeusen/FabledCurator/releases")
|
|
headers = {"Authorization": "token " + os.environ["TOKEN"]}
|
|
|
|
# Paginated rather than first-page-only: ext-* releases share this
|
|
# list with the v* release tags, so one page would start missing them
|
|
# as those accumulate. The bound FAILS rather than silently scanning
|
|
# part of the list and calling the highest it saw the highest there is.
|
|
tags = []
|
|
for page in range(1, 21):
|
|
req = urllib.request.Request(
|
|
f"{API}?limit=50&page={page}", headers=headers)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
batch = json.load(resp)
|
|
if not batch:
|
|
break
|
|
tags += [r.get("tag_name", "") for r in batch]
|
|
else:
|
|
sys.exit("guard: >1000 releases — pagination bound reached")
|
|
|
|
def parse(v):
|
|
try:
|
|
return tuple(int(part) for part in v.split("."))
|
|
except ValueError:
|
|
return None
|
|
|
|
derived_s = os.environ["DERIVED"]
|
|
derived = parse(derived_s)
|
|
if derived is None:
|
|
sys.exit(f"guard: derived version {derived_s!r} is not numeric")
|
|
|
|
signed = sorted(
|
|
(v, t) for t in tags if t.startswith("ext-")
|
|
for v in [parse(t[4:])] if v
|
|
)
|
|
if not signed:
|
|
print("guard: no ext-* release yet — nothing to go backwards from")
|
|
raise SystemExit(0)
|
|
|
|
hi, hi_tag = signed[-1]
|
|
print(f"guard: derived={derived_s} highest already signed={hi_tag}")
|
|
if derived < hi:
|
|
sys.exit(
|
|
f"REFUSING TO SIGN: derived {derived_s} is OLDER than the "
|
|
f"already-signed {hi_tag}. Firefox would reject it as a "
|
|
f"downgrade, and AMO will not release the burned version. "
|
|
f"First thing to check: did this job check out with "
|
|
f"fetch-depth: 0?"
|
|
)
|
|
print("guard: ok")
|
|
PY
|
|
|
|
- name: Check Forgejo release-asset cache
|
|
id: cache
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
run: |
|
|
set -eu
|
|
VERSION=${{ steps.extver.outputs.version }}
|
|
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)
|
|
echo "Tag lookup HTTP status: $STATUS"
|
|
# JSON parsing via python (ci-python:3.14 has stdlib json; jq is
|
|
# not in the image and adding it per ci-requirements.md is not
|
|
# warranted for a single consumer — operator-flagged 2026-05-26
|
|
# after a sign job failed with `jq: not found`).
|
|
if [ "$STATUS" = "200" ]; then
|
|
ASSET_ID=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['id'] if xpis else '')")
|
|
if [ -n "$ASSET_ID" ]; then
|
|
echo "cached=true" >> "$GITHUB_OUTPUT"
|
|
echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT"
|
|
echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign"
|
|
else
|
|
echo "cached=false" >> "$GITHUB_OUTPUT"
|
|
echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload"
|
|
fi
|
|
else
|
|
echo "cached=false" >> "$GITHUB_OUTPUT"
|
|
echo "No release named ext-$VERSION; will sign via AMO and upload"
|
|
fi
|
|
|
|
# No "download cached XPI in sign-extension" step: build-web
|
|
# fetches directly from the Forgejo ext-<version> release asset
|
|
# (removed 2026-05-26 alongside the actions/upload-artifact
|
|
# removal — sign-extension's job is just to ensure the cache
|
|
# exists on Forgejo; the build-web side reads it independently).
|
|
|
|
# web-ext signs whatever manifest.json says, so the derived value has to
|
|
# reach the tree before signing. package.json is written too: the two are
|
|
# required to agree (ci.yml's guard), and a local `npm run build` reads
|
|
# it. Working tree only — never committed, per the note on the derive
|
|
# step.
|
|
- name: Stamp the derived version into manifest.json + package.json
|
|
env:
|
|
DERIVED: ${{ steps.extver.outputs.version }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import json, os
|
|
|
|
version = os.environ["DERIVED"]
|
|
for path in ("extension/manifest.json", "extension/package.json"):
|
|
with open(path) as fh:
|
|
doc = json.load(fh)
|
|
doc["version"] = version
|
|
with open(path, "w") as fh:
|
|
json.dump(doc, fh, indent=2)
|
|
fh.write("\n")
|
|
print(f"{path}: version -> {version}")
|
|
PY
|
|
|
|
- name: Sign via AMO (cache miss)
|
|
if: steps.cache.outputs.cached != 'true'
|
|
run: |
|
|
cd extension && npm install --no-save --no-audit --no-fund && npm run sign
|
|
env:
|
|
WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }}
|
|
WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}
|
|
|
|
- name: Upload signed XPI to ext-<version> release (cache miss)
|
|
if: steps.cache.outputs.cached != 'true'
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
run: |
|
|
set -eux
|
|
VERSION=${{ steps.extver.outputs.version }}
|
|
# AMO renames signed XPIs with its internal addon-id-safe-string;
|
|
# canonicalize to fabledcurator-<version>.xpi so the FC server's
|
|
# whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi')
|
|
# keeps working.
|
|
SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1)
|
|
XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi"
|
|
cp "$SIGNED" "$XPI"
|
|
# Find-or-create the ext-<version> release. Track whether WE
|
|
# created it so an upload failure below can roll back (don't
|
|
# leave an empty release tombstone that the next run's
|
|
# cache-check mistakes for a partial-failure state).
|
|
#
|
|
# target_commitish is the signing commit, not a branch name: since
|
|
# step 6 either branch can create this release, and hard-coding
|
|
# `main` would tag a dev-signed XPI against a main commit that may
|
|
# not even contain the extension source it was built from.
|
|
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
|
|
CREATED_BY_US=false
|
|
else
|
|
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
-d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"$GITHUB_SHA\"}" \
|
|
-o release.json \
|
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases"
|
|
CREATED_BY_US=true
|
|
fi
|
|
RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json'))['id'])")
|
|
test -n "$RELEASE_ID"
|
|
# Rollback-on-failure: if the asset upload fails AND we just
|
|
# created the release in this run, delete it. Prevents an empty
|
|
# ext-<version> release from poisoning the next workflow run
|
|
# (operator-flagged 2026-05-26 — without rollback the next run
|
|
# saw 'release exists, no asset → cache miss → sign' which AMO
|
|
# then rejected with 409 'Version already exists').
|
|
rollback_if_we_created() {
|
|
if [ "$CREATED_BY_US" = "true" ]; then
|
|
echo "Rolling back: deleting just-created release $RELEASE_ID"
|
|
curl -s -X DELETE -H "Authorization: token $TOKEN" \
|
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID" || true
|
|
curl -s -X DELETE -H "Authorization: token $TOKEN" \
|
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/tags/ext-$VERSION" || true
|
|
fi
|
|
}
|
|
trap 'rollback_if_we_created' EXIT
|
|
HTTP_CODE=$(curl -s -X POST -H "Authorization: token $TOKEN" \
|
|
-F "attachment=@$XPI" \
|
|
-o /dev/null -w "%{http_code}" \
|
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi")
|
|
if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ]; then
|
|
echo "Asset upload failed with HTTP $HTTP_CODE"
|
|
exit 1
|
|
fi
|
|
# Upload succeeded — clear the rollback trap.
|
|
trap - EXIT
|
|
echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release"
|
|
|
|
# No actions/upload-artifact step: Forgejo Actions (and our
|
|
# act_runner) doesn't support upload-artifact@v4+ (GHES limitation
|
|
# surfaced 2026-05-26). Instead build-web reads the signed XPI
|
|
# straight from the ext-<version> Forgejo release we just uploaded
|
|
# 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]
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Full history: this job RE-DERIVES the extension version rather than
|
|
# being handed it, and a depth-1 clone derives a wrong, too-low value
|
|
# rather than failing — which would 404 the download of a release
|
|
# that exists perfectly well under its real name.
|
|
fetch-depth: 0
|
|
|
|
# --- derived values, one line (milestone 313) ------------------------
|
|
# These stopped being shadow output at step 3: `tag` is published on
|
|
# main and `revision` decides whether the build below runs at all. This
|
|
# step prints all three anyway, because the load-bearing steps each
|
|
# print only the one they use, and on dev the date tag is computed
|
|
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
|
# the line that says what the commit derived.
|
|
#
|
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
|
# never be the reason an image does not ship.
|
|
#
|
|
# What it should say:
|
|
# * a push touching only agent/ moves the agent and leaves web and ml
|
|
# STILL. If web moves, its path set is too wide.
|
|
# * a push touching only docs moves nothing.
|
|
# * a push touching the extension moves the extension AND web, since
|
|
# web bakes in the XPI. If web does not move, its set is too narrow
|
|
# — the direction that serves stale bytes on a pin.
|
|
# * dev and main derive the same values for the same source.
|
|
- name: Report the derived artifact version
|
|
run: |
|
|
set -u
|
|
A=web
|
|
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
V=$(sh scripts/artifacts.sh version "$A" 2>&1 || echo UNAVAILABLE)
|
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
|
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
|
|
|
- name: Determine tag
|
|
id: tag
|
|
run: |
|
|
# Two trigger shapes:
|
|
# refs/heads/main → push to main: publish :main + :latest
|
|
# (floating) AND :c-<short_sha> (immutable
|
|
# per-commit rollback substrate, per family
|
|
# release-posture rule "Tags are milestones,
|
|
# not gates — commit-SHA images are the
|
|
# rollback unit"). Rollback to any commit
|
|
# becomes `docker pull …:c-<sha>` without a
|
|
# release ceremony.
|
|
# refs/heads/dev → push to dev: publish :dev, the rolling test
|
|
# channel (family rule 146). Rolling means it may
|
|
# carry newer contents than the :c-<sha> of the
|
|
# same commit; it never writes :c-<sha> itself,
|
|
# because that is the rollback unit (rule 145).
|
|
# 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>
|
|
# main-push build failed at this step.
|
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
|
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
|
# THIS artifact's shipped files last changed in. Day precision is
|
|
# deliberate — same-day work is not something worth pinning, so a
|
|
# second main build the same day replaces the first rather than
|
|
# accumulating a tag nobody would roll back to.
|
|
#
|
|
# Derived per artifact, so an image whose files did not change keeps
|
|
# 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.
|
|
# `channel` is baked into the image as FC_CHANNEL and reported by
|
|
# /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
|
|
# empty and publish the tag `fabledcurator:` — an invalid
|
|
# name, from a green step. An empty pin must never reach the
|
|
# registry.
|
|
if [ -z "$CALVER" ]; then
|
|
echo "ERROR: could not derive a web version tag" >&2
|
|
exit 1
|
|
fi
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA},git.fabledsword.com/bvandeusen/fabledcurator:${CALVER}" >> "$GITHUB_OUTPUT"
|
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
|
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# A shell step, not docker/login-action@v3, because the action's shared
|
|
# cache races itself (#3118). act_runner caches a remote action under one
|
|
# /root/.cache/act/<hash> per runner, and build-web, build-ml and
|
|
# build-agent all start in the same second and all want this same action.
|
|
# One job re-clones the directory — which empties and repopulates it —
|
|
# while another is walking it to copy into its container, and the walker
|
|
# lstat()s a file that has just vanished. It failed twice on 2026-08-27,
|
|
# naming a DIFFERENT missing file each time (`eslint.config.mjs`, then
|
|
# `jest.config.ts`), which is what rules out a corrupt cache and points at
|
|
# a race. The loser dies with MODULE_NOT_FOUND on dist/index.js before the
|
|
# action runs at all, so the secret is never even reached.
|
|
#
|
|
# Nothing is lost by dropping it: logging in is one command, the docker
|
|
# CLI is already in the CI image (ci-requirements.md), and the same
|
|
# reasoning as family rule 5 applies — a marketplace action buys nothing
|
|
# when the tool is baked into the image the workflow already selected.
|
|
#
|
|
# Password on stdin, never as an argument: an argument lands in the
|
|
# process table and draws docker's own deprecation warning.
|
|
- name: Login to Forgejo registry
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
ACTOR: ${{ github.actor }}
|
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
|
|
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
|
# Does the image the channel tag already points at carry THIS commit's
|
|
# revision? If so the bytes this job would produce are already published
|
|
# and the build is pure waste: the remaining tags get repointed at that
|
|
# existing manifest instead, registry-side, in seconds.
|
|
#
|
|
# Keyed on an `fc.revision` LABEL rather than on a tag of its own
|
|
# (milestone 318 step 3). A tag would be a name minted per build that one
|
|
# thing reads — what rule 145 narrowed against — and would be prunable
|
|
# under the registry's keep_pattern (#3157), silently expiring the cache.
|
|
# A label rides inside a tag that has to exist anyway.
|
|
#
|
|
# An image with no such label reads as a miss and rebuilds. That is the
|
|
# migration, not a fault: labels cannot be backfilled, since the reuse
|
|
# path copies a manifest and config labels are not manifest annotations.
|
|
# Each artifact pays one rebuild, once.
|
|
#
|
|
# This is what stops a push that touched only `agent/` from rebuilding
|
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
|
#
|
|
# The failure direction is deliberate. An inspect that errors for ANY
|
|
# reason — network, auth, a registry hiccup — reads as a miss and the
|
|
# build runs. Only a genuine 200 skips one, so there is no path here
|
|
# that skips a build that was actually needed; the worst case is paying
|
|
# for a build we could have avoided.
|
|
#
|
|
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
|
# whose source stops moving stops picking up base-image updates under
|
|
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
|
# serving the bytes it served (fabledcurator:2026.7.17 still
|
|
# resolves to July's image), or it is not a pin — and family rule
|
|
# 145 already says where the refresh goes instead: a rebuild with
|
|
# different contents publishes only the MOVING tag, never the immutable
|
|
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
# it does not belong in the push path.
|
|
- name: Is this content already published?
|
|
id: reuse
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -eu
|
|
DERIVED=$(sh scripts/artifacts.sh revision web)
|
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
|
|
|
# The moving tag for this channel. Which tag we ask IS the channel —
|
|
# that is why the revision needs no -main/-dev qualifier any more.
|
|
if [ "$CHANNEL" = "main" ]; then T=latest; else T=dev; fi
|
|
echo "channel_ref=$IMAGE:$T" >> "$GITHUB_OUTPUT"
|
|
|
|
# Compare VALUES, never exit codes. Measured on buildx v0.36.1
|
|
# (run 4732): a missing key returns an empty string and exits 0, so
|
|
# branching on the exit code would read "no label yet" as success.
|
|
# An unreachable tag also lands here as empty via the `|| echo`.
|
|
# Empty never equals a 12-char revision, so every uncertain case
|
|
# falls through to a build — the safe direction, with no special
|
|
# casing for it.
|
|
#
|
|
# Read the SPECIFIC key. The map also carries whatever the base image
|
|
# set, and `org.opencontainers.image.version` sits right beside ours
|
|
# looking like a plausible answer (it reads 24.04 on the agent).
|
|
PUBLISHED=$(docker buildx imagetools inspect "$IMAGE:$T" \
|
|
--format '{{ index .Image.Config.Labels "fc.revision" }}' \
|
|
2>/dev/null || echo "")
|
|
echo "reuse: $IMAGE:$T carries fc.revision=${PUBLISHED:-<none>}; derived=$DERIVED"
|
|
|
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: already published — skipping the build"
|
|
else
|
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: not published — building"
|
|
fi
|
|
|
|
- name: Download signed XPI from Forgejo release asset
|
|
# 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.
|
|
#
|
|
# 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: |
|
|
set -eux
|
|
# Re-derived, not read from the repo: sign-extension published
|
|
# ext-<derived>, and the committed version has been inert since
|
|
# milestone 271 step 4. Both jobs run `packaging.sh version` over the
|
|
# same commit, so they agree by construction — and if they ever
|
|
# didn't, this download 404s and the build fails loudly instead of
|
|
# shipping a stale XPI.
|
|
VERSION=$(sh extension/scripts/packaging.sh version)
|
|
# 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
|
|
# code curl'd the metadata endpoint without -f and wrote the
|
|
# resulting 404-page-not-found text into fabledcurator-*.xpi,
|
|
# which Firefox then rejected as "corrupt").
|
|
# browser_download_url is the canonical binary endpoint and
|
|
# is also publicly accessible (no token needed) but we pass
|
|
# the token anyway for symmetry with private-repo support.
|
|
DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])")
|
|
test -n "$DOWNLOAD_URL"
|
|
echo "Downloading XPI from: $DOWNLOAD_URL"
|
|
mkdir -p frontend/public/extension
|
|
DEST="frontend/public/extension/fabledcurator-$VERSION.xpi"
|
|
# -f = fail on HTTP error (prevents silent corruption like the
|
|
# 2026-05-26 incident); -L = follow redirects.
|
|
curl -sfL -H "Authorization: token $TOKEN" -o "$DEST" "$DOWNLOAD_URL"
|
|
# Sanity check: the binary should start with the ZIP magic (PK\x03\x04).
|
|
# If it's anything else, the next docker build will ship a corrupt XPI.
|
|
MAGIC=$(head -c 2 "$DEST" | od -An -c | tr -d ' \n')
|
|
if [ "$MAGIC" != "PK" ]; then
|
|
echo "ERROR: downloaded XPI does not start with ZIP magic 'PK' (got '$MAGIC')"
|
|
echo "File contents preview:"
|
|
head -c 200 "$DEST"
|
|
exit 1
|
|
fi
|
|
cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
|
|
ls -la frontend/public/extension/
|
|
|
|
- name: Build and push web image
|
|
if: steps.reuse.outputs.hit != 'true'
|
|
uses: docker/build-push-action@v5
|
|
with:
|
|
context: .
|
|
file: Dockerfile
|
|
push: true
|
|
tags: ${{ steps.reuse.outputs.build_tags }}
|
|
# The reuse key. Read back off the channel tag on the next push to
|
|
# decide whether that push needs to build at all, so this is not
|
|
# decoration — an unstamped image is one that will always rebuild.
|
|
labels: |
|
|
fc.revision=${{ steps.reuse.outputs.revision }}
|
|
# Only the web image carries a channel: it is the one that serves
|
|
# /api/extension/manifest. The ml and agent images have nothing to
|
|
# report it to.
|
|
build-args: |
|
|
FC_CHANNEL=${{ steps.tag.outputs.channel }}
|
|
|
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
|
# channel tag already holds, so :c-<sha> and the date pin are
|
|
# byte-identical to what is published rather than a lookalike rebuild.
|
|
#
|
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
|
# rolling channel refreshes itself, so skipping a build must never mean
|
|
# leaving :dev or :latest pointing at something older than the commit
|
|
# that was just pushed.
|
|
- name: Repoint the tags at the published image (reuse)
|
|
if: steps.reuse.outputs.hit == 'true'
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -euf
|
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
|
ARGS=""
|
|
IFS=,
|
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
|
unset IFS
|
|
# Source is the channel tag itself — the image we just confirmed
|
|
# carries this revision. One of the targets is that same tag, which
|
|
# makes its copy a no-op; the others (:main, :c-<sha>, the date pin)
|
|
# are what this exists for.
|
|
# shellcheck disable=SC2086
|
|
docker buildx imagetools create $ARGS "$SOURCE"
|
|
echo "repointed to $SOURCE: $TAGS"
|
|
|
|
build-ml:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Full history: this job derives its artifact's version from the
|
|
# commit its shipped files last changed in (milestone 313). A
|
|
# depth-1 clone cannot see that commit — it either derives a wrong,
|
|
# too-low value or finds nothing at all, and neither is a failure
|
|
# the build would otherwise notice.
|
|
fetch-depth: 0
|
|
|
|
# --- derived values, one line (milestone 313) ------------------------
|
|
# These stopped being shadow output at step 3: `tag` is published on
|
|
# main and `revision` decides whether the build below runs at all. This
|
|
# step prints all three anyway, because the load-bearing steps each
|
|
# print only the one they use, and on dev the date tag is computed
|
|
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
|
# the line that says what the commit derived.
|
|
#
|
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
|
# never be the reason an image does not ship.
|
|
#
|
|
# What it should say:
|
|
# * a push touching only agent/ moves the agent and leaves web and ml
|
|
# STILL. If web moves, its path set is too wide.
|
|
# * a push touching only docs moves nothing.
|
|
# * a push touching the extension moves the extension AND web, since
|
|
# web bakes in the XPI. If web does not move, its set is too narrow
|
|
# — the direction that serves stale bytes on a pin.
|
|
# * dev and main derive the same values for the same source.
|
|
- name: Report the derived artifact version
|
|
run: |
|
|
set -u
|
|
A=ml
|
|
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
V=$(sh scripts/artifacts.sh version "$A" 2>&1 || echo UNAVAILABLE)
|
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
|
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
|
|
|
- name: Determine tag
|
|
id: tag
|
|
run: |
|
|
# 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>
|
|
# main-push build failed at this step.
|
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
|
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
|
# THIS artifact's shipped files last changed in. Day precision is
|
|
# deliberate — same-day work is not something worth pinning, so a
|
|
# second main build the same day replaces the first rather than
|
|
# accumulating a tag nobody would roll back to.
|
|
#
|
|
# Derived per artifact, so an image whose files did not change keeps
|
|
# 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##*/}" = "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
|
|
# empty and publish the tag `fabledcurator-ml:` — an invalid
|
|
# name, from a green step. An empty pin must never reach the
|
|
# registry.
|
|
if [ -z "$CALVER" ]; then
|
|
echo "ERROR: could not derive a ml version tag" >&2
|
|
exit 1
|
|
fi
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA},git.fabledsword.com/bvandeusen/fabledcurator-ml:${CALVER}" >> "$GITHUB_OUTPUT"
|
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
|
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Shell step rather than docker/login-action — see build-web's note on
|
|
# the shared action-cache race (#3118).
|
|
- name: Login to Forgejo registry
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
ACTOR: ${{ github.actor }}
|
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
|
|
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
|
# Does the image the channel tag already points at carry THIS commit's
|
|
# revision? If so the bytes this job would produce are already published
|
|
# and the build is pure waste: the remaining tags get repointed at that
|
|
# existing manifest instead, registry-side, in seconds.
|
|
#
|
|
# Keyed on an `fc.revision` LABEL rather than on a tag of its own
|
|
# (milestone 318 step 3). A tag would be a name minted per build that one
|
|
# thing reads — what rule 145 narrowed against — and would be prunable
|
|
# under the registry's keep_pattern (#3157), silently expiring the cache.
|
|
# A label rides inside a tag that has to exist anyway.
|
|
#
|
|
# An image with no such label reads as a miss and rebuilds. That is the
|
|
# migration, not a fault: labels cannot be backfilled, since the reuse
|
|
# path copies a manifest and config labels are not manifest annotations.
|
|
# Each artifact pays one rebuild, once.
|
|
#
|
|
# This is what stops a push that touched only `agent/` from rebuilding
|
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
|
#
|
|
# The failure direction is deliberate. An inspect that errors for ANY
|
|
# reason — network, auth, a registry hiccup — reads as a miss and the
|
|
# build runs. Only a genuine 200 skips one, so there is no path here
|
|
# that skips a build that was actually needed; the worst case is paying
|
|
# for a build we could have avoided.
|
|
#
|
|
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
|
# whose source stops moving stops picking up base-image updates under
|
|
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
|
# serving the bytes it served (fabledcurator-ml:2026.7.17 still
|
|
# resolves to July's image), or it is not a pin — and family rule
|
|
# 145 already says where the refresh goes instead: a rebuild with
|
|
# different contents publishes only the MOVING tag, never the immutable
|
|
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
# it does not belong in the push path.
|
|
- name: Is this content already published?
|
|
id: reuse
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -eu
|
|
DERIVED=$(sh scripts/artifacts.sh revision ml)
|
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
|
|
|
# The moving tag for this channel. Which tag we ask IS the channel —
|
|
# that is why the revision needs no -main/-dev qualifier any more.
|
|
if [ "$CHANNEL" = "main" ]; then T=latest; else T=dev; fi
|
|
echo "channel_ref=$IMAGE:$T" >> "$GITHUB_OUTPUT"
|
|
|
|
# Compare VALUES, never exit codes. Measured on buildx v0.36.1
|
|
# (run 4732): a missing key returns an empty string and exits 0, so
|
|
# branching on the exit code would read "no label yet" as success.
|
|
# An unreachable tag also lands here as empty via the `|| echo`.
|
|
# Empty never equals a 12-char revision, so every uncertain case
|
|
# falls through to a build — the safe direction, with no special
|
|
# casing for it.
|
|
#
|
|
# Read the SPECIFIC key. The map also carries whatever the base image
|
|
# set, and `org.opencontainers.image.version` sits right beside ours
|
|
# looking like a plausible answer (it reads 24.04 on the agent).
|
|
PUBLISHED=$(docker buildx imagetools inspect "$IMAGE:$T" \
|
|
--format '{{ index .Image.Config.Labels "fc.revision" }}' \
|
|
2>/dev/null || echo "")
|
|
echo "reuse: $IMAGE:$T carries fc.revision=${PUBLISHED:-<none>}; derived=$DERIVED"
|
|
|
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: already published — skipping the build"
|
|
else
|
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: not published — building"
|
|
fi
|
|
|
|
- name: Build and push ml image
|
|
if: steps.reuse.outputs.hit != 'true'
|
|
uses: docker/build-push-action@v5
|
|
with:
|
|
context: .
|
|
file: Dockerfile.ml
|
|
push: true
|
|
tags: ${{ steps.reuse.outputs.build_tags }}
|
|
# The reuse key. Read back off the channel tag on the next push to
|
|
# decide whether that push needs to build at all, so this is not
|
|
# decoration — an unstamped image is one that will always rebuild.
|
|
labels: |
|
|
fc.revision=${{ steps.reuse.outputs.revision }}
|
|
|
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
|
# channel tag already holds, so :c-<sha> and the date pin are
|
|
# byte-identical to what is published rather than a lookalike rebuild.
|
|
#
|
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
|
# rolling channel refreshes itself, so skipping a build must never mean
|
|
# leaving :dev or :latest pointing at something older than the commit
|
|
# that was just pushed.
|
|
- name: Repoint the tags at the published image (reuse)
|
|
if: steps.reuse.outputs.hit == 'true'
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -euf
|
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
|
ARGS=""
|
|
IFS=,
|
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
|
unset IFS
|
|
# Source is the channel tag itself — the image we just confirmed
|
|
# carries this revision. One of the targets is that same tag, which
|
|
# makes its copy a no-op; the others (:main, :c-<sha>, the date pin)
|
|
# are what this exists for.
|
|
# shellcheck disable=SC2086
|
|
docker buildx imagetools create $ARGS "$SOURCE"
|
|
echo "repointed to $SOURCE: $TAGS"
|
|
|
|
# The desktop GPU agent (#114) — published so the operator pulls + runs it on
|
|
# the GPU machine instead of building locally. Independent of web/ml (its own
|
|
# CUDA + onnxruntime-gpu image, context = agent/). Same tag cadence.
|
|
build-agent:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Full history: this job derives its artifact's version from the
|
|
# commit its shipped files last changed in (milestone 313). A
|
|
# depth-1 clone cannot see that commit — it either derives a wrong,
|
|
# too-low value or finds nothing at all, and neither is a failure
|
|
# the build would otherwise notice.
|
|
fetch-depth: 0
|
|
|
|
# --- derived values, one line (milestone 313) ------------------------
|
|
# These stopped being shadow output at step 3: `tag` is published on
|
|
# main and `revision` decides whether the build below runs at all. This
|
|
# step prints all three anyway, because the load-bearing steps each
|
|
# print only the one they use, and on dev the date tag is computed
|
|
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
|
# the line that says what the commit derived.
|
|
#
|
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
|
# never be the reason an image does not ship.
|
|
#
|
|
# What it should say:
|
|
# * a push touching only agent/ moves the agent and leaves web and ml
|
|
# STILL. If web moves, its path set is too wide.
|
|
# * a push touching only docs moves nothing.
|
|
# * a push touching the extension moves the extension AND web, since
|
|
# web bakes in the XPI. If web does not move, its set is too narrow
|
|
# — the direction that serves stale bytes on a pin.
|
|
# * dev and main derive the same values for the same source.
|
|
- name: Report the derived artifact version
|
|
run: |
|
|
set -u
|
|
A=agent
|
|
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
V=$(sh scripts/artifacts.sh version "$A" 2>&1 || echo UNAVAILABLE)
|
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
|
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
|
|
|
- name: Determine tag
|
|
id: tag
|
|
run: |
|
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
|
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
|
# THIS artifact's shipped files last changed in. Day precision is
|
|
# deliberate — same-day work is not something worth pinning, so a
|
|
# second main build the same day replaces the first rather than
|
|
# accumulating a tag nobody would roll back to.
|
|
#
|
|
# Derived per artifact, so an image whose files did not change keeps
|
|
# 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##*/}" = "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
|
|
# empty and publish the tag `fabledcurator-agent:` — an invalid
|
|
# name, from a green step. An empty pin must never reach the
|
|
# registry.
|
|
if [ -z "$CALVER" ]; then
|
|
echo "ERROR: could not derive a agent version tag" >&2
|
|
exit 1
|
|
fi
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:main,git.fabledsword.com/bvandeusen/fabledcurator-agent:latest,git.fabledsword.com/bvandeusen/fabledcurator-agent:c-${SHORT_SHA},git.fabledsword.com/bvandeusen/fabledcurator-agent:${CALVER}" >> "$GITHUB_OUTPUT"
|
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:dev" >> "$GITHUB_OUTPUT"
|
|
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Shell step rather than docker/login-action — see build-web's note on
|
|
# the shared action-cache race (#3118).
|
|
- name: Login to Forgejo registry
|
|
env:
|
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
ACTOR: ${{ github.actor }}
|
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
|
|
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
|
# Does the image the channel tag already points at carry THIS commit's
|
|
# revision? If so the bytes this job would produce are already published
|
|
# and the build is pure waste: the remaining tags get repointed at that
|
|
# existing manifest instead, registry-side, in seconds.
|
|
#
|
|
# Keyed on an `fc.revision` LABEL rather than on a tag of its own
|
|
# (milestone 318 step 3). A tag would be a name minted per build that one
|
|
# thing reads — what rule 145 narrowed against — and would be prunable
|
|
# under the registry's keep_pattern (#3157), silently expiring the cache.
|
|
# A label rides inside a tag that has to exist anyway.
|
|
#
|
|
# An image with no such label reads as a miss and rebuilds. That is the
|
|
# migration, not a fault: labels cannot be backfilled, since the reuse
|
|
# path copies a manifest and config labels are not manifest annotations.
|
|
# Each artifact pays one rebuild, once.
|
|
#
|
|
# This is what stops a push that touched only `agent/` from rebuilding
|
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
|
#
|
|
# The failure direction is deliberate. An inspect that errors for ANY
|
|
# reason — network, auth, a registry hiccup — reads as a miss and the
|
|
# build runs. Only a genuine 200 skips one, so there is no path here
|
|
# that skips a build that was actually needed; the worst case is paying
|
|
# for a build we could have avoided.
|
|
#
|
|
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
|
# whose source stops moving stops picking up base-image updates under
|
|
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
|
# serving the bytes it served (fabledcurator-agent:2026.7.17 still
|
|
# resolves to July's image), or it is not a pin — and family rule
|
|
# 145 already says where the refresh goes instead: a rebuild with
|
|
# different contents publishes only the MOVING tag, never the immutable
|
|
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
# it does not belong in the push path.
|
|
- name: Is this content already published?
|
|
id: reuse
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -eu
|
|
DERIVED=$(sh scripts/artifacts.sh revision agent)
|
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
|
|
|
# The moving tag for this channel. Which tag we ask IS the channel —
|
|
# that is why the revision needs no -main/-dev qualifier any more.
|
|
if [ "$CHANNEL" = "main" ]; then T=latest; else T=dev; fi
|
|
echo "channel_ref=$IMAGE:$T" >> "$GITHUB_OUTPUT"
|
|
|
|
# Compare VALUES, never exit codes. Measured on buildx v0.36.1
|
|
# (run 4732): a missing key returns an empty string and exits 0, so
|
|
# branching on the exit code would read "no label yet" as success.
|
|
# An unreachable tag also lands here as empty via the `|| echo`.
|
|
# Empty never equals a 12-char revision, so every uncertain case
|
|
# falls through to a build — the safe direction, with no special
|
|
# casing for it.
|
|
#
|
|
# Read the SPECIFIC key. The map also carries whatever the base image
|
|
# set, and `org.opencontainers.image.version` sits right beside ours
|
|
# looking like a plausible answer (it reads 24.04 on the agent).
|
|
PUBLISHED=$(docker buildx imagetools inspect "$IMAGE:$T" \
|
|
--format '{{ index .Image.Config.Labels "fc.revision" }}' \
|
|
2>/dev/null || echo "")
|
|
echo "reuse: $IMAGE:$T carries fc.revision=${PUBLISHED:-<none>}; derived=$DERIVED"
|
|
|
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: already published — skipping the build"
|
|
else
|
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
|
echo "reuse: not published — building"
|
|
fi
|
|
|
|
- name: Build and push agent image
|
|
if: steps.reuse.outputs.hit != 'true'
|
|
uses: docker/build-push-action@v5
|
|
with:
|
|
context: agent
|
|
file: agent/Dockerfile
|
|
push: true
|
|
tags: ${{ steps.reuse.outputs.build_tags }}
|
|
# The reuse key. Read back off the channel tag on the next push to
|
|
# decide whether that push needs to build at all, so this is not
|
|
# decoration — an unstamped image is one that will always rebuild.
|
|
labels: |
|
|
fc.revision=${{ steps.reuse.outputs.revision }}
|
|
|
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
|
# channel tag already holds, so :c-<sha> and the date pin are
|
|
# byte-identical to what is published rather than a lookalike rebuild.
|
|
#
|
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
|
# rolling channel refreshes itself, so skipping a build must never mean
|
|
# leaving :dev or :latest pointing at something older than the commit
|
|
# that was just pushed.
|
|
- name: Repoint the tags at the published image (reuse)
|
|
if: steps.reuse.outputs.hit == 'true'
|
|
env:
|
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
|
TAGS: ${{ steps.tag.outputs.tags }}
|
|
run: |
|
|
set -euf
|
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
|
ARGS=""
|
|
IFS=,
|
|
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
|
unset IFS
|
|
# Source is the channel tag itself — the image we just confirmed
|
|
# carries this revision. One of the targets is that same tag, which
|
|
# makes its copy a no-op; the others (:main, :c-<sha>, the date pin)
|
|
# are what this exists for.
|
|
# shellcheck disable=SC2086
|
|
docker buildx imagetools create $ARGS "$SOURCE"
|
|
echo "repointed to $SOURCE: $TAGS"
|