From e10221cf209a40e9774d0c255e6d29ad306ac807 Mon Sep 17 00:00:00 2001
From: Austin Ziegler <aziegler@kineticcommerce.com>
Date: Tue, 23 Apr 2024 10:13:58 -0400
Subject: [PATCH] chore: Refactor maintenance recipes and docs

This is a major refactoring of version management and other tooling designed to
remove most manual steps in the maintenance documentation. The changes are
fairly extensive, but only affect maintainers. It is now assumed that *all*
version updates are done through the `just` recipes.

The documentation is probably better to read, because the *downside* to
embedding scripts in a `Justfile` is that they are harder to read.

- Changed `version` recipe to behave like the previous `version` task if
  a `VERSION` parameter is not provided, or like `set-version` if it is
  provided, while also printing *both* the pre and post version updates. Removed
  `set-version`.

- Added a `tag` recipe to create a git tag for the current image version.

- Renamed `*-set-version VERSION` to just `*` (that is, `alpine-set-version` is
  now just `alpine VERSION`, etc.).

- Removed recipes `pgtap-set-hashref` and `pgtap-remove-hashref` and integrated
  the core functionality into the `pgtap` recipe.

- Refactored the `update_package_version` private recipe to be able to handle
  grouped changes better as is required for the `pgtap` recipe. This allows
  "dependency" update rather than subtask update (this mostly reduces task
  duplication).

- Added a new `postgres` recipe to manipulate `build/pgtap/versions.json`. The
  only capability not present is *removal*, but given the pgTAP build process at
  this point (and that old versions have not yet been removed from pgTAP),
  manual removal is OK. It's still fairly limited in that `just postgres 11`
  will *clear* any attributes currently set (this is quite intentional and
  documented), so all required attributes must be set with each call.

- The `pgtap`, `postgres`, and `alpine` recipes will conditionally trigger the
  recipe `update-pgtap` (which downloads and rebuilds the pgTAP cache).

- The recipe `update-pgtap` will now call `update-do_pgtap` to regenerate the
  PostgreSQL versions clause in `scripts/do_pgtap` based on
  `build/pgtap/versions.json` after an otherwise successful build.
---
 Justfile       | 493 ++++++++++++++++++++++++++++++++-----------------
 Maintenance.md | 217 +++++++++++++++-------
 2 files changed, 469 insertions(+), 241 deletions(-)

diff --git a/Justfile b/Justfile
index 39e0f74..87d52ed 100644
--- a/Justfile
+++ b/Justfile
@@ -1,212 +1,357 @@
+version_file := justfile_directory() / "package-versions.json"
+pgtap_versions := justfile_directory() / "build/pgtap/versions.json"
 pg_prove_version := `jq -r .pg_prove.version < package-versions.json`
 pgtap_version := `jq -r .pgtap.version < package-versions.json`
 pgtap_hashref := `jq -r '.pgtap.hashref // empty' < package-versions.json`
 sqitch_version := `jq -r .sqitch.version < package-versions.json`
 alpine_version := `jq -r .alpine.version < package-versions.json`
 image_version := `jq -r .VERSION < package-versions.json`
+image_date := `jq -r .DATE < package-versions.json`
+
+[private]
+@list:
+    just --list --unsorted
 
 # Show the version as it would be from the built image
-version:
-  #!/usr/bin/env bash
+version NEW_VERSION="":
+    #! /usr/bin/env bash
 
-  set -euo pipefail
+    set -euo pipefail
 
-  declare pgtap_version pgtap_hashref
-  pgtap_version="{{ pgtap_version }}"
-  pgtap_hashref="{{ pgtap_hashref }}"
+    rv() {
+      jq -r ".$1" < {{ version_file }}
+    }
 
-  if [[ -n "${pgtap_hashref}" ]] && [[ "${pgtap_hashref}" != null ]]; then
-    pgtap_version="${pgtap_version} (${pgtap_hashref})"
-  fi
+    show() {
+      local alpine_version image_date image_version pg_prove_version \
+        pgtap_version pgtap_hashref sqitch_version
 
-  cat <<EOS
-  [gchr.io/]kineticcafe/sqitch-pgtap:{{ image_version }}
+      alpine_version="$(rv alpine.version)"
+      image_date="$(rv DATE)"
+      image_version="$(rv VERSION)"
+      pg_prove_version="$(rv pg_prove.version)"
+      pgtap_hashref="$(rv pgtap.hashref)"
+      pgtap_version="$(rv pgtap.version)"
+      sqitch_version="$(rv sqitch.version)"
 
-    alpine {{ alpine_version }}
-    sqitch (App::Sqitch) v{{ sqitch_version }}
-    pgtap ${pgtap_version}
-    pg_prove {{ pg_prove_version }}
-  EOS
+      if [[ -n "${pgtap_hashref}" ]] && [[ "${pgtap_hashref}" != null ]]; then
+        pgtap_version="${pgtap_version} (${pgtap_hashref})"
+      fi
 
-# Set new alpine version
-alpine-set-version NEW_VERSION:
-  @just _update_package_versions alpine.version {{ NEW_VERSION }}
-  @jq '.defaults.alpine = "{{ NEW_VERSION }}"' \
-    {{ justfile_directory() }}/build/pgtap/versions.json | \
-    sponge {{ justfile_directory() }}/build/pgtap/versions.json
+      cat <<EOS
+    [gchr.io/]kineticcafe/sqitch-pgtap:${image_version}
 
-# Set the new sqitch version
-sqitch-set-version NEW_VERSION:
-  @just _update_package_versions sqitch.version {{ NEW_VERSION }}
+      alpine ${alpine_version}
+      sqitch (App::Sqitch) v${sqitch_version}
+      pgtap ${pgtap_version}
+      pg_prove ${pg_prove_version}
 
-# Set the new pgtap version
-pgtap-set-version NEW_VERSION NEW_HASHREF="":
-  #!/usr/bin/env bash
+    Last updated ${image_date}
 
-  just _update_package_versions pgtap.version {{ NEW_VERSION }}
+    EOS
+    }
 
-  declare new_hashref
-  new_hashref="{{ NEW_HASHREF }}"
+    show
 
-  if [[ -n "${new_hashref}" ]]; then
-    just _update_package_versions pgtap.hashref "${new_hashref}"
-  fi
+    if [[ "{{ NEW_VERSION }}" != "" ]]; then
+      just update_package_versions VERSION {{ NEW_VERSION }}
+      echo ""
 
-# Set the new pgtap git hashref
-pgtap-set-hashref NEW_VERSION:
-  @just _update_package_versions pgtap.hashref {{ NEW_VERSION }}
+      show
+    fi
 
-# Clear the pgtap git hashref
-pgtap-remove-hashref:
-  @jq -c 'if (.pgtap | has("hashref")) then del(.pgtap.hashref) else . end' \
-    package-versions.json | sponge package-versions.json
+# Creates a git tag for the stored image version (only on main branch)
+[no-exit-message]
+@tag:
+    if [[ "$(git branch --show-current)" == main ]]; then \
+      git tag v{{ image_version }}; \
+    else \
+      echo "Must be on main branch to create tag v{{ image_version }}."; \
+    fi
 
-# Set the new pg_prove version
-pg_prove-set-version NEW_VERSION:
-  @just _update_package_versions pg_prove.version {{ NEW_VERSION }}
+# Set the new sqitch version
+sqitch NEW_VERSION: (update_package_versions "sqitch.version" NEW_VERSION)
 
-# Set the image version
-set-version NEW_VERSION:
-  @just _update_package_versions VERSION {{ NEW_VERSION }}
+# Set the new pgtap version
+pgtap NEW_VERSION NEW_HASHREF="": (update_package_versions "pgtap.version" NEW_VERSION "pgtap.hashref" NEW_HASHREF)
+    @git diff --quiet || just update-pgtap
+
+# Set the new pg_prove version
+pg_prove NEW_VERSION: (update_package_versions "pg_prove.version" NEW_VERSION)
 
-# Build the image
+# Set new alpine version
+@alpine NEW_VERSION: (update_package_versions "alpine.version" NEW_VERSION)
+    jq '.defaults.alpine = "{{ NEW_VERSION }}"' "{{ pgtap_versions }}" | \
+      sponge "{{ pgtap_versions }}"
+    git diff --quiet || just update-pgtap
+
+# Add a new PostgreSQL version
+postgres VERSION *EXTRA:
+    #!/usr/bin/env bash
+
+    set -euo pipefail
+
+    set -- {{ EXTRA }}
+
+    while (($#)); do
+      case "$1" in
+        using) using="$2" ;;
+        alpine) alpine="$2" ;;
+        eol) eol="$2" ;;
+        *)
+          echo >&2 "Unknown custom version expecting 'using', 'alpine', or 'eol'."
+          exit 1
+          ;;
+      esac
+
+      shift 2 || break
+    done
+
+    version="{{ VERSION }}"
+
+    case "${version}" in
+      =*)
+        jq --arg version "${version:1}" \
+          '.postgres[] | select(.name == $version)' \
+          "{{ pgtap_versions }}"
+        exit 0
+        ;;
+    esac
+
+    jq --arg version "${version}" \
+       --arg using "${using:-}" \
+       --arg alpine "${alpine:-}" \
+       --arg eol "${eol:-}" \
+       '
+       {name: $version} as $pg |
+       (if $using == "" then $pg else $pg + {version: $using} end) as $pg |
+       (if $alpine == "" then $pg else $pg + {alpine: $alpine} end) as $pg |
+       (if $eol == "" then $pg else $pg + {eol: $eol} end) as $pg |
+       .postgres |=
+        (map(.name) | index($version)) as $ix |
+        if $ix then .[$ix] = $pg else [$pg] + . end
+    ' "{{ pgtap_versions }}" | sponge "{{ pgtap_versions }}"
+
+    git diff --quiet || just update-pgtap
+
+# Build a test version of the image
 build:
-  #!/usr/bin/env bash
-
-  set -euo pipefail
-
-  docker build \
-    --build-arg ALPINE_VERSION="{{ alpine_version }}" \
-    --build-arg PG_PROVE_VERSION="{{ pg_prove_version }}" \
-    --build-arg PGTAP_VERSION="{{ pgtap_version }}" \
-    --build-arg SQITCH_VERSION="{{ sqitch_version }}" \
-    --build-arg __DOCKERFILE_VERSION__="{{ image_version }}" \
-    --tag kineticcafe/sqitch-pgtap:latest .
-
-# Update the pgTAP sources
-update-pgtap: _download_pgtap _generate_pgtap_dockerfile
-  #!/usr/bin/env bash
-
-  set -euo pipefail
-
-  docker build \
-    --build-arg PGTAP_VERSION="{{ pgtap_version }}" \
-    --tag build-pgtap:latest \
-    "{{ justfile_directory() }}/build/pgtap"
-  docker container create --name builder build-pgtap:latest
-  docker container cp --quiet builder:/opt/pgtap.tar "{{ justfile_directory() }}"
-  docker container rm builder
-  docker image rm build-pgtap:latest
-  tar xf pgtap.tar
-
-  rm -rf {{ justfile_directory() }}/build/pgtap/pgtap-"{{ pgtap_version }}" \
-    {{ justfile_directory() }}/build/pgtap/pgtap-"{{ pgtap_version }}".zip \
-    {{ justfile_directory() }}/pgtap.tar
-
-  git add opt/pgtap
-
-_download_pgtap:
-  #!/usr/bin/env bash
-
-  set -euo pipefail
-
-  rm -rf "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}" \
-    "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}.zip" \
-    "{{ justfile_directory() }}/pgtap.tar"
-
-  if [[ -n "{{ pgtap_hashref }}" ]]; then
-    git clone https://github.com/theory/pgtap.git \
-      "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}"
-    git -C "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}" \
-      switch --detach "{{ pgtap_hashref }}"
-  else
-    curl -sq -LO \
-      "https://api.pgxn.org/dist/pgtap/{{ pgtap_version }}/pgtap-{{ pgtap_version }}.zip"
-    unzip -qq -d "{{ justfile_directory() }}/build/pgtap" "pgtap-{{ pgtap_version }}.zip"
-  fi
-
-_generate_pgtap_dockerfile:
-  #!/usr/bin/env ruby
-
-  require 'json'
-
-  versions = JSON.load_file("{{ justfile_directory() }}/build/pgtap/versions.json")
-
-  blocks = versions["postgres"].map { |pg_version|
-    name = pg_version.fetch("name")
-    version = pg_version.fetch("version") { name }
-    alpine = pg_version.fetch("alpine") { versions.dig("defaults", "alpine") }
-
-    <<~BUILD_BLOCK
-      FROM postgres:#{version}-alpine#{alpine} AS build-pgtap-psql-#{name}
-
-      ARG PGTAP_VERSION
-
-      COPY pgtap-$PGTAP_VERSION /opt/pgtap-$PGTAP_VERSION
-
-      RUN <<SETUP
-      set -eux
-
-      apk update
-
-      apk add \\
-        bash \\
-        build-base \\
-        make \\
-        openssl \\
-        perl \\
-        perl-dev \\
-        postgresql-dev \\
-        wget
-
-      wanted_clang=$(
-        pg_config --configure |
-          tr ' ' '\\n' |
-          grep CLANG |
-          sed "s/'CLANG=\\(.*\\)'/\\1/"
-      )
-
-      if ! command -v "${wanted_clang}" >/dev/null 2>/dev/null; then
-        version="${wanted_clang/clang-}"
-        apk add clang"${version}" llvm"${version}"
-      fi
+    #!/usr/bin/env bash
+
+    set -euo pipefail
+
+    docker build \
+      --build-arg ALPINE_VERSION="{{ alpine_version }}" \
+      --build-arg PG_PROVE_VERSION="{{ pg_prove_version }}" \
+      --build-arg PGTAP_VERSION="{{ pgtap_version }}" \
+      --build-arg SQITCH_VERSION="{{ sqitch_version }}" \
+      --build-arg __DOCKERFILE_VERSION__="{{ image_version }}" \
+      --build-arg __DOCKERFILE_DATE__="{{ image_date }}" \
+      --tag kineticcafe/sqitch-pgtap:latest \
+      .
+
+# Update pgTAP sources
+update-pgtap: download_pgtap generate_pgtap_dockerfile && update-do_pgtap
+    #!/usr/bin/env bash
+
+    set -euo pipefail
+
+    docker build \
+      --build-arg PGTAP_VERSION="{{ pgtap_version }}" \
+      --tag build-pgtap:latest \
+      "{{ justfile_directory() }}/build/pgtap"
+    docker container create --name builder build-pgtap:latest
+    docker container cp --quiet builder:/opt/pgtap.tar "{{ justfile_directory() }}"
+    docker container rm builder
+    docker image rm build-pgtap:latest
+    tar xf pgtap.tar
+
+    rm -rf {{ justfile_directory() }}/build/pgtap/pgtap-"{{ pgtap_version }}" \
+      {{ justfile_directory() }}/build/pgtap/pgtap-"{{ pgtap_version }}".zip \
+      {{ justfile_directory() }}/pgtap.tar
+
+    git add opt/pgtap
+
+[private]
+download_pgtap:
+    #!/usr/bin/env bash
+
+    set -euo pipefail
 
-      mkdir -p /opt/pgtap/#{name}
-      SETUP
+    rm -rf "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}" \
+      "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}.zip" \
+      "{{ justfile_directory() }}/pgtap.tar"
 
-      RUN <<BUILD
-      set -eux
+    if [[ -n "{{ pgtap_hashref }}" ]]; then
+      git clone https://github.com/theory/pgtap.git \
+        "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}"
+      git -C "{{ justfile_directory() }}/build/pgtap/pgtap-{{ pgtap_version }}" \
+        switch --detach "{{ pgtap_hashref }}"
+    else
+      curl -sq -LO \
+        "https://api.pgxn.org/dist/pgtap/{{ pgtap_version }}/pgtap-{{ pgtap_version }}.zip"
+      unzip -qq -d "{{ justfile_directory() }}/build/pgtap" "pgtap-{{ pgtap_version }}.zip"
+    fi
 
-      cd /opt/pgtap-$PGTAP_VERSION
-      make
-      make install
-      mv sql/pgtap.sql sql/uninstall_pgtap.sql /opt/pgtap/#{name}
-      BUILD
-    BUILD_BLOCK
+[private]
+generate_pgtap_dockerfile:
+    #!/usr/bin/env ruby
+
+    require 'json'
 
-  }
+    versions = JSON.load_file("{{ pgtap_versions }}")
+
+    blocks = versions["postgres"].map { |pg_version|
+      name = pg_version.fetch("name")
+      version = pg_version.fetch("version") { name }
+      alpine = pg_version.fetch("alpine") { versions.dig("defaults", "alpine") }
+
+      <<~BUILD_BLOCK
+        FROM postgres:#{version}-alpine#{alpine} AS build-pgtap-psql-#{name}
+
+        ARG PGTAP_VERSION
+
+        COPY pgtap-$PGTAP_VERSION /opt/pgtap-$PGTAP_VERSION
+
+        RUN <<SETUP
+        set -eux
+
+        apk update
+
+        apk add \\
+          bash \\
+          build-base \\
+          make \\
+          openssl \\
+          perl \\
+          perl-dev \\
+          postgresql-dev \\
+          wget
+
+        wanted_clang=$(
+          pg_config --configure |
+            tr ' ' '\\n' |
+            grep CLANG |
+            sed "s/'CLANG=\\(.*\\)'/\\1/"
+        )
+
+        if ! command -v "${wanted_clang}" >/dev/null 2>/dev/null; then
+          version="${wanted_clang/clang-}"
+          apk add clang"${version}" llvm"${version}"
+        fi
+
+        mkdir -p /opt/pgtap/#{name}
+        SETUP
+
+        RUN <<BUILD
+        set -eux
+
+        cd /opt/pgtap-$PGTAP_VERSION
+        make
+        make install
+        mv sql/pgtap.sql sql/uninstall_pgtap.sql /opt/pgtap/#{name}
+        BUILD
+      BUILD_BLOCK
+    }
+
+    copy = versions["postgres"].map { |pg_version|
+      name = pg_version.fetch("name")
+      "COPY --from=build-pgtap-psql-#{name} /opt/pgtap/#{name} /opt/pgtap/#{name}"
+    }
+
+    dockerfile = <<~DOCKERFILE
+      # syntax=docker/dockerfile:1
+
+      #{blocks.join("\n\n")}
+
+      FROM alpine:{{ alpine_version }} AS package-pgtap
 
-  copy = versions["postgres"].map { |pg_version|
-    name = pg_version.fetch("name")
-    "COPY --from=build-pgtap-psql-#{name} /opt/pgtap/#{name} /opt/pgtap/#{name}"
-  }
+      RUN mkdir -p /opt/pgtap
+
+      #{copy.join("\n")}
 
-  dockerfile = <<~DOCKERFILE
-    # syntax=docker/dockerfile:1
+      RUN tar cf /opt/pgtap.tar /opt/pgtap
+    DOCKERFILE
+
+    File.write("build/pgtap/Dockerfile", dockerfile)
+
+[private]
+update-do_pgtap:
+    #!/usr/bin/env ruby
+
+    require 'json'
+
+    versions = JSON.load_file("{{ pgtap_versions }}")
+
+    clauses = versions["postgres"].sort_by { _1["name"].to_f }.map { |pg_version|
+      name = pg_version.fetch("name")
+      eol = pg_version["eol"]
+
+      if eol
+        <<~CLAUSE
+          #{name}*)
+            eol #{name} #{eol}
+            version=#{name}
+            ;;
+        CLAUSE
+      else
+        "#{name}*) version=#{name} ;;\n"
+      end
+    }.join
 
-    #{blocks.join("\n\n")}
+    case_esac = <<~CASE
+      case "${version}" in
+      #{clauses.chomp}
+      *)
+        echo >&2 "Unknown or unsupported PostgreSQL version '${version}'."
+        exit 1
+        ;;
+      esac
+    CASE
 
-    FROM alpine:{{ alpine_version }} AS package-pgtap
+    script = File
+      .read("scripts/do_pgtap")
+      .sub(/case "\$\{version\}" in.+?esac/m, case_esac.chomp)
+
+    File.write("scripts/do_pgtap", script)
+
+[private]
+update_package_versions *args:
+    #! /usr/bin/env bash
+
+    set -euo pipefail
+
+    set -- {{ args }}
+
+    update() {
+      local key value old
+      key="$1"
+      value="${2:-}"
+      old="$(jq -r ".$1" < {{ version_file }})"
+
+      jq -c --arg value "${value}" "
+        path (.${key}) as \$key |
+        if \$value == \"\" then
+          delpaths([\$key])
+        else
+          setpath(\$key; \$value)
+        end
+      " "{{ version_file }}" | sponge "{{ version_file }}"
 
-    RUN mkdir -p /opt/pgtap
+      tput setaf 1 && echo "- ${key} :: ${old}"
+      [[ "${value}" == "" ]] || {
+        tput setaf bold && tput setaf 2 && echo "+ ${key} :: ${value}"
+      }
 
-    #{copy.join("\n")}
+      tput sgr0
+    }
 
-    RUN tar cf /opt/pgtap.tar /opt/pgtap
-  DOCKERFILE
+    while (( $# )); do
+      update "$1" "${2:-}"
 
-  File.write("build/pgtap/Dockerfile", dockerfile)
+      shift 2 || break
+    done
 
-_update_package_versions key value:
-  @jq -c '.{{ key }} = "{{ value }}"' {{ justfile_directory() }}/package-versions.json | \
-    sponge {{ justfile_directory() }}/package-versions.json
+    if ! git diff --quiet; then
+      update DATE "$(date +"%Y-%m-%d")"
+    fi
diff --git a/Maintenance.md b/Maintenance.md
index 26fa6d8..70e02e7 100644
--- a/Maintenance.md
+++ b/Maintenance.md
@@ -1,107 +1,190 @@
 # kineticcafe/sqitch-pgtap Maintenance
 
 Maintenance of kineticcafe/sqitch-pgtap is fairly easy but has some points worth
-documenting.
-
-On every release, remember to update the `opt/pgtap` cache files with `just
-update-pgtap`.
+documenting. Release maintenance is managed with [casey/just][] and require
+[sponge][], [jq][], and [docker][].
 
 ## Updating Package Versions
 
-Primary package versions are updated through `package-versions.json`. `pg_prove`
-and `sqitch` can currently only be updated with releases; `pgtap` can be updated
-with download versions or git references.
+Package versions are managed in `package-versions.json` with just recipes to
+ensure that the values are correctly updated.
+
+### `just version`
+
+This recipe prints the current version of all packages, approximating what
+`kineticcafe-sqitch-pgtap version` without building or running the image.
 
-It is recommended that you use `just PACKAGE-set-version` to set new versions,
-as this will maintain the required condensed format:
+```console
+$ just version
+[gchr.io/]kineticcafe/sqitch-pgtap:2.6.1
 
-```sh
-just alpine-set-version 3.18
-just sqitch-set-version 1.4.0
-just pgtap-set-version 1.2.1
-just pgtap-set-version 1.2.1 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
-just pgtap-set-hashref 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
-just pg_prove-set-version 3.36
+  alpine 3.19
+  sqitch (App::Sqitch) v1.4.1
+  pgtap 1.3.3
+  pg_prove 3.36
+
+Last updated 2024-04-22
 ```
 
-Note that this file _must_ be condensed as it is passed as a JSON object in
-GitHub Actions:
+### `just version VERSION`
+
+This recipe prints the current version of all packages and then updates the
+image version and date.
+
+```console
+$ just version 2.6.1
+[gchr.io/]kineticcafe/sqitch-pgtap:2.6.0
+
+  alpine 3.19
+  sqitch (App::Sqitch) v1.4.1
+  pgtap 1.3.3
+  pg_prove 3.36
+
+Last updated null
+
+- VERSION :: 2.6.0
++ VERSION :: 2.6.1
+- DATE :: null
++ DATE :: 2024-04-22
+
+[gchr.io/]kineticcafe/sqitch-pgtap:2.6.1
+
+  alpine 3.19
+  sqitch (App::Sqitch) v1.4.1
+  pgtap 1.3.3
+  pg_prove 3.36
 
-```sh
-jq -c < package-versions.json | sponge package-versions.json
+Last updated 2024-04-22
 ```
 
-### Update `pg_prove`
+### `just sqitch VERSION`
 
-To update `pg_prove`, update `pg_prove.version` in `package-versions.json`. That
-version of `pg_prove` will be installed during the build of the Docker image.
+Sets the desired [Sqitch][] version to `VERSION`. Sqitch will be installed from
+[CPAN][] during the build of the Docker image.
 
-```sh
-just pg_prove-set-version 3.36
+```console
+$ just sqitch 1.4.1
+- sqitch.version :: 1.4.0
++ sqitch.version :: 1.4.1
+- DATE :: 2023-08-03
++ DATE :: 2024-02-27
 ```
 
-### Update `pgtap`
+### `just pgtap VERSION [HASHREF]`
 
-To update `pgtap`, update `pgtap.version` and/or `pgtap.hashref` in
-`package-versions.json`. If `pgtap.hashref` is set, pgTAP will be updated from
-git (from <https://github.com/theory/pgtap>). If omitted or empty, it will be
-downloaded from [PGXN][].
+Sets the desired [pgTAP][] version to `VERSION`. pgTAP will be installed from
+[PGXN][] during the refresh of the pgTAP scripts in `opt/...`.
 
-```sh
-just pgtap-set-version 1.2.1
-just pgtap-set-version 1.2.1 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
-just pgtap-set-hashref 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
+If a pre-release version is required, the full `HASHREF` of must be provided to
+use that version from <https://github.com/theory/pgtap>. Only pre-release
+commits on the `main` branch should be used.
+
+```console
+$ just pgtap 1.3.3 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
+- pgtap.version :: 1.3.2
++ pgtap.version :: 1.3.3
+- pgtap.hashref :: null
++ pgtap.hashref :: 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
+
+$ just pgtap 1.3.3
+- pgtap.version :: 1.3.3
++ pgtap.version :: 1.3.3
+- pgtap.hashref :: 96a7a416311ea5f2fa140f59cfdf7c7afbded17c
 ```
 
-The scripts and functions used by pgTAP vary by PostgreSQL version, so the
-updated versions must be cached as `pgtap.tar`, which is committed to this repo.
-This cache file can be updated with `just update-pgtap` (this requires
-[casey/just][]).
+If a version change is detected, [`just update-pgtap`](#just-update-pgtap) will
+be run.
+
+### `just pg_prove VERSION`
+
+Sets the desired [`pg_prove`][] version to `VERSION`. `pg_prove` will be
+installed from [CPAN][] during the build of the Docker image.
+
+```console
+$ just pg_prove 3.36
+- pg_prove.version :: 3.35
++ pg_prove.version :: 3.36
+```
 
-### Update `sqitch`
+### `just alpine VERSION`
 
-To update `sqitch`, update `sqitch.version` in `package-versions.json`. That
-version of `sqitch` will be installed during the build of the Docker image.
+Updates the base Docker image for use with the main `Dockerfile` and for
+`build/pgtap/versions.json` from which the pgTAP image is generated (used in
+`just update-pgtap`). Updating this version will automatically run
+[`just update-pgtap`](#just-update-pgtap).
 
-```sh
-just sqitch-set-version 1.4.0
+```console
+$ just alpine 3.19
+- alpine.version :: 3.18
++ alpine.version :: 3.19
 ```
 
-## Adding or Removing PostgreSQL Versions
+### `just postgres VERSION [using NAME] [alpine ALPINE_VERSION]`
 
-PostgreSQL versions require updating in multiple places:
+Adds or updates a PostgreSQL version specification for pgTAP in
+`build/pgtap/versions.json`. It can be used to:
 
-- `scripts/do_pgtap`: update the `version` case statement (lines 36–54) to add
-  or remove a version pattern match.
-- `build/pgtap/versions.json`:
+1. Add a new pre-release version of PostgreSQL:
 
-  - If the base version of Alpine is being changed, update `defaults.alpine`.
-    This is done automatically by `just alpine-set-version`
+   ```console
+   $ just postgres 17 using 17beta1
+   # Inserts PostgreSQL 17 beta 1 as version 17
+   ```
 
-  - Add or remove a block in the `postgres` array. The only required parameter
-    is `name`, which should be the short version name.
+2. Add a release version of PostgreSQL or update a pre-release version to
+   released:
 
-    - If an alternative (older) version of Alpine is required, set the parameter
-      `alpine` to the required version.
+   ```console
+   # just postgres 17
+   # Updates PostgreSQL 17 to use the latest release
+   ```
 
-    - When adding a beta version, add a `version` key with the required version.
-      For 17beta1, the block would look like:
+3. Force a version of PostgreSQL to a specific version of Alpine. This is
+   required when the PostgreSQL version has reached end of life and no longer
+   receives builds on newer versions of Alpine.
 
-      ```json
-      { "name": "17", "version": "17beta1" }
-      ```
+   ```console
+   $ just postgres 11 alpine 3.19
+   # Updates PostgreSQL 11 to use Alpine 3.19
+   ```
 
-The `build/pgtap/Dockerfile` will be updated automatically based on
-`build/pgtap/versions.json`.
+4. Mark a version of PostgreSQL as end of life.
 
-## Updating Docker Base Images
+   ```console
+   $ just postgres 11 eol 2023-11-06
+   # Updates PostgreSQL 11 to be considered EOL as of the provided date
+   ```
 
-Docker base images must be kept up to date, and this is managed through
-`package-versions.json`.
+If changing an already existing version, remember to include all attributes that
+should be kept or they will be erased. The current attributes can be found by
+prepending `=` to the version:
 
-```sh
-just alpine-set-version 3.18
+```console
+$ just postgres =9.6
+{
+  "name": "9.6",
+  "alpine": "3.15",
+  "eol": "2021-11-11"
+}
+$ just postgres =17
+# The output is blank because PostgreSQL 17 is not yet available.
 ```
 
-[pgxn]: https://pgxn.org/dist/pgtap
+Updating this version will automatically update `scripts/do_pgtap` and run
+[`just update-pgtap`](#just-update-pgtap).
+
+### `just update-pgtap`
+
+This recipe will _usually_ be run automatically after Alpine, PostgreSQL, or
+pgTAP changes. If a manual rebuild of the cached pgTAP artifacts is required,
+call this recipe.
+
+[`pg_prove`]: https://pgtap.org/pg_prove.html
 [casey/just]: https://github.com/casey/just
+[cpan]: https://www.cpan.org
+[docker]: https://www.docker.com
+[jq]: https://jqlang.github.io/jq/
+[pgtap]: https://pgtap.org
+[pgxn]: https://pgxn.org/dist/pgtap
+[sponge]: https://joeyh.name/code/moreutils/
+[sqitch]: https://sqitch.org