#!/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.blue assume_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 shift done 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=0 tofu -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 0 fi 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 ;; esac fi 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 0 fi changes="$(release_changes "$before" "$after")" if [ -z "$changes" ]; then echo echo "Applied. No release id moved, so nothing is posted." exit 0 fi 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_PASSWORD session="$(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." >&2 fi # 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 }] }]')" ;; esac fi 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")" } echo echo "$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>" fi echo echo "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."