10 min read
GitHub Action
The StateAnchor GitHub Action (stateanchor-hq/sync-action@v1) gates every API spec change in CI. On each push it diffs your stateanchor.yaml against the prior IR snapshot and blocks breaking changes (ERR lane), warns on degradations (WARN lane), and passes additive changes (INFO lane). Every gate decision is written to an append-only Merkle audit log.
Overview
On each triggering event the action performs three steps in sequence:
- OIDC exchange -- requests a GitHub OIDC token and exchanges it with StateAnchor for a short-lived action token. This authenticates the repo without storing secrets in your repository settings.
- Validate -- sends
stateanchor.yamlto/api/action/validatefor schema and structural validation. - Gate check -- calls
/api/action/gate-checkto diff the spec against the previous IR, classify breaking changes into ERR / WARN / INFO lanes, and return a gate decision.
Pull requests from forks cannot receive OIDC tokens. In that case the action automatically runs local YAML validation only and emits a soft-pass -- it never fails-closed on a fork.
Installation
Copy the example workflow below into .github/workflows/stateanchor-gate.yml in your repository. No secrets are required when GitHub OIDC is available.
# StateAnchor -- API Contract Gate
#
# This action gates every API spec change in CI. On each push it diffs your
# stateanchor.yaml against the prior snapshot and blocks breaking changes (ERR),
# warns on degradations (WARN), and passes additive changes (INFO).
#
# Full docs: https://stateanchor.dev/docs/github-action
name: StateAnchor Gate
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # required for GitHub OIDC authentication to StateAnchor
contents: read
pull-requests: write # required to post gate verdict as a PR comment
jobs:
stateanchor-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: stateanchor-hq/sync-action@v1
with:
# Gate mode: "enforce" runs a full gate check that can block your CI.
# Use "audit" to adopt StateAnchor without blocking merges while you
# get familiar with the gate signal.
mode: enforce
# Post the gate verdict as a PR annotation and step summary.
annotate-pr: 'true'
# Fail the PR step when the gate returns ERR.
# Set to false for advisory-only adoption (gate reports but does not block).
enforce-on-prs: 'true'
# Path to your stateanchor.yaml (default: stateanchor.yaml at repo root).
# config-path: stateanchor.yamlRequired permissions
The action requires two GitHub permissions in your workflow:
| Permission | Required | Why |
|---|---|---|
id-token: write | Yes | Requests a GitHub OIDC token for keyless authentication with StateAnchor. Without this permission the action falls back to local YAML validation only and emits soft-pass. |
contents: read | Yes | Required by actions/checkout@v4 to read the repository contents including stateanchor.yaml. |
pull-requests: write | No | Required only if annotate-pr: true and you want the action to post gate verdicts as PR step summaries. Omit if you do not need PR annotations. |
Inputs reference
| Input | Required | Default | Description |
|---|---|---|---|
mode | No | enforce | Gate mode: enforce (or its alias sync) runs a full gate check that can block CI on ERR. audit runs the same gate check but never fails the step -- it reports results only. |
config-path | No | stateanchor.yaml | Path to the spec file relative to the repository root. Must resolve inside GITHUB_WORKSPACE -- path traversal is rejected. |
api-base-url | No | https://stateanchor.dev | StateAnchor API base URL. Do not change unless self-hosting or using a staging deployment. |
oidc-audience | No | stateanchor-sync-action | OIDC audience claim for the GitHub token exchange. Do not change unless using a custom StateAnchor deployment. |
timeout-ms | No | 15000 | HTTP request timeout in milliseconds. Accepted range: 1000-20000. Increase if you see frequent timeout failures during the sync pipeline. |
outage-policy | No | fail-closed | Behavior when the StateAnchor service is unreachable. fail-open passes CI with soft-pass -- consistent with ADR-004 (never block customer deploys on our downtime). fail-closed fails the step. |
local-fallback | No | true | Run local YAML schema validation when OIDC or the remote gate is unavailable. Recommended to keep enabled. When disabled and outage-policy is fail-open, the step passes with no validation at all. |
annotate-pr | No | true | Post the gate verdict as a PR check annotation and step summary comment. Requires pull-requests: write permission. |
enforce-on-prs | No | true | Fail the PR check when the gate returns ERR or a blocking WARN. Set to false for advisory-only adoption -- the gate reports results without blocking merges. Useful during the initial rollout period. |
api-key | No | -- | StateAnchor API key (alternative to OIDC). Use only when your CI provider does not support GitHub OIDC. Retrieve from your dashboard and store as a repository secret. |
Outputs reference
| Output | Type | Description | Example values |
|---|---|---|---|
result | string | Overall step result. | pass, soft-pass, fail |
gate-action | string | Gate engine decision from the sync run. Use this to branch downstream steps. | allow, block, approval_required |
gate-result | string | Gate lane from the most recent sync run. The lane is the categorical classification -- the lane drives the gate decision, not the score. | ERR, WARN, INFO, PASS |
gate-score | integer | Composite breaking change score (0-100). Display-only -- the lane drives the gate decision per ADR-003. A score of 0 with lane INFO means no breaking changes. | 0, 40, 65 |
sync-run-id | UUID | ID of the sync run created by the gate check. Use this to link to the run in the StateAnchor dashboard or to query the API for full results. | fb87c28a-4509-... |
mode-effective | string | Mode that was actually executed. May differ from the requested mode if the server overrides it (e.g. a PR always runs in audit mode unless enforce-on-prs is true). | audit, sync |
remote-executed | boolean | true if the remote gate check ran successfully. false on outage or OIDC failure. | true, false |
fallback-executed | boolean | true if local YAML validation ran as a fallback. Always false when remote succeeded. | false, true |
Using outputs in your workflow
Reference outputs using the step ID to add conditional downstream steps:
- uses: stateanchor-hq/sync-action@v1
id: sa
with:
mode: enforce
- name: Comment on PR if blocked
if: steps.sa.outputs.gate-action == 'block'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: ' StateAnchor blocked this PR. Lane: ' +
'${steps.sa.outputs.gate-result}' + ', Score: ' +
'${steps.sa.outputs.gate-score}'
})
- name: Deploy if gate proceeds
if: steps.sa.outputs.gate-action == 'allow'
run: echo "Gate passed -- deploying"Behavior documentation
On a PASS result (gate-action: allow)
When the gate returns allow (lane INFO or WARN below threshold), the action sets result: pass and exits with code 0. CI continues normally. The sync run is recorded in the StateAnchor dashboard and the Merkle log.
On a block result (gate-action: block)
When the gate returns block (lane ERR), the action sets result: fail and exits with code 1, causing the workflow step to fail. The Actions log includes the block reason, effective score, a table of breaking operations, and the sync run ID for dashboard lookup. Whether the PR check fails depends on enforce-on-prs -- set it to false to log the block without preventing merge during advisory adoption.
When StateAnchor is unreachable (fail-open)
When outage-policy: fail-open is set and the API is unreachable, the action runs local YAML schema validation (if local-fallback: true) and emits result: soft-pass. No breaking-change diff was evaluated -- treat soft-pass as "the spec parsed but the gate was unreachable" and review the sync manually before deploying. This is consistent with ADR-004: StateAnchor never blocks customer CI on its own downtime.
Rate limiting (429 / 503)
When the gate-check endpoint returns 429 or 503, the action reads the Retry-After response header (defaulting to 30 seconds if absent), waits that duration, and retries the request once. If the retry also fails, the action falls through to the outage handler -- fail-open proceeds with soft-pass, fail-closed fails the step.
Fork PR behavior
Fork pull requests do not receive workflow secrets or GitHub OIDC tokens. The action detects this automatically by reading the GITHUB_EVENT_PATH payload. It then:
- Emits a workflow-level warning identifying local-validation mode.
- Runs local YAML schema validation.
- Always emits
result: soft-passif validation passes -- it never fails-closed on a fork. - Does not post PR annotations (cannot authenticate to the base repo).
How the OIDC token exchange works
StateAnchor uses GitHub's native OIDC provider to authenticate Actions without long-lived secrets -- the same mechanism used by AWS, GCP, and Azure for keyless auth from GitHub Actions.
- The action requests a short-lived JWT from GitHub's OIDC provider using the
id-token: writepermission. This token is scoped to the current workflow run and expires in minutes. - The action sends this JWT to
POST /api/action/oidc/exchangeon the StateAnchor API, along with the repository, ref, SHA, workflow, and job fields. - StateAnchor validates the JWT signature against GitHub's OIDC JWKS endpoint, checks the
repository,ref, andworkflowclaims, and verifies the repository is linked to a StateAnchor project. - If valid, StateAnchor returns a single-use action token with a 5-minute TTL and a unique JTI (JWT ID) that prevents replay attacks.
- The action uses this token for all subsequent API calls. After use, the JTI is recorded and the token cannot be reused.
The GitHub OIDC token is validated and discarded immediately -- it is never stored. No API keys or secrets are required in your repository settings.
Multi-environment configuration
For teams with staging and production environments, use separate workflow files or conditional inputs:
# .github/workflows/stateanchor-staging.yml
name: StateAnchor Staging
on:
push:
branches: [develop]
permissions:
id-token: write
contents: read
jobs:
stateanchor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: stateanchor-hq/sync-action@v1
with:
mode: audit # staging: report only, never block
config-path: stateanchor.yaml
# .github/workflows/stateanchor-prod.yml
name: StateAnchor Production
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
stateanchor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: stateanchor-hq/sync-action@v1
with:
mode: enforce # production: block breaking changes
outage-policy: fail-open
config-path: stateanchor.yamlWhat the Action logs look like
Successful run (gate: INFO, action: allow)
[StateAnchor] OIDC exchange: success (token expires in 300s)
[StateAnchor] Validating stateanchor.yaml...
[StateAnchor] Validation: pass (4 endpoints, 2 models)
[StateAnchor] Gate check: running...
[StateAnchor] Gate result: allow (score: 0)
Lane: INFO
No breaking operations detected
[StateAnchor] Sync run: fb87c28a-4509-4c7b-b3e1-... (completed)
[StateAnchor] ok Done -- result: passBlocked run (gate lane: ERR, action: block)
[StateAnchor] OIDC exchange: success (token expires in 300s)
[StateAnchor] Validating stateanchor.yaml...
[StateAnchor] Validation: pass (3 endpoints, 2 models)
[StateAnchor] Gate check: running...
[StateAnchor] Gate result: block (score: 65)
Lane: ERR
Breaking operations:
[40] REMOVED endpoint DELETE /users/:id
[30] REMOVED required field user.email
Block reason: BLOCK -- breaking removal detected
[StateAnchor] Sync run: a1b2c3d4-... (blocked)
[StateAnchor] x Gate BLOCKED -- exit code 1Outage with fail-open
[StateAnchor] OIDC exchange: success
[StateAnchor] Validating stateanchor.yaml...
[StateAnchor] Remote validation: failed (timeout after 15000ms)
[StateAnchor] Outage policy: fail-open
[StateAnchor] Local fallback: running YAML schema validation...
[StateAnchor] Local validation: pass
[StateAnchor] ok Done -- result: soft-pass (remote unavailable)soft-pass explained
soft-pass is not a gate lane -- it is the Action result when the remote gate check could not run and outage-policy: fail-open is configured, or when the action is running on a fork PR. The Action falls back to local YAML schema validation only, and if that passes, emits result: soft-pass. No breaking-change diff was evaluated, no ERR / WARN / INFO lane was assigned, and no artifacts were generated. Treat soft-pass as “the spec parsed but the gate was unreachable” — review the sync manually before deploying.
Troubleshooting
1. “repo_not_linked” error
The repository is not connected to a StateAnchor project. Go to the StateAnchor dashboard, click Connect repository, and follow the GitHub App installation flow. The OIDC exchange will fail until the repo is linked.
2. “invalid_action_token” -- OIDC permissions missing
The action could not obtain a GitHub OIDC token. Verify that your workflow includes:
permissions:
id-token: write
contents: readThis block must appear at the workflow level or on the specific job, not just on individual steps. Fork PRs cannot obtain OIDC tokens -- the action falls back to local validation automatically for forks.
3. Gate always passes even on breaking changes
If the gate returns INFO on every push regardless of what you change, check the following:
- stateanchor.yaml is not being read: Verify the file exists at the path specified in
config-path(default:stateanchor.yamlat repo root). Check for typos -- the file must be.yaml, not.yml. - First sync has no prior IR: The very first sync run for a new project has nothing to diff against, so it always passes. Run one sync, then make a breaking change to see the gate block.
- mode: audit: In audit mode the gate runs but never blocks. Switch to
mode: enforceto enable blocking.
4. Action fails with 401 on fork PRs
This is expected behavior -- fork PRs cannot receive OIDC tokens and cannot authenticate to StateAnchor. The action should automatically detect the fork context and fall back to local validation. If you are seeing a hard 401 failure instead of local fallback, verify that local-fallback is not set to false.
5. Action is slow (>30 seconds)
The sync pipeline runs five stages: OIDC exchange → spec validation → Stage A IR normalization → Stage B ICE ensemble (3 Haiku evaluators) → Stage C artifact generation. Expected timing under normal load is 8-20 seconds total. If the action consistently exceeds 30 seconds:
- Increase
timeout-msto 20000. - Check stateanchor.dev for service status -- high latency usually correlates with upstream LLM provider load.
- Consider setting
outage-policy: fail-openso timeouts do not block your CI during peak periods.