dev → main: rule usage telemetry, the plugin's derived version, and the backlog since b267037
#136
@@ -327,6 +327,14 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Rule 149 asks for this on any job deriving the version NAME. The
|
||||||
|
# name here comes from HEAD's commit TIME, which a depth-1 clone
|
||||||
|
# already has — but the rule states it unconditionally because the
|
||||||
|
# failure it guards is silent (a too-low value, every lane green),
|
||||||
|
# and a later change to how the name is derived would inherit the
|
||||||
|
# landmine rather than the guard.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate image tags and version
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
@@ -339,7 +347,27 @@ jobs:
|
|||||||
# the runner log on commit 2a374d9.
|
# the runner log on commit 2a374d9.
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||||
BUILD_VERSION="dev"
|
|
||||||
|
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
|
||||||
|
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
|
||||||
|
# image self-reported {"version":"main"}, a channel name where a
|
||||||
|
# build identifier belongs. That cost a debugging session: with the
|
||||||
|
# deploy misbehaving, nothing on the running instance could say
|
||||||
|
# which commit was serving it.
|
||||||
|
|
||||||
|
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
|
||||||
|
# since 2020-01-01. Never a commit count (not monotonic across
|
||||||
|
# branches) and never commit time (goes DOWN when an older
|
||||||
|
# commit is rebuilt).
|
||||||
|
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
||||||
|
|
||||||
|
# 2. NAME — COMMIT time, so the same source reports the same string
|
||||||
|
# on every lane and the channel is the only thing that differs.
|
||||||
|
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
||||||
|
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
||||||
|
|
||||||
|
# 3. CHANNEL — its own value. Never a suffix, never a segment.
|
||||||
|
CHANNEL="dev"
|
||||||
case "${{ github.ref }}" in
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
@@ -348,15 +376,17 @@ jobs:
|
|||||||
# main IS the production line: publish :latest (plus the :<sha>
|
# main IS the production line: publish :latest (plus the :<sha>
|
||||||
# set above). No separate :main tag.
|
# set above). No separate :main tag.
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||||
BUILD_VERSION="main"
|
CHANNEL="stable"
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
refs/tags/*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
CHANNEL="stable"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
|
||||||
|
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
|
||||||
|
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||||
@@ -386,7 +416,15 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
tags: ${{ steps.tags.outputs.value }}
|
tags: ${{ steps.tags.outputs.value }}
|
||||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
# All three, plus the commit — rule 145: the registry's identity for
|
||||||
|
# a build (:<sha>) and the artifact's identity for itself must
|
||||||
|
# agree, and they can only be checked against each other if the
|
||||||
|
# artifact says which commit it is.
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
||||||
|
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
||||||
|
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
||||||
|
BUILD_COMMIT=${{ github.sha }}
|
||||||
# Registry-backed layer cache. Pull from :cache to prime
|
# Registry-backed layer cache. Pull from :cache to prime
|
||||||
# BuildKit, push updated layers back to :cache so the next
|
# BuildKit, push updated layers back to :cache so the next
|
||||||
# build starts warm even if the runner's local cache was
|
# build starts warm even if the runner's local cache was
|
||||||
|
|||||||
+21
-2
@@ -41,10 +41,29 @@ COPY alembic/ alembic/
|
|||||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
||||||
# Falls back to "dev" for local / untagged builds
|
#
|
||||||
|
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
|
||||||
|
# string on every lane for the same source, so it answers "is this the same
|
||||||
|
# code?" rather than "which lane built it?".
|
||||||
|
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
|
||||||
|
# the only value anything may compare to decide what is newer.
|
||||||
|
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
|
||||||
|
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
|
||||||
|
# it was published under (rule 145).
|
||||||
|
#
|
||||||
|
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
|
||||||
|
# local build genuinely has no ordering key or channel, and the endpoint says
|
||||||
|
# so by omitting them. Inventing values would make a local image claim a
|
||||||
|
# position in an update order it is not part of.
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
|
ARG BUILD_KEY=
|
||||||
|
ARG BUILD_CHANNEL=
|
||||||
|
ARG BUILD_COMMIT=
|
||||||
ENV APP_VERSION=$BUILD_VERSION
|
ENV APP_VERSION=$BUILD_VERSION
|
||||||
|
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||||
|
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||||
|
ENV APP_COMMIT=$BUILD_COMMIT
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -10,6 +10,61 @@ async def health():
|
|||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def build_version_payload() -> dict:
|
||||||
|
"""What build is this, separated into the values that answer different
|
||||||
|
questions (rule 149).
|
||||||
|
|
||||||
|
UNTIL 2026-08-31 THIS RETURNED THE CHANNEL. `BUILD_VERSION` in CI was
|
||||||
|
literally "dev" / "main" / the tag, so a running instance reported
|
||||||
|
`{"version": "main"}` — a channel name sitting where a build identifier
|
||||||
|
belongs. The cost was concrete: with a deploy misbehaving, nothing on the
|
||||||
|
instance could say which commit was serving it, and the one endpoint whose
|
||||||
|
job that is answered with the name of a branch.
|
||||||
|
|
||||||
|
The three values, and why they are three:
|
||||||
|
|
||||||
|
- `version` — the NAME, `YYYY.MM.DD.HHMM` from COMMIT time. Answers "is
|
||||||
|
this the same code?", so two channels carrying one commit report the
|
||||||
|
same string.
|
||||||
|
- `build` — the ORDERING KEY, minutes since 2020-01-01 from BUILD time.
|
||||||
|
Answers "may this be installed over that?". The ONLY value anything may
|
||||||
|
compare; it is monotonic by construction, which neither a commit count
|
||||||
|
(branches diverge) nor a commit time (rebuilds go backwards) is.
|
||||||
|
- `channel` — its own field, never folded into the name.
|
||||||
|
|
||||||
|
Plus `commit`, so the artifact's claim about itself can be checked against
|
||||||
|
the `:<sha>` it was published under (rule 145).
|
||||||
|
|
||||||
|
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key
|
||||||
|
and no channel, and saying so is honest; emitting `""` or a placeholder
|
||||||
|
would let it claim a position in an update order it is not part of. A
|
||||||
|
reader must treat a missing `build` as "cannot be ordered", not as zero.
|
||||||
|
"""
|
||||||
|
payload: dict = {"version": os.environ.get("APP_VERSION", "dev")}
|
||||||
|
|
||||||
|
# Reported verbatim, never validated against an enum — a build claiming
|
||||||
|
# something unexpected is better shown than dropped (rule 149).
|
||||||
|
for key, env in (("channel", "APP_CHANNEL"), ("commit", "APP_COMMIT")):
|
||||||
|
value = (os.environ.get(env) or "").strip()
|
||||||
|
if value:
|
||||||
|
payload[key] = value
|
||||||
|
|
||||||
|
raw_key = (os.environ.get("APP_BUILD_KEY") or "").strip()
|
||||||
|
if raw_key:
|
||||||
|
try:
|
||||||
|
# An INTEGER, not a string. A string ordering key is how a
|
||||||
|
# comparison silently becomes lexicographic — "9" > "10" — which
|
||||||
|
# is the same class of fault as folding the channel in: it reads
|
||||||
|
# fine and orders wrong.
|
||||||
|
payload["build"] = int(raw_key)
|
||||||
|
except ValueError:
|
||||||
|
# A malformed key is omitted rather than passed through: a reader
|
||||||
|
# that cannot order is correct, one that orders on garbage is not.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@api.route("/version")
|
@api.route("/version")
|
||||||
async def version():
|
async def version():
|
||||||
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
|
return jsonify(build_version_payload())
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""`/api/version` reports three values, and never folds them together.
|
||||||
|
|
||||||
|
WHAT THIS IS ABOUT (rule 149). Until 2026-08-31 the endpoint returned
|
||||||
|
`{"version": "main"}` — CI set `BUILD_VERSION` to the CHANNEL, so a running
|
||||||
|
instance answered the question "which build are you?" with the name of a
|
||||||
|
branch. The cost was concrete rather than theoretical: during #3244's live
|
||||||
|
acceptance a deploy was behaving as though it held older code, and the one
|
||||||
|
endpoint whose job is to settle that could not.
|
||||||
|
|
||||||
|
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 scribe.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 2026-08-31 bug returning."
|
||||||
|
)
|
||||||
|
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 (rule 149)."""
|
||||||
|
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"which is the 2026-08-31 regression."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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" — rule 149 names this case specifically.
|
||||||
|
(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)
|
||||||
Reference in New Issue
Block a user