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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 20:55:25 -05:00
parent 3df487feb8
commit 5b12157460
3 changed files with 59 additions and 3 deletions

View File

@ -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."

View File

@ -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.

View File

@ -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)"'));
}
});
});