ci: a release publishes a changelog, not an image (318 step 7)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m52s
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m52s
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 source matches. That left the tag with nothing to do at all. This is the job 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). The previous release is found by walking ANCESTRY, not by sorting a list. That is load-bearing here specifically: rule 148 moved the tag shape from `v26.05.22.0` to `v2026.08.28.2208`, and lexicographically `v2026...` sorts BEFORE `v26...` — the third character is `0` against `6`. A sorted implementation would reach back past every new-shape tag to the newest old-shape one and publish months of commits as "changes since", looking entirely correct while doing it. `git describe --exclude` is immune to the shape change, and reachability is the more honest question anyway. The publisher GETs and PATCHes rather than POSTing and recovering the id from a 409 — note #3127 §6.7, which is ThoughtSync #2182's bug. A `v*` tag is created once so the conflict path is rare, but "rare" is how that one survived to be found somewhere else. Cross-checks are reported on the release, not enforced. The tag is already pushed by the time this runs, so failing would leave the operator with a tag, no release, and a red lane to explain it — while the release is still the useful object. It says so at the top when the tag names a version the web image does not report, or when the commit is not on `main` and the `:c-` rollback refs it lists were never published. Nothing runs on a schedule and nothing auto-tags on merge. Release tags are bookmarks (note #3127 §0); FC went twelve weeks without one and nothing was wrong. Also here: - `scripts/` joins the ruff lane. release_notes.py runs only on a tag push, so a syntax error there would otherwise surface at the one moment nobody wants to be debugging a workflow. - version.spec.js reads the workflow directory instead of listing three files by hand. Its own comment says the assertion should survive consumers coming and going; the hardcoded list was the part that could not, and release.yml would have joined the directory without joining the check. Tests build a synthetic history spanning the tag-shape change rather than leaning on this repo's tags, so the span assertion holds whether or not a checkout brought the tags along — a span test that quietly skips is worse than one that fails.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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\//)
|
||||
|
||||
@@ -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()
|
||||
@@ -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