Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d98dfc0ec | ||
|
|
b3989d0224 | ||
|
|
cd0b0ff04a | ||
|
|
454eb3f973 | ||
|
|
7e065fed70 | ||
|
|
dee93faa37 | ||
|
|
d9aa5aa832 | ||
|
|
6c76f08b69 | ||
|
|
fb2c4d5b80 | ||
|
|
609bc82acc | ||
|
|
7a20c55441 | ||
|
|
0c43fa3eb2 | ||
|
|
cf06c81db9 | ||
|
|
7b1019ba82 | ||
|
|
0db38cc111 | ||
|
|
0d204e6837 | ||
|
|
8300029741 | ||
|
|
b5b437ca80 | ||
|
|
ce0dac3524 | ||
|
|
9b5ec86222 | ||
|
|
89c83ee5de | ||
|
|
d3192f1843 | ||
|
|
5a5694f200 | ||
|
|
bb1a938cc0 | ||
|
|
fc0293029d | ||
|
|
7d1c701b67 | ||
|
|
b638382cd5 | ||
|
|
17903068b4 | ||
|
|
31d400ab0a | ||
|
|
d45ce5e426 | ||
|
|
77bfc32b02 | ||
|
|
d13efe1b69 | ||
|
|
ca6d26d236 | ||
|
|
5366047d55 | ||
|
|
2923257529 | ||
|
|
934731e9ef | ||
|
|
4c67c116b2 | ||
|
|
f8b667604f | ||
|
|
11572f469a | ||
|
|
ba3fd2a118 | ||
|
|
9bf095179a | ||
|
|
cefc064e0f | ||
|
|
06757daf80 | ||
|
|
a723ef436b | ||
|
|
d68f39ca50 | ||
|
|
a66a695977 | ||
|
|
5289fa3879 | ||
|
|
ebac34e17b | ||
|
|
4c3ba20198 | ||
|
|
d2acec61ae | ||
|
|
15ae5ef4fa | ||
|
|
e3ceefc820 | ||
|
|
e52090a4cf | ||
|
|
bfba8045e4 | ||
|
|
a708357436 | ||
|
|
d9354ac1e1 | ||
|
|
1d48770793 | ||
|
|
489e6aaaee | ||
|
|
ed20df905b | ||
|
|
5ba9871ef0 | ||
|
|
2a820d0848 | ||
|
|
2f9aa3d86c | ||
|
|
560a5000a2 | ||
|
|
7ddad231f8 | ||
|
|
a78f7eaace | ||
|
|
71337b0ba4 | ||
|
|
3996205f3b | ||
|
|
9f9db01456 | ||
|
|
216b7fc743 | ||
|
|
fbb76e6f36 | ||
|
|
5ef1478ade | ||
|
|
88ff4147e1 | ||
|
|
1ea02ad44c | ||
|
|
937cfb65b4 | ||
|
|
baac851220 | ||
|
|
a28c33281a | ||
|
|
40be0a9323 | ||
|
|
2c6bf26bfc | ||
|
|
3c1e76bd44 | ||
|
|
831c5b1c10 | ||
|
|
76b6af4903 | ||
|
|
92494ec4ed | ||
|
|
bdfc17477c | ||
|
|
6cd3153bf4 | ||
|
|
5f2853168a | ||
|
|
9a979ee808 | ||
|
|
3138f912fd | ||
|
|
9df874e396 | ||
|
|
6915a7590a | ||
|
|
5e8c28236a | ||
|
|
7e3c0f0b74 | ||
|
|
5d0c7ba706 | ||
|
|
18300e1f8a | ||
|
|
d52ac0a0e2 | ||
|
|
401fe8213e | ||
|
|
e8774d7953 | ||
|
|
bc0f00c51b | ||
|
|
1bef68aa29 | ||
|
|
1a4bc2f981 | ||
|
|
862ace69d6 | ||
|
|
abf88b1a15 | ||
|
|
c05dcafbea | ||
|
|
55e8632dab | ||
|
|
825e6b90bf | ||
|
|
fb012c557c | ||
|
|
66593ab895 | ||
|
|
b266a54ad3 | ||
|
|
ad803b646f | ||
|
|
1f5da3d283 | ||
|
|
93034f580d | ||
|
|
9b9b12f410 | ||
|
|
376d310693 | ||
|
|
bc69495a16 | ||
|
|
478f898e72 | ||
|
|
38a5e7f332 | ||
|
|
57fe15c267 | ||
|
|
eb3231ef10 | ||
|
|
e9af459c0d | ||
|
|
6f02806aec | ||
|
|
a1d19bd96a | ||
|
|
26827ff38f | ||
|
|
26dcfaf6c2 | ||
|
|
9b1b0369cc | ||
|
|
18123fb9cb | ||
|
|
2e806f202f | ||
|
|
18d5c05639 | ||
|
|
11ddfc3876 | ||
|
|
2b8ce86622 | ||
|
|
49bee77cdc | ||
|
|
c209e3b37e | ||
|
|
cffdd93418 | ||
|
|
fd84be40dd | ||
|
|
79f510d7f8 | ||
|
|
59181069da | ||
|
|
428ecd8642 | ||
|
|
ed1e04b831 | ||
|
|
f5156bd847 | ||
|
|
dfc3922d24 | ||
|
|
3eb08e926b | ||
|
|
9e81ced359 | ||
|
|
11e9f5af60 | ||
|
|
909fa37b15 | ||
|
|
dfab8f65ff | ||
|
|
618f7cdc36 | ||
|
|
028ea33a7c | ||
|
|
444c1fb075 | ||
|
|
26c68b0a75 | ||
|
|
e75427b19a | ||
|
|
5447fab987 | ||
|
|
bad37e07b2 | ||
|
|
2bfc9936a1 | ||
|
|
4c6406ee18 | ||
|
|
bb47e80b3e | ||
|
|
dc1083b5e0 | ||
|
|
e46893fefd | ||
|
|
0666e15211 | ||
|
|
747390631d | ||
|
|
e0d2a20588 | ||
|
|
1d84f67418 | ||
|
|
91265df3d6 | ||
|
|
11acdb0322 | ||
|
|
2eb9fd5dd0 | ||
|
|
01e5ce1410 | ||
|
|
3bb94674cf | ||
|
|
a75c602175 | ||
|
|
ef8f4f7193 | ||
|
|
ec3d27b219 | ||
|
|
03bd3b2eda | ||
|
|
7395e77d75 | ||
|
|
575d817919 | ||
|
|
2a8f7cd8b6 | ||
|
|
83f8af8090 | ||
|
|
9a2617c1a2 | ||
|
|
81688815a0 | ||
|
|
773128c3bf | ||
|
|
ce7b154ae9 | ||
|
|
9430a9d9c3 | ||
|
|
23aee56ce3 | ||
|
|
711abea567 | ||
|
|
844bb86802 | ||
|
|
a8f6a464aa | ||
|
|
ab9922ad2e | ||
|
|
0533807669 | ||
|
|
279dff3fb6 | ||
|
|
37e66cddc4 | ||
|
|
9cf6b2d363 | ||
|
|
6ef0fed41f | ||
|
|
89b48f8f35 | ||
|
|
d60e0b9494 | ||
|
|
9c27a2d3c7 | ||
|
|
93e37681b7 | ||
|
|
64ca858574 | ||
|
|
9d0c0b7da8 | ||
|
|
8e4d252ae4 | ||
|
|
fdd3e01f56 | ||
|
|
c82fb308b6 | ||
|
|
8cf8d2ca4d | ||
|
|
b1d58bc3b8 | ||
|
|
65386f02a0 | ||
|
|
667b05f14e | ||
|
|
856e9104b4 | ||
|
|
0397642b21 | ||
|
|
237575447d | ||
|
|
ed358757dc | ||
|
|
d181f4afb8 | ||
|
|
2886fa4997 | ||
|
|
f256f587ee | ||
|
|
384d8d5e50 | ||
|
|
319e8c1d18 | ||
|
|
9075d8eadd | ||
|
|
88e53e5b86 | ||
|
|
37e8b796a1 | ||
|
|
4e82208926 | ||
|
|
52fff00353 | ||
|
|
c14338cbce | ||
|
|
8c36dd28b0 | ||
|
|
88cfb3dd02 | ||
|
|
5d4f223b71 | ||
|
|
05090c6e85 | ||
|
|
3a577d5ade | ||
|
|
f4fe02e346 | ||
|
|
e766197d99 | ||
|
|
3872e1dda9 | ||
|
|
9814f3dbaf | ||
|
|
b214460fdb | ||
|
|
ac55d0e8d8 | ||
|
|
89a89e0ded | ||
|
|
4e9aac2c05 | ||
|
|
2879ac6f2b | ||
|
|
b8dce6c483 | ||
|
|
d1c0b82a22 | ||
|
|
5526b8dc78 | ||
|
|
16eb7075c4 | ||
|
|
885dcf64f3 | ||
|
|
f2f6b6d25e | ||
|
|
0822240fde | ||
|
|
27f7f3fd01 | ||
|
|
c5bf564f53 | ||
|
|
602c7d275d |
+697
-129
@@ -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
|
||||||
@@ -103,6 +107,26 @@ jobs:
|
|||||||
# cache hit and holds AMO to one call per extension CHANGE. Only moving
|
# 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
|
# backwards is a failure, so this runs on every path — cache hit
|
||||||
# included — rather than only before a sign.
|
# 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
|
||||||
|
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 version=$V revision=$R sha=$GITHUB_SHA"
|
||||||
|
|
||||||
- name: Guard — the derived version must never go backwards
|
- name: Guard — the derived version must never go backwards
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
@@ -302,12 +326,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
|
||||||
@@ -320,26 +344,205 @@ jobs:
|
|||||||
# that exists perfectly well under its real name.
|
# that exists perfectly well under its real name.
|
||||||
fetch-depth: 0
|
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
|
||||||
|
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 version=$V revision=$R sha=$GITHUB_SHA"
|
||||||
|
|
||||||
|
- name: Determine tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
# Two trigger shapes, and between them they publish three tags:
|
||||||
|
# main → :latest (production, moving — rule 147: main IS production)
|
||||||
|
# :c-<sha> (immutable, the rollback unit — rule 145)
|
||||||
|
# dev → :dev (the rolling test channel — rule 146)
|
||||||
|
#
|
||||||
|
# That is the whole list. No :<version>, and no :main — rule 145,
|
||||||
|
# narrowed 2026-08-28 once it was verified that nothing pins:
|
||||||
|
# "a third name for the same thing is upkeep for a model we do not
|
||||||
|
# run." The date tag published between milestone 313 step 3 and
|
||||||
|
# milestone 318 was exactly that; :main was a second moving name for
|
||||||
|
# whatever :latest already pointed at.
|
||||||
|
#
|
||||||
|
# `dev` gets no :c-<sha> deliberately. On a channel whose entire
|
||||||
|
# contract is that it moves, a per-push immutable tag is a rollback
|
||||||
|
# target nobody has ever pulled, accumulating forever. The accepted
|
||||||
|
# cost: on dev there is no rollback but the previous :dev, which is
|
||||||
|
# gone — recovery is revert-on-git plus a CI cycle.
|
||||||
|
#
|
||||||
|
# Reinstating :<version> is a real decision, not a default. It earns
|
||||||
|
# its place when something genuinely pins: a second instance held on
|
||||||
|
# a known-good build, or a deliberately frozen window. Tag at the
|
||||||
|
# moment you decide to freeze; no back-catalogue is needed.
|
||||||
|
#
|
||||||
|
# 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 the first :c-<sha>
|
||||||
|
# main-push build failed at this step.
|
||||||
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$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 [ -z "$PUBLISHED" ] && docker buildx imagetools inspect "$IMAGE:$T" >/dev/null 2>&1; then
|
||||||
|
# The tag resolves but carries no readable label. Expected exactly
|
||||||
|
# once per artifact, during the migration onto labels. If it recurs
|
||||||
|
# every push, something is rewriting the channel tag as a manifest
|
||||||
|
# index — see the repoint step's note.
|
||||||
|
echo "reuse: NOTE $IMAGE:$T exists but has no readable fc.revision."
|
||||||
|
echo "reuse: NOTE Fine once, while migrating. Every push means the"
|
||||||
|
echo "reuse: NOTE tag is being index-wrapped and reuse is dead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
- 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
|
||||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')
|
# 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:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -351,25 +554,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
|
||||||
@@ -399,111 +598,299 @@ jobs:
|
|||||||
cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
|
cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
|
||||||
ls -la frontend/public/extension/
|
ls -la frontend/public/extension/
|
||||||
|
|
||||||
- name: Determine tag
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
# Three trigger shapes:
|
|
||||||
# refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
|
|
||||||
# no `.N` per family release-posture rule).
|
|
||||||
# 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
|
|
||||||
# (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)
|
|
||||||
# `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
|
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Login to Forgejo registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.fabledsword.com
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push web image
|
- name: Build and push web image
|
||||||
|
if: steps.reuse.outputs.hit != 'true'
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.tag.outputs.tags }}
|
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
|
# Only the web image carries a channel: it is the one that serves
|
||||||
# /api/extension/manifest. The ml and agent images have nothing to
|
# /api/extension/manifest. The ml and agent images have nothing to
|
||||||
# report it to.
|
# report it to.
|
||||||
build-args: |
|
build-args: |
|
||||||
FC_CHANNEL=${{ steps.tag.outputs.channel }}
|
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
|
||||||
|
# The source tag is EXCLUDED from the targets, and that is load-
|
||||||
|
# bearing rather than an optimisation.
|
||||||
|
#
|
||||||
|
# `imagetools create` wraps the source manifest in an INDEX. Point it
|
||||||
|
# at the channel tag with that same tag as a target and the tag stops
|
||||||
|
# being a plain image — after which `.Image.Config.Labels` no longer
|
||||||
|
# resolves through it and the fc.revision label reads as absent. The
|
||||||
|
# next push then misses and rebuilds, so reuse worked exactly once
|
||||||
|
# and every subsequent push paid full price. Observed on run 4751:
|
||||||
|
# ml:dev reported fc.revision=<none> one push after run 4749 had read
|
||||||
|
# a7e626a67a79 off it. Nothing failed; the savings just evaporated.
|
||||||
|
#
|
||||||
|
# Excluding the source means the channel tag is only ever written by
|
||||||
|
# a real build, so it stays a plain image and stays readable. On dev
|
||||||
|
# that leaves nothing to do — :dev already points at the right
|
||||||
|
# content, which is what the hit established. On main it leaves
|
||||||
|
# :c-<sha>, which rule 145 requires of every main push whether or not
|
||||||
|
# a build ran.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
if [ -z "$ARGS" ]; then
|
||||||
|
echo "repoint: $SOURCE already carries this revision and is the"
|
||||||
|
echo "repoint: only tag for this channel — nothing to write."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
|
echo "repointed from $SOURCE:$ARGS"
|
||||||
|
|
||||||
build-ml:
|
build-ml:
|
||||||
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
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
|
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 version=$V revision=$R sha=$GITHUB_SHA"
|
||||||
|
|
||||||
- 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 tag list; see the comment there.
|
||||||
# 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.
|
|
||||||
# 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>
|
||||||
# main-push build failed at this step.
|
# main-push build failed at this step.
|
||||||
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
||||||
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}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
- name: Login to Forgejo registry
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
registry: git.fabledsword.com
|
ACTOR: ${{ github.actor }}
|
||||||
username: ${{ github.actor }}
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
||||||
password: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
# --- 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 [ -z "$PUBLISHED" ] && docker buildx imagetools inspect "$IMAGE:$T" >/dev/null 2>&1; then
|
||||||
|
# The tag resolves but carries no readable label. Expected exactly
|
||||||
|
# once per artifact, during the migration onto labels. If it recurs
|
||||||
|
# every push, something is rewriting the channel tag as a manifest
|
||||||
|
# index — see the repoint step's note.
|
||||||
|
echo "reuse: NOTE $IMAGE:$T exists but has no readable fc.revision."
|
||||||
|
echo "reuse: NOTE Fine once, while migrating. Every push means the"
|
||||||
|
echo "reuse: NOTE tag is being index-wrapped and reuse is dead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
- name: Build and push ml image
|
||||||
|
if: steps.reuse.outputs.hit != 'true'
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.ml
|
file: Dockerfile.ml
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.tag.outputs.tags }}
|
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
|
||||||
|
# The source tag is EXCLUDED from the targets, and that is load-
|
||||||
|
# bearing rather than an optimisation.
|
||||||
|
#
|
||||||
|
# `imagetools create` wraps the source manifest in an INDEX. Point it
|
||||||
|
# at the channel tag with that same tag as a target and the tag stops
|
||||||
|
# being a plain image — after which `.Image.Config.Labels` no longer
|
||||||
|
# resolves through it and the fc.revision label reads as absent. The
|
||||||
|
# next push then misses and rebuilds, so reuse worked exactly once
|
||||||
|
# and every subsequent push paid full price. Observed on run 4751:
|
||||||
|
# ml:dev reported fc.revision=<none> one push after run 4749 had read
|
||||||
|
# a7e626a67a79 off it. Nothing failed; the savings just evaporated.
|
||||||
|
#
|
||||||
|
# Excluding the source means the channel tag is only ever written by
|
||||||
|
# a real build, so it stays a plain image and stays readable. On dev
|
||||||
|
# that leaves nothing to do — :dev already points at the right
|
||||||
|
# content, which is what the hit established. On main it leaves
|
||||||
|
# :c-<sha>, which rule 145 requires of every main push whether or not
|
||||||
|
# a build ran.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
if [ -z "$ARGS" ]; then
|
||||||
|
echo "repoint: $SOURCE already carries this revision and is the"
|
||||||
|
echo "repoint: only tag for this channel — nothing to write."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
|
echo "repointed from $SOURCE:$ARGS"
|
||||||
|
|
||||||
# The desktop GPU agent (#114) — published so the operator pulls + runs it on
|
# 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
|
# the GPU machine instead of building locally. Independent of web/ml (its own
|
||||||
@@ -514,31 +901,212 @@ jobs:
|
|||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
|
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 version=$V revision=$R sha=$GITHUB_SHA"
|
||||||
|
|
||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
|
||||||
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:latest,git.fabledsword.com/bvandeusen/fabledcurator-agent:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
||||||
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}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "channel=dev" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
- name: Login to Forgejo registry
|
||||||
uses: docker/login-action@v3
|
env:
|
||||||
with:
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
registry: git.fabledsword.com
|
ACTOR: ${{ github.actor }}
|
||||||
username: ${{ github.actor }}
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
||||||
password: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
|
# --- 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 [ -z "$PUBLISHED" ] && docker buildx imagetools inspect "$IMAGE:$T" >/dev/null 2>&1; then
|
||||||
|
# The tag resolves but carries no readable label. Expected exactly
|
||||||
|
# once per artifact, during the migration onto labels. If it recurs
|
||||||
|
# every push, something is rewriting the channel tag as a manifest
|
||||||
|
# index — see the repoint step's note.
|
||||||
|
echo "reuse: NOTE $IMAGE:$T exists but has no readable fc.revision."
|
||||||
|
echo "reuse: NOTE Fine once, while migrating. Every push means the"
|
||||||
|
echo "reuse: NOTE tag is being index-wrapped and reuse is dead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
- name: Build and push agent image
|
||||||
|
if: steps.reuse.outputs.hit != 'true'
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: agent
|
context: agent
|
||||||
file: agent/Dockerfile
|
file: agent/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.tag.outputs.tags }}
|
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
|
||||||
|
# The source tag is EXCLUDED from the targets, and that is load-
|
||||||
|
# bearing rather than an optimisation.
|
||||||
|
#
|
||||||
|
# `imagetools create` wraps the source manifest in an INDEX. Point it
|
||||||
|
# at the channel tag with that same tag as a target and the tag stops
|
||||||
|
# being a plain image — after which `.Image.Config.Labels` no longer
|
||||||
|
# resolves through it and the fc.revision label reads as absent. The
|
||||||
|
# next push then misses and rebuilds, so reuse worked exactly once
|
||||||
|
# and every subsequent push paid full price. Observed on run 4751:
|
||||||
|
# ml:dev reported fc.revision=<none> one push after run 4749 had read
|
||||||
|
# a7e626a67a79 off it. Nothing failed; the savings just evaporated.
|
||||||
|
#
|
||||||
|
# Excluding the source means the channel tag is only ever written by
|
||||||
|
# a real build, so it stays a plain image and stays readable. On dev
|
||||||
|
# that leaves nothing to do — :dev already points at the right
|
||||||
|
# content, which is what the hit established. On main it leaves
|
||||||
|
# :c-<sha>, which rule 145 requires of every main push whether or not
|
||||||
|
# a build ran.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
|
unset IFS
|
||||||
|
if [ -z "$ARGS" ]; then
|
||||||
|
echo "repoint: $SOURCE already carries this revision and is the"
|
||||||
|
echo "repoint: only tag for this channel — nothing to write."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
|
echo "repointed from $SOURCE:$ARGS"
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ jobs:
|
|||||||
SECRET_KEY: ci_unit_test_placeholder
|
SECRET_KEY: ci_unit_test_placeholder
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history for tests/test_artifact_identity.py, which derives
|
||||||
|
# each artifact's revision to check the identity scheme. On a
|
||||||
|
# depth-1 clone that derivation either fails or returns the tip sha
|
||||||
|
# — so the lane would go green while asserting nothing, which is
|
||||||
|
# the one outcome worse than a red one.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
# keeping three copies of one fact in sync by hand is how issue #2397 happened:
|
# keeping three copies of one fact in sync by hand is how issue #2397 happened:
|
||||||
#
|
#
|
||||||
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
||||||
# 2. the :(exclude) pathspec (ci.yml's extension-version guard)
|
# 2. the :(exclude) pathspec (what moves the version — a WIDER
|
||||||
|
# set than the ignore list; see
|
||||||
|
# NOT_VERSION_RELEVANT)
|
||||||
# 3. the git-log pathspec (the derived version, below)
|
# 3. the git-log pathspec (the derived version, below)
|
||||||
#
|
#
|
||||||
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
||||||
@@ -35,6 +37,28 @@ set -euf
|
|||||||
NOT_PACKAGED_TRACKED='package.json package-lock.json README.md .gitignore vitest.config.js scripts scripts/** test test/**'
|
NOT_PACKAGED_TRACKED='package.json package-lock.json README.md .gitignore vitest.config.js scripts scripts/** test test/**'
|
||||||
NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
|
NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
|
||||||
|
|
||||||
|
# Paths under extension/ that cannot change the SHIPPED BYTES, and so must not
|
||||||
|
# move the derived version.
|
||||||
|
#
|
||||||
|
# Deliberately NOT the same list as NOT_PACKAGED_TRACKED, and the whole
|
||||||
|
# difference is `scripts/`. packaging.sh is not packaged into the XPI — but it
|
||||||
|
# DECIDES the version string, and build.yml stamps that string into the
|
||||||
|
# manifest.json that is packaged. A change to how the version is computed is
|
||||||
|
# therefore a change to the shipped bytes.
|
||||||
|
#
|
||||||
|
# Excluding it was harmless only while every push rebuilt the web image.
|
||||||
|
# Milestone 313 step 4 made the rebuild conditional on the derived revision
|
||||||
|
# moving, which turned it into a silent failure: a packaging.sh change gives a
|
||||||
|
# NEW version, so sign-extension misses its ext-<version> cache and signs —
|
||||||
|
# while build-web sees an unmoved revision, reuses the published image, and
|
||||||
|
# ships the OLD XPI. An orphaned AMO signature, and an instance quietly serving
|
||||||
|
# code the registry says is current.
|
||||||
|
#
|
||||||
|
# The two directions are not symmetric, which is why this list is the narrower
|
||||||
|
# one. Too wide costs a re-sign and a rebuild for a change that ships nothing
|
||||||
|
# new. Too narrow serves stale bytes and says nothing.
|
||||||
|
NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**'
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: packaging.sh {ignore|pathspec|version|major-minor|patch}" >&2
|
echo "usage: packaging.sh {ignore|pathspec|version|major-minor|patch}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -49,11 +73,14 @@ cmd_ignore() {
|
|||||||
echo "$NOT_PACKAGED_TRACKED $NOT_PACKAGED_BUILD"
|
echo "$NOT_PACKAGED_TRACKED $NOT_PACKAGED_BUILD"
|
||||||
}
|
}
|
||||||
|
|
||||||
# git pathspec excluding the non-packaged tracked files, e.g.
|
# git pathspec excluding the tracked files that cannot change the shipped
|
||||||
# :(exclude)extension/package.json :(exclude)extension/test/**
|
# bytes, e.g. :(exclude)extension/package.json :(exclude)extension/test/**
|
||||||
|
#
|
||||||
|
# This answers "what moves the version?", NOT "what goes in the XPI?" — see
|
||||||
|
# NOT_VERSION_RELEVANT for why those differ. cmd_ignore answers the other one.
|
||||||
# Same `set -f` requirement as above.
|
# Same `set -f` requirement as above.
|
||||||
cmd_pathspec() {
|
cmd_pathspec() {
|
||||||
for entry in $NOT_PACKAGED_TRACKED; do
|
for entry in $NOT_VERSION_RELEVANT; do
|
||||||
printf ':(exclude)extension/%s ' "$entry"
|
printf ':(exclude)extension/%s ' "$entry"
|
||||||
done
|
done
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -44,9 +44,39 @@ describe('packaging.sh — the single definition of what ships', () => {
|
|||||||
// covering anything added later.
|
// covering anything added later.
|
||||||
const pathspec = packaging('pathspec')
|
const pathspec = packaging('pathspec')
|
||||||
expect(pathspec).toContain(':(exclude)extension/test/**')
|
expect(pathspec).toContain(':(exclude)extension/test/**')
|
||||||
expect(pathspec).toContain(':(exclude)extension/scripts/**')
|
|
||||||
expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
|
expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
|
||||||
expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)
|
expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)
|
||||||
|
|
||||||
|
const ignore = packaging('ignore')
|
||||||
|
expect(ignore).toContain('test/**')
|
||||||
|
expect(ignore).toContain('scripts/**')
|
||||||
|
expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets packaging.sh move the version, though it never ships in the XPI', () => {
|
||||||
|
// The two lists answer different questions and this is the one place they
|
||||||
|
// disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon
|
||||||
|
// code — but packaging.sh DECIDES the version string, and build.yml stamps
|
||||||
|
// that string into the manifest.json that does ship. So changing how the
|
||||||
|
// version is computed changes the shipped bytes.
|
||||||
|
//
|
||||||
|
// Excluding it from the pathspec was invisible while every push rebuilt the
|
||||||
|
// web image. Milestone 313 step 4 made that rebuild conditional on the
|
||||||
|
// derived revision moving, and the omission turned into a silent failure:
|
||||||
|
// a new version means sign-extension misses its ext-<version> cache and
|
||||||
|
// signs, while build-web sees an unmoved revision, reuses the published
|
||||||
|
// image and ships the OLD XPI. An orphaned signature, and an instance
|
||||||
|
// serving code the registry calls current.
|
||||||
|
const pathspec = packaging('pathspec')
|
||||||
|
expect(
|
||||||
|
pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')),
|
||||||
|
'the pathspec excludes scripts/, so a change to how the version is '
|
||||||
|
+ 'derived would not move the version it derives',
|
||||||
|
).toBe(false)
|
||||||
|
|
||||||
|
// ...and it is still kept out of the package itself. Both must hold: the
|
||||||
|
// tempting "fix" for either half is to make the two lists one again.
|
||||||
|
expect(packaging('ignore')).toContain('scripts')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps its own scripts and specs out of the XPI', () => {
|
it('keeps its own scripts and specs out of the XPI', () => {
|
||||||
|
|||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Single definition of WHAT EACH PUBLISHED ARTIFACT IS BUILT FROM, and the
|
||||||
|
# version derived from it. Milestone 313; generalises the shape
|
||||||
|
# extension/scripts/packaging.sh established for the extension alone.
|
||||||
|
#
|
||||||
|
# Four artifacts, four independent versions. An artifact whose shipped files
|
||||||
|
# did not change keeps its version and does not rebuild — that is the whole
|
||||||
|
# point, and it is why each path set must match its Dockerfile rather than
|
||||||
|
# being a plausible guess. Getting a set wrong is quiet in BOTH directions:
|
||||||
|
#
|
||||||
|
# too narrow -> a pin serves stale bytes, because the version did not move
|
||||||
|
# when the content did. This is the dangerous one.
|
||||||
|
# too wide -> the artifact re-versions and rebuilds for a change it does
|
||||||
|
# not ship. Merely wasteful.
|
||||||
|
#
|
||||||
|
# tests/test_artifact_paths.py asserts every COPY source in each Dockerfile is
|
||||||
|
# covered here, so adding a COPY without updating this file fails CI.
|
||||||
|
#
|
||||||
|
# POSIX sh only — CI's run shell is busybox on some paths.
|
||||||
|
#
|
||||||
|
# -f (no pathname expansion) is load-bearing for the whole script: the lists
|
||||||
|
# below are iterated with deliberate word-splitting, and without it the shell
|
||||||
|
# would glob `frontend/test/**` against the working tree and silently narrow
|
||||||
|
# the pattern. Callers substituting the output need their own `set -f` too;
|
||||||
|
# the two guards protect different expansions.
|
||||||
|
set -euf
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
# --- what each artifact ships ------------------------------------------------
|
||||||
|
#
|
||||||
|
# Each set includes its own Dockerfile and requirements: changing a base image
|
||||||
|
# or a pin changes the artifact just as surely as changing a source file.
|
||||||
|
#
|
||||||
|
# web (Dockerfile, context `.`) — the runtime stage copies backend/, alembic/,
|
||||||
|
# alembic.ini, entrypoint.sh and requirements.txt; the frontend-builder stage
|
||||||
|
# copies frontend/ and the runtime takes its `dist` output.
|
||||||
|
#
|
||||||
|
# frontend/test is excluded: `npm run build` is vite, which builds from src/,
|
||||||
|
# index.html and public/ and never reads test/. It lands in the builder layer
|
||||||
|
# but not in `dist`, so it cannot reach the shipped image.
|
||||||
|
#
|
||||||
|
# The web image ALSO bundles the signed XPI (build.yml downloads it into
|
||||||
|
# frontend/public/extension/ before the docker build), so an extension change
|
||||||
|
# changes the web image. The extension's packaged set is appended in cmd_paths
|
||||||
|
# rather than restated — one definition, per #2397.
|
||||||
|
WEB_PATHS='Dockerfile requirements.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
|
||||||
|
|
||||||
|
# ml (Dockerfile.ml, context `.`) — no frontend, no extension. Note it copies
|
||||||
|
# BOTH requirements-ml.txt and requirements.txt.
|
||||||
|
ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic alembic.ini entrypoint.sh'
|
||||||
|
|
||||||
|
# agent (agent/Dockerfile, context `agent`) — copies requirements.txt and
|
||||||
|
# fc_agent only. agent/README.md, agent/docker-compose.yml and agent/ruff.toml
|
||||||
|
# live in the directory but never reach the image, so they must not re-version
|
||||||
|
# it: this is deliberately NOT `agent/`.
|
||||||
|
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
|
||||||
|
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# The extension's packaged set, read from its own definition rather than
|
||||||
|
# copied. packaging.sh emits `:(exclude)extension/...` entries, so the bare
|
||||||
|
# `extension` include has to come with them.
|
||||||
|
ext_paths() {
|
||||||
|
echo "extension $(sh "$ROOT/extension/scripts/packaging.sh" pathspec)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_paths() {
|
||||||
|
case "$1" in
|
||||||
|
web) echo "$WEB_PATHS $(ext_paths)" ;;
|
||||||
|
ml) echo "$ML_PATHS" ;;
|
||||||
|
agent) echo "$AGENT_PATHS" ;;
|
||||||
|
extension) ext_paths ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# "<unix ts> <sha>" of the newest commit touching this artifact's shipped set.
|
||||||
|
# Unquoted on purpose: the pathspec must word-split into separate args.
|
||||||
|
# Globbing is already off script-wide.
|
||||||
|
newest() {
|
||||||
|
# shellcheck disable=SC2046
|
||||||
|
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $(cmd_paths "$1") \
|
||||||
|
| sort -n | tail -1)"
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "artifacts.sh: no commit touches this artifact's shipped files" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Formatted through git rather than date(1): busybox date does not reliably
|
||||||
|
# accept `-d @<epoch>`, and git's own --date=format-local is available wherever
|
||||||
|
# git is. TZ=UTC so the value does not depend on the runner's timezone.
|
||||||
|
fmt() {
|
||||||
|
(cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leading zeros stripped so every segment is a plain integer — some version
|
||||||
|
# validators reject `08`, and a leading zero buys nothing. `0000` (midnight)
|
||||||
|
# must survive as `0`, not as the empty string.
|
||||||
|
strip0() {
|
||||||
|
printf '%s' "$1" | sed -e 's/^0*//' -e 's/^$/0/'
|
||||||
|
}
|
||||||
|
|
||||||
|
# The IDENTITY of an artifact's content: the commit its shipped files last
|
||||||
|
# changed in. This is what decides whether a build can be skipped.
|
||||||
|
#
|
||||||
|
# It is published as the `fc.revision` LABEL on the image itself, and read
|
||||||
|
# back off the moving channel tag — not as a tag of its own (milestone 318
|
||||||
|
# step 3). A tag would be a name minted per build that only one thing reads,
|
||||||
|
# which is what rule 145 narrowed against; it would also be prunable under the
|
||||||
|
# registry's keep_pattern (#3157), so the cache would silently expire.
|
||||||
|
#
|
||||||
|
# A published image with no such label reads as a MISS and rebuilds. That is
|
||||||
|
# the migration path, not a fault: `imagetools create` copies a manifest and
|
||||||
|
# config labels are not manifest annotations, so the reuse path cannot stamp
|
||||||
|
# one and there is nothing to backfill. Each artifact pays one rebuild, once.
|
||||||
|
cmd_revision() {
|
||||||
|
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
|
||||||
|
}
|
||||||
|
|
||||||
|
# The ORDERING KEY: full precision, YYYY.M.D.HHMM. Used by the extension,
|
||||||
|
# where the value is what Firefox compares to decide whether an update exists
|
||||||
|
# — two same-day builds MUST be distinguishable or the second never reaches
|
||||||
|
# anyone.
|
||||||
|
cmd_version() {
|
||||||
|
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
||||||
|
printf '%s.%s.%s.%s\n' \
|
||||||
|
"$(fmt "$sha" %Y)" \
|
||||||
|
"$(strip0 "$(fmt "$sha" %m)")" \
|
||||||
|
"$(strip0 "$(fmt "$sha" %d)")" \
|
||||||
|
"$(strip0 "$(fmt "$sha" %H%M)")"
|
||||||
|
}
|
||||||
|
|
||||||
|
[ $# -ge 2 ] || usage
|
||||||
|
case "$1" in
|
||||||
|
paths) cmd_paths "$2" ;;
|
||||||
|
revision) cmd_revision "$2" ;;
|
||||||
|
version) cmd_version "$2" ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""`artifacts.sh revision` is what decides whether a build gets skipped.
|
||||||
|
|
||||||
|
Milestone 318 step 3: each image carries its revision as an `fc.revision`
|
||||||
|
label, and build.yml reads that label back off the moving channel tag. Equal
|
||||||
|
to the derived revision means the bytes this push would produce are already
|
||||||
|
published, so the build is skipped.
|
||||||
|
|
||||||
|
That makes the revision load-bearing in a way a version string is not — it is
|
||||||
|
compared for equality against a value stamped into a real published artifact.
|
||||||
|
Both ways of getting it wrong are silent:
|
||||||
|
|
||||||
|
* **it does not identify the content** — a revision that moves when the source
|
||||||
|
did not (a HEAD-derived value, say) never matches, nothing is ever skipped,
|
||||||
|
and the mechanism quietly buys nothing while every lane stays green.
|
||||||
|
* **it identifies the wrong content** — a revision that holds still when the
|
||||||
|
source DID change matches a stale label, the build is skipped, and the
|
||||||
|
channel serves bytes that do not correspond to the commit. This is the
|
||||||
|
dangerous direction, and it is what `test_artifact_paths.py` guards from the
|
||||||
|
other side by pinning the path sets.
|
||||||
|
|
||||||
|
This module owns the narrower claim: whatever the path sets say, the revision
|
||||||
|
is genuinely the commit those paths last changed in.
|
||||||
|
|
||||||
|
The identity-TAG tests this file used to hold are gone with the tag. There is
|
||||||
|
no longer a `CHANNELLED` list to drift (the channel is which tag you inspect),
|
||||||
|
and no `identity` subcommand to refuse an unqualified call.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
ARTIFACTS = ("web", "ml", "agent", "extension")
|
||||||
|
|
||||||
|
# 12 hex chars — the prefix build.yml stamps and compares.
|
||||||
|
_REVISION = re.compile(r"^[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
|
||||||
|
# Everything here goes through artifacts.sh rather than importing a sibling
|
||||||
|
# test module. That is the interface build.yml actually calls, so the tests
|
||||||
|
# exercise the contract instead of a Python re-implementation of it — and no
|
||||||
|
# other test module in this repo imports another, so a cross-test import would
|
||||||
|
# be a new convention introduced for no gain.
|
||||||
|
def artifacts(*args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), *args],
|
||||||
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def revision(artifact: str) -> str:
|
||||||
|
return artifacts("revision", artifact).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def newest_by_commit_time(artifact: str) -> str:
|
||||||
|
"""The full SHA of the newest commit touching this artifact's shipped set.
|
||||||
|
|
||||||
|
Ordered by committer TIME, matching what artifacts.sh means. Deliberately
|
||||||
|
not `git log -1`: git's default order is reverse-chronological only within
|
||||||
|
topological constraints, so on a merged history it can name a different
|
||||||
|
commit than the newest timestamp does. They agree on this repo today, and
|
||||||
|
a test that silently depends on them continuing to agree would be a flake
|
||||||
|
waiting for the branch shape that separates them.
|
||||||
|
"""
|
||||||
|
paths = artifacts("paths", artifact).split()
|
||||||
|
log = subprocess.run(
|
||||||
|
["git", "log", "--format=%ct %H", "HEAD", "--", *paths],
|
||||||
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||||
|
).stdout.split("\n")
|
||||||
|
commits = [line.split(" ", 1) for line in log if line.strip()]
|
||||||
|
assert commits, (
|
||||||
|
f"no commit in this history touches the {artifact} path set — the "
|
||||||
|
f"derivation has nothing to stand on"
|
||||||
|
)
|
||||||
|
return max(commits, key=lambda c: int(c[0]))[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", ARTIFACTS)
|
||||||
|
def test_revision_is_the_commit_its_own_shipped_files_last_changed_in(artifact):
|
||||||
|
"""The claim the whole skip decision rests on.
|
||||||
|
|
||||||
|
Computed from git rather than asked of the script, so it fails if the
|
||||||
|
derivation ever stops meaning what it says — switching to HEAD, to a build
|
||||||
|
clock, or to a path set it did not actually use. Each of those still
|
||||||
|
produces a plausible 12-hex value, which is why this is worth asserting
|
||||||
|
rather than eyeballing.
|
||||||
|
"""
|
||||||
|
expected = newest_by_commit_time(artifact)
|
||||||
|
got = revision(artifact)
|
||||||
|
assert expected.startswith(got), (
|
||||||
|
f"{artifact} derives {got!r}, but the newest commit touching its "
|
||||||
|
f"shipped files is {expected[:12]!r}. The label stamped into the image "
|
||||||
|
f"would not identify its own content."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", ARTIFACTS)
|
||||||
|
def test_revision_is_a_legal_label_value_and_is_stable(artifact):
|
||||||
|
"""It is stamped as a docker label and compared for string equality, so a
|
||||||
|
stray newline or a varying value breaks the comparison rather than the
|
||||||
|
build — the mechanism would simply stop hitting, silently."""
|
||||||
|
first = revision(artifact)
|
||||||
|
assert _REVISION.match(first), f"{first!r} is not a 12-char hex revision"
|
||||||
|
assert first == revision(artifact), "revision is not stable across calls"
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""`scripts/artifacts.sh` path sets must match what the Dockerfiles copy.
|
||||||
|
|
||||||
|
Each published artifact's version derives from the newest commit touching its
|
||||||
|
own shipped file set (milestone 313). The whole scheme rests on those sets
|
||||||
|
being right, and both ways of being wrong are silent:
|
||||||
|
|
||||||
|
* **too narrow** — a file ships but is not in the set, so the version does not
|
||||||
|
move when the content does, and a pin serves stale bytes. This is the
|
||||||
|
dangerous direction and the one this module exists for.
|
||||||
|
* **too wide** — a file is in the set but never reaches the image, so the
|
||||||
|
artifact re-versions and rebuilds for a change it does not ship.
|
||||||
|
|
||||||
|
Nothing else notices either. The version still derives, CI still goes green,
|
||||||
|
and the mismatch only surfaces as "I pinned that build and got the wrong
|
||||||
|
bytes". So the Dockerfiles are read here and compared against the declaration.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# artifact -> (dockerfile, build context relative to the repo root)
|
||||||
|
ARTIFACTS = {
|
||||||
|
"web": ("Dockerfile", ""),
|
||||||
|
"ml": ("Dockerfile.ml", ""),
|
||||||
|
"agent": ("agent/Dockerfile", "agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# COPY --from=<stage> copies from an earlier build stage, not from the build
|
||||||
|
# context, so its source is not a repo path and cannot be in a path set.
|
||||||
|
_COPY = re.compile(r"^\s*COPY\s+(?!--from=)(?P<args>.+)$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def declared_paths(artifact: str) -> list[str]:
|
||||||
|
out = subprocess.run(
|
||||||
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), "paths", artifact],
|
||||||
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||||
|
).stdout
|
||||||
|
return out.split()
|
||||||
|
|
||||||
|
|
||||||
|
def includes(artifact: str) -> list[str]:
|
||||||
|
"""The set minus its `:(exclude)…` entries."""
|
||||||
|
return [p for p in declared_paths(artifact) if not p.startswith(":(exclude)")]
|
||||||
|
|
||||||
|
|
||||||
|
def copy_sources(dockerfile: str, context: str) -> list[str]:
|
||||||
|
"""Repo-relative sources of every context COPY in a Dockerfile."""
|
||||||
|
text = (ROOT / dockerfile).read_text()
|
||||||
|
sources: list[str] = []
|
||||||
|
for m in _COPY.finditer(text):
|
||||||
|
args = m.group("args").split()
|
||||||
|
# Last arg is the destination; everything before it is a source.
|
||||||
|
for src in args[:-1]:
|
||||||
|
# `frontend/package-lock.json*` — the glob is an optional-file
|
||||||
|
# idiom; the directory it sits in is what matters for coverage.
|
||||||
|
src = src.rstrip("*")
|
||||||
|
sources.append(f"{context}/{src}" if context else src)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def covered_by(path: str, include: str) -> bool:
|
||||||
|
"""`path` ships if an include names it or one of its ancestors."""
|
||||||
|
path = path.rstrip("/").lstrip("./")
|
||||||
|
include = include.rstrip("/")
|
||||||
|
return path == include or path.startswith(include + "/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
|
||||||
|
def test_every_copied_path_is_in_the_artifacts_path_set(artifact):
|
||||||
|
"""The too-narrow direction — the one that serves stale bytes on a pin."""
|
||||||
|
dockerfile, context = ARTIFACTS[artifact]
|
||||||
|
inc = includes(artifact)
|
||||||
|
for src in copy_sources(dockerfile, context):
|
||||||
|
assert any(covered_by(src, i) for i in inc), (
|
||||||
|
f"{dockerfile} copies {src!r} into the {artifact} image, but no "
|
||||||
|
f"include in scripts/artifacts.sh covers it. The {artifact} "
|
||||||
|
f"version will not move when that file changes, so a pinned build "
|
||||||
|
f"will serve stale bytes. Add it to the path set.\n"
|
||||||
|
f" declared includes: {inc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
|
||||||
|
def test_the_dockerfile_itself_is_in_the_path_set(artifact):
|
||||||
|
"""Changing a base image or a RUN changes the artifact as surely as
|
||||||
|
changing a source file, so each set must include its own Dockerfile."""
|
||||||
|
dockerfile, _ = ARTIFACTS[artifact]
|
||||||
|
assert any(covered_by(dockerfile, i) for i in includes(artifact)), (
|
||||||
|
f"{dockerfile} is not in the {artifact} path set — a base-image bump "
|
||||||
|
f"would not move the version."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_image_versions_on_an_extension_change():
|
||||||
|
"""The web image bundles the signed XPI, so the extension's packaged files
|
||||||
|
are part of what it ships. Miss this and `:latest` serves a NEW extension
|
||||||
|
under an unchanged web version — a pin that quietly disagrees with itself.
|
||||||
|
"""
|
||||||
|
inc = includes("web")
|
||||||
|
assert any(covered_by("extension/background/background.js", i) for i in inc), (
|
||||||
|
"the web path set does not cover the extension's packaged files, but "
|
||||||
|
"build.yml downloads the signed XPI into frontend/public/extension/ "
|
||||||
|
"before the docker build"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_web_image_versions_on_a_version_derivation_change():
|
||||||
|
"""packaging.sh ships in no image, yet it belongs in the sets that bundle
|
||||||
|
the XPI — because it decides the version string build.yml stamps into the
|
||||||
|
packaged manifest.json. Changing the derivation changes the shipped bytes.
|
||||||
|
|
||||||
|
Left out, milestone 313 step 4 turns it silent: the new version misses the
|
||||||
|
ext-<version> cache and gets signed, while web's revision has not moved, so
|
||||||
|
the reuse path republishes the old image and the fresh signature is
|
||||||
|
orphaned. Guarded for web and the extension both, since web bundles what
|
||||||
|
the extension produces.
|
||||||
|
"""
|
||||||
|
for artifact in ("extension", "web"):
|
||||||
|
inc = includes(artifact)
|
||||||
|
excluded = [
|
||||||
|
p[len(":(exclude)"):] for p in declared_paths(artifact)
|
||||||
|
if p.startswith(":(exclude)")
|
||||||
|
]
|
||||||
|
path = "extension/scripts/packaging.sh"
|
||||||
|
assert any(covered_by(path, i) for i in inc), (
|
||||||
|
f"{path} is not in the {artifact} path set"
|
||||||
|
)
|
||||||
|
assert not any(
|
||||||
|
covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded
|
||||||
|
), (
|
||||||
|
f"{path} is excluded from the {artifact} path set, so a change to "
|
||||||
|
f"how the version is derived would not move the version — and "
|
||||||
|
f"step 4 would reuse the image that carries the old one"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"artifact, path",
|
||||||
|
[
|
||||||
|
# Deliberate exclusions — the too-wide direction. Each of these lives
|
||||||
|
# beside shipped code but never reaches an image, and including it
|
||||||
|
# would re-version the artifact for a change it does not carry.
|
||||||
|
("agent", "agent/README.md"),
|
||||||
|
("agent", "agent/ruff.toml"),
|
||||||
|
("agent", "agent/docker-compose.yml"),
|
||||||
|
# vite builds from src/, index.html and public/; it never reads test/,
|
||||||
|
# so a frontend test change cannot reach `dist`.
|
||||||
|
("web", "frontend/test/gallery.spec.js"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_files_that_never_reach_an_image_do_not_version_it(artifact, path):
|
||||||
|
paths = declared_paths(artifact)
|
||||||
|
excluded = [p[len(":(exclude)"):] for p in paths if p.startswith(":(exclude)")]
|
||||||
|
inc = [p for p in paths if not p.startswith(":(exclude)")]
|
||||||
|
|
||||||
|
included = any(covered_by(path, i) for i in inc)
|
||||||
|
exempted = any(covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded)
|
||||||
|
assert not included or exempted, (
|
||||||
|
f"{path} is in the {artifact} path set but is not copied into the "
|
||||||
|
f"image — it would re-version and rebuild {artifact} for a change it "
|
||||||
|
f"does not ship."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user