CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
7.5 KiB
Python
181 lines
7.5 KiB
Python
"""`/api/version` reports three values, and never folds them together.
|
|
|
|
WHAT THIS IS ABOUT. A CI that sets `BUILD_VERSION` to the CHANNEL makes a
|
|
running instance answer "which build are you?" with the name of a branch — and
|
|
when a deploy behaves as though it holds older code, the one endpoint whose job
|
|
is to settle that cannot.
|
|
|
|
The three values answer different questions and so cannot be one value:
|
|
|
|
version the NAME, from COMMIT time — "is this the same code?"
|
|
build the ORDERING KEY, BUILD time — "may this be installed over that?"
|
|
channel its own field — "which line is this?"
|
|
|
|
These pin the SHAPE the lanes emit, not the values — a test asserting today's
|
|
timestamp would fail tomorrow, and one asserting the format catches the thing
|
|
that actually breaks: a channel creeping back into the name, or an ordering
|
|
key that is not orderable.
|
|
"""
|
|
import os
|
|
import pathlib
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
CI = pathlib.Path(__file__).resolve().parents[1] / ".forgejo/workflows/ci.yml"
|
|
|
|
# The NAME's shape: four dot-separated numeric fields, zero-padded, and
|
|
# nothing else. A channel token anywhere in here is the bug this file exists
|
|
# to prevent.
|
|
NAME_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
|
|
|
|
|
|
_ENV_KEYS = ("APP_VERSION", "APP_BUILD_KEY", "APP_CHANNEL", "APP_COMMIT")
|
|
|
|
|
|
def _version_payload(env: dict) -> dict:
|
|
"""The real payload builder, under a controlled environment.
|
|
|
|
Calls `build_version_payload` rather than the route: the payload is the
|
|
behaviour, and reaching it through an app and a request context would
|
|
make these tests depend on app startup to assert a dict. The route is a
|
|
one-line `jsonify` wrapper over this.
|
|
"""
|
|
from grimoire.routes.api import build_version_payload
|
|
|
|
with patch.dict(os.environ, env, clear=False):
|
|
# patch.dict cannot REMOVE, and "absent" is exactly what several of
|
|
# these assert — so anything the caller left out is cleared.
|
|
for key in _ENV_KEYS:
|
|
if key not in env:
|
|
os.environ.pop(key, None)
|
|
return build_version_payload()
|
|
|
|
|
|
def test_the_three_values_are_three_fields():
|
|
"""The headline. One field cannot answer three questions, and the failure
|
|
mode of trying is silent: the string looks plausible and orders wrong."""
|
|
out = _version_payload({
|
|
"APP_VERSION": "2026.08.31.0403",
|
|
"APP_BUILD_KEY": "3505443",
|
|
"APP_CHANNEL": "stable",
|
|
"APP_COMMIT": "b267037",
|
|
})
|
|
assert out["version"] == "2026.08.31.0403"
|
|
assert out["build"] == 3505443
|
|
assert out["channel"] == "stable"
|
|
assert out["commit"] == "b267037"
|
|
|
|
|
|
def test_the_channel_is_never_inside_the_name():
|
|
"""The regression itself. `{"version": "main"}` is what this catches."""
|
|
out = _version_payload({
|
|
"APP_VERSION": "2026.08.31.0403", "APP_CHANNEL": "stable",
|
|
})
|
|
assert NAME_RE.match(out["version"]), (
|
|
f"the version name is {out['version']!r} — not YYYY.MM.DD.HHMM. A "
|
|
f"channel or branch name here is the channel folded into the version."
|
|
)
|
|
assert "stable" not in out["version"]
|
|
|
|
|
|
def test_the_ordering_key_is_an_INTEGER():
|
|
"""A string ordering key is how a comparison silently becomes
|
|
lexicographic — "9" > "10" — which reads fine and orders wrong."""
|
|
out = _version_payload({"APP_VERSION": "x", "APP_BUILD_KEY": "3505443"})
|
|
assert isinstance(out["build"], int)
|
|
assert not isinstance(out["build"], bool)
|
|
|
|
|
|
def test_unknown_values_are_ABSENT_not_empty():
|
|
"""A local build genuinely has no ordering key and no channel. Emitting
|
|
`""` or a placeholder would let it claim a position in an update order it
|
|
is not part of; a reader must see "cannot be ordered", not zero."""
|
|
out = _version_payload({"APP_VERSION": "dev"})
|
|
assert out == {"version": "dev"}
|
|
assert "build" not in out and "channel" not in out and "commit" not in out
|
|
|
|
|
|
def test_an_empty_env_var_counts_as_absent():
|
|
"""Docker sets an ARG with no default to the empty string, so "unset" and
|
|
"set to nothing" both reach the handler as ''."""
|
|
out = _version_payload({
|
|
"APP_VERSION": "dev", "APP_CHANNEL": "", "APP_BUILD_KEY": "",
|
|
"APP_COMMIT": " ",
|
|
})
|
|
assert out == {"version": "dev"}
|
|
|
|
|
|
def test_a_malformed_ordering_key_is_dropped_not_passed_through():
|
|
"""A reader that cannot order is correct; one that orders on garbage is
|
|
not. Dropping it degrades to "unorderable", which is a state the caller
|
|
already has to handle."""
|
|
out = _version_payload({"APP_VERSION": "dev", "APP_BUILD_KEY": "main"})
|
|
assert "build" not in out
|
|
|
|
|
|
def test_the_channel_is_reported_verbatim():
|
|
"""Never validated against an enum — a build claiming something
|
|
unexpected is better shown than dropped."""
|
|
out = _version_payload({"APP_VERSION": "dev", "APP_CHANNEL": "canary"})
|
|
assert out["channel"] == "canary"
|
|
|
|
|
|
# ── The lane, as CI actually writes it ─────────────────────────────────
|
|
|
|
def test_ci_does_not_stamp_the_channel_as_the_version():
|
|
"""The bug lived in the workflow, not the handler. A correct handler fed
|
|
`BUILD_VERSION=main` still reports a branch name."""
|
|
text = CI.read_text()
|
|
assert "BUILD_VERSION=${{ steps.tags.outputs.build_name }}" in text, (
|
|
"CI no longer passes the derived NAME as BUILD_VERSION. If it is "
|
|
"passing a branch or channel again, /api/version is lying."
|
|
)
|
|
for wrong in ('BUILD_VERSION="main"', 'BUILD_VERSION="dev"'):
|
|
assert wrong not in text, (
|
|
f"CI sets {wrong} — that is the channel in the version field, "
|
|
f"folding the three values into one."
|
|
)
|
|
|
|
|
|
def test_ci_derives_the_name_from_COMMIT_time_and_the_key_from_BUILD_time():
|
|
"""The two clocks are deliberate and easy to "tidy" into one.
|
|
|
|
The name must come from the commit so two lanes building one source agree;
|
|
the key must come from the build so it cannot go backwards when an older
|
|
commit is rebuilt. Collapsing them breaks whichever question loses.
|
|
"""
|
|
text = CI.read_text()
|
|
assert "git log --format=%ct -1 HEAD" in text, (
|
|
"the version NAME is no longer derived from commit time — two lanes "
|
|
"building the same commit will now report different strings"
|
|
)
|
|
assert "$(date -u +%s) - 1577836800" in text, (
|
|
"the ORDERING KEY is no longer minutes-since-2020 from build time; "
|
|
"if it now comes from the commit it can go backwards on a rebuild"
|
|
)
|
|
|
|
|
|
def test_ci_passes_all_three_plus_the_commit():
|
|
text = CI.read_text()
|
|
for arg in ("BUILD_KEY=", "BUILD_CHANNEL=", "BUILD_COMMIT="):
|
|
assert arg in text, f"CI no longer passes {arg} to the image build"
|
|
|
|
|
|
@pytest.mark.parametrize("commit_epoch,expected", [
|
|
# Midnight, where a naive formatter drops the leading zeros and yields
|
|
# "2026.01.05.0" — the case the padding exists for.
|
|
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
|
|
(datetime(2026, 1, 5, 0, 7, tzinfo=timezone.utc), "2026.01.05.0007"),
|
|
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
|
|
(datetime(2026, 8, 31, 4, 3, tzinfo=timezone.utc), "2026.08.31.0403"),
|
|
])
|
|
def test_the_name_format_zero_pads_every_field(commit_epoch, expected):
|
|
"""`date -u +%Y.%m.%d.%H%M` is what CI runs; this pins what that must
|
|
produce, so a reformat that loses zero-padding fails here rather than in
|
|
a comparison months later."""
|
|
assert commit_epoch.strftime("%Y.%m.%d.%H%M") == expected
|
|
assert NAME_RE.match(expected)
|