#!/usr/bin/env bash # Build the frontpage, upload it as an immutable release tree under # s3://substandard.blog/releases//, then run `tofu apply`, which # only flips CloudFront's origin_path to the new tree (the sha is handed to # tofu as release_sha in infra/terraform.tfvars.json, patched here). Roll # back by setting release_sha to a previously uploaded sha and re-applying. # # Checks AWS credentials up front — the only credentials a deploy needs # (the Route53 zone, ACM cert, S3 bucket, and CloudFront distribution all # live in one AWS account, and tofu state is local in infra/). # # Extra arguments are passed through to `tofu apply` (e.g. -auto-approve). set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" bucket="substandard.blog" if ! identity="$(aws sts get-caller-identity --output text --query Arn 2>&1)"; then echo "deploy-site: AWS credential check failed:" >&2 echo " ${identity}" >&2 echo "deploy-site: configure credentials for the substandard.blog account, then re-run." >&2 exit 1 fi echo "deploy-site: deploying as ${identity}" # Same lockfile discipline as deploy-ext.sh: install exactly # web/package-lock.json so the uploaded release tree is a function of the # commit rather than of whatever is in web/node_modules right now. npm --prefix "${repo_root}/web" ci # The homepage gallery serves the popup captures out of web/public/, so a # capture regenerated without a re-sync would deploy a screenshot of a popup # the extension no longer has. Re-sync before the build rather than trusting # that someone ran `npm run shots`; node builtins only, so it needs no install # of its own, and verify:dist below fails the deploy if a copy is still stale. # A sync that changes files leaves the tree dirty, which the release naming # below already reports. node "${repo_root}/scripts/sync-shots.mjs" npm --prefix "${repo_root}/web" run build # A post page renders its document link tag from the atUri in its own # frontmatter, and publish.sh writes that back only after the deploy that put # the page online — so between publishing and the next commit the built page # has no tag. Inject re-reads sequoia's state and puts it back before anything # is uploaded: it leaves alone a tag the build already emitted, and touches no # page that is not a published post. The state file is gitignored, so a # checkout that never published finds nothing to do; the frontmatter is still # the committed source of the tag, and this is the second chance at it. (cd "${repo_root}/web" && ./node_modules/.bin/sequoia inject) # Verify what is about to be uploaded, injected tags included. npm --prefix "${repo_root}/web" run verify:dist release="$(git -C "${repo_root}" rev-parse --short=12 HEAD)" # A dirty tree is not that commit's build: keep it out of the commit's # immutable release tree. The -dirty tree is scratch and gets overwritten. # # `status`, not `diff-index`: diff-index trusts the stat info cached in the # index, so a file rewritten with identical content reads as modified — and # the sync above rewrites every capture on each run, which made this suffix # -dirty on a clean tree every time. status refreshes the index first and # compares contents. --untracked-files=no keeps the compared set the same: # tracked files only. if [[ -n "$(git -C "${repo_root}" status --porcelain --untracked-files=no)" ]]; then release="${release}-dirty" echo "deploy-site: uncommitted changes; deploying as ${release}" fi prefix="releases/${release}" echo "deploy-site: release ${release}" # Record the release for tofu before uploading: on a first deploy the bucket # does not exist yet, and `tofu -chdir=infra apply` can create it from this # file before this script is re-run. node "${repo_root}/scripts/patch-tfvars.mjs" \ "${repo_root}/infra/terraform.tfvars.json" release_sha "${release}" if ! aws s3api head-bucket --bucket "${bucket}" >/dev/null 2>&1; then echo "deploy-site: s3://${bucket} does not exist yet (first deploy?)." >&2 echo "deploy-site: run \`tofu -chdir=infra apply\` to create the infra" >&2 echo "deploy-site: (infra/terraform.tfvars.json is already written), then re-run this script." >&2 exit 1 fi content_type() { case "$1" in *.html) echo "text/html; charset=utf-8" ;; *.css) echo "text/css; charset=utf-8" ;; *.js) echo "text/javascript; charset=utf-8" ;; *.json) echo "application/json" ;; *.png) echo "image/png" ;; *.svg) echo "image/svg+xml" ;; *.ico) echo "image/x-icon" ;; *.txt) echo "text/plain; charset=utf-8" ;; *.woff) echo "font/woff" ;; *.woff2) echo "font/woff2" ;; *.webp) echo "image/webp" ;; *) echo "application/octet-stream" ;; esac } upload() { local file="$1" key="$2" local args=(--content-type "$(content_type "${key}")") # Astro's _astro/ assets carry a content hash in the filename, so they can # be cached forever; everything else falls back to CloudFront defaults. if [[ "${key}" == _astro/* ]]; then args+=(--cache-control "public, max-age=31536000, immutable") fi aws s3 cp --no-progress "${file}" "s3://${bucket}/${prefix}/${key}" "${args[@]}" } # Derive the key from the path so xargs can hand each worker a single # argument. upload_from_dist() { upload "$1" "${1#"${repo_root}/web/dist/"}" } # One `aws` process per file costs ~0.5s of Python startup and a fresh TLS # handshake, which for a tree this small is nearly all of the upload time. # Run ten at a time. xargs exits non-zero if any single upload failed, and # `set -e` turns that into a failed deploy, so a partial release tree can # never reach the `tofu apply` below that points CloudFront at it. export -f upload upload_from_dist content_type export bucket prefix repo_root find "${repo_root}/web/dist" -type f -print0 \ | xargs -0 -r -P 10 -I {} bash -c 'upload_from_dist "$@"' _ {} upload "${repo_root}/oauth/client-metadata.json" "client-metadata.json" echo "deploy-site: uploaded release to s3://${bucket}/${prefix}/" tofu -chdir="${repo_root}/infra" apply "$@" # The apply only updates the distribution config; edge caches still hold the # previous release. Wait for the config to reach every edge, then invalidate. dist_id="$(tofu -chdir="${repo_root}/infra" output -raw distribution_id)" echo "deploy-site: waiting for CloudFront distribution ${dist_id} to deploy" aws cloudfront wait distribution-deployed --id "${dist_id}" invalidation="$(aws cloudfront create-invalidation --distribution-id "${dist_id}" \ --paths "/*" --output text --query Invalidation.Id)" echo "deploy-site: created CloudFront invalidation ${invalidation}"