Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e15c44c51 | ||
|
|
d38585ed94 | ||
|
|
a3071a7549 | ||
|
|
b6b9fd8287 | ||
|
|
bce894ba24 | ||
|
|
5771fd5770 | ||
|
|
6d98dfc0ec | ||
|
|
b3989d0224 | ||
|
|
cd0b0ff04a | ||
|
|
454eb3f973 | ||
|
|
7e065fed70 | ||
|
|
dee93faa37 | ||
|
|
d9aa5aa832 | ||
|
|
6c76f08b69 | ||
|
|
7b1019ba82 | ||
|
|
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 |
+403
-304
@@ -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
|
||||||
@@ -112,17 +116,17 @@ jobs:
|
|||||||
# STILL. If web moves, its path set is too wide.
|
# STILL. If web moves, its path set is too wide.
|
||||||
# * a push touching only docs moves nothing.
|
# * a push touching only docs moves nothing.
|
||||||
# * a push touching the extension moves the extension AND web, since
|
# * 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
|
# web bakes in the XPI. If web does not move, its set is too narrow:
|
||||||
# — the direction that serves stale bytes on a pin.
|
# the reuse check hits, and the channel serves a web image bundling
|
||||||
|
# the PREVIOUS XPI while the freshly signed one is orphaned (#3156).
|
||||||
# * dev and main derive the same values for the same source.
|
# * dev and main derive the same values for the same source.
|
||||||
- name: Shadow — derived artifact version (informational)
|
- name: Shadow — derived artifact version (informational)
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
A=extension
|
A=extension
|
||||||
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
||||||
V=$(sh scripts/artifacts.sh version "$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)
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
||||||
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
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:
|
||||||
@@ -323,12 +327,12 @@ jobs:
|
|||||||
# to. Same source of truth; no double-store.
|
# to. Same source of truth; no double-store.
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
|
# A plain `needs` — no `always()`. That expression existed to let a
|
||||||
|
# SKIPPED sign-extension through on a tag push while still blocking a
|
||||||
|
# FAILED one. With no tag trigger, sign-extension always runs, so the
|
||||||
|
# default behaviour is exactly what we want: a failed sign skips build-web
|
||||||
|
# rather than shipping an image without its XPI.
|
||||||
needs: [sign-extension]
|
needs: [sign-extension]
|
||||||
# sign-extension runs on main and dev, and is skipped on a tag push (which
|
|
||||||
# polls for the release instead). Either is fine to build on; a FAILED sign
|
|
||||||
# is not — this condition lets success and skipped through, so a failure
|
|
||||||
# skips build-web rather than shipping an image without the XPI.
|
|
||||||
if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped')
|
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -342,12 +346,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# --- derived values, one line (milestone 313) ------------------------
|
# --- derived values, one line (milestone 313) ------------------------
|
||||||
# These stopped being shadow output at step 3: `tag` is published on
|
# These stopped being shadow output at step 3. `revision` decides
|
||||||
# main and `revision` decides whether the build below runs at all. This
|
# whether the build below runs at all and `version` is what the image
|
||||||
# step prints all three anyway, because the load-bearing steps each
|
# reports about itself; the load-bearing steps each print only the one
|
||||||
# print only the one they use, and on dev the date tag is computed
|
# they use, so this is the only place the pair appears together. When a
|
||||||
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
# build is skipped, this is the line that says what the commit derived.
|
||||||
# the line that says what the commit derived.
|
|
||||||
#
|
#
|
||||||
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
||||||
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
||||||
@@ -358,78 +361,52 @@ jobs:
|
|||||||
# STILL. If web moves, its path set is too wide.
|
# STILL. If web moves, its path set is too wide.
|
||||||
# * a push touching only docs moves nothing.
|
# * a push touching only docs moves nothing.
|
||||||
# * a push touching the extension moves the extension AND web, since
|
# * 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
|
# web bakes in the XPI. If web does not move, its set is too narrow:
|
||||||
# — the direction that serves stale bytes on a pin.
|
# the reuse check hits, and the channel serves a web image bundling
|
||||||
|
# the PREVIOUS XPI while the freshly signed one is orphaned (#3156).
|
||||||
# * dev and main derive the same values for the same source.
|
# * dev and main derive the same values for the same source.
|
||||||
- name: Report the derived artifact version
|
- name: Report the derived artifact version
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
A=web
|
A=web
|
||||||
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
||||||
V=$(sh scripts/artifacts.sh version "$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)
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
||||||
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
echo "derived: artifact=$A version=$V revision=$R sha=$GITHUB_SHA"
|
||||||
|
|
||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
# Three trigger shapes:
|
# Two trigger shapes, and between them they publish three tags:
|
||||||
# refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
|
# main → :latest (production, moving — rule 147: main IS production)
|
||||||
# plus `.N` when the day already carries a tag —
|
# :c-<sha> (immutable, the rollback unit — rule 145)
|
||||||
# family rule 148, amended 2026-08-24 after a
|
# dev → :dev (the rolling test channel — rule 146)
|
||||||
# same-day tag was retargeted and a release
|
#
|
||||||
# deleted to make room, note 2813).
|
# That is the whole list. No :<version>, and no :main — rule 145,
|
||||||
# Publish ONLY the immutable version tag;
|
# narrowed 2026-08-28 once it was verified that nothing pins:
|
||||||
# don't touch :latest (the main-push build
|
# "a third name for the same thing is upkeep for a model we do not
|
||||||
# for the merge commit already did that).
|
# run." The date tag published between milestone 313 step 3 and
|
||||||
# refs/heads/main → push to main: publish :main + :latest
|
# milestone 318 was exactly that; :main was a second moving name for
|
||||||
# (floating) AND :c-<short_sha> (immutable
|
# whatever :latest already pointed at.
|
||||||
# per-commit rollback substrate, per family
|
#
|
||||||
# release-posture rule "Tags are milestones,
|
# `dev` gets no :c-<sha> deliberately. On a channel whose entire
|
||||||
# not gates — commit-SHA images are the
|
# contract is that it moves, a per-push immutable tag is a rollback
|
||||||
# rollback unit"). Rollback to any commit
|
# target nobody has ever pulled, accumulating forever. The accepted
|
||||||
# becomes `docker pull …:c-<sha>` without a
|
# cost: on dev there is no rollback but the previous :dev, which is
|
||||||
# release ceremony.
|
# gone — recovery is revert-on-git plus a CI cycle.
|
||||||
# refs/heads/dev → push to dev: publish :dev, the rolling test
|
#
|
||||||
# channel (family rule 146). Rolling means it may
|
# Reinstating :<version> is a real decision, not a default. It earns
|
||||||
# carry newer contents than the :c-<sha> of the
|
# its place when something genuinely pins: a second instance held on
|
||||||
# same commit; it never writes :c-<sha> itself,
|
# a known-good build, or a deliberately frozen window. Tag at the
|
||||||
# because that is the rollback unit (rule 145).
|
# moment you decide to freeze; no back-catalogue is needed.
|
||||||
|
#
|
||||||
# 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 the 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)
|
||||||
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
# THIS artifact's shipped files last changed in. Day precision is
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
# deliberate — same-day work is not something worth pinning, so a
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
# 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). 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
|
|
||||||
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"
|
echo "channel=main" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||||
@@ -462,12 +439,21 @@ jobs:
|
|||||||
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
||||||
|
|
||||||
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
||||||
# The identity tag names this artifact's CONTENT — r-<revision>, the
|
# Does the image the channel tag already points at carry THIS commit's
|
||||||
# commit its shipped files last changed in, plus the channel for images
|
# revision? If so the bytes this job would produce are already published
|
||||||
# that bake one in. If the registry already carries it, the bytes this
|
# and the build is pure waste: the remaining tags get repointed at that
|
||||||
# job would produce are already published and the build is pure waste:
|
# existing manifest instead, registry-side, in seconds.
|
||||||
# the channel and date tags get repointed at the 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
|
# 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.
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
||||||
@@ -478,69 +464,88 @@ jobs:
|
|||||||
# that skips a build that was actually needed; the worst case is paying
|
# that skips a build that was actually needed; the worst case is paying
|
||||||
# for a build we could have avoided.
|
# for a build we could have avoided.
|
||||||
#
|
#
|
||||||
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
# BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops
|
||||||
# whose source stops moving stops picking up base-image updates under
|
# picking up base-image updates. Milestone 318 removed the argument this
|
||||||
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
# used to need rather than answering it — with no version tags there is
|
||||||
# serving the bytes it served (fabledcurator:2026.7.17 still
|
# no immutable name a refresh could contradict, and rule 145 already
|
||||||
# resolves to July's image), or it is not a pin — and family rule
|
# allows a rebuild with different contents to republish a MOVING tag.
|
||||||
# 145 already says where the refresh goes instead: a rebuild with
|
# So a refresh is just a build. A scheduled channel-only one is tracked
|
||||||
# different contents publishes only the MOVING tag, never the immutable
|
# separately (#3154); it does not belong in the push path.
|
||||||
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
||||||
# it does not belong in the push path.
|
|
||||||
- name: Is this content already published?
|
- name: Is this content already published?
|
||||||
id: reuse
|
id: reuse
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
||||||
CHANNEL: ${{ steps.tag.outputs.channel }}
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
ID=$(sh scripts/artifacts.sh identity web "$CHANNEL")
|
DERIVED=$(sh scripts/artifacts.sh revision web)
|
||||||
echo "identity=$ID" >> "$GITHUB_OUTPUT"
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
||||||
|
# Baked into the web image as FC_VERSION and reported by /api/health.
|
||||||
# A tag-push builds a revision that main already published, so it
|
# A pure function of the revision — same commit, same string — so it
|
||||||
# must NOT claim the identity: image configs are not bit-reproducible
|
# adds no variability the reuse check would have to account for.
|
||||||
# (embedded timestamps), so re-pushing r-<rev> would point an
|
echo "version=$(sh scripts/artifacts.sh version web)" >> "$GITHUB_OUTPUT"
|
||||||
# immutable tag at fresh bytes — rule 145's exact prohibition. It
|
|
||||||
# publishes only its own :v… label and otherwise reuses.
|
|
||||||
if [ "$IS_TAG_PUSH" = "true" ]; then
|
|
||||||
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
else
|
|
||||||
echo "build_tags=$TAGS,$IMAGE:$ID" >> "$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
|
fi
|
||||||
|
|
||||||
if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
||||||
echo "hit=true" >> "$GITHUB_OUTPUT"
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is already published — skipping the build"
|
echo "reuse: already published — skipping the build"
|
||||||
else
|
else
|
||||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is not published — building"
|
echo "reuse: not published — building"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Download signed XPI from Forgejo release asset
|
- name: Download signed XPI from Forgejo release asset
|
||||||
# Fires on every trigger shape. dev and main each bundle the XPI their
|
# dev and main each bundle the XPI their own sign-extension just
|
||||||
# own sign-extension just published — that is the whole point of the
|
# published — the point of the channel work (milestone 271 step 6): the
|
||||||
# channel work (milestone 271 step 6): the dev image carries the
|
# dev image carries the extension being developed, rather than
|
||||||
# extension being developed, rather than requiring a merge to try it.
|
# requiring a merge to try it.
|
||||||
# Tag-push builds re-package the same source as the preceding main-push
|
|
||||||
# build but with an immutable version tag — they need the XPI too,
|
|
||||||
# otherwise the versioned image ships without the signed extension.
|
|
||||||
#
|
#
|
||||||
# Tag-push vs main-push race (operator-flagged 2026-05-27 after
|
# The 10-minute polling loop that used to live here is gone with the
|
||||||
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost
|
# tag trigger (milestone 318 step 2). It existed for one shape only: a
|
||||||
# simultaneously. Main-push runs sign-extension (1-5min AMO round
|
# release cut fired the tag build and the main build together, the tag
|
||||||
# trip) before publishing the ext-<version> release; tag-push
|
# build skipped sign-extension and raced straight here, and it lost
|
||||||
# skips sign-extension (gated to main) and races straight to
|
# every time (operator-flagged 2026-05-27 after v26.05.27.0). Polling
|
||||||
# this download step. Tag-push lost every time. Fix: poll the
|
# was the fix for a build that should not have been running.
|
||||||
# ext-<version> release endpoint with a sleep+retry loop (30s
|
#
|
||||||
# for up to 10min total) before giving up. Main-push's signing
|
# sign-extension is a `needs` dependency and it succeeded, so the
|
||||||
# eventually wins and tag-push picks the release up on a later
|
# release exists. A single fetch is correct, and a 404 now means a real
|
||||||
# iteration.
|
# disagreement about the derived version rather than a race — which is
|
||||||
# Gated on the reuse miss as well: if the image is already published it
|
# exactly what should fail loudly instead of being slept through.
|
||||||
# already contains its XPI, so this would download (and on a tag-push,
|
#
|
||||||
# poll up to 10 minutes for) a file nothing then reads.
|
# Still gated on the reuse miss: a published image already contains its
|
||||||
if: steps.reuse.outputs.hit != 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
# XPI, so this would fetch a file nothing then reads.
|
||||||
|
if: steps.reuse.outputs.hit != 'true'
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -552,25 +557,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
|
||||||
|
# report.
|
||||||
STATUS=$(curl -s -o release.json -w "%{http_code}" \
|
STATUS=$(curl -s -o release.json -w "%{http_code}" \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
|
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
|
||||||
if [ "$STATUS" = "200" ]; then
|
if [ "$STATUS" != "200" ]; then
|
||||||
echo "Found ext-$VERSION release on attempt $attempt"
|
echo "ERROR: ext-$VERSION release not found (HTTP $STATUS)."
|
||||||
break
|
echo "sign-extension succeeded in this run, so it published some"
|
||||||
fi
|
echo "other version — the two jobs derived different values for one"
|
||||||
if [ "$attempt" = "20" ]; then
|
echo "commit. Check that both checked out with fetch-depth: 0."
|
||||||
echo "ERROR: ext-$VERSION release not available after 10min of polling"
|
|
||||||
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
|
||||||
@@ -608,16 +609,22 @@ jobs:
|
|||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.reuse.outputs.build_tags }}
|
tags: ${{ steps.reuse.outputs.build_tags }}
|
||||||
# Only the web image carries a channel: it is the one that serves
|
# The reuse key. Read back off the channel tag on the next push to
|
||||||
# /api/extension/manifest. The ml and agent images have nothing to
|
# decide whether that push needs to build at all, so this is not
|
||||||
# report it to.
|
# decoration — an unstamped image is one that will always rebuild.
|
||||||
|
labels: |
|
||||||
|
fc.revision=${{ steps.reuse.outputs.revision }}
|
||||||
|
# Only the web image carries these: it is the one with a UI and an
|
||||||
|
# HTTP surface to report them on. The ml and agent images have
|
||||||
|
# nothing to tell.
|
||||||
build-args: |
|
build-args: |
|
||||||
FC_CHANNEL=${{ steps.tag.outputs.channel }}
|
FC_CHANNEL=${{ steps.tag.outputs.channel }}
|
||||||
|
FC_VERSION=${{ steps.reuse.outputs.version }}
|
||||||
|
|
||||||
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
||||||
# rebuild. Each -t becomes another reference to the SAME manifest the
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
||||||
# identity tag holds, so :latest and the date pin are byte-identical to
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# what was published rather than a lookalike rebuild.
|
# published rather than a lookalike rebuild.
|
||||||
#
|
#
|
||||||
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
||||||
# rolling channel refreshes itself, so skipping a build must never mean
|
# rolling channel refreshes itself, so skipping a build must never mean
|
||||||
@@ -627,19 +634,46 @@ jobs:
|
|||||||
if: steps.reuse.outputs.hit == 'true'
|
if: steps.reuse.outputs.hit == 'true'
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator
|
||||||
IDENTITY: ${{ steps.reuse.outputs.identity }}
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
run: |
|
run: |
|
||||||
set -euf
|
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
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
||||||
# docker/build-push-action takes; imagetools wants a -t per ref.
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
||||||
ARGS=""
|
ARGS=""
|
||||||
IFS=,
|
IFS=,
|
||||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
for t in $TAGS; do
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
unset IFS
|
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
|
# shellcheck disable=SC2086
|
||||||
docker buildx imagetools create $ARGS "$IMAGE:$IDENTITY"
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
echo "repointed to $IMAGE:$IDENTITY: $TAGS"
|
echo "repointed from $SOURCE:$ARGS"
|
||||||
|
|
||||||
build-ml:
|
build-ml:
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
@@ -656,12 +690,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# --- derived values, one line (milestone 313) ------------------------
|
# --- derived values, one line (milestone 313) ------------------------
|
||||||
# These stopped being shadow output at step 3: `tag` is published on
|
# These stopped being shadow output at step 3. `revision` decides
|
||||||
# main and `revision` decides whether the build below runs at all. This
|
# whether the build below runs at all and `version` is what the image
|
||||||
# step prints all three anyway, because the load-bearing steps each
|
# reports about itself; the load-bearing steps each print only the one
|
||||||
# print only the one they use, and on dev the date tag is computed
|
# they use, so this is the only place the pair appears together. When a
|
||||||
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
# build is skipped, this is the line that says what the commit derived.
|
||||||
# the line that says what the commit derived.
|
|
||||||
#
|
#
|
||||||
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
||||||
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
||||||
@@ -672,56 +705,30 @@ jobs:
|
|||||||
# STILL. If web moves, its path set is too wide.
|
# STILL. If web moves, its path set is too wide.
|
||||||
# * a push touching only docs moves nothing.
|
# * a push touching only docs moves nothing.
|
||||||
# * a push touching the extension moves the extension AND web, since
|
# * 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
|
# web bakes in the XPI. If web does not move, its set is too narrow:
|
||||||
# — the direction that serves stale bytes on a pin.
|
# the reuse check hits, and the channel serves a web image bundling
|
||||||
|
# the PREVIOUS XPI while the freshly signed one is orphaned (#3156).
|
||||||
# * dev and main derive the same values for the same source.
|
# * dev and main derive the same values for the same source.
|
||||||
- name: Report the derived artifact version
|
- name: Report the derived artifact version
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
A=ml
|
A=ml
|
||||||
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
||||||
V=$(sh scripts/artifacts.sh version "$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)
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
||||||
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
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)
|
||||||
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
# THIS artifact's shipped files last changed in. Day precision is
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
# deliberate — same-day work is not something worth pinning, so a
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
# 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#refs/tags/}" != "${GITHUB_REF}" ]; then
|
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
|
||||||
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"
|
echo "channel=main" >> "$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"
|
||||||
@@ -737,12 +744,21 @@ jobs:
|
|||||||
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
||||||
|
|
||||||
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
||||||
# The identity tag names this artifact's CONTENT — r-<revision>, the
|
# Does the image the channel tag already points at carry THIS commit's
|
||||||
# commit its shipped files last changed in, plus the channel for images
|
# revision? If so the bytes this job would produce are already published
|
||||||
# that bake one in. If the registry already carries it, the bytes this
|
# and the build is pure waste: the remaining tags get repointed at that
|
||||||
# job would produce are already published and the build is pure waste:
|
# existing manifest instead, registry-side, in seconds.
|
||||||
# the channel and date tags get repointed at the 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
|
# 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.
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
||||||
@@ -753,44 +769,61 @@ jobs:
|
|||||||
# that skips a build that was actually needed; the worst case is paying
|
# that skips a build that was actually needed; the worst case is paying
|
||||||
# for a build we could have avoided.
|
# for a build we could have avoided.
|
||||||
#
|
#
|
||||||
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
# BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops
|
||||||
# whose source stops moving stops picking up base-image updates under
|
# picking up base-image updates. Milestone 318 removed the argument this
|
||||||
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
# used to need rather than answering it — with no version tags there is
|
||||||
# serving the bytes it served (fabledcurator-ml:2026.7.17 still
|
# no immutable name a refresh could contradict, and rule 145 already
|
||||||
# resolves to July's image), or it is not a pin — and family rule
|
# allows a rebuild with different contents to republish a MOVING tag.
|
||||||
# 145 already says where the refresh goes instead: a rebuild with
|
# So a refresh is just a build. A scheduled channel-only one is tracked
|
||||||
# different contents publishes only the MOVING tag, never the immutable
|
# separately (#3154); it does not belong in the push path.
|
||||||
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
||||||
# it does not belong in the push path.
|
|
||||||
- name: Is this content already published?
|
- name: Is this content already published?
|
||||||
id: reuse
|
id: reuse
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
||||||
CHANNEL: ${{ steps.tag.outputs.channel }}
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
ID=$(sh scripts/artifacts.sh identity ml "$CHANNEL")
|
DERIVED=$(sh scripts/artifacts.sh revision ml)
|
||||||
echo "identity=$ID" >> "$GITHUB_OUTPUT"
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# A tag-push builds a revision that main already published, so it
|
|
||||||
# must NOT claim the identity: image configs are not bit-reproducible
|
|
||||||
# (embedded timestamps), so re-pushing r-<rev> would point an
|
|
||||||
# immutable tag at fresh bytes — rule 145's exact prohibition. It
|
|
||||||
# publishes only its own :v… label and otherwise reuses.
|
|
||||||
if [ "$IS_TAG_PUSH" = "true" ]; then
|
|
||||||
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
else
|
|
||||||
echo "build_tags=$TAGS,$IMAGE:$ID" >> "$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
|
fi
|
||||||
|
|
||||||
if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
||||||
echo "hit=true" >> "$GITHUB_OUTPUT"
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is already published — skipping the build"
|
echo "reuse: already published — skipping the build"
|
||||||
else
|
else
|
||||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is not published — building"
|
echo "reuse: not published — building"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push ml image
|
- name: Build and push ml image
|
||||||
@@ -801,11 +834,16 @@ jobs:
|
|||||||
file: Dockerfile.ml
|
file: Dockerfile.ml
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.reuse.outputs.build_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
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
||||||
# rebuild. Each -t becomes another reference to the SAME manifest the
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
||||||
# identity tag holds, so :latest and the date pin are byte-identical to
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# what was published rather than a lookalike rebuild.
|
# published rather than a lookalike rebuild.
|
||||||
#
|
#
|
||||||
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
||||||
# rolling channel refreshes itself, so skipping a build must never mean
|
# rolling channel refreshes itself, so skipping a build must never mean
|
||||||
@@ -815,19 +853,46 @@ jobs:
|
|||||||
if: steps.reuse.outputs.hit == 'true'
|
if: steps.reuse.outputs.hit == 'true'
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml
|
||||||
IDENTITY: ${{ steps.reuse.outputs.identity }}
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
run: |
|
run: |
|
||||||
set -euf
|
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
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
||||||
# docker/build-push-action takes; imagetools wants a -t per ref.
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
||||||
ARGS=""
|
ARGS=""
|
||||||
IFS=,
|
IFS=,
|
||||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
for t in $TAGS; do
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
unset IFS
|
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
|
# shellcheck disable=SC2086
|
||||||
docker buildx imagetools create $ARGS "$IMAGE:$IDENTITY"
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
echo "repointed to $IMAGE:$IDENTITY: $TAGS"
|
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
|
||||||
@@ -847,12 +912,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# --- derived values, one line (milestone 313) ------------------------
|
# --- derived values, one line (milestone 313) ------------------------
|
||||||
# These stopped being shadow output at step 3: `tag` is published on
|
# These stopped being shadow output at step 3. `revision` decides
|
||||||
# main and `revision` decides whether the build below runs at all. This
|
# whether the build below runs at all and `version` is what the image
|
||||||
# step prints all three anyway, because the load-bearing steps each
|
# reports about itself; the load-bearing steps each print only the one
|
||||||
# print only the one they use, and on dev the date tag is computed
|
# they use, so this is the only place the pair appears together. When a
|
||||||
# nowhere else. When a build is skipped or a pin looks wrong, this is
|
# build is skipped, this is the line that says what the commit derived.
|
||||||
# the line that says what the commit derived.
|
|
||||||
#
|
#
|
||||||
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
# Still diagnostic, so it still must not fail the build — no `set -e`,
|
||||||
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
# and every derivation falls back to UNAVAILABLE. A broken echo must
|
||||||
@@ -863,48 +927,25 @@ jobs:
|
|||||||
# STILL. If web moves, its path set is too wide.
|
# STILL. If web moves, its path set is too wide.
|
||||||
# * a push touching only docs moves nothing.
|
# * a push touching only docs moves nothing.
|
||||||
# * a push touching the extension moves the extension AND web, since
|
# * 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
|
# web bakes in the XPI. If web does not move, its set is too narrow:
|
||||||
# — the direction that serves stale bytes on a pin.
|
# the reuse check hits, and the channel serves a web image bundling
|
||||||
|
# the PREVIOUS XPI while the freshly signed one is orphaned (#3156).
|
||||||
# * dev and main derive the same values for the same source.
|
# * dev and main derive the same values for the same source.
|
||||||
- name: Report the derived artifact version
|
- name: Report the derived artifact version
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
A=agent
|
A=agent
|
||||||
T=$(sh scripts/artifacts.sh tag "$A" 2>&1 || echo UNAVAILABLE)
|
|
||||||
V=$(sh scripts/artifacts.sh version "$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)
|
R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE)
|
||||||
echo "derived: artifact=$A tag=$T version=$V revision=$R sha=$GITHUB_SHA"
|
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)
|
||||||
# The pinnable tag (milestone 313 step 3): YYYY.M.D of the commit
|
# Mirrors build-web's tag list; see the comment there.
|
||||||
# THIS artifact's shipped files last changed in. Day precision is
|
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
# deliberate — same-day work is not something worth pinning, so a
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:latest,git.fabledsword.com/bvandeusen/fabledcurator-agent:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
# 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#refs/tags/}" != "${GITHUB_REF}" ]; then
|
|
||||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "channel=main" >> "$GITHUB_OUTPUT"
|
|
||||||
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
|
||||||
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"
|
echo "channel=main" >> "$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"
|
||||||
@@ -920,12 +961,21 @@ jobs:
|
|||||||
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
|
||||||
|
|
||||||
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
# --- reuse-if-published (milestone 313, step 4) ----------------------
|
||||||
# The identity tag names this artifact's CONTENT — r-<revision>, the
|
# Does the image the channel tag already points at carry THIS commit's
|
||||||
# commit its shipped files last changed in, plus the channel for images
|
# revision? If so the bytes this job would produce are already published
|
||||||
# that bake one in. If the registry already carries it, the bytes this
|
# and the build is pure waste: the remaining tags get repointed at that
|
||||||
# job would produce are already published and the build is pure waste:
|
# existing manifest instead, registry-side, in seconds.
|
||||||
# the channel and date tags get repointed at the 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
|
# 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.
|
# web and ml, and a merge to main from rebuilding what dev already built.
|
||||||
@@ -936,44 +986,61 @@ jobs:
|
|||||||
# that skips a build that was actually needed; the worst case is paying
|
# that skips a build that was actually needed; the worst case is paying
|
||||||
# for a build we could have avoided.
|
# for a build we could have avoided.
|
||||||
#
|
#
|
||||||
# BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact
|
# BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops
|
||||||
# whose source stops moving stops picking up base-image updates under
|
# picking up base-image updates. Milestone 318 removed the argument this
|
||||||
# its pinned tag. That is what a pin MEANS — a date tag has to keep
|
# used to need rather than answering it — with no version tags there is
|
||||||
# serving the bytes it served (fabledcurator-agent:2026.7.17 still
|
# no immutable name a refresh could contradict, and rule 145 already
|
||||||
# resolves to July's image), or it is not a pin — and family rule
|
# allows a rebuild with different contents to republish a MOVING tag.
|
||||||
# 145 already says where the refresh goes instead: a rebuild with
|
# So a refresh is just a build. A scheduled channel-only one is tracked
|
||||||
# different contents publishes only the MOVING tag, never the immutable
|
# separately (#3154); it does not belong in the push path.
|
||||||
# one. A scheduled channel-only refresh is tracked separately (#3154);
|
|
||||||
# it does not belong in the push path.
|
|
||||||
- name: Is this content already published?
|
- name: Is this content already published?
|
||||||
id: reuse
|
id: reuse
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
||||||
CHANNEL: ${{ steps.tag.outputs.channel }}
|
CHANNEL: ${{ steps.tag.outputs.channel }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
IS_TAG_PUSH: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
ID=$(sh scripts/artifacts.sh identity agent "$CHANNEL")
|
DERIVED=$(sh scripts/artifacts.sh revision agent)
|
||||||
echo "identity=$ID" >> "$GITHUB_OUTPUT"
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# A tag-push builds a revision that main already published, so it
|
|
||||||
# must NOT claim the identity: image configs are not bit-reproducible
|
|
||||||
# (embedded timestamps), so re-pushing r-<rev> would point an
|
|
||||||
# immutable tag at fresh bytes — rule 145's exact prohibition. It
|
|
||||||
# publishes only its own :v… label and otherwise reuses.
|
|
||||||
if [ "$IS_TAG_PUSH" = "true" ]; then
|
|
||||||
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
else
|
|
||||||
echo "build_tags=$TAGS,$IMAGE:$ID" >> "$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
|
fi
|
||||||
|
|
||||||
if docker buildx imagetools inspect "$IMAGE:$ID" >/dev/null 2>&1; then
|
if [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then
|
||||||
echo "hit=true" >> "$GITHUB_OUTPUT"
|
echo "hit=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is already published — skipping the build"
|
echo "reuse: already published — skipping the build"
|
||||||
else
|
else
|
||||||
echo "hit=false" >> "$GITHUB_OUTPUT"
|
echo "hit=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "reuse: $IMAGE:$ID is not published — building"
|
echo "reuse: not published — building"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push agent image
|
- name: Build and push agent image
|
||||||
@@ -984,11 +1051,16 @@ jobs:
|
|||||||
file: agent/Dockerfile
|
file: agent/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.reuse.outputs.build_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
|
# Registry-side manifest copy: no layer transfer, no local daemon, no
|
||||||
# rebuild. Each -t becomes another reference to the SAME manifest the
|
# rebuild. Each -t becomes another reference to the SAME manifest the
|
||||||
# identity tag holds, so :latest and the date pin are byte-identical to
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# what was published rather than a lookalike rebuild.
|
# published rather than a lookalike rebuild.
|
||||||
#
|
#
|
||||||
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
# Runs on EVERY reuse, which is what keeps family rule 146 true: a
|
||||||
# rolling channel refreshes itself, so skipping a build must never mean
|
# rolling channel refreshes itself, so skipping a build must never mean
|
||||||
@@ -998,16 +1070,43 @@ jobs:
|
|||||||
if: steps.reuse.outputs.hit == 'true'
|
if: steps.reuse.outputs.hit == 'true'
|
||||||
env:
|
env:
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-agent
|
||||||
IDENTITY: ${{ steps.reuse.outputs.identity }}
|
SOURCE: ${{ steps.reuse.outputs.channel_ref }}
|
||||||
TAGS: ${{ steps.tag.outputs.tags }}
|
TAGS: ${{ steps.tag.outputs.tags }}
|
||||||
run: |
|
run: |
|
||||||
set -euf
|
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
|
# steps.tag emits ONE comma-separated list, because that is the shape
|
||||||
# docker/build-push-action takes; imagetools wants a -t per ref.
|
# docker/build-push-action takes; imagetools wants a -t per ref.
|
||||||
ARGS=""
|
ARGS=""
|
||||||
IFS=,
|
IFS=,
|
||||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
for t in $TAGS; do
|
||||||
|
[ "$t" = "$SOURCE" ] && continue
|
||||||
|
ARGS="$ARGS -t $t"
|
||||||
|
done
|
||||||
unset IFS
|
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
|
# shellcheck disable=SC2086
|
||||||
docker buildx imagetools create $ARGS "$IMAGE:$IDENTITY"
|
docker buildx imagetools create $ARGS "$SOURCE"
|
||||||
echo "repointed to $IMAGE:$IDENTITY: $TAGS"
|
echo "repointed from $SOURCE:$ARGS"
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ jobs:
|
|||||||
- name: Ruff lint
|
- name: Ruff lint
|
||||||
# agent/ included so the GPU-agent is linted before its image is built
|
# agent/ included so the GPU-agent is linted before its image is built
|
||||||
# (build.yml only `docker build`s it — this is where it gets checked).
|
# (build.yml only `docker build`s it — this is where it gets checked).
|
||||||
run: ruff check backend/ tests/ alembic/ agent/
|
# scripts/ likewise: release_notes.py runs only on a tag push, so a
|
||||||
|
# syntax or import error there would otherwise surface at the one
|
||||||
|
# moment nobody wants to debug a workflow.
|
||||||
|
run: ruff check backend/ tests/ alembic/ agent/ scripts/
|
||||||
- name: Agent syntax check
|
- name: Agent syntax check
|
||||||
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
||||||
# CI image, so we can't import it — but compileall parses every module,
|
# CI image, so we can't import it — but compileall parses every module,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# A `v*` tag publishes a changelog. It does NOT build anything.
|
||||||
|
#
|
||||||
|
# Milestone 318 step 2 removed the tag trigger from build.yml: by the time
|
||||||
|
# anyone tags a commit, `main` has already built and published it, and a
|
||||||
|
# rebuild would re-push `:c-<sha>` — which rule 145 forbids even when the
|
||||||
|
# source matches, since image configs carry timestamps and "same source" does
|
||||||
|
# not mean "same manifest". That left the tag with no consequence at all.
|
||||||
|
#
|
||||||
|
# This is the consequence it has instead. Step 6 put the derived version in the
|
||||||
|
# Settings footer, so an operator can say WHICH build they are running; this
|
||||||
|
# says what is IN it that was not in the one they ran last month. Both halves
|
||||||
|
# of one question (note #3127 §5).
|
||||||
|
#
|
||||||
|
# Nothing here runs on a schedule and nothing auto-tags on merge. Release tags
|
||||||
|
# are bookmarks — cut one when you will want to point at that day by name,
|
||||||
|
# otherwise don't (note #3127 §0). FC went twelve weeks between v26.06.04.0 and
|
||||||
|
# the next one and nothing was wrong. A schedule would turn an optional
|
||||||
|
# bookmark back into ceremony, which is the thing this milestone is removing.
|
||||||
|
#
|
||||||
|
# Cutting the tag is an explicit operator action under rule 2 ("`main` — never
|
||||||
|
# without explicit request", which since 2026-08-28 covers PR, merge and tag
|
||||||
|
# alike). This lane only decides what happens once they do.
|
||||||
|
#
|
||||||
|
# Requires repo secret RELEASE_TOKEN with the `write:release` scope — the same
|
||||||
|
# PAT build.yml uses for the ext-<version> XPI asset cache.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
# So a release body can be regenerated after the fact — the publisher PATCHes
|
||||||
|
# an existing release rather than falling through on a conflict, so re-running
|
||||||
|
# this on a tag rewrites the body instead of silently keeping the first one
|
||||||
|
# (note #3127 §6.7).
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to (re)publish notes for'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Load-bearing twice over: the previous release is found by walking
|
||||||
|
# ancestry back through the tag graph, and the cross-check against
|
||||||
|
# the derived web version calls artifacts.sh, which reads commit
|
||||||
|
# times. A shallow clone would find no previous tag and emit the
|
||||||
|
# entire history as the changelog — plausible-looking and wrong.
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
# The `:c-<sha>` rollback refs are only real if `main` built this commit.
|
||||||
|
# The script checks that against origin/main and downgrades the claim to
|
||||||
|
# "unverified" when it cannot resolve one; fetching it here means that
|
||||||
|
# downgrade stays an actual signal instead of firing on every release.
|
||||||
|
- name: Make main's history resolvable
|
||||||
|
run: git fetch --no-tags --quiet origin +main:refs/remotes/origin/main || true
|
||||||
|
|
||||||
|
# TAG goes through the environment, not through `${{ }}` inside the
|
||||||
|
# run block. The value is operator-supplied, and an expression expanded
|
||||||
|
# into a shell line is expanded BEFORE the shell sees it — there is no
|
||||||
|
# quoting that makes that safe. On a tag push it is empty and the script
|
||||||
|
# falls back to GITHUB_REF.
|
||||||
|
- name: Publish the derived changelog
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
TAG: ${{ github.event.inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -n "${TAG:-}" ]; then
|
||||||
|
python3 scripts/release_notes.py "$TAG"
|
||||||
|
else
|
||||||
|
python3 scripts/release_notes.py
|
||||||
|
fi
|
||||||
+9
-3
@@ -58,11 +58,17 @@ COPY --from=frontend-builder /build/dist ./frontend/dist
|
|||||||
# exactly the shape every reader already has to handle.
|
# exactly the shape every reader already has to handle.
|
||||||
#
|
#
|
||||||
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
|
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
|
||||||
# this is the one value that differs between the dev and main builds of
|
# these are the values that differ between builds of otherwise identical
|
||||||
# identical source — put it any earlier and the two channels could never share
|
# source — put them any earlier and the two channels could never share a
|
||||||
# a cached pip install.
|
# cached pip install.
|
||||||
|
#
|
||||||
|
# FC_VERSION is what the instance reports about itself in the UI. Since
|
||||||
|
# milestone 318 stopped publishing version image tags, that self-report is
|
||||||
|
# the only answer to "which build is this?" — nothing else names it.
|
||||||
ARG FC_CHANNEL=""
|
ARG FC_CHANNEL=""
|
||||||
ENV FC_CHANNEL=${FC_CHANNEL}
|
ENV FC_CHANNEL=${FC_CHANNEL}
|
||||||
|
ARG FC_VERSION=""
|
||||||
|
ENV FC_VERSION=${FC_VERSION}
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ In production. `main` is continuously deployed — every merge to `main` builds
|
|||||||
and publishes `:latest` images, so whatever is on `main` is what is running.
|
and publishes `:latest` images, so whatever is on `main` is what is running.
|
||||||
Day-to-day work happens on `dev`, which publishes `:dev` images.
|
Day-to-day work happens on `dev`, which publishes `:dev` images.
|
||||||
|
|
||||||
|
## Versions and tags
|
||||||
|
|
||||||
|
Three image tags exist, and no others:
|
||||||
|
|
||||||
|
| Tag | Branch | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `:latest` | `main` | Production. Moves on every merge. |
|
||||||
|
| `:c-<sha>` | `main` | Immutable — the rollback unit, all three images together. |
|
||||||
|
| `:dev` | `dev` | The rolling test channel. Moves on every push. |
|
||||||
|
|
||||||
|
There are deliberately **no version tags**. Nothing pins one, and a per-build
|
||||||
|
name nobody reads is upkeep for a model FC does not run (family rule 145; the
|
||||||
|
reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
|
||||||
|
|
||||||
|
Each artifact still has a version, derived rather than chosen: the commit time
|
||||||
|
of the newest change to that artifact's *own* shipped files, as
|
||||||
|
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
|
||||||
|
a push touching only `agent/` re-versions the agent and leaves web and ml
|
||||||
|
alone, and CI skips the builds whose content did not move.
|
||||||
|
|
||||||
|
Because no registry name carries it, the running instance's own report is the
|
||||||
|
only answer to "which build is this?". The foot of Settings shows
|
||||||
|
`FabledCurator 2026.08.29.0201 · dev`, and `/api/health` returns the same two
|
||||||
|
fields.
|
||||||
|
|
||||||
|
Release tags are optional bookmarks — FC went twelve weeks without one and
|
||||||
|
nothing was wrong. Pushing `v<version>` publishes a Forgejo release listing the
|
||||||
|
commits since the previous tag; it builds no image.
|
||||||
|
|
||||||
## What's in here
|
## What's in here
|
||||||
|
|
||||||
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
||||||
@@ -52,9 +81,10 @@ FabledCurator is designed to run inside a self-hosted homelab environment over p
|
|||||||
|
|
||||||
## CI / Forgejo setup
|
## CI / Forgejo setup
|
||||||
|
|
||||||
Three workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
||||||
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
||||||
content verification), and `build.yml` (sign + publish).
|
content verification), `build.yml` (sign + publish), and `release.yml`, which
|
||||||
|
runs only on a `v*` tag and publishes a changelog without building anything.
|
||||||
|
|
||||||
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
||||||
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
||||||
@@ -71,8 +101,12 @@ The repo expects one secret:
|
|||||||
|
|
||||||
Generate at https://git.fabledsword.com/user/settings/applications. The injected `GITHUB_TOKEN` cannot be used because it lacks `write:package`.
|
Generate at https://git.fabledsword.com/user/settings/applications. The injected `GITHUB_TOKEN` cannot be used because it lacks `write:package`.
|
||||||
|
|
||||||
AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`; it runs on
|
AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`.
|
||||||
`main` only and is cached per version, since AMO rejects a re-signed version.
|
It runs on **both** channels and is cached per version: because the version is
|
||||||
|
derived from commit time, `dev` and `main` derive the same number for the same
|
||||||
|
source, so `main` finds `dev`'s signature already cached and makes no second AMO
|
||||||
|
call. That cache is why signing must be one-shot — AMO rejects a re-signed
|
||||||
|
version.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..build_info import FC_CHANNEL as _FC_CHANNEL
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import AppSetting
|
from ..models import AppSetting
|
||||||
from ..services.extension_service import (
|
from ..services.extension_service import (
|
||||||
@@ -33,10 +33,13 @@ XPI_DIR = Path("/app/frontend/dist/extension")
|
|||||||
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
|
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
|
||||||
|
|
||||||
# Which channel this image belongs to — "dev" or "main" — baked in at build
|
# Which channel this image belongs to — "dev" or "main" — baked in at build
|
||||||
# time from the FC_CHANNEL build arg (milestone 271 step 7). Empty for a local
|
# time (milestone 271 step 7). Read from build_info rather than the environment
|
||||||
# build, or for any image predating the field. Tests override by monkeypatching
|
# a second time: /api/health reports the same value, and two independent
|
||||||
# this constant, same as XPI_DIR above.
|
# `os.environ.get` calls are two things that can drift.
|
||||||
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
|
#
|
||||||
|
# Still bound as a module-level name here, so tests monkeypatch
|
||||||
|
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
|
||||||
|
FC_CHANNEL = _FC_CHANNEL
|
||||||
|
|
||||||
|
|
||||||
async def _ext_key_required(session) -> bool:
|
async def _ext_key_required(session) -> bool:
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
"""Health endpoint — no DB or Redis touch; just liveness."""
|
"""Health endpoint — no DB or Redis touch; liveness, plus the build's identity.
|
||||||
|
|
||||||
|
The identity rides here rather than on a route of its own because it answers
|
||||||
|
at the same cost: two module constants, no I/O, nothing that can be slow or
|
||||||
|
fail. It is also already fetched app-wide — TopNav calls `refreshHealth` on
|
||||||
|
mount — so a separate endpoint would mean a second request for two strings.
|
||||||
|
|
||||||
|
Both fields are OMITTED when unset rather than sent empty. See build_info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..build_info import FC_CHANNEL, FC_VERSION
|
||||||
|
|
||||||
|
|
||||||
async def get_health():
|
async def get_health():
|
||||||
return {"status": "ok"}, 200
|
body = {"status": "ok"}
|
||||||
|
if FC_VERSION:
|
||||||
|
body["version"] = FC_VERSION
|
||||||
|
if FC_CHANNEL:
|
||||||
|
body["channel"] = FC_CHANNEL
|
||||||
|
return body, 200
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""What this build IS — stamped at image build time, not configurable.
|
||||||
|
|
||||||
|
Deliberately separate from `config.py`. Those are operator settings, read from
|
||||||
|
the environment and meant to be changed. These describe the artifact itself and
|
||||||
|
are baked in by CI (the `FC_VERSION` / `FC_CHANNEL` build args); an operator
|
||||||
|
setting them by hand is not a supported thing to do, it is just how a value
|
||||||
|
gets from the build into the running process.
|
||||||
|
|
||||||
|
**Absent rather than empty when unknown.** A locally-built image has no version,
|
||||||
|
and neither did any image predating the field — one spelling of "cannot say",
|
||||||
|
which every reader already has to handle, instead of a second one to
|
||||||
|
special-case (note #3127 §7).
|
||||||
|
|
||||||
|
**Why this matters more than it used to.** Milestone 318 stopped publishing
|
||||||
|
version image tags, so a running instance's self-report is now the *only*
|
||||||
|
answer to "which build is this?" — there is no registry name left to check it
|
||||||
|
against. A wrong value here has nothing to contradict it. That is why the UI
|
||||||
|
renders `unknown` rather than a blank or a plausible default: an empty footer
|
||||||
|
reads as "no version", which is a different and false claim.
|
||||||
|
|
||||||
|
The channel lives BESIDE the version and is never folded into it (rule 149).
|
||||||
|
A `-dev` suffix would be parsed by the extension's comparator as a segment
|
||||||
|
worth 0, making every dev build compare equal to every other — issue #2993's
|
||||||
|
exact failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
|
||||||
|
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
|
||||||
+67
-23
@@ -9,14 +9,17 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
|||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
- python 3.14
|
- python 3.14
|
||||||
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
|
- ruff (analyzer for `backend/`, `tests/`, `alembic/`, `agent/`, `scripts/`)
|
||||||
- node (frontend job: `npm install` + vitest + vite build)
|
- node (frontend job: `npm install` + vitest + vite build)
|
||||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Fabled-Git registry push)
|
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml, build-agent — Fabled-Git registry push, and `imagetools inspect`/`create` for the reuse path)
|
||||||
|
|
||||||
## Secondary runtime image
|
## Secondary runtime image
|
||||||
|
|
||||||
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
|
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
|
||||||
|
|
||||||
|
`.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else
|
||||||
|
and installs nothing: it needs git and stdlib python, and builds no image.
|
||||||
|
|
||||||
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
|
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
|
||||||
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
|
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
|
||||||
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
|
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
|
||||||
@@ -54,13 +57,19 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
|
|||||||
shims to production code — the libs ship as `background.scripts`, not ES
|
shims to production code — the libs ship as `background.scripts`, not ES
|
||||||
modules, so the specs exercise exactly the bytes packaged into the XPI.
|
modules, so the specs exercise exactly the bytes packaged into the XPI.
|
||||||
- **`extension/scripts/packaging.sh` is the single definition of what ships
|
- **`extension/scripts/packaging.sh` is the single definition of what ships
|
||||||
inside the XPI.** Two consumers read from it rather than keeping their own
|
inside the XPI.** Three consumers read from it rather than keeping their own
|
||||||
copy: web-ext's `--ignore-files` (`extension/package.json`), and the `git log`
|
copy: web-ext's `--ignore-files` (`extension/package.json`), the `git log`
|
||||||
pathspec inside the script's own version derivation. It was three until
|
pathspec inside the script's own version derivation, and `scripts/artifacts.sh`,
|
||||||
2026-08-27 — `ci.yml`'s `extension-version` guard held the third and went when
|
which appends the extension's set to web's because the web image bundles the
|
||||||
the manual bump it guarded did (milestone 271 step 5). Hand-kept copies of
|
signed XPI. Hand-kept copies of that one fact is what allowed issue #2397, so
|
||||||
that one fact is what allowed issue #2397, so `extension/test/version.spec.js`
|
`extension/test/version.spec.js` asserts no workflow has reintroduced a
|
||||||
asserts no workflow has reintroduced a literal `:(exclude)extension/…`.
|
literal `:(exclude)extension/…`.
|
||||||
|
- **Packaged and version-relevant are two different sets** (#3156). `scripts/`
|
||||||
|
is excluded from the XPI and is NOT excluded from the version derivation,
|
||||||
|
because `packaging.sh` decides the version string stamped into the packaged
|
||||||
|
`manifest.json`. The membership test is *"can changing this file change the
|
||||||
|
published bytes?"*, not *"is this file copied in?"* — which is why the script
|
||||||
|
keeps two lists rather than one.
|
||||||
- **The shipped extension version is derived, not committed.** It is the commit
|
- **The shipped extension version is derived, not committed.** It is the commit
|
||||||
TIME of the newest packaged-extension change (minutes since 2020-01-01, per
|
TIME of the newest packaged-extension change (minutes since 2020-01-01, per
|
||||||
family rule 149 — never a commit count, which orders by branch rather than by
|
family rule 149 — never a commit count, which orders by branch rather than by
|
||||||
@@ -68,20 +77,55 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
|
|||||||
`extension/manifest.json` + `package.json` in the working tree before signing;
|
`extension/manifest.json` + `package.json` in the working tree before signing;
|
||||||
the stamp is never committed. Treat the version in the repo as a base: only
|
the stamp is never committed. Treat the version in the repo as a base: only
|
||||||
its MAJOR.MINOR is read, and its patch component is inert.
|
its MAJOR.MINOR is read, and its patch component is inert.
|
||||||
- Every job that calls `packaging.sh version` checks out with `fetch-depth: 0` —
|
- Every job that derives anything checks out with `fetch-depth: 0` — all four
|
||||||
`build.yml`'s `sign-extension` and `build-web`, and `ci.yml`'s
|
`build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test`
|
||||||
`extension-version`. A depth-1 clone sees one commit and derives a wrong,
|
(for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and
|
||||||
too-low value **rather than failing**, so the full-history checkout is
|
`release.yml`, which additionally walks the tag graph. A depth-1 clone sees
|
||||||
load-bearing rather than incidental.
|
one commit and derives a wrong, too-low value **rather than failing**, so the
|
||||||
- **`FC_CHANNEL` is a build arg, not a runtime setting.** `build.yml` passes
|
full-history checkout is load-bearing rather than incidental.
|
||||||
`dev` / `main` to the web image only (the ml and agent images have nothing to
|
- **`scripts/artifacts.sh` is the same shape one level up: one definition per
|
||||||
report it to), and `/api/extension/manifest` reports it beside the version so
|
artifact of what it is built from, and the two values derived from it.**
|
||||||
an install can be traced to a channel. It is declared LAST in the Dockerfile
|
`revision` (12 hex of the newest commit touching that set) and `version`
|
||||||
on purpose: an ARG invalidates every layer below it, and this is the one value
|
(`YYYY.MM.DD.HHMM` UTC, rule 148). Four artifacts, four independent answers,
|
||||||
that differs between the dev and main builds of identical source, so placing
|
so a push touching only `agent/` leaves web and ml alone.
|
||||||
it earlier would stop the two channels ever sharing a cached `pip install`.
|
`tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY
|
||||||
Empty by default — a local build then reports no channel at all rather than
|
source is covered, so adding a COPY without updating the script fails CI.
|
||||||
claiming one.
|
- **A file that DECIDES an artifact's identity belongs in its set even though it
|
||||||
|
is copied into nothing** — `packaging.sh` for the extension and web (#3156),
|
||||||
|
and `artifacts.sh` itself for web (#3202), which decides the `FC_VERSION`
|
||||||
|
baked into that image. Only web needs the second entry: every artifact stamps
|
||||||
|
a revision, but a revision has a backstop (a changed derivation stops matching
|
||||||
|
the published label and forces a rebuild) and a version has none, because
|
||||||
|
nothing compares it to anything. `tests/test_artifact_paths.py`'s `DERIVERS`
|
||||||
|
table is the guard.
|
||||||
|
- **Builds are skipped when the content is already published.** Each image
|
||||||
|
carries its revision as an `fc.revision` LABEL, and `build.yml` reads that
|
||||||
|
label back off the moving channel tag (`imagetools inspect --format`). Equal
|
||||||
|
to the derived revision means the bytes are already published, so the job
|
||||||
|
repoints the remaining tags at the existing manifest instead of rebuilding.
|
||||||
|
Two things this depends on: an inspect that errors for ANY reason reads as a
|
||||||
|
MISS so no needed build is ever skipped, and the repoint must EXCLUDE the
|
||||||
|
source tag — `imagetools create` wraps its source in a manifest index, and
|
||||||
|
config labels do not resolve through an index, so writing the channel tag
|
||||||
|
from itself destroys the label the next run reads (#3183).
|
||||||
|
- **`FC_CHANNEL` and `FC_VERSION` are build args, not runtime settings.**
|
||||||
|
`build.yml` passes them to the web image only — the ml and agent images have
|
||||||
|
nothing to report them to. `/api/health` returns both, the foot of Settings
|
||||||
|
renders them, and `/api/extension/manifest` reports the channel beside the
|
||||||
|
extension version so an install can be traced to a channel. With no version image tags, that
|
||||||
|
self-report is the ONLY answer to "which build is this?" — which is why a
|
||||||
|
missing version renders `unknown` rather than a blank: an empty footer reads
|
||||||
|
as "no version", a different and false claim.
|
||||||
|
Both are declared LAST in the Dockerfile on purpose: an ARG invalidates every
|
||||||
|
layer below it, and these are the values that differ between the dev and main
|
||||||
|
builds of identical source, so placing them earlier would stop the two
|
||||||
|
channels ever sharing a cached `pip install`. Empty by default — a local build
|
||||||
|
then reports nothing rather than claiming a channel it is not on.
|
||||||
|
- **The channel is never folded into the version.** A `-dev` suffix makes the
|
||||||
|
extension's per-segment `parseInt` comparator read that segment as 0, so every
|
||||||
|
dev build compares equal to every other — issue #2993 exactly (rule 149).
|
||||||
|
`frontend/test/systemBuild.spec.js` pins the rendered version to the bare
|
||||||
|
number.
|
||||||
- Callers MUST `set -f` before substituting the script's output. Without it the
|
- Callers MUST `set -f` before substituting the script's output. Without it the
|
||||||
shell expands `test/**` against the working tree and silently narrows the
|
shell expands `test/**` against the working tree and silently narrows the
|
||||||
pattern to whatever files exist at that moment — a failure that looks like
|
pattern to whatever files exist at that moment — a failure that looks like
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@@ -107,15 +107,24 @@ describe('consumers delegate rather than keeping their own copy', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.yml']
|
// Read from disk rather than listed by hand. The point of this assertion is
|
||||||
|
// that it survives consumers coming and going, and a hardcoded list is the
|
||||||
|
// one part of it that cannot — release.yml (milestone 318 step 7) would have
|
||||||
|
// joined the directory without joining the check.
|
||||||
|
const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) =>
|
||||||
|
f.endsWith('.yml')
|
||||||
|
)
|
||||||
|
|
||||||
it('no workflow hardcodes the packaged-file set', () => {
|
it('no workflow hardcodes the packaged-file set', () => {
|
||||||
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
|
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
|
||||||
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
|
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
|
||||||
// set today, and nothing should start to: a literal :(exclude)extension/...
|
// set today, and nothing should start to: a literal :(exclude)extension/...
|
||||||
// in a workflow means someone bypassed the shared definition, which is
|
// in a workflow means someone bypassed the shared definition, which is
|
||||||
// exactly the drift #2397 was about. Asserted across all three rather than
|
// exactly the drift #2397 was about.
|
||||||
// against one named consumer, so it keeps holding as consumers come and go.
|
expect(
|
||||||
|
WORKFLOWS.length,
|
||||||
|
'no workflows found — the glob is not looking where it thinks'
|
||||||
|
).toBeGreaterThan(2)
|
||||||
for (const wf of WORKFLOWS) {
|
for (const wf of WORKFLOWS) {
|
||||||
const text = readText('..', '.forgejo', 'workflows', wf)
|
const text = readText('..', '.forgejo', 'workflows', wf)
|
||||||
expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
|
expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ import { useApi } from '../composables/useApi.js'
|
|||||||
export const useSystemStore = defineStore('system', () => {
|
export const useSystemStore = defineStore('system', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const healthy = ref(null) // null=unknown, true=ok, false=down
|
const healthy = ref(null) // null=unknown, true=ok, false=down
|
||||||
|
// What the instance says it is. Since milestone 318 stopped publishing
|
||||||
|
// version image tags, this is the only answer to "which build is this?" —
|
||||||
|
// there is no registry name left to check it against.
|
||||||
|
//
|
||||||
|
// Three states, and collapsing any two of them would lie:
|
||||||
|
// buildLoaded=false we have not asked yet -> render nothing
|
||||||
|
// buildLoaded=true, version='' the build cannot say -> render "unknown"
|
||||||
|
// buildLoaded=true, version=x this build is x
|
||||||
|
// A blank footer would read as "no version", which is a different claim.
|
||||||
|
const buildVersion = ref('')
|
||||||
|
const buildChannel = ref('')
|
||||||
|
const buildLoaded = ref(false)
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const statsLoading = ref(false)
|
const statsLoading = ref(false)
|
||||||
|
|
||||||
@@ -12,8 +24,17 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
try {
|
try {
|
||||||
const body = await api.get('/api/health')
|
const body = await api.get('/api/health')
|
||||||
healthy.value = body.status === 'ok'
|
healthy.value = body.status === 'ok'
|
||||||
|
// Absent means "cannot say" — the server omits these rather than
|
||||||
|
// sending empty strings, so `?? ''` preserves that rather than
|
||||||
|
// inventing a value for it.
|
||||||
|
buildVersion.value = body.version ?? ''
|
||||||
|
buildChannel.value = body.channel ?? ''
|
||||||
|
buildLoaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
healthy.value = false
|
healthy.value = false
|
||||||
|
// Deliberately NOT setting buildLoaded: a failed health call tells us
|
||||||
|
// nothing about the build, and claiming "unknown" would present a
|
||||||
|
// network blip as a defective image.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,5 +47,8 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { healthy, stats, statsLoading, refreshHealth, refreshStats }
|
return {
|
||||||
|
healthy, stats, statsLoading, refreshHealth, refreshStats,
|
||||||
|
buildVersion, buildChannel, buildLoaded,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,21 @@
|
|||||||
<MaintenancePanel />
|
<MaintenancePanel />
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
</v-window>
|
</v-window>
|
||||||
|
|
||||||
|
<!-- Which build is this? With no version image tags (milestone 318) the
|
||||||
|
instance's own report is the only answer, so it is shown rather than
|
||||||
|
hidden. The instinct to treat it as information disclosure does not
|
||||||
|
survive contact: the JS bundle and asset hashes fingerprint the build
|
||||||
|
anyway, and "I'm on 2026.08.28.1249" is the single most useful line in
|
||||||
|
a bug report.
|
||||||
|
|
||||||
|
Channel sits BESIDE the version, never inside it (rule 149) — a
|
||||||
|
`-dev` suffix would read as a 0 segment to the extension's comparator
|
||||||
|
and make every dev build compare equal (#2993). -->
|
||||||
|
<div v-if="system.buildLoaded" class="text-caption text-medium-emphasis text-center mt-8">
|
||||||
|
FabledCurator {{ system.buildVersion || 'unknown' }}
|
||||||
|
<span v-if="system.buildChannel"> · {{ system.buildChannel }}</span>
|
||||||
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useSystemStore } from '../src/stores/system.js'
|
||||||
|
|
||||||
|
// Which build am I running? Milestone 318 stopped publishing version image
|
||||||
|
// tags, so the instance's own report is the ONLY answer — there is no registry
|
||||||
|
// name left to check it against. That promotes this from a convenience to the
|
||||||
|
// mechanism, and it means the three states below have to stay distinct: a
|
||||||
|
// wrong answer here has nothing to contradict it.
|
||||||
|
//
|
||||||
|
// not asked yet -> render nothing
|
||||||
|
// asked, no version -> render "unknown"
|
||||||
|
// asked, has a version -> render it
|
||||||
|
//
|
||||||
|
// Collapsing the first two would show "unknown" during every page load, and
|
||||||
|
// collapsing either into a blank would read as "no version", which is a
|
||||||
|
// different and false claim.
|
||||||
|
|
||||||
|
function stubHealth(body, { fail = false } = {}) {
|
||||||
|
globalThis.fetch = vi.fn(async () => {
|
||||||
|
if (fail) throw new Error('network down')
|
||||||
|
return {
|
||||||
|
ok: true, status: 200, statusText: '200',
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('system store — build identity', () => {
|
||||||
|
beforeEach(() => setActivePinia(createPinia()))
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); delete globalThis.fetch })
|
||||||
|
|
||||||
|
it('starts having asked nothing, so the footer renders nothing', () => {
|
||||||
|
const s = useSystemStore()
|
||||||
|
expect(s.buildLoaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the version and channel the instance claims', async () => {
|
||||||
|
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
|
||||||
|
const s = useSystemStore()
|
||||||
|
await s.refreshHealth()
|
||||||
|
|
||||||
|
expect(s.buildLoaded).toBe(true)
|
||||||
|
expect(s.buildVersion).toBe('2026.08.28.1249')
|
||||||
|
expect(s.buildChannel).toBe('dev')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the channel OUT of the version string', async () => {
|
||||||
|
// The tempting shortcut is a `-dev` suffix. The extension's comparator
|
||||||
|
// parses each dotted segment with parseInt, so a suffixed segment reads as
|
||||||
|
// 0 and every dev build compares equal to every other — #2993 exactly
|
||||||
|
// (rule 149). If anyone ever "simplifies" by folding them together, the
|
||||||
|
// version stops being the bare derived number and this fails.
|
||||||
|
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
|
||||||
|
const s = useSystemStore()
|
||||||
|
await s.refreshHealth()
|
||||||
|
|
||||||
|
expect(s.buildVersion).toBe('2026.08.28.1249')
|
||||||
|
expect(s.buildVersion).not.toContain('dev')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an absent version as "cannot say", not as a value', async () => {
|
||||||
|
// A locally-built image, or one predating the field. The server omits the
|
||||||
|
// key rather than sending an empty string; `?? ''` must preserve that
|
||||||
|
// rather than inventing something. The view renders "unknown" from it.
|
||||||
|
stubHealth({ status: 'ok' })
|
||||||
|
const s = useSystemStore()
|
||||||
|
await s.refreshHealth()
|
||||||
|
|
||||||
|
expect(s.buildLoaded).toBe(true)
|
||||||
|
expect(s.buildVersion).toBe('')
|
||||||
|
expect(s.buildChannel).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a version with no channel without inventing one', async () => {
|
||||||
|
stubHealth({ status: 'ok', version: '2026.08.28.1249' })
|
||||||
|
const s = useSystemStore()
|
||||||
|
await s.refreshHealth()
|
||||||
|
|
||||||
|
expect(s.buildVersion).toBe('2026.08.28.1249')
|
||||||
|
expect(s.buildChannel).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not claim "unknown" when the health call itself failed', async () => {
|
||||||
|
// A network blip says nothing about the build. Marking it loaded here
|
||||||
|
// would present a transient failure as a defective image — and since
|
||||||
|
// nothing else names the build, there would be no second source to
|
||||||
|
// correct the impression.
|
||||||
|
stubHealth(null, { fail: true })
|
||||||
|
const s = useSystemStore()
|
||||||
|
await s.refreshHealth()
|
||||||
|
|
||||||
|
expect(s.healthy).toBe(false)
|
||||||
|
expect(s.buildLoaded).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
+68
-88
@@ -3,6 +3,11 @@
|
|||||||
# version derived from it. Milestone 313; generalises the shape
|
# version derived from it. Milestone 313; generalises the shape
|
||||||
# extension/scripts/packaging.sh established for the extension alone.
|
# extension/scripts/packaging.sh established for the extension alone.
|
||||||
#
|
#
|
||||||
|
# "Built from" is deliberately wider than "copied into". A file that DECIDES an
|
||||||
|
# artifact's identity is part of what that artifact is built from even though it
|
||||||
|
# never reaches the image — see DERIVER below, and #3156 for the same finding
|
||||||
|
# about packaging.sh.
|
||||||
|
#
|
||||||
# Four artifacts, four independent versions. An artifact whose shipped files
|
# Four artifacts, four independent versions. An artifact whose shipped files
|
||||||
# did not change keeps its version and does not rebuild — that is the whole
|
# 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
|
# point, and it is why each path set must match its Dockerfile rather than
|
||||||
@@ -56,25 +61,36 @@ ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic ale
|
|||||||
# it: this is deliberately NOT `agent/`.
|
# it: this is deliberately NOT `agent/`.
|
||||||
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
|
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
|
||||||
|
|
||||||
# Which artifacts bake the BUILD CHANNEL into the image, and therefore cannot
|
# This file. It is copied into no image and it is still part of what the web
|
||||||
# share a content identity across channels. The web image takes FC_CHANNEL as
|
# image is built from, because it DECIDES the FC_VERSION baked into that image
|
||||||
# a build-arg and reports it from /api/extension/manifest (milestone 271 step
|
# (#3202). Same finding as #3156 about packaging.sh, one level up.
|
||||||
# 7), so `main` and `dev` builds of one revision are genuinely different
|
|
||||||
# images — reusing the dev one on main would ship an instance that names
|
|
||||||
# itself `dev` forever.
|
|
||||||
#
|
#
|
||||||
# ml and agent take no build-args at all: one revision, one image, and a merge
|
# Why web and nothing else. Every artifact stamps `fc.revision`, but only web
|
||||||
# to main can reuse exactly what dev already built. That is not a detail, it is
|
# also stamps a version (build.yml line ~488 feeds `version web` to the
|
||||||
# most of what step 4 saves — merges would otherwise rebuild the agent's CUDA
|
# FC_VERSION build arg; ml and agent ask for `revision` alone, and the
|
||||||
# image to produce bytes that already exist.
|
# extension takes its version from packaging.sh). For a revision-only artifact
|
||||||
|
# this file needs no entry: any change to how the revision is COMPUTED changes
|
||||||
|
# the derived value, which then disagrees with the label on the published image
|
||||||
|
# and forces a rebuild. That mechanism is self-correcting because it compares
|
||||||
|
# against a string stamped into a real artifact.
|
||||||
#
|
#
|
||||||
# Extend this list if a second artifact ever gains a build-arg;
|
# The version is compared against nothing, so it has no such backstop. Before
|
||||||
# tests/test_artifact_identity.py reads the Dockerfiles and fails if it drifts.
|
# this entry, a change to cmd_version alone left every artifact's revision
|
||||||
CHANNELLED='web'
|
# untouched, the reuse check hit, the build was skipped, and the published
|
||||||
|
# image went on reporting the OLD version format — silently, until some
|
||||||
|
# unrelated commit happened to force a rebuild. Milestone 318 step 5 is the
|
||||||
|
# worked instance: b3989d0 and 5771fd5 share revision fb2c4d5b80be while the
|
||||||
|
# version moved 2026.8.28.1249 -> 2026.08.28.1249. It cost nothing only because
|
||||||
|
# FC_VERSION did not exist until one commit later.
|
||||||
|
#
|
||||||
|
# Named as a file, not as `scripts`: release_notes.py lives beside it and only
|
||||||
|
# READS derived values, so it decides nothing and must not re-version anything.
|
||||||
|
# A future script that derives an identity belongs here explicitly.
|
||||||
|
DERIVER='scripts/artifacts.sh'
|
||||||
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: artifacts.sh {paths|revision|version|tag} {web|ml|agent|extension}" >&2
|
echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2
|
||||||
echo " artifacts.sh identity {web|ml|agent} [channel]" >&2
|
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +103,7 @@ ext_paths() {
|
|||||||
|
|
||||||
cmd_paths() {
|
cmd_paths() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
web) echo "$WEB_PATHS $(ext_paths)" ;;
|
web) echo "$WEB_PATHS $DERIVER $(ext_paths)" ;;
|
||||||
ml) echo "$ML_PATHS" ;;
|
ml) echo "$ML_PATHS" ;;
|
||||||
agent) echo "$AGENT_PATHS" ;;
|
agent) echo "$AGENT_PATHS" ;;
|
||||||
extension) ext_paths ;;
|
extension) ext_paths ;;
|
||||||
@@ -116,83 +132,49 @@ fmt() {
|
|||||||
(cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1")
|
(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
|
# The IDENTITY of an artifact's content: the commit its shipped files last
|
||||||
# changed in. This — not the tag — is what decides whether a build can be
|
# changed in. This is what decides whether a build can be skipped.
|
||||||
# skipped, because the published tag is only day-precise and two different
|
#
|
||||||
# builds can share it.
|
# 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() {
|
cmd_revision() {
|
||||||
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
|
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
|
||||||
}
|
}
|
||||||
|
|
||||||
# The ORDERING KEY: full precision, YYYY.M.D.HHMM. Used by the extension,
|
# The VERSION: `YYYY.MM.DD.HHMM`, zero-padded, UTC. One shape across the whole
|
||||||
# where the value is what Firefox compares to decide whether an update exists
|
# family (note #3127 §1, rule 148) — the number an instance reports about
|
||||||
# — two same-day builds MUST be distinguishable or the second never reaches
|
# itself, and, with a `v` in front, the release tag naming the same build.
|
||||||
# anyone.
|
#
|
||||||
|
# Zero-padded since 2026-08-28. This stripped leading zeros until then, on the
|
||||||
|
# reasoning that every segment should read as a plain integer — which never
|
||||||
|
# held, since comparison strips them on parse anyway. Padding costs nothing,
|
||||||
|
# sorts lexically as well as numerically, and keeps this project emitting the
|
||||||
|
# same string as its siblings: unpadded, a `2026.8.28.1432` here sits beside a
|
||||||
|
# `2026.08.28.1432` there, two shapes one character apart. Two obviously
|
||||||
|
# different formats are safer than two nearly identical ones.
|
||||||
|
#
|
||||||
|
# Comparison is numeric per dot-segment, so `08` and `8` are equal and nothing
|
||||||
|
# already published is reordered by the change.
|
||||||
|
#
|
||||||
|
# HHMM is not decoration: it is what makes the value unique per build with no
|
||||||
|
# lookup. A date alone collides on the second build of a day, and resolving
|
||||||
|
# that needs a `.N` suffix, which needs asking the registry what already
|
||||||
|
# exists — at which point two lanes derive different answers for one source
|
||||||
|
# and the shared-signature property is lost.
|
||||||
cmd_version() {
|
cmd_version() {
|
||||||
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
||||||
printf '%s.%s.%s.%s\n' \
|
# One git call for the whole string rather than four and a sed. git's
|
||||||
"$(fmt "$sha" %Y)" \
|
# format-local takes the complete format, and doing it in pieces was only
|
||||||
"$(strip0 "$(fmt "$sha" %m)")" \
|
# ever there to strip the padding between them.
|
||||||
"$(strip0 "$(fmt "$sha" %d)")" \
|
fmt "$sha" '%Y.%m.%d.%H%M'
|
||||||
"$(strip0 "$(fmt "$sha" %H%M)")"
|
|
||||||
}
|
|
||||||
|
|
||||||
# The PUBLISHED IMAGE TAG: day precision, YYYY.M.D. Deliberately coarser than
|
|
||||||
# the ordering key, per the operator 2026-08-28 — same-day work is not
|
|
||||||
# something worth pinning, so a second build the same day replaces the first
|
|
||||||
# rather than accumulating a tag nobody would roll back to. Safe only because
|
|
||||||
# skip decisions key on cmd_revision, never on this.
|
|
||||||
cmd_tag() {
|
|
||||||
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
|
|
||||||
printf '%s.%s.%s\n' \
|
|
||||||
"$(fmt "$sha" %Y)" \
|
|
||||||
"$(strip0 "$(fmt "$sha" %m)")" \
|
|
||||||
"$(strip0 "$(fmt "$sha" %d)")"
|
|
||||||
}
|
|
||||||
|
|
||||||
# The CONTENT IDENTITY of a published image: an immutable tag naming exactly
|
|
||||||
# what a build of this commit would produce. build.yml asks the registry for it
|
|
||||||
# and, on a hit, skips the build entirely and repoints the channel and date
|
|
||||||
# tags at the manifest that is already there (milestone 313 step 4).
|
|
||||||
#
|
|
||||||
# It is deliberately NOT either of the other two values:
|
|
||||||
# * the date tag is day-precise and last-one-wins, so two different builds
|
|
||||||
# share it — it cannot answer "is this content published?".
|
|
||||||
# * the commit sha moves on every push, so it would never hit, which is the
|
|
||||||
# redundant rebuild this exists to remove.
|
|
||||||
#
|
|
||||||
# The revision does both jobs: it is content-unique AND stable across pushes
|
|
||||||
# that did not touch the artifact.
|
|
||||||
cmd_identity() {
|
|
||||||
_art=$1
|
|
||||||
_chan=${2:-}
|
|
||||||
case "$_art" in
|
|
||||||
web|ml|agent) ;;
|
|
||||||
extension)
|
|
||||||
echo "artifacts.sh: the extension is cached as an ext-<version> Forgejo release, not an image tag — use \`version\`" >&2
|
|
||||||
exit 2 ;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
for _c in $CHANNELLED; do
|
|
||||||
if [ "$_art" = "$_c" ]; then
|
|
||||||
# Refused rather than defaulted: an unqualified identity for a
|
|
||||||
# channelled artifact would let a dev image be reused as the main one.
|
|
||||||
if [ -z "$_chan" ]; then
|
|
||||||
echo "artifacts.sh: $_art bakes the channel into the image — identity needs one" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
printf 'r-%s-%s\n' "$(cmd_revision "$_art")" "$_chan"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf 'r-%s\n' "$(cmd_revision "$_art")"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ $# -ge 2 ] || usage
|
[ $# -ge 2 ] || usage
|
||||||
@@ -200,7 +182,5 @@ case "$1" in
|
|||||||
paths) cmd_paths "$2" ;;
|
paths) cmd_paths "$2" ;;
|
||||||
revision) cmd_revision "$2" ;;
|
revision) cmd_revision "$2" ;;
|
||||||
version) cmd_version "$2" ;;
|
version) cmd_version "$2" ;;
|
||||||
tag) cmd_tag "$2" ;;
|
|
||||||
identity) cmd_identity "$2" "${3:-}" ;;
|
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Publish a Forgejo release whose body is derived from git, not written by hand.
|
||||||
|
|
||||||
|
Milestone 318 step 2 took the build consequence away from a `v*` tag: `main`
|
||||||
|
has already built and published the commit by the time anyone tags it, and
|
||||||
|
rebuilding would re-push `:c-<sha>`, which rule 145 forbids even when the bytes
|
||||||
|
match. That left the tag with nothing to do. This gives it the job it has left.
|
||||||
|
|
||||||
|
**The half of the question a version string cannot answer.** Step 6 puts
|
||||||
|
`2026.08.28.2208` in the Settings footer, so an operator can say which build
|
||||||
|
they are running. They still cannot say what is in it that was not in the one
|
||||||
|
they ran last month. A dated release carrying the commits since the previous
|
||||||
|
one is the object that interprets the identifier (note #3127 §5).
|
||||||
|
|
||||||
|
**Derived, so it cannot drift.** The alternative is a hand-maintained
|
||||||
|
`CHANGELOG.md`, which goes aspirational the first time someone forgets — and
|
||||||
|
nothing ever catches it, because there is no second source to disagree with.
|
||||||
|
Every line below comes out of `git log` at publish time.
|
||||||
|
|
||||||
|
**Optional by construction.** Release tags are bookmarks: cut one when you will
|
||||||
|
want to point at that day by name, otherwise don't. FC went twelve weeks
|
||||||
|
without one and nothing was wrong (note #3127 §0). This runs on a tag push and
|
||||||
|
on nothing else — deliberately no schedule and no auto-tag on merge, either of
|
||||||
|
which would turn an optional bookmark back into ceremony.
|
||||||
|
|
||||||
|
## Finding the previous release
|
||||||
|
|
||||||
|
`git describe --exclude <this tag>`, which walks ANCESTRY, not a sorted list.
|
||||||
|
That is not fussiness: this repo's existing tags are the old `v26.05.22.0`
|
||||||
|
shape and the next one will be rule 148's `v2026.08.28.2208`. Lexicographically
|
||||||
|
`v2026...` sorts BEFORE `v26...` — every release from here on would report its
|
||||||
|
predecessor as itself-or-nothing and emit a changelog covering the entire
|
||||||
|
history. Ancestry is immune to the shape change, and it is also the more honest
|
||||||
|
question: "what is in this that was not in the last one" IS a reachability
|
||||||
|
question.
|
||||||
|
|
||||||
|
## Re-runs update, they do not fall through
|
||||||
|
|
||||||
|
Note #3127 §6.7: a publisher that POSTs and recovers the id from a `409` never
|
||||||
|
rewrites the body, so a re-run silently keeps the first version. Harmless for a
|
||||||
|
`v*` tag created once — and wrong the moment anything re-points. This one GETs
|
||||||
|
first and PATCHes when the release exists, so it is correct either way rather
|
||||||
|
than correct by luck (ThoughtSync #2182 is the same bug).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
API = "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator"
|
||||||
|
|
||||||
|
IMAGES = (
|
||||||
|
"git.fabledsword.com/bvandeusen/fabledcurator",
|
||||||
|
"git.fabledsword.com/bvandeusen/fabledcurator-ml",
|
||||||
|
"git.fabledsword.com/bvandeusen/fabledcurator-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rule 148: `v` + the artifact's own version, zero-padded, no `.N`, no lookup.
|
||||||
|
RULE_148 = re.compile(r"^v\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||||
|
|
||||||
|
# Past this, the list has stopped being something anyone reads. It is reached
|
||||||
|
# in exactly one situation — no previous tag is reachable, so the span is the
|
||||||
|
# whole history — which happens on a genuine first release and on a tag cut
|
||||||
|
# somewhere `main`'s tags cannot be seen from. Truncating says so; emitting
|
||||||
|
# 1100 lines would bury the note explaining why there are 1100 of them.
|
||||||
|
MAX_COMMITS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def git_ok(*args: str) -> str | None:
|
||||||
|
"""Run git, returning None instead of raising when it fails.
|
||||||
|
|
||||||
|
Used for the questions that legitimately have no answer — no previous tag,
|
||||||
|
no local `main` — where the absence is information rather than a fault.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return git(*args)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def previous_tag(ref: str, tag: str | None) -> str | None:
|
||||||
|
"""The most recent `v*` tag reachable from `ref`, excluding `tag` itself.
|
||||||
|
|
||||||
|
`--exclude` rather than `<ref>^` so this is the same call whether or not
|
||||||
|
`ref` is the tag being released — and so it does not blow up on a root
|
||||||
|
commit that has no parent to walk to.
|
||||||
|
"""
|
||||||
|
args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]
|
||||||
|
if tag:
|
||||||
|
args += ["--exclude", tag]
|
||||||
|
return git_ok(*args, ref)
|
||||||
|
|
||||||
|
|
||||||
|
def commits(previous: str | None, ref: str) -> list[str]:
|
||||||
|
"""The subjects between the previous release and this one.
|
||||||
|
|
||||||
|
`--no-merges` because rule 153 merges `dev` into `main` with a plain merge
|
||||||
|
commit, so `main`'s first-parent view is a list of "Merge pull request #N"
|
||||||
|
and nothing else. The work is in the commits under those merges.
|
||||||
|
"""
|
||||||
|
span = f"{previous}..{ref}" if previous else ref
|
||||||
|
out = git("log", "--no-merges", "--format=%s (%h)", span)
|
||||||
|
return [line for line in out.split("\n") if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(log: list[str]) -> tuple[list[str], str | None]:
|
||||||
|
if len(log) <= MAX_COMMITS:
|
||||||
|
return log, None
|
||||||
|
return log[:MAX_COMMITS], (
|
||||||
|
f"{len(log)} commits in this span — more than a changelog is for. "
|
||||||
|
f"Listing the newest {MAX_COMMITS}. This usually means no previous "
|
||||||
|
f"`v*` tag was reachable from here."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list[str]) -> str:
|
||||||
|
short = sha[:7]
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if notes:
|
||||||
|
# Anything the derivation could not stand behind goes at the TOP, not
|
||||||
|
# in a footnote. A release that quietly names a build nobody can find
|
||||||
|
# is the failure this whole milestone is about.
|
||||||
|
parts.append("\n".join(f"> **Note:** {n}" for n in notes))
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f"Built from `{short}`. The rollback unit is the immutable `:c-` tag "
|
||||||
|
f"(rule 145) — these three move together:\n\n```\n"
|
||||||
|
+ "\n".join(f"{image}:c-{short}" for image in IMAGES)
|
||||||
|
+ "\n```"
|
||||||
|
)
|
||||||
|
|
||||||
|
heading = f"## Changes since {previous}" if previous else "## Changes"
|
||||||
|
if log:
|
||||||
|
parts.append(heading + "\n\n" + "\n".join(f"- {line}" for line in log))
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
heading
|
||||||
|
+ "\n\n_No non-merge commits since the previous release. This tag "
|
||||||
|
"names the same source under a new name._"
|
||||||
|
)
|
||||||
|
|
||||||
|
span = f"{previous}..{tag}" if previous else tag
|
||||||
|
parts.append(
|
||||||
|
f"---\n\n_Derived at publish time from `git log --no-merges {span}`. "
|
||||||
|
f"Nothing here is hand-maintained._"
|
||||||
|
)
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def cross_checks(tag: str, sha: str) -> list[str]:
|
||||||
|
"""Everything the derivation knows that would make the release a lie.
|
||||||
|
|
||||||
|
Reported rather than enforced. The tag is already pushed by the time this
|
||||||
|
runs, so failing here would leave the operator with a tag and no release
|
||||||
|
and nothing but a red lane to explain it — while the release itself is
|
||||||
|
still the useful object. Say what is wrong, on the release, and publish.
|
||||||
|
"""
|
||||||
|
notes = []
|
||||||
|
|
||||||
|
if not RULE_148.match(tag):
|
||||||
|
notes.append(
|
||||||
|
f"`{tag}` is not rule 148's `vYYYY.MM.DD.HHMM` shape. Published "
|
||||||
|
f"anyway — the old `v26.*` tags predate the rule."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
derived = artifact_version("web")
|
||||||
|
if derived and derived != tag[1:]:
|
||||||
|
notes.append(
|
||||||
|
f"This tag names `{tag[1:]}`, but the web image built from "
|
||||||
|
f"`{sha[:7]}` reports `{derived}`. The Settings footer will not "
|
||||||
|
f"match this release's name."
|
||||||
|
)
|
||||||
|
|
||||||
|
# `:c-<sha>` only exists if `main` built this commit. Checking costs one
|
||||||
|
# git call; claiming it without checking costs a rollback that 404s at the
|
||||||
|
# moment someone needs it.
|
||||||
|
main = git_ok("rev-parse", "--verify", "-q", "refs/remotes/origin/main")
|
||||||
|
if main is None:
|
||||||
|
notes.append(
|
||||||
|
"Could not resolve `origin/main` here, so the `:c-` tags above are "
|
||||||
|
"unverified — they exist only if `main` built this commit."
|
||||||
|
)
|
||||||
|
elif subprocess.run(
|
||||||
|
["git", "merge-base", "--is-ancestor", sha, main], capture_output=True
|
||||||
|
).returncode != 0:
|
||||||
|
notes.append(
|
||||||
|
f"`{sha[:7]}` is not on `main`, so no `:c-{sha[:7]}` images were "
|
||||||
|
f"ever published. The refs above will not pull."
|
||||||
|
)
|
||||||
|
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_version(artifact: str) -> str | None:
|
||||||
|
"""What `artifacts.sh` derives for one artifact in the CURRENT checkout.
|
||||||
|
|
||||||
|
It takes no ref because `artifacts.sh` takes none — it walks history from
|
||||||
|
HEAD. That is right here only because a tag push checks out the tagged
|
||||||
|
commit; calling this after `--dry-run some-other-ref` would compare the
|
||||||
|
tag against the working tree, which is why the mismatch note below is
|
||||||
|
reported and not enforced.
|
||||||
|
|
||||||
|
Returns None rather than raising if the script is missing or unhappy: a
|
||||||
|
cross-check that cannot run should not take the release down with it.
|
||||||
|
"""
|
||||||
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["sh", os.path.join(root, "scripts", "artifacts.sh"), "version", artifact],
|
||||||
|
capture_output=True, text=True, check=True, cwd=root,
|
||||||
|
).stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, token: str, payload: dict | None = None) -> dict | None:
|
||||||
|
body = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path, data=body, method=method,
|
||||||
|
headers={
|
||||||
|
"Authorization": "token " + token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
return None
|
||||||
|
sys.exit(f"release: {method} {path} failed with HTTP {exc.code}: {exc.read()!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def publish(tag: str, name: str, body: str, token: str) -> None:
|
||||||
|
existing = api("GET", f"/releases/tags/{tag}", token)
|
||||||
|
if existing:
|
||||||
|
api("PATCH", f"/releases/{existing['id']}", token, {"name": name, "body": body})
|
||||||
|
print(f"release: updated existing release {existing['id']} for {tag}")
|
||||||
|
else:
|
||||||
|
api("POST", "/releases", token, {"tag_name": tag, "name": name, "body": body})
|
||||||
|
print(f"release: created release for {tag}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument(
|
||||||
|
"ref", nargs="?", default=None,
|
||||||
|
help="tag or commit to release. Defaults to GITHUB_REF's tag, else HEAD.",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="render the body to stdout and publish nothing. Needs no token, "
|
||||||
|
"so it also works as a preview before you decide to cut the tag.",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
github_ref = os.environ.get("GITHUB_REF", "")
|
||||||
|
if args.ref:
|
||||||
|
ref = args.ref
|
||||||
|
elif github_ref.startswith("refs/tags/"):
|
||||||
|
ref = github_ref[len("refs/tags/"):]
|
||||||
|
else:
|
||||||
|
ref = "HEAD"
|
||||||
|
|
||||||
|
# A tag only if git knows it as one — `HEAD` and a raw sha are refs to
|
||||||
|
# release FROM, never the name to exclude or to publish under.
|
||||||
|
tag = ref if git_ok("rev-parse", "--verify", "-q", f"refs/tags/{ref}") else None
|
||||||
|
sha = git("rev-parse", ref)
|
||||||
|
previous = previous_tag(ref, tag)
|
||||||
|
|
||||||
|
print(f"release: ref={ref} sha={sha[:12]} previous={previous or '<none>'}")
|
||||||
|
|
||||||
|
notes = cross_checks(tag, sha) if tag else [
|
||||||
|
f"Rendered for `{ref}`, which is not a tag. Nothing was published."
|
||||||
|
]
|
||||||
|
for note in notes:
|
||||||
|
print(f"release: NOTE {note}")
|
||||||
|
|
||||||
|
log = commits(previous, ref)
|
||||||
|
print(f"release: {len(log)} non-merge commits in the span")
|
||||||
|
log, overflow = truncate(log)
|
||||||
|
if overflow:
|
||||||
|
print(f"release: NOTE {overflow}")
|
||||||
|
notes.append(overflow)
|
||||||
|
body = render(tag or ref, sha, previous, log, notes)
|
||||||
|
|
||||||
|
if args.dry_run or not tag:
|
||||||
|
print("--- body ---")
|
||||||
|
print(body)
|
||||||
|
return
|
||||||
|
|
||||||
|
token = os.environ.get("RELEASE_TOKEN") or os.environ.get("TOKEN")
|
||||||
|
if not token:
|
||||||
|
sys.exit("release: no RELEASE_TOKEN in the environment")
|
||||||
|
publish(tag, f"FabledCurator {tag[1:]}", body, token)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+146
-120
@@ -1,23 +1,46 @@
|
|||||||
"""`artifacts.sh identity` is what decides whether a build gets skipped.
|
"""The two values `artifacts.sh` derives, and what each of them promises.
|
||||||
|
|
||||||
Milestone 313 step 4: build.yml asks the registry for `<image>:<identity>` and,
|
`revision` decides whether a build gets skipped; `version` is what an instance
|
||||||
on a hit, publishes NO new bytes — it repoints the channel and date tags at the
|
reports about itself and what a release tag is named after. Neither has a
|
||||||
manifest already there. So the identity has to be a true name for the content.
|
consumer that would notice it going subtly wrong.
|
||||||
Both ways of getting it wrong are silent at build time and only surface in
|
|
||||||
production:
|
|
||||||
|
|
||||||
* **too coarse** — two genuinely different images share an identity, so the
|
## revision
|
||||||
second one never gets built and its tags point at the first one's bytes. The
|
|
||||||
live case is FC_CHANNEL: a `dev` and a `main` build of one revision differ,
|
|
||||||
and collapsing them ships an instance that reports the wrong channel forever.
|
|
||||||
* **too fine** — the identity moves when the content did not, nothing ever
|
|
||||||
hits, and step 4 buys nothing. A commit sha would do exactly this.
|
|
||||||
|
|
||||||
The Dockerfiles are read here rather than trusted, because the coarse direction
|
Milestone 318 step 3: each image carries its revision as an `fc.revision`
|
||||||
appears the moment someone adds a build-arg without touching `CHANNELLED`.
|
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.
|
||||||
|
|
||||||
|
## version
|
||||||
|
|
||||||
|
`YYYY.MM.DD.HHMM`, zero-padded, UTC — one shape across the family (note #3127
|
||||||
|
§1, rule 148), so the string this project emits is the same string its siblings
|
||||||
|
emit. Two nearly-identical formats are more dangerous than two obviously
|
||||||
|
different ones, and the only thing keeping them identical is a test.
|
||||||
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -26,115 +49,118 @@ import pytest
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Only image artifacts have an identity — the extension is cached as an
|
ARTIFACTS = ("web", "ml", "agent", "extension")
|
||||||
# ext-<version> Forgejo release, not a registry tag.
|
|
||||||
IMAGE_ARTIFACTS = {
|
|
||||||
"web": "Dockerfile",
|
|
||||||
"ml": "Dockerfile.ml",
|
|
||||||
"agent": "agent/Dockerfile",
|
|
||||||
}
|
|
||||||
|
|
||||||
CHANNELS = ("main", "dev")
|
# 12 hex chars — the prefix build.yml stamps and compares.
|
||||||
|
_REVISION = re.compile(r"^[0-9a-f]{12}$")
|
||||||
|
|
||||||
# docker's own tag grammar: [A-Za-z0-9_][A-Za-z0-9._-]{0,127}
|
# YYYY.MM.DD.HHMM, every segment zero-padded to its full width.
|
||||||
_TAG = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
|
_VERSION = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||||
|
|
||||||
# `ARG FC_CHANNEL` in a Dockerfile means build.yml passes a per-channel value
|
|
||||||
# in, so the channel is part of what the image IS.
|
|
||||||
_ARG_CHANNEL = re.compile(r"^\s*ARG\s+FC_CHANNEL\b", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def identity(artifact: str, channel: str | None = None) -> subprocess.CompletedProcess:
|
# Everything here goes through artifacts.sh rather than importing a sibling
|
||||||
cmd = ["sh", str(ROOT / "scripts" / "artifacts.sh"), "identity", artifact]
|
# test module. That is the interface build.yml actually calls, so the tests
|
||||||
if channel is not None:
|
# exercise the contract instead of a Python re-implementation of it — and no
|
||||||
cmd.append(channel)
|
# other test module in this repo imports another, so a cross-test import would
|
||||||
return subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT)
|
# be a new convention introduced for no gain.
|
||||||
|
def artifacts(*args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
def ok(artifact: str, channel: str | None = None) -> str:
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), *args],
|
||||||
proc = identity(artifact, channel)
|
|
||||||
assert proc.returncode == 0, f"identity {artifact} {channel}: {proc.stderr}"
|
|
||||||
return proc.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def bakes_the_channel(artifact: str) -> bool:
|
|
||||||
return bool(_ARG_CHANNEL.search((ROOT / IMAGE_ARTIFACTS[artifact]).read_text()))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
||||||
def test_channel_dependence_matches_the_dockerfile(artifact):
|
|
||||||
"""The coarse direction, caught at its source.
|
|
||||||
|
|
||||||
Whether the channel belongs in the identity is not a preference — it is
|
|
||||||
dictated by whether the Dockerfile takes it as a build-arg. Adding an
|
|
||||||
`ARG FC_CHANNEL` to another image without adding it to `CHANNELLED` would
|
|
||||||
make its dev and main builds collide, and nothing else would notice.
|
|
||||||
"""
|
|
||||||
per_channel = {c: ok(artifact, c) for c in CHANNELS}
|
|
||||||
differs = len(set(per_channel.values())) > 1
|
|
||||||
|
|
||||||
if bakes_the_channel(artifact):
|
|
||||||
assert differs, (
|
|
||||||
f"{IMAGE_ARTIFACTS[artifact]} declares ARG FC_CHANNEL, so a dev "
|
|
||||||
f"build and a main build of one revision are different images — "
|
|
||||||
f"but both derive the identity {per_channel['main']!r}. The main "
|
|
||||||
f"build would reuse the dev image and report the wrong channel. "
|
|
||||||
f"Add {artifact!r} to CHANNELLED in scripts/artifacts.sh."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert not differs, (
|
|
||||||
f"{IMAGE_ARTIFACTS[artifact]} takes no channel build-arg, so one "
|
|
||||||
f"revision is one image and a merge to main should reuse what dev "
|
|
||||||
f"already built — but the identity differs per channel "
|
|
||||||
f"({per_channel}), so every merge rebuilds it for nothing. Remove "
|
|
||||||
f"{artifact!r} from CHANNELLED in scripts/artifacts.sh."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
||||||
def test_identity_tracks_the_artifacts_own_revision(artifact):
|
|
||||||
"""The fine direction: the identity must be the revision, not the push.
|
|
||||||
|
|
||||||
`revision` is the commit this artifact's shipped files last changed in, so
|
|
||||||
it holds still across pushes that did not touch it. Anything derived from
|
|
||||||
HEAD instead would move every push and never hit the registry.
|
|
||||||
"""
|
|
||||||
rev = subprocess.run(
|
|
||||||
["sh", str(ROOT / "scripts" / "artifacts.sh"), "revision", artifact],
|
|
||||||
capture_output=True, text=True, check=True, cwd=ROOT,
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", ARTIFACTS)
|
||||||
|
def test_version_is_zero_padded_calver(artifact):
|
||||||
|
"""The family shape, pinned.
|
||||||
|
|
||||||
|
Padding was stripped until 2026-08-28 on the reasoning that each segment
|
||||||
|
should read as a plain integer — which never held, since comparison strips
|
||||||
|
leading zeros on parse anyway. What it did do was make this project emit
|
||||||
|
`2026.8.28.1432` while a sibling emitted `2026.08.28.1432`: two shapes one
|
||||||
|
character apart, which is the hard kind of difference to notice.
|
||||||
|
|
||||||
|
Also catches the midnight case. A `%H%M` of `0322` must survive as `0322`;
|
||||||
|
the old strip-leading-zeros helper turned it into `322`, silently changing
|
||||||
|
a four-digit field into three.
|
||||||
|
"""
|
||||||
|
value = artifacts("version", artifact).strip()
|
||||||
|
assert _VERSION.match(value), (
|
||||||
|
f"{artifact} derives {value!r}, which is not zero-padded "
|
||||||
|
f"YYYY.MM.DD.HHMM. Note #3127 §1 and rule 148 both specify the padded "
|
||||||
|
f"form, and a release tag is this string with a `v` in front."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artifact", ARTIFACTS)
|
||||||
|
def test_version_and_revision_describe_the_same_commit(artifact):
|
||||||
|
"""They are derived independently and must not be able to disagree.
|
||||||
|
|
||||||
|
A build reports the version and skips on the revision, so a divergence
|
||||||
|
would mean an instance naming one commit while carrying another's bytes —
|
||||||
|
unfalsifiable from outside, since both values look perfectly well-formed.
|
||||||
|
"""
|
||||||
|
sha = newest_by_commit_time(artifact)
|
||||||
|
stamped = subprocess.run(
|
||||||
|
["git", "show", "-s", "--format=%cd", "--date=format-local:%Y.%m.%d.%H%M", sha],
|
||||||
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||||
|
env={"TZ": "UTC", "PATH": os.environ.get("PATH", "")},
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
value = ok(artifact, "main")
|
assert artifacts("version", artifact).strip() == stamped
|
||||||
assert rev and rev in value, (
|
assert sha.startswith(revision(artifact))
|
||||||
f"identity {value!r} does not contain the {artifact} revision {rev!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
||||||
def test_identity_is_a_legal_docker_tag(artifact):
|
|
||||||
"""It is pushed as a tag, so an illegal one fails at the registry — after
|
|
||||||
the build has already run."""
|
|
||||||
for channel in CHANNELS:
|
|
||||||
value = ok(artifact, channel)
|
|
||||||
assert _TAG.match(value), f"{value!r} is not a valid docker tag"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_channelled_artifact_refuses_an_unqualified_identity():
|
|
||||||
"""Refusing beats defaulting. If `identity web` quietly returned the
|
|
||||||
unqualified `r-<rev>`, a workflow that forgot to pass the channel would
|
|
||||||
publish one image under a name both channels then reuse — the exact
|
|
||||||
collision the CHANNELLED list exists to prevent, reintroduced by an
|
|
||||||
omission rather than by an edit."""
|
|
||||||
proc = identity("web")
|
|
||||||
assert proc.returncode != 0, (
|
|
||||||
"identity web returned a value with no channel: "
|
|
||||||
f"{proc.stdout.strip()!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_extension_has_no_image_identity():
|
|
||||||
"""It is cached as an ext-<version> release asset, and its cache key is the
|
|
||||||
version. Answering with a plausible image tag would invite a second,
|
|
||||||
divergent cache."""
|
|
||||||
proc = identity("extension", "main")
|
|
||||||
assert proc.returncode != 0
|
|
||||||
assert "ext-" in proc.stderr
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ being right, and both ways of being wrong are silent:
|
|||||||
Nothing else notices either. The version still derives, CI still goes green,
|
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
|
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.
|
bytes". So the Dockerfiles are read here and compared against the declaration.
|
||||||
|
|
||||||
|
The COPY list is not the whole answer, though. A file that DECIDES what an
|
||||||
|
artifact reports belongs in its set even though it is copied into nothing —
|
||||||
|
see DERIVERS below, where the same finding is recorded twice (#3156, #3202).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -110,26 +114,48 @@ def test_the_web_image_versions_on_an_extension_change():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_web_image_versions_on_a_version_derivation_change():
|
# A file that DECIDES an artifact's identity is part of what that artifact is
|
||||||
"""packaging.sh ships in no image, yet it belongs in the sets that bundle
|
# built from, even though it is copied into no image. Both entries here are the
|
||||||
the XPI — because it decides the version string build.yml stamps into the
|
# same finding twice — #3156 for packaging.sh, #3202 for artifacts.sh — and
|
||||||
packaged manifest.json. Changing the derivation changes the shipped bytes.
|
# both were latent for the same reason: the version has no backstop.
|
||||||
|
#
|
||||||
|
# The revision does. Change how a REVISION is computed and the derived value
|
||||||
|
# stops matching the label on the published image, which forces a rebuild; the
|
||||||
|
# mechanism self-corrects because it compares against a string stamped into a
|
||||||
|
# real artifact. Nothing compares a version to anything, so a version-only
|
||||||
|
# derivation change is invisible unless the deriver is in the set.
|
||||||
|
DERIVERS = [
|
||||||
|
# packaging.sh decides the version build.yml stamps into the packaged
|
||||||
|
# manifest.json, so changing it changes the shipped bytes. Left out,
|
||||||
|
# milestone 313 step 4 turns 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 too, since web bundles what the extension makes.
|
||||||
|
("extension/scripts/packaging.sh", ("extension", "web")),
|
||||||
|
# artifacts.sh decides the FC_VERSION baked into the web image (#3202).
|
||||||
|
# Web only, and deliberately: ml and agent ask this script for `revision`
|
||||||
|
# alone, so they are covered by the self-correcting path above, and the
|
||||||
|
# extension takes its version from packaging.sh. Milestone 318 step 5 is
|
||||||
|
# the worked instance — b3989d0 and 5771fd5 share revision fb2c4d5b80be
|
||||||
|
# while the version moved 2026.8.28.1249 -> 2026.08.28.1249. It was
|
||||||
|
# harmless only because FC_VERSION did not exist until one commit later.
|
||||||
|
("scripts/artifacts.sh", ("web",)),
|
||||||
|
]
|
||||||
|
|
||||||
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
|
@pytest.mark.parametrize("path, artifacts", DERIVERS, ids=lambda v: str(v))
|
||||||
the reuse path republishes the old image and the fresh signature is
|
def test_a_version_deriver_is_in_the_set_of_what_it_decides(path, artifacts):
|
||||||
orphaned. Guarded for web and the extension both, since web bundles what
|
for artifact in artifacts:
|
||||||
the extension produces.
|
|
||||||
"""
|
|
||||||
for artifact in ("extension", "web"):
|
|
||||||
inc = includes(artifact)
|
inc = includes(artifact)
|
||||||
excluded = [
|
excluded = [
|
||||||
p[len(":(exclude)"):] for p in declared_paths(artifact)
|
p[len(":(exclude)"):] for p in declared_paths(artifact)
|
||||||
if p.startswith(":(exclude)")
|
if p.startswith(":(exclude)")
|
||||||
]
|
]
|
||||||
path = "extension/scripts/packaging.sh"
|
|
||||||
assert any(covered_by(path, i) for i in inc), (
|
assert any(covered_by(path, i) for i in inc), (
|
||||||
f"{path} is not in the {artifact} path set"
|
f"{path} decides the version {artifact} reports, but is not in the "
|
||||||
|
f"{artifact} path set. A change to the derivation would leave the "
|
||||||
|
f"revision untouched, the build skipped, and the published image "
|
||||||
|
f"reporting the old version — with nothing to disagree with it."
|
||||||
)
|
)
|
||||||
assert not any(
|
assert not any(
|
||||||
covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded
|
covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded
|
||||||
@@ -146,6 +172,11 @@ def test_the_web_image_versions_on_a_version_derivation_change():
|
|||||||
# Deliberate exclusions — the too-wide direction. Each of these lives
|
# Deliberate exclusions — the too-wide direction. Each of these lives
|
||||||
# beside shipped code but never reaches an image, and including it
|
# beside shipped code but never reaches an image, and including it
|
||||||
# would re-version the artifact for a change it does not carry.
|
# would re-version the artifact for a change it does not carry.
|
||||||
|
#
|
||||||
|
# "Never reaches an image" is the test, not "is not source": DERIVERS
|
||||||
|
# above are also copied into nothing and DO belong in their sets,
|
||||||
|
# because they decide what the image reports. The line between the two
|
||||||
|
# lists is whether the file has a say in the artifact's identity.
|
||||||
("agent", "agent/README.md"),
|
("agent", "agent/README.md"),
|
||||||
("agent", "agent/ruff.toml"),
|
("agent", "agent/ruff.toml"),
|
||||||
("agent", "agent/docker-compose.yml"),
|
("agent", "agent/docker-compose.yml"),
|
||||||
|
|||||||
@@ -9,3 +9,63 @@ async def test_health_returns_ok(client):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = await response.get_json()
|
body = await response.get_json()
|
||||||
assert body == {"status": "ok"}
|
assert body == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- build identity (milestone 318 step 6) --------------------------------
|
||||||
|
#
|
||||||
|
# With no version image tags left, /api/health is the only place an instance
|
||||||
|
# says which build it is. Both fields are OMITTED when unset rather than sent
|
||||||
|
# empty: absence already means "cannot say" — an image predating the field
|
||||||
|
# says exactly that by not having the key — and a second spelling would make
|
||||||
|
# every reader special-case it (note #3127 §7).
|
||||||
|
#
|
||||||
|
# The test above is load-bearing for that: it asserts the body is EXACTLY
|
||||||
|
# {"status": "ok"} when nothing is stamped, so a well-meaning `or ""` default
|
||||||
|
# fails it.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_reports_the_build_it_is(client, monkeypatch):
|
||||||
|
from backend.app.api import health
|
||||||
|
|
||||||
|
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
|
||||||
|
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
|
||||||
|
|
||||||
|
body = await (await client.get("/api/health")).get_json()
|
||||||
|
assert body == {
|
||||||
|
"status": "ok",
|
||||||
|
"version": "2026.08.28.1249",
|
||||||
|
"channel": "dev",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_keeps_the_channel_out_of_the_version(client, monkeypatch):
|
||||||
|
"""Rule 149, asserted rather than assumed.
|
||||||
|
|
||||||
|
The tempting shortcut is a `-dev` suffix on the version. The extension's
|
||||||
|
comparator parses each dotted segment with `parseInt`, so a suffixed
|
||||||
|
segment reads as 0 and every dev build compares equal to every other —
|
||||||
|
#2993 exactly. Two separate keys cannot express that mistake.
|
||||||
|
"""
|
||||||
|
from backend.app.api import health
|
||||||
|
|
||||||
|
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
|
||||||
|
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
|
||||||
|
|
||||||
|
body = await (await client.get("/api/health")).get_json()
|
||||||
|
assert body["version"] == "2026.08.28.1249"
|
||||||
|
assert "dev" not in body["version"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_omits_a_channel_it_cannot_name(client, monkeypatch):
|
||||||
|
"""A locally-built image has a version but no channel. It must not gain an
|
||||||
|
empty one — the key's absence is the answer."""
|
||||||
|
from backend.app.api import health
|
||||||
|
|
||||||
|
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
|
||||||
|
monkeypatch.setattr(health, "FC_CHANNEL", "")
|
||||||
|
|
||||||
|
body = await (await client.get("/api/health")).get_json()
|
||||||
|
assert body == {"status": "ok", "version": "2026.08.28.1249"}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""What the release changelog promises, and the way it would lie quietly.
|
||||||
|
|
||||||
|
A changelog has no consumer that checks it. If it lists the wrong span nothing
|
||||||
|
fails — the release publishes, reads perfectly, and tells the operator that a
|
||||||
|
month of work landed in a build that never contained it. That is the same
|
||||||
|
silent-and-plausible failure class as a revision that identifies the wrong
|
||||||
|
content (`test_artifact_identity.py` guards the other side of it), so the span
|
||||||
|
selection is asserted rather than eyeballed.
|
||||||
|
|
||||||
|
Everything runs the script the way `release.yml` runs it — as a subprocess,
|
||||||
|
through `--dry-run`. That is the same code path as a real publish right up to
|
||||||
|
the HTTP call, so these exercise the interface CI uses instead of a Python
|
||||||
|
re-implementation of it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT = ROOT / "scripts" / "release_notes.py"
|
||||||
|
|
||||||
|
|
||||||
|
def notes(*args: str, cwd: Path | None = None) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["python3", str(SCRIPT), "--dry-run", *args],
|
||||||
|
capture_output=True, text=True, check=True, cwd=cwd or ROOT,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(out: str) -> str:
|
||||||
|
assert "--- body ---" in out, f"no body was rendered:\n{out}"
|
||||||
|
return out.split("--- body ---", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-c", "user.email=ci@example.invalid", "-c", "user.name=ci",
|
||||||
|
"-c", "commit.gpgsign=false", *args],
|
||||||
|
capture_output=True, text=True, check=True, cwd=repo,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shaped_history(tmp_path: Path) -> Path:
|
||||||
|
"""Three releases spanning the rule 148 tag-shape change.
|
||||||
|
|
||||||
|
Ancestry order is `v26.06.04.0` → `v2026.08.28.2208` → `v2026.08.29.1000`,
|
||||||
|
which is the exact arrangement where walking ancestry and sorting a list
|
||||||
|
disagree — see the test below. Synthetic rather than taken from this repo's
|
||||||
|
own tags so it holds whether or not CI's checkout brought the tags along:
|
||||||
|
a span test that quietly skips is the one outcome worse than a failing one.
|
||||||
|
"""
|
||||||
|
repo = tmp_path / "shaped"
|
||||||
|
repo.mkdir()
|
||||||
|
git(repo, "init", "-q", "-b", "main")
|
||||||
|
for i, tag in enumerate(("v26.06.04.0", "v2026.08.28.2208", "v2026.08.29.1000")):
|
||||||
|
(repo / "f.txt").write_text(f"{i}\n")
|
||||||
|
git(repo, "add", "f.txt")
|
||||||
|
git(repo, "commit", "-q", "-m", f"work landing in {tag}")
|
||||||
|
git(repo, "tag", tag)
|
||||||
|
# One more commit and a merge, so the merge-exclusion test has something to
|
||||||
|
# exclude that a first-parent listing would otherwise show.
|
||||||
|
git(repo, "checkout", "-q", "-b", "side")
|
||||||
|
(repo / "g.txt").write_text("side\n")
|
||||||
|
git(repo, "add", "g.txt")
|
||||||
|
git(repo, "commit", "-q", "-m", "feat: work done on the side branch")
|
||||||
|
git(repo, "checkout", "-q", "main")
|
||||||
|
git(repo, "merge", "-q", "--no-ff", "side", "-m", "Merge pull request #999 from side")
|
||||||
|
git(repo, "tag", "v2026.08.30.0900")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_previous_release_is_found_by_ancestry_not_by_sorting(shaped_history):
|
||||||
|
"""The trap this repo is standing in right now.
|
||||||
|
|
||||||
|
Rule 148 moved the tag shape from `v26.05.22.0` to `v2026.08.28.2208`.
|
||||||
|
Lexicographically `v2026...` sorts BEFORE `v26...` — the third character is
|
||||||
|
`0` against `6` — so a sorted-list implementation reaches back past every
|
||||||
|
new-shape tag to the newest OLD-shape one and emits months of commits as
|
||||||
|
"changes since". It looks entirely correct on any repo whose tags share a
|
||||||
|
single shape, which is every repo until the day the shape changes.
|
||||||
|
|
||||||
|
Here, ancestry says `v2026.08.28.2208` and sorting says `v26.06.04.0`.
|
||||||
|
"""
|
||||||
|
out = notes("v2026.08.29.1000", cwd=shaped_history)
|
||||||
|
assert "previous=v2026.08.28.2208" in out
|
||||||
|
assert "v26.06.04.0" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_body_names_the_span_it_actually_listed(shaped_history):
|
||||||
|
"""A body whose heading says "since X" over commits computed from Y is
|
||||||
|
unfalsifiable from outside — both halves read fine on their own."""
|
||||||
|
body = body_of(notes("v2026.08.29.1000", cwd=shaped_history))
|
||||||
|
assert "## Changes since v2026.08.28.2208" in body
|
||||||
|
assert "v2026.08.28.2208..v2026.08.29.1000" in body
|
||||||
|
assert "work landing in v2026.08.29.1000" in body
|
||||||
|
assert "work landing in v2026.08.28.2208" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_merges_are_excluded_so_the_list_is_the_work(shaped_history):
|
||||||
|
"""Rule 153 merges dev into main with a plain merge commit, so `main`'s
|
||||||
|
first-parent view is nothing but "Merge pull request #N". Including those
|
||||||
|
would publish a changelog of PR numbers over the actual changes."""
|
||||||
|
body = body_of(notes("v2026.08.30.0900", cwd=shaped_history))
|
||||||
|
assert "feat: work done on the side branch" in body
|
||||||
|
assert "Merge pull request #999" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_first_release_still_renders_with_nothing_behind_it(shaped_history):
|
||||||
|
"""No previous tag is reachable from the oldest one. That is a real state,
|
||||||
|
not an error, and it must not take the release down with it."""
|
||||||
|
out = notes("v26.06.04.0", cwd=shaped_history)
|
||||||
|
assert "previous=<none>" in out
|
||||||
|
assert "## Changes" in body_of(out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_tag_ref_renders_but_refuses_to_claim_it_published():
|
||||||
|
"""`--dry-run HEAD` is the operator's preview before deciding to cut a tag
|
||||||
|
at all. It must not describe itself as a release that happened."""
|
||||||
|
out = notes("HEAD")
|
||||||
|
assert "which is not a tag" in out
|
||||||
|
body_of(out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rollback_refs_name_all_three_images():
|
||||||
|
"""Rule 145: `:c-<sha>` is the rollback unit, and the three images move
|
||||||
|
together. A release listing only the web image sends an operator into a
|
||||||
|
rollback that leaves ml and agent on the newer build — the exact mismatch
|
||||||
|
build.yml builds all three on every push to avoid."""
|
||||||
|
body = body_of(notes("HEAD"))
|
||||||
|
for image in ("fabledcurator", "fabledcurator-ml", "fabledcurator-agent"):
|
||||||
|
assert f"bvandeusen/{image}:c-" in body, f"{image} missing from the rollback refs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unbounded_span_is_truncated_and_says_so():
|
||||||
|
"""With no reachable previous tag the span is the whole history. Emitting
|
||||||
|
eleven hundred lines would bury the one line explaining why there are
|
||||||
|
eleven hundred of them, so the cap is part of the message, not a silent
|
||||||
|
slice."""
|
||||||
|
out = notes("HEAD")
|
||||||
|
if "previous=<none>" not in out:
|
||||||
|
pytest.skip("a previous tag is reachable from HEAD in this checkout")
|
||||||
|
body = body_of(out)
|
||||||
|
listed = [ln for ln in body.split("\n") if ln.startswith("- ")]
|
||||||
|
assert len(listed) <= 200
|
||||||
|
assert "more than a changelog is for" in body
|
||||||
Reference in New Issue
Block a user