dev→main: milestone 318 steps 5–7 and 9, plus #3202 #240
@@ -116,8 +116,9 @@ 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: |
|
||||||
@@ -345,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
|
||||||
@@ -361,8 +361,9 @@ 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: |
|
||||||
@@ -463,15 +464,13 @@ 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:
|
||||||
@@ -482,6 +481,10 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
DERIVED=$(sh scripts/artifacts.sh revision web)
|
DERIVED=$(sh scripts/artifacts.sh revision web)
|
||||||
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
echo "revision=$DERIVED" >> "$GITHUB_OUTPUT"
|
||||||
|
# Baked into the web image as FC_VERSION and reported by /api/health.
|
||||||
|
# A pure function of the revision — same commit, same string — so it
|
||||||
|
# adds no variability the reuse check would have to account for.
|
||||||
|
echo "version=$(sh scripts/artifacts.sh version web)" >> "$GITHUB_OUTPUT"
|
||||||
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
echo "build_tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# The moving tag for this channel. Which tag we ask IS the channel —
|
# The moving tag for this channel. Which tag we ask IS the channel —
|
||||||
@@ -611,16 +614,17 @@ jobs:
|
|||||||
# decoration — an unstamped image is one that will always rebuild.
|
# decoration — an unstamped image is one that will always rebuild.
|
||||||
labels: |
|
labels: |
|
||||||
fc.revision=${{ steps.reuse.outputs.revision }}
|
fc.revision=${{ steps.reuse.outputs.revision }}
|
||||||
# Only the web image carries a channel: it is the one that serves
|
# Only the web image carries these: it is the one with a UI and an
|
||||||
# /api/extension/manifest. The ml and agent images have nothing to
|
# HTTP surface to report them on. The ml and agent images have
|
||||||
# report it to.
|
# 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
|
||||||
# channel tag already holds, so :c-<sha> and the date pin are
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# byte-identical to what is 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
|
||||||
@@ -686,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
|
||||||
@@ -702,8 +705,9 @@ 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: |
|
||||||
@@ -765,15 +769,13 @@ 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:
|
||||||
@@ -840,8 +842,8 @@ jobs:
|
|||||||
|
|
||||||
# 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
|
||||||
# channel tag already holds, so :c-<sha> and the date pin are
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# byte-identical to what is 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
|
||||||
@@ -910,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
|
||||||
@@ -926,8 +927,9 @@ 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: |
|
||||||
@@ -984,15 +986,13 @@ 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:
|
||||||
@@ -1059,8 +1059,8 @@ jobs:
|
|||||||
|
|
||||||
# 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
|
||||||
# channel tag already holds, so :c-<sha> and the date pin are
|
# channel tag already holds, so :c-<sha> is byte-identical to what is
|
||||||
# byte-identical to what is 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+57
-17
@@ -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,6 +61,33 @@ 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'
|
||||||
|
|
||||||
|
# This file. It is copied into no image and it is still part of what the web
|
||||||
|
# image is built from, because it DECIDES the FC_VERSION baked into that image
|
||||||
|
# (#3202). Same finding as #3156 about packaging.sh, one level up.
|
||||||
|
#
|
||||||
|
# Why web and nothing else. Every artifact stamps `fc.revision`, but only web
|
||||||
|
# also stamps a version (build.yml line ~488 feeds `version web` to the
|
||||||
|
# FC_VERSION build arg; ml and agent ask for `revision` alone, and the
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# The version is compared against nothing, so it has no such backstop. Before
|
||||||
|
# this entry, a change to cmd_version alone left every artifact's revision
|
||||||
|
# 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} {web|ml|agent|extension}" >&2
|
echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2
|
||||||
@@ -71,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 ;;
|
||||||
@@ -100,13 +132,6 @@ 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 is what decides whether a build can be skipped.
|
# changed in. This is what decides whether a build can be skipped.
|
||||||
#
|
#
|
||||||
@@ -124,17 +149,32 @@ 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)")"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ $# -ge 2 ] || usage
|
[ $# -ge 2 ] || usage
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
"""`artifacts.sh revision` is what decides whether a build gets skipped.
|
"""The two values `artifacts.sh` derives, and what each of them promises.
|
||||||
|
|
||||||
|
`revision` decides whether a build gets skipped; `version` is what an instance
|
||||||
|
reports about itself and what a release tag is named after. Neither has a
|
||||||
|
consumer that would notice it going subtly wrong.
|
||||||
|
|
||||||
|
## revision
|
||||||
|
|
||||||
Milestone 318 step 3: each image carries its revision as an `fc.revision`
|
Milestone 318 step 3: each image carries its revision as an `fc.revision`
|
||||||
label, and build.yml reads that label back off the moving channel tag. Equal
|
label, and build.yml reads that label back off the moving channel tag. Equal
|
||||||
@@ -21,12 +27,20 @@ Both ways of getting it wrong are silent:
|
|||||||
This module owns the narrower claim: whatever the path sets say, the revision
|
This module owns the narrower claim: whatever the path sets say, the revision
|
||||||
is genuinely the commit those paths last changed in.
|
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
|
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),
|
no longer a `CHANNELLED` list to drift (the channel is which tag you inspect),
|
||||||
and no `identity` subcommand to refuse an unqualified call.
|
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
|
||||||
@@ -40,6 +54,9 @@ ARTIFACTS = ("web", "ml", "agent", "extension")
|
|||||||
# 12 hex chars — the prefix build.yml stamps and compares.
|
# 12 hex chars — the prefix build.yml stamps and compares.
|
||||||
_REVISION = re.compile(r"^[0-9a-f]{12}$")
|
_REVISION = re.compile(r"^[0-9a-f]{12}$")
|
||||||
|
|
||||||
|
# YYYY.MM.DD.HHMM, every segment zero-padded to its full width.
|
||||||
|
_VERSION = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
||||||
|
|
||||||
|
|
||||||
# Everything here goes through artifacts.sh rather than importing a sibling
|
# Everything here goes through artifacts.sh rather than importing a sibling
|
||||||
# test module. That is the interface build.yml actually calls, so the tests
|
# test module. That is the interface build.yml actually calls, so the tests
|
||||||
@@ -107,3 +124,43 @@ def test_revision_is_a_legal_label_value_and_is_stable(artifact):
|
|||||||
first = revision(artifact)
|
first = revision(artifact)
|
||||||
assert _REVISION.match(first), f"{first!r} is not a 12-char hex revision"
|
assert _REVISION.match(first), f"{first!r} is not a 12-char hex revision"
|
||||||
assert first == revision(artifact), "revision is not stable across calls"
|
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()
|
||||||
|
assert artifacts("version", artifact).strip() == stamped
|
||||||
|
assert sha.startswith(revision(artifact))
|
||||||
|
|||||||
@@ -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