|
1 | | -# IMPLEMENT — Issue #58 (supply-chain attestations) |
| 1 | +# Implement: issue #66 — paginate GraphQL fetcher + MAX_FETCHED\_\* caps |
2 | 2 |
|
3 | 3 | ## Summary |
4 | 4 |
|
5 | | -Wires SLSA v1 build provenance + CycloneDX/SPDX SBOMs into every Docker image |
6 | | -the release pipeline publishes, and adds a hard regression gate that fails the |
7 | | -workflow if either attestation is silently dropped by a future refactor. All |
8 | | -changes are additive YAML (`.github/workflows/docker-build.yml`) plus matching |
9 | | -docs — no `Dockerfile.*`, `package.json`, or `src/` change. Closes #58. |
10 | | - |
11 | | -The fix has two layers: |
12 | | - |
13 | | -1. **BuildKit-native** (`provenance: mode=max` + `sbom: true` on the build |
14 | | - step): each per-arch leaf push gets an in-toto SLSA v1 provenance manifest |
15 | | - and an SPDX 2.3 SBOM stored as OCI subject descriptors, and the merge |
16 | | - step's `imagetools create` walks each index digest so the descriptors |
17 | | - survive the manifest-list assembly without any extra CLI plumbing. |
18 | | -2. **GitHub-Sigstore** (`actions/attest-build-provenance@v4` + |
19 | | - `actions/attest-sbom@v4` after the merge): bind Sigstore-signed |
20 | | - attestations to the merged manifest digest, surfaced via the GitHub |
21 | | - Attestations API and Docker Hub's "Build attestations" badge. The |
22 | | - CycloneDX SBOM that flows into `attest-sbom` is generated from the |
23 | | - merged image by `anchore/sbom-action@v0`. |
24 | | - |
25 | | -The `scan` job runs `gh attestation verify` for both predicate types |
26 | | -(`https://slsa.dev/provenance/v1` + `https://cyclonedx.org/bom`) before Trivy |
27 | | -on every release tag — two separate calls so each predicate must exist. |
| 5 | +Closes #66 (`fix(pipeline): GraphQL fetcher silently truncates PR/issue context past 100 items`). |
| 6 | + |
| 7 | +`src/core/fetcher.ts` previously issued single-page GraphQL requests with `first: 100` on every connection (issue/PR comments, reviews, the inline comments nested under each review, and changed files). Anything past the first 100 items was silently dropped before the data ever reached the agent — the prompt looked complete but was missing context, which is the failure mode the issue calls out. |
| 8 | + |
| 9 | +The fix: |
| 10 | + |
| 11 | +1. Every paginated query exposes a single cursor variable named exactly `$cursor` and selects `pageInfo { hasNextPage endCursor }` on its one paginated connection. Both names are part of `@octokit/plugin-paginate-graphql`'s contract: the plugin hard-codes the cursor parameter name (`iterator.js`) and runs a depth-first search for the first `pageInfo` it finds (`object-helpers.js`), so multi-connection / aliased-cursor queries silently truncate or throw. The PR fetch is therefore split into three parallel `paginate(...)` calls (`PR_FIRST_QUERY` for top-level scalars + files, `PR_COMMENTS_QUERY`, `PR_REVIEWS_QUERY`); the issue fetch uses one. The plugin is bundled with `octokit ^5.0.5` and exposed on every `octokit.graphql` instance. |
| 12 | +2. A separate `REVIEW_COMMENTS_QUERY` walks each review's overflow inline comments keyed on the review node ID — the nested per-review pagination cannot ride along on `PR_REVIEWS_QUERY` because of contract-rule #1. |
| 13 | +3. Four new env vars (`MAX_FETCHED_COMMENTS` / `_REVIEWS` / `_REVIEW_COMMENTS` / `_FILES`, default `500` each) cap the merged result. When a cap fires the fetcher emits `log.warn({ connection, fetched, cap })` and sets `FetchedData.truncated.<connection> = true`. |
| 14 | +4. `buildPrompt` in `src/core/prompt-builder.ts` reads `data.truncated` and prepends a `WARNING: pre-fetched context is incomplete…` banner naming the affected connections, so the agent knows to reach for the GitHub CLI when it needs the missing items. |
| 15 | +5. The TOCTOU filter (`filterByTriggerTime`) runs after the paginate merge, so its semantics are unchanged. |
| 16 | + |
| 17 | +## Plan deviations |
| 18 | + |
| 19 | +- **T1 / T2 (install + thread plugin) skipped as no-ops.** `octokit ^5.0.5` already bundles `@octokit/plugin-paginate-graphql` and exposes `octokit.graphql.paginate` on every existing instance. Verified via lockfile inspection and a runtime probe. Threading a shared factory through 11 instantiation sites would have been pure churn for zero behaviour change. |
28 | 20 |
|
29 | 21 | ## Files changed |
30 | 22 |
|
31 | | -- `.github/workflows/docker-build.yml` · primary subject of the issue — |
32 | | - enables BuildKit attestations on the build step (T1), scopes |
33 | | - `id-token: write` + `attestations: write` to the merge job only (T3), |
34 | | - captures the merged manifest digest, generates a CycloneDX SBOM, and |
35 | | - publishes Sigstore-signed provenance + SBOM attestations after the |
36 | | - manifest-list push (T4); adds a `gh attestation verify` regression gate |
37 | | - to the scan job before Trivy (T5). Top-level perms gain |
38 | | - `attestations: read` so build/scan stay read-only; merge overrides locally. |
39 | | -- `docs/operate/deployment.md` · new "Verifying image attestations" |
40 | | - subsection under "Build" with consumer-side `gh attestation verify` and |
41 | | - `docker buildx imagetools inspect` recipes covering both attestation |
42 | | - flavours and both image variants (T7). |
43 | | -- `docs/operate/observability.md` · new "Supply-chain attestations" section |
44 | | - documenting the registry / Sigstore / GitHub-API storage matrix and |
45 | | - pointing operators at the consumer commands in `deployment.md` (T8). |
46 | | -- `CLAUDE.md` · "Owns" cell for `docker-build.yml` updated to mention SLSA + SBOM attestations and the `gh attestation verify` regression gate; one-line `20260502-supply-chain-attestations` entry in "Recent Changes" (T9). |
| 23 | +- `src/core/fetcher.ts` · rewrote both queries to select `pageInfo`, switched to `graphql.paginate`, added `applyCap()` + nested review-comment follow-up; emits structured warn logs and sets `FetchedData.truncated` flags. |
| 24 | +- `src/types.ts` · extended `FetchedData` with `truncated?: { comments?, reviews?, reviewComments?, changedFiles? }`. |
| 25 | +- `src/config.ts` · added 4 zod fields + env wiring (`MAX_FETCHED_COMMENTS` / `_REVIEWS` / `_REVIEW_COMMENTS` / `_FILES`, default `500`). |
| 26 | +- `src/core/prompt-builder.ts` · added `buildTruncationBanner` and injected it into the prompt when any flag is set. |
| 27 | +- `test/core/fetcher.test.ts` · new tests covering pagination merge (length > 100), TOCTOU after merge, cap fire (log + flag), nested review-comment pagination, and banner injection. |
| 28 | +- `test/factories.ts` · extended `makeOctokit` with `graphqlPaginateResponses` (substring-keyed routing on the paginate fn). |
| 29 | +- `docs/operate/configuration.md` · 4 new rows documenting the env vars. |
| 30 | +- `docs/operate/observability.md` · new "Data fetching safety caps" section documenting the warn log shape and prompt banner. |
47 | 31 |
|
48 | 32 | ## Commits |
49 | 33 |
|
50 | | -See the PR commit list — short SHAs and conventional-commit subjects are |
51 | | -visible there. |
| 34 | +- `fix(fetcher): paginate GraphQL connections + MAX_FETCHED_* caps (closes #66)` — single commit, branch `fix/issue-66-paginate-graphql`. |
52 | 35 |
|
53 | 36 | ## Tests run |
54 | 37 |
|
55 | | -- `bun run typecheck` · pass (no TypeScript output, exit 0) |
56 | | -- `bun run lint` · pass (0 errors, 289 pre-existing warnings — none new from this change) |
57 | | -- `bun run format` · pass after `bun run format:fix` re-flowed two doc tables |
58 | | -- `actionlint .github/workflows/*.yml` · pass (no output, all workflows lint-clean) |
59 | | -- `bun run scripts/check-docs-citations.ts` · pass (every `src/<file>:<line>` citation in-range) |
60 | | -- `bun run scripts/check-docs-versions.ts` · pass (Bun version pins consistent with `.tool-versions`) |
61 | | -- `mkdocs build --strict` · pass (`Documentation built in 0.58 seconds`, no warnings) |
62 | | -- `bun test` · 519 pass / 153 skip / **194 pre-existing fail** — verified |
63 | | - baseline by `git stash && bun test` before reapplying my diff: same |
64 | | - pass/fail counts. The failing suites need Postgres + Valkey |
65 | | - (`bun run dev:deps`); they are unrelated to YAML / Markdown changes here. |
| 38 | +- `bun run typecheck` · clean. |
| 39 | +- `NODE_OPTIONS='--max-old-space-size=4096' bunx eslint .` · 0 errors / 291 warnings (all pre-existing return-type warnings in unrelated files). |
| 40 | +- `bun run format` · clean. |
| 41 | +- `bun test test/core/fetcher.test.ts` · 30 pass / 0 fail / 64 expect calls. |
| 42 | +- `bun run scripts/check-docs-citations.ts` · clean. |
| 43 | +- `bun run scripts/check-docs-versions.ts` · clean. |
| 44 | +- `bun run docs:build` · skipped locally (`mkdocs` not installed in workspace); runs in CI via `.github/workflows/docs.yml`. |
| 45 | +- `bun run test` (isolated runner): 78 files passed, 25 files skipped because Postgres / Valkey are not running in this workspace (pre-existing infrastructure dependency, none of the skipped files were touched by this PR). |
66 | 46 |
|
67 | 47 | ## Verification |
68 | 48 |
|
69 | | -Each task in the plan is satisfied as follows: |
70 | | - |
71 | | -- **T1** — `.github/workflows/docker-build.yml:160-169` adds |
72 | | - `provenance: mode=max` and `sbom: true`. Inline comment cites the |
73 | | - `push-by-digest` default-off behaviour the issue called out and links the |
74 | | - Docker multi-platform guide. BuildKit will emit per-arch attestation |
75 | | - manifests alongside each leaf image push. |
76 | | - |
77 | | -- **T2** — Merge step preserved as-is (`imagetools create` already walks |
78 | | - the per-arch index digests, which now reference both image AND |
79 | | - attestation manifests via the BuildKit emission from T1). Inline comment |
80 | | - at `.github/workflows/docker-build.yml:248-256` explains why no CLI |
81 | | - plumbing is needed; this matches the documented Docker multi-platform |
82 | | - pattern. |
83 | | - |
84 | | -- **T3** — Top-level adds `attestations: read` (line 61) so the scan job |
85 | | - inherits read-only verification scope. Merge job overrides locally |
86 | | - (lines 199-202) with `id-token: write` + `attestations: write` — |
87 | | - least-privilege; build/scan retain only the top-level grants. |
88 | | - |
89 | | -- **T4** — After `Create manifest list and push`, the workflow now: |
90 | | - 1. `Inspect merged image and capture digest` (lines 261-274) — captures |
91 | | - the index digest with regex validation so a malformed parse fails |
92 | | - fast rather than silently passing a bad subject to attest-\*. |
93 | | - 2. `Generate CycloneDX SBOM for merged image` (lines 276-283) using |
94 | | - `anchore/sbom-action@v0` — syft-backed, produces CycloneDX JSON. |
95 | | - 3. `Attest build provenance` (lines 285-290) — Sigstore-signed in-toto |
96 | | - SLSA v1 attestation pushed to the registry as a sibling descriptor |
97 | | - on the merged manifest digest. |
98 | | - 4. `Attest SBOM` (lines 292-298) — Sigstore-signed CycloneDX SBOM |
99 | | - attestation, same subject digest. |
100 | | - |
101 | | -- **T5** — Scan job's new `Verify image attestations` step (lines 329-343) |
102 | | - calls `gh attestation verify` twice with explicit `--predicate-type` |
103 | | - filters for SLSA provenance and CycloneDX SBOM. Each call fails the job |
104 | | - if its predicate type is absent — so dropping either attestation in a |
105 | | - future refactor will break the release before Trivy runs. |
106 | | - |
107 | | -- **T6 (deviation noted)** — Plan said to SHA-pin in addition to the major |
108 | | - tag. Repo-wide `Grep` for `uses: .+@[0-9a-f]{40}` returned zero matches: |
109 | | - every workflow uses tag-only pinning at the major version, with Renovate |
110 | | - handling bumps via the `github-actions` group rule in `renovate.json`. I |
111 | | - followed the **existing repo posture** (tag-only major-version pins for |
112 | | - `actions/attest-build-provenance@v4`, `actions/attest-sbom@v4`, |
113 | | - `anchore/sbom-action@v0`) over the plan's SHA-pin recommendation, since |
114 | | - adding SHAs only here would be immediately undone by the next Renovate |
115 | | - run and breaks consistency with the other 11 actions in the file. |
116 | | - Renovate's `github-actions` group will pick up bumps weekly. Also bumped |
117 | | - the action major versions from the plan's `@v3` to `@v4` because v4.x |
118 | | - has been GA since 2026-02-26 (today: 2026-05-02) and the repo otherwise |
119 | | - tracks current major versions for actions (`checkout@v6`, |
120 | | - `build-push-action@v7`, `download-artifact@v8`). |
121 | | - |
122 | | -- **T7-T8** — `docs/operate/deployment.md` and `docs/operate/observability.md` |
123 | | - updated with consumer verification commands and storage-surface matrix; |
124 | | - cross-linked. `mkdocs build --strict` passes; the project's bespoke |
125 | | - citation / version checks pass. |
126 | | - |
127 | | -- **T9** — `CLAUDE.md` CI/CD row updated and a `20260502-…` "Recent Changes" |
128 | | - entry added. Format auto-fix re-flowed the table column widths, expected. |
129 | | - |
130 | | -- **T10 (deferred — out-of-band verification)** — End-to-end smoke test via |
131 | | - `gh workflow run docker-build.yml` against a dev tag is the maintainer's |
132 | | - call to schedule (it pushes a real image to Docker Hub and consumes an |
133 | | - attestations-API quota). All YAML / docs gates that _can_ run locally |
134 | | - pass; the actual attestation-emit / verify behaviour is the maintainer's |
135 | | - smoke test on first dev release after merge. |
136 | | - |
137 | | -### Security posture preserved |
138 | | - |
139 | | -- Top-level `permissions:` only grew by `attestations: read` (least |
140 | | - required to verify); the existing `contents: read` and |
141 | | - `security-events: write` are unchanged. |
142 | | -- The merge job's elevated scopes (`id-token: write`, |
143 | | - `attestations: write`) are confined to that one job — build and scan |
144 | | - cannot mint Sigstore tokens or write attestations. |
145 | | -- `gh attestation verify` runs with `secrets.GITHUB_TOKEN` (the default |
146 | | - job token), no PAT. |
147 | | -- All dynamic inputs flowing into `run:` blocks remain passed via `env:` |
148 | | - first (defense-in-depth posture from CLAUDE.md preserved — the new |
149 | | - steps follow the same pattern, e.g. `IMAGE_REF` / `REPO` / `TAG` / |
150 | | - `IMAGE` / `DIGEST` env mappings). |
| 49 | +- **Acceptance criterion: PRs/issues with > 100 comments / reviews / changed files no longer truncate silently.** New test "merges paginated issue comments into FetchedData (length > 100)" proves the merge for 250 comments; "merges paginated review comments across nested pageInfo" proves the nested review-comment merge (100 + 50 = 150). |
| 50 | +- **Acceptance criterion: a hard cap protects the prompt window.** `applyCap` clamps to `config.maxFetchedComments` (etc.), emits a structured warn, and sets `truncated.<connection> = true`. Test "logs warn and sets truncated flag when MAX_FETCHED cap fires" asserts all three (length, log fields, flag). |
| 51 | +- **Acceptance criterion: the agent must know when context is incomplete.** `buildPrompt` injects a `WARNING: pre-fetched context is incomplete…` banner naming the affected connections. Test "buildPrompt includes truncation banner when truncated flag is set" asserts the banner. |
| 52 | +- **TOCTOU semantics preserved.** Test "applies filterByTriggerTime AFTER pagination merge" sets `triggerTimestamp` to comment-240 of 600 and asserts comment-239 (newest pre-trigger) survives while comments 240+ are dropped. |
| 53 | + |
| 54 | +### Intentionally NOT done |
| 55 | + |
| 56 | +- T1 / T2 (paginate-graphql install + threaded factory). Plugin is already bundled with `octokit ^5.0.5` and `octokit.graphql.paginate` is present on every existing instance. Documented above under "Plan deviations". |
0 commit comments