#!/usr/bin/env bash set -Eeuo pipefail required_vars=( SITE_BUCKET EXPECTED_SITE_BUCKET CLOUDFRONT_DISTRIBUTION_ID SITE_URL EXPECTED_API_URL ) for name in "${required_vars[@]}"; do if [[ -z "${!name:-}" ]]; then echo "::error::${name} must be set explicitly." >&2 exit 1 fi done DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}" EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}" FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}" API_SMOKE_URL="${API_SMOKE_URL:-}" API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}" if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2 exit 1 fi if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2 exit 1 fi if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2 exit 1 fi if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2 exit 1 fi if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2 exit 1 fi SITE_URL="${SITE_URL%/}" work_dir="$(mktemp -d)" published_index_version="" prior_index_version="" deployment_verified="false" invalidate_and_wait() { local invalidation_id invalidation_id="$( aws cloudfront create-invalidation \ --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \ --paths "/*" \ --query "Invalidation.Id" \ --output text )" test -n "${invalidation_id}" aws cloudfront wait invalidation-completed \ --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \ --id "${invalidation_id}" } rollback_index() { [[ -n "${published_index_version}" ]] || return 0 echo "::warning::Verification failed. Restoring the prior index version." >&2 if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then aws s3api copy-object \ --bucket "${SITE_BUCKET}" \ --key index.html \ --copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \ --cache-control "no-cache,no-store,must-revalidate" \ --content-type "text/html" \ --metadata-directive REPLACE >/dev/null else aws s3api delete-object \ --bucket "${SITE_BUCKET}" \ --key index.html \ --version-id "${published_index_version}" >/dev/null fi invalidate_and_wait || true } cleanup() { local status=$? if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then rollback_index fi rm -rf "${work_dir}" exit "${status}" } trap cleanup EXIT versioning_status="$( aws s3api get-bucket-versioning \ --bucket "${SITE_BUCKET}" \ --query Status \ --output text )" if [[ "${versioning_status}" != "Enabled" ]]; then echo "::error::The target bucket must have versioning enabled." >&2 exit 1 fi if ! prior_index_version="$( aws s3api head-object \ --bucket "${SITE_BUCKET}" \ --key index.html \ --query VersionId \ --output text 2>"${work_dir}/prior-index.error" )"; then if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then prior_index_version="" else cat "${work_dir}/prior-index.error" >&2 echo "::error::Could not inspect the current index version." >&2 exit 1 fi fi : >"${work_dir}/prior-asset-versions.tsv" if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then prior_manifest_key="$( aws s3api list-objects-v2 \ --bucket "${SITE_BUCKET}" \ --prefix ".deploy/releases/" \ --query "reverse(sort_by(Contents,&LastModified))[0].Key" \ --output text )" if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \ "${work_dir}/prior-manifest.json" --quiet node - "${work_dir}/prior-manifest.json" \ >"${work_dir}/prior-asset-versions.tsv" <<'NODE' const manifest = require(process.argv[2]); for (const asset of manifest.assets ?? []) { if (typeof asset === "object" && asset.key && asset.versionId) { console.log(`${asset.key}\t${asset.versionId}`); } } NODE else aws s3api list-objects-v2 \ --bucket "${SITE_BUCKET}" \ --query "Contents[].Key" \ --output text | tr "\t" "\n" \ | awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \ | sort -u >"${work_dir}/prior-asset-keys.txt" while IFS= read -r prior_asset_key; do [[ -n "${prior_asset_key}" ]] || continue prior_asset_version="$( aws s3api head-object \ --bucket "${SITE_BUCKET}" \ --key "${prior_asset_key}" \ --query VersionId \ --output text )" if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2 exit 1 fi printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \ >>"${work_dir}/prior-asset-versions.tsv" done <"${work_dir}/prior-asset-keys.txt" fi fi echo "Building SPA for ${EXPECTED_API_URL}..." npm ci npm run build test -s dist/index.html if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then echo "::error::Built output does not contain EXPECTED_API_URL." >&2 exit 1 fi for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2 exit 1 fi done echo "Publishing immutable release assets..." aws s3 sync dist/ "s3://${SITE_BUCKET}/" \ --exclude "index.html" \ --cache-control "public,max-age=31536000,immutable" published_index_version="$( aws s3api put-object \ --bucket "${SITE_BUCKET}" \ --key index.html \ --body dist/index.html \ --cache-control "no-cache,no-store,must-revalidate" \ --content-type "text/html" \ --query VersionId \ --output text )" if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then echo "::error::Index upload did not return a version ID." >&2 exit 1 fi node - >"${work_dir}/asset-keys.txt" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); function files(directory, prefix = "") { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const relative = path.posix.join(prefix, entry.name); return entry.isDirectory() ? files(path.join(directory, entry.name), relative) : [relative]; }); } for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) { console.log(file); } NODE : >"${work_dir}/asset-versions.tsv" while IFS= read -r asset_key; do asset_version="$( aws s3api head-object \ --bucket "${SITE_BUCKET}" \ --key "${asset_key}" \ --query VersionId \ --output text )" if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2 exit 1 fi printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv" done <"${work_dir}/asset-keys.txt" node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \ "${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \ >"${work_dir}/manifest.json" <<'NODE' const fs = require("node:fs"); const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] = process.argv.slice(2); function readVersions(path) { return fs .readFileSync(path, "utf8") .trim() .split("\n") .filter(Boolean) .map((line) => { const [key, versionId] = line.split("\t"); return { key, versionId }; }); } process.stdout.write( `${JSON.stringify( { release, indexVersion, priorIndexVersion, assets: readVersions(versionsPath), priorAssets: readVersions(priorVersionsPath), }, null, 2, )}\n`, ); NODE manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json" echo "Invalidating CloudFront and waiting for propagation..." invalidate_and_wait fetch_route() { local route="$1" local slug="$2" curl -fsS --max-time 30 \ -D "${work_dir}/${slug}.headers" \ -o "${work_dir}/${slug}.body" \ "${SITE_URL}${route}" grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers" grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers" grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers" grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers" cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body" } curl -fsS --max-time 30 \ -D "${work_dir}/root.headers" \ -o "${work_dir}/root.body" \ "${SITE_URL}/" grep -qi "^content-type:.*text/html" "${work_dir}/root.headers" grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers" grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers" grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers" fetch_route "/login" "login" fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless" grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \ | awk -F'"' '{ print $2 }' \ | sort -u >"${work_dir}/asset-paths.txt" test -s "${work_dir}/asset-paths.txt" : >"${work_dir}/asset-content.txt" while IFS= read -r asset_path; do asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")" curl -fsS --max-time 30 \ -D "${work_dir}/${asset_slug}.headers" \ -o "${work_dir}/${asset_slug}.body" \ "${SITE_URL}${asset_path}" grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers" grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers" cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt" done <"${work_dir}/asset-paths.txt" grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt" for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do if [[ -n "${forbidden_url}" ]] && grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2 exit 1 fi done if [[ -n "${API_SMOKE_URL}" ]]; then api_status="$( curl -sS --max-time 30 \ -H "Origin: ${API_CORS_ORIGIN}" \ -D "${work_dir}/api.headers" \ -o "${work_dir}/api.body" \ -w "%{http_code}" \ "${API_SMOKE_URL}" )" if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then echo "::error::API smoke request failed with HTTP ${api_status}." >&2 exit 1 fi grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers" curl -fsS --max-time 30 \ -X OPTIONS \ -H "Origin: ${API_CORS_ORIGIN}" \ -H "Access-Control-Request-Method: GET" \ -D "${work_dir}/cors.headers" \ -o /dev/null \ "${API_SMOKE_URL}" grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers" grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers" fi aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \ --cache-control "no-cache,no-store,must-revalidate" \ --content-type "application/json" # Once the manifest is durable, this release and its rollback target are both # protected from pruning. A later cleanup failure must not roll back a release # whose prior assets may already have been pruned. deployment_verified="true" # Keep exactly the current and immediately prior release manifests and every # object version they reference. Prune only unreferenced versions, after all # remote checks pass. aws s3api list-objects-v2 \ --bucket "${SITE_BUCKET}" \ --prefix ".deploy/releases/" \ --query "reverse(sort_by(Contents,&LastModified))[].Key" \ --output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt" printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt" awk -v current="${manifest_key}" '$0 != current { print; exit }' \ "${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt" : >"${work_dir}/retained-versions.tsv" while IFS= read -r kept_manifest; do [[ -n "${kept_manifest}" ]] || continue aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet kept_manifest_version="$( aws s3api head-object \ --bucket "${SITE_BUCKET}" \ --key "${kept_manifest}" \ --query VersionId \ --output text )" printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \ >>"${work_dir}/retained-versions.tsv" is_current_manifest="false" if [[ "${kept_manifest}" == "${manifest_key}" ]]; then is_current_manifest="true" fi node - "${work_dir}/kept.json" "${is_current_manifest}" \ >>"${work_dir}/retained-versions.tsv" <<'NODE' const manifest = require(process.argv[2]); const isCurrentManifest = process.argv[3] === "true"; if (manifest.indexVersion && manifest.indexVersion !== "None") { console.log(`index.html\t${manifest.indexVersion}`); } if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") { console.log(`index.html\t${manifest.priorIndexVersion}`); } for (const asset of manifest.assets) { if (typeof asset === "object" && asset.key && asset.versionId) { console.log(`${asset.key}\t${asset.versionId}`); } } if (isCurrentManifest) { for (const asset of manifest.priorAssets ?? []) { if (typeof asset === "object" && asset.key && asset.versionId) { console.log(`${asset.key}\t${asset.versionId}`); } } } NODE done <"${work_dir}/kept-manifests.txt" sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv" aws s3api list-object-versions \ --bucket "${SITE_BUCKET}" \ --output json >"${work_dir}/object-versions.json" node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE' const listing = require(process.argv[2]); for (const version of listing.Versions ?? []) { console.log(`version\t${version.Key}\t${version.VersionId}`); } for (const marker of listing.DeleteMarkers ?? []) { console.log(`marker\t${marker.Key}\t${marker.VersionId}`); } NODE while IFS=$'\t' read -r kind object_key version_id; do [[ -n "${object_key}" && -n "${version_id}" ]] || continue if [[ "${kind}" == "version" ]] && grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then continue fi aws s3api delete-object \ --bucket "${SITE_BUCKET}" \ --key "${object_key}" \ --version-id "${version_id}" >/dev/null done <"${work_dir}/prune-candidates.tsv" echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified."