From d9ce507a20ea5a789a2de77a6ffd251aa1c8fb7c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 11 Sep 2026 10:50:07 -0400 Subject: [PATCH] feat(scripts): build and publish policy.did.bot build-policy-site.sh builds the wasm, then runs npm ci, build and test in policy-site/. publish-policy-site.sh refuses a tree missing the page, its wasm or client metadata, or whose client_id is not its served address. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I90f0f422020a34608e564259c3021d5336f9630b --- prek.toml | 3 + scripts/build-policy-site.sh | 45 ++++++ scripts/publish-policy-site.sh | 95 ++++++++++++ scripts/test-publish-policy-site.sh | 232 ++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100755 scripts/build-policy-site.sh create mode 100755 scripts/publish-policy-site.sh create mode 100755 scripts/test-publish-policy-site.sh diff --git a/prek.toml b/prek.toml index c1639351..8860ff6e 100644 --- a/prek.toml +++ b/prek.toml @@ -120,6 +120,9 @@ hooks = [ # script), so this is cheap enough to run on every touch of these files — # unlike site-build above, it never invokes cargo or npm. { id = "publish-site-tests", name = "publish-site tests", entry = "scripts/test-publish-site.sh", language = "system", files = '^scripts/(publish-site|publish-lib|test-publish-site|build-site)\.sh$', pass_filenames = false, stages = ["pre-commit"] }, + # The same for publish-policy-site.sh, whose tests stub the aws CLI the same + # way. + { id = "publish-policy-site-tests", name = "publish-policy-site tests", entry = "scripts/test-publish-policy-site.sh", language = "system", files = '^scripts/(publish-policy-site|publish-lib|test-publish-policy-site|build-policy-site)\.sh$', pass_filenames = false, stages = ["pre-commit"] }, ] # Conventional Commits, checked as the message is written. It matters more than diff --git a/scripts/build-policy-site.sh b/scripts/build-policy-site.sh new file mode 100755 index 00000000..d9902ae8 --- /dev/null +++ b/scripts/build-policy-site.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds https://policy.did.bot into policy-site/dist/, ready to sync to a +# bucket. It never touches AWS: scripts/publish-policy-site.sh calls this and +# then does the upload. +# +# The page checks a policy with wasm that scripts/build-policy-wasm.sh writes +# into policy-site/public/wasm/, which Vite copies into the build untouched, so +# that runs first. The tests run last, against the built tree; among them, +# policy-site/tests/check-csp.mjs holds it to the CSP in +# infra/policy-site/main.tf. +# +# Usage: scripts/build-policy-site.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ "$#" -ne 0 ]; then + echo "usage: $0" >&2 + exit 2 +fi + +if [ ! -f scripts/build-policy-wasm.sh ]; then + echo "build-policy-site: scripts/build-policy-wasm.sh is missing." >&2 + echo " It builds the policy-check wasm the page loads, and the page is not built without it." >&2 + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "build-policy-site: npm is required (policy-site/ is a Vite project) and was not found" >&2 + exit 1 +fi + +echo "=== build-policy-wasm: the policy check the page runs ===" >&2 +scripts/build-policy-wasm.sh + +echo "=== npm ci: policy-site/'s locked dependencies ===" >&2 +(cd policy-site && npm ci) + +echo "=== vite build ===" >&2 +(cd policy-site && npm run build) + +echo "=== policy-site/tests ===" >&2 +(cd policy-site && npm test) + +echo "build-policy-site: done — policy-site/dist/ is ready to publish" >&2 diff --git a/scripts/publish-policy-site.sh b/scripts/publish-policy-site.sh new file mode 100755 index 00000000..db1810cf --- /dev/null +++ b/scripts/publish-policy-site.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Builds policy.did.bot (via scripts/build-policy-site.sh) and puts it online, +# as an immutable release tree and a CloudFront origin flip. +# scripts/publish-lib.sh holds the steps and says how a release and a rollback +# work. +# +# Roll back by applying the sha of a tree already uploaded: +# +# tofu -chdir=infra/policy-site apply -var release_sha= +# +# The bucket, the distribution, its headers and the records are +# infra/policy-site/'s. +# +# Usage: +# scripts/publish-policy-site.sh # build + upload + apply + invalidate +# scripts/publish-policy-site.sh --dry-run # build + upload plan + tofu plan +# scripts/publish-policy-site.sh --build-only # build, skip AWS entirely +# scripts/publish-policy-site.sh --skip-build # reuse policy-site/dist/, publish it +# +# Anything else is passed straight to `tofu apply` (and to the dry run's `tofu +# plan`), which is how an unattended run gets `-auto-approve`. +set -euo pipefail + +cd "$(dirname "$0")/.." + +name="publish-policy-site" +build="scripts/build-policy-site.sh" +dist="policy-site/dist" +bucket="did-bot-policy-site" +stack="infra/policy-site" +url="https://policy.did.bot/" + +. scripts/publish-lib.sh + +publish_parse_args "$@" +publish_build + +# The page, the OAuth client metadata it signs in with, and the wasm it checks +# a policy with. A tree missing any of them cannot do its one job. +for file in index.html client-metadata.json wasm/policy-check.js wasm/policy-check_bg.wasm; do + if [ ! -f "${dist}/${file}" ]; then + echo "${name}: ${dist}/${file} is missing; refusing to publish a tree without it" >&2 + exit 1 + fi +done + +# An authorization server fetches the client metadata from the address its +# client_id names, and refuses a document naming any other. A build carrying a +# development address would sign nobody in. +client_id_want="${url}client-metadata.json" +if ! client_id="$(node -e ' + const doc = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(doc.client_id)); +' "${dist}/client-metadata.json" 2>&1)"; then + echo "${name}: ${dist}/client-metadata.json could not be read:" >&2 + echo " ${client_id}" >&2 + exit 1 +fi +if [ "$client_id" != "$client_id_want" ]; then + echo "${name}: ${dist}/client-metadata.json has client_id ${client_id}," >&2 + echo " but it is served at ${client_id_want}; refusing to publish it" >&2 + exit 1 +fi + +publish_require_credentials +publish_name_release +publish_require_bucket + +# Three Cache-Control values, and `aws s3 sync` sets one per call: +# +# - Vite names everything under assets/ by its content hash, so a browser +# may keep it forever. +# - The wasm keeps a fixed name, and a browser refuses to compile it +# streaming unless it arrives as application/wasm, so its type is set here +# rather than guessed. +# - Everything else keeps a fixed name too, index.html and +# client-metadata.json among them. `no-cache` has every load revalidate, +# so a publish reaches an operator at their next page load. +sync_tree() { + local dryrun=("$@") + + aws s3 sync "${dist}" "s3://${bucket}/${prefix}" --no-progress \ + --exclude "*" --include "assets/*" --exclude "*.wasm" \ + --cache-control "public, max-age=31536000, immutable" ${dryrun+"${dryrun[@]}"} + + aws s3 sync "${dist}" "s3://${bucket}/${prefix}" --no-progress \ + --exclude "*" --include "*.wasm" \ + --content-type "application/wasm" --cache-control "no-cache" ${dryrun+"${dryrun[@]}"} + + aws s3 sync "${dist}" "s3://${bucket}/${prefix}" --no-progress \ + --exclude "assets/*" --exclude "*.wasm" \ + --cache-control "no-cache" ${dryrun+"${dryrun[@]}"} +} + +publish_release diff --git a/scripts/test-publish-policy-site.sh b/scripts/test-publish-policy-site.sh new file mode 100755 index 00000000..87d060c7 --- /dev/null +++ b/scripts/test-publish-policy-site.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# Tests scripts/publish-policy-site.sh without ever touching real AWS: fake +# `aws` and `tofu` binaries go first on PATH, and every check reads what the +# script would have done from their call log. +# +# What is checked: +# +# 1. Without working credentials it refuses, before uploading anything. +# 2. A first publish, before the stack exists, says how to create it. +# 3. A tree missing the page, its client metadata or its wasm is refused +# before anything is uploaded. +# 4. A client-metadata.json whose client_id is not the address it is served +# at is refused before anything is uploaded. +# 5. A dry run plans and writes nothing. +# 6. A plain run uploads under `releases//`, hands that sha to `tofu +# apply`, waits for the distribution, and only then invalidates. +# 7. Each upload pass carries its own Cache-Control, and the wasm pass sets +# application/wasm. +# +# Usage: scripts/test-publish-policy-site.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +workdir="$(mktemp -d)" +# A real build in policy-site/dist/ is moved aside for the duration and always +# restored, pass or fail. +dist_backup="$workdir/dist-backup" +if [ -d policy-site/dist ]; then + mv policy-site/dist "$dist_backup" +fi +restore_dist() { + rm -rf policy-site/dist + if [ -d "$dist_backup" ]; then + mv "$dist_backup" policy-site/dist + fi + rmdir policy-site 2>/dev/null || true + rm -rf "$workdir" +} +trap restore_dist EXIT + +log="$workdir/calls.log" +: >"$log" + +# Every invocation is appended to $log verbatim. FAKE_AWS_NO_CREDS and +# FAKE_AWS_NO_BUCKET make the credential probe and the bucket probe fail. +cat >"$workdir/aws" <>"$log" +case "\$1 \$2" in + "sts get-caller-identity") + if [ -n "\${FAKE_AWS_NO_CREDS:-}" ]; then + echo "Unable to locate credentials" >&2 + exit 255 + fi + echo "arn:aws:sts::000000000000:assumed-role/test/test" + ;; + "s3api head-bucket") + if [ -n "\${FAKE_AWS_NO_BUCKET:-}" ]; then + exit 254 + fi + ;; + "cloudfront create-invalidation") echo "ITESTINVALIDATION" ;; +esac +exit 0 +EOF +cat >"$workdir/tofu" <>"$log" +for arg in "\$@"; do + if [ "\$arg" = "output" ]; then + echo "ETESTDISTRIBUTION" + fi +done +exit 0 +EOF +chmod +x "$workdir/aws" "$workdir/tofu" + +export PATH="$workdir:$PATH" + +# A stand-in for a real build, carrying every file the script requires. +seed_dist() { + rm -rf policy-site/dist + mkdir -p policy-site/dist/assets policy-site/dist/wasm + echo "" >policy-site/dist/index.html + echo '{"client_id":"https://policy.did.bot/client-metadata.json"}' \ + >policy-site/dist/client-metadata.json + echo "export {}" >policy-site/dist/wasm/policy-check.js + echo "wasm" >policy-site/dist/wasm/policy-check_bg.wasm + echo "export {}" >policy-site/dist/assets/index-abc123.js +} +seed_dist + +status=0 + +fail() { + echo "FAIL: $1" >&2 + status=1 +} + +publish() { + scripts/publish-policy-site.sh --skip-build "$@" >"$workdir/out" 2>&1 +} + +# Asserts the last run failed, said $1, and uploaded nothing. +refused() { + local said="$1" why="$2" + if [ "$3" -eq 0 ]; then + fail "publish should have failed: $why" + elif ! grep -qF -- "$said" "$workdir/out"; then + fail "the refusal did not say \"$said\": $why" + cat "$workdir/out" >&2 + fi + if grep -q '^aws s3 sync' "$log"; then + fail "the tree was uploaded: $why" + fi +} + +echo "=== a failing credential check is refused, before any upload ===" >&2 +: >"$log" +rc=0 +FAKE_AWS_NO_CREDS=1 publish || rc=$? +refused "credential check failed" "no credentials" "$rc" + +echo "=== a first publish, with no bucket yet, says how to create it ===" >&2 +: >"$log" +rc=0 +FAKE_AWS_NO_BUCKET=1 publish || rc=$? +refused "tofu -chdir=infra/policy-site apply" "no bucket" "$rc" + +echo "=== a tree missing a required file is refused, before any upload ===" >&2 +for file in index.html client-metadata.json wasm/policy-check.js wasm/policy-check_bg.wasm; do + : >"$log" + rm "policy-site/dist/$file" + rc=0 + publish || rc=$? + refused "$file" "the tree has no $file" "$rc" + seed_dist +done + +echo "=== a client_id other than the served address is refused ===" >&2 +: >"$log" +echo '{"client_id":"http://localhost:5173/client-metadata.json"}' \ + >policy-site/dist/client-metadata.json +rc=0 +publish || rc=$? +refused "client_id http://localhost:5173/client-metadata.json" "a development client_id" "$rc" +seed_dist + +echo "=== --dry-run plans and writes nothing ===" >&2 +: >"$log" +if ! publish --dry-run; then + fail "the dry run should succeed against the stubs" + cat "$workdir/out" >&2 +fi +if ! grep -q '^tofu -chdir=infra/policy-site plan' "$log"; then + fail "the dry run did not plan the stack" + cat "$log" >&2 +fi +if [ "$(grep -c -- '^aws s3 sync .*--dryrun' "$log" || true)" -ne 3 ]; then + fail "the dry run did not rehearse all three upload passes" + cat "$log" >&2 +fi +if grep -qE '^tofu -chdir=infra/policy-site apply|create-invalidation' "$log"; then + fail "the dry run applied or invalidated" + cat "$log" >&2 +fi + +echo "=== a plain run uploads the tree, flips the origin, then invalidates ===" >&2 +: >"$log" +if ! publish; then + fail "a plain run should succeed against the stubs" + cat "$workdir/out" >&2 +fi + +syncs="$(grep '^aws s3 sync' "$log" || true)" +if [ "$(grep -c . <<<"$syncs")" -ne 3 ]; then + fail "expected three upload passes" + cat "$log" >&2 +fi +if grep -q -- '--dryrun' "$log"; then + fail "a plain run rehearsed the upload instead of making it" +fi + +release="$(git rev-parse --short=12 HEAD)" +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + release="${release}-dirty" +fi +if [ "$(grep -c "s3://did-bot-policy-site/releases/${release} " <<<"$syncs" || true)" -ne 3 ]; then + fail "not every upload pass went to releases/${release}" + cat "$log" >&2 +fi +if ! grep -q "^tofu -chdir=infra/policy-site apply -var release_sha=${release}" "$log"; then + fail "the apply did not carry the sha the tree was uploaded under" + cat "$log" >&2 +fi + +# The order is the point: an invalidation issued before the new config reaches +# the edges re-caches the tree it was meant to clear. +apply_line="$(grep -n '^tofu -chdir=infra/policy-site apply' "$log" | head -1 | cut -d: -f1)" +wait_line="$(grep -n '^aws cloudfront wait distribution-deployed' "$log" | head -1 | cut -d: -f1)" +invalidate_line="$(grep -n '^aws cloudfront create-invalidation' "$log" | head -1 | cut -d: -f1)" +if [ -z "$apply_line" ] || [ -z "$wait_line" ] || [ -z "$invalidate_line" ]; then + fail "expected an apply, a wait and an invalidation; saw '$apply_line' '$wait_line' '$invalidate_line'" + cat "$log" >&2 +elif [ "$apply_line" -gt "$wait_line" ] || [ "$wait_line" -gt "$invalidate_line" ]; then + fail "expected apply, then wait, then invalidate; saw $apply_line, $wait_line, $invalidate_line" + cat "$log" >&2 +fi + +# Only the content-hashed assets may be kept forever. The fixed names -- +# index.html and client-metadata.json among them -- revalidate every load. +immutable="$(grep -- '--cache-control public, max-age=31536000, immutable' <<<"$syncs" || true)" +if [ "$(grep -c . <<<"$immutable")" -ne 1 ] || ! grep -q -- '--include assets/\*' <<<"$immutable"; then + fail "expected exactly one immutable pass, over assets/" + cat "$log" >&2 +fi +wasm="$(grep -- '--include \*.wasm' <<<"$syncs" || true)" +if ! grep -q -- '--content-type application/wasm --cache-control no-cache' <<<"$wasm"; then + fail "the wasm pass does not set application/wasm and no-cache" + cat "$log" >&2 +fi +fixed="$(grep -- '--exclude assets/\*' <<<"$syncs" || true)" +if ! grep -q -- '--cache-control no-cache' <<<"$fixed"; then + fail "the pass over the fixed names does not set no-cache" + cat "$log" >&2 +fi + +if [ "$status" -eq 0 ]; then + echo "test-publish-policy-site: ok" >&2 +fi +exit "$status" -- 2.51.2