GitHub approvals

How a run asks a team of people for permission on a pull request, and how the answer comes back. The adapter is ks-github (#96); it implements two ports from ks-core:

It speaks plain HTTP through an HttpClient the caller hands it and nothing else. The socket, TLS and host allowlist belong to that client (ADR 0002); this crate holds the protocol.

The flow

  1. A run reaches a step that policy says needs a human. The engine parks the step and calls request with an ApprovalRequest: which run, which step, the rendered plan summary, the exact artifact digests, which team, and who proposed it.

  2. request posts one PR comment carrying the summary and the instructions, and returns a Ticket — which is the id of that comment.

  3. The run writes its state and exits. The ticket is all a later process needs.

  4. A team member reads the comment and answers with a slash command.

  5. A resume process calls poll with the ticket. The adapter re-reads the request comment, recovers the artifacts from the hidden marker, checks the answerer's membership, and returns a Verdict — or Ok(None) if nobody has answered yet.

  6. The engine applies its own ApprovalRequest::accepts check before the run continues.

Steps 3 and 5 are separate processes on purpose: the adapter is stateless across them (ADR 0005), so a run can park for a night and resume in a fresh job.

The engine's approval step and the keepshipping approve command line are landing separately (#94, #95); this file documents the adapter underneath them, and the workflow sketch below is a sketch.

The comment

request posts a body like this:

## Approval needed: production deploy

@platform must approve this run before it can continue.

- Run: `<run id>`
- Step: apply-infra
- Reason: production deploy
- Proposed by: @ada
- Team: @platform
- Artifacts: sha256:1a2b…

3 to create, 1 to destroy


---

To approve, comment `/keepshipping approve`.
To refuse, comment `/keepshipping refuse <reason>`.
You must be a member of @platform and not the proposer.

<!-- keepshipping:approval v1
run: <run id>
step: apply-infra
from: @platform
proposed_by: ada
artifacts: plan:sha256:1a2b…
-->

The team is printed as a bare handle — @platform, never @Keep-Shipping/platform. The channel checks membership through the Directory port, which does not expose the organization, so an org-qualified handle would be a guess — and a wrong one whenever the repository owner and the team's organization differ, as they can on GitHub Enterprise Server.

The last block is the marker: an HTML comment GitHub does not render, and the only part poll trusts. It carries one field per line — run, step, from, proposed_by, artifacts — so a value may contain spaces. artifacts is a comma-separated list of plan: / image: / other: entries, each ending in the artifact's digest.

The marker is trusted because only the token owner can post it. Everything a caller controls — the summary, the reason, the step name, each marker field — is stripped of <!-- and --> (and of newlines in the marker), so none of it can open a second marker or close the real one; the summary is fenced with more backticks than it contains so it cannot break out of its code fence; and parse_marker believes a body only when it carries exactly one marker.

Commands are read from the first line of a comment: it must be /keepshipping approve or /keepshipping refuse, optionally followed by free text that becomes the verdict's comment.

The ticket is the comment id

request returns Ticket::new(comment_id.to_string()) — the whole ticket. A ticket that does not parse as a number, or a comment id GitHub answers 404 for, is ApprovalError::UnknownTicket, never a scripted answer.

Configuration

Both constructors take an HttpClient and a Token:

let directory = Arc::new(GitHubDirectory::new(http, token.clone(), "Keep-Shipping"));
let approvals = GitHubApprovals::new(http, token, "Keep-Shipping", "harness", pr, directory);

Optional builders: with_api_base (for GHES, e.g. https://github.example/api/v3), with_user_agent, and with_clock(clock, interval) for in-process polling in wait. The default interval is 15s.

The adapter never reads the environment. It has no notion of GITHUB_TOKEN: the caller supplies the token, as GithubActionsRunContext::from_env takes a &BTreeMap<String, String> rather than reading ambient variables itself.

Scopes. The token needs issues:write (or pull-requests:write) on the repository, to post the request comment and the replies, and read:org, to read team membership. A token that has lost read:org makes every membership lookup fail, which parks the run rather than approving it. Token's Debug prints Token(<redacted>), and neither adapter's Debug prints it.

The rules

These are the engine's rules, enforced once in ApprovalRequest::accepts and Directory::is_member so that they hold identically for every adapter. The GitHub adapter re-checks membership first and never re-derives the rest:

An ignored command gets one reply saying why, carrying <!-- keepshipping:ignored comment=ID --> so repeated polls — and repeated jobs — never reply twice.

A GitHub Actions sketch

Two jobs. This is a sketch, not shipped wiring: the engine's approval step and the CLI half are still landing (#94, #95).

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - run: keepshipping run ship.ks --env prod
        # parks at the approval step; exits 4
  resume:
    if: github.event_name == 'issue_comment'
    runs-on: ubuntu-latest
    steps:
      - run: keepshipping run ship.ks --env prod --resume
        # re-polls the ticket from the parked run state

plan exits 4 (waiting_for_approval — see CLI.md) once it has posted the request comment, having written the ticket into its run state. resume is triggered by issue_comment and re-polls the ticket.

wait is the other half of this: with a Clock it polls every interval until a verdict arrives or the timeout elapses. Without one it polls once and returns ApprovalError::Undecided with after: 0s — nothing was waited for, so it does not claim the timeout elapsed. The caller is expected to re-poll across jobs, which is what the sketch above does.

Known limits

See also