diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 495348ab776..6635cc098b7 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -33,3 +33,75 @@ jobs: with: files: ./coverage/coverage-final.json token: ${{ secrets.CODECOV_TOKEN }} + + # Guard against changes that only break after merge (see the 2026-07-01 + # incident): the production build - whose boot script bytes are gated by a + # CSP `script-src` sha256 where the client is embedded - and the deploy + # script's dependency chain both run for the first time in the post-merge + # Release workflow, so a PR that alters either is green here and fails (or + # silently breaks the shipped client) in production. + prod-build-guard: + name: Prod build & boot hash guard + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + path: pr + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + - name: Cache the PR node_modules dir + uses: actions/cache@v4 + with: + path: pr/node_modules + key: ${{ runner.os }}-node_modules-guard-pr-${{ hashFiles('pr/yarn.lock') }} + - name: Cache the base node_modules dir + uses: actions/cache@v4 + with: + path: base/node_modules + key: ${{ runner.os }}-node_modules-guard-base-${{ hashFiles('base/yarn.lock') }} + - name: Build production bundles (PR) + run: cd pr && yarn install --immutable && yarn build + - name: Build production bundles (base) + run: cd base && yarn install --immutable && yarn build + - name: Smoke-check deploy-time dependencies + run: | + cd pr + node scripts/check-deploy-deps.js + node scripts/deploy-to-s3.js --help > /dev/null + - name: Check boot script for byte drift + env: + DRIFT_APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'boot-bytes-approved') }} + run: | + # Cache-buster fingerprints (`?abc123`) in the inlined asset manifest + # legitimately change whenever any asset's content changes, so they are + # stripped before comparing: only drift in the boot script's *code* + # (the 2026-07-01 incident class) should fail the check. + normalized_hash() { sed -E 's/\?[0-9a-f]{6}//g' "$1" | sha256sum | awk '{print $1}'; } + drift="" + for f in boot-template.js boot.js; do + base_hash=$(normalized_hash "base/build/$f") + pr_hash=$(normalized_hash "pr/build/$f") + echo "$f (fingerprints stripped): base=$base_hash pr=$pr_hash" + echo "$f (raw): base=$(sha256sum "base/build/$f" | awk '{print $1}') pr=$(sha256sum "pr/build/$f" | awk '{print $1}')" + if [ "$base_hash" != "$pr_hash" ]; then + drift="$drift $f" + fi + done + if [ -n "$drift" ]; then + msg="Boot script bytes changed vs the base branch:$drift. Where the client \ + is embedded via an inline script gated by a CSP script-src sha256, this change \ + invalidates that hash and the browser will block the client (2026-07-01 outage). \ + If the change is intentional, coordinate refreshing the CSP hash allowlist with \ + the deploy, then add the 'boot-bytes-approved' label to this PR and re-run." + if [ "$DRIFT_APPROVED" = "true" ]; then + echo "::warning::$msg (approved via 'boot-bytes-approved' label)" + else + echo "::error::$msg" + exit 1 + fi + fi diff --git a/gulpfile.js b/gulpfile.js index 6159de7039c..b17265b41b4 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -7,6 +7,7 @@ import { } from '@hypothesis/frontend-build'; import gulp from 'gulp'; import changed from 'gulp-changed'; +import { writeFileSync } from 'node:fs'; import { serveDev } from './dev-server/serve-dev.js'; import { servePackage } from './dev-server/serve-package.js'; @@ -89,7 +90,21 @@ const manifestSourceFiles = 'build/{scripts,styles}/*.{css,js,map}'; gulp.task('build-boot-script', async () => { // Generate the manifest containing cache-busted asset URLs - await generateManifest({ pattern: manifestSourceFiles }); + const manifest = await generateManifest({ pattern: manifestSourceFiles }); + // Rewrite the manifest with sorted keys. `generateManifest` inserts entries + // in file-read-completion order, which varies between runs, and the boot + // bundle inlines this JSON — key order must be stable for the boot script's + // bytes (and any CSP hash derived from them) to be reproducible. + writeFileSync( + 'build/manifest.json', + JSON.stringify( + Object.fromEntries( + Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)), + ), + null, + 2, + ), + ); // Generate the boot script template await buildJS('./rollup-boot.config.js'); // Replace variables in the template with real URLs diff --git a/scripts/check-deploy-deps.js b/scripts/check-deploy-deps.js new file mode 100644 index 00000000000..edc2fe80f95 --- /dev/null +++ b/scripts/check-deploy-deps.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Smoke-check the release-time deploy dependencies that PR CI otherwise never + * exercises. + * + * Context: on 2026-07-01 a `fast-xml-parser` pin made the AWS SDK reject the + * ` ` (carriage return) entity that S3 returns in `GetBucketLocation` + * responses, blocking every deploy while PR CI stayed green, because the + * deploy script's dependency chain only runs in the post-merge Release + * workflow. This script exercises that chain on every PR: + * + * 1. The AWS SDK S3 client can be imported and constructed. + * 2. The XML parser resolved for the AWS SDK accepts the ` ` entity. + * + * `deploy-to-s3.js --help` is run separately by CI to cover the deploy + * script's own import chain (commander, arborist, npm-packlist). + */ +import { S3Client } from '@aws-sdk/client-s3'; +import { XMLParser } from 'fast-xml-parser'; + +new S3Client({ region: 'us-west-1' }); + +// Mirrors the shape of a real S3 `GetBucketLocation` response, including the +// carriage-return entity that broke deploys on 2026-07-01. +const xml = + 'us-west-1 '; +const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml); +const value = + parsed?.LocationConstraint?.['#text'] ?? parsed?.LocationConstraint; + +if (typeof value !== 'string' || !value.startsWith('us-west-1')) { + console.error('Unexpected S3 XML parse result:', JSON.stringify(parsed)); + process.exit(1); +} + +console.log('Deploy dependency smoke checks passed.');