From 5b121574607a11c378fed6ce4926946d321b1ccb Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 7 Sep 2026 20:55:25 -0500 Subject: [PATCH] fix(ci): verify staging dependency resolution before installation Require a reviewed lock digest for staging migrator and app builds so registry drift fails before lifecycle-enabled installation. Co-Authored-By: Paperclip --- .github/workflows/docker.yml | 31 +++++++++++++++++-- doc/sandbox-work-folders.md | 5 ++- .../src/__tests__/docker-build-stamp.test.ts | 26 ++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b0923ed38d..56f15eb5a3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,11 @@ on: type: string default: "" + staging_lock_sha256: + description: Reviewed SHA-256 of the resolved pnpm 9 lockfile; required with staging_artifact_base_url + type: string + default: "" + permissions: contents: read packages: write @@ -52,8 +57,14 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 - - name: Resolve branch manifest changes before the frozen install - run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + - name: Resolve and verify staging dependencies before installation + env: + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} + run: | + set -euo pipefail + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict - run: pnpm install --frozen-lockfile - name: Build matching migrator artifacts env: @@ -125,10 +136,18 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi + changed="$(git status --porcelain)" if [ -z "$changed" ]; then echo "Lockfile already matches package metadata." @@ -319,10 +338,18 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi + changed="$(git status --porcelain)" if [ -z "$changed" ]; then echo "Lockfile already matches package metadata." diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index cdce71a5dc..88a9eec63a 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -176,7 +176,10 @@ the user's explicit sign-off. Staging migrator artifacts use an immutable object-storage prefix. The Docker workflow's optional `staging_artifact_base_url` input builds DB/shared tarballs and an integrity manifest as a GitHub Actions artifact; it has no release-write -permission. Transfer those artifacts to the staging bucket using conditional +permission. Supply `staging_lock_sha256` with the reviewed SHA-256 of the +resolved pnpm 9.15.4 lockfile. The migrator and both app-image builds verify +that digest before installing dependencies, failing if registry resolution +has changed. Transfer those artifacts to the staging bucket using conditional creates, publishing the manifest last. Do not create GitHub releases or publish npm packages for this flow. diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index f013d30ec6..b7ba62bd0b 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -56,3 +56,29 @@ describe("docker build-stamp wiring", () => { ).toBeGreaterThanOrEqual(2); }); }); + + +describe("staging dependency integrity", () => { + it("verifies the reviewed resolved lock before installing the migrator", () => { + const migrator = workflow.split(" staging-migrator:")[1].split(" build-and-push:")[0]; + const verification = migrator.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(migrator).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(migrator).toContain('[[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]]'); + expect(verification).toBeGreaterThan(migrator.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(migrator.indexOf("pnpm install --frozen-lockfile")); + }); + + it("checks the same lock in both staging app build contexts", () => { + const contexts = workflow.split(" - name: Refresh lockfile for Docker build context").slice(1); + expect(contexts).toHaveLength(2); + for (const context of contexts) { + const refresh = context.split(" - name:")[0]; + expect(refresh).toContain("STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }}"); + expect(refresh).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(refresh).toContain('if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then'); + const verification = refresh.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(verification).toBeGreaterThan(refresh.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(refresh.indexOf('changed="$(git status --porcelain)"')); + } + }); +});