# Sourced by scripts/publish-site.sh and scripts/publish-policy-site.sh: the # steps both take to put a static build online. Each sets these before # sourcing, then calls the steps in the order they appear here: # # name prefix for every message # build the script that builds the tree # dist the tree it builds # bucket the S3 bucket the tree is uploaded to # stack the OpenTofu root that owns the bucket and the distribution # url the address the distribution serves # # A publish is two steps and one pointer move: # # 1. The build is uploaded as an immutable tree under # s3:///releases//. Nothing serves it yet, and the # bucket is private behind the distribution's origin access control, so # an uploaded tree is visible to nobody until step 2. # 2. `tofu apply -var release_sha=` flips CloudFront's origin_path to # that tree, waits for the distribution to reach every edge, and # invalidates. A reader sees the old tree or the new one, never a # half-written mixture of the two. # # Roll back by applying the sha of a tree already uploaded: # # tofu -chdir= apply -var release_sha= # # State is remote (/backend.tf), so what is live is recorded there: # `tofu -chdir= output release_sha` answers it. publish_usage() { echo "usage: $0 [--dry-run] [--build-only] [--skip-build] [tofu arguments...]" >&2 } # Sets dry_run, build_only, skip_build and tofu_args. Anything not a flag is # passed straight to `tofu apply` (and to the dry run's `tofu plan`), which is # how an unattended run gets `-auto-approve`. publish_parse_args() { dry_run=0 build_only=0 skip_build=0 tofu_args=() local arg for arg in "$@"; do case "$arg" in --dry-run) dry_run=1 ;; # The old spelling of the default. Kept as a no-op rather than an error # so a habit or a saved command line does not fail; it asks for exactly # what happens anyway. --publish) ;; --build-only) build_only=1 ;; --skip-build) skip_build=1 ;; --*) publish_usage exit 2 ;; *) tofu_args+=("$arg") ;; esac done if [ "$dry_run" -eq 1 ] && [ "$build_only" -eq 1 ]; then echo "${name}: --dry-run and --build-only are contradictory" >&2 exit 2 fi } # Builds the tree, or reuses it under --skip-build. Under --build-only, stops # here. publish_build() { if [ "$skip_build" -eq 1 ]; then echo "${name}: --skip-build passed; reusing ${dist}/ as-is" >&2 if [ ! -d "$dist" ]; then echo "${name}: ${dist}/ does not exist -- run without --skip-build first" >&2 exit 1 fi else "$build" fi if [ "$build_only" -eq 1 ]; then echo "${name}: --build-only passed; not touching AWS" >&2 exit 0 fi } # Fails before uploading anything, not halfway through: a dry run checks the # same credentials the real one needs, so a rehearsal is a real rehearsal. publish_require_credentials() { local tool for tool in aws tofu; do if ! command -v "$tool" >/dev/null 2>&1; then echo "${name}: $tool is required and was not found on PATH" >&2 exit 1 fi done local identity if ! identity="$(aws sts get-caller-identity --output text --query Arn 2>&1)"; then echo "${name}: AWS credential check failed:" >&2 echo " ${identity}" >&2 echo "${name}: log in to the account this site deploys to (AWS_PROFILE=), then re-run." >&2 exit 1 fi echo "${name}: publishing as ${identity}" >&2 } # Sets release and prefix. The release is the commit, so a tree can be traced # back to what built it and rolling back is naming an earlier one. `status`, # not `diff-index`: diff-index trusts the index's cached stat info, so a file # rewritten with identical content reads as modified. --untracked-files=no # keeps the comparison to tracked files. publish_name_release() { release="$(git rev-parse --short=12 HEAD)" if [ -n "$(git status --porcelain --untracked-files=no)" ]; then release="${release}-dirty" echo "${name}: uncommitted changes; publishing as ${release}" >&2 fi prefix="releases/${release}" echo "${name}: release ${release} -> s3://${bucket}/${prefix}/" >&2 } # The bucket is the stack's, so the first publish into a fresh account has to # apply the stack before it has anywhere to upload to. That apply names a # release tree nobody has uploaded yet, which is the ordinary state for a few # minutes, until this script runs again and fills the tree in. publish_require_bucket() { if ! aws s3api head-bucket --bucket "${bucket}" >/dev/null 2>&1; then echo "${name}: s3://${bucket} does not exist yet (first publish?)." >&2 echo "${name}: run \`tofu -chdir=${stack} init -backend-config=backend.hcl\` and" >&2 echo "${name}: \`tofu -chdir=${stack} apply -var release_sha=${release}\`" >&2 echo "${name}: to create the infrastructure, then re-run this script." >&2 exit 1 fi } # Uploads with `sync_tree`, which the calling script defines and which passes # its arguments on to every `aws s3 sync` it runs. Then flips the origin, # waits, and invalidates. Under --dry-run, rehearses the upload and plans. publish_release() { if [ "$dry_run" -eq 1 ]; then echo "=== dry run: nothing is uploaded and nothing is applied ===" >&2 sync_tree --dryrun tofu -chdir="${stack}" plan -var "release_sha=${release}" ${tofu_args+"${tofu_args[@]}"} echo "${name}: dry run complete; run without --dry-run to upload and apply" >&2 exit 0 fi sync_tree echo "${name}: uploaded release to s3://${bucket}/${prefix}/" >&2 tofu -chdir="${stack}" apply -var "release_sha=${release}" ${tofu_args+"${tofu_args[@]}"} # The apply only rewrites the distribution's config. Edge caches still hold # the previous tree until the new config has reached them, so wait, then # invalidate. An invalidation issued first would re-cache the old tree. local dist_id invalidation dist_id="$(tofu -chdir="${stack}" output -raw distribution_id)" echo "${name}: waiting for CloudFront distribution ${dist_id} to deploy" >&2 aws cloudfront wait distribution-deployed --id "${dist_id}" invalidation="$(aws cloudfront create-invalidation --distribution-id "${dist_id}" \ --paths "/*" --output text --query Invalidation.Id)" echo "${name}: created CloudFront invalidation ${invalidation}" >&2 echo "${name}: ${url} is serving ${release}" >&2 }