Docs

The local workflow

Running bastion review for real: the loop, the two output formats, exit codes, and inspecting what was saved.

The local CLI applies the same reviewers and decisions CI enforces: CI executes them fresh, records an agent-trigger skip, replays an attested local outcome, or carries an unchanged reviewer from the newest prior CI run on the branch that resolved that reviewer. So a green local loop usually means a PR that CI confirms. They still differ when a local run cannot see the pull request (no PR, or gh is missing or failed), so reviewers miss that discussion, and when a local run merges in personal reviewers with --with-user-reviewers, which CI never sees (see Authoring reviewers). This chapter covers the loop in depth.

The loop

The intended use is a tight loop: run the review, read what blocks, fix it, run again. A person watching the CLI can keep going until green. An agent following the bundled using-bastion skill stops after three full bastion review invocations if the gate is still blocked, because each invocation’s launch cap resets and an unbounded loop can spend without a stopping point.

bastion review

Without an explicit --base, bastion review asks gh pr view for the current branch’s PR. It uses that PR’s direct base commit, or main when the branch has no PR or gh cannot run. If gh runs and fails, it warns and uses the same fallback. If the local parent branch has unpushed commits already included in the child, Bastion uses that local parent tip instead of GitHub’s older commit. The changeset includes uncommitted and untracked files but excludes the base branch’s own changes. Bastion then selects reviewer candidates and renders progress plus each terminal verdict or agent-trigger skip. Candidates resolve in parallel with per-reviewer timeouts. A re-run is incremental (next section): a reviewer that already passed may carry its verdict forward instead of executing again, locally and in CI.

To send native reviewer sessions to a local Akari install, write akari.yaml with enabled: true in your user config directory (the same place as a personal .bastion.yaml). This is off by default and is not repository configuration, so a checkout cannot turn it on. BASTION_AKARI=1 enables it without a file. A failed ingest is logged and does not change the verdict.

For a native GitHub stack, automatic base selection reviews one PR layer at a time: C against B, B against A, and A against trunk. The checkout may contain uncommitted or untracked work. If the repository sets attestations: true, CI then checks for a verified attestation covering the run. A covered reviewer replays its recorded terminal outcome, while every other reviewer carries an eligible prior pass or executes fresh (see Attestation).

  • --base <branch>: the branch you are merging into. The changeset is diffed at the merge base with it, not at its tip, so the base moving on does not change what is under review. An explicit value always wins. Without one, Bastion asks gh for the current PR’s direct base and falls back to main when no PR exists. A missing gh is the same silent fallback; a gh that ran and failed prints a warning before using it. The review fails if no merge base resolves (an unrelated branch, or a shallow clone). When the base is a local branch whose remote-tracking ref would give HEAD a different merge base (a local main that lags origin/main, say), the review warns on stderr that the changeset may include upstream commits and suggests reviewing against --base with the tracking ref, or fetching and bringing the local branch up to date with it; the check reads only refs already on disk, never fetches, and the review proceeds unchanged.
  • --format <human|jsonl>: output format. Defaults to human.
  • --repo <owner/name>: the GitHub repository containing the pull request. Defaults to $GITHUB_REPOSITORY.
  • --pr <number>: select the pull request used for automatic base detection and reviewer context. Requires a repository, from --repo or $GITHUB_REPOSITORY; passing --pr with no repository is an error.
  • --config-dir <path>: the user-level config directory to search for personal reviewers (env BASTION_CONFIG_DIR). Defaults to your platform config directory (~/.config/bastion on Linux, ~/Library/Application Support/bastion on macOS, %APPDATA%\bastion on Windows). Personal reviewers are the fallback when the repository has no reviewer configuration.
  • --with-user-reviewers: merge personal reviewers into a repository’s configured reviewer set. This applies only to a purely local review; it is rejected when either --repo or --pr is supplied on the command line, and with bastion validate FILE. An ambient $GITHUB_REPOSITORY without --pr does not conflict or turn a local review into a GitHub-source review.
  • --include <path> (repeatable): merge an extra reviewer registry file into the repository registry, like an include: entry in the root file except that a relative path resolves against the current directory (see Splitting the registry across files). The extra reviewers become part of the effective repository configuration for the run, so bastion attest needs the same --include flags to re-derive the same configuration hash.
  • --reviewer <name> (repeatable; alias --only): run only these triggered reviewers. An unknown or untriggered name is an error. Excluding a triggered reviewer makes the run partial (see below).
  • --fresh: disable incremental reuse below. No reviewer carries a prior pass or continues a prior agent conversation. It does not affect attestation replay: a --repo/--pr run still replays reviewers a verified attestation covers.

Re-runs are incremental

The loop’s dominant cost would otherwise be re-executing reviewers that already passed. So on a re-run of the same branch, a reviewer whose newest prior verdict on that branch was a pass, and whose scope digest is unchanged since that run, is carried: its prior verdict counts in the gate tally, the stream marks it "carried": true, and no agent runs and no tokens are spent on it. The digest covers everything the verdict was keyed to: the reviewer’s own definition, the path-matched diff for a path trigger or the entire changeset for an agent trigger, the commit messages that touched the same files, and the content of untracked files in that scope. So an edit to scoped content, a reworded commit that touched it, or an edited reviewer re-runs the reviewer; the ones that blocked always re-run, since your fix touched the files they flagged. Blocks are never carried.

What deliberately does not re-run a reviewer: the base branch moving, or a rebase over it, when your scoped diff comes out identical. The digest binds the changeset a verdict judged, not the commit it happened to be diffed at, so a rebase over unrelated upstream changes carries every pass straight through, while one that changes the diff (a conflict resolution, upstream edits close enough to shift a hunk’s context) re-runs the affected reviewers. What the base changed was reviewed by its own changesets when it merged; your reviewers judge only what your branch changes. A path trigger bounds the concern and its carry digest to the matched files. Agent-trigger paths only prefilter whether routing starts, so an admitted agent-trigger reviewer keys carry to the full changeset. A reviewer with attestation: never in the registry is never carried, and --fresh re-runs everything.

When a reviewer does re-run, Bastion continues the newest compatible agent conversation recorded for that reviewer on this repository and branch. It sends the complete current review prompt as the next turn. Changing the reviewer prompt, backend, model, effort, trigger, or other effective configuration starts a new conversation. --fresh also starts a new conversation.

Conversation state is a cache. If the backend session or thread is unavailable, Bastion immediately performs the review in a fresh conversation. The verdict does not depend on resume succeeding.

One extra condition applies to the repository’s own reviewers (not personal user-level ones): they carry only from a prior run the binary sealed and can still verify, with no test seam recorded. A prior run that was never sealed, or whose seal no longer checks out, executes those reviewers fresh; nothing warns about it, since carry is an optimization and fresh execution is always correct.

CI carries too. A workflow that persists and restores the run store across pushes (the example workflow in Continuous integration includes the restore and upload steps that do this) lets a push carry an unchanged reviewer from the newest prior CI run on the branch that resolved that reviewer, the same way your local loop does and on the same verified-seal condition. This is separate from attestation replay: replay reuses your signed local run so CI need not re-execute it at all, while carry walks CI’s own prior runs newest first when a later push leaves a reviewer’s scoped content untouched.

The packaged GitHub Action restores <data-dir>/runs, including conversation ids, but it does not restore the backend’s native session store. A resume on a standard ephemeral runner therefore falls back to a fresh conversation. A custom workflow can preserve the backend session store too. With Akari isolation enabled for Claude Code, Codex, or Pi, persist <data-dir>/native alongside runs; otherwise persist the session location used by the selected backend.

Running a subset by hand

--reviewer <name> narrows the run to reviewers you name, for iterating on one stubborn gate without waiting on the rest. The named reviewers never carry a prior pass, local or CI (asking for a reviewer by name means asking for it to run); on a --repo/--pr run, a verified attestation can still replay a selected reviewer. When the selection excludes at least one triggered reviewer, the run is marked partial everywhere it is recorded: the run.started/run.completed events carry "partial": true, the human output and bastion runs say so, and the run cannot be attested. (Naming every triggered reviewer is a full run: the selection reduced nothing, so nothing is marked.) A partial green speaks only for the reviewers that ran. Finish with a plain bastion review: only a full run seals a real green. Carry walks the branch’s prior runs newest first and, for each reviewer, uses the newest run that resolved it. A later partial run does not hide earlier sealed passes for reviewers it did not run, so those unchanged passes still carry on the finishing full run. The named reviewer itself executes fresh there: a partial run is never sealed, so a repository reviewer’s pass from the partial cannot carry.

The CI workflow passes --repo/--pr to select the PR and give reviewers its stated intent and discussion. Locally, gh pr view detects the current branch’s PR without those flags and uses your existing gh authentication; gh api then reads the same first 100 conversation comments and first 100 review comments. If you pass --repo/--pr, Bastion also accepts the Actions REST client as a compatibility fallback. If gh cannot run and no REST token is available, the review continues without PR context. If gh runs and fails, Bastion warns and continues the same way. Those discussion requests are best effort and do not paginate. Intent is the PR body when it is non-empty, otherwise the title, otherwise your branch’s commit messages (base..HEAD). Each reviewer’s prior findings come from the run store.

Exit codes

The exit code is the gate, so a loop can branch on it:

Aggregate verdictExit code
pass (every applicable gate passed; other gates may be semantically skipped)0
block (a gate blocked, errored, or timed out)non-zero
# Keep working until every gate is green.
until bastion review; do
  echo "still blocked; fixing..."
  # ... make changes ...
done

A blocked review is an expected outcome, not a crash: Bastion still exits cleanly with structured output, and only the code signals the gate.

Two audiences, two formats

By default bastion review renders human-readable progress for a person watching. An agent passes --format jsonl and gets a machine stream instead. Both describe the same run; only the presentation differs.

The JSONL stream

With --format jsonl, Bastion emits one JSON object per line, as each thing happens. A run is a typed sequence of events:

{"type":"run.started","run":"r-0f3a","branch":"feat/cart","base":"main","changed":12,"reviewers":[{"name":"tenant-isolation","mode":"gate"},{"name":"single-responsibility","mode":"gate"}]}
{"type":"reviewer.started","run":"r-0f3a","reviewer":"tenant-isolation","mode":"gate","backend":"claude-code"}
{"type":"reviewer.started","run":"r-0f3a","reviewer":"single-responsibility","mode":"gate","backend":"codex"}
{"type":"reviewer.finished","run":"r-0f3a","reviewer":"single-responsibility","duration_ms":842,"completed":1,"total":2}
{"type":"reviewer.finished","run":"r-0f3a","reviewer":"tenant-isolation","duration_ms":38120,"completed":2,"total":2}
{"type":"reviewer.resolved","run":"r-0f3a","reviewer":"tenant-isolation","verdict":"block","summary":"A new query path reads rows without scoping by tenant id.","findings":[{"kind":"blocking","path":"src/server/db.rs","line_start":88,"line_end":91,"detail":"scope this query by tenant_id"}],"usage":{"tokens_in":18204,"tokens_out":1560,"cache_read":12000,"cost_usd":0.21},"duration_ms":38120,"has_transcript":true}
{"type":"reviewer.skipped","run":"r-0f3a","reviewer":"single-responsibility","mode":"gate","trigger":{"backend":"codex","decision":"skip","reason":"No responsibility boundary changed.","duration_ms":842},"has_transcript":true}
{"type":"run.completed","run":"r-0f3a","verdict":"block","gates":{"total":2,"passed":0,"blocked":1,"skipped":1},"duration_ms":41030,"tokens_in":20480,"tokens_out":1875,"cache_read":13100,"cost_usd":0.37}

The event types:

EventMeaning
run.startedThe run began; lists the reviewer candidates in the plan. Each executes, semantically skips, replays from a verified attestation, or carries from the newest prior run on the branch that resolved that reviewer. Under --reviewer the list holds only the selected reviewers, and the event carries partial: true when that selection excludes a candidate.
reviewer.startedOne reviewer candidate began resolving: dispatched to its trigger or reviewer backend, reconstructed from a verified attestation bundle, or carried from the newest prior run on the branch that resolved that reviewer.
reviewer.finishedOne fresh reviewer task stopped executing. completed and total count only the fresh tasks because replayed and carried reviewers dispatch no backend. This event is progress only. The final outcome follows after post-run scope-digest checks.
reviewer.resolvedOne reviewer was finalized; carries its verdict, summary, findings, usage (present only when the backend reported it; a Muse Code reviewer never does), and a has_transcript flag. An agent-triggered reviewer that ran also carries its preceding trigger decision and, when its backend reported it, usage. Carries replayed: true when the terminal outcome came from a verified attestation, and carried: true when the verdict was carried from the newest prior run on the branch that resolved that reviewer instead of a fresh execution. A reviewer that produced a real verdict this run is also stamped with scope_digest, a hash of everything the verdict was keyed to; a later run carries a prior pass only when its own digest is identical.
reviewer.skippedAn agent trigger decided that its full reviewer did not apply. Carries the trigger backend, decision, reason, usage (when the backend reported it), duration, and transcript availability without recording a pass verdict. It can also carry replayed: true when CI restored the terminal outcome from an attestation.
run.completedThe aggregate decision and gate tally, including gates.skipped, plus the run’s wall-clock duration_ms and usage totals summed across trigger and full-reviewer calls. Carries partial: true (as does run.started) when --reviewer narrowed the run.
run.attestedA signed local run was replayed; carries the replayed reviewers, the attesting public_key, and attested_at.
run.attestation-fallbackAn attestation was offered but refused; carries the reason (an unreadable or unverifiable note, an unregistered key, a stale binding, and so on). A dirty CI checkout is the one refusal that needs no note: it is checked before note lookup, so a dirty tree emits this event even when HEAD carries no note. Otherwise a commit that offered no note is not a refusal and emits no such event: it resolves through the ordinary carry-or-execute path silently.

How an agent should consume it:

  • Only need the outcome? Ignore everything until run.completed and read its verdict.
  • Want live progress? Read each reviewer.finished event as it lands. Act on reviewer.resolved findings after finalization; record reviewer.skipped as an intentional omission, not a pass that needs fixing.

For agents: the consumption contract

If you are an agent driving the loop, this is the whole contract:

  1. Run bastion review --format jsonl.
  2. Parse stdout one line at a time as JSON; each line has a type.
  3. Act on every reviewer.resolved with verdict: "block" using its findings (path + line_start/line_end + detail). Do not open transcripts; the findings already say what to change.
  4. Treat reviewer.skipped as a recorded routing outcome. It has no verdict or findings, so never count it as a pass.
  5. The aggregate decision is run.completed.verdict. The process also exits non-zero on block, so you can branch on the exit code alone if you only need pass/fail.
  6. Fix what blocked and re-run. Stop when run.completed.verdict is pass (exit zero), or after three full invocations, whichever comes first. Then open your PR. If you stopped blocked, do not keep paying for another local run.

Without --base, Bastion uses gh to detect the current PR and selects its direct base. If there is no PR, or gh cannot run, it uses main without a warning. If gh runs and fails, Bastion warns and uses the same fallback. Pass --base <branch> when you need an explicit comparison point.

This contract is exactly what bastion skills install checks into your repo as the using-bastion agent skill, so your agents follow it without being told each time. See Teach your agents to use Bastion.

The skills-freshness notice on stderr

Before it runs, bastion review compares the using-bastion skill checked into your repo (under .claude/skills and .agents/skills) against the copy bundled in the running binary, the same comparison bastion skills check makes. When the checked-in copy is missing or has drifted, it prints a one-line notice to stderr naming the affected files and pointing at bastion skills install. This is the case where your agents may be following stale guidance, so the driving agent sees the notice inline with the run.

It goes to stderr on purpose, keeping stdout as pure JSONL for a parser; the notice is advisory, so it never adds an event to the stream and never changes the exit status. A block still comes only from a reviewer. Run bastion skills install (add --force to overwrite a file you edited) and commit the result to clear it.

The notice appears only when this repository has adopted Bastion, meaning a repo-level reviewer registry is present: a .bastion.yaml, its .bastion.yml spelling, or the deprecated bastion/reviewers.yaml, the same registry discovery a review already does. If your review is running solely on your own user-level reviewers in a repo that has not configured Bastion, the notice stays silent: installing skills into a project that has not adopted Bastion would be beside the point.

Money is dollars

Cost fields (cost_usd) serialize as dollars (0.21) even though Bastion tracks exact cents internally, so you never see floating-point cent drift in the stream. Token fields (tokens_in, tokens_out, cache_read) are plain integer counts; on run.completed they are the totals summed across every agent call that reported usage, including trigger calls that skipped the full reviewer. A resolved reviewer’s usage is top-level on reviewer.resolved; trigger usage is nested under trigger on either terminal reviewer event. cache_read is the input tokens served from the provider’s prompt cache (cache hits); each backend names it differently natively (Claude’s and Grok Build’s cache_read_input_tokens, Codex’s cached_input_tokens, Pi’s cacheRead) and Bastion normalizes them to one field. It is 0 when a backend reports no cache usage. Muse Code’s stream carries no usage at all, so a Muse reviewer reports no tokens and no cost and adds nothing to the run totals.

What is streamed vs. what is saved

The stream deliberately leaves out the verbose detail. A transcript is mostly noise to an agent that just wants to know what to fix; streaming thousands of lines on every run would bury the findings and burn the agent’s own context.

  • Streamed: the decisions and the things you act on immediately: the reviewer set, start and terminal events, verdicts or skip reasons, summaries, findings, and per-reviewer usage.
  • Saved, not streamed: the verbose detail: full session transcripts, raw verdict payloads when a review ran, and per-reviewer metadata. Written to disk, read on demand.

That is why both reviewer.resolved and reviewer.skipped carry the boolean has_transcript rather than the transcript itself. When it is true and a decision surprises you, the transcript is one command away (next section); a replayed outcome may have no local transcript and carry false.

Inspecting saved runs

Every run is persisted, so you can inspect history without re-running anything. These commands are the local equivalent of clicking “Details” on a CI check. The run-targeted ones (show, transcript) default to the latest run when you omit a run id; runs and clean operate over all saved runs.

bastion runs                         # list recent runs: id, verdict, branch, reviewer count
bastion show [<run>]                 # re-print terminal verdicts, skips, and findings
bastion transcript [<run>] <reviewer>   # the full agent session for one reviewer
bastion clean [--keep N | --older-than <dur>]   # prune saved runs
  • runs is the index: what ran recently and how each landed.
  • show re-emits a past run’s terminal outcomes and aggregate: verdicts with findings, or semantic skip reasons with no findings. It accepts --format human|jsonl.
  • transcript prints the saved session for one reviewer. This is the explicit, opt-in way to see what was kept off the stream; reach for it when a verdict is surprising and you want to know why. It is raw text (a transcript is already a document). Pass either <reviewer> (latest run) or <run> <reviewer>.
  • clean prunes old runs. --keep N retains the N most recent; --older-than <dur> (e.g. 7d, 12h) removes runs older than a duration. The two are mutually exclusive.

Where runs live

Bastion persists every run under a per-user data directory, by platform convention:

  • Linux: $XDG_DATA_HOME/bastion, default ~/.local/share/bastion
  • macOS: ~/Library/Application Support/bastion
  • Windows: %APPDATA%\bastion

Override it with --data-dir <path> or the BASTION_DATA_DIR environment variable, handy for scratch runs you do not want in your real history. The layout:

<data-dir>/
  runs/
    r-0f3a/
      run.jsonl                  # the full event stream (always JSONL, regardless of display format)
      identity.json              # opaque repository identity used for history lookup
      seal.json                  # the run seal, when the run was sealed (what `bastion attest` reads)
      reviewers/
        tenant-isolation/
          transcript.jsonl       # the full agent session
          verdict.json           # the raw structured verdict; absent on a semantic skip
          meta.json              # backend, timing, usage, trigger, resumable conversation
    latest                       # a plain file holding the most recent run id
  native/                        # isolated backend session state when Akari is enabled

Full runs at one commit reuse r-<short-sha> and overwrite the previous full run. A --reviewer partial is stored as r-<short-sha>-partial so it cannot overwrite that full record.

Prior-run lookup is scoped to the repository and branch. Runs created before this identity metadata existed remain available to show and transcript, but Bastion does not use them for findings, carry, or conversation continuation.

run.jsonl is the same event stream whether a human or an agent triggered the run, so any run can be replayed or inspected after the fact. Runs accumulate: bastion review does not prune, so history grows until you run bastion clean, which keeps the most recent 20 when given no arguments (or use --keep N / --older-than <dur>).

Providing environments locally

For a native reviewer, the reviewer process inherits Bastion’s own environment, so anything your shell or a precommit script has exported (a service on http://localhost:3000, say) is visible to the agent; a reviewer’s env and inputs values are literal text set in the YAML, not shell-expanded. Bastion only reads values your shell or CI already exported; it does not stand them up. This is the same boundary CI honors, which keeps the local and CI surfaces in agreement.

A containerized reviewer (one with a runner, which today must also set capabilities.network: true to run) does not inherit your shell environment, since it runs in a container. Into it go the reviewer’s literal env pairs plus a fixed provider-credential set, and nothing else. (If the reviewer’s env sets one of those credential names, its value wins and the host’s is not also forwarded.) So an exported PREVIEW_URL that a native reviewer would see for free reaches a containerized one only if you write its literal value into that reviewer’s env, and a containerized reviewer typically reaches a host service over the container network rather than localhost.

Attesting a run for CI

Every reviewer is an agent invocation, so a project running Bastion both locally and in CI can pay for each review roughly twice: once in your loop, once again when CI confirms it. Incremental carry recovers some of that across CI pushes (an unchanged reviewer carries from the newest prior CI run on the branch that resolved that reviewer), but the first CI run of a changeset has nothing to carry from. If your repository has set attestations: true in its registry (see Continuous integration), you can sign your last green local run so CI reuses it instead of re-running every reviewer:

git commit -am "final change"        # attest needs a review over committed content
git fetch origin
base="$(gh pr view --json baseRefName --jq .baseRefName)"
git rebase "origin/$base"            # or merge; get up to date with the direct base
bastion review --base "origin/$base" # ends green
bastion attest                       # signs the run that just finished
git push origin refs/notes/bastion

The sequence syncs with the base branch before the review because CI does not take the note’s word for what was reviewed: it re-derives the merge base against the PR’s base branch, the diff’s patch id, and HEAD’s tree from its own checkout, and replays only when all of them match what the run sealed. A review against a stale view of the base seals a merge base CI will not derive, so CI refuses the note and runs every reviewer fresh, the duplicate spend attestation exists to avoid. Diff against the fetched direct-base ref rather than a local branch, which can lag its remote. And sync before the review, not after: a rebase or merge moves HEAD, and the note binds to the reviewed HEAD. If the base moves again before CI runs and the PR reports an attestation fallback, repeat the sequence, and expect it to be cheap: a rebase moves the merge base but not your changeset, so every reviewer whose scoped diff comes out identical carries instead of re-running, and bastion attest signs the carried run like any other.

bastion attest [RUN] takes an optional run id positional; omit it and it signs the latest recorded run, which is what you want right after bastion review. Pass one explicitly (bastion attest r-0f3a) to attest an older run instead.

The review has to run over committed content for this to work. To use attestation, commit your final change, then run bastion review, then bastion attest. A review over a dirty working tree (uncommitted tracked changes or untracked files) still runs and seals, but the seal records that the tree was dirty, and bastion attest refuses that run outright and tells you to commit the final content, re-run the review, and attest that run instead. bastion attest also refuses a run recorded while any backend or container override was set (BASTION_CLAUDE_BIN, BASTION_CODEX_BIN, BASTION_PI_BIN, BASTION_GROK_BIN, BASTION_MUSE_BIN, BASTION_CONTAINER_ENGINE): such a run exercised a stubbed reviewer, not a real review, so it cannot be attested either. Re-run bastion review without those variables set, then attest that run. A partial run (bastion review --reviewer) is refused too: its verdict speaks only for the reviewers you selected, so run a full bastion review and attest that.

bastion attest also re-checks that your repository has not moved on since a clean review (the same tree, the same diff, the same effective reviewer config) and refuses to sign if it has, so the note can never claim the reviewers saw something they did not. It signs with your SSH key (git config user.signingkey, or --key <path> to name one explicitly), prompting for a hardware token or keychain if your key requires it, and prints the exact push command. The signed bundle carries each repository reviewer’s terminal outcome: either its verdict and findings or its agent-trigger skip reason and usage. A repository reviewer that blocked locally still blocks in CI when its verdict replays, and a skip replays as a skip rather than a pass. Your personal user-level reviewers are excluded from the bundle (they never gate anyone else’s PR), so a run blocked only by a personal reviewer still attests, and CI sees only the repository reviewers’ results.

Push the printed command (or fold it into your normal git push) before opening the PR, so CI has the note when it runs. CI verifies the signature against the SSH signing keys you have registered with GitHub, so this only works for a key you have added there; a key freshly generated on the machine you are pushing from, with nothing registered, never verifies.

Whether to use a plain key file or a presence-gated one (a hardware token or an OS keychain entry that prompts you per signature) is your call to make. A plain file key means an agent running on your machine could sign an attestation without you noticing, the same trust you already extend to that machine through your commit access. See Attestation for the full trust model.

The same surface in CI

For the repository’s reviewers, these local events are not a separate system from CI; their terminal outcomes have GitHub twins (check runs, comments, and annotations), laid out side by side in the Continuous integration chapter. The local run.started, reviewer.started, and reviewer.finished progress events have no separate GitHub surface. A green local loop predicts a green PR when both runs see the same reviewers and context. The two surfaces run the repository’s reviewers and aggregation. A local run that cannot see the pull request (no PR, or gh missing or failed) omits that discussion, so a reviewer that weighs it can decide differently. A purely local run can also include your personal user-level reviewers with --with-user-reviewers; their run.started and terminal reviewer.resolved or reviewer.skipped events are local-only and never become checks or comments (see Authoring reviewers).


Next: Continuous integration. Promoting these same reviewers into GitHub Actions as a required merge check.