Skip to content

Release Process ​

Branching Model (Git Flow) ​

text
  main ─────●───────────────●──────────── (tagged releases)
            ↑               ↑
  release/  │  release/1.0.2│  release/1.1.0
            │               │
  develop ──┴───────────────┴──────────── (integration branch)
            ↑
  feature/* │
            │
  • develop — integration branch. All feature PRs target develop.
  • release/X.Y.Z — cut from develop when ready. Only bug fixes go here.
  • main — production branch. Merging a release branch triggers automated release.
  • hotfix/* — branch from main for critical production fixes.

How to Cut a Release ​

1. Create the release branch ​

bash
task release:cut -- 1.1.0

This 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:

  1. semantic-release analyzes commits, determines the next version.

  2. 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.

  3. Creates a Git tag vX.Y.Z and a GitHub Release with auto-generated notes and hegemony-release.json attached as an asset.

    The notes come from scripts/release/changelog-preset.mjs: the standard angular preset with two corrections. Only numeric #123 references are linked (the generator's GitHub defaults also read a gh- prefix, which once turned a mention of the gh-aw tool into a link to issue aw), and commit trailers such as Co-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 the released label (releasedLabels is off): the label is a labeled event, and ci.yml listens for those, so every labelled PR used to re-run CI on a merge commit long past caring.

  4. Updates CHANGELOG.md, bumps Python/UI/runtime version metadata, and regenerates committed OpenAPI/UI contract artifacts and the software bills of materials in sbom/.

    The regeneration happens inside the release commit, via scripts/bump-version.sh, and .releaserc.json commits sbom/*.cdx.json and sbom/README.md alongside the version bump. That ordering is load-bearing rather than incidental: each document's subject carries a license reference built from the version in pyproject.toml, so it names blob/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 whose LICENSING.md they were not generated against.

    Every name the generator writes has to be in that asset list, not only the JSON. sbom/README.md embeds 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, and generate-sbom.py --check failed on that tag for ever — which is exactly the reproduce-from-a-clone property the documents advertise. Tests in tests/deploy/test_sbom.py hold all three halves of this: the default tagFormat, the sbom/*.cdx.json asset glob, and that no file the generator writes is left uncovered by some asset pattern.

  5. 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:

    1. 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.
    2. Scan that digest. The gate is severity: CRITICAL,HIGH with ignore-unfixed: true, so a finding blocks publication only once a fixed version exists upstream. A second, non-blocking scan with ignore-unfixed: false then prints the findings that have no fix available, which the gate excludes from its output entirely.
    3. Promote the scanned digest to the release tags with docker buildx imagetools create. This writes manifest references, so the bytes behind latest are 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.

  6. The demo is told. A notify-demo job sends a repository_dispatch (type platform-release, client_payload.version) to hegemony-sh/hegemony-demo-data, whose own workflow downloads the new hegemony-release.json and 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:

bash
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:

bash
task release:finish

If 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:

text
  hegemony-*-plugins ──(tag vX.Y.Z, wheels + SHA256SUMS)──▶ this repository
                                                              │ pins the tags
                                                              │ gate + manifest
                                                              ▼
                                                        hegemony-demo-data
                                                        (adopts the release)
  1. 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 SHA256SUMS under the tag vX.Y.Z. Every package in one of those repositories shares its SDK's version, so the SDK version is the release tag.
  2. The host pins released tags in .github/plugin-pins.json and is built and tested against exactly those commits. Its release requires the pins to be released tags — that is the gate — and publishes hegemony-release.json, the manifest of what it was built with.
  3. 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:

json
{
  "hegemony-step-plugins": {
    "ref": "v0.4.0",
    "commit": "5d174adef8c5c4e46f1cd4670b4746ba93910aac"
  }
}
  • "ref" records what was pinned: a tag, a branch or a SHA. On develop it may be a branch or a merge commit while a feature is being integrated across repositories; a release requires it to be the tag v<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:

ModeRuns whereChecks
--checkevery 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.
buildthe release, from scripts/bump-version.sh (the semantic-release prepareCmd), before anything is committed or taggedeverything 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:

text
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 tag

The 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:

json
{
  "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 of task 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:sync moves every floor to the pinned version, sets the window above it, and runs uv lock (the lockfile records the requirement strings too). Commit pyproject.toml and uv.lock on the Renovate branch.

Hotfix Flow ​

  1. Branch from main: git checkout -b hotfix/fix-description main
  2. Apply the fix, commit with conventional format: fix(api): description
  3. PR hotfix/* → main — merge triggers the same release pipeline.
  4. Back-merge main → develop via the release back-merge flow above to propagate the fix.

Version Determination ​

semantic-release uses Conventional Commits:

Commit prefixVersion bumpExample
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:

text
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-nflog

Tags ​

  • :latest — latest push to main
  • :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 ​

bash
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.1

Attestation verification ​

Release attestations are bound to the repository path at publish time:

bash
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.

main ​

  • Require PR reviews (1+)
  • Require status checks: CI, commit-lint
  • No force pushes
  • No direct pushes (PRs only)
  • Bypass actor: The RELEASE_TOKEN owner (admin) must be listed as a bypass actor in the protect-main ruleset so semantic-release can push version-bump commits and tags.

Required Secrets ​

SecretPurposeScope
RELEASE_TOKENPAT (or GitHub App token) for semantic-release to push to main past branch protectioncontents: read+write
SIBLING_REPOS_TOKENRead-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_TOKENToken that may send repository_dispatch to hegemony-demo-data; absent, the dispatch is skipped with a noticecontents: write on the demo repo

Why not GITHUB_TOKEN? GitHub Actions' built-in GITHUB_TOKEN cannot bypass repository rulesets, even with contents: write permission. 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/* → develop PR after releases and hotfixes

Taskfile Commands ​

CommandDescription
task release:cut -- X.Y.ZCreate release/X.Y.Z from develop and push
task release:finishMerge main into local develop; direct push only works if branch rules allow it
task release:verify-version-syncVerify package, runtime, OpenAPI, and UI version metadata are in sync
task release:manifest:checkVerify the plugin pins, sibling checkouts and pyproject.toml version floors are consistent (offline; what CI runs on every PR)
task release:manifest:buildBuild hegemony-release.json against the plugin release tags (strict; what the release runs)
task release:floors:syncMove the hegemony-* version floors to the pinned plugin versions and refresh uv.lock

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].