Something went wrong. Try again.
Infrastructure-as-code for running lance.blue
Something went wrong. Try again.
Shell
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345#!/usr/bin/env bash# A deploy in one command: plan, read the plan, apply it, then post to# lance.blue's Bluesky account when a release id moved. The post is how anyone# watching finds out that something shipped.## The post carries a link card for lance.blue and the commits behind the ids# that moved, read out of the sibling checkouts - ../headquarters, ../arena,# ../helm, or wherever LANCE_REPOS says. A changelog longer than one post# becomes a thread. scripts/lib/post.sh decides all of that and explains it.## scripts/deploy.sh [--yes] [--dry-run] [--no-post] [env]## env defaults to lance.blue, the only one there is. Credentials are the# caller's: whatever already works - SSO session, env var, instance role,# named profile - exactly as for a hand-run apply.## It applies a saved plan rather than re-planning, so what you read on screen# is what runs. The one confirmation is this script's, not tofu's - applying a# plan file never prompts - and --yes skips it for an unattended run.## Posting needs an app password for the lance.blue account - never the account# password. It comes from the login keyring:## secret-tool store --label='lance.blue app password' \# service lance.blue key atp-app-password## ATP_APP_PASSWORD overrides the keyring for a machine that has none. Either# way the password is never printed and never reaches a command line, and it# is deliberately nowhere near AWS: whoever takes the apply role gets the# infrastructure, and must not also get the account that speaks for it.## Without a password the deploy still happens and the post is skipped, because# a missing credential is not a reason to hold up a deploy.set -euo pipefail
source "$(dirname "$0")/lib/aws.sh"source "$(dirname "$0")/lib/post.sh"
usage() { echo "usage: scripts/deploy.sh [--yes] [--dry-run] [--no-post] [env]"}
env_name=lance.blueassume_yes=""dry_run=""post=1
while [ $# -gt 0 ]; do case "$1" in -y | --yes) assume_yes=1 ;; -n | --dry-run) dry_run=1 ;; --no-post) post="" ;; -h | --help) usage exit 0 ;; -*) echo "unknown option: $1" >&2 usage >&2 exit 2 ;; *) env_name="$1" ;; esac shiftdone
cd "$(dirname "$0")/.."
env_dir="envs/$env_name"[ -d "$env_dir" ] || { echo "no such environment: infra/$env_dir" >&2 exit 2}
for tool in tofu aws jq curl; do command -v "$tool" >/dev/null || { echo "$tool is not installed." >&2 exit 1 }done
require_aws_credentials
# Resolved here rather than after the apply: finding out the credential is# missing once the deploy is already live helps nobody.## secret-tool is libsecret's CLI and needs a Secret Service on the session -# gnome-keyring or KWallet. Over ssh or on a bare window manager there may be# none, which is what ATP_APP_PASSWORD is for.keyring_service="${ATP_SECRET_SERVICE:-lance.blue}"keyring_key="${ATP_SECRET_KEY:-atp-app-password}"
if [ -n "$post" ] && [ -z "${ATP_APP_PASSWORD:-}" ] && command -v secret-tool >/dev/null; then # A miss and a locked keyring both come back empty; neither is fatal. ATP_APP_PASSWORD="$(secret-tool lookup \ service "$keyring_service" key "$keyring_key" 2>/dev/null || true)"fi
if [ -n "$post" ] && [ -z "${ATP_APP_PASSWORD:-}" ]; then echo "No app password in the keyring or the environment - deploying without posting." >&2 echo " secret-tool store --label='lance.blue app password' \\" >&2 echo " service $keyring_service key $keyring_key" >&2 post=""fi
# Tagged onto every resource, so a running resource says which commit planned# it. Absent outside a checkout; the variable's default handles that.plan_args=()commit="$(git rev-parse --short=12 HEAD 2>/dev/null || true)"[ -z "$commit" ] || plan_args+=(-var "git_commit=$commit")
# What is live now. Read before the plan, because after the apply it is gone -# and the pair is the whole basis for deciding there is something to post.before="$(tofu -chdir="$env_dir" output -json releases 2>/dev/null || true)"
# Plans can embed resolved variable values, so the file lives outside the repo# and does not outlive the run.plan_file="$(mktemp)"trap 'rm -f "$plan_file"' EXIT
rc=0tofu -chdir="$env_dir" plan -detailed-exitcode -out="$plan_file" \ ${plan_args[@]+"${plan_args[@]}"} || rc=$?
case "$rc" in 0) echo echo "No changes. Nothing to apply, nothing to post." exit 0 ;; 2) ;; # changes to make *) exit "$rc" ;;esac
if [ -n "$dry_run" ]; then echo echo "Dry run: planned only, nothing applied." exit 0fi
if [ -z "$assume_yes" ]; then echo printf 'Apply this plan to %s? [y/N] ' "$env_name" # Nothing on stdin - piped, or a cron that forgot --yes - is a no, and says # so. Without the fallback, set -e would end the run with no explanation. read -r reply || reply="" case "$reply" in y | Y | yes | Yes) ;; *) echo "Aborted." exit 1 ;; esacfi
tofu -chdir="$env_dir" apply "$plan_file"
[ -n "$post" ] || exit 0
# Only three outputs are read, and all three are public knowledge: the release# ids, and the two addresses anyone can already visit. Nothing that describes# the account's insides - bucket names, the instance id, security group ids -# goes anywhere near a public feed. Widening this is a deliberate act.after="$(tofu -chdir="$env_dir" output -json releases)"
if [ -z "$before" ]; then echo echo "No releases output in state before this apply, so there is nothing to" echo "compare against. The next deploy posts." exit 0fi
changes="$(release_changes "$before" "$after")"
if [ -z "$changes" ]; then echo echo "Applied. No release id moved, so nothing is posted." exit 0fi
changed="$(release_summary "$changes")"
# What is in the deploy, read out of the sibling checkouts: every release id# ends in the commit it was built from, so the pair either side of an apply is# a commit range. A checkout that is missing or behind is skipped silently -# the post is better without a changelog than held up for one.changelog="$(build_changelog "$(repos_root)" "$changes")"
# A distinct address per deploy, so each post is a link of its own to whatever# counts them. Readers never see it: a card prints the domain, not the query.site_url="$(tofu -chdir="$env_dir" output -raw site_url)"link="$(stamped_link "$site_url" "$(date -u +%Y%m%d%H%M%S)")"
identifier="${ATP_IDENTIFIER:-lance.blue}"# The account lives on somebody else's PDS - this repo hosts no identity. Set# ATP_PDS if it ever moves off bsky.social.pds="${ATP_PDS:-https://bsky.social}"
# The password is handed to jq through the environment rather than as an# argument, so it never appears in the process list.export ATP_APP_PASSWORDsession="$(jq -n --arg id "$identifier" \ '{ identifier: $id, password: env.ATP_APP_PASSWORD }' | curl -fsS --max-time 30 -X POST \ "$pds/xrpc/com.atproto.server.createSession" \ -H 'content-type: application/json' --data-binary @-)"
did="$(jq -r .did <<<"$session")"jwt="$(jq -r .accessJwt <<<"$session")"
# Bluesky unfurls nothing on its own: a post shows a card because the client# that wrote it put one in the record. So read the site's own og: tags - the# same ones every other card fetcher reads - and build the card here.## Anything missing means no card, and then the address goes back in the text# as a plain link. A post announcing a deploy has to link to it.embed='{}'fallback_link="$link"
page="$(curl -fsS --max-time 30 -A 'lance.blue deploy' "$link" || true)"card_title="$(og_meta "$page" og:title)"card_description="$(og_meta "$page" og:description)"card_image="$(og_meta "$page" og:image)"
if [ -n "$card_title" ]; then thumb=null if [ -n "$card_image" ]; then card_tmp="$(mktemp)" trap 'rm -f "$plan_file" "$card_tmp"' EXIT # The type comes from the response rather than the extension, and the size # from the file: a blob over a megabyte is refused by the PDS, and a card # without a picture is better than a deploy that ends in an error. card_type="$(curl -fsS --max-time 30 -o "$card_tmp" -w '%{content_type}' "$card_image" || true)" if [ -n "$card_type" ] && [ "$(wc -c <"$card_tmp")" -le 1000000 ]; then thumb="$(curl -fsS --max-time 60 -X POST \ "$pds/xrpc/com.atproto.repo.uploadBlob" \ -H "authorization: Bearer $jwt" \ -H "content-type: $card_type" \ --data-binary @"$card_tmp" | jq -c .blob)" else echo "Card image is missing or too large; posting the card without it." >&2 fi fi
embed="$(jq -n --arg uri "$link" --arg title "$card_title" \ --arg description "$card_description" --argjson thumb "${thumb:-null}" ' { embed: { "$type": "app.bsky.embed.external", external: ({ uri: $uri, title: $title, description: $description } + (if $thumb == null then {} else { thumb: $thumb } end)) } }')" fallback_link=""else echo "No og: tags on $site_url; posting a plain link instead of a card." >&2fi
# The first post, and the thread under it when the changelog does not fit.build_post "$changed" "$changelog" "$fallback_link"
# Bluesky does not linkify anything on its own either: a URL is a link only# when a facet names the byte range it occupies. Bytes, not characters - hence# wc -c. Only the fallback needs this; a card carries its own address.facets='[]'if [ -n "$fallback_link" ]; then case "$ROOT_TEXT" in *"$fallback_link"*) prefix="${ROOT_TEXT%%"$fallback_link"*}" start="$(printf '%s' "$prefix" | wc -c | tr -d ' ')" end="$((start + $(printf '%s' "$fallback_link" | wc -c | tr -d ' ')))" facets="$(jq -n --arg u "$fallback_link" --argjson s "$start" --argjson e "$end" ' [{ index: { byteStart: $s, byteEnd: $e }, features: [{ "$type": "app.bsky.richtext.facet#link", uri: $u }] }]')" ;; esacfi
post_uri=""post_cid=""
# One post. $extra is whatever this one carries beyond its text - a card, a# facet, the refs that make it a reply.create_post() { local text="$1" extra="$2" created created="$(jq -n --arg did "$did" --arg text "$text" \ --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson extra "$extra" ' { repo: $did, collection: "app.bsky.feed.post", record: { "$type": "app.bsky.feed.post", text: $text, createdAt: $now, langs: ["en"] } + $extra }' | curl -fsS --max-time 30 -X POST \ "$pds/xrpc/com.atproto.repo.createRecord" \ -H "authorization: Bearer $jwt" \ -H 'content-type: application/json' --data-binary @-)" post_uri="$(jq -r .uri <<<"$created")" post_cid="$(jq -r .cid <<<"$created")"}
echoecho "$ROOT_TEXT"for chunk in ${CHUNKS[@]+"${CHUNKS[@]}"}; do echo echo "--" echo "$chunk"done# The address is in the card rather than the text now, so say what is going# out - it is the one part of the post that is not on the screen already.if [ -z "$fallback_link" ]; then echo echo "Card: $card_title <$link>"fiechoecho "Posting as $identifier..."
create_post "$ROOT_TEXT" "$(jq -n --argjson facets "$facets" --argjson embed "$embed" ' (if ($facets | length) > 0 then { facets: $facets } else {} end) + $embed')"
root_uri="$post_uri"root_cid="$post_cid"parent_uri="$post_uri"parent_cid="$post_cid"
# A chain rather than a fan: each post replies to the one before it, so the# thread reads in order wherever it is opened.for chunk in ${CHUNKS[@]+"${CHUNKS[@]}"}; do create_post "$chunk" "$(jq -n \ --arg ru "$root_uri" --arg rc "$root_cid" \ --arg pu "$parent_uri" --arg pc "$parent_cid" ' { reply: { root: { uri: $ru, cid: $rc }, parent: { uri: $pu, cid: $pc } } }')" parent_uri="$post_uri" parent_cid="$post_cid"done
echo "Posted: https://bsky.app/profile/$identifier/post/${root_uri##*/}"[ "${#CHUNKS[@]}" -eq 0 ] || echo " and ${#CHUNKS[@]} more in the thread."