Release Process
Branching Model (Git Flow)
main ─────●───────────────●──────────── (tagged releases)
↑ ↑
release/ │ release/1.0.2│ release/1.1.0
│ │
develop ──┴───────────────┴──────────── (integration branch)
↑
feature/* │
│develop— integration branch. All feature PRs targetdevelop.release/X.Y.Z— cut fromdevelopwhen ready. Only bug fixes go here.main— production branch. Merging a release branch triggers automated release.hotfix/*— branch frommainfor critical production fixes.
How to Cut a Release
1. Create the release branch
task release:cut -- 1.1.0This creates release/1.1.0 from develop and pushes it.
2. Stabilize (bug fixes only)
Push bug-fix commits directly to the release branch. No new features.
3. Merge to main
Open a PR: release/1.1.0 → main. After review, merge (no squash — preserve commit history for semantic-release).
4. Automated release pipeline
On merge to main:
semantic-release analyzes commits, determines the next version.
The release gate runs first inside the prepare step: every plugin pin must be a released tag whose commit is the pinned one (see Release train). A pin that is not fails the release here, before anything is modified, committed or tagged. The gate writes
hegemony-release.json, the manifest of what the release was built with.Creates a Git tag
vX.Y.Zand a GitHub Release with auto-generated notes andhegemony-release.jsonattached as an asset.The notes come from
scripts/release/changelog-preset.mjs: the standardangularpreset with two corrections. Only numeric#123references are linked (the generator's GitHub defaults also read agh-prefix, which once turned a mention of thegh-awtool into a link to issueaw), and commit trailers such asCo-Authored-By:are cut from breaking-change notes, which otherwise run to the end of the commit message. Merged pull requests get the release comment but not thereleasedlabel (releasedLabelsis off): the label is alabeledevent, andci.ymllistens for those, so every labelled PR used to re-run CI on a merge commit long past caring.Updates
CHANGELOG.md, bumps Python/UI/runtime version metadata, and regenerates committed OpenAPI/UI contract artifacts and the software bills of materials insbom/.The regeneration happens inside the release commit, via
scripts/bump-version.sh, and.releaserc.jsoncommitssbom/*.cdx.jsonandsbom/README.mdalongside the version bump. That ordering is load-bearing rather than incidental: each document's subject carries alicensereference built from the version inpyproject.toml, so it namesblob/vX.Y.Z/LICENSING.md— the very tag step 3 creates. A release that bumped the version without regenerating the documents would ship bills of materials pointing at a tag whoseLICENSING.mdthey were not generated against.Every name the generator writes has to be in that asset list, not only the JSON.
sbom/README.mdembeds the platform version too, so when it was missing the bump regenerated it and the release commit then discarded it: the tag held a summary naming the previous version beside six documents naming the new one, andgenerate-sbom.py --checkfailed on that tag for ever — which is exactly the reproduce-from-a-clone property the documents advertise. Tests intests/deploy/test_sbom.pyhold all three halves of this: the defaulttagFormat, thesbom/*.cdx.jsonasset glob, and that no file the generator writes is left uncovered by some asset pattern.docker-publish workflow publishes each of the 5 Docker images (api, ui, worker, scheduler, egress-nflog) in three steps, so that what Trivy clears is by construction what gets a release tag:
- Build and push by digest. The image is uploaded with no tag, so it is reachable only as
name@sha256:.... Nothing resolvable by tag exists in GHCR yet. - Scan that digest. The gate is
severity: CRITICAL,HIGHwithignore-unfixed: true, so a finding blocks publication only once a fixed version exists upstream. A second, non-blocking scan withignore-unfixed: falsethen prints the findings that have no fix available, which the gate excludes from its output entirely. - Promote the scanned digest to the release tags with
docker buildx imagetools create. This writes manifest references, so the bytes behindlatestare the bytes Trivy read — no rebuild, and no window in which a tag points at an unscanned image.
A green publish therefore means "nothing fixable outstanding", not "no CRITICAL/HIGH present" — the unfixable ones are in the non-blocking report. If the gate fails, the untagged manifest is simply never promoted and is collected as an unreferenced blob.
- Build and push by digest. The image is uploaded with no tag, so it is reachable only as
The demo is told. A
notify-demojob sends arepository_dispatch(typeplatform-release,client_payload.version) tohegemony-sh/hegemony-demo-data, whose own workflow downloads the newhegemony-release.jsonand opens its adoption pull request. See Dispatch to the demo.
5. Back-merge to develop
After semantic-release and Docker publishing succeed, back-merge main into develop so the integration branch contains the release commit, CHANGELOG.md, version bumps, and any hotfixes.
For repositories where develop requires PRs, use a back-merge branch:
git fetch origin main develop
git checkout -B backmerge/main-to-develop-vX.Y.Z origin/develop
git merge origin/main --no-edit
git push -u origin backmerge/main-to-develop-vX.Y.Z
gh pr create --base develop --head backmerge/main-to-develop-vX.Y.Z --title "chore(release): back-merge main into develop"Merge that PR after CI passes. Use a merge commit, not squash, so develop records the release history.
The DCO app checks that PR like any other: every commit needs a Signed-off-by: line unless it is a merge commit or was made by a GitHub bot account. The release commit needs none, because the semantic-release step in .github/workflows/release.yml commits as github-actions[bot], using the numeric noreply address that ties the commit to that bot account.
If branch rules allow direct maintainer pushes to develop, the helper task can perform the same merge and push:
task release:finishIf task release:finish completes the merge locally but the push is rejected by branch protection, keep the merge commit, push it as a backmerge/* branch, and open the PR shown above.
Release train: plugins → host → demo
Three kinds of repository release in sequence, and dependencies point one way:
hegemony-*-plugins ──(tag vX.Y.Z, wheels + SHA256SUMS)──▶ this repository
│ pins the tags
│ gate + manifest
▼
hegemony-demo-data
(adopts the release)- A plugin repository releases when a change merges there: its release workflow verifies every package carries the tag's version, builds the wheels, and publishes them with a
SHA256SUMSunder the tagvX.Y.Z. Every package in one of those repositories shares its SDK's version, so the SDK version is the release tag. - The host pins released tags in
.github/plugin-pins.jsonand is built and tested against exactly those commits. Its release requires the pins to be released tags — that is the gate — and publisheshegemony-release.json, the manifest of what it was built with. - The demo adopts the host release. The host dispatches an event; the demo repository downloads the manifest and re-derives its own pins from it. The host never reads anything from the demo repository — no pin, no contract test — so a demo change can never block a platform release.
Plugin pins
.github/plugin-pins.json is the single source of truth for which revision of each plugin repository the host is built against, one entry per repository:
{
"hegemony-step-plugins": {
"ref": "v0.4.0",
"commit": "5d174adef8c5c4e46f1cd4670b4746ba93910aac"
}
}"ref"records what was pinned: a tag, a branch or a SHA. Ondevelopit may be a branch or a merge commit while a feature is being integrated across repositories; a release requires it to be the tagv<SDK version>."commit"is the 40-hex commit that ref resolved to when it was pinned, and is what CI actually fetches (.github/actions/checkout-siblings), so a moving branch ref can never change what a pull request is tested against.
Every consumer reads this file rather than carrying its own SHA: the checkout-siblings action, scripts/dump-step-handler-types.py (the step handler catalog is generated from the pinned wheels), the two release-train scripts below, and Renovate. tests/deploy/test_ci_plugin_pins.py fails a workflow that hardcodes a sibling SHA.
Locally, keep the sibling checkouts (../hegemony-*-plugins, the layout CONTRIBUTING.md prescribes) at the pinned commits; both scripts below refuse to reason about a checkout that is somewhere else, since the versions it reports would not be the pinned ones.
The release gate and hegemony-release.json
scripts/release/build-release-manifest.py has two modes and is stdlib-only, so it runs from a bare clone with python3:
| Mode | Runs where | Checks |
|---|---|---|
--check | every pull request (CI Python Checks, task release:manifest:check) | the pins parse; the siblings are checked out at the pinned commits; the SDK and package versions are readable; the pyproject.toml version floors admit every pinned version. A ref that is not a release tag is a warning. No network. |
build | the release, from scripts/bump-version.sh (the semantic-release prepareCmd), before anything is committed or tagged | everything above as errors, plus: the ref is the tag v<SDK version>; git ls-remote shows that tag pointing at the pinned commit; the release under it publishes a wheel for every package, taken from its SHA256SUMS. Writes hegemony-release.json. |
build collects every problem before failing, and each message names the repository, the pinned ref and commit, and the tag it expected, for example:
error: hegemony-inventory-plugins: ref '10fc1fcc…' is not the release tag v0.4.0
(the tag named by packages/inventory_sdk/pyproject.toml, hegemony-inventory-sdk 0.4.0);
a release needs the pin at that tag
error: hegemony-inventory-plugins: tag v0.4.0 does not exist in
https://github.com/hegemony-sh/hegemony-inventory-plugins (pinned ref '10fc1fcc…',
commit 10fc1fccbef0); release the plugin repository first, then pin its tagThe fix is always in the same place: release the plugin repository, then move the pin to its tag (or let Renovate propose it).
The manifest (schema_version 1) is a release asset — git-ignored, never committed — listed under the @semantic-release/github plugin's assets in .releaserc.json. Its shape is a contract with the demo repository:
{
"schema_version": 1,
"platform": {
"version": "3.2.0",
"images": {
"api": "ghcr.io/hegemony-sh/hegemony/api:3.2.0",
"worker": "…", "scheduler": "…", "ui": "…", "egress-nflog": "…"
}
},
"sdks": {
"hegemony-inventory-sdk": "0.4.0", "hegemony-notification-sdk": "0.2.1",
"hegemony-secret-sdk": "0.2.0", "hegemony-step-sdk": "0.4.0"
},
"plugins": [
{
"repo": "hegemony-inventory-plugins", "tag": "v0.4.0", "commit": "<40-hex>",
"packages": [
{
"name": "hegemony-inventory-netbox", "version": "0.4.0",
"wheel": "https://github.com/hegemony-sh/hegemony-inventory-plugins/releases/download/v0.4.0/hegemony_inventory_netbox-0.4.0-py3-none-any.whl",
"sha256": "<64-hex>"
}
]
}
]
}sdks lists the SDK package under each sibling's packages/; plugins[].packages lists every other distributable package of that sibling (under plugins/, and for the step plugins also transports/ and probes/), with the name and version its pyproject.toml declares and the wheel URL and checksum from the release's SHA256SUMS. The manifest states facts about this repository and the plugins it was built with, nothing else — in particular nothing about the demo.
While the plugin repositories are private, build needs a token to list their tags and read their release assets: SIBLING_REPOS_TOKEN (falling back to GITHUB_TOKEN), which release.yml passes into the semantic-release step. The token travels as a git config environment variable and an Authorization header — never on a command line — and is scrubbed from every message the script prints. Against public repositories no token is needed.
Dispatch to the demo
After semantic-release succeeds with a new version, the notify-demo job in release.yml sends a repository_dispatch of type platform-release with client_payload.version set to the released version to hegemony-sh/hegemony-demo-data, using the DEMO_DISPATCH_TOKEN secret.
Secrets cannot be tested in an if: expression, so the job maps the secret to an environment variable and gates the step on that variable being non-empty: a fork, or a repository that has not created the secret, skips the dispatch with a notice rather than failing the release. The job has its own scope (permissions: {}, one step) so the cross-repository token is never present in the environment of npm or semantic-release.
Renovate: pin bumps and version floors
Renovate proposes plugin pin bumps from the plugin repositories' tags. A custom regex manager in .github/renovate.json reads .github/plugin-pins.json with the github-tags datasource against hegemony-sh/<repo> and semver-coerced versioning, so a pull request moves "ref" to the newest release tag and "commit" to that tag's commit in the same edit — exactly the pair the gate requires. That datasource lists tags and never branches, and the plugin pins package rule's allowedVersions admits only exact vX.Y.Z tags, so neither a branch whose name looks numeric (dependabot/uv/anyio-4.14.2) nor a prerelease can be proposed. A pin whose ref is a branch (main) or a bare SHA is not a version and is simply not updated. The four pins are grouped into one pull request, plugin pins, so the whole train is reviewed together.
The Renovate app must be installed on the plugin repositories as well as on this one; the tag lookups run as the app, and while those repositories are private an uninstalled app sees no tags at all.
Third-party updates wait seven days after publication before Renovate proposes them (minimumReleaseAge, set repository-wide in .github/renovate.json). Docker Hub is the only registry that reports when a tag was pushed, so the wait applies there and releases without a timestamp flow as before. Vulnerability-alert fixes skip the wait, and so do the plugin pins: the plugin pins rule sets the age to null, because those tags are this organisation's own releases and the gate, not their age, vouches for them.
The hegemony-* requirements in pyproject.toml are one-minor windows, hegemony-<pkg>>=X.Y.Z,<X.(Y+1).0. [tool.uv.sources] overrides every one of them with the editable sibling checkout, so uv never enforces the range — it is the declared compatible range for an install without the override, which is exactly why nothing used to notice a pin moving past it. scripts/release/sync-plugin-floors.py closes that gap:
--check(part oftask release:manifest:check, run by CI on every pull request) fails when a pinned version falls outside its declared range, or when a range is not a one-minor window. A patch release of a plugin lands green; a Renovate bump onto a new minor lands red, visibly, with the fix named in the failure and in the pull request body.task release:floors:syncmoves every floor to the pinned version, sets the window above it, and runsuv lock(the lockfile records the requirement strings too). Commitpyproject.tomlanduv.lockon the Renovate branch.
Hotfix Flow
- Branch from
main:git checkout -b hotfix/fix-description main - Apply the fix, commit with conventional format:
fix(api): description - PR
hotfix/* → main— merge triggers the same release pipeline. - Back-merge
main → developvia the release back-merge flow above to propagate the fix.
Version Determination
semantic-release uses Conventional Commits:
| Commit prefix | Version bump | Example |
|---|---|---|
fix(...) | PATCH (1.0.1 → 1.0.2) | fix(api): handle null device |
feat(...) | MINOR (1.0.1 → 1.1.0) | feat(ui): add device filter |
feat(...)! or BREAKING CHANGE: | MAJOR (1.0.1 → 2.0.0) | feat(api)!: rename endpoint |
Docker Images (GHCR)
Images are published to GitHub Container Registry:
ghcr.io/hegemony-sh/hegemony/api
ghcr.io/hegemony-sh/hegemony/worker
ghcr.io/hegemony-sh/hegemony/scheduler
ghcr.io/hegemony-sh/hegemony/ui
ghcr.io/hegemony-sh/hegemony/egress-nflogTags
:latest— latest push tomain:X.Y.Z— specific release version (e.g.,:1.0.1):X.Y— latest patch within minor (e.g.,:1.0):<sha>— specific commit SHA
Pull a specific version
docker pull ghcr.io/hegemony-sh/hegemony/api:1.0.1
docker pull ghcr.io/hegemony-sh/hegemony/worker:1.0.1
docker pull ghcr.io/hegemony-sh/hegemony/scheduler:1.0.1
docker pull ghcr.io/hegemony-sh/hegemony/ui:1.0.1
docker pull ghcr.io/hegemony-sh/hegemony/egress-nflog:1.0.1Attestation verification
Release attestations are bound to the repository path at publish time:
gh attestation verify --repo hegemony-sh/hegemony <artifact>Older releases are bound to whatever path this repository carried when they were published, so verifying one of those means passing that path to --repo instead. The plugin repositories follow the same rule under their own names.
Recommended Branch Protection
main
- Require PR reviews (1+)
- Require status checks: CI, commit-lint
- No force pushes
- No direct pushes (PRs only)
- Bypass actor: The
RELEASE_TOKENowner (admin) must be listed as a bypass actor in theprotect-mainruleset so semantic-release can push version-bump commits and tags.
Required Secrets
| Secret | Purpose | Scope |
|---|---|---|
RELEASE_TOKEN | PAT (or GitHub App token) for semantic-release to push to main past branch protection | contents: read+write |
SIBLING_REPOS_TOKEN | Read-only PAT on the plugin repositories; the release gate lists their tags and downloads SHA256SUMS with it while they are private (see private period) | contents: read on the plugin repos |
DEMO_DISPATCH_TOKEN | Token that may send repository_dispatch to hegemony-demo-data; absent, the dispatch is skipped with a notice | contents: write on the demo repo |
Why not
GITHUB_TOKEN? GitHub Actions' built-inGITHUB_TOKENcannot bypass repository rulesets, even withcontents: writepermission. A PAT owned by a user/app with ruleset bypass authority is required.
develop
- Require status checks: CI
- Require PRs for branch updates; no direct pushes
- No force pushes
- Use a
backmerge/* → developPR after releases and hotfixes
Taskfile Commands
| Command | Description |
|---|---|
task release:cut -- X.Y.Z | Create release/X.Y.Z from develop and push |
task release:finish | Merge main into local develop; direct push only works if branch rules allow it |
task release:verify-version-sync | Verify package, runtime, OpenAPI, and UI version metadata are in sync |
task release:manifest:check | Verify the plugin pins, sibling checkouts and pyproject.toml version floors are consistent (offline; what CI runs on every PR) |
task release:manifest:build | Build hegemony-release.json against the plugin release tags (strict; what the release runs) |
task release:floors:sync | Move the hegemony-* version floors to the pinned plugin versions and refresh uv.lock |