#!/usr/bin/env bash
# The release, from a green head to the published assets and the AUR, in one command.
#
#   scripts/release.sh VERSION [--dry-run] [--resume] [--skip-ci]
#   scripts/release.sh VERSION --ext-version
#   scripts/release.sh VERSION --notes
#   scripts/release.sh --selftest
#
# A patch release of 4.2 or later moves the extension version with it (pg_sift.control, SIFT_MODULE_VERSION, an empty update script into it and an empty one back to the minor, so that
# the next minor needs the one script of its own): `scripts/release.sh VERSION --ext-version` makes that commit on the tree to release; the preflight stops on a tree without it.
# It does not run the test matrix again: GitHub CI is the gate. In this order, stopping at the first failure:
#   vocabulary the private wording vocabulary is readable (NEUTRAL_VOCAB_FILE, or the text in NEUTRAL_VOCAB): without it the release stops, a dry run too
#   preflight  clean tree on main, equal to origin/main; the notes (docs/releases/vVERSION.md), CHANGELOG, control and SQL scripts are in place
#   checks     at once: ci (a green run of the workflow `ci` with "Full scope" on any ref whose commit has HEAD's build inputs: waited for when it runs,
#              dispatched when none exists), upgrade (the previous release's library writes a BM25 index, a sift_col index and a sift_table, this
#              tree takes them over, ALTER EXTENSION UPDATE, verify, REINDEX; then the previous library reads what this one writes in the previous
#              column format), package (make install DESTDIR, the files, packaging/smoke.sql from the first and from the last version)
#   bump       CHANGELOG, sqlite/mariadb CHANGELOG and VERSION, PKGBUILD, README badge, install lines, and the figure waivers of Unreleased in scripts/bench-cases.allow (they go
#              with the section): one commit, after a check that every place agrees
#   lint       scripts/lint.sh on the bumped tree, what the push CI of main runs on the release commit, with the vocabulary required (SIFT_NEUTRAL_REQUIRED=1,
#              which the push CI cannot do: a name it lets pass stops the release here): a red one stops the release before the tag
#   publish    push main and the tag, watch the workflow `release`, verify the assets (names, SHA256SUMS)
# A step that passed is in $RELEASE_STATE/VERSION/manifest (default $XDG_STATE_HOME/pg_sift-release): --resume skips it. --dry-run does it all
# on a scratch clone and pushes, tags and publishes nothing (the AUR is cloned over https, the diff shown); a failed run says what to run.
# --skip-ci leaves out the ci check only, for a release whose CI the person releasing accepts as it is (no full-scope run, or a lighter one): it prints
#   ci: SKIPPED by --skip-ci (no green full-scope run required), records it in the manifest (--resume and the last line show it), and every other check runs.
# Environment: NEUTRAL_VOCAB_FILE or NEUTRAL_VOCAB (the wording vocabulary, required), RELEASE_TMP (scratch, default /dev/shm), SIFT_PORT (first port of two, default 56210), AUR_SSH_KEY, SSH_ASKPASS (release-aur.sh).
set -uo pipefail

REPO=$(cd "$(dirname "$0")/.." && pwd)
VERSION='' DRY='' RESUME='' SKIPCI='' SELFTEST='' EXTVER='' NOTES=''
for a in "$@"; do
	case $a in --dry-run) DRY=1 ;; --resume) RESUME=1 ;; --skip-ci) SKIPCI=1 ;; --selftest) SELFTEST=1 ;; --ext-version) EXTVER=1 ;; --notes) NOTES=1 ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; -*) sed -n '2,20p' "$0" >&2; exit 2 ;; *) VERSION=$a ;; esac
done
die() { echo "release: $*" >&2; exit 1; }
# A tag is never cut without the wording gate: a vocabulary that is not readable stops the release (a dry run too: one that passes is one that can be released), and the lint of
# the release commit requires it. The CI of the repository has no such vocabulary and checks only the structure of the wording.
vocabulary() { [[ -n ${NEUTRAL_VOCAB:-} ]] || { [[ -n ${NEUTRAL_VOCAB_FILE:-} && -r $NEUTRAL_VOCAB_FILE ]]; }; }
# the extension version of a release: its MAJOR.MINOR for the first (and for every patch of a minor before 4.2, whose patch releases kept it, or carried SQL: MAJOR.MINOR.N), MAJOR.MINOR.PATCH
# for a patch release of 4.2 or later: ext_version_ok CONTROL_VERSION MAJOR.MINOR PATCH
before_42() { [[ $1 != 4.2 && $(printf '%s\n' "$1" 4.2 | sort -V | head -n 1) == "$1" ]]; }
# an extension at FROM reaches TO by its own script, or, for a patch version (4.2.2), by the script back to its minor (4.2.2--4.2) and the one from the minor (4.2--4.3):
# update_path_ok SQLDIR FROM TO
update_path_ok() {
	local d=$1 from=$2 to=$3 minor=${2%.*}
	[[ $from == "$to" || -f $d/pg_sift--$from--$to.sql ]] && return 0
	[[ $from == *.*.* && -f $d/pg_sift--$from--$minor.sql && -f $d/pg_sift--$minor--$to.sql ]]
}
ext_version_ok() {
	(( $3 == 0 )) && { [[ $1 == "$2" ]]; return; }
	before_42 "$2" && { [[ $1 == "$2" || $1 == "$2".[0-9]* ]]; return; }
	[[ $1 == "$2.$3" ]]
}
# the sections of the notes that read the same in every patch release (Packages, SQLite extension, MariaDB extension): one template (scripts/release-components.tmpl), the version filled in;
# --notes appends them to docs/releases/vVERSION.md, which then holds only what is about that release (the sections of a published release stay as they were)
components() { sed "s/@VERSION@/$1/g" "$REPO/scripts/release-components.tmpl"; }
notes_components() {
	local f=$REPO/docs/releases/v$VERSION.md
	[[ -f $f ]] || die "write the part of the notes that is about $VERSION first: $f"
	! grep -q '^## Packages' "$f" || die "$f has the Packages, SQLite and MariaDB sections already"
	{ echo; components "$VERSION"; } >>"$f"
	echo "release: the Packages, SQLite extension and MariaDB extension sections of $VERSION are appended to docs/releases/v$VERSION.md"
}
sed_file() { sed "$1" "$2" >"$2.new" && mv "$2.new" "$2"; }   # not sed -i: BSD sed reads the expression as the backup suffix
# the commit of --ext-version: the update script from the control's version into VERSION, the one back to MAJOR.MINOR, the control and the library's version string
ext_bump() {
	local from to=$VERSION hdr
	(( PATCH >= 1 )) || die "the extension version of $VERSION is $MM: only a patch release moves it"
	! before_42 "$MM" || die "the $MM line keeps its extension version"
	[[ -z $(git -C "$REPO" status --porcelain) ]] || die "the tree is not clean"
	from=$(sed -n "s/^default_version *= *'\(.*\)'/\1/p" "$REPO/pg_sift.control")
	[[ $from != "$to" ]] || die "pg_sift.control is at $to already"
	hdr() { printf '\\echo Use "ALTER EXTENSION pg_sift UPDATE TO '"'%s'"'" to load this file. \\quit\n\n-- %s\n' "$1" "$2"; }
	hdr "$to" "The patch release $to changes no SQL object: this script only moves the extension version." >"$REPO/sql/pg_sift--$from--$to.sql"
	hdr "$MM" "From a patch release back to $MM, so that the script of the next minor (from $MM) serves every patch release before it." >"$REPO/sql/pg_sift--$to--$MM.sql"
	sed_file "s/^default_version *=.*/default_version = '$to'/" "$REPO/pg_sift.control"
	sed_file "s/^\(#define SIFT_MODULE_VERSION[[:space:]]*\)\"[^\"]*\"/\1\"$to\"/" "$REPO/src/core/ops.h"
	git -C "$REPO" add pg_sift.control src/core/ops.h "sql/pg_sift--$from--$to.sql" "sql/pg_sift--$to--$MM.sql"
	git -C "$REPO" commit -q -m "release: $to: the extension version" && echo "release: the extension version is $to: sql/pg_sift--$from--$to.sql and sql/pg_sift--$to--$MM.sql, committed"
}
lint_tree() { ( cd "$1" && GITHUB_BASE_REF=main SIFT_NEUTRAL_REQUIRED=1 scripts/lint.sh ); }
# The waivers of the figure lines of Unreleased (`figure LINE<TAB>reason` in scripts/bench-cases.allow) go with the section: the bump moves those lines into history, which the claims check does not
# read, so a waiver left would fail the lint of the release commit ("matches no line that states a figure"). Other lines of the allow list stay.
drop_figure_waivers() { awk '!/^figure /' "$1" >"$1.new" && mv "$1.new" "$1"; }   # not sed -i: BSD sed reads the expression as the backup suffix
# the build inputs of the shipped files: a CI run whose commit differs from HEAD in none of them is evidence for HEAD (notes, versions and docs are not inputs;
# neither is the allow list of the figure waivers, which the bump itself edits and the lint of the bumped tree reads)
GUARDED=(src test scripts Makefile pg_sift.control 'sql/pg_sift--*.sql' sqlite mariadb ':!*.md' ':!*/VERSION' ':!scripts/release*' ':!scripts/bench-cases.allow')
selftest() {
	local bad=0 v
	t=$(mktemp -d) && trap 'rm -rf "$t"' EXIT   # global: the trap runs after the function
	printf '[denylist]\nx\n' >"$t/voc"
	chk() { local want=$1 what=$2; shift 2; if "$@"; then [[ $want == pass ]] || { echo "FAIL: $what"; bad=1; }; else [[ $want == fail ]] || { echo "FAIL: $what"; bad=1; }; fi; }
	chk fail "no vocabulary is none" env -u NEUTRAL_VOCAB -u NEUTRAL_VOCAB_FILE bash -c "$(declare -f vocabulary); vocabulary"
	chk fail "an unreadable file is none" env -u NEUTRAL_VOCAB NEUTRAL_VOCAB_FILE="$t/missing" bash -c "$(declare -f vocabulary); vocabulary"
	chk pass "a readable file is one" env -u NEUTRAL_VOCAB NEUTRAL_VOCAB_FILE="$t/voc" bash -c "$(declare -f vocabulary); vocabulary"
	chk pass "the text of a vocabulary is one" env -u NEUTRAL_VOCAB_FILE NEUTRAL_VOCAB="$(cat "$t/voc")" bash -c "$(declare -f vocabulary); vocabulary"
	# the extension version of a release: the minor for its first release; for a patch release of 4.2 or later the version of the release, before 4.2 the minor or a patch of it (an update
	# script goes into it); not another minor, a prefix of the digits, or a non-number
	local c mm n want
	while IFS=' ' read -r c mm n want; do
		chk "$want" "ext_version_ok $c $mm $n" bash -c "$(declare -f before_42 ext_version_ok); ext_version_ok \"\$1\" \"\$2\" \"\$3\"" _ "$c" "$mm" "$n"
	done <<<"4.0 4.0 0 pass
4.0.3 4.0 3 pass
4.0.12 4.0 12 pass
4.0 4.0 3 pass
4.1 4.1 4 pass
4.0.3 4.0 0 fail
4.1 4.0 3 fail
4.00 4.0 3 fail
4.01 4.0 3 fail
4.0. 4.0 3 fail
4.0.x 4.0 3 fail
4 4.0 3 fail
4.2 4.2 0 pass
4.2 4.2 1 fail
4.2.1 4.2 1 pass
4.2.1 4.2 2 fail
4.2.2 4.2 1 fail
4.2.12 4.2 12 pass
4.2.1 4.2 12 fail
4.2.1 4.2 0 fail
4.3.1 4.3 1 pass
4.3 4.3 1 fail
4.10.2 4.10 2 pass
4.10 4.10 2 fail
5.0.1 5.0 1 pass"
	# --ext-version: the update script into the version and the one back to the minor, the control and the library's version string, one commit; not for a line before 4.2, a minor or a dirty tree
	local g=$t/ext gid=(GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t)
	mkdir -p "$g/scripts" "$g/src/core" "$g/sql" && cp "$0" "$g/scripts/release.sh"
	printf "default_version = '4.2'\n" >"$g/pg_sift.control"; printf '#define SIFT_MODULE_VERSION\t\t"4.2"\n' >"$g/src/core/ops.h"; touch "$g/sql/pg_sift--4.1--4.2.sql"
	( cd "$g" && git init -q && git add -A && env "${gid[@]}" git commit -q -m base )
	ext() { ( cd "$g" && env "${gid[@]}" scripts/release.sh "$@" ) >"$t/ext.out" 2>&1; }
	ext 4.2.1 --ext-version
	chk pass "--ext-version 4.2.1 commits the scripts, the control and the library's version" bash -c 'cd "$1" && grep -qx "default_version = .4.2.1." pg_sift.control && grep -q "SIFT_MODULE_VERSION.*\"4.2.1\"" src/core/ops.h &&
		[[ $(head -n 1 sql/pg_sift--4.2--4.2.1.sql) == *"UPDATE TO '"'4.2.1'"'\" to load this file."* && $(head -n 1 sql/pg_sift--4.2.1--4.2.sql) == *"UPDATE TO '"'4.2'"'\" to load"* ]] &&
		[[ $(git log -1 --format=%s) == "release: 4.2.1: the extension version" && -z $(git status --porcelain) ]]' _ "$g"
	ext 4.2.2 --ext-version
	chk pass "--ext-version 4.2.2 goes on from 4.2.1" bash -c 'cd "$1" && [[ -f sql/pg_sift--4.2.1--4.2.2.sql && -f sql/pg_sift--4.2.2--4.2.sql ]]' _ "$g"
	chk pass "a patch version reaches the next minor by its way back to its minor, a minor needs its own script" bash -c "$(declare -f update_path_ok)"'; d=$1; touch "$d/pg_sift--4.2--4.3.sql" "$d/pg_sift--4.2.2--4.2.sql"
		update_path_ok "$d" 4.2.2 4.3 && update_path_ok "$d" 4.3 4.3 && ! update_path_ok "$d" 4.2.2 4.4 && ! update_path_ok "$d" 4.1 4.3' _ "$g/sql"
	ext 4.3.0 --ext-version
	chk pass "a minor has no extension version of its own to move" grep -q "only a patch release" "$t/ext.out"
	ext 4.1.9 --ext-version
	chk pass "a line before 4.2 keeps its extension version" grep -q "keeps its extension version" "$t/ext.out"
	echo dirty >"$g/dirty"; ext 4.2.3 --ext-version
	chk pass "--ext-version stops on a tree that is not clean" grep -q "not clean" "$t/ext.out"
	# the release itself, without a vocabulary: it stops at once, before any scratch clone or state exists
	v=$(env -u NEUTRAL_VOCAB -u NEUTRAL_VOCAB_FILE RELEASE_STATE="$t/state" RELEASE_TMP="$t" SIFT_RELEASE_SCOPED=1 "$0" 9.9.9 --dry-run 2>&1); local rc=$?
	chk pass "a dry run without a vocabulary exits 1 and says why" bash -c '[[ $1 == 1 && $2 == *vocabulary* && ! -e $3/state ]]' _ "$rc" "$v" "$t"
	# the lint of the release commit runs with the vocabulary required, and the vocabulary reaches it
	mkdir -p "$t/tree/scripts"
	printf '#!/usr/bin/env bash\n[[ ${SIFT_NEUTRAL_REQUIRED:-} == 1 && ${GITHUB_BASE_REF:-} == main && -n ${NEUTRAL_VOCAB_FILE:-} ]]\n' >"$t/tree/scripts/lint.sh"; chmod +x "$t/tree/scripts/lint.sh"
	chk pass "the lint of the release commit requires the vocabulary" env NEUTRAL_VOCAB_FILE="$t/voc" bash -c "$(declare -f lint_tree); lint_tree '$t/tree'"
	# the bump edits the figure waivers: a run of the tree before it is still evidence for the tree after it, and a change of a build input is not
	local r=$t/guarded
	mkdir -p "$r/src" "$r/scripts" && ( cd "$r" && git init -q && echo a >src/a.c && echo a >scripts/bench-cases.allow && git add -A && env "${gid[@]}" git commit -q -m base )
	chk pass "a change of the figure waivers leaves the build inputs as they were" bash -c 'cd "$1" && echo b >scripts/bench-cases.allow && git diff --quiet HEAD -- "${@:2}"' _ "$r" "${GUARDED[@]}"
	chk fail "a change of a source file does not" bash -c 'cd "$1" && echo b >src/a.c && git diff --quiet HEAD -- "${@:2}"' _ "$r" "${GUARDED[@]}"
	printf '# reasons\nfigure A line\twhy\nORDER BY offset\tcase x\nfigure B line\twhy\ncommit abc\tgate\n' >"$t/allow"
	drop_figure_waivers "$t/allow"
	chk pass "the bump drops the figure waivers and keeps the other lines of the allow list" bash -c '[[ $(cat "$1") == $(printf "# reasons\nORDER BY offset\tcase x\ncommit abc\tgate") ]]' _ "$t/allow"
	chk pass "the template renders the sections of 4.1.1 to 4.1.4 byte for byte" env REPO="$REPO" bash -c "$(declare -f components)"'; for v in 4.1.1 4.1.2 4.1.3 4.1.4; do [[ $(components $v) == "$(sed -n "/^## Packages/,\$p" "$REPO/docs/releases/v$v.md")" ]] || exit 1; done'
	((! bad)) || exit 1
	echo "release: selftest ok (the notes sections, the vocabulary is required for a release and its lint, the bump drops the figure waivers, the extension version of a patch release)"
}
[[ -z $SELFTEST ]] || { selftest; exit; }
[[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || { echo "release: the version is MAJOR.MINOR.PATCH" >&2; exit 2; }
MM=${BASH_REMATCH[1]}.${BASH_REMATCH[2]} PATCH=${BASH_REMATCH[3]}
[[ -z $EXTVER ]] || { ext_bump; exit; }
[[ -z $NOTES ]] || { notes_components; exit; }
vocabulary || { echo "release: no wording vocabulary: set NEUTRAL_VOCAB_FILE to a readable file (or NEUTRAL_VOCAB to its text); a release is never cut, nor a dry run passed, without the gate that reads it" >&2; exit 1; }
TAG=v$VERSION
# the whole run in 2 cores and 12 GB, whatever the caller did
if [[ -z ${SIFT_RELEASE_SCOPED:-} ]] && command -v systemd-run >/dev/null; then
	exec systemd-run --user --scope -q -p MemoryMax=12G -p MemorySwapMax=0 -p OOMPolicy=continue -p CPUQuota=200% env SIFT_RELEASE_SCOPED=1 "$0" "$@"
fi

STATE=${RELEASE_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/pg_sift-release}/$VERSION${DRY:+.dry}
MAN=$STATE/manifest PORT0=${SIFT_PORT:-56210}
CI_SKIPPED='ci: SKIPPED by --skip-ci (no green full-scope run required)'
log() { printf '[%s] %s\n' "$(date +%T)" "$*" >&2; }
soft() { if [[ -n $DRY ]]; then log "dry run, would stop: $*"; else die "$*"; fi; }	# a precondition of the real release, a note in a dry run

if [[ -f $MAN && -z $RESUME ]]; then die "$MAN exists: --resume continues that release, removing $STATE starts over"; fi
mkdir -p "$STATE"
step() { # NAME KEY CMD...: CMD, unless the manifest has the step passed with that key and this is a --resume
	local n=$1 k=$2 t=$SECONDS rc=0
	shift 2
	if [[ -n $RESUME ]] && cut -f1-3 "$MAN" 2>/dev/null | grep -qxF "$n"$'\t'pass$'\t'"$k"; then log "$n: passed before"; return 0; fi
	log "$n"
	"$@" || rc=$?
	printf '%s\t%s\t%s\t%ss\t%s\n' "$n" "$( (( rc )) && echo fail || echo pass)" "$k" $((SECONDS - t)) "$(date '+%F %T')" >>"$MAN"
	(( rc == 0 )) || log "$n failed (rc $rc); the logs are in $STATE/logs, --resume continues"
	return $rc
}

W=$(mktemp -d "${RELEASE_TMP:-$([[ -d /dev/shm ]] && echo /dev/shm || echo "${TMPDIR:-/tmp}")}/sift-release.XXXXXX") && mkdir -p "$STATE/logs" || exit 1
trap 'cp -f "$W"/*.log "$STATE/logs/" 2>/dev/null; rm -rf "$W"' EXIT
if [[ -n $DRY ]]; then R=$W/repo; git clone -q "$REPO" "$R" && git -C "$R" remote set-url origin "$(git -C "$REPO" remote get-url origin)" && git -C "$R" remote set-url --push origin no-push://dry-run; else R=$REPO; fi
g() { git -C "$R" "$@"; }
tree() { mkdir -p "$2" && git -C "$R" archive "$1" | tar -x -C "$2"; }	# REF DIR: a tree without a checkout
pgt() { local d=$1 tmp=$2 port=$3; shift 3; ( cd "$d" && SIFT_TMP=$tmp SIFT_PORT=$port scripts/pgtest.sh "$@" ); }
q() { local sock=$1 port=$2 db=$3; shift 3; psql -X -q -At -v ON_ERROR_STOP=1 -h "$sock" -p "$port" -U postgres -d "$db" "$@"; }
control_version() { sed -n "s/^default_version *= *'\(.*\)'/\1/p" "$R/pg_sift.control"; }

# ---------- preflight
preflight() {
	local notes=docs/releases/$TAG.md f pm ext
	[[ -z $(g status --porcelain) ]] || { g status --short; die "the tree is not clean"; }
	g fetch -q --tags origin main
	g rev-parse -q --verify "refs/tags/$TAG" >/dev/null && [[ -z $RESUME ]] && die "$TAG exists"
	[[ $(g rev-parse --abbrev-ref HEAD) == main ]] || soft "not on main"
	# HEAD is origin/main; a resumed release may be one commit ahead (the bump) or already pushed
	[[ -n $RESUME ]] || [[ $(g rev-parse HEAD) == "$(g rev-parse origin/main)" ]] || soft "HEAD is not origin/main: push or pull first"
	[[ -f $R/$notes ]] || soft "write $notes first (copy the notes of a previous release)"
	! grep -qsE 'X\.Y\.Z|<one line|<feature>' "$R/$notes" || soft "$notes still has template text"
	[[ ! -f $R/$notes ]] || grep -q '^## Packages' "$R/$notes" || soft "$notes has no Packages section: scripts/release.sh $VERSION --notes appends it with the SQLite and MariaDB sections"
	for f in sqlite mariadb; do [[ ! -f $R/$f/VERSION ]] || [[ ! -f $R/$notes ]] || grep -qi "^## $f extension" "$R/$notes" || soft "$notes has no '$f' section"; done
	[[ -n $RESUME ]] || sed -n '/^## Unreleased/,/^## [0-9]/p' "$R/CHANGELOG.md" | grep '^- ' >/dev/null || soft "CHANGELOG.md has nothing under Unreleased"
	ext=$(control_version)
	ext_version_ok "$ext" "$MM" "$PATCH" || die "pg_sift.control default_version is $ext, not the extension version of $VERSION (from 4.2 a patch release moves it: scripts/release.sh $VERSION --ext-version)"
	[[ -f $R/sql/pg_sift--$ext.sql ]] || compgen -G "$R/sql/pg_sift--*--$ext.sql" >/dev/null || die "sql/pg_sift--$ext.sql is missing, and no update script goes into $ext"
	PREV=$(g describe --tags --abbrev=0 --match 'v*' --exclude "$TAG" HEAD) || die "no previous release tag"
	# the upgrade script from the previous release's version to this one (a patch release keeps the version unless it carries SQL)
	pm=$(g show "$PREV:pg_sift.control" | sed -n "s/^default_version *= *'\(.*\)'/\1/p")
	update_path_ok "$R/sql" "$pm" "$ext" || die "sql/pg_sift--$pm--$ext.sql is missing, and $pm has no way back to its minor and on: the upgrade from $PREV has no script"
	[[ $ext == "$MM" ]] || before_42 "$MM" || [[ -f $R/sql/pg_sift--$ext--$MM.sql ]] || die "sql/pg_sift--$ext--$MM.sql is missing: the next minor has no path from $ext"
}

# ---------- the checks
# ci: evidence is by content, not by commit: a green run of the workflow ci on any ref but a pull request (main, a dispatch of a branch, the nightly) whose "Full scope" step ran and whose
# commit has the build inputs of HEAD (a rebase merge, a revert or a docs commit after it change no input). A run in progress on such a commit is waited for; with none, one is dispatched.
check_ci() {
	local id sha st wait prev latest dispatched='' n
	[[ -z $SKIPCI ]] || { echo "$CI_SKIPPED"; return 0; }
	latest() { gh run list --workflow ci.yml --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId'; }
	while :; do
		wait=
		while IFS=$'\t' read -r id sha st; do
			g cat-file -e "$sha^{commit}" 2>/dev/null || g fetch -q origin "$sha" 2>/dev/null || continue
			g diff --quiet "$sha" HEAD -- "${GUARDED[@]}" || continue
			if [[ $st != completed ]]; then wait=$id; continue; fi
			gh run view "$id" --json jobs --jq '[.jobs[] | select(.name == "changes") | .steps[] | select(.name == "Full scope" and .conclusion == "success")] | length' | grep -qx 1 || continue
			echo "ci: run $id (${sha:0:7}) is green with full scope on the build inputs of HEAD"; return 0
		done < <(gh run list --workflow ci.yml --limit 100 --json databaseId,headSha,status,conclusion,event --jq '.[] |
			select(.event != "pull_request" and ((.status == "completed" and .conclusion == "success") or (.status != "completed" and .event != "schedule"))) | [.databaseId, .headSha, .status] | @tsv')
		if [[ -z $wait ]]; then
			[[ -z $dispatched ]] || { echo "ci: the full run is not green: gh run list --workflow ci.yml"; return 1; }
			if [[ -n $DRY ]]; then echo "ci: no green full-scope run on the build inputs of HEAD: a release dispatches one now (gh workflow run ci.yml --ref main)"; return 0; fi
			[[ $(g rev-parse HEAD) == "$(g rev-parse origin/main)" ]] || { echo "ci: no green full-scope run, and HEAD is not origin/main to dispatch one"; return 1; }
			prev=$(latest) && gh workflow run ci.yml --ref main >/dev/null || return 1
			dispatched=1
			for n in $(seq 36); do sleep 5; wait=$(latest); [[ $wait != "$prev" ]] && break; done
		fi
		echo "ci: waiting for run $wait"; gh run watch "$wait" --exit-status >/dev/null 2>&1 || echo "ci: run $wait did not pass"
	done
}

DATA="CREATE EXTENSION pg_sift;
	CREATE TABLE docs (id int PRIMARY KEY, g int, n int, body text);
	INSERT INTO docs SELECT i, i % 9, i % 100, 'alpha' || i % 5 || ' beta' || i % 11 FROM generate_series(1, 30000) i;
	CREATE INDEX docs_idx ON docs USING sift (body);
	CREATE INDEX docs_col ON docs USING sift_col (id, g, n);
	CREATE TABLE tbl (id int, g int, n int) USING sift_table;
	INSERT INTO tbl SELECT i, i % 9, i % 100 FROM generate_series(1, 30000) i;
	VACUUM docs; VACUUM tbl;"
ANSC="SET sift.enable_agg = on; SET cpu_tuple_cost = 0.5;
	SELECT g, count(*), sum(n) FROM docs GROUP BY g ORDER BY g;
	SELECT g, count(*), sum(n) FROM tbl GROUP BY g ORDER BY g;
	SELECT count(*) FROM tbl WHERE id % 7 = 3;"
ANS="SELECT count(*) FROM docs WHERE body @@> 'alpha3 beta4';
	SELECT count(*), sum(n) FROM docs WHERE body @@> 'beta7 NOT alpha1';
	$ANSC"
check_upgrade() {
	local U=$W/upgrade sock=$W/upgrade/sock port=$PORT0 a b ver min
	export SIFT_TESTING=
	tree "$PREV" "$W/old"; tree HEAD "$W/new"
	pgt "$W/old" "$U" $port install >/dev/null && pgt "$W/old" "$U" $port start >/dev/null || return 1
	q "$sock" $port sift <<<"$DATA"
	a=$(q "$sock" $port sift <<<"$ANS"); pgt "$W/old" "$U" $port stop >/dev/null
	# this tree's files replace the previous release's in the same cluster
	pgt "$W/new" "$U" $port install >/dev/null && pgt "$W/new" "$U" $port start >/dev/null || return 1
	q "$sock" $port sift -c "ALTER EXTENSION pg_sift UPDATE"
	ver=$(q "$sock" $port sift -c "SELECT extversion FROM pg_extension WHERE extname = 'pg_sift'")
	[[ $ver == "$(control_version)" ]] || { echo "extversion is $ver, not $(control_version)"; return 1; }
	[[ $(q "$sock" $port sift <<<"$ANS") == "$a" ]] || { echo "the answers changed by the update"; return 1; }
	q "$sock" $port sift -c "SELECT sift.verify('docs_idx', true), sift.col_verify('docs_col', true)" -c "REINDEX INDEX docs_idx" -c "REINDEX INDEX docs_col" >/dev/null
	[[ $(q "$sock" $port sift <<<"$ANS") == "$a" ]] || { echo "the answers changed by REINDEX"; return 1; }
	# the format the previous release reads: the column format this library writes when asked (the BM25 segment format has no such
	# setting: the previous release refuses a newer one by design, so the BM25 index is dropped first).
	# The setting exists once a function of the library ran in the session.
	min=$(q "$sock" $port sift <<<"SELECT sift.verify('docs_idx', true); SELECT min_val FROM pg_settings WHERE name = 'sift.col_format_minor'" | tail -1) || true
	if [[ -n $min ]]; then
		# server-wide, as docs/UPGRADING.md "Going back" does: the background worker of sift.col_auto_merge (on by default since 3.3) rewrites the index
		# with the setting of its own process, not the session's, and its default would put a section the previous library cannot read into it
		q "$sock" $port sift -c "ALTER SYSTEM SET sift.col_format_minor = $min" -c "SELECT pg_reload_conf()" >/dev/null
		q "$sock" $port sift <<<"DROP INDEX docs_idx; SET sift.col_format_minor = $min; REINDEX INDEX docs_col; INSERT INTO docs SELECT i, i % 9, i % 100, 'x' FROM generate_series(30001, 30500) i; VACUUM docs"
		q "$sock" $port sift <<<"SELECT sift.col_verify('docs_col', true)" >/dev/null
		b=$(q "$sock" $port sift <<<"$ANSC"); pgt "$W/new" "$U" $port stop >/dev/null
		pgt "$W/old" "$U" $port install >/dev/null && pgt "$W/old" "$U" $port start >/dev/null || return 1
		[[ $(q "$sock" $port sift <<<"$ANSC") == "$b" ]] || { echo "$PREV answers differently on the data of the previous column format (minor $min)"; return 1; }
		q "$sock" $port sift <<<"SELECT sift.col_verify('docs_col', true)" >/dev/null
		pgt "$W/old" "$U" $port stop >/dev/null
		echo "upgrade ok: $PREV to $VERSION, and $PREV reads the column minor $min this tree writes"
	else echo "no sift.col_format_minor: the previous-format check is skipped"; pgt "$W/new" "$U" $port stop >/dev/null; fi
}
check_package() {
	local U=$W/package sock=$W/package/sock port=$((PORT0 + 10)) root=$W/package/root f from ver=$(control_version)
	tree HEAD "$W/pkg"
	( cd "$W/pkg" && nice make -j2 USE_PGXS=1 SIFT_TESTING= >/dev/null && make USE_PGXS=1 SIFT_TESTING= DESTDIR="$root" install >/dev/null ) || { echo "make install failed"; return 1; }
	for f in "$R/pg_sift.control" "$R"/sql/pg_sift--*.sql; do
		cmp "$f" "$root$(pg_config --sharedir)/extension/${f##*/}" || { echo "the install target did not preserve ${f##*/}"; return 1; }
	done
	[[ -f $root$(pg_config --pkglibdir)/pg_sift.so && ! -e $root$(pg_config --sharedir)/extension/pg_sift_test.control && ! -e $root$(pg_config --sharedir)/extension/pg_sift_helpers.control ]] || { echo "the library is missing, or a test-only extension is installed"; return 1; }
	( cd "$W/pkg" && SIFT_TMP=$U SIFT_ROOT=$root SIFT_PORT=$port scripts/pgtest.sh start >/dev/null ) || return 1
	for from in "$ver" 1.0; do
		q "$sock" $port postgres -c "CREATE DATABASE smoke_${from//./_}"
		q "$sock" $port "smoke_${from//./_}" -v from="$from" -v to="$ver" -f "$R/packaging/smoke.sql" >/dev/null || { echo "packaging/smoke.sql failed from $from"; return 1; }
	done
	( cd "$W/pkg" && SIFT_TMP=$U SIFT_PORT=$port scripts/pgtest.sh stop >/dev/null ) && echo "package ok: the installed files create $ver and update from 1.0"
}
# versions: every place that names the release agrees (VERSION: the check after the bump, where the files carry it)
check_versions() {
	local f bad=()
	ext_version_ok "$(control_version)" "$MM" "$PATCH" || bad+=("pg_sift.control")
	[[ $(sed -n 's/^pkgver=//p' "$R/packaging/PKGBUILD") == "$VERSION" ]] || bad+=("packaging/PKGBUILD pkgver")
	for f in sqlite mariadb; do [[ ! -f $R/$f/VERSION ]] || [[ $(<"$R/$f/VERSION") == "$VERSION" ]] || bad+=("$f/VERSION"); done
	grep -q "release-$VERSION-blue" "$R/README.md" || bad+=("README.md badge")
	grep -q "^version: \"$VERSION\"" "$R/packaging/pgxman.yaml" || bad+=("packaging/pgxman.yaml")
	[[ $(grep -c "\"version\": \"$VERSION\"" "$R/META.json") == 2 ]] || bad+=("META.json")
	grep -q "^## $VERSION - " "$R/CHANGELOG.md" || bad+=("CHANGELOG.md heading")
	(( ${#bad[@]} == 0 )) || { echo "not at $VERSION: ${bad[*]}"; return 1; }
}
checks() {
	local n names=(ci upgrade package) rc=0
	[[ -z $SKIPCI ]] || { log "$CI_SKIPPED"; printf 'ci\tskipped\t--skip-ci\t0s\t%s\n' "$(date '+%F %T')" >>"$MAN"; }
	( exec 9>/tmp/sift-ci.lock; flock -n 9 || { log "waiting for /tmp/sift-ci.lock"; flock 9; }
	  check_upgrade >"$W/upgrade.log" 2>&1 & p1=$!; check_package >"$W/package.log" 2>&1 & p2=$!
	  r=0; wait $p1 || r=1; wait $p2 || r=1; exit $r ) &
	local local_pid=$!
	check_ci >"$W/ci.log" 2>&1 || rc=1
	wait "$local_pid" || rc=1
	for n in "${names[@]}"; do echo "== $n"; tail -5 "$W/$n.log"; done
	return $rc
}

# ---------- the bump
bump() {
	local old date oldre f; old=$(sed -n 's/^pkgver=//p' "$R/packaging/PKGBUILD") date=$(date +%F) oldre=${old//./\\.}
	[[ $(g log -1 --format=%s) == "release: $VERSION" ]] && return 0
	sed -i "s/^## Unreleased\$/## Unreleased\n\n## $VERSION - $date/" "$R/CHANGELOG.md"
	for f in sqlite mariadb; do
		[[ -f $R/$f/VERSION ]] || continue
		echo "$VERSION" >"$R/$f/VERSION"
		# the lines under Unreleased move under the new heading; an empty section gets one line
		awk -v v="$VERSION" 'function fill() { if (!has) print "- Rebuilt with the PostgreSQL release " v ".\n"; inu = 0 }
			/^## Unreleased$/ { print; print ""; print "## " v; inu = 1; next }
			inu && /^## / { fill() }
			inu && /[^[:space:]]/ { has = 1 }
			{ print } END { if (inu) fill() }' "$R/$f/CHANGELOG.md" >"$W/changelog" && cp "$W/changelog" "$R/$f/CHANGELOG.md"
	done
	drop_figure_waivers "$R/scripts/bench-cases.allow"
	sed -i "s/^pkgver=.*/pkgver=$VERSION/; s/^pkgrel=.*/pkgrel=1/" "$R/packaging/PKGBUILD"
	sed -i "s/release-$oldre-blue/release-$VERSION-blue/" "$R/README.md"
	sed -i "s/$oldre/$VERSION/g" "$R/packaging/pgxman.yaml" "$R/META.json"
	sed -i "s/\([^0-9.]\)$oldre\([^0-9]\)/\1$VERSION\2/g" "$R/docs/USAGE.md"
	sed -i "s/^V=$oldre\$/V=$VERSION/" "$R/sqlite/README.md"
	check_versions || return 1
	g add CHANGELOG.md README.md docs/USAGE.md packaging/PKGBUILD packaging/pgxman.yaml META.json sqlite/README.md scripts/bench-cases.allow
	for f in sqlite mariadb; do [[ ! -f $R/$f/VERSION ]] || g add "$f/VERSION" "$f/CHANGELOG.md"; done
	g commit -q -m "release: $VERSION"
}

# ---------- lint of the release commit: what the push CI of main runs on it (a red lint there is a tag commit that fails by construction)
lint_release() {
	lint_tree "$R" >"$W/lint.log" 2>&1 || { grep -E '^(FAIL|[a-z-]+:)' "$W/lint.log" | head -20; echo "scripts/lint.sh fails on the bumped tree (the whole log: $W/lint.log, kept in $STATE/logs)"; return 1; }
	echo "lint ok on $(g rev-parse --short HEAD)"
}

# ---------- publish
publish() {
	if [[ -n $DRY ]]; then g tag -f -a "$TAG" -m "pg_sift $VERSION"; echo "dry run: $(g rev-parse --short HEAD) would be pushed to main and tagged $TAG"; return 0; fi
	[[ -z $(g ls-remote origin "refs/tags/$TAG") ]] || return 0
	g rev-parse -q --verify "refs/tags/$TAG" >/dev/null || g tag -a "$TAG" -m "pg_sift $VERSION"
	g push -q --atomic origin HEAD:refs/heads/main "refs/tags/$TAG"
}
workflow() {
	local id n
	[[ -z $DRY ]] || { echo "dry run: the release workflow of $TAG is not started"; return 0; }
	for n in $(seq 60); do
		id=$(gh run list --workflow release.yml --branch "$TAG" --limit 1 --json databaseId --jq '.[0].databaseId')
		[[ -z $id ]] || break
		sleep 5
	done
	[[ -n $id ]] || { echo "no release workflow run for $TAG"; return 1; }
	gh run watch "$id" --exit-status >/dev/null || { echo "the release workflow failed: gh run view $id --log-failed"; return 1; }
}
assets() { # the downloads are the files the workflow built: names with the version, every one in SHA256SUMS
	local d=$W/assets f n
	[[ -z $DRY ]] || { echo "dry run: nothing is published to download"; return 0; }
	mkdir -p "$d" && gh release download "$TAG" -D "$d" || return 1
	( cd "$d" && "$REPO/scripts/sha256.sh" -c SHA256SUMS ) || return 1
	"$REPO/scripts/package.sh" verify "$d" || { ls "$d"; return 1; }
	ls "$d"/*_"$VERSION"-*.deb "$d"/*-"$VERSION"-*.rpm >/dev/null || { echo "the packages are not those of $VERSION"; ls "$d"; return 1; }
	for f in sqlite mariadb; do
		[[ ! -f $R/$f/VERSION ]] || [[ -f $d/sift-$f-$VERSION-linux-x86_64.tar.gz ]] || { echo "the $f tarball of $VERSION is missing"; return 1; }
	done
	for n in "$d"/*.deb "$d"/*.rpm "$d"/*.tar.gz; do grep -q "$(basename "$n")" "$d/SHA256SUMS" || { echo "$(basename "$n") is not in SHA256SUMS"; return 1; }; done
}
aur() {
	[[ -z $DRY ]] && { "$R/scripts/release-aur.sh"; return; }
	# no key and a script that knows --dry-run: an older one would push
	grep -q -- --dry-run "$R/scripts/release-aur.sh" || { echo "dry run: $R/scripts/release-aur.sh has no --dry-run, skipped"; return 0; }
	env -u SSH_AUTH_SOCK -u AUR_SSH_KEY "$R/scripts/release-aur.sh" --dry-run
}

preflight
T0=$SECONDS
ci_skipped() { cut -f1-2 "$MAN" 2>/dev/null | grep -qx $'ci\tskipped'; }
[[ -z $RESUME ]] || ! ci_skipped || log "$CI_SKIPPED (recorded in the manifest)"
step checks "$(g log -1 --format=%H -- "${GUARDED[@]}")" checks || exit 1
step bump "$VERSION" bump || exit 1
step lint "$VERSION" lint_release || exit 1
step publish "$VERSION" publish || exit 1
step workflow "$VERSION" workflow || exit 1
step assets "$VERSION" assets || exit 1
step aur "$VERSION" aur || exit 1
printf 'done\t%s\t%ss\n' "$VERSION" $((SECONDS - T0)) >>"$MAN"
echo "release: $VERSION ${DRY:+(dry run) }done in $((SECONDS - T0)) s$(ci_skipped && echo ", $CI_SKIPPED")"
