Releasing
Releases are cut on demand from main. A Release GitHub Actions workflow does the whole job in one run, so a release never means hand editing the version in its seven places again.
The two published packages, @input-output-hk/agent-review and @input-output-hk/agent-review-pi, are versioned in lockstep.
Cut a release
- Make sure the
## Unreleasedsection ofCHANGELOG.mdlists the changes to ship. The release fails if that section is empty. - Open the repository's Actions tab, choose the Release workflow, click Run workflow, and keep the workflow branch set to
main(the protected environment rejects any other branch). - Pick a
patch,minor, ormajorbump, or type an explicit version such as1.0.0, then run it.
The workflow then:
- resolves the exact commit at
main, before any repository dependency runs, - checks out the literal
mainref in an unprivileged job, fails closed if it no longer matches the resolved commit, then validates the release candidate: it exercises the ordinary version and changelog scripts, verifies version, generated schemas, and lockfile integrity, and runs every CI lane, - starts a fresh job with no
node_modules, checks the literalmaincheckout against that same validated commit, and uses the dependency-freescripts/write-release.mjsto make the deterministic version, lockfile, and changelog edits, - commits those seven explicit files to
mainand creates the GitHub release.
Creating the release fires the existing Publish workflow. Its unprivileged job validates, tests, builds, and packs both packages with lifecycle scripts disabled; a separate environment-credentialed job downloads only those two tarballs and publishes them with --ignore-scripts. Its automatic GITHUB_TOKEN stays read-only, and only the protected environment supplies the package credential. A dependency process therefore never shares a runner with RELEASE_TOKEN or the package token. Publishing is idempotent: a version already on the registry is skipped, so a re-run is safe.
Every external action in CI, Pages, Release, and Publish is pinned to a full commit SHA. The human-readable version comment beside each pin records the upstream release; update the SHA and comment together when upgrading an action.
One-time setup: the protected release environment
The workflow needs a RELEASE_TOKEN environment secret. The default GITHUB_TOKEN cannot be used, because a release created with it does not trigger the Publish workflow (GitHub does not cascade workflow runs from the default token).
Configure it as follows:
- Create a fine-grained personal access token (GitHub → Settings → Developer settings → Fine-grained tokens):
- Resource owner:
input-output-hk. - Repository access: only
agent-peer-review. - Repository permissions: Contents: Read and write (this covers pushing the release commit, tagging, and creating the release).
- Resource owner:
- Open repository → Settings → Environments and create or edit the
releaseenvironment. - Require at least one reviewer, enable Prevent self-review, and restrict deployment branches/tags to a
mainbranch rule and a separatev*tag rule.mainadmits the credentialed Release job;v*admits a release-triggered Publish job. For a manual Publish dispatch, selectmainin the workflow branch picker and name the tag in the required input. - Under Environment variables, add
RELEASE_ENVIRONMENT_CONFIGUREDwith the exact valuetrue. Both credentialed jobs check it because GitHub otherwise auto-creates a referenced-but-missing environment with no protection; the missing setup must fail closed. - Add the token under Environment secrets as
RELEASE_TOKEN. - Create a dedicated personal access token (classic) for the package publisher with
write:packages(and its impliedread:packages) only, authorize it for the organization if SSO requires that, and add it to the same environment asPACKAGE_TOKEN. GitHub's npm registry currently supports personal access tokens only in classic form. A dedicated bot/service account keeps this package-only credential separate from the token that can pushmain. - Delete any repository-level
RELEASE_TOKEN,PACKAGE_TOKEN, andRELEASE_ENVIRONMENT_CONFIGURED. Repository-wide values are intentionally not fallbacks: a collaborator could change a workflow on another in-repository branch, manually dispatch that branch's version, and request them before branch protection ever sees the file. The environment's deployment rules are what prevent that branch from reaching either credential. The Publish job's automaticGITHUB_TOKENintentionally has nopackages: write; publishing uses the environment-onlyPACKAGE_TOKENinstead.
The token owner must be able to push directly to main, including bypassing its required status check and signed-commit rule. The live branch protection requires the build check and signed commits but does not enforce either on administrators, so an administrator's token is the current release actor. A GitHub App installation token works only if its actor has the equivalent bypass permission; contents: write by itself is not enough.
The workflow's release commit carries a DCO sign-off (git commit -s) but is not cryptographically signed, since CI has no signing key. It therefore relies on the administrator exemption just described. If branch protection starts applying to administrators, or the release actor loses bypass permission, the push will be rejected; at that point give the workflow a signing identity or change the release design to merge a GitHub-signed release pull request rather than weakening the rule.
Recovery if a release half-completes
The workflow commits and pushes the version bump to main before it creates the release, and the release step is retried and idempotent. If it still fails after the push, main is left bumped with an empty ## Unreleased and no release. Do not re-run the workflow: it would try to bump again and fail (the version is no longer greater, and ## Unreleased is now empty). Instead create the release by hand for the version already on main:
V="$(node -p "require('./package.json').version")"
# write the notes (copy the ## <version> section from CHANGELOG.md), then:
gh release create "v$V" --target main --title "v$V" --notes-file <notes>
That fires the Publish workflow as usual.
Local tooling
The same scripts the workflow uses are available locally:
npm run version:set <patch|minor|major|X.Y.Z>sets the version in every spot. Follow it withnpm install --package-lock-onlyto sync the lockfile.npm run check:versionfails if any spot has drifted from the root package version. This also runs in CI on every pull request.npm run check:schemasregenerates the published schemas and fails on drift. It runs in CI and again on both privileged release paths.npm run check:lockfilerejects resolved tarballs without an integrity hash except for the exact documented upstream shrinkwrap exemptions. It also runs in CI and both privileged release paths.npm run changelog:release <version>promotes## Unreleasedto that version and prints the release notes.node scripts/write-release.mjs <patch|minor|major|X.Y.Z> <notes-file>is the dependency-free equivalent used only by the credentialed release job. It has focused release-file tests and is intentionally restricted to Node built-ins.
Manual fallback
If the workflow is unavailable, a release can be cut by hand:
npm run version:set minor # or patch / major / an explicit version
npm install --package-lock-only
npm run check:version
npm run check:schemas
npm run check:lockfile
npm run typecheck && npm test && npm run build && npm run -w pi build
V="$(node -p "require('./package.json').version")"
npm run changelog:release -- "$V" --notes-out /tmp/notes.md
# commit the result to main (through a pull request), then:
gh release create "v$V" --target main --title "v$V" --notes-file /tmp/notes.md