#!/usr/bin/env bash # Build the site and put it online at https://atgc.codes. # # www/scripts/deploy.sh [tofu apply arguments…] # # The build is uploaded as an immutable tree under # s3://atgc.codes/releases//, and `tofu apply` then only flips # CloudFront's origin_path to it — so a deploy is one atomic pointer move # rather than a bucket being rewritten under a reader. Roll back with: # # tofu -chdir=infra apply -var release_sha= # # State is remote (infra/backend.tf), so the sha CloudFront is serving is # recorded there and not in a file anybody has to keep. `tofu -chdir=infra # output release_sha` is what is live. # # Publishing the notes to atproto is the step *after* this one, never part of # it: a document record claims an address, so the address has to answer first. # See www/scripts/publish.sh. set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" bucket="atgc.codes" # The atgc.codes account. Written out here for the same reason # infra/backend.tf writes it into the state bucket's name: this runs before # tofu does, so there is nothing yet to read it from. account="549303709100" if ! identity="$(aws sts get-caller-identity --output text --query '[Account,Arn]' 2>&1)"; then echo "deploy: AWS credential check failed:" >&2 echo " ${identity}" >&2 echo "deploy: log in to the atgc.codes account (AWS_PROFILE=jmm-atgc-codes-admin), then re-run." >&2 exit 1 fi read -r caller_account caller_arn <<<"${identity}" # Valid credentials for the wrong account are the failure worth catching. The # SSO sessions in ~/.aws/config share one start URL, so a login authenticates # whoever the browser is already signed in as and hands back a working token # under whatever profile was asked for. Unchecked, that reads as a confusing # permissions error much later — or, with enough rights somewhere else, # uploads this site into somebody else's bucket. if [[ "${caller_account}" != "${account}" ]]; then echo "deploy: wrong AWS account." >&2 echo " credentials are for ${caller_account} (${caller_arn})" >&2 echo " atgc.codes is ${account}" >&2 echo "deploy: log in to it (AWS_PROFILE=jmm-atgc-codes-admin), then re-run." >&2 echo "deploy: if that profile is what you used, check who it logged you in as:" >&2 echo "deploy: aws sts get-caller-identity" >&2 exit 1 fi echo "deploy: deploying as ${caller_arn}" # `npm ci`, not `npm install`: the uploaded tree should be a function of the # commit rather than of whatever is in www/node_modules right now. The build # regenerates src/generated/ from the binary and then runs verify:dist, which # is what actually decides the tree is deployable. npm --prefix "${repo_root}/www" ci npm --prefix "${repo_root}/www" run build 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. # --untracked-files=no keeps the compared set to tracked files. if [[ -n "$(git -C "${repo_root}" status --porcelain --untracked-files=no)" ]]; then release="${release}-dirty" echo "deploy: uncommitted changes; deploying as ${release}" fi prefix="releases/${release}" echo "deploy: release ${release}" if ! aws s3api head-bucket --bucket "${bucket}" >/dev/null 2>&1; then echo "deploy: s3://${bucket} does not exist yet (first deploy?)." >&2 echo "deploy: run \`tofu -chdir=infra init\` and" >&2 echo "deploy: \`tofu -chdir=infra apply -var release_sha=${release}\` to create the" >&2 echo "deploy: infrastructure, 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" ;; *.xml) echo "application/xml; charset=utf-8" ;; *.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 assets/ carry a content hash in the filename, so they can be # cached forever; everything else falls back to CloudFront defaults. if [[ "${key}" == assets/* ]]; 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}/www/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}/www/dist" -type f -print0 \ | xargs -0 -r -P 10 -I {} bash -c 'upload_from_dist "$@"' _ {} echo "deploy: uploaded release to s3://${bucket}/${prefix}/" tofu -chdir="${repo_root}/infra" apply -var "release_sha=${release}" "$@" # 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: 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: created CloudFront invalidation ${invalidation}"