Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/brave-cups-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@savvy-web/github-action-effects": patch
---

## Bug Fixes

`Attest.listForSubject` now pins `X-GitHub-Api-Version: 2026-03-10` on requests to the GitHub repository attestations endpoint. The previously used default API version has been deprecated (Sunset 2028-03-10) and produced a deprecation warning on every call.

Under the new version the inline Sigstore `bundle` field is absent from list responses. When a `predicateType` filter is supplied, the server-side `predicate_type` query parameter narrows results without any per-entry bundle fetch. When no filter is supplied, each entry's `bundle_url` is fetched to recover the `predicateType`. The public `AttestationListEntry` type is unchanged.
6 changes: 3 additions & 3 deletions .claude/design/github-action-effects/errors-and-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,10 @@ Attest.sbom(input)
-> Attest.attest({ subjects, predicateType: CYCLONEDX_BOM, predicate })

Attest.listForSubject(sha256Hex, options?)
-> GET /repos/{owner}/{repo}/attestations/sha256:{hex}
-> GET /repos/{owner}/{repo}/attestations/sha256:{hex} (X-GitHub-Api-Version: 2026-03-10)
-> 404 -> return []
-> parse each bundle's dsseEnvelope to extract predicateType
-> client-side filter by options.predicateType if provided
-> if options.predicateType: pass predicate_type query param, trust server narrowing
-> else: fetch each entry's bundle_url, decode its in-toto predicateType, drop undecodable entries
-> returns Array<AttestationListEntry>
```

Expand Down
2 changes: 1 addition & 1 deletion .claude/design/github-action-effects/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ End-to-end attestation: build in-toto statements, sign via Sigstore, upload to G
- `attest(input)` -- Full end-to-end: build statement, sign, POST to `POST /repos/{owner}/{repo}/attestations`. Returns `AttestationRecord`. Requires `SigstoreSigner | OidcTokenIssuer | GitHubClient`
- `sbom(input)` -- Generate a CycloneDX SBOM and attest it (`CYCLONEDX_BOM` predicateType). `input` accepts either `dependencies` (the service builds the BOM) or a pre-built `bomDocument` (attested verbatim). Requires `Sbom | SigstoreSigner | OidcTokenIssuer | GitHubClient`
- `provenance(input)` -- Attest with a caller-supplied SLSA Provenance v1 predicate. Requires `SigstoreSigner | OidcTokenIssuer | GitHubClient`
- `listForSubject(subjectSha256, options?)` -- List existing attestations for a tarball digest (`GET /repos/{owner}/{repo}/attestations/sha256:{hex}`). `options.predicateType` filters client-side. Returns an empty array on 404 (never-attested subject). Requires `GitHubClient`
- `listForSubject(subjectSha256, options?)` -- List existing attestations for a tarball digest (`GET /repos/{owner}/{repo}/attestations/sha256:{hex}`), pinned to `X-GitHub-Api-Version: 2026-03-10`. Under that version the inline Sigstore `bundle` is dropped, so `predicateType` is recovered server-side via the `predicate_type` query parameter when `options.predicateType` is supplied, or by fetching each entry's `bundle_url` and decoding its in-toto statement when no filter is given. Returns an empty array on 404 (never-attested subject). Requires `GitHubClient`

**Types:** `SbomAttestationInput`, `ProvenanceAttestationInput`, `AttestationListEntry { attestationUrl, predicateType }`, `AttestationRecord` (see `schemas/Attestation.ts`)

Expand Down
2 changes: 1 addition & 1 deletion docs/03-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,7 @@ const program = Effect.gen(function* () {
})
```

`Attest.provenance` / `.sbom` / `.attest` require `SigstoreSigner | OidcTokenIssuer | GitHubClient`; `.sbom` also requires `Sbom` when it builds the BOM. `listForSubject`'s `predicateType` filter is applied client-side — see the [attestations guide](./10-slsa-attestations.md#idempotent-recovery).
`Attest.provenance` / `.sbom` / `.attest` require `SigstoreSigner | OidcTokenIssuer | GitHubClient`; `.sbom` also requires `Sbom` when it builds the BOM. `listForSubject`'s `predicateType` filter is applied server-side when supplied — see the [attestations guide](./10-slsa-attestations.md#idempotent-recovery).

### Sbom

Expand Down
2 changes: 1 addition & 1 deletion docs/10-slsa-attestations.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,4 @@ const program = Effect.gen(function* () {
})
```

The `predicateType` filter is applied **client-side**: `listForSubject` fetches every attestation for the digest, parses the in-toto statement out of each Sigstore bundle, reads its `predicateType` and keeps the matching entries. It is not a server query parameter — the GitHub REST API does not expose one. Pass `https://slsa.dev/provenance/v1` (or use no filter) to ask the same question about provenance. The call returns an empty array when the subject has no attestations or the API returns a 404; only genuine failures (auth, network, rate limit) surface as `AttestError`.
When you pass a `predicateType`, `listForSubject` hands it to GitHub as a server-side filter so the API only returns matching attestations. With no filter it lists every attestation for the digest and recovers each entry's `predicateType` by reading its bundle. Either way the result is the same `{ attestationUrl, predicateType }` entries. Pass `https://slsa.dev/provenance/v1` (or use no filter) to ask the same question about provenance. The call returns an empty array when the subject has no attestations or the API returns a 404; only genuine failures (auth, network, rate limit) surface as `AttestError`.
168 changes: 117 additions & 51 deletions src/layers/AttestLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ import { buildStatement, subject as makeSubject, npmPurl, serializeStatement } f

const CREATE_ATTESTATION_REQUEST = "POST /repos/{owner}/{repo}/attestations" as const;
const LIST_ATTESTATIONS_REQUEST = "GET /repos/{owner}/{repo}/attestations/{subject_digest}" as const;
/** Octokit route template for fetching a Sigstore bundle by its absolute `bundle_url`. */
const FETCH_BUNDLE_REQUEST = "GET {bundle_url}" as const;

/**
* Pinned REST API version for the attestations list endpoint.
*
* @remarks
* Octokit's default API version still serves the legacy list response
* shape, which GitHub has deprecated (Sunset 2028-03-10) and now flags
* with a `Deprecation` response header that Octokit logs on every call.
* Pinning `2026-03-10` opts into the supported shape: the inline `bundle`
* is dropped in favour of a `bundle_url`, so we recover the in-toto
* `predicateType` either from the server-side `predicate_type` filter
* (when the caller supplies one) or by fetching the bundle.
*
* @see {@link https://docs.github.com/en/rest/about-the-rest-api/breaking-changes?apiVersion=2026-03-10}
* @internal
*/
const ATTESTATIONS_API_VERSION = "2026-03-10" as const;

interface OctokitLike {
readonly request: (route: string, params: Record<string, unknown>) => Promise<{ data: unknown }>;
Expand All @@ -44,25 +63,21 @@ const attestationIdFromResponse = (raw: unknown): number => {
};

/**
* Shape of one `attestations[]` entry on `GET /repos/.../attestations/{subject_digest}`.
* Shape of one `attestations[]` entry on `GET /repos/.../attestations/{subject_digest}`
* under API version `2026-03-10`.
*
* @remarks
* Mirrors the Octokit OpenAPI surface: `bundle.dsseEnvelope.payload`
* is a base64-encoded in-toto statement, `repository_id` /
* `bundle_url` / `initiator` are advisory metadata. The real GitHub
* API also includes an `id` field on each entry that the OpenAPI
* types omit — we read it defensively when present so the result's
* The `2026-03-10` response omits the inline `bundle` carried by the
* legacy shape; `bundle_url` points at the full Sigstore bundle and
* `repository_id` / `initiator` are advisory metadata. The real GitHub
* API also includes an `id` field on each entry that the OpenAPI types
* omit — we read it defensively when present so the result's
* `attestationUrl` can point at the GitHub UI.
*
* @internal
*/
interface RawListedAttestation {
readonly id?: number;
readonly bundle?: {
readonly dsseEnvelope?: {
readonly payload?: string;
};
};
readonly bundle_url?: string;
}

Expand All @@ -74,13 +89,11 @@ interface RawListedAttestation {
* The DSSE payload is a base64-encoded JSON in-toto statement; the
* statement carries `predicateType` as a top-level string. Returns
* `null` when the payload is missing, undecodable, or non-JSON —
* callers treat that as "unknown predicate type" and filter the
* entry out of any predicate-type-filtered result.
* callers treat that as "unknown predicate type" and drop the entry.
*
* @internal
*/
const predicateTypeFromBundle = (entry: RawListedAttestation): string | null => {
const payload = entry.bundle?.dsseEnvelope?.payload;
const predicateTypeFromPayload = (payload: unknown): string | null => {
if (typeof payload !== "string" || payload.length === 0) return null;
try {
const decoded = Buffer.from(payload, "base64").toString("utf-8");
Expand All @@ -93,37 +106,69 @@ const predicateTypeFromBundle = (entry: RawListedAttestation): string | null =>

/**
* Parse the raw `GET /repos/.../attestations/{subject_digest}` body
* into a list of {@link AttestationListEntry} rows.
*
* @remarks
* Owner/repo are needed to construct the GitHub UI URL when the
* response carries a numeric `id`. When `id` is missing, we fall
* back to `bundle_url` (the raw-bundle endpoint) so callers always
* receive a non-empty URL to surface.
* into its list of entries.
*
* @internal
*/
const parseListedAttestations = (raw: unknown, owner: string, repo: string): ReadonlyArray<AttestationListEntry> => {
const parseRawAttestations = (raw: unknown): ReadonlyArray<RawListedAttestation> => {
const data = typeof raw === "string" ? (JSON.parse(raw) as unknown) : raw;
const attestations =
data && typeof data === "object" && "attestations" in data
? (data as { attestations: ReadonlyArray<RawListedAttestation> | undefined }).attestations
: undefined;
if (!Array.isArray(attestations)) return [];
return Array.isArray(attestations) ? attestations : [];
};

const entries: AttestationListEntry[] = [];
for (const entry of attestations) {
const predicateType = predicateTypeFromBundle(entry);
if (predicateType === null) continue;
const attestationUrl =
typeof entry.id === "number"
? `https://github.com/${owner}/${repo}/attestations/${entry.id}`
: typeof entry.bundle_url === "string" && entry.bundle_url.length > 0
? entry.bundle_url
: `https://github.com/${owner}/${repo}/attestations`;
entries.push({ attestationUrl, predicateType });
/**
* Build the GitHub UI URL for a listed attestation.
*
* @remarks
* Prefers the numeric `id` when present; otherwise falls back to
* `bundle_url` (the raw-bundle endpoint) so callers always receive a
* non-empty URL to surface.
*
* @internal
*/
const attestationUrlFor = (entry: RawListedAttestation, owner: string, repo: string): string =>
typeof entry.id === "number"
? `https://github.com/${owner}/${repo}/attestations/${entry.id}`
: typeof entry.bundle_url === "string" && entry.bundle_url.length > 0
? entry.bundle_url
: `https://github.com/${owner}/${repo}/attestations`;

/**
* Fetch a `bundle_url` and recover the in-toto `predicateType` from the
* Sigstore bundle's DSSE payload.
*
* @remarks
* The `2026-03-10` list response omits the inline `bundle`, so the
* no-filter path resolves each entry's predicate type by fetching its
* bundle. Returns `null` when the URL is missing or the payload cannot
* be decoded — the entry is then dropped, matching the legacy
* "undecodable bundle" behaviour.
*
* @internal
*/
const predicateTypeFromBundleUrl = async (
octokit: OctokitLike,
bundleUrl: string | undefined,
): Promise<string | null> => {
if (typeof bundleUrl !== "string" || bundleUrl.length === 0) return null;
try {
const response = await octokit.request(FETCH_BUNDLE_REQUEST, {
bundle_url: bundleUrl,
headers: { "X-GitHub-Api-Version": ATTESTATIONS_API_VERSION },
});
const data = typeof response.data === "string" ? (JSON.parse(response.data) as unknown) : response.data;
const payload =
data && typeof data === "object"
? ((data as { dsseEnvelope?: { payload?: unknown } }).dsseEnvelope?.payload ??
(data as { bundle?: { dsseEnvelope?: { payload?: unknown } } }).bundle?.dsseEnvelope?.payload)
: undefined;
return predicateTypeFromPayload(payload);
} catch {
return null;
}
return entries;
};

/**
Expand Down Expand Up @@ -385,20 +430,46 @@ export const AttestLive = Layer.succeed(Attest, {
throw new Error("GitHubClient did not provide an Octokit-compatible client");
}
try {
// `predicate_type` query parameter is deprecated as of
// 2026-03-10 and scheduled for removal 2028-03-10. We
// already filter client-side below (the canonical match
// because some predicates carry the freeform URI rather
// than the short alias the server accepts), so we omit
// the query entirely — the endpoint returns every
// attestation for the subject digest and the loop
// downstream selects the matching one.
// Pin the supported API version. Octokit's default still
// serves the legacy list shape that GitHub flags as
// deprecated (Sunset 2028-03-10); `2026-03-10` drops the
// inline `bundle` in favour of a `bundle_url`. When the
// caller filters by predicate type we let the server
// narrow the list (`predicate_type` accepts the freeform
// URI), so every returned entry matches the requested
// type. Without a filter we fetch each `bundle_url` to
// recover its predicate type.
const response = await octokit.request(LIST_ATTESTATIONS_REQUEST, {
owner,
repo,
subject_digest: `sha256:${subjectSha256}`,
headers: { "X-GitHub-Api-Version": ATTESTATIONS_API_VERSION },
...(predicateTypeFilter !== undefined ? { predicate_type: predicateTypeFilter } : {}),
});
return { data: parseListedAttestations(response.data, owner, repo) };
const raw = parseRawAttestations(response.data);

if (predicateTypeFilter !== undefined) {
return {
data: raw.map((entry) => ({
attestationUrl: attestationUrlFor(entry, owner, repo),
predicateType: predicateTypeFilter,
})),
};
}

// Fetch the bundles concurrently; `Promise.all` preserves
// input order, and `predicateTypeFromBundleUrl` swallows its
// own errors (returning `null`) so one bad bundle never
// rejects the batch — undecodable entries are dropped below.
const resolved = await Promise.all(
raw.map(async (entry) => {
const predicateType = await predicateTypeFromBundleUrl(octokit, entry.bundle_url);
return predicateType === null
? null
: { attestationUrl: attestationUrlFor(entry, owner, repo), predicateType };
}),
);
return { data: resolved.filter((entry): entry is AttestationListEntry => entry !== null) };
} catch (cause) {
if (isEmptyListError(cause)) {
return { data: [] as ReadonlyArray<AttestationListEntry> };
Expand All @@ -417,11 +488,6 @@ export const AttestLive = Layer.succeed(Attest, {
),
);

// Server-side `predicate_type` is advisory — some attestations
// carry the freeform URI variant that the short-alias filter
// does not catch. Re-filter client-side on the parsed in-toto
// predicateType for an authoritative match.
if (predicateTypeFilter === undefined) return entries;
return entries.filter((e) => e.predicateType === predicateTypeFilter);
return entries;
}),
});
Loading
Loading