intent-rollout-gate — one-pager + operator audit + changelog
A GitHub Action that consumes signed Evidence Bundles and decides allow / block for your CI pipeline — fail closed, zero decision logic in the action itself.
intent-rollout-gate is the GitHub Action shell layer of the Intent Eval Platform. It reads an Evidence Bundle (in-toto Statement v1 rows under predicateType https://evals.intentsolutions.io/gate-result/v1), resolves a declared rollout policy, and emits an allow or block decision with full reasoning. Every line of decision logic is delegated to the published @intentsolutions/rollout-gate package (Apache-2.0, sigstore provenance) — the action only wires inputs, files, outputs, and exit codes around it. The consumption contract was frozen at v0.2.0 (2026-06-13); the current release is v0.3.1 (2026-07-05). It is no longer experimental — inputs/outputs evolve additively only, so pinned workflows keep working across the v0.x line.
Links: GitHub · @intentsolutions/rollout-gate on npm
CI pipelines ship on vibes. A green checkmark usually means "the steps that happened to run exited 0" — not "the gates this repo declared as required all passed, with verifiable evidence." Test results live in ephemeral logs, gate semantics are scattered across ad-hoc shell steps, and nothing forces the ship/no-ship decision to be derived from a declared policy against structured, signed evidence. When a release goes out, there is no machine-readable record of why it was allowed.
A gate Action that consumes Evidence Bundles and fails closed. Upstream tools (deterministic static gates, behavioral eval harnesses) emit gate-result/v1 rows into an Evidence Bundle. This action reads that bundle, checks it against a declared rollout policy (required_gates, forbid_decisions, and related knobs), and produces a single allow / block decision with every contributing reason listed. A block fails the job by default. Malformed bundle, garbage policy, ambiguous inputs, schema-invalid rows, or any unexpected wiring error all produce block — there is no silent pass.
| Question | Answer |
|---|---|
| What | A node24 GitHub Action: Evidence Bundle + rollout policy in → allow/block decision, reasons, and a step-summary table out. |
| Why | Make the ship/no-ship decision a policy evaluation over structured evidence instead of an implicit side effect of whichever steps exited 0. |
| Who | Repos in (or adopting) the Intent Eval Platform convergence — anything that emits or consumes gate-result/v1 Evidence Bundle rows. First downstream adopter (M6) will be audit-harness itself. |
| When | At the rollout decision point of a CI pipeline, after evidence-emitting jobs complete. |
| Where | GitHub Actions (uses: jeremylongshore/intent-rollout-gate@v0). Apache-2.0, open source. |
| Layer | Choice |
|---|---|
| Runtime | GitHub Actions node24 runtime; esbuild CJS bundle transpiled to a node20-compatible target (DR-002 "Node 20+" lock) |
| Language | TypeScript (locked by DR-002, recording the upstream ecosystem TS-primary decision for signing surfaces) |
| Decision logic | @intentsolutions/rollout-gate@2.0.0 — decide() / parsePolicy(), pinned exact, published with sigstore provenance |
| Schema validation | Kernel @intentsolutions/core (^0.9.0) gate-result/v1 statement schema, reused via the package — no schema is re-declared in this repo |
| Action wiring | @actions/core (inputs, outputs, step summary, exit codes) |
| Tests | vitest unit suite over the shell wiring + CI smoke job running the real action against synthetic fixtures |
| Quality gates | @intentsolutions/audit-harness (hash-pinned policy verification in CI), typecheck, dist-sync check |
| Tooling | pnpm (frozen lockfile), esbuild, TypeScript 5.x |
- Thin-shell architecture. Zero decision logic lives in the action. Gate semantics, policy interpretation, and predicate evaluation all live in the provenance-published
@intentsolutions/rollout-gatelibrary; the action contains only input validation, file I/O, summary rendering, and exit-code wiring. If decision behavior must change, it changes upstream and the dependency is bumped here. - Fail closed, everywhere. Missing/unreadable/invalid-JSON bundle, both-or-neither policy inputs, garbage policy, malformed or empty bundle, schema-invalid rows, missing or non-passing required gates, forbidden decisions, unexpected errors — every failure mode is
decision=blockwith reasons, and the job fails unlessfail-on-block: 'false'is set explicitly. - Additive-only input evolution. v0.0.x inputs are retained as deprecated aliases (
policy-file,dry-run) or honest reserved no-ops (predicate-uri,rekor-url,cosign-key) per Evidence Bundle SPEC R18 — existing workflow wiring does not break across the frozen v0.x contract. - Composable partial attestation. A bundle covering a subset of gate categories can pass if the declared policy only requires that subset (Evidence Bundle SPEC R2).
The repo is deliberately small. The entire runtime surface is two source files:
action.yml # public contract: inputs, outputs, runs.using: node24, main: dist/index.js
src/main.ts # entrypoint — imports and invokes run()
src/run.ts # ALL shell wiring: input validation, file I/O, summary rendering, exit codes
src/summary.ts # GITHUB_STEP_SUMMARY renderer (extracted at v0.3.0, unit-tested independently)
dist/index.js # committed esbuild CJS bundle (GitHub Actions convention); CI enforces dist↔src sync
tests/ # vitest suite + synthetic-gate-ID fixtures (allow / fail-row / malformed bundles)
Decision flow at runtime:
- Read the Evidence Bundle at
bundle-path. Both wire forms are accepted: the v2 plain array of in-toto Statements (kernelEvidenceBundlePayload) and the v1 legacy container{"bundle_format":"json-array","rows":[...]}. - Resolve the rollout policy from exactly one of
policy-path(JSON file) orpolicy-json(inline JSON string). Both or neither → block. - Delegate the decision to
decide(bundle, policy)from@intentsolutions/rollout-gate@2.0.0. Row validation reuses the kernelgate-result/v1statement schema. - Report: set
decision/reasons/summaryoutputs, write a markdown step summary (required-gate table + blocking rows + flat reason list), and fail the job onblockunlessfail-on-block: 'false'.
The thin-shell rule is a binding architectural constraint, not a style preference: this repo must never re-implement gate semantics, policy interpretation, or predicate evaluation. PRs that re-implement decision logic locally are out of order by design.
Ecosystem position — fourth repo in the Intent Eval Platform convergence, coupled at the schema layer (the gate-result/v1 predicate), not via package consolidation:
| Sister repo | Role |
|---|---|
intent-eval-lab |
Methodology, Evidence Bundle SPEC, taxonomy, OTel RFC |
audit-harness |
Deterministic static gates — emits gate-result/v1 Evidence Bundle rows |
j-rig-skill-binary-eval |
Behavioral judgment harness; home of the @intentsolutions/rollout-gate decision library |
intent-rollout-gate (this repo) |
Thin GitHub Action shell — delegates the ship/no-ship decision to the library |
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
jobs:
static-gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: pnpm exec audit-harness verify
- run: pnpm exec audit-harness emit-evidence --out evidence/
rollout-decision:
needs: [static-gates]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jeremylongshore/intent-rollout-gate@v0 # floating major; or pin @v0.3.1
id: gate
with:
bundle-path: evidence/bundle.json
policy-json: |
{
"required_gates": ["audit-harness:ci:*"],
"forbid_decisions": ["fail", "error"]
}
- run: echo "decision=${{ steps.gate.outputs.decision }}"Or keep the policy in a committed file (enforcement travels with the code):
- uses: jeremylongshore/intent-rollout-gate@v0.3.1
with:
bundle-path: evidence/bundle.json
policy-path: tests/rollout-policy.json
fail-on-block: "true" # default; 'false' = report-only modePolicy document shape (required_gates patterns match gate_id values; * is the only wildcard; everything except required_gates is optional with fail-closed defaults):
{
"required_gates": ["audit-harness:ci:*"],
"forbid_decisions": ["fail", "error"],
"advisory_blocks": false,
"allow_unknown_gates": true
}Inputs
| Input | Required | Default | Purpose |
|---|---|---|---|
bundle-path |
yes | — | Path to the Evidence Bundle JSON file (v2 plain array or v1 container). Missing/unreadable/invalid JSON → block. |
policy-path |
one of | '' |
Path to the rollout policy JSON document. Exactly one of policy-path / policy-json is required. |
policy-json |
one of | '' |
Inline rollout policy JSON string. |
fail-on-block |
no | 'true' |
'true': a block decision fails the job. 'false': report-only. Anything other than an explicit 'false' fails on block. |
policy-file |
no | '' |
Deprecated alias for policy-path (v0.0.x name). Removing it would be a SemVer-major event with its own migration note. |
predicate-uri |
no | gate-result/v1 URI |
Reserved. Only the stable v1 URI is supported; any other value blocks. |
rekor-url |
no | https://rekor.sigstore.dev |
Reserved. The action's runtime decision-row Rekor push stays deferred; this input is ignored. |
cosign-key |
no | '' |
Reserved. Runtime decision-row signing is not implemented; setting it warns and performs no signing. |
dry-run |
no | 'false' |
Deprecated alias for fail-on-block: 'false'. |
Outputs
| Output | Purpose |
|---|---|
decision |
allow or block, verbatim from @intentsolutions/rollout-gate. |
reasons |
JSON array string of every blocking reason — empty array exactly when decision is allow. |
summary |
Markdown decision summary (required-gate table + blocking rows + reasons). Also written to the job step summary. |
signed-decision-row-path |
Reserved — always empty until runtime decision-row signing lands (deferred). |
Development
pnpm install --frozen-lockfile
pnpm run check # typecheck + vitest unit tests
pnpm run build # esbuild bundle src/main.ts → dist/index.js (node20 target)
pnpm run dist:check # rebuild + git diff --exit-code dist/CI (.github/workflows/ci.yml; branch protection on main is live):
| Job | What it does |
|---|---|
check |
pnpm frozen install → audit-harness verify (hash-pinned policy integrity, per the Testing SOP) → typecheck → vitest → dist-sync (rebuild + git diff --exit-code dist/; stale bundles fail). |
lint-action-yaml |
Validates action.yml structure — YAML well-formed, required keys, node runtime declares runs.main. |
smoke-action |
Runs the real action (uses: ./) against synthetic fixtures: allow bundle → decision=allow + job succeeds; fail-row bundle with fail-on-block: 'false' → decision=block + job still succeeds; asserts outputs. |
The release.yml pipeline (added at v0.2.0, hardened at v0.3.0) fires on tag push: build → dist-reproducibility check → GitHub Release → floating-major retag. Production cosign signing of the committed dist/index.js is workflow_dispatch-only, dry-run by default, and gated fail-closed by the iah-E06 DNSSEC/CAA pre-flight against evals.intentsolutions.io.
Fail-closed is the load-bearing property. Every wiring-failure path in src/run.ts concludes with decision=block and an explicit reason: unreadable or invalid-JSON bundle file, both-or-neither policy inputs, a policy parsePolicy() rejects (no default-policy fallback exists), a non-default predicate-uri, and a final catch-all for unexpected errors. The v0.0.x always-exit-0 stub contract is retired and must not be reintroduced.
Supply chain — release-pipeline provenance. The decision library is pinned exact (@intentsolutions/rollout-gate@2.0.0) and published to npm with sigstore provenance. CI installs with a frozen lockfile. dist/index.js is committed (GitHub Actions convention) and the dist-sync CI job fails any PR whose committed bundle does not match a clean rebuild of src/ — the published action code is reviewable against its source on every change. The release.yml sign job (enabled v0.2.0) performs production-Rekor cosign signing of the committed dist/index.js, against the sigstore public-good trust root (fulcio.sigstore.dev + rekor.sigstore.dev, GitHub Actions ambient OIDC). Signing is workflow_dispatch-only with a reversible dry-run default and is gated fail-closed by the iah-E06 DNSSEC/CAA pre-flight. At v0.3.0 the checkout was pinned to the dispatched tag (not main), closing a wrong-bytes attestation class — a signed-provenance one-way door.
What is deferred, stated plainly.
- The action's runtime decision-row signing is deferred. The action does not emit, sign, or Rekor-anchor a
rollout-decision/v1row at runtime. This is distinct from the release-pipeline signing of the committeddist/index.jsartifact (which v0.2.0 enabled).cosign-keywarns and no-ops;rekor-urlis ignored;signed-decision-row-pathis always empty. When runtime signing lands, it delegates toaudit-harnessemit-evidence, inheriting the same DNSSEC/CAA pre-flight already wired here. - Predicate URI immutability is a hard constraint. Only
https://evals.intentsolutions.io/gate-result/v1is evaluated; any otherpredicate-urivalue blocks. Breaking predicate changes mint/v2— URI strings are permanent once any signed row references them. - The public
uses:interface is forward-compatible across v0.x — inputs/outputs evolve additively only (Evidence Bundle SPEC R18); no breaking change ships without a new predicate URI (SPEC R17). - Test fixtures use synthetic gate IDs only (
synth-gate-*); no real engagement gate IDs appear in fixtures.
| Milestone | Status |
|---|---|
| M4 — Substantive bootstrap | DONE. Repo, design doc, no-op action stub (v0.0.1, 2026-05-26). |
| M5 — Implementation | DONE. Runtime locked to TypeScript by DR-002; decision logic delegated to @intentsolutions/rollout-gate@2.0.0. |
| Consumption contract | FROZEN at v0.2.0 (2026-06-13). No longer experimental; uses: interface is additive-only across v0.x. |
| Release-pipeline signing | LIVE (v0.2.0+). Production-Rekor cosign signing of the committed dist/index.js, dispatch-only / dry-run-default / DNSSEC-CAA-gated. Provenance one-way-door fix landed v0.3.0. |
| Runtime decision-row signing | Deferred. rollout-decision/v1 runtime signing + Rekor anchoring not yet implemented. |
| M6 — First adopter | Not started. audit-harness self-adopts as the first downstream before any external repo wires this in. |
All notable changes to intent-rollout-gate are documented here.
Format follows Keep a Changelog; versioning follows SemVer 2.0.0.
- Runtime decision-row signing (
rollout-decision/v1) — emit + sign the action's own in-toto decision row (distinct from signing the committeddist/index.jsartifact, which v0.2.0 enabled). It delegates toaudit-harnessemit-evidence, inheriting the same DNSSEC/CAA pre-flight already wired here. Thesigned-decision-row-pathoutput stays empty until this lands. tests/TESTING.mdpolicy parsing — deferred per DR-002 § 5; the action continues to consume JSON policy documents only.- M6 first adopter —
audit-harnessself-adopts the gate end-to-end before any partner repo (DR-002 § 6 criterion 5).
0.3.1 - 2026-07-05
- Bump
@intentsolutions/core^0.7.0→^0.9.0. The committeddist/index.jsis rebuilt with the current kernel validators, so the action's advisoryGateResultV1Schemacheck (countKernelInvalidPredicates) runs against the 0.9.0 predicate schema. No change to the action's publicuses:interface (inputs/outputs byte-identical to v0.3.0); adopters upgrade the pin, no rewiring required. (#50)
0.3.0 - 2026-06-15
Release-pipeline hardening + provenance correctness. No change to the action's public uses: interface (inputs/outputs are byte-identical to v0.2.0) — this release hardens the release/signing pipeline itself and fixes a provenance-correctness bug in the dispatch re-release path. Adopters upgrade the pin; no workflow rewiring is required.
release.shhelper + extractedGITHUB_STEP_SUMMARYrenderer. Step-summary rendering moved out ofsrc/main.tsinto a dedicatedsrc/summary.tsmodule (unit-tested independently); arelease.shscript standardizes the local bump-tag-push flow. (#29)- ntfy CI-failure alert over the tailnet. A new
alert-on-failurejob joins the tailnet via Tailscale OIDC and pushes a high-priority alert to theprod-deploysntfy topic when a tag-release build or release job fails. (#28) - Advisory
actionlintCI lane — non-blocking workflow-manifest linting. (#31) - Advisory
typosspell-check CI lane — non-blocking. (#30)
- Production signing is dispatch-only with a reversible dry-run; the sigstage path is removed. The
signjob runs ONLY onworkflow_dispatch— a plain tag push does build + GitHub Release + floating-major retag and stops, never signing. Signing always targets the PRODUCTION sigstore public-good trust root (cosign defaults:fulcio.sigstore.dev+rekor.sigstore.dev, GitHub Actions ambient OIDC);dry-run(defaulttrue) controls only whether a permanent Rekor transparency-log entry is written. The non-representative sigstage path was dropped (its SCT verification failed against cosign's production-default TUF trust root). (#27) - The iah-E06 DNSSEC/CAA pre-flight always gates production signing (fail-closed). Because production is the only target, the read-only DNSSEC + CAA verification against
evals.intentsolutions.ioruns on EVERY dispatch (dry-run and real fire); either non-zero exit aborts before cosign runs. (#27) - Release bump to v0.3.0 —
package.json+version.txtadvanced; CHANGELOG section cut for the pipeline-hardening release. (#33)
- Checkout now pins to the dispatched tag, not
main(provenance one-way-door fix). Onworkflow_dispatchthebuild,release, andsignjobs previously resolvedGITHUB_REF(the default branch) — so a re-release / sign dispatch would rebuild and attestdist/index.jsbytes frommain, NOT the bytes consumers resolve viauses: jeremylongshore/intent-rollout-gate@<tag>. Every checkout now setsref: ${{ inputs.tag }}; on a plain tag-push eventinputs.tagis empty and checkout correctly falls back to the pushed tag. This closes a wrong-bytes attestation class — a signed-provenance one-way door (CISO reproducible-from-tag invariant). (#32)
0.2.0 - 2026-06-13
Stable consumption contract + production-Rekor signing enabled. Graduates the M5 TypeScript MVP from v0.1.0 (experimental — behavior present, contract not yet frozen) to a frozen consumption contract, and enables the previously-HELD sigstore PRODUCTION transparency-log signing of the committed dist/index.js behind the iah-E06 DNSSEC/CAA pre-flight (fail-closed). The action's public uses: interface stays forward-compatible: inputs/outputs are additive only (Evidence Bundle SPEC R18); no breaking change ships without a new predicate URI (SPEC R17). Adopters upgrade the pin; no workflow rewiring is required.
- Tag-triggered release pipeline + dist reproducibility. A
release.ymlpipeline fires on tag push: build → dist-reproducibility check → GitHub Release → floating-major retag, with sigstore wiring present. (#25) - Production-Rekor signing ENABLED (fail-closed). The
release.ymlsignjob previously HARD-REFUSED production (HELD pending the CISO pre-flight). Both former preconditions are now met:@intentsolutions/audit-harness@1.1.8ships the iah-E06 DNSSEC/CAA pre-flight scripts (read-only, fail-closed), andevals.intentsolutions.iohas DNSSEC enabled + CAA pinned (verified live). A production pre-flight step runs both scripts against the predicate-URI host underset -euo pipefail; either non-zero exit fails the job → cosign NEVER anchors to production Rekor. Production is reachable only via explicituse-production-rekor=trueworkflow_dispatch. (#26) - Recorded public gist id for sweep/release tooling. (#22)
- Consumed-row contract frozen — kernel predicate URI consumed. The action consumes
@intentsolutions/coreforgate-result/v1directly; the kernelgate-result/v1JSON Schema is pinned as the stable consumed-row contract. (#23, #24) @intentsolutions/audit-harnessdev-dependency bumped^1.1.7→^1.1.8— 1.1.8 is the first release to ship the iah-E06 pre-flight scripts the production gate consumes. (#26)- Decision logic remains delegated to
@intentsolutions/rollout-gate@2.0.0(thin shell preserved); no gate semantics added to this repo. - The
policy-fileanddry-rundeprecated aliases (introduced at v0.1.0) remain accepted — deprecated, not removed. Removing them would be a SemVer-major event with its own migration note.
0.1.0 - 2026-06-12
M5 TypeScript MVP. The action graduates from the v0.0.x composite no-op stub to a real Node-runtime action. Runtime language locked to TypeScript by DR-002. Thin shell by design (Blueprint A): every line of decision logic is delegated to the published @intentsolutions/rollout-gate@2.0.0 package (Apache-2.0, sigstore provenance) — decide() / parsePolicy(); row validation reuses the kernel @intentsolutions/core gate-result/v1 statement schema. Zero gate semantics live in this repo.
- Node runtime action —
runs.using: node24,main: dist/index.js(esbuild CJS bundle, node20-compatible transpile target per the DR-002 "Node 20+" lock).dist/is committed per GitHub Actions convention; CI enforces dist↔src sync (rebuild +git diff --exit-code dist/). (#21) - Inputs:
policy-path(JSON policy file),policy-json(inline policy),fail-on-block(default'true'). Exactly one ofpolicy-path/policy-jsonis required — both or neither blocks (fail closed). (#21) - Outputs:
reasons(JSON array string of every blocking reason;[]exactly when allowed).decisionemitsallow/blockverbatim from the package (the stub-eranot-implementedvalue is retired). (#21) - Step summary — markdown table of evaluated required gates (pattern / status / matched gate IDs) + blocking rows + flat reason list; also exposed as the
summaryoutput. (#21) - Fail-closed wiring — missing/unreadable/invalid-JSON bundle file, ambiguous policy inputs, garbage policy (
parsePolicythrows; no default-policy fallback), non-defaultpredicate-uri, and any unexpected error all producedecision=block. The job fails on block unlessfail-on-block: 'false'. (#21) - Unit tests — vitest suite over the shell wiring against synthetic-gate-ID fixtures: an allow bundle, a fail-row bundle, a malformed bundle. (#21)
- CI —
checkjob (pnpm frozen install → typecheck → vitest → dist-sync), retainedlint-action-yamljob, newsmoke-actionjob running the real action against the fixtures. (#21)
- BREAKING (stub-era behavior): the action no longer unconditionally exits 0. A
blockdecision fails the job by default. The v0.0.x always-exit-0 contract was explicitly a bootstrap affordance. (#21) - IEP convergence-debt fixes — license-drift fix,
action.ymlheredoc hardening, and the runtime-language lock recorded as DR-002. (#19) policy-filebecomes a deprecated alias forpolicy-path; itstests/TESTING.mddefault is removed.dry-runbecomes a deprecated alias forfail-on-block: 'false'. (#21)predicate-uri,rekor-url,cosign-keyinputs are retained additively (Evidence Bundle SPEC R18) as reserved: only the default v1 predicate URI is accepted (anything else blocks), no runtime Rekor push happens, andcosign-keywarns + no-ops. (#21).gitignorerewritten for the locked TS runtime (Go/Python sections removed;dist/now tracked). (#21)
0.0.1 - 2026-05-26
Baseline release. Establishes the tag + CHANGELOG baseline for this repo. No npm-publish surface — this is a GitHub Action distributed via the action.yml manifest at the repo root. Tag enables GitHub Marketplace listing.
- Initial repo scaffold (commit
8abcfdc) — repository bootstrap. - Beads issue tracking initialized (commit
fc40b3f). - M4 substantive bootstrap (commit
87de651) — repository,action.ymlAction manifest, initial design doc at000-docs/001-DR-DESIGN-rollout-gate-architecture-2026-05-12.md. Predicate URI is the stable v1 formhttps://evals.intentsolutions.io/gate-result/v1; consumers MUST NOT change it unless consuming a different predicate type. - First IEP /appaudit baseline (#13) — operator-grade devops playbook filed at
000-docs/002-AA-AUDT-appaudit-devops-playbook.md+.pdf. - Repo scaffolding for baseline release:
CHANGELOG.md,CODE_OF_CONDUCT.md(Contributor Covenant 2.1),version.txttracking baseline as0.0.1. (#14) - Baseline release AAR at
000-docs/003-RL-REPT-baseline-release-v0.0.1-2026-05-26.md. (#14)
- License relicensed from MIT to Apache 2.0 (#12, commit
295cbe4) — BREAKING. Mirrors the audit-harness (#32) andj-rig-skill-binary-eval(#73) relicenses. Per Blueprint A, the 5-repo IEP taxonomy standardizes on Apache 2.0 for a downstream-friendly patent grant.