Reusable, policy-gated release automation for Outfitter projects. One mechanism, maintained once, consumed by every @outfitter/* repo (and eventually Trails and Skillset).
A Changesets "Version Packages" PR opens and updates the pending release. When that PR merges, a verify-don't-trust policy engine independently re-derives the facts — branch, bot identity, the exact generated diff, CI status, changelog, version delta, and registry state — and only then publishes. A human label states intent; the machine verifies reality. Anything ambiguous routes to a manual-approval environment instead of publishing.
Generalized for multi-package, lockstep-versioned workspaces published with bun publish (so workspace:/catalog: ranges resolve). See ADR-0001 in the monorepo for the design rationale.
Add one workflow to a consumer repo:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
release:
uses: outfitter-dev/release-action/.github/workflows/release.yml@v1
with:
# Bumps versions + writes changelogs from pending changesets.
version-command: bunx changeset version
# Optional build before publish.
build-command: bun run build
# CI check-run names that must pass before an auto publish.
required-checks: "check"
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}The action does the rest: opens/maintains the version PR, and — once it merges — plans, gates, publishes (auto or manual), and cuts the tagged GitHub release.
push main
│
▼
version ─────────────► opens/updates the "Version Packages" PR (changesets/action),
│ has changesets? labels it with conservative release intent
│ no (PR merged)
▼
plan ────────────────► reads the npm registry; is there anything unpublished?
│
▼
policy ──────────────► verify-don't-trust: re-derives every fact and decides
│ auto │ manual │ none │ block
├── auto ──► publish-auto (environment: npm-auto, NPM_TOKEN)
└── manual ► publish-manual (environment: npm, protected approval)
│
▼
github-release ──────► tag-authoritative GitHub release at the version commit
Intent is expressed with labels on the version PR (and stack:boundary on the source PRs that contributed the changesets):
- Publish:
publish:auto·publish:manual·publish:block·publish:none - Channel:
channel:stable·channel:preview·channel:canary - Release:
release:major·release:minor·release:patch - Stack (source PRs):
stack:boundary
The decision resolves, in order, to block (conflicting/unknown labels, registry drift, or explicit publish:block), none (publish:none with an audit reason), manual (the default, or publish:manual), or auto (only publish:auto and every gate condition holds).
publish:auto is a request, not an authorization. Automatic publish happens only when all of these hold — otherwise the release downgrades to manual approval (it never silently publishes):
- the generated diff is exactly the expected version-bump file set (the discovered packages'
package.json+CHANGELOG.md, plus a consumed.changeset/*.md) and nothing else; - every consumed changeset source PR carries
stack:boundary; - repository and branch match the expected release context;
- channel is stable and the dist-tag is
latest; - the version PR is the bot's PR with the expected title and identity;
- the changelog contains the new version heading;
- the version delta is positive and consistent with any
release:*label; - the required CI checks passed at the exact release SHA.
Lockstep: all publishable packages share one version and bump together. This keeps dist-tag selection, registry-completeness, and tagging to a single tuple. Independent per-package versioning is not supported yet.
A consumer repo needs:
- Changesets configured (
.changeset/config.json). - An
NPM_TOKENsecret (granular automation token) — Bun's publisher resolvesworkspace:/catalog:, which npm OIDC cannot, so a token is used rather than Trusted Publishing. - Two environments:
npm-auto(used by the auto path) andnpm(protected, manual approval). - The
required-checksinput naming the CI check-run(s) that gate an auto publish.
bun install
bun run check # format + markdown + typecheck + tests
bun run build # bundle src/ -> dist/index.js (committed; the Action runs it)dist/index.js is committed on purpose: GitHub Actions runs the bundled action directly, without installing dependencies.