Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 16 additions & 1 deletion gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions scripts/check-deploy-deps.js
Original file line number Diff line number Diff line change
@@ -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 =
'<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">us-west-1&#xD;</LocationConstraint>';
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.');
Loading