A version somebody can read, instead of a sha nobody can

Updates follow a channel now. `stable` is the newest vX.Y.Z tag; `edge` is the
branch tip, which is what this did before. Stable is the default, because a
branch tip is not a release -- following one means deploying whatever was pushed
five minutes ago, possibly mid-feature, which is right for whoever builds this
and wrong for whoever runs it. The page can now say "running 1.0.0, 1.1.0
available" rather than showing two shas and leaving somebody to guess.

Read with git plumbing and never a forge API, for three reasons in the order
they bite. It would need a token on the deployment host -- a credential that can
reach the repository, sitting on a box, to answer a read-only question about
version numbers. It would tie this to one forge, so a fork on GitHub gets
nothing. And it breaks: checked against the Gitea this is developed on, `tea
whoami` works and `tea releases list` returns a 500 from a server-side panic
about token scopes, so a page resting on that endpoint would have shipped
already broken.

Release notes still travel, inside the annotated tag object, which
`git for-each-ref` reads with no API anywhere.

Two details that are only obvious after getting them wrong. A tag with a suffix
is not a release: git's version sort puts v1.1.0-rc1 *above* v1.1.0, so
accepting one would step a stable host onto a candidate on the strength of a
hyphen. And `--sort=-v:refname` rather than a lexical sort, which puts v1.9.0
above v1.10.0 and does it silently the first time a project reaches ten of
anything -- there is a test.

What is running is `git describe --tags --always`, so it reads "1.0.0" at a tag,
"1.0.0-7-gd4f56d" seven commits past one, and a bare sha before the first
release ever exists. That last case is what `--always` is for. When it lands
exactly on a tag whose name disagrees with __version__, the page says so: a tag
cut before the version bump names a release nobody can identify afterwards, and
the check costs no subprocess because both facts are already in hand.

update.sh resolves the channel the same way and detaches at the tag rather than
resetting -- a `reset --hard <tag>` while on main would move the local branch to
it, which is a rewrite of a ref nobody asked to rewrite. A host with no tags
falls back to the branch and says so, which is every host until the release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 20:42:03 +02:00
parent c3bb6c9eaf
commit 0c438a6333
11 changed files with 551 additions and 124 deletions
+113 -3
View File
@@ -14,6 +14,29 @@ from lembas.config import settings
from lembas.services import updates
@pytest.fixture
def tagged():
"""A release tag on HEAD, with notes in the annotated tag object.
Made and removed around the test rather than assumed: this repository has no
tags until the release, and a test that needed one to exist would pass once
and then start failing for the wrong reason.
"""
import subprocess
root = updates.checkout_dir()
subprocess.run(
["git", "tag", "-a", "v9.9.9", "-m", "Ninth release.\n\n- one thing"],
cwd=root, check=True, capture_output=True,
)
try:
yield "v9.9.9"
finally:
subprocess.run(
["git", "tag", "-d", "v9.9.9"], cwd=root, check=False, capture_output=True
)
@pytest.fixture(autouse=True)
def clean():
updates.clear_request()
@@ -71,6 +94,10 @@ def test_the_running_commit_is_reported(db):
assert state.version
assert state.head is not None
assert len(state.head.short) == 7
# `git describe`, so a tagged checkout says "1.0.0" and an untagged one says
# a short sha rather than failing outright -- which is what `--always` is
# there for, and is every repository before its first release.
assert state.running
def test_a_commit_subject_with_separators_survives():
@@ -93,6 +120,79 @@ def test_a_git_that_is_not_there_is_a_message_and_not_a_crash(db, monkeypatch, t
assert "git" in state.error
# --- Channels ---------------------------------------------------------------------
def test_only_a_release_shaped_tag_counts_as_one(db):
"""`v1.1.0-rc1` sorts *above* `v1.1.0` under git's version sort, so accepting
a suffix would step a stable instance onto a release candidate on the
strength of a hyphen."""
for good in ("v1.0.0", "1.0.0", "v10.2.30"):
assert updates.RELEASE_TAG.match(good), good
for bad in ("v1.1.0-rc1", "v1.0", "nightly", "v1.0.0+build", "release-1"):
assert not updates.RELEASE_TAG.match(bad), bad
def test_stable_picks_the_newest_release_and_reads_its_notes(db, tagged):
"""Notes travel inside the annotated tag object, so there is no forge API
anywhere in this -- which matters, because the API this was checked against
returns a 500 from a server-side panic."""
root = updates.checkout_dir()
assert updates.release_tags(root)[0] == "v9.9.9"
target = updates.resolve_target(root, updates.CHANNEL_STABLE, "main")
assert target.label == "9.9.9"
assert "one thing" in target.notes
def test_a_version_sort_is_not_a_lexical_one(db, tagged):
"""`v1.10.0` above `v1.9.0`, which a lexical sort gets wrong -- and gets
wrong silently the first time a project reaches ten of anything."""
import subprocess
root = updates.checkout_dir()
for tag in ("v1.9.0", "v1.10.0"):
subprocess.run(["git", "tag", tag], cwd=root, check=True, capture_output=True)
try:
tags = updates.release_tags(root)
assert tags.index("v1.10.0") < tags.index("v1.9.0")
finally:
subprocess.run(
["git", "tag", "-d", "v1.9.0", "v1.10.0"], cwd=root, check=False,
capture_output=True,
)
def test_edge_follows_the_branch_tip(db):
root = updates.checkout_dir()
target = updates.resolve_target(root, updates.CHANNEL_EDGE, updates.branch_name())
# A short sha rather than a version: the two channels answer "what version
# is this?" with different kinds of thing, which is why the label is stored
# rather than derived.
assert target is None or len(target.label) == 7
def test_stable_with_nothing_tagged_says_so(db, monkeypatch):
""""Nothing has been released" is not "up to date" -- the second would be
true of an empty repository and useless."""
monkeypatch.setattr(updates, "release_tags", lambda root: [])
monkeypatch.setattr(updates, "channel_name", lambda: updates.CHANNEL_STABLE)
state = updates.read()
assert state.no_releases is True
assert state.up_to_date is False
def test_a_tag_that_disagrees_with_the_version_is_flagged(db, tagged):
"""A tag cut before the version bump names a release nobody can identify
afterwards. Costs no subprocess: both facts are already in hand."""
state = updates.read()
assert state.version_mismatch == "9.9.9"
assert state.version != "9.9.9"
# --- The request ------------------------------------------------------------------
def test_the_request_file_carries_nothing_that_reaches_a_command(db):
"""No branch, no ref, no arguments. Being able to press the button is
@@ -114,11 +214,20 @@ def test_a_request_can_be_withdrawn(db):
assert updates.pending() is False
def test_the_branch_comes_from_the_environment_and_not_a_request(db):
"""Deployment configuration rather than an instance setting: it decides what
def test_the_channel_and_branch_come_from_the_environment(db):
"""Deployment configuration rather than instance settings: they decide what
code runs here, and a value a web administrator could edit would turn "you
may deploy the branch" into "you may deploy anything"."""
may deploy the channel" into "you may deploy anything"."""
assert updates.branch_name() == settings.update_branch
assert updates.channel_name() in updates.CHANNELS
def test_an_unknown_channel_falls_back_to_stable(db, monkeypatch):
"""A value stored by an older version, or a typo in lembas.env. Falling back
to edge would silently start deploying unreleased work."""
monkeypatch.setattr(settings, "update_channel", "nightly")
assert updates.channel_name() == updates.CHANNEL_STABLE
# --- The page ---------------------------------------------------------------------
@@ -203,6 +312,7 @@ def test_the_helper_units_take_no_branch_from_the_request():
unit = (root / "deploy/lembas-update.service").read_text()
assert "__UPDATE_BRANCH__" in unit
assert "__UPDATE_CHANNEL__" in unit
assert "update-requested" in unit # deleted, not read
assert "ExecStart=/bin/bash __PREFIX__/app/deploy/update.sh" in unit
# Deleted before the script runs, or the path unit re-arms on a file that is