diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml index 62025d3a85..38fb18e2f7 100644 --- a/.github/workflows/docker-cloud.yml +++ b/.github/workflows/docker-cloud.yml @@ -3,6 +3,13 @@ name: Docker cloud on: workflow_dispatch: workflow_call: + inputs: + staging_artifact_base_url: + type: string + default: "" + staging_lock_sha256: + type: string + default: "" permissions: {} @@ -98,9 +105,16 @@ 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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bbb830b080..d74f75fd1d 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: "" + # Least privilege: nothing at the workflow level; each job declares exactly # the token scopes it uses (checkout needs contents:read, GHCR pushes need # packages:write). @@ -59,6 +64,14 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 + - 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: @@ -172,10 +185,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." @@ -406,6 +427,9 @@ jobs: build-and-push-cloud: if: github.event_name != 'push' || github.ref != 'refs/heads/master' uses: ./.github/workflows/docker-cloud.yml + with: + staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }} + staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }} permissions: contents: read packages: write diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 4794989feb..26a4737957 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -76,3 +76,34 @@ 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 caller = workflow.split(" build-and-push-cloud:")[1].split(" # Moves")[0]; + expect(caller).toContain("uses: ./.github/workflows/docker-cloud.yml"); + expect(caller).toContain("staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }}"); + expect(caller).toContain("staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }}"); + const contexts = [workflow, cloudWorkflow].flatMap((source) => + source.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)"')); + } + }); +});