diff --git a/.changeset/brave-cups-dream.md b/.changeset/brave-cups-dream.md new file mode 100644 index 0000000..219128a --- /dev/null +++ b/.changeset/brave-cups-dream.md @@ -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. diff --git a/.claude/design/github-action-effects/errors-and-schemas.md b/.claude/design/github-action-effects/errors-and-schemas.md index 3472a31..0362836 100644 --- a/.claude/design/github-action-effects/errors-and-schemas.md +++ b/.claude/design/github-action-effects/errors-and-schemas.md @@ -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 ``` diff --git a/.claude/design/github-action-effects/services.md b/.claude/design/github-action-effects/services.md index 9ba7bf7..873543a 100644 --- a/.claude/design/github-action-effects/services.md +++ b/.claude/design/github-action-effects/services.md @@ -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`) diff --git a/docs/03-services.md b/docs/03-services.md index 9487be1..a74916f 100644 --- a/docs/03-services.md +++ b/docs/03-services.md @@ -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 diff --git a/docs/10-slsa-attestations.md b/docs/10-slsa-attestations.md index 391bb60..39e47fe 100644 --- a/docs/10-slsa-attestations.md +++ b/docs/10-slsa-attestations.md @@ -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`. diff --git a/src/layers/AttestLive.ts b/src/layers/AttestLive.ts index 6e41527..879a5ee 100644 --- a/src/layers/AttestLive.ts +++ b/src/layers/AttestLive.ts @@ -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) => Promise<{ data: unknown }>; @@ -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; } @@ -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"); @@ -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 => { +const parseRawAttestations = (raw: unknown): ReadonlyArray => { const data = typeof raw === "string" ? (JSON.parse(raw) as unknown) : raw; const attestations = data && typeof data === "object" && "attestations" in data ? (data as { attestations: ReadonlyArray | 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 => { + 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; }; /** @@ -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 }; @@ -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; }), }); diff --git a/src/services/Attest.list.test.ts b/src/services/Attest.list.test.ts index 408f772..0a92ec8 100644 --- a/src/services/Attest.list.test.ts +++ b/src/services/Attest.list.test.ts @@ -36,34 +36,80 @@ import { const dssePayload = (statement: Record): string => Buffer.from(JSON.stringify(statement), "utf-8").toString("base64"); +/** Build the Sigstore bundle that `bundle_url` returns under API version 2026-03-10. */ +const bundleFor = (predicateType: string) => ({ + mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + verificationMaterial: {}, + dsseEnvelope: { + payload: dssePayload({ + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "pkg:npm/x@1.0.0", digest: { sha256: "a".repeat(64) } }], + predicateType, + predicate: {}, + }), + payloadType: "application/vnd.in-toto+json", + signatures: [], + }, +}); + +interface CapturedRequest { + readonly route: string; + readonly params: Record; +} + /** - * Build a synthetic attestations-list response shaped like - * `GET /repos/{owner}/{repo}/attestations/{digest}` returns. + * Faithful simulation of the `2026-03-10` attestations API: the list + * route honours the server-side `predicate_type` filter and returns + * entries WITHOUT the inline `bundle` (only `bundle_url`); fetching a + * `bundle_url` returns the full Sigstore bundle. Optionally records every + * request for assertions on the pinned API version and query parameters. */ -const listResponse = ( - entries: ReadonlyArray<{ readonly id: number; readonly predicateType: string; readonly subjectName?: string }>, -) => ({ - attestations: entries.map((e) => ({ - id: e.id, - bundle: { - mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", - verificationMaterial: {}, - dsseEnvelope: { - payload: dssePayload({ - _type: "https://in-toto.io/Statement/v1", - subject: [{ name: e.subjectName ?? "pkg:npm/x@1.0.0", digest: { sha256: "a".repeat(64) } }], - predicateType: e.predicateType, - predicate: {}, - }), - payloadType: "application/vnd.in-toto+json", - signatures: [], - }, - }, - repository_id: 1234, - bundle_url: `https://example.test/bundles/${e.id}`, - initiator: "test", - })), -}); +const attestationServer = ( + all: ReadonlyArray<{ readonly id: number; readonly predicateType: string }>, + opts: { readonly repo?: { owner: string; repo: string }; readonly calls?: CapturedRequest[] } = {}, +): Layer.Layer => { + const repo = opts.repo ?? { owner: "savvy-web", repo: "silk-release-action" }; + const byUrl = new Map( + all.map((a) => [`https://example.test/bundles/${a.id}`, a]), + ); + const handle = (route: string, params: Record): unknown => { + opts.calls?.push({ route, params }); + if (route.includes("{subject_digest}")) { + const filter = params.predicate_type as string | undefined; + const selected = filter === undefined ? all : all.filter((a) => a.predicateType === filter); + return { + attestations: selected.map((a) => ({ + id: a.id, + repository_id: 1234, + bundle_url: `https://example.test/bundles/${a.id}`, + initiator: "test", + })), + }; + } + const found = byUrl.get(params.bundle_url as string); + if (found === undefined) { + const error = new Error("HTTP 404") as Error & { status: number }; + error.status = 404; + throw error; + } + return bundleFor(found.predicateType); + }; + return Layer.succeed(GitHubClient, { + rest: (_op: string, fn: (octokit: unknown) => Promise<{ data: T }>) => + Effect.tryPromise({ + try: () => + fn({ + request: (route: string, params: Record) => + Promise.resolve({ data: handle(route, params) }), + }), + catch: (e) => e as never, + }).pipe(Effect.map((r) => r.data)), + graphql: () => Effect.die("graphql not used"), + paginate: () => Effect.die("paginate not used"), + paginateStream: () => Stream.die("paginateStream not used"), + repo: Effect.succeed(repo), + }); +}; /** Build a synthetic GitHubClient that returns a fixed REST response and ignores graphql/paginate. */ const fixedGitHubClient = ( @@ -119,17 +165,17 @@ const noopSigner: Layer.Layer = Layer.succeed(SigstoreSigner, { describe("Attest.listForSubject — live implementation", () => { const SUBJECT = "abc123".padEnd(64, "0"); - it("returns every attestation when no predicateType filter is provided", async () => { + it("returns every attestation when no predicateType filter is provided (decoding each bundle_url)", async () => { const layer = Layer.mergeAll( AttestLive, noopSigner, noopOidc, - fixedGitHubClient( - listResponse([ + attestationServer( + [ { id: 1, predicateType: SLSA_PROVENANCE_V1 }, { id: 2, predicateType: CYCLONEDX_BOM }, - ]), - { owner: "acme", repo: "widgets" }, + ], + { repo: { owner: "acme", repo: "widgets" } }, ), ); @@ -147,18 +193,16 @@ describe("Attest.listForSubject — live implementation", () => { expect(entries[1]?.predicateType).toBe(CYCLONEDX_BOM); }); - it("filters by predicateType client-side", async () => { + it("filters by predicateType via the server-side predicate_type query", async () => { const layer = Layer.mergeAll( AttestLive, noopSigner, noopOidc, - fixedGitHubClient( - listResponse([ - { id: 1, predicateType: SLSA_PROVENANCE_V1 }, - { id: 2, predicateType: CYCLONEDX_BOM }, - { id: 3, predicateType: SLSA_PROVENANCE_V1 }, - ]), - ), + attestationServer([ + { id: 1, predicateType: SLSA_PROVENANCE_V1 }, + { id: 2, predicateType: CYCLONEDX_BOM }, + { id: 3, predicateType: SLSA_PROVENANCE_V1 }, + ]), ); const entries = await Effect.runPromise( @@ -170,6 +214,61 @@ describe("Attest.listForSubject — live implementation", () => { expect(entries).toHaveLength(2); expect(entries.every((e) => e.predicateType === SLSA_PROVENANCE_V1)).toBe(true); + expect(entries.map((e) => e.attestationUrl)).toEqual([ + "https://github.com/savvy-web/silk-release-action/attestations/1", + "https://github.com/savvy-web/silk-release-action/attestations/3", + ]); + }); + + it("pins X-GitHub-Api-Version 2026-03-10 and forwards predicate_type without fetching bundles", async () => { + const calls: CapturedRequest[] = []; + const layer = Layer.mergeAll( + AttestLive, + noopSigner, + noopOidc, + attestationServer([{ id: 1, predicateType: SLSA_PROVENANCE_V1 }], { calls }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const attest = yield* Attest; + return yield* attest.listForSubject(SUBJECT, { predicateType: SLSA_PROVENANCE_V1 }); + }).pipe(Effect.provide(layer)), + ); + + // Only the list request fires — the server-side filter means no + // per-entry bundle_url fetch on the filtered path. + expect(calls).toHaveLength(1); + const list = calls[0]; + expect(list?.route).toBe("GET /repos/{owner}/{repo}/attestations/{subject_digest}"); + expect(list?.params.subject_digest).toBe(`sha256:${SUBJECT}`); + expect(list?.params.predicate_type).toBe(SLSA_PROVENANCE_V1); + expect((list?.params.headers as Record)["X-GitHub-Api-Version"]).toBe("2026-03-10"); + }); + + it("pins X-GitHub-Api-Version 2026-03-10 on both the list and the bundle_url fetch", async () => { + const calls: CapturedRequest[] = []; + const layer = Layer.mergeAll( + AttestLive, + noopSigner, + noopOidc, + attestationServer([{ id: 1, predicateType: SLSA_PROVENANCE_V1 }], { calls }), + ); + + await Effect.runPromise( + Effect.gen(function* () { + const attest = yield* Attest; + return yield* attest.listForSubject(SUBJECT); + }).pipe(Effect.provide(layer)), + ); + + // List + one bundle fetch, both pinned and the list unfiltered. + expect(calls).toHaveLength(2); + expect(calls[0]?.params.predicate_type).toBeUndefined(); + expect(calls[1]?.route).toBe("GET {bundle_url}"); + for (const call of calls) { + expect((call.params.headers as Record)["X-GitHub-Api-Version"]).toBe("2026-03-10"); + } }); it("returns [] when the API responds with an empty attestations array", async () => { diff --git a/src/services/Attest.ts b/src/services/Attest.ts index 91065f5..4745e7d 100644 --- a/src/services/Attest.ts +++ b/src/services/Attest.ts @@ -183,17 +183,23 @@ export class Attest extends Context.Tag("github-action-effects/Attest")< * List existing attestations for a tarball digest. * * @remarks - * Hits `GET /repos/{owner}/{repo}/attestations/sha256:{hex}`. The - * GitHub REST API returns one entry per existing attestation; each - * entry's Sigstore bundle is parsed to recover the in-toto - * statement's `predicateType` (e.g. `https://slsa.dev/provenance/v1` - * for provenance, `https://cyclonedx.org/bom` for SBOM). + * Hits `GET /repos/{owner}/{repo}/attestations/sha256:{hex}` pinned + * to REST API version `2026-03-10` (the legacy shape is deprecated; + * Sunset 2028-03-10). That version omits the inline Sigstore bundle, + * so `predicateType` is recovered one of two ways: * - * `options.predicateType` filters the result client-side so callers - * can ask "is there a provenance attestation already?" or "is there - * an SBOM attestation already?" without re-fetching. Returns an - * empty array when the subject has no attestations or the API - * returns a 404 — only true errors (auth, network, rate limit) + * - When `options.predicateType` is supplied the server narrows the + * list via the `predicate_type` query parameter (which accepts the + * freeform URI), and every returned entry is that type — no bundle + * fetch needed. This is the orchestrator's path ("is there a + * provenance attestation already?" / "...an SBOM attestation?"). + * - Without a filter each entry's `bundle_url` is fetched and its + * in-toto `predicateType` decoded (e.g. `https://slsa.dev/provenance/v1` + * for provenance, `https://cyclonedx.org/bom` for SBOM); entries + * whose bundle cannot be decoded are dropped. + * + * Returns an empty array when the subject has no attestations or the + * API returns a 404 — only true errors (auth, network, rate limit) * surface as {@link AttestError}. * * The orchestrator uses presence to decide whether to skip a