diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 47228b7..72660ca 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -116,8 +116,9 @@ jobs: # STILL. If web moves, its path set is too wide. # * a push touching only docs moves nothing. # * a push touching the extension moves the extension AND web, since - # web bakes in the XPI. If web does not move, its set is too narrow - # — the direction that serves stale bytes on a pin. + # web bakes in the XPI. If web does not move, its set is too narrow: + # 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. - name: Shadow — derived artifact version (informational) run: | @@ -345,12 +346,11 @@ jobs: fetch-depth: 0 # --- derived values, one line (milestone 313) ------------------------ - # These stopped being shadow output at step 3: `tag` is published on - # main and `revision` decides whether the build below runs at all. This - # step prints all three anyway, because the load-bearing steps each - # print only the one they use, and on dev the date tag is computed - # nowhere else. When a build is skipped or a pin looks wrong, this is - # the line that says what the commit derived. + # These stopped being shadow output at step 3. `revision` decides + # whether the build below runs at all and `version` is what the image + # reports about itself; the load-bearing steps each print only the one + # they use, so this is the only place the pair appears together. When a + # build is skipped, this is the line that says what the commit derived. # # Still diagnostic, so it still must not fail the build — no `set -e`, # and every derivation falls back to UNAVAILABLE. A broken echo must @@ -361,8 +361,9 @@ jobs: # STILL. If web moves, its path set is too wide. # * a push touching only docs moves nothing. # * a push touching the extension moves the extension AND web, since - # web bakes in the XPI. If web does not move, its set is too narrow - # — the direction that serves stale bytes on a pin. + # web bakes in the XPI. If web does not move, its set is too narrow: + # 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. - name: Report the derived artifact version run: | @@ -463,15 +464,13 @@ jobs: # that skips a build that was actually needed; the worst case is paying # for a build we could have avoided. # - # BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact - # whose source stops moving stops picking up base-image updates under - # its pinned tag. That is what a pin MEANS — a date tag has to keep - # serving the bytes it served (fabledcurator:2026.7.17 still - # resolves to July's image), or it is not a pin — and family rule - # 145 already says where the refresh goes instead: a rebuild with - # different contents publishes only the MOVING tag, never the immutable - # one. A scheduled channel-only refresh is tracked separately (#3154); - # it does not belong in the push path. + # BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops + # picking up base-image updates. Milestone 318 removed the argument this + # used to need rather than answering it — with no version tags there is + # no immutable name a refresh could contradict, and rule 145 already + # allows a rebuild with different contents to republish a MOVING tag. + # So a refresh is just a build. A scheduled channel-only one is tracked + # separately (#3154); it does not belong in the push path. - name: Is this content already published? id: reuse env: @@ -482,6 +481,10 @@ jobs: set -eu DERIVED=$(sh scripts/artifacts.sh revision web) 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" # 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. labels: | fc.revision=${{ steps.reuse.outputs.revision }} - # Only the web image carries a channel: it is the one that serves - # /api/extension/manifest. The ml and agent images have nothing to - # report it to. + # Only the web image carries these: it is the one with a UI and an + # HTTP surface to report them on. The ml and agent images have + # nothing to tell. build-args: | FC_CHANNEL=${{ steps.tag.outputs.channel }} + FC_VERSION=${{ steps.reuse.outputs.version }} # Registry-side manifest copy: no layer transfer, no local daemon, no # rebuild. Each -t becomes another reference to the SAME manifest the - # channel tag already holds, so :c- and the date pin are - # byte-identical to what is published rather than a lookalike rebuild. + # channel tag already holds, so :c- is byte-identical to what is + # published rather than a lookalike rebuild. # # Runs on EVERY reuse, which is what keeps family rule 146 true: a # rolling channel refreshes itself, so skipping a build must never mean @@ -686,12 +690,11 @@ jobs: fetch-depth: 0 # --- derived values, one line (milestone 313) ------------------------ - # These stopped being shadow output at step 3: `tag` is published on - # main and `revision` decides whether the build below runs at all. This - # step prints all three anyway, because the load-bearing steps each - # print only the one they use, and on dev the date tag is computed - # nowhere else. When a build is skipped or a pin looks wrong, this is - # the line that says what the commit derived. + # These stopped being shadow output at step 3. `revision` decides + # whether the build below runs at all and `version` is what the image + # reports about itself; the load-bearing steps each print only the one + # they use, so this is the only place the pair appears together. When a + # build is skipped, this is the line that says what the commit derived. # # Still diagnostic, so it still must not fail the build — no `set -e`, # and every derivation falls back to UNAVAILABLE. A broken echo must @@ -702,8 +705,9 @@ jobs: # STILL. If web moves, its path set is too wide. # * a push touching only docs moves nothing. # * a push touching the extension moves the extension AND web, since - # web bakes in the XPI. If web does not move, its set is too narrow - # — the direction that serves stale bytes on a pin. + # web bakes in the XPI. If web does not move, its set is too narrow: + # 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. - name: Report the derived artifact version run: | @@ -765,15 +769,13 @@ jobs: # that skips a build that was actually needed; the worst case is paying # for a build we could have avoided. # - # BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact - # whose source stops moving stops picking up base-image updates under - # its pinned tag. That is what a pin MEANS — a date tag has to keep - # serving the bytes it served (fabledcurator-ml:2026.7.17 still - # resolves to July's image), or it is not a pin — and family rule - # 145 already says where the refresh goes instead: a rebuild with - # different contents publishes only the MOVING tag, never the immutable - # one. A scheduled channel-only refresh is tracked separately (#3154); - # it does not belong in the push path. + # BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops + # picking up base-image updates. Milestone 318 removed the argument this + # used to need rather than answering it — with no version tags there is + # no immutable name a refresh could contradict, and rule 145 already + # allows a rebuild with different contents to republish a MOVING tag. + # So a refresh is just a build. A scheduled channel-only one is tracked + # separately (#3154); it does not belong in the push path. - name: Is this content already published? id: reuse env: @@ -840,8 +842,8 @@ jobs: # Registry-side manifest copy: no layer transfer, no local daemon, no # rebuild. Each -t becomes another reference to the SAME manifest the - # channel tag already holds, so :c- and the date pin are - # byte-identical to what is published rather than a lookalike rebuild. + # channel tag already holds, so :c- is byte-identical to what is + # published rather than a lookalike rebuild. # # Runs on EVERY reuse, which is what keeps family rule 146 true: a # rolling channel refreshes itself, so skipping a build must never mean @@ -910,12 +912,11 @@ jobs: fetch-depth: 0 # --- derived values, one line (milestone 313) ------------------------ - # These stopped being shadow output at step 3: `tag` is published on - # main and `revision` decides whether the build below runs at all. This - # step prints all three anyway, because the load-bearing steps each - # print only the one they use, and on dev the date tag is computed - # nowhere else. When a build is skipped or a pin looks wrong, this is - # the line that says what the commit derived. + # These stopped being shadow output at step 3. `revision` decides + # whether the build below runs at all and `version` is what the image + # reports about itself; the load-bearing steps each print only the one + # they use, so this is the only place the pair appears together. When a + # build is skipped, this is the line that says what the commit derived. # # Still diagnostic, so it still must not fail the build — no `set -e`, # and every derivation falls back to UNAVAILABLE. A broken echo must @@ -926,8 +927,9 @@ jobs: # STILL. If web moves, its path set is too wide. # * a push touching only docs moves nothing. # * a push touching the extension moves the extension AND web, since - # web bakes in the XPI. If web does not move, its set is too narrow - # — the direction that serves stale bytes on a pin. + # web bakes in the XPI. If web does not move, its set is too narrow: + # 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. - name: Report the derived artifact version run: | @@ -984,15 +986,13 @@ jobs: # that skips a build that was actually needed; the worst case is paying # for a build we could have avoided. # - # BASE-IMAGE FRESHNESS, decided rather than left implicit: an artifact - # whose source stops moving stops picking up base-image updates under - # its pinned tag. That is what a pin MEANS — a date tag has to keep - # serving the bytes it served (fabledcurator-agent:2026.7.17 still - # resolves to July's image), or it is not a pin — and family rule - # 145 already says where the refresh goes instead: a rebuild with - # different contents publishes only the MOVING tag, never the immutable - # one. A scheduled channel-only refresh is tracked separately (#3154); - # it does not belong in the push path. + # BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops + # picking up base-image updates. Milestone 318 removed the argument this + # used to need rather than answering it — with no version tags there is + # no immutable name a refresh could contradict, and rule 145 already + # allows a rebuild with different contents to republish a MOVING tag. + # So a refresh is just a build. A scheduled channel-only one is tracked + # separately (#3154); it does not belong in the push path. - name: Is this content already published? id: reuse env: @@ -1059,8 +1059,8 @@ jobs: # Registry-side manifest copy: no layer transfer, no local daemon, no # rebuild. Each -t becomes another reference to the SAME manifest the - # channel tag already holds, so :c- and the date pin are - # byte-identical to what is published rather than a lookalike rebuild. + # channel tag already holds, so :c- is byte-identical to what is + # published rather than a lookalike rebuild. # # Runs on EVERY reuse, which is what keeps family rule 146 true: a # rolling channel refreshes itself, so skipping a build must never mean diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e7f71c8..7620eca 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -35,7 +35,10 @@ jobs: - name: Ruff lint # 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). - 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 # 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, diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..6a9bf44 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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-` — 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- 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-` 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 diff --git a/Dockerfile b/Dockerfile index 050cc04..f0e184d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,11 +58,17 @@ COPY --from=frontend-builder /build/dist ./frontend/dist # exactly the shape every reader already has to handle. # # 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 -# identical source — put it any earlier and the two channels could never share -# a cached pip install. +# these are the values that differ between builds of otherwise identical +# source — put them any earlier and the two channels could never share a +# 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="" ENV FC_CHANNEL=${FC_CHANNEL} +ARG FC_VERSION="" +ENV FC_VERSION=${FC_VERSION} EXPOSE 8080 diff --git a/README.md b/README.md index 915ffa5..5531d81 100644 --- a/README.md +++ b/README.md @@ -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. 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-` | `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-`. + +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` publishes a Forgejo release listing the +commits since the previous tag; it builds no image. + ## What's in here 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 -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 -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` 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`. -AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`; it runs on -`main` only and is cached per version, since AMO rejects a re-signed version. +AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`. +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 diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 868bab2..e79e31a 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -7,13 +7,13 @@ from __future__ import annotations import asyncio import hashlib import hmac -import os import re from pathlib import Path from quart import Blueprint, jsonify, request from sqlalchemy import select +from ..build_info import FC_CHANNEL as _FC_CHANNEL from ..extensions import get_session from ..models import AppSetting from ..services.extension_service import ( @@ -33,10 +33,13 @@ XPI_DIR = Path("/app/frontend/dist/extension") _XPI_VERSION_RE = re.compile(r"fabledcurator-(?P[\w.-]+)\.xpi$") # 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 -# build, or for any image predating the field. Tests override by monkeypatching -# this constant, same as XPI_DIR above. -FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip() +# time (milestone 271 step 7). Read from build_info rather than the environment +# a second time: /api/health reports the same value, and two independent +# `os.environ.get` calls are two things that can drift. +# +# 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: diff --git a/backend/app/api/health.py b/backend/app/api/health.py index ab4918e..7da388c 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -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(): - return {"status": "ok"}, 200 + body = {"status": "ok"} + if FC_VERSION: + body["version"] = FC_VERSION + if FC_CHANNEL: + body["channel"] = FC_CHANNEL + return body, 200 diff --git a/backend/app/build_info.py b/backend/app/build_info.py new file mode 100644 index 0000000..7c04bbf --- /dev/null +++ b/backend/app/build_info.py @@ -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() diff --git a/ci-requirements.md b/ci-requirements.md index 0be64a8..f57e46d 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -9,14 +9,17 @@ git.fabledsword.com/bvandeusen/ci-python:3.14 ## Image deps used - 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) -- 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 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 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`, @@ -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 modules, so the specs exercise exactly the bytes packaged into the XPI. - **`extension/scripts/packaging.sh` is the single definition of what ships - inside the XPI.** Two consumers read from it rather than keeping their own - copy: web-ext's `--ignore-files` (`extension/package.json`), and the `git log` - pathspec inside the script's own version derivation. It was three until - 2026-08-27 — `ci.yml`'s `extension-version` guard held the third and went when - the manual bump it guarded did (milestone 271 step 5). Hand-kept copies of - that one fact is what allowed issue #2397, so `extension/test/version.spec.js` - asserts no workflow has reintroduced a literal `:(exclude)extension/…`. + inside the XPI.** Three consumers read from it rather than keeping their own + copy: web-ext's `--ignore-files` (`extension/package.json`), the `git log` + pathspec inside the script's own version derivation, and `scripts/artifacts.sh`, + which appends the extension's set to web's because the web image bundles the + signed XPI. Hand-kept copies of that one fact is what allowed issue #2397, so + `extension/test/version.spec.js` asserts no workflow has reintroduced a + 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 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 @@ -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; 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. -- Every job that calls `packaging.sh version` checks out with `fetch-depth: 0` — - `build.yml`'s `sign-extension` and `build-web`, and `ci.yml`'s - `extension-version`. A depth-1 clone sees one commit and derives a wrong, - too-low value **rather than failing**, so the full-history checkout is - load-bearing rather than incidental. -- **`FC_CHANNEL` is a build arg, not a runtime setting.** `build.yml` passes - `dev` / `main` to the web image only (the ml and agent images have nothing to - report it to), and `/api/extension/manifest` reports it beside the version so - an install can be traced to a channel. It is declared LAST in the Dockerfile - on purpose: an ARG invalidates every layer below it, and this is the one value - that differs between the dev and main builds of identical source, so placing - it earlier would stop the two channels ever sharing a cached `pip install`. - Empty by default — a local build then reports no channel at all rather than - claiming one. +- Every job that derives anything checks out with `fetch-depth: 0` — all four + `build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test` + (for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and + `release.yml`, which additionally walks the tag graph. A depth-1 clone sees + one commit and derives a wrong, too-low value **rather than failing**, so the + full-history checkout is load-bearing rather than incidental. +- **`scripts/artifacts.sh` is the same shape one level up: one definition per + artifact of what it is built from, and the two values derived from it.** + `revision` (12 hex of the newest commit touching that set) and `version` + (`YYYY.MM.DD.HHMM` UTC, rule 148). Four artifacts, four independent answers, + so a push touching only `agent/` leaves web and ml alone. + `tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY + source is covered, so adding a COPY without updating the script fails CI. +- **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 shell expands `test/**` against the working tree and silently narrows the pattern to whatever files exist at that moment — a failure that looks like diff --git a/extension/test/version.spec.js b/extension/test/version.spec.js index 3b75088..ed8a307 100644 --- a/extension/test/version.spec.js +++ b/extension/test/version.spec.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' 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', () => { // ci.yml used to substitute `packaging.sh pathspec` directly, for the // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the // set today, and nothing should start to: a literal :(exclude)extension/... // in a workflow means someone bypassed the shared definition, which is - // exactly the drift #2397 was about. Asserted across all three rather than - // against one named consumer, so it keeps holding as consumers come and go. + // exactly the drift #2397 was about. + expect( + WORKFLOWS.length, + 'no workflows found — the glob is not looking where it thinks' + ).toBeGreaterThan(2) for (const wf of WORKFLOWS) { const text = readText('..', '.forgejo', 'workflows', wf) expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//) diff --git a/frontend/src/stores/system.js b/frontend/src/stores/system.js index b3f772f..eae4059 100644 --- a/frontend/src/stores/system.js +++ b/frontend/src/stores/system.js @@ -5,6 +5,18 @@ import { useApi } from '../composables/useApi.js' export const useSystemStore = defineStore('system', () => { const api = useApi() 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 statsLoading = ref(false) @@ -12,8 +24,17 @@ export const useSystemStore = defineStore('system', () => { try { const body = await api.get('/api/health') 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 { 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, + } }) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 21fa1db..35381cb 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -54,6 +54,21 @@ + + +
+ FabledCurator {{ system.buildVersion || 'unknown' }} + · {{ system.buildChannel }} +
diff --git a/frontend/test/systemBuild.spec.js b/frontend/test/systemBuild.spec.js new file mode 100644 index 0000000..eef4908 --- /dev/null +++ b/frontend/test/systemBuild.spec.js @@ -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) + }) +}) diff --git a/scripts/artifacts.sh b/scripts/artifacts.sh index 0b19cb2..a7cfcdf 100755 --- a/scripts/artifacts.sh +++ b/scripts/artifacts.sh @@ -3,6 +3,11 @@ # version derived from it. Milestone 313; generalises the shape # 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 # 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 @@ -56,6 +61,33 @@ ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic ale # it: this is deliberately NOT `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() { echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2 @@ -71,7 +103,7 @@ ext_paths() { cmd_paths() { case "$1" in - web) echo "$WEB_PATHS $(ext_paths)" ;; + web) echo "$WEB_PATHS $DERIVER $(ext_paths)" ;; ml) echo "$ML_PATHS" ;; agent) echo "$AGENT_PATHS" ;; extension) ext_paths ;; @@ -100,13 +132,6 @@ fmt() { (cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1") } -# Leading zeros stripped so every segment is a plain integer — some version -# validators reject `08`, and a leading zero buys nothing. `0000` (midnight) -# must survive as `0`, not as the empty string. -strip0() { - printf '%s' "$1" | sed -e 's/^0*//' -e 's/^$/0/' -} - # The IDENTITY of an artifact's content: the commit its shipped files last # changed in. This is what decides whether a build can be skipped. # @@ -124,17 +149,32 @@ cmd_revision() { echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12 } -# The ORDERING KEY: full precision, YYYY.M.D.HHMM. Used by the extension, -# where the value is what Firefox compares to decide whether an update exists -# — two same-day builds MUST be distinguishable or the second never reaches -# anyone. +# The VERSION: `YYYY.MM.DD.HHMM`, zero-padded, UTC. One shape across the whole +# family (note #3127 §1, rule 148) — the number an instance reports about +# itself, and, with a `v` in front, the release tag naming the same build. +# +# 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() { sha=$(echo "$(newest "$1")" | cut -d' ' -f2) - printf '%s.%s.%s.%s\n' \ - "$(fmt "$sha" %Y)" \ - "$(strip0 "$(fmt "$sha" %m)")" \ - "$(strip0 "$(fmt "$sha" %d)")" \ - "$(strip0 "$(fmt "$sha" %H%M)")" + # One git call for the whole string rather than four and a sed. git's + # format-local takes the complete format, and doing it in pieces was only + # ever there to strip the padding between them. + fmt "$sha" '%Y.%m.%d.%H%M' } [ $# -ge 2 ] || usage diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..0511d99 --- /dev/null +++ b/scripts/release_notes.py @@ -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-`, 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 `, 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 `^` 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-` 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 ''}") + + 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() diff --git a/tests/test_artifact_identity.py b/tests/test_artifact_identity.py index e234ab2..60d086f 100644 --- a/tests/test_artifact_identity.py +++ b/tests/test_artifact_identity.py @@ -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` 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 is genuinely the commit those paths last changed in. +## version + +`YYYY.MM.DD.HHMM`, zero-padded, UTC — one shape across the family (note #3127 +§1, rule 148), so the string this project emits is the same string its siblings +emit. Two nearly-identical formats are more dangerous than two obviously +different ones, and the only thing keeping them identical is a test. + The identity-TAG tests this file used to hold are gone with the tag. There is no longer a `CHANNELLED` list to drift (the channel is which tag you inspect), and no `identity` subcommand to refuse an unqualified call. """ from __future__ import annotations +import os import re import subprocess from pathlib import Path @@ -40,6 +54,9 @@ ARTIFACTS = ("web", "ml", "agent", "extension") # 12 hex chars — the prefix build.yml stamps and compares. _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 # 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) assert _REVISION.match(first), f"{first!r} is not a 12-char hex revision" assert first == revision(artifact), "revision is not stable across calls" + + +@pytest.mark.parametrize("artifact", ARTIFACTS) +def test_version_is_zero_padded_calver(artifact): + """The family shape, pinned. + + Padding was stripped until 2026-08-28 on the reasoning that each segment + should read as a plain integer — which never held, since comparison strips + leading zeros on parse anyway. What it did do was make this project emit + `2026.8.28.1432` while a sibling emitted `2026.08.28.1432`: two shapes one + character apart, which is the hard kind of difference to notice. + + Also catches the midnight case. A `%H%M` of `0322` must survive as `0322`; + the old strip-leading-zeros helper turned it into `322`, silently changing + a four-digit field into three. + """ + value = artifacts("version", artifact).strip() + assert _VERSION.match(value), ( + f"{artifact} derives {value!r}, which is not zero-padded " + f"YYYY.MM.DD.HHMM. Note #3127 §1 and rule 148 both specify the padded " + f"form, and a release tag is this string with a `v` in front." + ) + + +@pytest.mark.parametrize("artifact", ARTIFACTS) +def test_version_and_revision_describe_the_same_commit(artifact): + """They are derived independently and must not be able to disagree. + + A build reports the version and skips on the revision, so a divergence + would mean an instance naming one commit while carrying another's bytes — + unfalsifiable from outside, since both values look perfectly well-formed. + """ + sha = newest_by_commit_time(artifact) + stamped = subprocess.run( + ["git", "show", "-s", "--format=%cd", "--date=format-local:%Y.%m.%d.%H%M", sha], + capture_output=True, text=True, check=True, cwd=ROOT, + env={"TZ": "UTC", "PATH": os.environ.get("PATH", "")}, + ).stdout.strip() + assert artifacts("version", artifact).strip() == stamped + assert sha.startswith(revision(artifact)) diff --git a/tests/test_artifact_paths.py b/tests/test_artifact_paths.py index 8063517..888d4c3 100644 --- a/tests/test_artifact_paths.py +++ b/tests/test_artifact_paths.py @@ -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, 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. + +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 @@ -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(): - """packaging.sh ships in no image, yet it belongs in the sets that bundle - the XPI — because it decides the version string build.yml stamps into the - packaged manifest.json. Changing the derivation changes the shipped bytes. +# A file that DECIDES an artifact's identity is part of what that artifact is +# built from, even though it is copied into no image. Both entries here are the +# same finding twice — #3156 for packaging.sh, #3202 for artifacts.sh — and +# 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- 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- cache and gets signed, while web's revision has not moved, so - the reuse path republishes the old image and the fresh signature is - orphaned. Guarded for web and the extension both, since web bundles what - the extension produces. - """ - for artifact in ("extension", "web"): + +@pytest.mark.parametrize("path, artifacts", DERIVERS, ids=lambda v: str(v)) +def test_a_version_deriver_is_in_the_set_of_what_it_decides(path, artifacts): + for artifact in artifacts: inc = includes(artifact) excluded = [ p[len(":(exclude)"):] for p in declared_paths(artifact) if p.startswith(":(exclude)") ] - path = "extension/scripts/packaging.sh" assert any(covered_by(path, i) for i in inc), ( - f"{path} is not in the {artifact} path set" + 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( 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 # beside shipped code but never reaches an image, and including it # 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/ruff.toml"), ("agent", "agent/docker-compose.yml"), diff --git a/tests/test_health.py b/tests/test_health.py index 2a2069d..2041918 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -9,3 +9,63 @@ async def test_health_returns_ok(client): assert response.status_code == 200 body = await response.get_json() 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"} diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..129be14 --- /dev/null +++ b/tests/test_release_notes.py @@ -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=" 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-` 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=" 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