diff --git a/.dockerignore b/.dockerignore index b8cad4152a..1b9aa11ca5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,3 +29,11 @@ packages/paperclip-runner/**/*.test.ts packages/paperclip-runner/**/*.test.tsx packages/paperclip-runner/runner/crates/*/tests packages/paperclip-runner/scripts/*-smoke.mjs +# Exceptions (last match wins): the image build re-runs the runner's +# generated-file drift checks, so their committed outputs and inputs must +# survive the slimming above. 2026-09-04: the *.md rule stripped the +# committed capability contract out of the context and every image build +# on master failed its drift check — .github/docker-context-checks.Dockerfile +# now guards this in PR CI. +!packages/paperclip-runner/generated/** +!packages/paperclip-runner/docs/capability-contract.md diff --git a/.github/docker-context-checks.Dockerfile b/.github/docker-context-checks.Dockerfile new file mode 100644 index 0000000000..48f5921d9c --- /dev/null +++ b/.github/docker-context-checks.Dockerfile @@ -0,0 +1,43 @@ +# Runs the runner's generated-file drift checks against the EXACT build +# context the image builds see — same .dockerignore semantics — so a +# context-slimming change that strips a committed build input fails the +# pull request instead of every post-merge image build. (2026-09-04: a new +# `packages/paperclip-runner/**/*.md` ignore rule stripped the committed +# capability contract out of the context; every Docker build on master then +# failed its drift check, and no cloud image published for eight hours +# while PR CI stayed green.) +# +# Only checks whose compared output is independent of dependency versions +# run here: ajv is installed for schema VALIDATION only (pinned to the +# runner's declared range), while codegen checks like +# generate-protocol-schema-module stay out — their emitted bytes vary with +# the ajv release, so running them against a fresh install would raise +# false drift alarms. Those still run inside the real image build, which +# installs the locked dependency tree; the existence assertions below keep +# their committed inputs and outputs covered by this probe regardless. +# +# node:24-slim — the runner requires Node >= 24.11 and the production +# image builds on Node 24; the digest pin keeps the security gate's own +# runtime immutable. +FROM node:24-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e +WORKDIR /context +COPY . . +# Committed artifacts the image build reads whose drift checks cannot run +# here (they need the locked dependency tree or compiled dist/). Existence +# in the context is the property this probe guards; content correctness is +# the real build's job. If a path is intentionally removed from the repo, +# update this list in the same PR. +RUN test -f packages/paperclip-runner/generated/capability/semantic-tool-contracts.json \ + && test -f packages/paperclip-runner/generated/semantic-action-catalog.json \ + && test -f packages/paperclip-runner/spec/evals/stress-workflow-traceability.json \ + && test -d packages/paperclip-runner/protocol/fixtures/replay +# ajv is installed in an isolated directory (the runner's own package.json +# uses workspace: ranges npm cannot install from) and symlinked in so ESM +# resolution finds it from the scripts' location. +RUN AJV_RANGE="$(node -p "require('/context/packages/paperclip-runner/package.json').dependencies.ajv")" \ + && mkdir /probe-deps && cd /probe-deps && npm init -y >/dev/null \ + && npm install --ignore-scripts --no-audit --no-fund "ajv@${AJV_RANGE}" \ + && ln -s /probe-deps/node_modules /context/packages/paperclip-runner/node_modules \ + && cd /context/packages/paperclip-runner \ + && node scripts/generate-capability-contract.mjs --check \ + && node scripts/check-capability-inventory.mjs diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index cc04e18fc4..4bcaaa279a 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -513,11 +513,40 @@ jobs: pnpm test:run:general -- --group '${{ matrix.group }}' fi + docker_context_integrity: + name: Docker context integrity + needs: gate + if: ${{ needs.gate.outputs.full_ci == 'true' }} + runs-on: ${{ needs.gate.outputs.runner }} + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + # Not every runner the gate can select ships the Buildx plugin — + # the image-build workflows set it up explicitly, so this lane does + # too rather than failing before it checks anything. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + + # Same .dockerignore semantics as the real image builds: a + # context-slimming change that strips a committed build input must + # fail here, on the pull request, instead of failing every + # post-merge image build. (2026-09-04: a new **/*.md ignore rule + # stripped the committed capability contract out of the context; + # every Docker build on master failed its drift check and no cloud + # image published for eight hours while PR CI stayed green.) + - name: Run generated-file drift checks against the Docker build context + run: docker buildx build --file .github/docker-context-checks.Dockerfile . + verify: # Preserve the legacy required-check name while the underlying work runs in parallel. name: verify if: ${{ always() }} - needs: [gate, policy, typecheck_release_registry, general_tests, build] + needs: [gate, policy, typecheck_release_registry, general_tests, build, docker_context_integrity] runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 5 @@ -529,6 +558,7 @@ jobs: TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }} GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }} BUILD_RESULT: ${{ needs.build.result }} + DOCKER_CONTEXT_INTEGRITY_RESULT: ${{ needs.docker_context_integrity.result }} run: | test "$POLICY_RESULT" = "success" case "$FULL_CI" in @@ -536,11 +566,13 @@ jobs: test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success" test "$GENERAL_TESTS_RESULT" = "success" test "$BUILD_RESULT" = "success" + test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "success" ;; false) test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped" test "$GENERAL_TESTS_RESULT" = "skipped" test "$BUILD_RESULT" = "skipped" + test "$DOCKER_CONTEXT_INTEGRITY_RESULT" = "skipped" ;; *) echo "Invalid full_ci decision: $FULL_CI" >&2 diff --git a/scripts/__tests__/e2e-shard.test.mjs b/scripts/__tests__/e2e-shard.test.mjs index ae56c9c411..1fed746a27 100644 --- a/scripts/__tests__/e2e-shard.test.mjs +++ b/scripts/__tests__/e2e-shard.test.mjs @@ -241,11 +241,20 @@ test("the trusted PR workflow limits full CI to merge-relevant stack layers", () ); const verify = jobs.get("verify"); - assert.match(verify, /^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, build\]$/m); + assert.match( + verify, + /^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, build, docker_context_integrity\]$/m, + ); assert.match(verify, /POLICY_RESULT: \$\{\{ needs\.policy\.result \}\}/); assert.match(verify, /test "\$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"/); assert.match(verify, /test "\$GENERAL_TESTS_RESULT" = "skipped"/); assert.match(verify, /test "\$BUILD_RESULT" = "skipped"/); + // Both halves of the docker-context lane's gating: the result must be + // wired into the aggregate's env AND asserted successful on full CI — + // dropping either would let `verify` pass after the lane fails. + assert.match(verify, /DOCKER_CONTEXT_INTEGRITY_RESULT: \$\{\{ needs\.docker_context_integrity\.result \}\}/); + assert.match(verify, /test "\$DOCKER_CONTEXT_INTEGRITY_RESULT" = "success"/); + assert.match(verify, /test "\$DOCKER_CONTEXT_INTEGRITY_RESULT" = "skipped"/); const e2e = jobs.get("e2e"); assert.match(e2e, /^ {4}needs: \[gate, policy, e2e_shards\]$/m);