Release Strategy¶
Agentic Hardware-in-the-Loop (Agentic HIL) publishes through PyPI and GitHub Releases. GitHub Releases trigger publishing and carry release notes; PyPI is the canonical installation channel (pip install agentic-hil, uv tool install agentic-hil, pipx install agentic-hil).
Do not cut the next release for metadata-only or README-only cleanup. Batch hygiene work into the next release that delivers visible user value.
Use small releases while the project stabilizes, but only when each release has a clear user-facing reason. After the early releases, move to monthly or bi-monthly SemVer releases with GitHub auto-generated release notes as the starting point.
Versioning¶
Use SemVer for user-visible behavior:
patch docs, metadata, packaging hygiene, compatible bug fixes
minor new MCP tools, new supported workflows, compatible config additions
major breaking CLI, config, MCP, or report schema changes
Keep releases small enough that each one has a clear theme and an obvious rollback path.
A cycle carries two versions. The release commit sets the final number, and the first commit after it moves the distribution version to the next patch with a .dev0 suffix, so a released 1.2.3 becomes 1.2.4.dev0 on the next commit. Only the two positions that identify the built artifact move with it: pyproject.toml and src/agentic_hil/__init__.py. Every other position python tools/check_version_consistency.py --list prints states a floor, an install pin or a published manifest, so it keeps naming the release a reader can actually install. Without the suffix, src/agentic_hil moves for a whole cycle while the version string stands still and a working tree becomes indistinguishable from the release it shadows: an install from it reports the released number, agentic-hil upgrade calls it already_current, and the install eval refuses to run a local matrix at all rather than report this tree as that release. The gate holds both shapes. On a release commit the two versions are one string and every check is the one it always was; between releases each position is compared against the version it is supposed to carry, --release-tag is refused outright because a tag never carries a suffix, and a tree whose package has moved past its release without saying so is refused where the release tag is present to prove it.
The CI-example pins in examples/ci/*.yml and docs/ci-examples.md are among those release positions, and they pin the newest published release exactly. A reader copies one of those files on whatever day they find it and installs the version it names, so it has to be a version the index carries on that day: the file installs on the day it is copied. A floor or a compatible-release specifier would install too, but it would let the board-free job and the bench resolve to different code, which is the one property the exact pin exists to hold. The cost is that the examples can demonstrate a command only once a release ships it: tests/test_ci_examples.py holds every subcommand they invoke to a committed recording of the pinned release's command surface, tests/fixtures/published_cli_surface.json, and not to this checkout's parser, which between releases may define a command the pinned release lacks. That delays a demonstration rather than shipping a file that cannot run.
Release Notes¶
Each GitHub Release should include:
what changed
how to install or upgrade
validated workflows
known limitations
links to relevant docs
Distribution Channels¶
PyPI first. Publishing runs through GitHub Actions trusted publishing with OIDC (.github/workflows/workflow.yml): no long-lived PyPI API tokens. The synchronized package, registry, marketplace, plugin, changelog, install-eval, troubleshooting, and bundled-skill contracts are already settled before the merge by tools/check_version_consistency.py in CI; the release job runs the same module again with --release-tag, which is the one comparison a pull request cannot make. It then builds sdist and wheel and validates them with twine.
After PyPI accepts a release, the same workflow verifies the package's mcp-name ownership marker and publishes server.json to the preview MCP Registry through GitHub Actions OIDC. No MCP Registry secret is stored. The release tag, Python package version, top-level server version, and package version in server.json must match exactly. The registry is an additional discovery channel; the documented local CLI and MCP configuration path remains authoritative and host-independent.
If MCP Registry publication fails after PyPI succeeds, re-run only the failed job or manually dispatch the release workflow from the protected default branch. Manual dispatch from any other ref is skipped; the valid recovery path skips PyPI and republishes only the already released, synchronized registry metadata.
Naming is part of the release contract: the Python distribution/install target, CLI command, repository URL, and MCP server name use agentic-hil. Python imports, pytest plugin names, fixtures, and Python examples use agentic_hil.
Later packaging candidates are Homebrew, Scoop or WinGet, and conda-forge; add them only when they are reproducible and built by CI.
The Documentation Site¶
The documentation is docs/ and mkdocs.yml. It is published at https://agentic-hil.github.io/docs/ by the workflow of the agentic-hil.github.io repository, which builds master with that address as site_url, so every page names its address there as canonical.
It was first published at https://agentic-hil.readthedocs.io/. Read the Docs keeps serving the last build of every active version for as long as the project exists, so the project stays, and every page it served points at the page that replaced it:
Old page, below /en/latest/ and /en/stable/ |
Current page |
|---|---|
/ |
https://agentic-hil.github.io/docs/ |
/installation/ |
https://agentic-hil.github.io/docs/installation/ |
/configuration/ |
https://agentic-hil.github.io/docs/configuration/ |
/mcp-hosts/ |
https://agentic-hil.github.io/docs/mcp-hosts/ |
/mcp-tools/ |
https://agentic-hil.github.io/docs/mcp-tools/ |
/testing/ |
https://agentic-hil.github.io/docs/testing/ |
/test-plan-contract/ |
https://agentic-hil.github.io/docs/test-plan-contract/ |
/safety-model/ |
https://agentic-hil.github.io/docs/safety-model/ |
/security-design/ |
https://agentic-hil.github.io/docs/security-design/ |
/can-service-design/ |
https://agentic-hil.github.io/docs/can-service-design/ |
/github-action-design/ |
https://agentic-hil.github.io/docs/github-action-design/ |
/release-strategy/ |
https://agentic-hil.github.io/docs/release-strategy/ |
/repository-security/ |
https://agentic-hil.github.io/docs/repository-security/ |
Each old page kept its source file, its title and every heading anchor, so each maps to the page of the same name and a link with a fragment still lands on its section. The same paths without the language and version, which the old pages named as canonical, map the same way. An address the old site never served has no counterpart: nothing sends it to the home page.
One redirect rule in the Read the Docs project settings carries the table, with a permanent HTTP redirect. It lives in the dashboard, not in this source tree: type Page Redirect, From URL /*, To URL https://agentic-hil.github.io/docs/:splat, HTTP status 301, Force Redirect on. A page redirect matches the path below the language and version, so the one rule covers latest, stable and the unversioned paths, and Force Redirect applies it to pages a build still holds (Read the Docs: redirects). An address with no page sent there gets the 404 of the new site.
Read the Docs builds no copy of the documentation any more. .readthedocs.yaml runs tools/readthedocs_moved.py instead of MkDocs, which writes a notice at every old path of latest: the current page as canonical, a zero-second meta refresh, which Google Search treats as a permanent redirect (Google Search Central: redirects), a script that keeps the fragment, and the link. That is what latest shows wherever the rule is not forced, and its 404 page links the new site without redirecting. tests/test_readthedocs_moved.py holds every target to a page of the mkdocs.yml navigation and this table to the script's. A change to the table reaches the old address the next time latest is built there.
A notice is served with status 200, so a client that follows neither a meta refresh nor a script sees the notice and its link; only the rule answers with a redirect status. Read the Docs answers / and /en/ itself with a temporary redirect to the default version before any rule of the project applies, so those two take one 302 before the permanent redirect. /robots.txt and /sitemap.xml are served for the whole domain and are not redirected; the moved-notice build has neither, so Read the Docs generates both.
No versioned documentation is kept. Read the Docs only ever built latest, from the default branch, and stable, its automatic alias of the newest tag it had seen (v0.11.0); no tag version was activated. Neither was history chosen for publication, and both named the old host as canonical, so both point at the current pages. Deactivate stable rather than build it: a build of latest moves stable to the newest tag and builds it while it is active, and a tag cut before the moved-notice build carries a .readthedocs.yaml that builds its full site. The changes between releases are in CHANGELOG.md and on the GitHub Releases page.
Verifying the CI example pin after publication¶
The shipped CI examples pin AGENTIC_HIL_VERSION to the newest published release. tests/test_ci_examples.py proves that the pin equals the newest release CHANGELOG.md dates and that the examples invoke only commands a committed recording of that release's command surface defines, tests/fixtures/published_cli_surface.json. Between releases that recording is taken from the distribution the index serves, with python tools/record_cli_surface.py. A release commit moves the pin to the release being cut, which the index does not carry yet, so the release stamp records the surface again from the stamped tree with --from-tree. That is everything a pull request can establish: it has no network, so on a release commit the recording of the tree stands in for the artifact.
Exactly one moment closes that gap: after the release job has published to PyPI, the pinned release is a real distribution. So the publish workflow's verify-published-examples job runs tools/verify_published_examples.py, which reads the exact string the examples carry, confirms both examples agree on it and that it is the newest published release, installs that distribution from the index into an isolated environment, and asserts the installed CLI reports that version and answers --help for every command the examples invoke. The recording stops standing in for the published artifact; the artifact answers for itself.
It runs after the PyPI upload, which is the point of no return, so it is an alarm and not a gate: a published distribution cannot be inspected before it is published, and a version cannot be re-uploaded. What it turns into a red release is a release whose own examples name an artifact it did not ship or whose CLI does not expose what those examples run -- which is the failure a stranger who copied the file would otherwise be the first to hit.
Release Checklist¶
Before creating a release:
1. Bump the version in every position `python tools/check_version_consistency.py --list` prints, then run `python tools/check_version_consistency.py` until it is silent. The recorded command surface is not edited by hand: once pyproject.toml carries the release, `python tools/record_cli_surface.py --from-tree` records it again from the stamped tree.
2. Refresh the pinned Astral uv bootstrap in both installers, as described under "The uv Installer Pin" below.
3. Re-check every host registration block in docs/mcp-hosts.md against the documentation it links, then move the date at the top of that page, as described under "The Host Documentation Check" below.
4. Run ruff check src tests evals tools and pytest.
5. Run python -m build (or uv build) and inspect the packaged files.
6. Open a pull request and let Required CI pass; the same check runs there, so a forgotten position is red before a release exists.
7. Create a GitHub Release with a strict SemVer vX.Y.Z tag that exactly matches pyproject.toml.
8. Let the publish workflow re-run the same check with the release tag before it builds, checks, and publishes to PyPI.
9. Let the workflow verify the PyPI ownership marker and publish the matching `server.json` through GitHub OIDC.
10. Let the workflow install the exact `agentic-hil==X.Y.Z` the CI examples pin and confirm the published CLI reports that version and answers every command the examples invoke (`tools/verify_published_examples.py`), so a release whose artifact does not match its own examples is an alarm on the release rather than a stranger's failed copy.
11. Verify: uvx --from agentic-hil agentic-hil --version resolves the new version from PyPI.
12. Verify the release appears as `io.github.agentic-hil/agentic-hil` in the MCP Registry API.
13. Attach the one-line installers *and* their checksums to the release, all four taken from the tagged commit: `install.sh`, `install.ps1`, and `sha256sum install.sh > install.sh.sha256` and `sha256sum install.ps1 > install.ps1.sha256`, uploaded under those names, in that format, because the verify-first path in docs/installation.md feeds them straight to `sha256sum -c`. The scripts belong there because the checksum can only speak for the file published beside it: the default branch moves between releases, so a recipe that pairs a release checksum with a default-branch script fails on the first fix that lands after a release.
14. Start from GitHub auto-generated release notes, then edit for clarity.
15. Move the tree to the next development version in pyproject.toml and src/agentic_hil/__init__.py, in the first commit after the release. Every other position keeps naming the release just published, the CI examples and their recorded command surface included.
The uv Installer Pin¶
Both one-line installers can bootstrap Astral's uv on a machine that has
neither uv nor a new-enough Python. They do not fetch that bootstrap from the
moving https://astral.sh/uv/install.sh, which serves whatever is current at
the second it is asked. Each script names one uv release in its URL, carries the
SHA-256 of exactly those bytes as a constant, and refuses to execute a download
that is not those bytes. install.sh holds UV_INSTALLER_VERSION and
UV_INSTALLER_SHA256; install.ps1 holds $UvInstallerVersion and
$UvInstallerSha256. A third fetch site carries the same pin for the same
reason: evals/tls_proxy/container/Dockerfile bootstraps uv in the TLS-proxy
eval image and fetches the very file install.sh does, so its ARG
UV_INSTALLER_VERSION and ARG UV_INSTALLER_SHA256 are a copy of that script's
pair rather than a second measurement. All five constants move together, and the
three sites pin the same uv release; a static test refuses a tree where any of
them disagrees.
Refreshing the pin is a release chore and not an install-time one. An installer that went looking for a newer uv on the operator's machine would be back to executing bytes nobody here has read, which is the whole thing the pin exists to stop. So the pin ages deliberately between releases, and a release is where it is brought forward, by a person who can look at what changed:
1. Read https://github.com/astral-sh/uv/releases and take the current stable tag.
2. Download both installers at that version and hash them:
curl -LsSf -o uv-install.sh https://astral.sh/uv/<version>/install.sh
curl -LsSf -o uv-install.ps1 https://astral.sh/uv/<version>/install.ps1
sha256sum uv-install.sh uv-install.ps1
3. Put the version and the two digests into install.sh and install.ps1. Never one
without the other: a version without its digest fails every install, which is
the intended failure mode and not a thing to work around.
4. Copy install.sh's version and digest into the ARG pair in
evals/tls_proxy/container/Dockerfile. It fetches the same file, so there is
nothing extra to hash.
5. Run pytest tests/test_install_scripts.py, which checks the URL shape, the
presence of the digest check in both scripts, and that all three sites name
the same uv.
A stale pin is safe, not broken: it installs an older uv, which then upgrades itself or is upgraded by the operator. The failure worth designing for is the other one, so a digest mismatch stops the install outright and prints the expected digest, the digest found, and the sentence that the pin may be stale.
The pin covers the installer, not the uv binaries that installer goes on to
fetch. Astral's POSIX installer carries a SHA-256 per release artifact and
verifies the archive it downloaded, which is the second layer and the hop our
pin cannot reach. Their PowerShell installer, as of the pinned release, has no
checksum step at all, so on Windows our pin is the only integrity check between
astral.sh and an executed script. If that ever changes on their side, the
comment in install.ps1 that says so is what needs correcting with it.
The Host Documentation Check¶
docs/mcp-hosts.md prints one registration block per MCP host and links the
upstream page each block was read from. Host configuration formats are exactly
the kind of thing that moves without asking us, so the page carries the date it
was last checked, and that sentence is what tells a reader how far to trust the
syntax under each heading. Written once, it then survived every release and
every edit of its own file, which is how a date stops being evidence and starts
inviting trust it cannot support.
So the check is a release chore, done by a person who can read what changed:
1. Open every page linked from a "Sources:" line in docs/mcp-hosts.md.
2. Compare each block against the shape that page documents now: the container
key, the transport field, whether `cwd` and `enabled` are still keys, and
which file the host reads them from. Where upstream moved, move our block and
the prose around it; where it did not, leave both alone.
3. Follow a redirect to its destination and write the destination into the link.
A permanent redirect is upstream saying the page has moved.
4. Move the date at the top of the page to the day the check was done, and only
then: the date speaks for the blocks, not for the release.
5. Run pytest tests/test_tool_annotations.py tests/test_agentic_hil.py -k "host_documentation or host_guide",
which holds that page's tool table and its three generated registration
blocks against what this package advertises and writes.
A host whose documentation is unreachable that day is not silently blessed. Scope the sentence to the hosts that were checked and name the one that could not be, because a guide that says which of its blocks were verified is worth more than one that claims all of them.
What the Release Gate Checks, and When¶
Step 1 deliberately names no files. An enumeration kept by hand in this document
drifted: it listed seven positions while the release carried a version in twelve
files, and the two install-eval matrices it omitted sat at a stale version for
two releases, aborting every run before it started. The list now lives in
tools/check_version_consistency.py, which is the check that enforces it, so
this document cannot fall behind it. --list prints every position and what
carries the version there; a file that starts carrying the version and is named
in neither the check nor its declared exceptions is itself an error.
That check is the single enforcement point for version agreement. It reads files
and compares strings (no secret, no OIDC, no tag), so it runs pre-merge as the
Release metadata consistency job of .github/workflows/ci.yml, on every push
and every pull request, with no paths: filter: the failure it exists to catch
is a change that forgets a file, and a paths filter keys on the files a pull
request touched. Required CI depends on it, so a version that does not agree
with itself cannot reach master.
Three things still cannot be established before the merge, and no rearrangement of the workflow changes that:
release tag vs pyproject.toml the tag does not exist until the release is created;
checked at release with --release-tag, and again by
publish-mcp-registry before it publishes server.json
PyPI mcp-name ownership marker needs the artifact PyPI has accepted
MCP Registry acceptance needs the published PyPI release to verify against
Everything else the release job used to discover for the first time at
release: published (every position --list prints, the three JSON manifests
byte for byte against their contracts, and the sweep that refuses a file
carrying the version that no check covers) is now settled before the merge.
That matters because publishing is the point of no return: a PyPI version
cannot be re-uploaded.
One check reads git rather than files: the rule that a tree whose
src/agentic_hil has moved past its release must carry a development version
needs the release tag to compare against. The pre-merge job therefore checks out
full history, and where the tag is genuinely absent (a fork, a shallow clone, an
unpacked sdist) the check reports that it could not establish this and does not
fail.
Repository Protection¶
Keep master protected with required status checks (Required CI). Pull requests from agent-driven development are reviewed and approved by the repository owner, so a required approval count of 1 works even with a single human maintainer. Block force pushes and branch deletion. Dismiss stale approvals when new commits are pushed.