From 86e2bacf428a4385f1dec6a0dfb64ebdb562b495 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 12:57:47 +0700 Subject: [PATCH 1/2] ci(release): detect whether the latest release checksums are signed (#1327) --- scripts/release-signature-reminder.sh | 65 +++++++++++ .../tests/release-signature-reminder.test.sh | 102 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100755 scripts/release-signature-reminder.sh create mode 100644 scripts/tests/release-signature-reminder.test.sh diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh new file mode 100755 index 00000000..c02849da --- /dev/null +++ b/scripts/release-signature-reminder.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Open (and re-ping) a "Sign release (minisign)" issue while the latest +# stable release has no checksums-sha256.txt.minisig, and close it once the +# signature is on the release. Signing itself stays offline +# (scripts/sign-release.sh); this script only watches and nags, holds no key +# and reads no secret beyond the GitHub token gh already uses. +# +# scripts/release-signature-reminder.sh [--dry-run] +# +# env: GH_REPO or GITHUB_REPOSITORY (owner/name), RELEASE_SIGNER (default +# ajianaz), REMINDER_AFTER_HOURS (default 12), REMINDER_NOW_EPOCH (tests). +# bash 3.2 compatible. +set -euo pipefail + +DRY=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + *) echo "usage: $0 [--dry-run]" >&2; exit 2 ;; + esac +done + +REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}" +[ -n "$REPO" ] || { echo "error: set GH_REPO or GITHUB_REPOSITORY" >&2; exit 2; } +SIGNER="${RELEASE_SIGNER:-ajianaz}" +HOURS="${REMINDER_AFTER_HOURS:-12}" +case "$HOURS" in ''|*[!0-9]*) echo "error: REMINDER_AFTER_HOURS must be an integer" >&2; exit 2 ;; esac +HOURS=$((10#$HOURS)) # "08" is a valid number here, not a bad octal +NOW="${REMINDER_NOW_EPOCH:-$(date +%s)}" +command -v gh >/dev/null 2>&1 || { echo "error: gh is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 2; } + +# Run a mutating gh command, or only print it under --dry-run. +mutate() { + # /dev/null +} +if ! has_asset "checksums-sha256.txt.bundle"; then + echo "$TAG has no checksums-sha256.txt.bundle (not signable by sign-release.sh); nothing to do." + exit 0 +fi +SIGNED=0 +if has_asset "checksums-sha256.txt.minisig"; then SIGNED=1; fi + +if [ "$SIGNED" -eq 1 ]; then echo "$TAG is signed"; else echo "$TAG is unsigned"; fi diff --git a/scripts/tests/release-signature-reminder.test.sh b/scripts/tests/release-signature-reminder.test.sh new file mode 100644 index 00000000..a73ef448 --- /dev/null +++ b/scripts/tests/release-signature-reminder.test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Tests for scripts/release-signature-reminder.sh with a stub gh on PATH. +# Never touches the network. +# bash scripts/tests/release-signature-reminder.test.sh +# bash 3.2 compatible. +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../release-signature-reminder.sh" +command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/tags" + +# Stub: api -> $STUB_RELEASE, issue list -> $STUB_ISSUES, everything else is +# a mutation and is logged one line per call. +cat > "$T/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$1" in + api) + case "$2" in + */releases/latest) printf '%s' "$STUB_RELEASE" ;; + esac ;; + *) echo "OTHER $*" >> "$STUB_LOG" ;; +esac +STUB +chmod +x "$T/bin/gh" + +NOW=1800000000 # fixed clock +iso() { date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +H=3600 + +rel() { # tag assets-json [prerelease] [draft] + printf '{"tag_name":"%s","prerelease":%s,"draft":%s,"published_at":"%s","assets":%s}' \ + "$1" "${3:-false}" "${4:-false}" "$(iso $((NOW - 30*H)))" "$2" +} +A_BUNDLE='[{"name":"checksums-sha256.txt"},{"name":"checksums-sha256.txt.bundle"}]' +A_SIGNED='[{"name":"checksums-sha256.txt"},{"name":"checksums-sha256.txt.bundle"},{"name":"checksums-sha256.txt.minisig"}]' +A_NOBUNDLE='[{"name":"checksums-sha256.txt"}]' +FAILS=0 +# run args... ; sets RC, OUT, and $T/log +run() { + local r="$1" i="$2"; shift 2 + : > "$T/log"; rm -f "$T/log.n" + OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_TAGDIR="$T/tags" STUB_RELEASE="$r" STUB_ISSUES="$i" \ + GH_REPO=o/r RELEASE_SIGNER=ajianaz REMINDER_NOW_EPOCH=$NOW \ + STUB_ISSUES2="${STUB_ISSUES2-}" STUB_API_ERR="${STUB_API_ERR-}" \ + STUB_FAIL_ASSIGN="${STUB_FAIL_ASSIGN-}" bash "$SCRIPT" "$@" 2>&1)" + RC=$? +} +check() { # name 0|1 + if [ "$2" = 0 ]; then echo "ok - $1"; else echo "FAIL - $1 (rc=$RC)"; echo "$OUT" | sed 's/^/ /'; echo " log:"; sed 's/^/ /' "$T/log"; FAILS=$((FAILS+1)); fi +} +rc_is() { [ "$RC" -eq "$1" ]; } +nlog() { grep -c -e "$1" "$T/log" || true; } +no_mutation() { [ ! -s "$T/log" ]; } +t() { local n="$1"; shift; if "$@"; then check "$n" 0; else check "$n" 1; fi; } + +run "$(rel v1.2.3 "$A_SIGNED")" '[]' +t "signed -> exit 0, reported, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +echo "$OUT" | grep -q 'v1.2.3 is signed'; check "signed -> says so" $? +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +t "unsigned -> exit 0, reported, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +echo "$OUT" | grep -q 'v1.2.3 is unsigned'; check "unsigned -> says so" $? + + +run "$(rel v1.2.3 "$A_NOBUNDLE")" '[]' +t "no bundle -> exit 0 and nothing mutating" no_mutation +t "no bundle -> exit code 0" rc_is 0 + +for bad in 'v1.2.3; rm -rf' 'v1.2.3-rc.1' '1.2.3' 'v1.2' 'v1.2.3+b1'; do + run "$(rel "$bad" "$A_BUNDLE")" '[]' + t "bad tag '$bad' -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +done + +run "$(rel v1.2.3 "$A_BUNDLE" true)" '[]' +t "prerelease latest -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +run "$(rel v1.2.3 "$A_BUNDLE" false true)" '[]' +t "draft latest -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run +t "--dry-run unsigned -> exit 0, no mutating call" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" + + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus +t "unknown flag -> exit 2" rc_is 2 +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run --bogus +t "extra unknown flag after --dry-run -> exit 2, nothing mutating" sh -c "[ $RC -eq 2 ] && [ ! -s '$T/log' ]" +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus --dry-run +t "unknown flag before --dry-run -> exit 2" rc_is 2 +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run --dry-run +t "repeated --dry-run is harmless" no_mutation +# HOURS=08 +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_TAGDIR="$T/tags" STUB_RELEASE="$(rel v1.2.3 "$A_BUNDLE")" GH_REPO=o/r REMINDER_AFTER_HOURS=08 bash "$SCRIPT" 2>&1)"; RC=$? +t "REMINDER_AFTER_HOURS=08 accepted" rc_is 0 +OUT="$(env PATH="$T/bin:$PATH" REMINDER_AFTER_HOURS=abc GH_REPO=o/r bash "$SCRIPT" 2>&1)"; RC=$? +t "non-numeric hours -> exit 2" rc_is 2 + +if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi +echo "all passed" From 0db286810b441e9826348d208119ea98fe191aa1 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 13:08:49 +0700 Subject: [PATCH 2/2] ci(release): say in the header that this part only detects (#1327) --- scripts/release-signature-reminder.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh index c02849da..fbf29d26 100755 --- a/scripts/release-signature-reminder.sh +++ b/scripts/release-signature-reminder.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Open (and re-ping) a "Sign release (minisign)" issue while the latest -# stable release has no checksums-sha256.txt.minisig, and close it once the -# signature is on the release. Signing itself stays offline -# (scripts/sign-release.sh); this script only watches and nags, holds no key -# and reads no secret beyond the GitHub token gh already uses. +# Detect whether the latest stable release carries a +# checksums-sha256.txt.minisig and print " is signed" or " is unsigned". +# Part 1 of the release-signature reminder: detection only. Opening, re-pinging +# and closing the "Sign release (minisign)" issue arrive in the next parts, +# so SIGNER, HOURS, NOW and mutate() below are not used yet. Signing itself +# stays offline (scripts/sign-release.sh); this script holds no key and reads no +# secret beyond the GitHub token gh already uses. # # scripts/release-signature-reminder.sh [--dry-run] #