From cf61a6949954ba89d41d8bc27430d743d33c60b5 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 05:15:34 +0200 Subject: [PATCH 01/32] fix(ci): isolate static deploy credentials (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 98 ++++++++++++++++---- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 8fe5e94..d46cefd 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -54,7 +54,7 @@ env: GCS_BUCKET: websites-deploy jobs: - build-deploy: + build: if: github.event.action != 'closed' # Self-hosted: site builds were the org's remaining paid-GHA-minutes bulk. # Prebaked ci-runner image = no per-job node/pnpm/gcloud downloads; npm @@ -64,17 +64,12 @@ jobs: container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest outputs: - deployed: ${{ steps.flags.outputs.should_deploy }} - # Forward NPM_TOKEN so .npmrc env-var substitution finds it during - # pnpm install. Caller side declares the secret via `secrets: inherit` - # (or an explicit `secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` map). + preview_eligible: ${{ steps.flags.outputs.should_deploy }} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} permissions: contents: read - id-token: write - pull-requests: write steps: - uses: actions/checkout@v4 @@ -123,9 +118,33 @@ jobs: env: BUILD_ENV_VARS: ${{ inputs.build_env }} + - name: Upload static site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + if-no-files-found: error + retention-days: 1 + + deploy-production: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: self-hosted-k8s + container: + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + + permissions: + contents: read + id-token: write + + steps: + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + - name: Authenticate to Google Cloud - if: steps.flags.outputs.should_deploy == 'true' - id: auth uses: google-github-actions/auth@v3 with: project_id: ${{ env.PROJECT_ID }} @@ -133,7 +152,6 @@ jobs: service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - name: Set up Cloud SDK - if: steps.flags.outputs.should_deploy == 'true' uses: google-github-actions/setup-gcloud@v2 with: skip_install: true # gcloud prebaked in ci-runner image @@ -154,7 +172,6 @@ jobs: # fail the deploy — this step also doubles as the auth canary before # the parallel rsync. - name: Warm gcloud credential cache - if: steps.flags.outputs.should_deploy == 'true' run: | for i in 1 2 3 4 5; do gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 @@ -165,15 +182,57 @@ jobs: exit 1 - name: Deploy to production - if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: WEBSITE: ${{ inputs.website }} run: | gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ ./dist "gs://${GCS_BUCKET}/${WEBSITE}" + deploy-preview: + needs: build + if: >- + github.event_name == 'pull_request' && + github.event.action != 'closed' && + needs.build.outputs.preview_eligible == 'true' + runs-on: self-hosted-k8s + container: + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + with: + skip_install: true # gcloud prebaked in ci-runner image + + - name: Warm gcloud credential cache + run: | + for i in 1 2 3 4 5; do + gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 + echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" + sleep $((i * 10)) + done + echo "gcloud warm-up failed after 5 attempts" >&2 + exit 1 + - name: Deploy preview - if: steps.flags.outputs.should_deploy == 'true' && github.event_name == 'pull_request' env: WEBSITE_SLUG: ${{ inputs.website_slug }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -182,7 +241,6 @@ jobs: ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" - name: Comment preview URL - if: steps.flags.outputs.should_deploy == 'true' && github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | @@ -256,20 +314,20 @@ jobs: tags: ${{ inputs.discord_tags }} discord-build-update: - needs: [build-deploy, discord-thread-open] + needs: [build, deploy-preview, discord-thread-open] if: >- always() && github.event_name == 'pull_request' && github.event.action != 'closed' && - needs.build-deploy.result != 'cancelled' + needs.build.result != 'cancelled' uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: thread_update - status: ${{ needs.build-deploy.result }} + status: ${{ needs.build.result == 'success' && (needs.build.outputs.preview_eligible != 'true' && 'success' || needs.deploy-preview.result) || needs.build.result }} # Fork PRs skip the preview deploy — don't post a dead link - preview_url: ${{ needs.build-deploy.outputs.deployed == 'true' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} + preview_url: ${{ needs.deploy-preview.result == 'success' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} discord-thread-close: if: github.event_name == 'pull_request' && github.event.action == 'closed' @@ -281,12 +339,12 @@ jobs: status: ${{ github.event.pull_request.merged && 'merged' || 'closed' }} discord-deploy: - needs: build-deploy + needs: deploy-production if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: deploy - status: ${{ needs.build-deploy.result }} + status: ${{ needs.deploy-production.result }} tags: ${{ inputs.discord_tags }} From b63da6668ddec77239fdd7459e7aa78a14719cf8 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 05:27:00 +0200 Subject: [PATCH 02/32] fix(ci): pin privileged deploy dependencies (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 38 ++++++++++---------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index d46cefd..4ca6c91 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -62,17 +62,14 @@ jobs: # already build-only via the should_deploy flag. runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 outputs: preview_eligible: ${{ steps.flags.outputs.should_deploy }} - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # Fork PRs can't authenticate via the repo-bound WIF provider — build # only, skip auth/deploy instead of failing red. @@ -96,7 +93,12 @@ jobs: # node_version input is no longer consulted (all sites are on 22). - name: Install dependencies - run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts + + - name: Rebuild dependencies + run: pnpm rebuild - name: Type check if: inputs.typecheck @@ -131,7 +133,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 permissions: contents: read @@ -145,14 +147,14 @@ jobs: path: dist - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: project_id: ${{ env.PROJECT_ID }} workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f with: skip_install: true # gcloud prebaked in ci-runner image @@ -196,7 +198,7 @@ jobs: needs.build.outputs.preview_eligible == 'true' runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 permissions: contents: read @@ -211,14 +213,14 @@ jobs: path: dist - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: project_id: ${{ env.PROJECT_ID }} workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f with: skip_install: true # gcloud prebaked in ci-runner image @@ -241,7 +243,7 @@ jobs: ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" - name: Comment preview URL - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const slug = '${{ inputs.website_slug }}'; @@ -279,7 +281,7 @@ jobs: if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: self-hosted-k8s container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner:latest + image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 permissions: contents: read @@ -287,14 +289,14 @@ jobs: steps: - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: project_id: ${{ env.PROJECT_ID }} workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f with: skip_install: true # gcloud prebaked in ci-runner image @@ -339,12 +341,12 @@ jobs: status: ${{ github.event.pull_request.merged && 'merged' || 'closed' }} discord-deploy: - needs: deploy-production + needs: [build, deploy-production] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: deploy - status: ${{ needs.deploy-production.result }} + status: ${{ needs.build.result == 'success' && needs.deploy-production.result || needs.build.result }} tags: ${{ inputs.discord_tags }} From b7b39c837186b8304c592540e8491e8f20ee40da Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 05:37:10 +0200 Subject: [PATCH 03/32] fix(ci): scope Discord caller permissions (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 4ca6c91..57ad5af 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -308,6 +308,8 @@ jobs: discord-thread-open: if: github.event_name == 'pull_request' && github.event.action == 'opened' + permissions: + issues: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -322,6 +324,8 @@ jobs: github.event_name == 'pull_request' && github.event.action != 'closed' && needs.build.result != 'cancelled' + permissions: + issues: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -333,6 +337,8 @@ jobs: discord-thread-close: if: github.event_name == 'pull_request' && github.event.action == 'closed' + permissions: + issues: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -343,6 +349,7 @@ jobs: discord-deploy: needs: [build, deploy-production] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: {} uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} From 4ffebfbbc65a4ae8ebfe83d0d3ea528d5a8b053b Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 05:53:17 +0200 Subject: [PATCH 04/32] fix(ci): configure actionlint runner labels --- .github/actionlint.yaml | 18 ++++++++++++++++++ .github/workflows/deploy-static-website.yaml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..b465694 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,18 @@ +self-hosted-runner: + labels: + - self-hosted-k8s + +paths: + .github/workflows/deploy-backend.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/deploy-cloudrun.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/i18n-instrument.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2086:.+' + .github/workflows/i18n-pipeline.yaml: + ignore: + - 'shellcheck reported issue in this script: SC2001:.+' + - 'shellcheck reported issue in this script: SC2086:.+' diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 57ad5af..4bb5f73 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -113,7 +113,7 @@ jobs: run: | if [ -n "$BUILD_ENV_VARS" ]; then while IFS= read -r line; do - [ -n "$line" ] && export "$line" + [ -n "$line" ] && export "${line?}" done <<< "$BUILD_ENV_VARS" fi pnpm build From 1aa7ed74624ee5c06879cd58dff72b8c440115c2 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 06:07:14 +0200 Subject: [PATCH 05/32] fix(ci): preserve nested workflow permission bounds (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 4bb5f73..c83ddd3 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -309,7 +309,7 @@ jobs: discord-thread-open: if: github.event_name == 'pull_request' && github.event.action == 'opened' permissions: - issues: write + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -325,7 +325,7 @@ jobs: github.event.action != 'closed' && needs.build.result != 'cancelled' permissions: - issues: write + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -338,7 +338,7 @@ jobs: discord-thread-close: if: github.event_name == 'pull_request' && github.event.action == 'closed' permissions: - issues: write + pull-requests: write uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -349,7 +349,9 @@ jobs: discord-deploy: needs: [build, deploy-production] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: {} + permissions: + contents: read + pull-requests: read uses: ./.github/workflows/discord-notify.yaml secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} From f9cfed8e75c530ee01e282dfe0485f5365ea3dbe Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 06:41:21 +0200 Subject: [PATCH 06/32] fix(ci): isolate pull request deploy credentials (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 542 +++++++++++-------- 1 file changed, 325 insertions(+), 217 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index c83ddd3..a09d007 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -36,6 +36,11 @@ on: required: false type: string default: "22" + pnpm_version: + description: "Trusted pnpm version used for preview builds" + required: false + type: string + default: "10.32.1" discord_tags: description: "Comma-separated Discord forum tag names (e.g. admin,importer)" required: false @@ -43,8 +48,8 @@ on: default: "" secrets: NPM_TOKEN: - description: "npm token used for private @cellarnode/* package installs (.npmrc env-var substitution)" - required: false + description: "Read-only npm token used to fetch private @cellarnode packages" + required: true DISCORD_WEBHOOK_URL: description: "Discord webhook credential" required: false @@ -54,260 +59,364 @@ env: GCS_BUCKET: websites-deploy jobs: - build: - if: github.event.action != 'closed' - # Self-hosted: site builds were the org's remaining paid-GHA-minutes bulk. - # Prebaked ci-runner image = no per-job node/pnpm/gcloud downloads; npm - # rides the in-region AR mirror. All repos are private, and fork PRs are - # already build-only via the should_deploy flag. + # pull_request_target loads this workflow from the base branch. The PR can + # change source and lockfiles, but it cannot add a step that reads NPM_TOKEN. + # This job only populates a pnpm store; it never executes project or package + # lifecycle code. The store crosses into the tokenless build through an + # artifact and the GitHub-hosted runners are isolated per job. + preview-dependencies: + if: github.event_name == 'pull_request_target' && github.event.action != 'closed' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout pull-request dependency lock + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: ${{ inputs.pnpm_version }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ inputs.node_version }} + + - name: Reject executable dependency sources + shell: bash + run: | + if grep -En '(git\+|github:|gitlab:|bitbucket:|https?://|file:|link:)' pnpm-lock.yaml; then + echo "Preview dependency lock contains a non-registry source" >&2 + exit 1 + fi + + - name: Fetch dependencies without lifecycle execution + shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store + run: | + rm -f -- .npmrc .pnpmfile.cjs + TRUSTED_HOME="${RUNNER_TEMP}/cellarnode-preview-home" + trap 'rm -rf -- "$TRUSTED_HOME"' EXIT + mkdir -p "$TRUSTED_HOME" + umask 077 + printf '%s\n' \ + '@cellarnode:registry=https://registry.npmjs.org/' \ + "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ + > "$TRUSTED_HOME/.npmrc" + HOME="$TRUSTED_HOME" pnpm fetch --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + + - name: Upload dependency store + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: preview-dependencies-${{ inputs.website_slug }} + path: ${{ runner.temp }}/pnpm-store + if-no-files-found: error + retention-days: 1 + + build-preview: + needs: preview-dependencies + if: github.event_name == 'pull_request_target' && github.event.action != 'closed' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout pull-request source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: ${{ inputs.pnpm_version }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ inputs.node_version }} + + - name: Download dependency store + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: preview-dependencies-${{ inputs.website_slug }} + path: ${{ runner.temp }}/pnpm-store + + - name: Install dependencies offline + shell: bash + env: + PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store + run: | + rm -f -- .npmrc .pnpmfile.cjs + pnpm install --offline --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + + - name: Rebuild dependencies + run: pnpm rebuild + + - name: Type check + if: inputs.typecheck + run: pnpm tsc --noEmit + + - name: Lint + if: inputs.lint + run: pnpm lint + + - name: Build preview + shell: bash + env: + BUILD_ENV_VARS: ${{ inputs.build_env }} + run: | + if [ -n "$BUILD_ENV_VARS" ]; then + while IFS= read -r line; do + [ -n "$line" ] && export "${line?}" + done <<< "$BUILD_ENV_VARS" + fi + pnpm build + + - name: Upload static site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + if-no-files-found: error + retention-days: 1 + + build-production: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 - outputs: - preview_eligible: ${{ steps.flags.outputs.should_deploy }} permissions: contents: read steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - # Fork PRs can't authenticate via the repo-bound WIF provider — build - # only, skip auth/deploy instead of failing red. - - name: Set deploy flags - id: flags - env: - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - BASE_REPO: ${{ github.repository }} - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - if [ "$HEAD_REPO" = "$BASE_REPO" ]; then - echo "should_deploy=true" >> "$GITHUB_OUTPUT" - else - echo "should_deploy=false" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Install dependencies + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts + + - name: Rebuild dependencies + run: pnpm rebuild + + - name: Type check + if: inputs.typecheck + run: pnpm tsc --noEmit + + - name: Lint + if: inputs.lint + run: pnpm lint + + - name: Build production + shell: bash + env: + BUILD_ENV_VARS: ${{ inputs.build_env }} + run: | + if [ -n "$BUILD_ENV_VARS" ]; then + while IFS= read -r line; do + [ -n "$line" ] && export "${line?}" + done <<< "$BUILD_ENV_VARS" fi - else - echo "should_deploy=true" >> "$GITHUB_OUTPUT" - fi - - # node22 + pnpm (corepack) come prebaked in the ci-runner image; the - # node_version input is no longer consulted (all sites are on 22). - - - name: Install dependencies - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts - - - name: Rebuild dependencies - run: pnpm rebuild - - - name: Type check - if: inputs.typecheck - run: pnpm tsc --noEmit - - - name: Lint - if: inputs.lint - run: pnpm lint - - - name: Build - shell: bash - run: | - if [ -n "$BUILD_ENV_VARS" ]; then - while IFS= read -r line; do - [ -n "$line" ] && export "${line?}" - done <<< "$BUILD_ENV_VARS" - fi - pnpm build - env: - BUILD_ENV_VARS: ${{ inputs.build_env }} - - - name: Upload static site artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: static-site-${{ inputs.website_slug }} - path: dist - if-no-files-found: error - retention-days: 1 + pnpm build + + - name: Upload static site artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + if-no-files-found: error + retention-days: 1 deploy-production: - needs: build + needs: build-production if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 - permissions: contents: read id-token: write steps: - - name: Download static site artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: static-site-${{ inputs.website_slug }} - path: dist - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 - with: - project_id: ${{ env.PROJECT_ID }} - workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' - service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f - with: - skip_install: true # gcloud prebaked in ci-runner image - - # Warm gcloud's SQLite credential/token caches with a single serial call - # before the parallel rsync. On ephemeral runners ~/.config/gcloud is - # fresh every run, so the first gcloud invocation performs a schema - # migration (ALTER TABLE ... ADD COLUMN id_token / regional_access_ - # boundary*). `gcloud storage rsync` fans out into worker processes that - # otherwise race that migration and the token cache writes, failing - # upload tasks with "duplicate column name: ..." / "database is locked" - # and leaving the site partially deployed (2026-07-09, admin dashboard). - # Retry with backoff: the WIF token exchange re-fetches the GitHub OIDC - # subject token on refresh, and GitHub's Envoy-fronted token endpoint - # intermittently rejects under load ("upstream connect error ... reset - # reason: overflow" → "Unable to retrieve Identity Pool subject token", - # seen 2026-07-09 on run 29019157677). Better to absorb that here than - # fail the deploy — this step also doubles as the auth canary before - # the parallel rsync. - - name: Warm gcloud credential cache - run: | - for i in 1 2 3 4 5; do - gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 - echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" - sleep $((i * 10)) - done - echo "gcloud warm-up failed after 5 attempts" >&2 - exit 1 - - - name: Deploy to production - env: - WEBSITE: ${{ inputs.website }} - run: | - gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ - ./dist "gs://${GCS_BUCKET}/${WEBSITE}" + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + with: + skip_install: true + + - name: Warm gcloud credential cache + run: | + for i in 1 2 3 4 5; do + gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 + echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" + sleep $((i * 10)) + done + echo "gcloud warm-up failed after 5 attempts" >&2 + exit 1 + + - name: Deploy to production + env: + WEBSITE: ${{ inputs.website }} + run: | + gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ + ./dist "gs://${GCS_BUCKET}/${WEBSITE}" deploy-preview: - needs: build + needs: build-preview if: >- - github.event_name == 'pull_request' && + github.event_name == 'pull_request_target' && github.event.action != 'closed' && - needs.build.outputs.preview_eligible == 'true' + github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 - permissions: contents: read id-token: write pull-requests: write steps: - - name: Download static site artifact - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: static-site-${{ inputs.website_slug }} - path: dist - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 - with: - project_id: ${{ env.PROJECT_ID }} - workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' - service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f - with: - skip_install: true # gcloud prebaked in ci-runner image - - - name: Warm gcloud credential cache - run: | - for i in 1 2 3 4 5; do - gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 - echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" - sleep $((i * 10)) - done - echo "gcloud warm-up failed after 5 attempts" >&2 - exit 1 - - - name: Deploy preview - env: - WEBSITE_SLUG: ${{ inputs.website_slug }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ - ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" - - - name: Comment preview URL - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b - with: - script: | - const slug = '${{ inputs.website_slug }}'; - const pr = context.payload.pull_request; - const previewUrl = `https://${slug}-pr${pr.number}.dev.cellarnode.com`; - const body = `## Preview Deployment\n\nPreview available at: ${previewUrl}`; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && c.body.includes('Preview Deployment') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ + - name: Download static site artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: static-site-${{ inputs.website_slug }} + path: dist + + - name: Reject non-regular artifact entries + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + with: + skip_install: true + + - name: Warm gcloud credential cache + run: | + for i in 1 2 3 4 5; do + gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 + echo "gcloud warm-up attempt ${i}/5 failed; retrying in $((i * 10))s" + sleep $((i * 10)) + done + echo "gcloud warm-up failed after 5 attempts" >&2 + exit 1 + + - name: Deploy preview + env: + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ + ./dist "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" + + - name: Comment preview URL + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const slug = process.env.WEBSITE_SLUG; + const prNumber = Number(process.env.PR_NUMBER); + const previewUrl = `https://${slug}-pr${prNumber}.dev.cellarnode.com`; + const body = `## Preview Deployment\n\nPreview available at: ${previewUrl}`; + + const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: pr.number, - body: body + issue_number: prNumber, }); - } + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes('Preview Deployment') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } cleanup: - if: github.event_name == 'pull_request' && github.event.action == 'closed' + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 - permissions: contents: read id-token: write steps: - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 - with: - project_id: ${{ env.PROJECT_ID }} - workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' - service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f - with: - skip_install: true # gcloud prebaked in ci-runner image - - - name: Delete preview - run: | - gcloud storage rm --recursive gs://${{ env.GCS_BUCKET }}/preview/${{ inputs.website_slug }}/pr${{ github.event.pull_request.number }} || true - - # --- Discord thread lifecycle --- + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + project_id: ${{ env.PROJECT_ID }} + workload_identity_provider: 'projects/1040576468442/locations/global/workloadIdentityPools/iamwipp-pipelines/providers/iamwipp-github-pipelines-beveriq' + service_account: 'pipelines@${{ env.PROJECT_ID }}.iam.gserviceaccount.com' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + with: + skip_install: true + + - name: Delete preview + env: + WEBSITE_SLUG: ${{ inputs.website_slug }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gcloud storage rm --recursive "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" || true discord-thread-open: - if: github.event_name == 'pull_request' && github.event.action == 'opened' + if: github.event_name == 'pull_request_target' && github.event.action == 'opened' permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -318,12 +427,12 @@ jobs: tags: ${{ inputs.discord_tags }} discord-build-update: - needs: [build, deploy-preview, discord-thread-open] + needs: [preview-dependencies, build-preview, deploy-preview, discord-thread-open] if: >- always() && - github.event_name == 'pull_request' && + github.event_name == 'pull_request_target' && github.event.action != 'closed' && - needs.build.result != 'cancelled' + needs.preview-dependencies.result != 'cancelled' permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -331,12 +440,11 @@ jobs: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: thread_update - status: ${{ needs.build.result == 'success' && (needs.build.outputs.preview_eligible != 'true' && 'success' || needs.deploy-preview.result) || needs.build.result }} - # Fork PRs skip the preview deploy — don't post a dead link + status: ${{ needs.preview-dependencies.result == 'success' && (needs.build-preview.result == 'success' && (needs.deploy-preview.result == 'skipped' && 'success' || needs.deploy-preview.result) || needs.build-preview.result) || needs.preview-dependencies.result }} preview_url: ${{ needs.deploy-preview.result == 'success' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} discord-thread-close: - if: github.event_name == 'pull_request' && github.event.action == 'closed' + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -347,7 +455,7 @@ jobs: status: ${{ github.event.pull_request.merged && 'merged' || 'closed' }} discord-deploy: - needs: [build, deploy-production] + needs: [build-production, deploy-production] if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read @@ -357,5 +465,5 @@ jobs: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: deploy - status: ${{ needs.build.result == 'success' && needs.deploy-production.result || needs.build.result }} + status: ${{ needs.build-production.result == 'success' && needs.deploy-production.result || needs.build-production.result }} tags: ${{ inputs.discord_tags }} From 806709f1ec59a0b4b3f01410b475981520df7194 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 06:47:42 +0200 Subject: [PATCH 07/32] fix(ci): scope preview lock source check (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index a09d007..9e7d100 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -92,7 +92,7 @@ jobs: - name: Reject executable dependency sources shell: bash run: | - if grep -En '(git\+|github:|gitlab:|bitbucket:|https?://|file:|link:)' pnpm-lock.yaml; then + if grep -En ":[[:space:]]*[\"']?(git\\+|github:|gitlab:|bitbucket:|https?://|file:|link:)" pnpm-lock.yaml; then echo "Preview dependency lock contains a non-registry source" >&2 exit 1 fi From 6d0e04093cbb60298dcce1746f7d10a3ba4d4b17 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 07:24:46 +0200 Subject: [PATCH 08/32] fix(ci): harden preview dependency boundary (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 101 +++++++++++++++---- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 9e7d100..0caf6fc 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -54,6 +54,10 @@ on: description: "Discord webhook credential" required: false +concurrency: + group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: PROJECT_ID: festive-terrain-478011-h0 GCS_BUCKET: websites-deploy @@ -65,19 +69,15 @@ jobs: # lifecycle code. The store crosses into the tokenless build through an # artifact and the GitHub-hosted runners are isolated per job. preview-dependencies: - if: github.event_name == 'pull_request_target' && github.event.action != 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read steps: - - name: Checkout pull-request dependency lock - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - name: Set up pnpm uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: @@ -89,13 +89,42 @@ jobs: with: node-version: ${{ inputs.node_version }} + - name: Checkout pull-request dependency lock + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Reject executable dependency sources shell: bash run: | - if grep -En ":[[:space:]]*[\"']?(git\\+|github:|gitlab:|bitbucket:|https?://|file:|link:)" pnpm-lock.yaml; then - echo "Preview dependency lock contains a non-registry source" >&2 - exit 1 - fi + ruby <<'RUBY' + require "yaml" + + source = /(?:\A|@)(?:git\+|git@|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:)/i + failures = [] + visit = lambda do |value, path| + case value + when Hash + value.each do |key, child| + name = key.to_s + failures << (path + [name]).join(".") if source.match?(name) + if path.last == "resolution" && %w[directory tarball].include?(name) + failures << (path + [name]).join(".") + end + visit.call(child, path + [name]) + end + when Array + value.each_with_index { |child, index| visit.call(child, path + [index.to_s]) } + when String + failures << path.join(".") if source.match?(value) + end + end + + visit.call(YAML.safe_load(File.read("pnpm-lock.yaml"), aliases: true), []) + abort "Preview dependency lock contains a non-registry source at #{failures.uniq.join(', ')}" unless failures.empty? + RUBY - name: Fetch dependencies without lifecycle execution shell: bash @@ -112,19 +141,25 @@ jobs: '@cellarnode:registry=https://registry.npmjs.org/' \ "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ > "$TRUSTED_HOME/.npmrc" - HOME="$TRUSTED_HOME" pnpm fetch --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + HOME="$TRUSTED_HOME" pnpm fetch ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + cp pnpm-lock.yaml "$RUNNER_TEMP/resolved-pnpm-lock.yaml" - name: Upload dependency store uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: preview-dependencies-${{ inputs.website_slug }} - path: ${{ runner.temp }}/pnpm-store + path: | + ${{ runner.temp }}/pnpm-store + ${{ runner.temp }}/resolved-pnpm-lock.yaml if-no-files-found: error retention-days: 1 build-preview: needs: preview-dependencies - if: github.event_name == 'pull_request_target' && github.event.action != 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: read @@ -152,15 +187,16 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: preview-dependencies-${{ inputs.website_slug }} - path: ${{ runner.temp }}/pnpm-store + path: ${{ runner.temp }}/preview-dependencies - name: Install dependencies offline shell: bash env: - PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store + PNPM_STORE_PATH: ${{ runner.temp }}/preview-dependencies/pnpm-store run: | rm -f -- .npmrc .pnpmfile.cjs - pnpm install --offline --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + cp "$RUNNER_TEMP/preview-dependencies/resolved-pnpm-lock.yaml" pnpm-lock.yaml + pnpm install --offline ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" - name: Rebuild dependencies run: pnpm rebuild @@ -268,10 +304,14 @@ jobs: - name: Set up Cloud SDK uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud with: skip_install: true - name: Warm gcloud credential cache + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud run: | for i in 1 2 3 4 5; do gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 @@ -283,6 +323,7 @@ jobs: - name: Deploy to production env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud WEBSITE: ${{ inputs.website }} run: | gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ @@ -327,10 +368,14 @@ jobs: - name: Set up Cloud SDK uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud with: skip_install: true - name: Warm gcloud credential cache + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud run: | for i in 1 2 3 4 5; do gcloud storage ls "gs://${GCS_BUCKET}" > /dev/null && exit 0 @@ -342,6 +387,7 @@ jobs: - name: Deploy preview env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud WEBSITE_SLUG: ${{ inputs.website_slug }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | @@ -387,7 +433,10 @@ jobs: } cleanup: - if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -405,18 +454,24 @@ jobs: - name: Set up Cloud SDK uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud with: skip_install: true - name: Delete preview env: + CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud WEBSITE_SLUG: ${{ inputs.website_slug }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | gcloud storage rm --recursive "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" || true discord-thread-open: - if: github.event_name == 'pull_request_target' && github.event.action == 'opened' + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'opened' && + github.event.pull_request.head.repo.full_name == github.repository permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -432,6 +487,7 @@ jobs: always() && github.event_name == 'pull_request_target' && github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository && needs.preview-dependencies.result != 'cancelled' permissions: pull-requests: write @@ -444,7 +500,10 @@ jobs: preview_url: ${{ needs.deploy-preview.result == 'success' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} discord-thread-close: - if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml From db1667e54125648c33db3375bed43d86a7a1c993 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 07:38:22 +0200 Subject: [PATCH 09/32] fix(ci): validate production artifacts (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 23 ++++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 0caf6fc..c5da592 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -165,13 +165,6 @@ jobs: contents: read steps: - - name: Checkout pull-request source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - name: Set up pnpm uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa with: @@ -183,6 +176,13 @@ jobs: with: node-version: ${{ inputs.node_version }} + - name: Checkout pull-request source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Download dependency store uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: @@ -295,6 +295,15 @@ jobs: name: static-site-${{ inputs.website_slug }} path: dist + - name: Reject non-regular artifact entries + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + - name: Authenticate to Google Cloud uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: From 09e1b30341be5768b35fff70af6878c28dc1e92e Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 08:03:34 +0200 Subject: [PATCH 10/32] fix(ci): restrict trusted preview dependencies (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 60 ++++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index c5da592..98d2460 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -65,14 +65,15 @@ env: jobs: # pull_request_target loads this workflow from the base branch. The PR can # change source and lockfiles, but it cannot add a step that reads NPM_TOKEN. - # This job only populates a pnpm store; it never executes project or package - # lifecycle code. The store crosses into the tokenless build through an - # artifact and the GitHub-hosted runners are isolated per job. + # This job only handles organization-member PRs and populates a pnpm store; + # it never executes project or package lifecycle code. The store crosses into + # the tokenless build through an artifact and runners are isolated per job. preview-dependencies: if: >- github.event_name == 'pull_request_target' && github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) runs-on: ubuntu-latest permissions: contents: read @@ -96,42 +97,42 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Reject executable dependency sources + - name: Validate and fetch dependencies without lifecycle execution shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store run: | - ruby <<'RUBY' + validate_lock() { + ruby - "$1" <<'RUBY' require "yaml" + path = ARGV.fetch(0) source = /(?:\A|@)(?:git\+|git@|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:)/i failures = [] - visit = lambda do |value, path| + visit = lambda do |value, keys| case value when Hash value.each do |key, child| name = key.to_s - failures << (path + [name]).join(".") if source.match?(name) - if path.last == "resolution" && %w[directory tarball].include?(name) - failures << (path + [name]).join(".") + failures << (keys + [name]).join(".") if source.match?(name) + if keys.last == "resolution" && %w[directory tarball].include?(name) + failures << (keys + [name]).join(".") end - visit.call(child, path + [name]) + visit.call(child, keys + [name]) end when Array - value.each_with_index { |child, index| visit.call(child, path + [index.to_s]) } + value.each_with_index { |child, index| visit.call(child, keys + [index.to_s]) } when String - failures << path.join(".") if source.match?(value) + failures << keys.join(".") if source.match?(value) end end - visit.call(YAML.safe_load(File.read("pnpm-lock.yaml"), aliases: true), []) + visit.call(YAML.safe_load(File.read(path), aliases: true), []) abort "Preview dependency lock contains a non-registry source at #{failures.uniq.join(', ')}" unless failures.empty? RUBY + } - - name: Fetch dependencies without lifecycle execution - shell: bash - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store - run: | rm -f -- .npmrc .pnpmfile.cjs TRUSTED_HOME="${RUNNER_TEMP}/cellarnode-preview-home" trap 'rm -rf -- "$TRUSTED_HOME"' EXIT @@ -141,7 +142,12 @@ jobs: '@cellarnode:registry=https://registry.npmjs.org/' \ "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ > "$TRUSTED_HOME/.npmrc" - HOME="$TRUSTED_HOME" pnpm fetch ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + validate_lock pnpm-lock.yaml + if [ "${{ inputs.frozen_lockfile }}" = "false" ]; then + HOME="$TRUSTED_HOME" pnpm install --lockfile-only --no-frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + validate_lock pnpm-lock.yaml + fi + HOME="$TRUSTED_HOME" pnpm fetch --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" cp pnpm-lock.yaml "$RUNNER_TEMP/resolved-pnpm-lock.yaml" - name: Upload dependency store @@ -159,7 +165,8 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) runs-on: ubuntu-latest permissions: contents: read @@ -196,7 +203,7 @@ jobs: run: | rm -f -- .npmrc .pnpmfile.cjs cp "$RUNNER_TEMP/preview-dependencies/resolved-pnpm-lock.yaml" pnpm-lock.yaml - pnpm install --offline ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + pnpm install --offline --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" - name: Rebuild dependencies run: pnpm rebuild @@ -343,7 +350,8 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -480,7 +488,8 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action == 'opened' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -497,6 +506,7 @@ jobs: github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && needs.preview-dependencies.result != 'cancelled' permissions: pull-requests: write From 86bb2b5b57222cecaa16cd0852d0b96cc74758ed Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 08:09:42 +0200 Subject: [PATCH 11/32] fix(ci): harden preview lifecycle handling (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 98d2460..4294007 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -423,14 +423,15 @@ jobs: const previewUrl = `https://${slug}-pr${prNumber}.dev.cellarnode.com`; const body = `## Preview Deployment\n\nPreview available at: ${previewUrl}`; - const { data: comments } = await github.rest.issues.listComments({ + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + per_page: 100, }); const botComment = comments.find(comment => - comment.user.type === 'Bot' && comment.body.includes('Preview Deployment') + comment.user?.type === 'Bot' && (comment.body ?? '').includes('Preview Deployment') ); if (botComment) { @@ -482,7 +483,11 @@ jobs: WEBSITE_SLUG: ${{ inputs.website_slug }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - gcloud storage rm --recursive "gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" || true + PREVIEW_PREFIX="gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" + PREVIEW_OBJECT=$(gcloud storage objects list "$PREVIEW_PREFIX" --limit=1 --format='value(name)') + if [ -n "$PREVIEW_OBJECT" ]; then + gcloud storage rm --recursive "$PREVIEW_PREFIX" + fi discord-thread-open: if: >- From e96b2bd6a676201780bbc719cad0f3bfbbcb3004 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 08:29:21 +0200 Subject: [PATCH 12/32] fix(ci): harden preview cleanup and comments (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 4294007..ba112ee 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -134,6 +134,10 @@ jobs: } rm -f -- .npmrc .pnpmfile.cjs + if [ ! -f pnpm-lock.yaml ] || [ -L pnpm-lock.yaml ]; then + echo "pnpm-lock.yaml must be a regular file" >&2 + exit 1 + fi TRUSTED_HOME="${RUNNER_TEMP}/cellarnode-preview-home" trap 'rm -rf -- "$TRUSTED_HOME"' EXIT mkdir -p "$TRUSTED_HOME" @@ -421,7 +425,8 @@ jobs: const slug = process.env.WEBSITE_SLUG; const prNumber = Number(process.env.PR_NUMBER); const previewUrl = `https://${slug}-pr${prNumber}.dev.cellarnode.com`; - const body = `## Preview Deployment\n\nPreview available at: ${previewUrl}`; + const marker = ''; + const body = `${marker}\n## Preview Deployment\n\nPreview available at: ${previewUrl}`; const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, @@ -431,7 +436,8 @@ jobs: }); const botComment = comments.find(comment => - comment.user?.type === 'Bot' && (comment.body ?? '').includes('Preview Deployment') + comment.user?.login === 'github-actions[bot]' && + ((comment.body ?? '').includes(marker) || (comment.body ?? '').includes('## Preview Deployment')) ); if (botComment) { @@ -484,7 +490,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | PREVIEW_PREFIX="gs://${GCS_BUCKET}/preview/${WEBSITE_SLUG}/pr${PR_NUMBER}" - PREVIEW_OBJECT=$(gcloud storage objects list "$PREVIEW_PREFIX" --limit=1 --format='value(name)') + PREVIEW_OBJECT=$(gcloud storage objects list "${PREVIEW_PREFIX}/**" --limit=1 --format='value(name)') if [ -n "$PREVIEW_OBJECT" ]; then gcloud storage rm --recursive "$PREVIEW_PREFIX" fi From 3cf5e7337f1c057d7b90a4c581f0f547bb63e589 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 08:46:38 +0200 Subject: [PATCH 13/32] fix(ci): allow scoped workspace lock entries (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index ba112ee..cbc13a2 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -108,23 +108,34 @@ jobs: require "yaml" path = ARGV.fetch(0) - source = /(?:\A|@)(?:git\+|git@|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:)/i + external_source = /(?:\A|@)(?:git\+|git@|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:)/i + workspace_source = /\A(?:link:|workspace:)/i + allowed_workspace_ref = lambda do |keys| + keys.first == "importers" && %w[specifier version].include?(keys.last) + end failures = [] visit = lambda do |value, keys| case value when Hash value.each do |key, child| name = key.to_s - failures << (keys + [name]).join(".") if source.match?(name) + child_keys = keys + [name] + if external_source.match?(name) || + (workspace_source.match?(name) && !allowed_workspace_ref.call(child_keys)) + failures << child_keys.join(".") + end if keys.last == "resolution" && %w[directory tarball].include?(name) - failures << (keys + [name]).join(".") + failures << child_keys.join(".") end - visit.call(child, keys + [name]) + visit.call(child, child_keys) end when Array value.each_with_index { |child, index| visit.call(child, keys + [index.to_s]) } when String - failures << keys.join(".") if source.match?(value) + if external_source.match?(value) || + (workspace_source.match?(value) && !allowed_workspace_ref.call(keys)) + failures << keys.join(".") + end end end From 9d74e6a595b593b579e038308edaafce401e6ac6 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 09:10:36 +0200 Subject: [PATCH 14/32] fix(ci): restrict preview close jobs (CEL-1328) --- .github/workflows/deploy-static-website.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index cbc13a2..8a54be0 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -471,7 +471,8 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -544,7 +545,8 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml From 7d561319132eea6179727106689ee9d8ad73618a Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 09:37:36 +0200 Subject: [PATCH 15/32] fix(ci): reject non-registry lock resolutions (CEL-1328) --- .../deploy-static-lock-validator.test.rb | 67 +++++++++++++++++++ .github/workflows/deploy-static-website.yaml | 7 +- .github/workflows/validate-static-deploy.yaml | 27 ++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 .github/tests/deploy-static-lock-validator.test.rb create mode 100644 .github/workflows/validate-static-deploy.yaml diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb new file mode 100644 index 0000000..5b99675 --- /dev/null +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -0,0 +1,67 @@ +require "open3" +require "rbconfig" +require "tempfile" + +workflow = File.read(File.expand_path("../workflows/deploy-static-website.yaml", __dir__)) +match = workflow.match(/ruby - "\$1" <<'RUBY'\n(?.*?)^\s+RUBY$/m) +abort "Lock validator heredoc not found" unless match + +validator = match[:body].gsub(/^ {10}/, "") +fixtures = { + "registry resolution" => [<<~YAML, true], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha512-safe + YAML + "workspace importer reference" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + local-package: + specifier: workspace:* + version: link:../local-package + YAML + "raw git protocol" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + git-package@1.0.0: + resolution: + type: git + repo: git://evil.example/repo.git + YAML + "structured git resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + git-package@1.0.0: + resolution: + type: git + repo: registry.example.invalid/repo + YAML + "unknown resolution shape" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + external-package@1.0.0: + resolution: + path: external-package + YAML +} + +fixtures.each do |name, (lock, expected)| + Tempfile.create(["pnpm-lock", ".yaml"]) do |file| + file.write(lock) + file.flush + _stdout, _stderr, status = Open3.capture3( + RbConfig.ruby, + "-", + file.path, + stdin_data: validator, + ) + actual = status.success? + abort "#{name}: expected accepted=#{expected}, got accepted=#{actual}" unless actual == expected + end +end + +puts "Lock validator fixtures passed: #{fixtures.length}" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 8a54be0..5c5d502 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -108,7 +108,7 @@ jobs: require "yaml" path = ARGV.fetch(0) - external_source = /(?:\A|@)(?:git\+|git@|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:)/i + external_source = /(?:\A|@)(?:git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:)/i workspace_source = /\A(?:link:|workspace:)/i allowed_workspace_ref = lambda do |keys| keys.first == "importers" && %w[specifier version].include?(keys.last) @@ -120,11 +120,14 @@ jobs: value.each do |key, child| name = key.to_s child_keys = keys + [name] + if name == "resolution" && !child.is_a?(Hash) + failures << child_keys.join(".") + end if external_source.match?(name) || (workspace_source.match?(name) && !allowed_workspace_ref.call(child_keys)) failures << child_keys.join(".") end - if keys.last == "resolution" && %w[directory tarball].include?(name) + if keys.last == "resolution" && name != "integrity" failures << child_keys.join(".") end visit.call(child, child_keys) diff --git a/.github/workflows/validate-static-deploy.yaml b/.github/workflows/validate-static-deploy.yaml new file mode 100644 index 0000000..0896944 --- /dev/null +++ b/.github/workflows/validate-static-deploy.yaml @@ -0,0 +1,27 @@ +name: Validate shared static deploy workflow + +on: + pull_request: + paths: + - .github/tests/deploy-static-lock-validator.test.rb + - .github/workflows/deploy-static-website.yaml + - .github/workflows/validate-static-deploy.yaml + push: + branches: [main] + paths: + - .github/tests/deploy-static-lock-validator.test.rb + - .github/workflows/deploy-static-website.yaml + - .github/workflows/validate-static-deploy.yaml + +permissions: + contents: read + +jobs: + lock-validator: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - name: Test lock validator fixtures + run: ruby .github/tests/deploy-static-lock-validator.test.rb From a3a7362698066f4ece90c8f58060f5914f2bc5b0 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 09:59:04 +0200 Subject: [PATCH 16/32] fix(ci): close static deploy trust gaps (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 17 +++++++ .../deploy-static-lock-validator.test.rb | 50 +++++++++++++++++++ .github/workflows/deploy-static-website.yaml | 23 +++++++-- .github/workflows/validate-static-deploy.yaml | 5 ++ 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 .github/tests/deploy-static-job-boundaries.test.rb diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb new file mode 100644 index 0000000..262ad02 --- /dev/null +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -0,0 +1,17 @@ +require "yaml" + +workflow_path = File.expand_path("../workflows/deploy-static-website.yaml", __dir__) +workflow = YAML.safe_load(File.read(workflow_path), aliases: true) +jobs = workflow.fetch("jobs") + +build_production = jobs.fetch("build-production") +abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" +abort "Production build must not use a self-hosted container" if build_production.key?("container") + +deploy_production = jobs.fetch("deploy-production") +abort "Production deploy must use the self-hosted runner" unless deploy_production.fetch("runs-on") == "self-hosted-k8s" + +deploy_steps = deploy_production.fetch("steps") +abort "Production deploy must not check out source" if deploy_steps.any? { |step| step.fetch("uses", "").start_with?("actions/checkout@") } + +puts "Static deploy job boundaries passed" diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 5b99675..3211a19 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -15,6 +15,42 @@ resolution: integrity: sha512-safe YAML + "empty resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: {} + YAML + "missing integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: + YAML + "structured integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: + digest: sha512-safe + YAML + "array integrity value" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: + - sha512-safe + YAML + "unknown integrity algorithm" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha999-safe + YAML "workspace importer reference" => [<<~YAML, true], lockfileVersion: '9.0' importers: @@ -47,6 +83,20 @@ resolution: path: external-package YAML + "tarball resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + external-package@1.0.0: + resolution: + tarball: https://evil.example/package.tgz + YAML + "directory resolution" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + external-package@1.0.0: + resolution: + directory: ../external-package + YAML } fixtures.each do |name, (lock, expected)| diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 5c5d502..7812e8c 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -120,8 +120,12 @@ jobs: value.each do |key, child| name = key.to_s child_keys = keys + [name] - if name == "resolution" && !child.is_a?(Hash) - failures << child_keys.join(".") + if name == "resolution" + valid_resolution = child.is_a?(Hash) && + child.keys.map(&:to_s) == ["integrity"] && + child["integrity"].is_a?(String) && + child["integrity"].match?(/\Asha(?:1|256|384|512)-[A-Za-z0-9+\/]+={0,2}\z/) + failures << child_keys.join(".") unless valid_resolution end if external_source.match?(name) || (workspace_source.match?(name) && !allowed_workspace_ref.call(child_keys)) @@ -256,13 +260,22 @@ jobs: build-production: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: self-hosted-k8s - container: - image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 + runs-on: ubuntu-latest permissions: contents: read steps: + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: ${{ inputs.pnpm_version }} + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: ${{ inputs.node_version }} + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: persist-credentials: false diff --git a/.github/workflows/validate-static-deploy.yaml b/.github/workflows/validate-static-deploy.yaml index 0896944..6024d53 100644 --- a/.github/workflows/validate-static-deploy.yaml +++ b/.github/workflows/validate-static-deploy.yaml @@ -4,12 +4,14 @@ on: pull_request: paths: - .github/tests/deploy-static-lock-validator.test.rb + - .github/tests/deploy-static-job-boundaries.test.rb - .github/workflows/deploy-static-website.yaml - .github/workflows/validate-static-deploy.yaml push: branches: [main] paths: - .github/tests/deploy-static-lock-validator.test.rb + - .github/tests/deploy-static-job-boundaries.test.rb - .github/workflows/deploy-static-website.yaml - .github/workflows/validate-static-deploy.yaml @@ -25,3 +27,6 @@ jobs: - name: Test lock validator fixtures run: ruby .github/tests/deploy-static-lock-validator.test.rb + + - name: Test static deploy job boundaries + run: ruby .github/tests/deploy-static-job-boundaries.test.rb From e9da6d80fcb9c2c8b9e18bda3c6275fa232a25fd Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 10:36:36 +0200 Subject: [PATCH 17/32] fix(ci): remove preview credential exposure (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 21 ++++ .github/workflows/deploy-static-website.yaml | 109 +++--------------- 2 files changed, 39 insertions(+), 91 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 262ad02..531a156 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -4,6 +4,19 @@ workflow = YAML.safe_load(File.read(workflow_path), aliases: true) jobs = workflow.fetch("jobs") +cancel_in_progress = workflow.fetch("concurrency").fetch("cancel-in-progress") +expected_cancel_policy = "${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }}" +abort "Production deploys must never be cancelled in progress" unless cancel_in_progress == expected_cancel_policy + +abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") + +build_preview = jobs.fetch("build-preview") +abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" +abort "Preview build must not use a self-hosted container" if build_preview.key?("container") +abort "Preview build must not receive NPM_TOKEN" if build_preview.fetch("steps").any? { |step| step.fetch("env", {}).key?("NPM_TOKEN") } +preview_guard = build_preview.fetch("if") +abort "Preview build must bind the synchronizing actor to the member author" unless preview_guard.include?("github.event.sender.login == github.event.pull_request.user.login") + build_production = jobs.fetch("build-production") abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" abort "Production build must not use a self-hosted container" if build_production.key?("container") @@ -14,4 +27,12 @@ deploy_steps = deploy_production.fetch("steps") abort "Production deploy must not check out source" if deploy_steps.any? { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +cleanup_guard = jobs.fetch("cleanup").fetch("if") +abort "Preview cleanup must survive author offboarding" if cleanup_guard.include?("author_association") + +validation_path = File.expand_path("../workflows/validate-static-deploy.yaml", __dir__) +validation = YAML.safe_load(File.read(validation_path), aliases: true) +validation_checkout = validation.fetch("jobs").fetch("lock-validator").fetch("steps").find { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +abort "Validation checkout must disable persisted credentials" unless validation_checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false + puts "Static deploy job boundaries passed" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 7812e8c..90d5585 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -56,24 +56,20 @@ on: concurrency: group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }} env: PROJECT_ID: festive-terrain-478011-h0 GCS_BUCKET: websites-deploy jobs: - # pull_request_target loads this workflow from the base branch. The PR can - # change source and lockfiles, but it cannot add a step that reads NPM_TOKEN. - # This job only handles organization-member PRs and populates a pnpm store; - # it never executes project or package lifecycle code. The store crosses into - # the tokenless build through an artifact and runners are isolated per job. - preview-dependencies: + build-preview: if: >- github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && + github.event.sender.login == github.event.pull_request.user.login runs-on: ubuntu-latest permissions: contents: read @@ -90,18 +86,15 @@ jobs: with: node-version: ${{ inputs.node_version }} - - name: Checkout pull-request dependency lock + - name: Checkout pull-request source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Validate and fetch dependencies without lifecycle execution + - name: Validate and install dependencies without credentials shell: bash - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - PNPM_STORE_PATH: ${{ runner.temp }}/pnpm-store run: | validate_lock() { ruby - "$1" <<'RUBY' @@ -156,76 +149,9 @@ jobs: echo "pnpm-lock.yaml must be a regular file" >&2 exit 1 fi - TRUSTED_HOME="${RUNNER_TEMP}/cellarnode-preview-home" - trap 'rm -rf -- "$TRUSTED_HOME"' EXIT - mkdir -p "$TRUSTED_HOME" - umask 077 - printf '%s\n' \ - '@cellarnode:registry=https://registry.npmjs.org/' \ - "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ - > "$TRUSTED_HOME/.npmrc" validate_lock pnpm-lock.yaml - if [ "${{ inputs.frozen_lockfile }}" = "false" ]; then - HOME="$TRUSTED_HOME" pnpm install --lockfile-only --no-frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" - validate_lock pnpm-lock.yaml - fi - HOME="$TRUSTED_HOME" pnpm fetch --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" - cp pnpm-lock.yaml "$RUNNER_TEMP/resolved-pnpm-lock.yaml" - - - name: Upload dependency store - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: preview-dependencies-${{ inputs.website_slug }} - path: | - ${{ runner.temp }}/pnpm-store - ${{ runner.temp }}/resolved-pnpm-lock.yaml - if-no-files-found: error - retention-days: 1 - - build-preview: - needs: preview-dependencies - if: >- - github.event_name == 'pull_request_target' && - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Set up pnpm - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa - with: - version: ${{ inputs.pnpm_version }} - run_install: false - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: ${{ inputs.node_version }} - - - name: Checkout pull-request source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - - name: Download dependency store - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: preview-dependencies-${{ inputs.website_slug }} - path: ${{ runner.temp }}/preview-dependencies - - - name: Install dependencies offline - shell: bash - env: - PNPM_STORE_PATH: ${{ runner.temp }}/preview-dependencies/pnpm-store - run: | - rm -f -- .npmrc .pnpmfile.cjs - cp "$RUNNER_TEMP/preview-dependencies/resolved-pnpm-lock.yaml" pnpm-lock.yaml - pnpm install --offline --frozen-lockfile --ignore-scripts --ignore-pnpmfile --store-dir "$PNPM_STORE_PATH" + pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile + validate_lock pnpm-lock.yaml - name: Rebuild dependencies run: pnpm rebuild @@ -382,7 +308,8 @@ jobs: github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && + github.event.sender.login == github.event.pull_request.user.login runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -487,8 +414,7 @@ jobs: if: >- github.event_name == 'pull_request_target' && github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) + github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -528,7 +454,8 @@ jobs: github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) + contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && + github.event.sender.login == github.event.pull_request.user.login permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -539,14 +466,15 @@ jobs: tags: ${{ inputs.discord_tags }} discord-build-update: - needs: [preview-dependencies, build-preview, deploy-preview, discord-thread-open] + needs: [build-preview, deploy-preview, discord-thread-open] if: >- always() && github.event_name == 'pull_request_target' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - needs.preview-dependencies.result != 'cancelled' + github.event.sender.login == github.event.pull_request.user.login && + needs.build-preview.result != 'cancelled' permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -554,15 +482,14 @@ jobs: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} with: event: thread_update - status: ${{ needs.preview-dependencies.result == 'success' && (needs.build-preview.result == 'success' && (needs.deploy-preview.result == 'skipped' && 'success' || needs.deploy-preview.result) || needs.build-preview.result) || needs.preview-dependencies.result }} + status: ${{ needs.build-preview.result == 'success' && (needs.deploy-preview.result == 'skipped' && 'success' || needs.deploy-preview.result) || needs.build-preview.result }} preview_url: ${{ needs.deploy-preview.result == 'success' && format('https://{0}-pr{1}.dev.cellarnode.com', inputs.website_slug, github.event.pull_request.number) || '' }} discord-thread-close: if: >- github.event_name == 'pull_request_target' && github.event.action == 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) + github.event.pull_request.head.repo.full_name == github.repository permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml From 0aa550d247ae1b76331754432e7ab022f261cc1b Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 10:36:43 +0200 Subject: [PATCH 18/32] fix(ci): validate Discord metadata ownership (CEL-1328) --- .../tests/discord-metadata-boundary.test.rb | 10 +++++++ .github/workflows/discord-notify.yaml | 29 +++++++++++++++---- .github/workflows/validate-static-deploy.yaml | 9 ++++++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .github/tests/discord-metadata-boundary.test.rb diff --git a/.github/tests/discord-metadata-boundary.test.rb b/.github/tests/discord-metadata-boundary.test.rb new file mode 100644 index 0000000..e37e822 --- /dev/null +++ b/.github/tests/discord-metadata-boundary.test.rb @@ -0,0 +1,10 @@ +workflow_path = File.expand_path("../workflows/discord-notify.yaml", __dir__) +workflow = File.read(workflow_path) + +abort "Discord metadata must ignore non-bot comments" unless workflow.include?("c.user?.login !== 'github-actions[bot]'") +abort "Discord metadata must validate allowed keys" unless workflow.include?("allowedMetadataKeys") +abort "Discord metadata must validate Discord snowflake IDs" unless workflow.include?("discordSnowflake") +abort "Trusted GitHub comment ID must override parsed metadata" unless workflow.include?("return { ...metadata, commentId: c.id };") +abort "Parsed metadata must never override trusted comment ID" if workflow.include?("return { commentId: c.id, ...JSON.parse(json) };") + +puts "Discord metadata boundary passed" diff --git a/.github/workflows/discord-notify.yaml b/.github/workflows/discord-notify.yaml index 939ecc6..2e21b27 100644 --- a/.github/workflows/discord-notify.yaml +++ b/.github/workflows/discord-notify.yaml @@ -82,6 +82,26 @@ jobs: // --- Metadata stored as a hidden PR comment --- const META_TAG = '$/s); + if (!match) return null; + + try { + const metadata = JSON.parse(match[1]); + if (!metadata || Array.isArray(metadata) || typeof metadata !== 'object') return null; + + const entries = Object.entries(metadata); + if (entries.some(([key, value]) => !allowedMetadataKeys.has(key) || typeof value !== 'string' || !discordSnowflake.test(value))) { + return null; + } + return metadata; + } catch { + return null; + } + } async function getMetadata() { const pr = context.payload.pull_request; @@ -95,12 +115,9 @@ jobs: }); for (const c of comments) { - if (c.body?.includes(META_TAG)) { - try { - const json = c.body.split(META_TAG)[1].split('-->')[0].trim(); - return { commentId: c.id, ...JSON.parse(json) }; - } catch { /* ignore malformed */ } - } + if (c.user?.login !== 'github-actions[bot]') continue; + const metadata = parseMetadata(c.body ?? ''); + if (metadata) return { ...metadata, commentId: c.id }; } return {}; } diff --git a/.github/workflows/validate-static-deploy.yaml b/.github/workflows/validate-static-deploy.yaml index 6024d53..761773d 100644 --- a/.github/workflows/validate-static-deploy.yaml +++ b/.github/workflows/validate-static-deploy.yaml @@ -5,6 +5,8 @@ on: paths: - .github/tests/deploy-static-lock-validator.test.rb - .github/tests/deploy-static-job-boundaries.test.rb + - .github/tests/discord-metadata-boundary.test.rb + - .github/workflows/discord-notify.yaml - .github/workflows/deploy-static-website.yaml - .github/workflows/validate-static-deploy.yaml push: @@ -12,6 +14,8 @@ on: paths: - .github/tests/deploy-static-lock-validator.test.rb - .github/tests/deploy-static-job-boundaries.test.rb + - .github/tests/discord-metadata-boundary.test.rb + - .github/workflows/discord-notify.yaml - .github/workflows/deploy-static-website.yaml - .github/workflows/validate-static-deploy.yaml @@ -24,9 +28,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false - name: Test lock validator fixtures run: ruby .github/tests/deploy-static-lock-validator.test.rb - name: Test static deploy job boundaries run: ruby .github/tests/deploy-static-job-boundaries.test.rb + + - name: Test Discord metadata boundary + run: ruby .github/tests/discord-metadata-boundary.test.rb From 5710c727940c0facda7e698e321c44af967e35cb Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 10:59:04 +0200 Subject: [PATCH 19/32] fix(ci): isolate preview registry credentials (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 9 +- .github/workflows/deploy-static-website.yaml | 82 ++++++++++++++++--- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 531a156..367a109 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -5,7 +5,7 @@ jobs = workflow.fetch("jobs") cancel_in_progress = workflow.fetch("concurrency").fetch("cancel-in-progress") -expected_cancel_policy = "${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }}" +expected_cancel_policy = "${{ github.event_name == 'pull_request' && github.event.action != 'closed' }}" abort "Production deploys must never be cancelled in progress" unless cancel_in_progress == expected_cancel_policy abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") @@ -13,7 +13,12 @@ build_preview = jobs.fetch("build-preview") abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" abort "Preview build must not use a self-hosted container" if build_preview.key?("container") -abort "Preview build must not receive NPM_TOKEN" if build_preview.fetch("steps").any? { |step| step.fetch("env", {}).key?("NPM_TOKEN") } +abort "Preview build must not receive NPM_TOKEN at job scope" if build_preview.fetch("env", {}).key?("NPM_TOKEN") +preview_install = build_preview.fetch("steps").find { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } +abort "Preview install must receive only the scoped NPM_TOKEN" unless preview_install&.fetch("env", {})&.fetch("NPM_TOKEN", nil) == "${{ secrets.NPM_TOKEN }}" +untrusted_preview_names = ["Verify credential isolation", "Rebuild dependencies", "Type check", "Lint", "Build preview"] +untrusted_preview_steps = build_preview.fetch("steps").select { |step| untrusted_preview_names.include?(step.fetch("name", "")) } +abort "Preview rebuild/check/build steps must explicitly clear NPM_TOKEN" unless untrusted_preview_steps.length == 5 && untrusted_preview_steps.all? { |step| step.fetch("env", {}).fetch("NPM_TOKEN", nil) == "" } preview_guard = build_preview.fetch("if") abort "Preview build must bind the synchronizing actor to the member author" unless preview_guard.include?("github.event.sender.login == github.event.pull_request.user.login") diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 90d5585..20213c2 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -56,7 +56,7 @@ on: concurrency: group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} env: PROJECT_ID: festive-terrain-478011-h0 @@ -65,7 +65,7 @@ env: jobs: build-preview: if: >- - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && @@ -93,8 +93,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Validate and install dependencies without credentials + - name: Install dependencies with scoped registry credential shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc run: | validate_lock() { ruby - "$1" <<'RUBY' @@ -144,7 +147,17 @@ jobs: RUBY } - rm -f -- .npmrc .pnpmfile.cjs + if [ -z "$NPM_TOKEN" ]; then + echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 + exit 1 + fi + rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" + trap 'rm -f "$NPM_CONFIG_USERCONFIG"' EXIT + umask 077 + printf '%s\n' \ + '@cellarnode:registry=https://registry.npmjs.org/' \ + "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ + > "$NPM_CONFIG_USERCONFIG" if [ ! -f pnpm-lock.yaml ] || [ -L pnpm-lock.yaml ]; then echo "pnpm-lock.yaml must be a regular file" >&2 exit 1 @@ -153,21 +166,38 @@ jobs: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile validate_lock pnpm-lock.yaml + - name: Verify credential isolation + env: + NPM_TOKEN: "" + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc + run: | + test -z "$NPM_TOKEN" + test ! -e "$NPM_CONFIG_USERCONFIG" + test ! -e .npmrc + test ! -e .pnpmfile.cjs + - name: Rebuild dependencies + env: + NPM_TOKEN: "" run: pnpm rebuild - name: Type check if: inputs.typecheck + env: + NPM_TOKEN: "" run: pnpm tsc --noEmit - name: Lint if: inputs.lint + env: + NPM_TOKEN: "" run: pnpm lint - name: Build preview shell: bash env: BUILD_ENV_VARS: ${{ inputs.build_env }} + NPM_TOKEN: "" run: | if [ -n "$BUILD_ENV_VARS" ]; then while IFS= read -r line; do @@ -206,26 +236,56 @@ jobs: with: persist-credentials: false - - name: Install dependencies + - name: Install dependencies with scoped registry credential env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-production-npmrc + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 + exit 1 + fi + rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" + trap 'rm -f "$NPM_CONFIG_USERCONFIG"' EXIT + umask 077 + printf '%s\n' \ + '@cellarnode:registry=https://registry.npmjs.org/' \ + "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ + > "$NPM_CONFIG_USERCONFIG" + pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile + + - name: Verify credential isolation + env: + NPM_TOKEN: "" + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-production-npmrc + run: | + test -z "$NPM_TOKEN" + test ! -e "$NPM_CONFIG_USERCONFIG" + test ! -e .npmrc + test ! -e .pnpmfile.cjs - name: Rebuild dependencies + env: + NPM_TOKEN: "" run: pnpm rebuild - name: Type check if: inputs.typecheck + env: + NPM_TOKEN: "" run: pnpm tsc --noEmit - name: Lint if: inputs.lint + env: + NPM_TOKEN: "" run: pnpm lint - name: Build production shell: bash env: BUILD_ENV_VARS: ${{ inputs.build_env }} + NPM_TOKEN: "" run: | if [ -n "$BUILD_ENV_VARS" ]; then while IFS= read -r line; do @@ -305,7 +365,7 @@ jobs: deploy-preview: needs: build-preview if: >- - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && @@ -412,7 +472,7 @@ jobs: cleanup: if: >- - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s @@ -451,7 +511,7 @@ jobs: discord-thread-open: if: >- - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action == 'opened' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && @@ -469,7 +529,7 @@ jobs: needs: [build-preview, deploy-preview, discord-thread-open] if: >- always() && - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && @@ -487,7 +547,7 @@ jobs: discord-thread-close: if: >- - github.event_name == 'pull_request_target' && + github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository permissions: From 589c4f4b411bce1126a4a4edc9bb699d8d836763 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 11:48:57 +0200 Subject: [PATCH 20/32] fix(ci): bind preview jobs to workflow actor (CEL-1328) --- .github/tests/deploy-static-job-boundaries.test.rb | 3 ++- .github/workflows/deploy-static-website.yaml | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 367a109..71bb690 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -20,7 +20,8 @@ untrusted_preview_steps = build_preview.fetch("steps").select { |step| untrusted_preview_names.include?(step.fetch("name", "")) } abort "Preview rebuild/check/build steps must explicitly clear NPM_TOKEN" unless untrusted_preview_steps.length == 5 && untrusted_preview_steps.all? { |step| step.fetch("env", {}).fetch("NPM_TOKEN", nil) == "" } preview_guard = build_preview.fetch("if") -abort "Preview build must bind the synchronizing actor to the member author" unless preview_guard.include?("github.event.sender.login == github.event.pull_request.user.login") +abort "Preview build must bind the workflow actor to the member author" unless preview_guard.include?("github.actor == github.event.pull_request.user.login") +abort "Preview build must not bind an unavailable sender field" if preview_guard.include?("github.event.sender.login") build_production = jobs.fetch("build-production") abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 20213c2..aa145a4 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -69,7 +69,7 @@ jobs: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.event.sender.login == github.event.pull_request.user.login + github.actor == github.event.pull_request.user.login runs-on: ubuntu-latest permissions: contents: read @@ -369,7 +369,7 @@ jobs: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.event.sender.login == github.event.pull_request.user.login + github.actor == github.event.pull_request.user.login runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -515,7 +515,7 @@ jobs: github.event.action == 'opened' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.event.sender.login == github.event.pull_request.user.login + github.actor == github.event.pull_request.user.login permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -533,7 +533,7 @@ jobs: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.event.sender.login == github.event.pull_request.user.login && + github.actor == github.event.pull_request.user.login && needs.build-preview.result != 'cancelled' permissions: pull-requests: write From 34758e8f03fb4f9b897a7486ae853e4cb756f9eb Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 11:56:25 +0200 Subject: [PATCH 21/32] fix(ci): gate preview jobs through trusted preflight (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 24 +++++- .github/workflows/deploy-static-website.yaml | 82 +++++++++++++------ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 71bb690..5eb17e5 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -10,6 +10,17 @@ abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") +preview_policy = jobs.fetch("preview-policy") +abort "Preview policy must use a GitHub-hosted runner" unless preview_policy.fetch("runs-on") == "ubuntu-latest" +abort "Preview policy must not receive secrets" unless preview_policy.fetch("permissions") == { "contents" => "read" } +abort "Preview policy must expose a trusted output" unless preview_policy.fetch("outputs", {}).fetch("trusted", nil) == "${{ steps.policy.outputs.trusted }}" +policy_step = preview_policy.fetch("steps").find { |step| step.fetch("id", "") == "policy" } +abort "Preview policy must compare actor and author inside the trusted shell" unless policy_step&.fetch("run", "")&.include?('[ "$ACTOR" = "$AUTHOR" ]') +abort "Preview policy must receive actor and author as data" unless policy_step&.fetch("env", {})&.slice("ACTOR", "AUTHOR") == { + "ACTOR" => "${{ github.actor }}", + "AUTHOR" => "${{ github.event.pull_request.user.login }}", +} + build_preview = jobs.fetch("build-preview") abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" abort "Preview build must not use a self-hosted container" if build_preview.key?("container") @@ -19,9 +30,16 @@ untrusted_preview_names = ["Verify credential isolation", "Rebuild dependencies", "Type check", "Lint", "Build preview"] untrusted_preview_steps = build_preview.fetch("steps").select { |step| untrusted_preview_names.include?(step.fetch("name", "")) } abort "Preview rebuild/check/build steps must explicitly clear NPM_TOKEN" unless untrusted_preview_steps.length == 5 && untrusted_preview_steps.all? { |step| step.fetch("env", {}).fetch("NPM_TOKEN", nil) == "" } -preview_guard = build_preview.fetch("if") -abort "Preview build must bind the workflow actor to the member author" unless preview_guard.include?("github.actor == github.event.pull_request.user.login") -abort "Preview build must not bind an unavailable sender field" if preview_guard.include?("github.event.sender.login") +abort "Preview build must depend on the trusted policy" unless Array(build_preview.fetch("needs")).include?("preview-policy") +abort "Preview build must require the trusted policy output" unless build_preview.fetch("if") == "needs.preview-policy.outputs.trusted == 'true'" + +%w[deploy-preview discord-thread-open discord-build-update].each do |job_name| + job = jobs.fetch(job_name) + abort "#{job_name} must depend on the trusted policy" unless Array(job.fetch("needs")).include?("preview-policy") + abort "#{job_name} must require the trusted policy output" unless job.fetch("if").include?("needs.preview-policy.outputs.trusted == 'true'") +end + +abort "Preview lifecycle must not bind an unavailable sender field" if File.read(workflow_path).include?("github.event.sender.login") build_production = jobs.fetch("build-production") abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index aa145a4..e112579 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -63,13 +63,58 @@ env: GCS_BUCKET: websites-deploy jobs: + preview-policy: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + trusted: ${{ steps.policy.outputs.trusted }} + steps: + - name: Evaluate preview trust boundary + id: policy + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + ACTOR: ${{ github.actor }} + AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + set -euo pipefail + + same_repository=false + if [ "$HEAD_REPOSITORY" = "$REPOSITORY" ]; then + same_repository=true + fi + + association_allowed=false + case "$AUTHOR_ASSOCIATION" in + OWNER|MEMBER) association_allowed=true ;; + esac + + actor_matches_author=false + if [ "$ACTOR" = "$AUTHOR" ]; then + actor_matches_author=true + fi + + trusted=false + if [ "$EVENT_NAME" = "pull_request" ] && + [ "$EVENT_ACTION" != "closed" ] && + [ "$same_repository" = "true" ] && + [ "$association_allowed" = "true" ] && + [ "$actor_matches_author" = "true" ]; then + trusted=true + fi + + printf 'trusted=%s\n' "$trusted" >> "$GITHUB_OUTPUT" + printf 'preview policy: event=%s action=%s same_repository=%s association_allowed=%s actor_matches_author=%s trusted=%s\n' \ + "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$association_allowed" "$actor_matches_author" "$trusted" + build-preview: - if: >- - github.event_name == 'pull_request' && - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.actor == github.event.pull_request.user.login + needs: preview-policy + if: needs.preview-policy.outputs.trusted == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -363,13 +408,8 @@ jobs: ./dist "gs://${GCS_BUCKET}/${WEBSITE}" deploy-preview: - needs: build-preview - if: >- - github.event_name == 'pull_request' && - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.actor == github.event.pull_request.user.login + needs: [preview-policy, build-preview] + if: needs.preview-policy.outputs.trusted == 'true' runs-on: self-hosted-k8s container: image: europe-north1-docker.pkg.dev/festive-terrain-478011-h0/beveriq/ci-runner@sha256:e57fadf58eb4e125a101f366a9cd97cb022e3cb8a9eae6028cefdfb0003b2960 @@ -510,12 +550,10 @@ jobs: fi discord-thread-open: + needs: preview-policy if: >- - github.event_name == 'pull_request' && - github.event.action == 'opened' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.actor == github.event.pull_request.user.login + needs.preview-policy.outputs.trusted == 'true' && + github.event.action == 'opened' permissions: pull-requests: write uses: ./.github/workflows/discord-notify.yaml @@ -526,14 +564,10 @@ jobs: tags: ${{ inputs.discord_tags }} discord-build-update: - needs: [build-preview, deploy-preview, discord-thread-open] + needs: [preview-policy, build-preview, deploy-preview, discord-thread-open] if: >- always() && - github.event_name == 'pull_request' && - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJSON('["OWNER","MEMBER"]'), github.event.pull_request.author_association) && - github.actor == github.event.pull_request.user.login && + needs.preview-policy.outputs.trusted == 'true' && needs.build-preview.result != 'cancelled' permissions: pull-requests: write From cddadf35491ba161d45b3d51c513be1400dae106 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 12:01:30 +0200 Subject: [PATCH 22/32] fix(ci): verify current preview actor permission (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 5 ++++ .github/workflows/deploy-static-website.yaml | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 5eb17e5..888e29f 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -20,6 +20,10 @@ "ACTOR" => "${{ github.actor }}", "AUTHOR" => "${{ github.event.pull_request.user.login }}", } +abort "Preview policy must authenticate its current permission lookup" unless policy_step&.fetch("env", {})&.fetch("GH_TOKEN", nil) == "${{ github.token }}" +abort "Preview policy must query current repository permission" unless policy_step&.fetch("run", "")&.include?('gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission"') +abort "Preview policy must require write-capable permission" unless policy_step&.fetch("run", "")&.include?("admin|maintain|write)") +abort "Preview policy must not trust stale author association" if policy_step&.fetch("env", {})&.key?("AUTHOR_ASSOCIATION") build_preview = jobs.fetch("build-preview") abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" @@ -40,6 +44,7 @@ end abort "Preview lifecycle must not bind an unavailable sender field" if File.read(workflow_path).include?("github.event.sender.login") +abort "Preview lifecycle must not trust stale author association" if File.read(workflow_path).include?("author_association") build_production = jobs.fetch("build-production") abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index e112579..d9b2ae8 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -78,9 +78,9 @@ jobs: EVENT_ACTION: ${{ github.event.action }} HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} REPOSITORY: ${{ github.repository }} - AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} ACTOR: ${{ github.actor }} AUTHOR: ${{ github.event.pull_request.user.login }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -89,28 +89,33 @@ jobs: same_repository=true fi - association_allowed=false - case "$AUTHOR_ASSOCIATION" in - OWNER|MEMBER) association_allowed=true ;; - esac - actor_matches_author=false if [ "$ACTOR" = "$AUTHOR" ]; then actor_matches_author=true fi + permission_allowed=false + if [ "$EVENT_NAME" = "pull_request" ] && + [ "$same_repository" = "true" ] && + [ "$actor_matches_author" = "true" ]; then + permission="$(gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission" --jq .permission)" + case "$permission" in + admin|maintain|write) permission_allowed=true ;; + esac + fi + trusted=false if [ "$EVENT_NAME" = "pull_request" ] && [ "$EVENT_ACTION" != "closed" ] && [ "$same_repository" = "true" ] && - [ "$association_allowed" = "true" ] && + [ "$permission_allowed" = "true" ] && [ "$actor_matches_author" = "true" ]; then trusted=true fi printf 'trusted=%s\n' "$trusted" >> "$GITHUB_OUTPUT" - printf 'preview policy: event=%s action=%s same_repository=%s association_allowed=%s actor_matches_author=%s trusted=%s\n' \ - "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$association_allowed" "$actor_matches_author" "$trusted" + printf 'preview policy: event=%s action=%s same_repository=%s permission_allowed=%s actor_matches_author=%s trusted=%s\n' \ + "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$permission_allowed" "$actor_matches_author" "$trusted" build-preview: needs: preview-policy From fb2de9c32fce60679a4780eac6922df67ff5c7c6 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 12:08:50 +0200 Subject: [PATCH 23/32] fix(ci): validate deploy Discord metadata (CEL-1328) --- .github/tests/discord-metadata-boundary.test.rb | 6 ++++++ .github/workflows/discord-notify.yaml | 9 ++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/tests/discord-metadata-boundary.test.rb b/.github/tests/discord-metadata-boundary.test.rb index e37e822..4bff440 100644 --- a/.github/tests/discord-metadata-boundary.test.rb +++ b/.github/tests/discord-metadata-boundary.test.rb @@ -7,4 +7,10 @@ abort "Trusted GitHub comment ID must override parsed metadata" unless workflow.include?("return { ...metadata, commentId: c.id };") abort "Parsed metadata must never override trusted comment ID" if workflow.include?("return { commentId: c.id, ...JSON.parse(json) };") +deploy_metadata = workflow[/listPullRequestsAssociatedWithCommit[\s\S]*?\/\/ Get commit message/] +abort "Deploy metadata must ignore non-bot comments" unless deploy_metadata&.include?("c.user?.login !== 'github-actions[bot]'") +abort "Deploy metadata must use the shared parser" unless deploy_metadata&.include?("const metadata = parseMetadata(c.body ?? '');") +abort "Deploy metadata must continue past invalid or incomplete comments" unless deploy_metadata&.include?("if (metadata?.threadId) {") +abort "Deploy metadata must not parse marker fragments directly" if deploy_metadata&.include?("split(META_TAG)") + puts "Discord metadata boundary passed" diff --git a/.github/workflows/discord-notify.yaml b/.github/workflows/discord-notify.yaml index 2e21b27..4c46d9a 100644 --- a/.github/workflows/discord-notify.yaml +++ b/.github/workflows/discord-notify.yaml @@ -380,11 +380,10 @@ jobs: per_page: 100 }); for (const c of comments) { - if (c.body?.includes(META_TAG)) { - try { - const json = c.body.split(META_TAG)[1].split('-->')[0].trim(); - threadId = JSON.parse(json).threadId; - } catch {} + if (c.user?.login !== 'github-actions[bot]') continue; + const metadata = parseMetadata(c.body ?? ''); + if (metadata?.threadId) { + threadId = metadata.threadId; break; } } From 8848680bc88a989c87ddfc76cc70c4e70ab336ad Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 12:23:06 +0200 Subject: [PATCH 24/32] fix(ci): restrict preview dependency artifacts (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 22 ++++++++++++ .../deploy-static-lock-validator.test.rb | 27 ++++++++++++++ .github/workflows/deploy-static-website.yaml | 36 ++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 888e29f..6cf7ca1 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -1,4 +1,6 @@ require "yaml" +require "open3" +require "tmpdir" workflow_path = File.expand_path("../workflows/deploy-static-website.yaml", __dir__) workflow = YAML.safe_load(File.read(workflow_path), aliases: true) @@ -37,6 +39,26 @@ abort "Preview build must depend on the trusted policy" unless Array(build_preview.fetch("needs")).include?("preview-policy") abort "Preview build must require the trusted policy output" unless build_preview.fetch("if") == "needs.preview-policy.outputs.trusted == 'true'" +%w[build-preview build-production].each do |job_name| + steps = jobs.fetch(job_name).fetch("steps") + validation_index = steps.index { |step| step.fetch("name", "") == "Reject non-regular build outputs" } + upload_index = steps.index { |step| step.fetch("name", "") == "Upload static site artifact" } + abort "#{job_name} must validate build outputs before upload" unless validation_index && upload_index && validation_index < upload_index + + validation = steps.fetch(validation_index).fetch("run") + Dir.mktmpdir do |directory| + dist = File.join(directory, "dist") + Dir.mkdir(dist) + File.write(File.join(dist, "index.html"), "safe") + _stdout, stderr, status = Open3.capture3("bash", "-euo", "pipefail", "-c", validation, chdir: directory) + abort "#{job_name} must accept regular build outputs: #{stderr}" unless status.success? + + File.symlink("/proc/self/environ", File.join(dist, "environment.txt")) + _stdout, _stderr, status = Open3.capture3("bash", "-euo", "pipefail", "-c", validation, chdir: directory) + abort "#{job_name} must reject symlinks before upload" if status.success? + end +end + %w[deploy-preview discord-thread-open discord-build-update].each do |job_name| job = jobs.fetch(job_name) abort "#{job_name} must depend on the trusted policy" unless Array(job.fetch("needs")).include?("preview-policy") diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 3211a19..ab07c06 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -97,6 +97,33 @@ resolution: directory: ../external-package YAML + "allowed private package" => [<<~YAML, true], + lockfileVersion: '9.0' + packages: + '@cellarnode/ui@0.154.0': + resolution: + integrity: sha512-safe + YAML + "unknown private package" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: + '@cellarnode/internal-secrets@1.0.0': + resolution: + integrity: sha512-safe + YAML + "aliased unknown private package" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + hidden-package: + specifier: npm:@cellarnode/internal-secrets@1.0.0 + version: '@cellarnode/internal-secrets@1.0.0' + packages: + '@cellarnode/internal-secrets@1.0.0': + resolution: + integrity: sha512-safe + YAML } fixtures.each do |name, (lock, expected)| diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index d9b2ae8..78c493c 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -156,16 +156,31 @@ jobs: path = ARGV.fetch(0) external_source = /(?:\A|@)(?:git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:)/i workspace_source = /\A(?:link:|workspace:)/i + private_package = /@cellarnode\/([a-z0-9._-]+)/i + allowed_private_packages = %w[ + auth + beverage-utils + elabel-compliance + finance + i18n + ui + ].freeze allowed_workspace_ref = lambda do |keys| keys.first == "importers" && %w[specifier version].include?(keys.last) end failures = [] + validate_private_packages = lambda do |text, keys| + text.to_s.scan(private_package).flatten.each do |package| + failures << keys.join(".") unless allowed_private_packages.include?(package.downcase) + end + end visit = lambda do |value, keys| case value when Hash value.each do |key, child| name = key.to_s child_keys = keys + [name] + validate_private_packages.call(name, child_keys) if name == "resolution" valid_resolution = child.is_a?(Hash) && child.keys.map(&:to_s) == ["integrity"] && @@ -185,6 +200,7 @@ jobs: when Array value.each_with_index { |child, index| visit.call(child, keys + [index.to_s]) } when String + validate_private_packages.call(value, keys) if external_source.match?(value) || (workspace_source.match?(value) && !allowed_workspace_ref.call(keys)) failures << keys.join(".") @@ -193,7 +209,7 @@ jobs: end visit.call(YAML.safe_load(File.read(path), aliases: true), []) - abort "Preview dependency lock contains a non-registry source at #{failures.uniq.join(', ')}" unless failures.empty? + abort "Preview dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY } @@ -256,6 +272,15 @@ jobs: fi pnpm build + - name: Reject non-regular build outputs + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + - name: Upload static site artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: @@ -344,6 +369,15 @@ jobs: fi pnpm build + - name: Reject non-regular build outputs + shell: bash + run: | + test -d dist + if find dist \( -type l -o -type b -o -type c -o -type p -o -type s \) -print -quit | grep -q .; then + echo "Static artifact contains a non-regular entry" >&2 + exit 1 + fi + - name: Upload static site artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: From 043e94af8a88ffada8b1af78e644f3d48f1eeea5 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 12:48:07 +0200 Subject: [PATCH 25/32] fix(ci): serialize gcloud storage sync (CEL-1328) --- .../tests/deploy-static-job-boundaries.test.rb | 16 ++++++++++++++++ .github/workflows/deploy-static-website.yaml | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 6cf7ca1..fb6b8eb 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -78,6 +78,22 @@ deploy_steps = deploy_production.fetch("steps") abort "Production deploy must not check out source" if deploy_steps.any? { |step| step.fetch("uses", "").start_with?("actions/checkout@") } +{ + "deploy-production" => "Deploy to production", + "deploy-preview" => "Deploy preview", +}.each do |job_name, step_name| + deploy_step = jobs.fetch(job_name).fetch("steps").find { |step| step.fetch("name", "") == step_name } + storage_parallelism = deploy_step&.fetch("env", {})&.slice( + "CLOUDSDK_STORAGE_PROCESS_COUNT", + "CLOUDSDK_STORAGE_THREAD_COUNT", + ) + expected_serial_execution = { + "CLOUDSDK_STORAGE_PROCESS_COUNT" => "1", + "CLOUDSDK_STORAGE_THREAD_COUNT" => "1", + } + abort "#{step_name} must serialize gcloud storage workers" unless storage_parallelism == expected_serial_execution +end + cleanup_guard = jobs.fetch("cleanup").fetch("if") abort "Preview cleanup must survive author offboarding" if cleanup_guard.include?("author_association") diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 78c493c..44bd07f 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -441,6 +441,8 @@ jobs: - name: Deploy to production env: CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + CLOUDSDK_STORAGE_PROCESS_COUNT: '1' + CLOUDSDK_STORAGE_THREAD_COUNT: '1' WEBSITE: ${{ inputs.website }} run: | gcloud storage rsync --recursive --checksums-only --delete-unmatched-destination-objects \ @@ -502,6 +504,8 @@ jobs: - name: Deploy preview env: CLOUDSDK_CONFIG: ${{ runner.temp }}/gcloud + CLOUDSDK_STORAGE_PROCESS_COUNT: '1' + CLOUDSDK_STORAGE_THREAD_COUNT: '1' WEBSITE_SLUG: ${{ inputs.website_slug }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | From 344464ab2a6536d276e1028b6a2c16721dadbd31 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 13:17:06 +0200 Subject: [PATCH 26/32] fix(ci): validate preview manifests before install (CEL-1328) --- .../deploy-static-lock-validator.test.rb | 81 ++++++++++++++++++- .github/workflows/deploy-static-website.yaml | 53 ++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index ab07c06..14d120f 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -1,6 +1,8 @@ require "open3" require "rbconfig" require "tempfile" +require "tmpdir" +require "yaml" workflow = File.read(File.expand_path("../workflows/deploy-static-website.yaml", __dir__)) match = workflow.match(/ruby - "\$1" <<'RUBY'\n(?.*?)^\s+RUBY$/m) @@ -141,4 +143,81 @@ end end -puts "Lock validator fixtures passed: #{fixtures.length}" +parsed_workflow = YAML.safe_load(workflow, aliases: true) +install_step = parsed_workflow + .fetch("jobs") + .fetch("build-preview") + .fetch("steps") + .find { |step| step["name"] == "Install dependencies with scoped registry credential" } +abort "Credentialed install step not found" unless install_step + +non_frozen_script = install_step.fetch("run").sub( + "${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }}", + "--no-frozen-lockfile", +) + +manifest_fixtures = { + "unknown private manifest package" => ["@cellarnode/internal-secrets", "1.0.0", false], + "aliased unknown private manifest package" => ["hidden-package", "npm:@cellarnode/internal-secrets@1.0.0", false], + "allowed private manifest package" => ["@cellarnode/ui", "1.0.0", true], +} + +manifest_fixtures.each do |name, (package, version, expected_install)| + Dir.mktmpdir("cel1328-manifest-boundary") do |directory| + File.write( + File.join(directory, "package.json"), + <<~JSON, + { + "dependencies": { + "#{package}": "#{version}" + } + } + JSON + ) + File.write( + File.join(directory, "pnpm-lock.yaml"), + <<~YAML, + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha512-safe + YAML + ) + + bin_directory = File.join(directory, "bin") + Dir.mkdir(bin_directory) + marker = File.join(directory, "pnpm-invoked") + pnpm = File.join(bin_directory, "pnpm") + File.write(pnpm, <<~SH) + #!/bin/sh + : > "$PNPM_MARKER" + SH + File.chmod(0o755, pnpm) + + _stdout, stderr, status = Open3.capture3( + { + "NPM_CONFIG_USERCONFIG" => File.join(directory, "preview-npmrc"), + "NPM_TOKEN" => "test-token", + "PATH" => "#{bin_directory}:#{ENV.fetch("PATH")}", + "PNPM_MARKER" => marker, + }, + "bash", + "-euo", + "pipefail", + "-c", + non_frozen_script, + chdir: directory, + ) + + if expected_install + abort "#{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) + else + abort "#{name}: install unexpectedly succeeded" if status.success? + abort "#{name}: package manager ran before manifest rejection" if File.exist?(marker) + abort "#{name}: wrong rejection: #{stderr}" unless stderr.include?("manifest contains a disallowed private package") + end + end +end + +puts "Lock validator fixtures passed: #{fixtures.length}; manifest fixtures passed: #{manifest_fixtures.length}" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 44bd07f..088cd51 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -149,6 +149,58 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc run: | + validate_manifests() { + ruby <<'RUBY' + require "find" + require "json" + + private_package = /@cellarnode\/([a-z0-9._-]+)/i + allowed_private_packages = %w[ + auth + beverage-utils + elabel-compliance + finance + i18n + ui + ].freeze + failures = [] + manifests = [] + Find.find(".") do |path| + if File.directory?(path) + Find.prune if %w[./.git ./node_modules].include?(path) + next + end + next unless File.basename(path) == "package.json" + + abort "Preview dependency manifest must be a regular file: #{path}" if File.symlink?(path) || !File.file?(path) + manifests << path + end + abort "Root package.json must be a regular file" unless manifests.include?("./package.json") + + visit = lambda do |value, path, keys| + case value + when Hash + value.each do |key, child| + child_keys = keys + [key.to_s] + key.to_s.scan(private_package).flatten.each do |package| + failures << "#{path}:#{child_keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + end + visit.call(child, path, child_keys) + end + when Array + value.each_with_index { |child, index| visit.call(child, path, keys + [index.to_s]) } + when String + value.scan(private_package).flatten.each do |package| + failures << "#{path}:#{keys.join('.')}" unless allowed_private_packages.include?(package.downcase) + end + end + end + + manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } + abort "Preview dependency manifest contains a disallowed private package at #{failures.uniq.join(', ')}" unless failures.empty? + RUBY + } + validate_lock() { ruby - "$1" <<'RUBY' require "yaml" @@ -213,6 +265,7 @@ jobs: RUBY } + validate_manifests if [ -z "$NPM_TOKEN" ]; then echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 exit 1 From fbe4c72d46473e61f23ecac10137c539da605cb1 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 14:02:24 +0200 Subject: [PATCH 27/32] fix(ci): trust default-branch preview callers (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 19 ++++- .../deploy-static-lock-validator.test.rb | 45 ++++++++++-- .github/workflows/deploy-static-website.yaml | 73 ++++++++++++------- 3 files changed, 102 insertions(+), 35 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index fb6b8eb..23e87f3 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -7,7 +7,7 @@ jobs = workflow.fetch("jobs") cancel_in_progress = workflow.fetch("concurrency").fetch("cancel-in-progress") -expected_cancel_policy = "${{ github.event_name == 'pull_request' && github.event.action != 'closed' }}" +expected_cancel_policy = "${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }}" abort "Production deploys must never be cancelled in progress" unless cancel_in_progress == expected_cancel_policy abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") @@ -16,6 +16,7 @@ abort "Preview policy must use a GitHub-hosted runner" unless preview_policy.fetch("runs-on") == "ubuntu-latest" abort "Preview policy must not receive secrets" unless preview_policy.fetch("permissions") == { "contents" => "read" } abort "Preview policy must expose a trusted output" unless preview_policy.fetch("outputs", {}).fetch("trusted", nil) == "${{ steps.policy.outputs.trusted }}" +abort "Preview policy must expose a trusted toolchain output" unless preview_policy.fetch("outputs", {}).fetch("toolchain_allowed", nil) == "${{ steps.policy.outputs.toolchain_allowed }}" policy_step = preview_policy.fetch("steps").find { |step| step.fetch("id", "") == "policy" } abort "Preview policy must compare actor and author inside the trusted shell" unless policy_step&.fetch("run", "")&.include?('[ "$ACTOR" = "$AUTHOR" ]') abort "Preview policy must receive actor and author as data" unless policy_step&.fetch("env", {})&.slice("ACTOR", "AUTHOR") == { @@ -26,6 +27,13 @@ abort "Preview policy must query current repository permission" unless policy_step&.fetch("run", "")&.include?('gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission"') abort "Preview policy must require write-capable permission" unless policy_step&.fetch("run", "")&.include?("admin|maintain|write)") abort "Preview policy must not trust stale author association" if policy_step&.fetch("env", {})&.key?("AUTHOR_ASSOCIATION") +abort "Preview policy must require the trusted pull-request event" unless policy_step&.fetch("run", "")&.include?('[ "$EVENT_NAME" = "pull_request_target" ]') +abort "Preview policy must receive toolchain inputs as data" unless policy_step&.fetch("env", {})&.slice("NODE_VERSION", "PNPM_VERSION") == { + "NODE_VERSION" => "${{ inputs.node_version }}", + "PNPM_VERSION" => "${{ inputs.pnpm_version }}", +} +abort "Preview policy must allow only Node.js 22" unless policy_step&.fetch("run", "")&.include?('[ "$NODE_VERSION" = "22" ]') +abort "Preview policy must allow only reviewed pnpm versions" unless policy_step&.fetch("run", "")&.include?("10.28.2|10.32.1") build_preview = jobs.fetch("build-preview") abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" @@ -39,6 +47,13 @@ abort "Preview build must depend on the trusted policy" unless Array(build_preview.fetch("needs")).include?("preview-policy") abort "Preview build must require the trusted policy output" unless build_preview.fetch("if") == "needs.preview-policy.outputs.trusted == 'true'" +preview_steps = build_preview.fetch("steps") +dependency_validation_index = preview_steps.index { |step| step.fetch("name", "") == "Validate preview dependency inputs" } +credentialed_install_index = preview_steps.index { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } +abort "Preview dependency inputs must be validated before credentialed install" unless dependency_validation_index && credentialed_install_index && dependency_validation_index < credentialed_install_index +dependency_validation = preview_steps.fetch(dependency_validation_index) +abort "Preview dependency validation must not receive NPM_TOKEN" if dependency_validation.fetch("env", {}).key?("NPM_TOKEN") + %w[build-preview build-production].each do |job_name| steps = jobs.fetch(job_name).fetch("steps") validation_index = steps.index { |step| step.fetch("name", "") == "Reject non-regular build outputs" } @@ -71,6 +86,8 @@ build_production = jobs.fetch("build-production") abort "Production build must use a GitHub-hosted runner" unless build_production.fetch("runs-on") == "ubuntu-latest" abort "Production build must not use a self-hosted container" if build_production.key?("container") +abort "Production build must depend on toolchain policy" unless Array(build_production.fetch("needs")).include?("preview-policy") +abort "Production build must require approved toolchain" unless build_production.fetch("if").include?("needs.preview-policy.outputs.toolchain_allowed == 'true'") deploy_production = jobs.fetch("deploy-production") abort "Production deploy must use the self-hosted runner" unless deploy_production.fetch("runs-on") == "self-hosted-k8s" diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 14d120f..35ae3b3 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -5,7 +5,7 @@ require "yaml" workflow = File.read(File.expand_path("../workflows/deploy-static-website.yaml", __dir__)) -match = workflow.match(/ruby - "\$1" <<'RUBY'\n(?.*?)^\s+RUBY$/m) +match = workflow.match(/cat > "\$LOCK_VALIDATOR" <<'RUBY'\n(?.*?)^\s+RUBY$/m) abort "Lock validator heredoc not found" unless match validator = match[:body].gsub(/^ {10}/, "") @@ -144,6 +144,13 @@ end parsed_workflow = YAML.safe_load(workflow, aliases: true) +validation_step = parsed_workflow + .fetch("jobs") + .fetch("build-preview") + .fetch("steps") + .find { |step| step["name"] == "Validate preview dependency inputs" } +abort "Credential-free dependency validation step not found" unless validation_step + install_step = parsed_workflow .fetch("jobs") .fetch("build-preview") @@ -151,6 +158,7 @@ .find { |step| step["name"] == "Install dependencies with scoped registry credential" } abort "Credentialed install step not found" unless install_step +validation_script = validation_step.fetch("run") non_frozen_script = install_step.fetch("run").sub( "${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }}", "--no-frozen-lockfile", @@ -188,6 +196,8 @@ bin_directory = File.join(directory, "bin") Dir.mkdir(bin_directory) marker = File.join(directory, "pnpm-invoked") + manifest_validator = File.join(directory, "manifest-validator.rb") + lock_validator = File.join(directory, "lock-validator.rb") pnpm = File.join(bin_directory, "pnpm") File.write(pnpm, <<~SH) #!/bin/sh @@ -195,12 +205,37 @@ SH File.chmod(0o755, pnpm) + validator_env = { + "MANIFEST_VALIDATOR" => manifest_validator, + "LOCK_VALIDATOR" => lock_validator, + } + _stdout, validation_stderr, validation_status = Open3.capture3( + validator_env, + "bash", + "-euo", + "pipefail", + "-c", + validation_script, + chdir: directory, + ) + + if expected_install + abort "#{name}: credential-free validation failed: #{validation_stderr}" unless validation_status.success? + else + abort "#{name}: credential-free validation unexpectedly succeeded" if validation_status.success? + abort "#{name}: package manager ran before manifest rejection" if File.exist?(marker) + abort "#{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed private package") + next + end + _stdout, stderr, status = Open3.capture3( { "NPM_CONFIG_USERCONFIG" => File.join(directory, "preview-npmrc"), "NPM_TOKEN" => "test-token", "PATH" => "#{bin_directory}:#{ENV.fetch("PATH")}", "PNPM_MARKER" => marker, + "MANIFEST_VALIDATOR" => manifest_validator, + "LOCK_VALIDATOR" => lock_validator, }, "bash", "-euo", @@ -210,13 +245,7 @@ chdir: directory, ) - if expected_install - abort "#{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) - else - abort "#{name}: install unexpectedly succeeded" if status.success? - abort "#{name}: package manager ran before manifest rejection" if File.exist?(marker) - abort "#{name}: wrong rejection: #{stderr}" unless stderr.include?("manifest contains a disallowed private package") - end + abort "#{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) end end diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 088cd51..c7eb68e 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -56,7 +56,7 @@ on: concurrency: group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }} env: PROJECT_ID: festive-terrain-478011-h0 @@ -69,6 +69,7 @@ jobs: contents: read outputs: trusted: ${{ steps.policy.outputs.trusted }} + toolchain_allowed: ${{ steps.policy.outputs.toolchain_allowed }} steps: - name: Evaluate preview trust boundary id: policy @@ -80,6 +81,8 @@ jobs: REPOSITORY: ${{ github.repository }} ACTOR: ${{ github.actor }} AUTHOR: ${{ github.event.pull_request.user.login }} + NODE_VERSION: ${{ inputs.node_version }} + PNPM_VERSION: ${{ inputs.pnpm_version }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -94,8 +97,15 @@ jobs: actor_matches_author=true fi + toolchain_allowed=false + if [ "$NODE_VERSION" = "22" ]; then + case "$PNPM_VERSION" in + 10.28.2|10.32.1) toolchain_allowed=true ;; + esac + fi + permission_allowed=false - if [ "$EVENT_NAME" = "pull_request" ] && + if [ "$EVENT_NAME" = "pull_request_target" ] && [ "$same_repository" = "true" ] && [ "$actor_matches_author" = "true" ]; then permission="$(gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission" --jq .permission)" @@ -105,17 +115,19 @@ jobs: fi trusted=false - if [ "$EVENT_NAME" = "pull_request" ] && + if [ "$EVENT_NAME" = "pull_request_target" ] && [ "$EVENT_ACTION" != "closed" ] && [ "$same_repository" = "true" ] && [ "$permission_allowed" = "true" ] && - [ "$actor_matches_author" = "true" ]; then + [ "$actor_matches_author" = "true" ] && + [ "$toolchain_allowed" = "true" ]; then trusted=true fi printf 'trusted=%s\n' "$trusted" >> "$GITHUB_OUTPUT" - printf 'preview policy: event=%s action=%s same_repository=%s permission_allowed=%s actor_matches_author=%s trusted=%s\n' \ - "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$permission_allowed" "$actor_matches_author" "$trusted" + printf 'toolchain_allowed=%s\n' "$toolchain_allowed" >> "$GITHUB_OUTPUT" + printf 'preview policy: event=%s action=%s same_repository=%s permission_allowed=%s actor_matches_author=%s toolchain_allowed=%s trusted=%s\n' \ + "$EVENT_NAME" "$EVENT_ACTION" "$same_repository" "$permission_allowed" "$actor_matches_author" "$toolchain_allowed" "$trusted" build-preview: needs: preview-policy @@ -143,14 +155,13 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - - name: Install dependencies with scoped registry credential + - name: Validate preview dependency inputs shell: bash env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-lock-validator.rb run: | - validate_manifests() { - ruby <<'RUBY' + cat > "$MANIFEST_VALIDATOR" <<'RUBY' require "find" require "json" @@ -199,10 +210,8 @@ jobs: manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } abort "Preview dependency manifest contains a disallowed private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY - } - validate_lock() { - ruby - "$1" <<'RUBY' + cat > "$LOCK_VALIDATOR" <<'RUBY' require "yaml" path = ARGV.fetch(0) @@ -263,27 +272,35 @@ jobs: visit.call(YAML.safe_load(File.read(path), aliases: true), []) abort "Preview dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY - } - validate_manifests + ruby "$MANIFEST_VALIDATOR" + if [ ! -f pnpm-lock.yaml ] || [ -L pnpm-lock.yaml ]; then + echo "pnpm-lock.yaml must be a regular file" >&2 + exit 1 + fi + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml + + - name: Install dependencies with scoped registry credential + shell: bash + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-preview-npmrc + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-lock-validator.rb + run: | if [ -z "$NPM_TOKEN" ]; then echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 exit 1 fi rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" - trap 'rm -f "$NPM_CONFIG_USERCONFIG"' EXIT + trap 'rm -f "$NPM_CONFIG_USERCONFIG" "$MANIFEST_VALIDATOR" "$LOCK_VALIDATOR"' EXIT umask 077 printf '%s\n' \ '@cellarnode:registry=https://registry.npmjs.org/' \ "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ > "$NPM_CONFIG_USERCONFIG" - if [ ! -f pnpm-lock.yaml ] || [ -L pnpm-lock.yaml ]; then - echo "pnpm-lock.yaml must be a regular file" >&2 - exit 1 - fi - validate_lock pnpm-lock.yaml pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile - validate_lock pnpm-lock.yaml + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml - name: Verify credential isolation env: @@ -343,7 +360,11 @@ jobs: retention-days: 1 build-production: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: preview-policy + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.preview-policy.outputs.toolchain_allowed == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -608,7 +629,7 @@ jobs: cleanup: if: >- - github.event_name == 'pull_request' && + github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted-k8s @@ -677,7 +698,7 @@ jobs: discord-thread-close: if: >- - github.event_name == 'pull_request' && + github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository permissions: From d0cb5f03fa4b946d4e48550017eee9bd27f60517 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 14:28:15 +0200 Subject: [PATCH 28/32] fix(ci): validate production dependencies before install (CEL-1328) --- .../deploy-static-lock-validator.test.rb | 181 +++++++++--------- .github/workflows/deploy-static-website.yaml | 20 +- 2 files changed, 108 insertions(+), 93 deletions(-) diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 35ae3b3..a50bd60 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -144,25 +144,28 @@ end parsed_workflow = YAML.safe_load(workflow, aliases: true) -validation_step = parsed_workflow - .fetch("jobs") - .fetch("build-preview") - .fetch("steps") - .find { |step| step["name"] == "Validate preview dependency inputs" } -abort "Credential-free dependency validation step not found" unless validation_step +dependency_scripts = %w[build-preview build-production].to_h do |job_name| + steps = parsed_workflow.fetch("jobs").fetch(job_name).fetch("steps") + validation_index = steps.index do |step| + env = step.fetch("env", {}) + env.key?("MANIFEST_VALIDATOR") && env.key?("LOCK_VALIDATOR") && !env.key?("NPM_TOKEN") + end + install_index = steps.index { |step| step["name"] == "Install dependencies with scoped registry credential" } + abort "#{job_name}: credential-free dependency validation step not found" unless validation_index + abort "#{job_name}: credentialed install step not found" unless install_index + abort "#{job_name}: dependency validation must run before credentialed install" unless validation_index < install_index + + validation_step = steps.fetch(validation_index) + abort "#{job_name}: dependency validation must not receive NPM_TOKEN" if validation_step.fetch("env", {}).key?("NPM_TOKEN") -install_step = parsed_workflow - .fetch("jobs") - .fetch("build-preview") - .fetch("steps") - .find { |step| step["name"] == "Install dependencies with scoped registry credential" } -abort "Credentialed install step not found" unless install_step + install_script = steps.fetch(install_index).fetch("run").sub( + "${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }}", + "--no-frozen-lockfile", + ) + [job_name, [validation_step.fetch("run"), install_script]] +end -validation_script = validation_step.fetch("run") -non_frozen_script = install_step.fetch("run").sub( - "${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }}", - "--no-frozen-lockfile", -) +abort "Preview and production dependency validation must use the same trusted script" unless dependency_scripts.values.map(&:first).uniq.one? manifest_fixtures = { "unknown private manifest package" => ["@cellarnode/internal-secrets", "1.0.0", false], @@ -170,83 +173,85 @@ "allowed private manifest package" => ["@cellarnode/ui", "1.0.0", true], } -manifest_fixtures.each do |name, (package, version, expected_install)| - Dir.mktmpdir("cel1328-manifest-boundary") do |directory| - File.write( - File.join(directory, "package.json"), - <<~JSON, - { - "dependencies": { - "#{package}": "#{version}" +dependency_scripts.each do |job_name, (validation_script, non_frozen_script)| + manifest_fixtures.each do |name, (package, version, expected_install)| + Dir.mktmpdir("cel1328-manifest-boundary") do |directory| + File.write( + File.join(directory, "package.json"), + <<~JSON, + { + "dependencies": { + "#{package}": "#{version}" + } } - } - JSON - ) - File.write( - File.join(directory, "pnpm-lock.yaml"), - <<~YAML, - lockfileVersion: '9.0' - packages: - '@scope/package@1.0.0': - resolution: - integrity: sha512-safe - YAML - ) + JSON + ) + File.write( + File.join(directory, "pnpm-lock.yaml"), + <<~YAML, + lockfileVersion: '9.0' + packages: + '@scope/package@1.0.0': + resolution: + integrity: sha512-safe + YAML + ) - bin_directory = File.join(directory, "bin") - Dir.mkdir(bin_directory) - marker = File.join(directory, "pnpm-invoked") - manifest_validator = File.join(directory, "manifest-validator.rb") - lock_validator = File.join(directory, "lock-validator.rb") - pnpm = File.join(bin_directory, "pnpm") - File.write(pnpm, <<~SH) - #!/bin/sh - : > "$PNPM_MARKER" - SH - File.chmod(0o755, pnpm) - - validator_env = { - "MANIFEST_VALIDATOR" => manifest_validator, - "LOCK_VALIDATOR" => lock_validator, - } - _stdout, validation_stderr, validation_status = Open3.capture3( - validator_env, - "bash", - "-euo", - "pipefail", - "-c", - validation_script, - chdir: directory, - ) + bin_directory = File.join(directory, "bin") + Dir.mkdir(bin_directory) + marker = File.join(directory, "pnpm-invoked") + manifest_validator = File.join(directory, "manifest-validator.rb") + lock_validator = File.join(directory, "lock-validator.rb") + pnpm = File.join(bin_directory, "pnpm") + File.write(pnpm, <<~SH) + #!/bin/sh + : > "$PNPM_MARKER" + SH + File.chmod(0o755, pnpm) - if expected_install - abort "#{name}: credential-free validation failed: #{validation_stderr}" unless validation_status.success? - else - abort "#{name}: credential-free validation unexpectedly succeeded" if validation_status.success? - abort "#{name}: package manager ran before manifest rejection" if File.exist?(marker) - abort "#{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed private package") - next - end - - _stdout, stderr, status = Open3.capture3( - { - "NPM_CONFIG_USERCONFIG" => File.join(directory, "preview-npmrc"), - "NPM_TOKEN" => "test-token", - "PATH" => "#{bin_directory}:#{ENV.fetch("PATH")}", - "PNPM_MARKER" => marker, + validator_env = { "MANIFEST_VALIDATOR" => manifest_validator, "LOCK_VALIDATOR" => lock_validator, - }, - "bash", - "-euo", - "pipefail", - "-c", - non_frozen_script, - chdir: directory, - ) + } + _stdout, validation_stderr, validation_status = Open3.capture3( + validator_env, + "bash", + "-euo", + "pipefail", + "-c", + validation_script, + chdir: directory, + ) + + if expected_install + abort "#{job_name} #{name}: credential-free validation failed: #{validation_stderr}" unless validation_status.success? + else + abort "#{job_name} #{name}: credential-free validation unexpectedly succeeded" if validation_status.success? + abort "#{job_name} #{name}: package manager ran before manifest rejection" if File.exist?(marker) + abort "#{job_name} #{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed private package") + next + end - abort "#{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) + _stdout, stderr, status = Open3.capture3( + { + "NPM_CONFIG_USERCONFIG" => File.join(directory, "preview-npmrc"), + "NPM_TOKEN" => "test-token", + "PATH" => "#{bin_directory}:#{ENV.fetch("PATH")}", + "PNPM_MARKER" => marker, + "MANIFEST_VALIDATOR" => manifest_validator, + "LOCK_VALIDATOR" => lock_validator, + }, + "bash", + "-euo", + "pipefail", + "-c", + non_frozen_script, + chdir: directory, + ) + + abort "#{job_name} #{name}: install did not run successfully: #{stderr}" unless status.success? && File.exist?(marker) + end end end -puts "Lock validator fixtures passed: #{fixtures.length}; manifest fixtures passed: #{manifest_fixtures.length}" +puts "Lock validator fixtures passed: #{fixtures.length}; manifest fixtures passed: #{manifest_fixtures.length * dependency_scripts.length}" diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index c7eb68e..c5bf618 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -160,7 +160,7 @@ jobs: env: MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-manifest-validator.rb LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-preview-lock-validator.rb - run: | + run: &dependency-validation | cat > "$MANIFEST_VALIDATOR" <<'RUBY' require "find" require "json" @@ -183,7 +183,7 @@ jobs: end next unless File.basename(path) == "package.json" - abort "Preview dependency manifest must be a regular file: #{path}" if File.symlink?(path) || !File.file?(path) + abort "Dependency manifest must be a regular file: #{path}" if File.symlink?(path) || !File.file?(path) manifests << path end abort "Root package.json must be a regular file" unless manifests.include?("./package.json") @@ -208,7 +208,7 @@ jobs: end manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } - abort "Preview dependency manifest contains a disallowed private package at #{failures.uniq.join(', ')}" unless failures.empty? + abort "Dependency manifest contains a disallowed private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY cat > "$LOCK_VALIDATOR" <<'RUBY' @@ -270,7 +270,7 @@ jobs: end visit.call(YAML.safe_load(File.read(path), aliases: true), []) - abort "Preview dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? + abort "Dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY ruby "$MANIFEST_VALIDATOR" @@ -385,23 +385,33 @@ jobs: with: persist-credentials: false + - name: Validate production dependency inputs + shell: bash + env: + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-production-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-production-lock-validator.rb + run: *dependency-validation + - name: Install dependencies with scoped registry credential env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/cellarnode-production-npmrc + MANIFEST_VALIDATOR: ${{ runner.temp }}/cellarnode-production-manifest-validator.rb + LOCK_VALIDATOR: ${{ runner.temp }}/cellarnode-production-lock-validator.rb run: | if [ -z "$NPM_TOKEN" ]; then echo "NPM_TOKEN is required to fetch private @cellarnode packages" >&2 exit 1 fi rm -f -- .npmrc .pnpmfile.cjs "$NPM_CONFIG_USERCONFIG" - trap 'rm -f "$NPM_CONFIG_USERCONFIG"' EXIT + trap 'rm -f "$NPM_CONFIG_USERCONFIG" "$MANIFEST_VALIDATOR" "$LOCK_VALIDATOR"' EXIT umask 077 printf '%s\n' \ '@cellarnode:registry=https://registry.npmjs.org/' \ "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" \ > "$NPM_CONFIG_USERCONFIG" pnpm install ${{ inputs.frozen_lockfile && '--frozen-lockfile' || '--no-frozen-lockfile' }} --ignore-scripts --ignore-pnpmfile + ruby "$LOCK_VALIDATOR" pnpm-lock.yaml - name: Verify credential isolation env: From bd44a0553b7cdfdc7a78405dc3f332fa7ca18bb4 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 14:56:46 +0200 Subject: [PATCH 29/32] fix(ci): reject non-registry manifest sources (CEL-1328) --- .../tests/deploy-static-lock-validator.test.rb | 9 ++++++++- .github/workflows/deploy-static-website.yaml | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index a50bd60..f37c977 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -171,6 +171,13 @@ "unknown private manifest package" => ["@cellarnode/internal-secrets", "1.0.0", false], "aliased unknown private manifest package" => ["hidden-package", "npm:@cellarnode/internal-secrets@1.0.0", false], "allowed private manifest package" => ["@cellarnode/ui", "1.0.0", true], + "allowed public registry range" => ["public-package", "^1.0.0", true], + "aliased public manifest package" => ["@cellarnode/ui", "npm:evil-package@1.0.0", false], + "git manifest package" => ["public-package", "git+https://evil.example/package.git", false], + "file manifest package" => ["public-package", "file:../package", false], + "workspace manifest package" => ["public-package", "workspace:*", false], + "github shorthand manifest package" => ["public-package", "attacker/package", false], + "tarball manifest package" => ["public-package", "package.tgz", false], } dependency_scripts.each do |job_name, (validation_script, non_frozen_script)| @@ -228,7 +235,7 @@ else abort "#{job_name} #{name}: credential-free validation unexpectedly succeeded" if validation_status.success? abort "#{job_name} #{name}: package manager ran before manifest rejection" if File.exist?(marker) - abort "#{job_name} #{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed private package") + abort "#{job_name} #{name}: wrong rejection: #{validation_stderr}" unless validation_stderr.include?("manifest contains a disallowed package or source") next end diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index c5bf618..f33d5f0 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -166,6 +166,17 @@ jobs: require "json" private_package = /@cellarnode\/([a-z0-9._-]+)/i + dependency_sections = %w[ + catalog + catalogs + dependencies + devDependencies + optionalDependencies + overrides + peerDependencies + resolutions + ].freeze + non_registry_dependency_source = /\A(?:npm:|git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:|patch:|portal:|catalog:)|[\\\/]|\.(?:tgz|tar\.gz)\z/i allowed_private_packages = %w[ auth beverage-utils @@ -204,11 +215,14 @@ jobs: value.scan(private_package).flatten.each do |package| failures << "#{path}:#{keys.join('.')}" unless allowed_private_packages.include?(package.downcase) end + if keys.any? { |key| dependency_sections.include?(key) } && non_registry_dependency_source.match?(value) + failures << "#{path}:#{keys.join('.')}" + end end end manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } - abort "Dependency manifest contains a disallowed private package at #{failures.uniq.join(', ')}" unless failures.empty? + abort "Dependency manifest contains a disallowed package or source at #{failures.uniq.join(', ')}" unless failures.empty? RUBY cat > "$LOCK_VALIDATOR" <<'RUBY' From 4017841a962931749bf73869bef1729d5280f435 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 15:14:27 +0200 Subject: [PATCH 30/32] fix(ci): validate pnpm workspace sources (CEL-1328) --- .../deploy-static-lock-validator.test.rb | 79 ++++++++++++++++++- .github/workflows/deploy-static-website.yaml | 17 ++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index f37c977..9f10a0e 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -178,10 +178,86 @@ "workspace manifest package" => ["public-package", "workspace:*", false], "github shorthand manifest package" => ["public-package", "attacker/package", false], "tarball manifest package" => ["public-package", "package.tgz", false], + "git workspace override" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + overrides: + public-package: git+https://evil.example/package.git + YAML + ], + "external workspace config dependency" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + configDependencies: + malicious-config: https://evil.example/config.tgz + YAML + ], + "registry workspace override" => [ + "public-package", + "^1.0.0", + true, + <<~YAML, + overrides: + "@radix-ui/react-dismissable-layer": "1.1.15" + YAML + ], + "external workspace catalog" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + catalogs: + default: + public-package: https://evil.example/package.tgz + YAML + ], + "external workspace package extension" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + packageExtensions: + "public-package@1.0.0": + dependencies: + injected-package: git+https://evil.example/package.git + YAML + ], + "workspace dependency patch" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + patchedDependencies: + "public-package@1.0.0": patches/public-package.patch + YAML + ], + "workspace named registry" => [ + "public-package", + "^1.0.0", + false, + <<~YAML, + namedRegistries: + attacker: https://evil.example/ + YAML + ], + "workspace build allowlist" => [ + "public-package", + "^1.0.0", + true, + <<~YAML, + onlyBuiltDependencies: + - "@swc/core" + - esbuild + YAML + ], } dependency_scripts.each do |job_name, (validation_script, non_frozen_script)| - manifest_fixtures.each do |name, (package, version, expected_install)| + manifest_fixtures.each do |name, (package, version, expected_install, workspace)| Dir.mktmpdir("cel1328-manifest-boundary") do |directory| File.write( File.join(directory, "package.json"), @@ -203,6 +279,7 @@ integrity: sha512-safe YAML ) + File.write(File.join(directory, "pnpm-workspace.yaml"), workspace) if workspace bin_directory = File.join(directory, "bin") Dir.mkdir(bin_directory) diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index f33d5f0..1fc4b50 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -164,15 +164,19 @@ jobs: cat > "$MANIFEST_VALIDATOR" <<'RUBY' require "find" require "json" + require "yaml" private_package = /@cellarnode\/([a-z0-9._-]+)/i dependency_sections = %w[ catalog catalogs + configDependencies dependencies devDependencies optionalDependencies overrides + packageExtensions + patchedDependencies peerDependencies resolutions ].freeze @@ -222,6 +226,19 @@ jobs: end manifests.each { |path| visit.call(JSON.parse(File.read(path)), path, []) } + workspace_path = "./pnpm-workspace.yaml" + if File.exist?(workspace_path) + abort "pnpm-workspace.yaml must be a regular file" if File.symlink?(workspace_path) || !File.file?(workspace_path) + workspace = YAML.safe_load(File.read(workspace_path), aliases: true) + abort "pnpm-workspace.yaml must contain a mapping" unless workspace.is_a?(Hash) + workspace.each_key do |key| + name = key.to_s + if %w[namedRegistries registries registry].include?(name) || name.end_with?(":registry") + failures << "#{workspace_path}:#{name}" + end + end + visit.call(workspace, workspace_path, []) + end abort "Dependency manifest contains a disallowed package or source at #{failures.uniq.join(', ')}" unless failures.empty? RUBY From 90d803b102ff2546a9c963747262dd81e42a171e Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 15:35:24 +0200 Subject: [PATCH 31/32] fix(ci): reject non-registry lock locators (CEL-1328) --- .../deploy-static-lock-validator.test.rb | 57 ++++++++++++++++++- .github/workflows/deploy-static-website.yaml | 19 +++---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index 9f10a0e..c887812 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -53,7 +53,7 @@ resolution: integrity: sha999-safe YAML - "workspace importer reference" => [<<~YAML, true], + "workspace importer reference" => [<<~YAML, false], lockfileVersion: '9.0' importers: .: @@ -62,6 +62,60 @@ specifier: workspace:* version: link:../local-package YAML + "patch importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: patch:dep@npm%3A1.0.0#hash + YAML + "portal importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: portal:../evil + YAML + "relative tarball importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: ../evil.tgz + YAML + "unknown protocol importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: exec:payload + YAML + "repository shorthand importer locator" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: + .: + dependencies: + external-package: + specifier: 1.0.0 + version: attacker/package + YAML + "npm registry alias importer locator" => [<<~YAML, true], + lockfileVersion: '9.0' + importers: + .: + dependencies: + aliased-package: + specifier: npm:public-package@1.0.0 + version: public-package@1.0.0 + YAML "raw git protocol" => [<<~YAML, false], lockfileVersion: '9.0' packages: @@ -178,6 +232,7 @@ "workspace manifest package" => ["public-package", "workspace:*", false], "github shorthand manifest package" => ["public-package", "attacker/package", false], "tarball manifest package" => ["public-package", "package.tgz", false], + "tar archive manifest package" => ["public-package", "package.tar", false], "git workspace override" => [ "public-package", "^1.0.0", diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 1fc4b50..7e19b7e 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -180,7 +180,7 @@ jobs: peerDependencies resolutions ].freeze - non_registry_dependency_source = /\A(?:npm:|git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:|patch:|portal:|catalog:)|[\\\/]|\.(?:tgz|tar\.gz)\z/i + non_registry_dependency_source = /\A(?:npm:|git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:|patch:|portal:|catalog:)|[\\\/]|\.(?:tgz|tar(?:\.gz)?)\z/i allowed_private_packages = %w[ auth beverage-utils @@ -246,9 +246,11 @@ jobs: require "yaml" path = ARGV.fetch(0) - external_source = /(?:\A|@)(?:git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:)/i - workspace_source = /\A(?:link:|workspace:)/i + non_registry_protocol = /(?:\A|@)(?!npm:)[a-z][a-z0-9+.-]*:/i + relative_archive_source = /\A(?:\.\.?[\\\/]|~[\\\/]|[\\\/])|\.(?:tgz|tar(?:\.gz)?)\z/i + repository_shorthand = /\A[a-z0-9._-]+(?:[\\\/][a-z0-9._-]+)+(?:#.*)?\z/i private_package = /@cellarnode\/([a-z0-9._-]+)/i + dependency_sections = %w[dependencies devDependencies optionalDependencies peerDependencies].freeze allowed_private_packages = %w[ auth beverage-utils @@ -257,9 +259,6 @@ jobs: i18n ui ].freeze - allowed_workspace_ref = lambda do |keys| - keys.first == "importers" && %w[specifier version].include?(keys.last) - end failures = [] validate_private_packages = lambda do |text, keys| text.to_s.scan(private_package).flatten.each do |package| @@ -280,8 +279,7 @@ jobs: child["integrity"].match?(/\Asha(?:1|256|384|512)-[A-Za-z0-9+\/]+={0,2}\z/) failures << child_keys.join(".") unless valid_resolution end - if external_source.match?(name) || - (workspace_source.match?(name) && !allowed_workspace_ref.call(child_keys)) + if non_registry_protocol.match?(name) || relative_archive_source.match?(name) failures << child_keys.join(".") end if keys.last == "resolution" && name != "integrity" @@ -293,8 +291,9 @@ jobs: value.each_with_index { |child, index| visit.call(child, keys + [index.to_s]) } when String validate_private_packages.call(value, keys) - if external_source.match?(value) || - (workspace_source.match?(value) && !allowed_workspace_ref.call(keys)) + if non_registry_protocol.match?(value) || + relative_archive_source.match?(value) || + (keys.any? { |key| dependency_sections.include?(key) } && repository_shorthand.match?(value)) failures << keys.join(".") end end From eb3021764542295230b58fe0b603dd0b593ce7e4 Mon Sep 17 00:00:00 2001 From: mjnong Date: Thu, 27 Aug 2026 16:05:23 +0200 Subject: [PATCH 32/32] fix(ci): revalidate preview authorization (CEL-1328) --- .../deploy-static-job-boundaries.test.rb | 68 ++++++++++++++++++- .../deploy-static-lock-validator.test.rb | 36 ++++++++++ .github/workflows/deploy-static-website.yaml | 61 +++++++++++++++-- 3 files changed, 160 insertions(+), 5 deletions(-) diff --git a/.github/tests/deploy-static-job-boundaries.test.rb b/.github/tests/deploy-static-job-boundaries.test.rb index 23e87f3..7f48fa1 100644 --- a/.github/tests/deploy-static-job-boundaries.test.rb +++ b/.github/tests/deploy-static-job-boundaries.test.rb @@ -1,13 +1,14 @@ require "yaml" require "open3" require "tmpdir" +require "json" workflow_path = File.expand_path("../workflows/deploy-static-website.yaml", __dir__) workflow = YAML.safe_load(File.read(workflow_path), aliases: true) jobs = workflow.fetch("jobs") cancel_in_progress = workflow.fetch("concurrency").fetch("cancel-in-progress") -expected_cancel_policy = "${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }}" +expected_cancel_policy = "${{ github.event_name == 'pull_request_target' }}" abort "Production deploys must never be cancelled in progress" unless cancel_in_progress == expected_cancel_policy abort "Preview dependencies must not cross jobs through a package-store artifact" if jobs.key?("preview-dependencies") @@ -39,6 +40,7 @@ abort "Preview build must use a GitHub-hosted runner" unless build_preview.fetch("runs-on") == "ubuntu-latest" abort "Preview build must not use a self-hosted container" if build_preview.key?("container") abort "Preview build must not receive NPM_TOKEN at job scope" if build_preview.fetch("env", {}).key?("NPM_TOKEN") +abort "Preview build must allow live pull-request authorization lookup" unless build_preview.fetch("permissions", {}).fetch("pull-requests", nil) == "read" preview_install = build_preview.fetch("steps").find { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } abort "Preview install must receive only the scoped NPM_TOKEN" unless preview_install&.fetch("env", {})&.fetch("NPM_TOKEN", nil) == "${{ secrets.NPM_TOKEN }}" untrusted_preview_names = ["Verify credential isolation", "Rebuild dependencies", "Type check", "Lint", "Build preview"] @@ -49,10 +51,16 @@ preview_steps = build_preview.fetch("steps") dependency_validation_index = preview_steps.index { |step| step.fetch("name", "") == "Validate preview dependency inputs" } +authorization_index = preview_steps.index { |step| step.fetch("name", "") == "Revalidate preview authorization" } credentialed_install_index = preview_steps.index { |step| step.fetch("name", "") == "Install dependencies with scoped registry credential" } abort "Preview dependency inputs must be validated before credentialed install" unless dependency_validation_index && credentialed_install_index && dependency_validation_index < credentialed_install_index +abort "Preview authorization must be revalidated immediately before credentialed install" unless authorization_index && authorization_index + 1 == credentialed_install_index dependency_validation = preview_steps.fetch(dependency_validation_index) abort "Preview dependency validation must not receive NPM_TOKEN" if dependency_validation.fetch("env", {}).key?("NPM_TOKEN") +preview_authorization = preview_steps.fetch(authorization_index) +abort "Preview authorization recheck must not receive NPM_TOKEN" if preview_authorization.fetch("env", {}).key?("NPM_TOKEN") +abort "Preview authorization recheck must use pinned GitHub Script" unless preview_authorization.fetch("uses", "").match?(/\Aactions\/github-script@[0-9a-f]{40}\z/) +abort "Preview authorization recheck must use the job token" unless preview_authorization.fetch("with", {}).fetch("github-token", nil) == "${{ github.token }}" %w[build-preview build-production].each do |job_name| steps = jobs.fetch(job_name).fetch("steps") @@ -80,6 +88,64 @@ abort "#{job_name} must require the trusted policy output" unless job.fetch("if").include?("needs.preview-policy.outputs.trusted == 'true'") end +deploy_preview_steps = jobs.fetch("deploy-preview").fetch("steps") +deploy_authorization_index = deploy_preview_steps.index { |step| step.fetch("name", "") == "Revalidate preview authorization" } +deploy_oidc_index = deploy_preview_steps.index { |step| step.fetch("name", "") == "Authenticate to Google Cloud" } +abort "Preview authorization must be revalidated immediately before OIDC" unless deploy_authorization_index && deploy_authorization_index + 1 == deploy_oidc_index +deploy_authorization = deploy_preview_steps.fetch(deploy_authorization_index) +abort "Build and deploy must execute the same authorization recheck" unless preview_authorization.fetch("with").fetch("script") == deploy_authorization.fetch("with").fetch("script") + +authorization_script = preview_authorization.fetch("with").fetch("script") + base_pull_request = { + "state" => "open", + "user" => { "login" => "maintainer" }, + "head" => { + "sha" => "trusted-head", + "repo" => { "full_name" => "CellarNode/site" }, + }, + } + cases = { + "current trusted author" => [base_pull_request, "write", true], + "closed pull request" => [base_pull_request.merge("state" => "closed"), "write", false], + "changed head" => [base_pull_request.merge("head" => base_pull_request.fetch("head").merge("sha" => "changed-head")), "write", false], + "forked head" => [base_pull_request.merge("head" => base_pull_request.fetch("head").merge("repo" => { "full_name" => "attacker/site" })), "write", false], + "different author" => [base_pull_request.merge("user" => { "login" => "other-user" }), "write", false], + "revoked permission" => [base_pull_request, "read", false], + } + + cases.each do |name, (pull_request, permission, expected)| + _stdout, _stderr, status = Open3.capture3( + { + "ACTOR" => "maintainer", + "AUTHORIZATION_SCRIPT" => authorization_script, + "CURRENT_PERMISSION" => permission, + "EXPECTED_HEAD" => "trusted-head", + "PR_JSON" => JSON.generate(pull_request), + "PR_NUMBER" => "42", + "REPOSITORY" => "CellarNode/site", + }, + "node", "-e", <<~'JAVASCRIPT', + const AsyncFunction = Object.getPrototypeOf(async () => null).constructor; + const pullRequest = JSON.parse(process.env.PR_JSON); + const github = { + rest: { + pulls: { get: async () => ({ data: pullRequest }) }, + repos: { + getCollaboratorPermissionLevel: async () => ({ + data: { permission: process.env.CURRENT_PERMISSION }, + }), + }, + }, + }; + const context = { repo: { owner: 'CellarNode', repo: 'site' } }; + const core = { setFailed: message => { throw new Error(message); } }; + new AsyncFunction('github', 'context', 'core', process.env.AUTHORIZATION_SCRIPT)(github, context, core) + .catch(error => { console.error(error.message); process.exitCode = 1; }); + JAVASCRIPT + ) + abort "#{name}: expected accepted=#{expected}, got accepted=#{status.success?}" unless status.success? == expected + end + abort "Preview lifecycle must not bind an unavailable sender field" if File.read(workflow_path).include?("github.event.sender.login") abort "Preview lifecycle must not trust stale author association" if File.read(workflow_path).include?("author_association") diff --git a/.github/tests/deploy-static-lock-validator.test.rb b/.github/tests/deploy-static-lock-validator.test.rb index c887812..87e1ef4 100644 --- a/.github/tests/deploy-static-lock-validator.test.rb +++ b/.github/tests/deploy-static-lock-validator.test.rb @@ -12,19 +12,43 @@ fixtures = { "registry resolution" => [<<~YAML, true], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: integrity: sha512-safe YAML + "null lock root" => ["null\n", false], + "array lock root" => ["- unexpected\n", false], + "unrelated lock root" => ["foo: bar\n", false], + "missing importers" => [<<~YAML, false], + lockfileVersion: '9.0' + packages: {} + YAML + "unsupported lockfile version" => [<<~YAML, false], + lockfileVersion: '8.0' + importers: {} + YAML + "non-mapping packages" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + packages: unexpected + YAML + "non-mapping snapshots" => [<<~YAML, false], + lockfileVersion: '9.0' + importers: {} + snapshots: [] + YAML "empty resolution" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: {} YAML "missing integrity value" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: @@ -32,6 +56,7 @@ YAML "structured integrity value" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: @@ -40,6 +65,7 @@ YAML "array integrity value" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: @@ -48,6 +74,7 @@ YAML "unknown integrity algorithm" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: @@ -118,6 +145,7 @@ YAML "raw git protocol" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: git-package@1.0.0: resolution: @@ -126,6 +154,7 @@ YAML "structured git resolution" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: git-package@1.0.0: resolution: @@ -134,6 +163,7 @@ YAML "unknown resolution shape" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: external-package@1.0.0: resolution: @@ -141,6 +171,7 @@ YAML "tarball resolution" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: external-package@1.0.0: resolution: @@ -148,6 +179,7 @@ YAML "directory resolution" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: external-package@1.0.0: resolution: @@ -155,6 +187,7 @@ YAML "allowed private package" => [<<~YAML, true], lockfileVersion: '9.0' + importers: {} packages: '@cellarnode/ui@0.154.0': resolution: @@ -162,6 +195,7 @@ YAML "unknown private package" => [<<~YAML, false], lockfileVersion: '9.0' + importers: {} packages: '@cellarnode/internal-secrets@1.0.0': resolution: @@ -233,6 +267,7 @@ "github shorthand manifest package" => ["public-package", "attacker/package", false], "tarball manifest package" => ["public-package", "package.tgz", false], "tar archive manifest package" => ["public-package", "package.tar", false], + "unknown protocol manifest package" => ["public-package", "exec:payload", false], "git workspace override" => [ "public-package", "^1.0.0", @@ -328,6 +363,7 @@ File.join(directory, "pnpm-lock.yaml"), <<~YAML, lockfileVersion: '9.0' + importers: {} packages: '@scope/package@1.0.0': resolution: diff --git a/.github/workflows/deploy-static-website.yaml b/.github/workflows/deploy-static-website.yaml index 7e19b7e..d07b02c 100644 --- a/.github/workflows/deploy-static-website.yaml +++ b/.github/workflows/deploy-static-website.yaml @@ -56,7 +56,7 @@ on: concurrency: group: static-deploy-${{ inputs.website_slug }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action != 'closed' }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} env: PROJECT_ID: festive-terrain-478011-h0 @@ -135,6 +135,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: read steps: - name: Set up pnpm @@ -180,7 +181,8 @@ jobs: peerDependencies resolutions ].freeze - non_registry_dependency_source = /\A(?:npm:|git\+|git@|git:\/\/|ssh:\/\/|github:|gitlab:|bitbucket:|https?:\/\/|file:|link:|workspace:|patch:|portal:|catalog:)|[\\\/]|\.(?:tgz|tar(?:\.gz)?)\z/i + protocol_dependency_source = /\A[a-z][a-z0-9+.-]*:/i + path_or_archive_dependency_source = /[\\\/]|\.(?:tgz|tar(?:\.gz)?)\z/i allowed_private_packages = %w[ auth beverage-utils @@ -219,7 +221,8 @@ jobs: value.scan(private_package).flatten.each do |package| failures << "#{path}:#{keys.join('.')}" unless allowed_private_packages.include?(package.downcase) end - if keys.any? { |key| dependency_sections.include?(key) } && non_registry_dependency_source.match?(value) + if keys.any? { |key| dependency_sections.include?(key) } && + (protocol_dependency_source.match?(value) || path_or_archive_dependency_source.match?(value)) failures << "#{path}:#{keys.join('.')}" end end @@ -299,7 +302,15 @@ jobs: end end - visit.call(YAML.safe_load(File.read(path), aliases: true), []) + lock = YAML.safe_load(File.read(path), aliases: true) + abort "Dependency lock must contain a mapping" unless lock.is_a?(Hash) + abort "Dependency lock must use lockfile version 9.0" unless lock["lockfileVersion"].to_s == "9.0" + abort "Dependency lock importers must contain a mapping" unless lock["importers"].is_a?(Hash) + %w[packages snapshots].each do |section| + abort "Dependency lock #{section} must contain a mapping" if lock.key?(section) && !lock[section].is_a?(Hash) + end + + visit.call(lock, []) abort "Dependency lock contains a disallowed source or private package at #{failures.uniq.join(', ')}" unless failures.empty? RUBY @@ -310,6 +321,37 @@ jobs: fi ruby "$LOCK_VALIDATOR" pnpm-lock.yaml + - name: Revalidate preview authorization + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + ACTOR: ${{ github.actor }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + with: + github-token: ${{ github.token }} + script: &preview-authorization | + const actor = process.env.ACTOR; + const expectedHead = process.env.EXPECTED_HEAD; + const prNumber = Number(process.env.PR_NUMBER); + const repository = process.env.REPOSITORY; + const { data: pullRequest } = await github.rest.pulls.get({ + ...context.repo, + pull_number: prNumber, + }); + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: actor, + }); + const authorized = pullRequest.state === 'open' && + pullRequest.user?.login === actor && + pullRequest.head.repo?.full_name === repository && + pullRequest.head.sha === expectedHead && + ['admin', 'maintain', 'write'].includes(access.permission); + if (!authorized) { + core.setFailed('Preview authorization is no longer valid'); + } + - name: Install dependencies with scoped registry credential shell: bash env: @@ -589,6 +631,17 @@ jobs: exit 1 fi + - name: Revalidate preview authorization + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + env: + ACTOR: ${{ github.actor }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + with: + github-token: ${{ github.token }} + script: *preview-authorization + - name: Authenticate to Google Cloud uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 with: