📘 reference — overview/guide, not tied to a code symbol
Validation by Beadloom
doc_sync— same source assync-check.
Releasing horizon-tracker ​
horizon-tracker's version number follows Semantic Versioning. A version number is a promise about a declared public surface and about nothing else, so this guide declares that surface, states which change raises which part of the version, and gives the release steps as .github/workflows/release.yml enforces them. Each release's section of CHANGELOG.md classifies its changes against this guide.
A release is a tag vX.Y.Z on a commit of main. Nothing reaches the host except a release: a merge to main runs CI and deploys nothing.
What the version number promises ​
horizon-tracker is a service, not a library. Its public surface is what an operator and a host depend on: the command the timers run, the configuration it reads and the contract the deploy keeps with the host.
The public surface ​
The
horizoncommand: its subcommands, options and exit codes. The subcommands arecheckin,checkoutandprobe.checkinandcheckouttake--dry-runand--force, and the group takes--versionand--help. The exit codes:Exit code When 0 a run that succeeded or skipped on purpose (a day off, too early, too late), every --dry-run, aprobewhose checks areokor onlydegraded,--version,--help1 a run whose report is FAILED, a probewith a FAILEDportalorchromecheck, and a configuration error in any command2 an unknown subcommand or option (click's usage error) The reference is
docs/services/cli.mdandhorizon --help.The environment variables: their names, defaults and accepted values. Every variable the command reads, the default it takes when the variable is unset or blank, and the values it accepts and refuses. That includes the two retired names,
HT_CHECKIN_WINDOWandHT_CHECKOUT_WINDOW, whose presence makes every command exit 1. The reference is the settings table indocs/domains/infrastructure/features/settings/SPEC.md, and the.envthe deploy writes is section 3.3 of the deployment guide.The deploy contract. The names of the secrets and variables of the GitHub Environment
horizon-vps(sections 3.1 and 3.2 of the deployment guide); the five unit nameshorizon-checkin.service,horizon-checkin.timer,horizon-checkout.service,horizon-checkout.timerandhorizon-bot.service; the layout of/opt/horizon-tracker(the clone,.venv/bin/horizon,.envowned byhorizon:horizonwith mode0600,artifacts/,state/); and the system userhorizonthe units run as.
The promise. Within one major version nothing on this list is removed, renamed or changed in a way that breaks a host configured as documented. A release that does any of those is a new major version, and its change log names the change under Breaking before anything else.
What is not promised ​
- The shape of the Telegram report. The report is prose for a person: its wording, its order and its layout can change in any release, and the version number does not say when.
- Python import paths. The modules, classes and functions under
horizon_tracker.*can move, be renamed or be removed in any release. The wheel and the sdist a release carries are the service's distribution, not a library. The API sections of the node documents underdocs/domains/describe a module's interface to the rest of this codebase and are not part of this promise. - The text
horizon probeandhorizon checkin --dry-run/horizon checkout --dry-runprint. Like the Telegram report, it is prose for a person and can change in a PATCH. Their exit codes are promised, as for every subcommand. This is the owner's decision of 2026-10-21. The reason: the text goes through the message catalogue andHT_LANGUAGE, and no machine reads it, because the deploy's smoke step reads the probe's exit code. A future machine consumer gets a--jsonoption whose schema is then promised, not a frozen text.
How a change sets the version ​
| Change to a public surface | Version part |
|---|---|
| A subcommand or option is removed or renamed | MAJOR |
| The same input gets a different exit code | MAJOR |
| An environment variable is removed or renamed, its default changes, or it gets a different meaning | MAJOR |
| A value a variable accepted is now refused | MAJOR |
| A new variable is required, or an optional one becomes required | MAJOR |
A horizon-vps secret or variable is removed or renamed, or a new one is required | MAJOR |
A unit is renamed, the /opt/horizon-tracker layout changes, or the units run as another user | MAJOR |
| A subcommand or option is added | MINOR |
| An optional variable is added whose default keeps today's behaviour | MINOR |
| A variable accepts a value it refused before | MINOR |
An optional horizon-vps secret or variable is added | MINOR |
| A fix that changes no public surface | PATCH |
A new required variable or secret is MAJOR although it is an addition: a host configured as the previous release documented has no value for it, so the command refuses to start or the deploy's Build .env step stops on the empty value. A change is classified by the highest row it reaches, and the change log states, in one sentence at the top of the release's section, which line made the version what it is.
Where the version is stated ​
pyproject.toml [project] version is the one source. Every other place derives from it or is checked against it:
horizon --versionprints the version of the installed distribution (importlib.metadata), whose metadata is built frompyproject.toml; the package carries no copy.tests/unit/test_cli.pyholds the two equal.uv.lockrecords the project's own version.uv lockrewrites it after a bump. CI installs withuv sync --locked, which refuses a lock that disagrees withpyproject.toml, andverify'slockcheck refuses a tag whoseuv.locklocks another version. The deploy'suv sync --lockedon the host is the last check, a backstop.- The
Current versionline of.claude/CLAUDE.mdis regenerated bybeadloom setup-agentic-flowand checked bybeadloom doctor, whichbeadloom ciruns. - The release tag is checked by the Release workflow's
verifyjob (below).
beadloom version-surface lists every place the project states a version and the instrument that checks each one. It reports four files as checked by nothing: CHANGELOG.md, the work-item documents under .claude/development/docs/features/, and the version placeholder of .github/ISSUE_TEMPLATE/bug_report.yml. A version the change log or a work-item document states is a record of history, true of the release or the plan it describes, and is not a claim about the current version. The bug form's placeholder is an example of the format horizon --version prints. None of them needs a checker.
Which workflow an event starts ​
One workflow per event, so the name of a run says what started it:
| Event | Workflow | What runs |
|---|---|---|
a pull request to main | ci.yml (CI) | lint, types and workflow lint; then the tests on Python 3.12 and 3.13, the Beadloom gate and mutation-per-change; then ci-ok |
a push to main (a merge) | ci.yml (CI) | the same without mutation-per-change; nothing reaches the host |
a tag vX.Y.Z pushed | release.yml (Release) | verify -> gate (CI on the tag's commit) -> deploy -> publish -> portal-build -> portal-deploy |
| Actions -> Release -> Run workflow, naming a tag | release.yml (Release) | the same jobs for an existing release tag: the redeploy |
| Monday 04:23 UTC, or by hand | mutation.yml (Mutation sample) | the weekly mutation sample of the domain |
| a Dependabot pull request | ci.yml (CI) | as any pull request |
CI also runs by hand (Actions -> CI -> Run workflow). ci-ok is the check a pull request must pass. mutation-per-change is not among its needs: its verdict is informative.
Cutting a release ​
The owner cuts releases.
Classify. Read the
[Unreleased]section ofCHANGELOG.mdand the changes merged since the last tag (git log --oneline vA.B.C..origin/main) against the table above. The highest row any change reaches decides MAJOR, MINOR or PATCH.Bump the version on a branch for the release:
bash# set [project] version = "X.Y.Z" in pyproject.toml, then uv lock # uv.lock records the project's new version beadloom setup-agentic-flow # regenerates the Current version line of .claude/CLAUDE.mdverifyrefuses a tag whose versionpyproject.tomldoes not declare at the tagged commit. CI installs withuv sync --locked --extra dev, so a pull request whoseuv.lockwas not rewritten fails CI, andverifyrefuses a tag whoseuv.locklocks another version.Write the change log. Move the
[Unreleased]entries into a section## [X.Y.Z] - YYYY-MM-DDwith Breaking first, then Added, Changed and Fixed, and one sentence at the top naming the line that set the version. Leave## [Unreleased]empty above it, and add the link references at the end of the file:markdown[Unreleased]: https://github.com/zoologov/horizon-tracker/compare/vX.Y.Z...HEAD [X.Y.Z]: https://github.com/zoologov/horizon-tracker/releases/tag/vX.Y.Zverifyextracts the section, without the link references, as the release notes.One pull request,
ci-okgreen, merge.Tag the merge commit and push the tag:
bashgit switch main && git pull --ff-only git tag -a vX.Y.Z -m "vX.Y.Z" # on the merge commit, now main's head git push origin vX.Y.ZA tag cut through the GitHub Releases page starts the same workflow, and
publishthen edits the release that page created instead of creating a second one. That release is visible before the deploy has run, so the tag pushed from a clone is the route this guide follows.The Release workflow runs its jobs in order, each only when the one before it passed:
verify,gate(the full CI throughworkflow_callon the tag's commit),deploy(the host, in the Environmenthorizon-vps; section 6.3 of the deployment guide),publish(the GitHub Release),portal-buildandportal-deploy(the portal).publishruns after a green deploy, so no GitHub Release names a version the host does not run.Check the result:
- the GitHub Release
vX.Y.Zexists, its notes are the change log section, and its assets are one wheel and one sdist; - the
deployjob's summary names the tag, the version and the commit, and shows the answer ofhorizon probeand the timers; - on the host,
ht --versionprintshorizon, version X.Y.Zandht probeanswerssettings: ok,portal: okandchrome: ok(thehtwrapper is section 4.2 of the deployment guide); - the portal at https://zoologov.github.io/horizon-tracker/ shows the released tree with its assets loading, and its landscape map (https://zoologov.github.io/horizon-tracker/landscape.html) shows the contracts
.beadloom/_graph/landscape.ymldeclares, eachconfirmed.
- the GitHub Release
Two releases never run at once: the workflow's concurrency group holds the next one until the running one ends, and of several waiting runs GitHub keeps the newest.
What verify refuses ​
verify reads every file from the tag's commit through git, never from the working tree, because on a run by hand the working tree is main's head. A refused tag deploys nothing and publishes nothing. The failing step names the check in its ::error annotation, titled verify: <check>.
| Check | Refused when | Why |
|---|---|---|
tag | the name is not vMAJOR.MINOR.PATCH with numbers without leading zeros (a number with a leading zero, a tag of two parts, any name given by hand) | the trigger's filter admits a leading zero and a run by hand admits any name; a release tag is strict SemVer without a pre-release or build part |
dispatch | a run by hand was started from a ref other than main | a run by hand executes the release.yml of the ref it starts from. The check stops a run started from another branch by mistake, and nothing more (below) |
tag | a run by hand names a tag the repository does not hold | a redeploy deploys an existing release, nothing else |
main | the tag's commit is not an ancestor of origin/main | only a commit of main is released: a tag on a branch, or on a commit main never merged, stops |
version | pyproject.toml at the tag's commit declares another version | the version has one source, and the tag names it |
lock | uv.lock is absent at the tag's commit, holds no entry for the project, or locks another version | the deploy's uv sync --locked refuses such a lock after the host has checked out the tag |
changelog | CHANGELOG.md is absent at the tag, has no section ## [X.Y.Z] - , the section carries no date YYYY-MM-DD, or the section is empty | the section is the release's notes; a release without them names nothing it changed |
What dispatch does not stop. verify cannot stop an edited copy of itself: a branch whose release.yml drops the check skips it, because a run by hand executes that branch's workflow. Only the environment's deployment rules stop such a run. The owner restricts horizon-vps to deployments from main and the tags v* (below), so a job of a run started from any other branch receives none of its secrets.
A tag with a pre-release part, such as vX.Y.Z-rc.1, starts no workflow at all: the trigger's pattern does not match it.
After a refusal. The tag ruleset (below) forbids moving or deleting a pushed release tag, so a refused tag stays where it is and no GitHub Release names it. The correction lands through a pull request like any change, and the release takes the next version number.
After a red gate or deploy. gate red means the tagged commit does not pass CI; nothing reached the host. A red deploy before Update the code (a missing variable, an unreachable host) changed nothing on the host; a red step after it can leave the host on the new code. Fix the cause (section 7 of the deployment guide) and re-run the failed jobs, or redeploy the tag by hand.
Redeploying a release ​
A rotated secret, a changed variable or a rebuilt host needs a deploy without a new version: Actions -> Release -> Run workflow, Use workflow from main, and in tag the current release tag. From a clone:
gh release view --json tagName --jq .tagName # the latest release tag
gh workflow run release.yml -f tag=vX.Y.ZThe run takes the same jobs as a pushed tag. verify applies the same checks to the named tag; gate checks the tag's commit (through the ref input of ci.yml), not the head of main the run itself starts from; deploy checks out the tag's commit on the host and rewrites .env from the Environment; publish edits the existing GitHub Release and replaces its assets; and the portal is rebuilt from the tag's tree. Naming an older release tag deploys that release: the host, the GitHub Release and the portal then show it.
Protecting the trunk and the tags ​
The trunk and the release tags are held by GitHub once the repository is public. The owner sets both, and reads them back:
beadloom setup-branch-protection --repo zoologov/horizon-tracker --check ci-ok --dry-run
beadloom setup-branch-protection --repo zoologov/horizon-tracker --check ci-ok
gh api repos/zoologov/horizon-tracker/branches/main/protection
gh api repos/zoologov/horizon-tracker/rulesetsmain. The protection call requires a pull request and makesci-okthe one required status check, strict, so a branch must be up to date withmainbefore it merges. It setsenforce_admins: true, so nobody pushes tomaindirectly, the owner included, and 0 required reviews, so the owner merges their own pull request. It leaves force-push and deletion at GitHub's default, forbidden.--check ci-okis mandatory: without it the command requires Beadloom's own job names (gate,tests (3.10),site-buildand others), which this repository never produces, and a required check that never reports makesmainunmergeable.--dry-runprints the call and its payload without making it.- The release tags. A tag ruleset on
v*(Settings -> Rules -> Rulesets) lets only the repository owner create a release tag and nobody update or delete one, so a release tag is cut by one person and never moved. A ruleset's bypass list applies to every rule in it, so the owner-only creation and the update and deletion that nobody may perform take two rulesets.
The portal ​
portal-build and portal-deploy publish the Beadloom portal of the released tree (the architecture overview, a page per graph node, the dashboard and the landscape map) to GitHub Pages at https://zoologov.github.io/horizon-tracker/. The portal follows releases, not pushes to main. The landscape map, landscape.html, draws the external systems and the contracts declared in .beadloom/_graph/landscape.yml. Its data is landscape.data.json, which beadloom docs site writes, so a contract added to the graph reaches the map with the next release (architecture: Landscape).
- The repository settings it needs, which the owner sets once: Settings -> Pages -> Build and deployment -> Source = GitHub Actions; and the
github-pagesenvironment allowing deployments from the tagsv*, because GitHub creates that environment allowing the default branch only. Thehorizon-vpsenvironment allows deployments from the branchmainand the tagsv*only, which keeps a run started from any other branch away from its secrets. - Its base. GitHub Pages serves a project repository under
/horizon-tracker/. That base issite.basein.beadloom/config.ymlandPORTAL_BASEinrelease.yml, which carry the same value.portal-buildrefuses a run whenPORTAL_BASEdiffers from the base Pages reports, because a portal built for one base and served under another loads none of its assets. - Its steps are those
beadloom docs site --pages-workflowwrites for the beadloom version the workflow installs. That command is never run in this repository: the file it writes deploys on every push tomain, so it would be a second deploy path. When beadloom is upgraded, the portal jobs are compared with what the new version writes. - Its permissions.
portal-deployis the only job that writes to Pages (pages: write,id-token: write). It runs in thegithub-pagesenvironment and the concurrency grouppages.