#!/usr/bin/env bash # Everything that decides what a deploy post says. Sourced by # scripts/deploy.sh, which owns the network and the account; nothing in here # talks to a PDS, so scripts/lib/post.test.sh can check the awkward parts - # the commit ranges and the chunking - without one. # # A post is 300 graphemes and its record 3000 bytes, which is smaller than one # repo's commits routinely are. So the changelog rides in the first post when # it fits and becomes a thread when it does not. # What build_post produces, read by whoever sourced this: the first post, and # the thread under it. # shellcheck disable=SC2034 # scripts/deploy.sh reads both ROOT_TEXT="" CHUNKS=() # A post's limits. Graphemes are counted as characters: commit subjects here # are ASCII, where the two agree, and a locale that counts bytes instead only # makes this stricter. POST_GRAPHEMES=300 POST_BYTES=3000 # How many posts a thread may run to before the tail is summarised. A range # of hundreds of commits is a mistake somewhere - a rebuilt branch, a first # deploy after a long gap - and turning it into fifty posts helps nobody. POST_MAX="${DEPLOY_MAX_POSTS:-25}" fits() { [ "$(printf '%s' "$1" | wc -m)" -le "$POST_GRAPHEMES" ] && [ "$(printf '%s' "$1" | wc -c)" -le "$POST_BYTES" ] } # Where the sibling checkouts are. --git-common-dir rather than --show-toplevel # so this is the real repository from inside a worktree too, and LANCE_REPOS # for a deploy host that keeps them somewhere else. repos_root() { local common if [ -n "${LANCE_REPOS:-}" ]; then printf '%s' "$LANCE_REPOS" return fi common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" [ -n "$common" ] || return 0 printf '%s' "$(dirname "$(dirname "$common")")" } # Which checkout built a release. api and site are both headquarters: one # apply moving both is one range, and it is printed once. repo_for() { case "$1" in api | site) printf 'headquarters' ;; arena) printf 'arena' ;; helm) printf 'helm' ;; *) return 1 ;; esac } # Every release id ends in the commit it was built from - "main-a7b1f59e8e3b", # "mm0.51.0-sur26.4.7-main-b812ae202e32". Anything else is not a commit and # gets no changelog rather than a wrong one. release_commit() { local tail="${1##*-}" case "$tail" in *[!0-9a-f]* | "") return 1 ;; esac [ "${#tail}" -ge 7 ] || return 1 printf '%s' "$tail" } # One repo's range, as " " lines. Silent when the checkout # is missing or has not fetched the commits: a deploy is not the moment to # find out somebody's clone is behind, and the post is better without the # changelog than held up for it. changelog_for() { local dir="$1" old="$2" new="$3" [ -d "$dir" ] || return 0 [ "$old" != "$new" ] || return 0 git -C "$dir" cat-file -e "$old^{commit}" 2>/dev/null || return 0 git -C "$dir" cat-file -e "$new^{commit}" 2>/dev/null || return 0 git -C "$dir" log --no-merges --format='%h %s' "$old..$new" 2>/dev/null || true } # Pack lines into posts. Lines are never split across posts, so a post is # whole commits; a single line longer than a post is the one exception and is # cut. $chunk_cont, set by the caller, is repeated at the top of each post a # group spills into, so a reader landing mid-thread knows what they are # reading. chunk_reset() { CHUNKS=() chunk_current="" chunk_cont="" } chunk_flush() { [ -n "$chunk_current" ] || return 0 CHUNKS+=("$chunk_current") chunk_current="" } chunk_add() { local line="$1" candidate [ "$(printf '%s' "$line" | wc -m)" -le "$POST_GRAPHEMES" ] || line="${line:0:297}..." if [ -z "$chunk_current" ]; then candidate="$line"; else candidate="$chunk_current"$'\n'"$line"; fi if fits "$candidate"; then chunk_current="$candidate" return 0 fi chunk_flush if [ -n "$chunk_cont" ]; then candidate="$chunk_cont"$'\n'"$line" if fits "$candidate"; then chunk_current="$candidate" return 0 fi fi chunk_current="$line" } # The release ids that moved, as tab-separated "keyoldnew". A key # the previous state did not have has an empty old. release_changes() { jq -rn --argjson before "$1" --argjson after "$2" ' $after | to_entries[] | ($before[.key] // "") as $old | select($old != .value) | [.key, $old, .value] | @tsv' } # The human line for each of those: "site: -> ", or just the value # when there was nothing to move from. release_summary() { local key old new while IFS=$'\t' read -r key old new; do [ -n "$key" ] || continue if [ -z "$old" ]; then printf '%s: %s\n' "$key" "$new" else printf '%s: %s -> %s\n' "$key" "$old" "$new" fi done <<<"$1" } # The changelog for a set of release changes, as lines: a header per repo and # then its commits. Ranges are deduplicated, so api and site moving together # print headquarters' commits once under both names. build_changelog() { local root="$1" changes="$2" local key old new repo old_sha new_sha range local -a order=() labels=() olds=() news=() repos=() local i found while IFS=$'\t' read -r key old new; do [ -n "$key" ] || continue repo="$(repo_for "$key")" || continue old_sha="$(release_commit "$old")" || continue new_sha="$(release_commit "$new")" || continue range="$repo $old_sha $new_sha" found="" for i in "${!order[@]}"; do if [ "${order[$i]}" = "$range" ]; then labels[i]="${labels[$i]}, $key" found=1 break fi done [ -n "$found" ] && continue order+=("$range") labels+=("$key") repos+=("$repo") olds+=("$old_sha") news+=("$new_sha") done <<<"$changes" local log count for i in "${!order[@]}"; do log="$(changelog_for "$root/${repos[$i]}" "${olds[$i]}" "${news[$i]}")" [ -n "$log" ] || continue count="$(printf '%s\n' "$log" | wc -l)" printf '%s: %s commit%s in %s\n' \ "${labels[$i]}" "$count" "$([ "$count" = 1 ] || printf s)" "${repos[$i]}" printf '%s\n' "$log" done } # The changelog as posts. CHUNKS comes back holding one post per element, and # a header line starts a new group so a repo never begins at the bottom of a # post it has one line left in. build_thread() { local changelog="$1" line label chunk_reset while IFS= read -r line; do [ -n "$line" ] || continue case "$line" in *' commit'*' in '*) label="${line%%:*}" chunk_flush chunk_cont="$label (cont.)" ;; esac chunk_add "$line" done <<<"$changelog" chunk_flush # A runaway range says the truth about itself in one line rather than in # thirty posts. if [ "${#CHUNKS[@]}" -gt "$POST_MAX" ]; then local dropped=$((${#CHUNKS[@]} - POST_MAX + 1)) CHUNKS=("${CHUNKS[@]:0:$((POST_MAX - 1))}") CHUNKS+=("... and $dropped more posts of commits, not shown.") fi } # The deploy's own address. A distinct URL per deploy is a distinct link card # and a distinct row in whatever counts them; readers never see it, because a # card shows the domain and not the path. stamped_link() { local url="$1" stamp="$2" case "$url" in *\?*) printf '%s&d=%s' "$url" "$stamp" ;; *) printf '%s?d=%s' "$url" "$stamp" ;; esac } # One og: value out of a page. The tags are written by headquarters' # web/src/layouts/Base.astro, property first, and are the same tags every # other card fetcher reads - so a page that unfurls anywhere unfurls here. og_meta() { printf '%s' "$1" | grep -o "property=\"$2\" content=\"[^\"]*\"" | head -1 | sed -e 's/.*content="//' -e 's/"$//' \ -e 's/'/'"'"'/g' -e 's/"/"/g' -e 's/<//g' \ -e 's/&/\&/g' } # What the deploy says, in full. ROOT_TEXT is the first post - the release ids # that moved, and the changelog too when it fits - and CHUNKS is the thread # under it, empty when there is nothing left over. # # $link is empty when a link card carries the address, which is the ordinary # case; it is the URL when the card could not be built, so that a post which # announces a deploy still links to it. build_post() { local summary="$1" changelog="$2" link="$3" local tail="" head names candidate [ -z "$link" ] || tail=$'\n\n'"$link" head="Deployed lance.blue"$'\n\n'"$summary" # Release ids are long and four of them can move at once. When they do not # fit, the names of what moved do. if ! fits "$head$tail"; then names="$(printf '%s\n' "$summary" | cut -d: -f1 | paste -sd, - | sed 's/,/, /g')" head="Deployed lance.blue: $names" fi CHUNKS=() if [ -n "$changelog" ]; then candidate="$head"$'\n\n'"$changelog$tail" if fits "$candidate"; then ROOT_TEXT="$candidate" return 0 fi fi ROOT_TEXT="$head$tail" [ -n "$changelog" ] || return 0 build_thread "$changelog" }