Attestation GitHub App transport¶
Purpose and trust boundary¶
Transport boundary for #11981 (epic #11972). The remote service owns policy and attestation truth in Neon. This client does not access a database or validate signed envelopes. GitHub authenticates the caller and displays informational checks; nothing stored on GitHub is authoritative evidence for skipping work.
Always ask the service for a fresh decision. Apply combineDecisions only after
obtaining that authoritative decision. Optional GitHub-side cached state can only
downgrade it: ignore > advisory > skip. Equal severity keeps the service’s
reason. Missing or invalid cached state is ignored. Never use a cached result as
the authoritative argument. Every transport failure yields ignore, meaning run
the job for real. An advisory decision also does not authorize a skip; only a
fully validated service skip does. The attestation check run is informational
only: it must never be configured as a required status check or used as a merge
gate input. The actual CI caller enforces the returned decision.
Create and install the App¶
Create the App under the owning account’s Settings → Developer settings → GitHub Apps (registration guide, installation token guide):
- No user-authorization callback; webhooks disabled (no webhook secret, events, user tokens, or OAuth client secret).
- Repository permissions exactly Checks: read and write and Metadata: read-only (mandatory); everything else, including Contents, Actions, Pull requests and Secrets, stays No access. No organization or account permissions.
- Note the numeric App ID and generate an RSA private key; keep the PEM in the trusted secret store, never committed or printed.
- Install on Only select repositories: kaeawc/auto-mobile only. The installation ID is optional; if omitted the client resolves it via the repository installation endpoint.
CI configuration and secrets¶
Add these GitHub Actions secrets with these exact names, then map them into the
trusted attestation-gate job’s environment. The shadow gate in scripts/ci/attestation/gate/ composes this transport without granting it signature-verification authority.
| Secret / environment variable | Requirement |
|---|---|
AUTOMOBILE_ATTESTATION_APP_ID |
Required positive numeric App ID |
AUTOMOBILE_ATTESTATION_APP_PRIVATE_KEY |
Required PEM; literal \n escapes are accepted |
AUTOMOBILE_ATTESTATION_INSTALLATION_ID |
Optional positive numeric installation ID |
AUTOMOBILE_ATTESTATION_SERVICE_URL |
Required HTTPS service base URL, optionally including a base path |
GITHUB_API_URL |
Optional HTTPS API base URL; defaults to https://api.github.com |
Only expose the private key to the trusted attestation-gate job. Never expose
it to PR-from-fork workflows or execute untrusted PR code in a job that has it.
Missing or invalid configuration disables this path: the caller must produce
{ kind: "ignore", reason: "unauthenticated" } and run real work. Configuration
errors list setting names only. Config toString, toJSON, and Node inspection
redact the PEM, which is also kept out of enumerable properties.
AUTOMOBILE_ATTESTATION_ALLOW_INSECURE_LOCALHOST=1 is a local test-only
environment switch, not a deployed Actions secret. It permits HTTP only for
localhost, 127.0.0.1, or [::1] on the service endpoint. Never set it in CI.
GitHub API URLs always require HTTPS. GITHUB_API_URL is trusted configuration
set by the runner, including for GitHub Enterprise Server. The App JWT is sent
to that API URL for installation lookup and token issuance; never take it from
untrusted PR input. Both base URLs reject embedded credentials,
query strings, and fragments. Credentials appear only in authorization headers,
never URL queries. Runtime callers should use the validated config loader rather
than construct unchecked configuration objects.
Token lifetime and scoping¶
The injected clock supplies wall time. An RS256 App JWT has iat = now - 60s,
exp = now + 540s, and iss = appId. The JWT is used only for installation
lookup and token issuance. getInstallationToken(owner, repo, purpose) issues
two separately scoped tokens, both restricted by repositories: [repo]:
servicerequests onlypermissions: { metadata: "read" }, and is sent to the external service for uploads and decisions.checksrequestspermissions: { metadata: "read", checks: "write" }, and is sent only to GitHub for informational check reporting.
Concurrent calls for the same owner, repository, and purpose share one in-flight
acquisition; tokens are never shared across owners, repositories, or purposes.
Tokens stay in process memory only, refreshing five minutes before expires_at. Failed
requests are not cached and an expired entry is never used as fallback.
GitHub installation tokens normally expire after one hour; the client honors
the expiry returned by GitHub. Rotate/revoke the App key through GitHub if needed.
The native fetch adapter rejects redirects, including same-host redirects.
The reused FetchLike type has no redirect field, so injected fetch adapters
must themselves reject redirects; the narrow interface cannot enforce that
behavior. No shared fetch type was changed. Responses are capped at 64 KiB
before JSON parsing, using both declared length and UTF-8 byte length. The native
adapter counts bytes while reading the body stream and cancels it immediately
when the cap is exceeded. Injected FetchLike adapters expose only text(): a
declared oversize length is rejected before reading, but an absent or inaccurate
length can still allocate an oversize body before the post-read check. The
deadline bounds both native and injected reads.
Each HTTP exchange has a ten-second deadline including its body read, driven by an injected Timer and abort signal. Authentication’s transient network, 429, or 5xx failure gets exactly one retry with the repository Backoff seam (100ms by default, capped at 1000ms). Timeout, authentication, and malformed-response failures are not retried. Service operations and check reporting are not retried. Service and reporter callers also bound token acquisition to ten seconds.
CI flow and endpoints¶
- Load configuration; on failure run real work. Construct the token provider
with an injected
Clock, and the service client with owner/repository identity. Time, signing, network, backoff, and timers have injectable test seams. - Producers call
AttestationUploader.upload({ inputHash, tier, envelope }).NoopAttestationUploaderis the local default and returns{ ok: false, reason: "uploads-disabled" }. Envelopes remain opaque JSON here. - CI calls
decide({ owner, repo, sha, tier, inputHash }). The service verifies the bearer installation token against GitHub, checks repository scope, then evaluates its Neon records and policy. An upload acknowledgment never grants a skip. This module does not implement the service or its authorization policy. - Optionally combine the fresh decision with cached state, then run work unless
the result is
skip. Report that final decision withPrDecisionReporter. It createsattestation / tier-N, success for skip and neutral otherwise, with a human sentence explaining every reason. Reporting errors are warned and swallowed without changing the decision. Checks must not be consulted for grants.
| Endpoint | Authentication | Request / successful response |
|---|---|---|
GET GITHUB_API_URL/repos/OWNER/REPO/installation |
App JWT | Used only without configured installation ID; response { id: number } |
POST GITHUB_API_URL/app/installations/ID/access_tokens |
App JWT | Restricted permissions/repositories above; response { token: string, expires_at: string } |
PUT BASE/v1/attestations/INPUTHASH |
Installation bearer token | { tier, envelope }; acknowledgment { ok: true, id?: string } |
POST BASE/v1/decisions |
Installation bearer token | { owner, repo, sha, tier, inputHash }; response { kind, reason, detail?: string } |
POST GITHUB_API_URL/repos/OWNER/REPO/check-runs |
Installation bearer token | Completed informational check run |
inputHash must be exactly 64 lowercase hexadecimal characters and is checked
before token acquisition or fetch. Decision kinds are skip, advisory, and
ignore. Reasons are verified-match, no-attestation, hash-mismatch, stale,
env-mismatch, policy-tier, transport-unavailable, invalid-response, and
unauthenticated. A skip is valid only with verified-match. An advisory
accepts only policy-tier, verified-match, stale, env-mismatch,
hash-mismatch, or no-attestation. An ignore accepts any listed reason;
transport-unavailable, invalid-response, and unauthenticated are valid only
with ignore. Unknown values, invalid pairs, and non-string details are rejected.
Optional free-form details and upload IDs are deliberately omitted from client results, and reports use
only fixed human reason text, preventing arbitrary service text from echoing
credentials. Provider and service-client inspection and JSON serialization reveal
only the class name; credential-bearing state uses ES private fields. Error text,
exception causes, and response bodies are never logged. Reporter input must have
a valid decision, a safe-integer tier from 1 through 3, and a 40- or 64-character
lowercase hexadecimal commit SHA; invalid input warns with fixed text and sends
nothing, including no token request.
Failure matrix¶
| Failure | Decision / behavior |
|---|---|
| Missing/invalid credentials or signing failure | ignore / unauthenticated |
| Service or GitHub HTTP 401/403 | ignore / unauthenticated |
| Network error, timeout, non-2xx, rejected redirect, exhausted auth retry | ignore / transport-unavailable |
| Invalid hash, oversize body, bad JSON, malformed token/installation/decision, unknown kind/reason | ignore / invalid-response |
| Upload failure | { ok: false, reason }; never grants anything; decision still requires a service call |
| Check reporting failure | Warn and swallow; preserve existing decision, which remains ignore if decision acquisition failed |
| Cached skip while service returns ignore | ignore; cached state cannot grant a skip |
Every decision-acquisition failure means ignore and run the job for real.
Acceptance check¶
Delete all GitHub-side state does not change any decision. Delete check runs and cached attestation evidence, then make the same fresh service request with unchanged Neon records and policy: it returns the same authoritative decision. This criterion concerns evidence/check/cache state, not deleting the App or its installation credentials (which fails closed to ignore). Optional cached downgrades may force more work while present, but deleting them cannot create a service grant. Test the full 3×3 downgrade matrix and absent/invalid cache cases. Use only fakes, a fixed clock, and FakeTimer for unit verification; no network, devices, database, webhook, or GitHub-state read is required.
Canonical envelope lookup¶
Implementation and offline tests now live in
scripts/ci/attestation/transport/ and test/scripts/attestation/transport/.
AttestationServiceClient.lookup() uses the existing POST /v1/decisions query.
A service may include an optional envelope beside kind and reason.
decide() retains its original decision-only result and strips remote detail.
The gate requires a strictly parsed v1 envelope for a found result, then verifies
it independently. A decision-only success response is unavailable to the gate;
no-attestation becomes not-found, and transport/authentication/invalid-response
failures become unavailable. No remote verdict alone can authorize reuse.
The wire value is ServiceDecision; the local policy verdict is
AttestationVerdict, both defined once in attestationSchema.ts. The service
verdict is an independent ceiling and cannot promote a local result.