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:
+113
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user