From b08bc25490d316e01a6ed78ca94995f7c857550f Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:09:01 +0700 Subject: [PATCH 1/9] ci(release): reminder script that nags until the release checksums are signed (#1327) --- scripts/release-signature-reminder.sh | 122 ++++++++++++++++ .../tests/release-signature-reminder.test.sh | 138 ++++++++++++++++++ 2 files changed, 260 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..98cb9dd7 --- /dev/null +++ b/scripts/release-signature-reminder.sh @@ -0,0 +1,122 @@ +#!/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 +case "${1-}" in + "") ;; + --dry-run) DRY=1 ;; + *) echo "usage: $0 [--dry-run]" >&2; exit 2 ;; +esac + +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 +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() { + if [ "$DRY" -eq 1 ]; then echo "[dry-run] gh $*"; else gh "$@"; fi +} + +# /releases/latest already excludes drafts and pre-releases; re-check anyway. +REL="$(gh api "repos/$REPO/releases/latest")" +TAG="$(printf '%s' "$REL" | jq -r '.tag_name // ""')" +IS_PRE="$(printf '%s' "$REL" | jq -r '.prerelease // false')" +IS_DRAFT="$(printf '%s' "$REL" | jq -r '.draft // false')" + +if [ "$IS_PRE" != "false" ] || [ "$IS_DRAFT" != "false" ]; then + echo "Latest release is a draft or pre-release; nothing to do." + exit 0 +fi +if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Latest release tag is not a plain vX.Y.Z tag; nothing to do." + exit 0 +fi + +has_asset() { + printf '%s' "$REL" | jq -e --arg n "$1" '[.assets[]?.name] | index($n) != null' >/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 + +TITLE="Sign release $TAG (minisign)" +ISSUES="$(gh issue list --repo "$REPO" --state open --limit 100 \ + --search "\"$TITLE\" in:title" --json number,title,createdAt,comments)" +# Exact title match in jq, lowest number first; empty when none. +EXISTING="$(printf '%s' "$ISSUES" | jq -c --arg t "$TITLE" \ + '[.[] | select(.title == $t)] | sort_by(.number) | .[0] // empty')" +NUM="" +if [ -n "$EXISTING" ]; then NUM="$(printf '%s' "$EXISTING" | jq -r '.number')"; fi + +if [ "$SIGNED" -eq 1 ]; then + if [ -n "$NUM" ]; then + echo "$TAG is signed; closing reminder #$NUM." + mutate issue comment "$NUM" --repo "$REPO" --body "Signature found on the release, closing." + mutate issue close "$NUM" --repo "$REPO" + else + echo "$TAG is signed and no reminder is open; nothing to do." + fi + exit 0 +fi + +if [ -z "$NUM" ]; then + BODY="Release $TAG has been published but its checksums are not signed yet. + +**Why:** \`uteke upgrade\` fails closed and refuses to install $TAG until \`checksums-sha256.txt.minisig\` is on the release (#1327). + +**What to do** (on the maintainer's machine; needs \`gh\`, \`cosign\` and \`minisign\`): + +\`\`\`bash +git pull +scripts/sign-release.sh $TAG +\`\`\` + +The script verifies the release's cosign bundle, shows the checksums, then asks for the minisign key password itself and for a confirmation before uploading. The key lives in the password manager with an offline backup (AGENTS.md, Release procedure, step 6); it is never in CI or GitHub secrets. + +This issue closes itself once \`checksums-sha256.txt.minisig\` is on the release. It is re-pinged at most every $HOURS hours until then." + echo "$TAG is unsigned and no reminder is open; creating \"$TITLE\" (assignee $SIGNER)." + if [ "$DRY" -eq 1 ]; then + echo "[dry-run] gh issue create --repo $REPO --title \"$TITLE\" --assignee $SIGNER" + printf '%s\n' "$BODY" | sed 's/^/[dry-run] /' + elif ! gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" --assignee "$SIGNER"; then + echo "Creating with assignee $SIGNER failed; retrying without an assignee." + gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" + fi + exit 0 +fi + +# Last activity = issue creation or the newest comment. +LAST="$(printf '%s' "$EXISTING" | jq -r \ + '([.createdAt] + [.comments[]?.createdAt]) | map(fromdateiso8601) | max')" +AGE=$(( NOW - LAST )) +if [ "$AGE" -lt $(( HOURS * 3600 )) ]; then + echo "Reminder #$NUM is recent (last activity $(( AGE / 3600 ))h ago, interval ${HOURS}h); not commenting." + exit 0 +fi +PUB="$(printf '%s' "$REL" | jq -r '.published_at // .created_at // ""')" +UNSIGNED_H="?" +if [ -n "$PUB" ]; then + PUB_EPOCH="$(printf '%s' "$PUB" | jq -R 'fromdateiso8601' 2>/dev/null || true)" + [ -n "$PUB_EPOCH" ] && UNSIGNED_H=$(( (NOW - PUB_EPOCH) / 3600 )) +fi +echo "Reminder #$NUM is stale; pinging @$SIGNER." +mutate issue comment "$NUM" --repo "$REPO" --body "@$SIGNER release $TAG has been unsigned for about ${UNSIGNED_H} hours, so \`uteke upgrade\` still refuses it. Run \`git pull\` then \`scripts/sign-release.sh $TAG\`. This issue closes itself once the \`.minisig\` is on the release." diff --git a/scripts/tests/release-signature-reminder.test.sh b/scripts/tests/release-signature-reminder.test.sh new file mode 100644 index 00000000..8c1c154d --- /dev/null +++ b/scripts/tests/release-signature-reminder.test.sh @@ -0,0 +1,138 @@ +#!/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" + +# 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) printf '%s' "$STUB_RELEASE" ;; + issue) + case "$2" in + list) printf '%s' "$STUB_ISSUES" ;; + create) + echo "CREATE $*" >> "$STUB_LOG" + if [ -n "${STUB_FAIL_ASSIGN-}" ]; then + case "$*" in *--assignee*) exit 1 ;; esac + fi ;; + *) echo "$(echo "$2" | tr a-z A-Z) $*" >> "$STUB_LOG" ;; + 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"}]' +TITLE="Sign release v1.2.3 (minisign)" +issue() { # age-hours-of-creation [comment-age-hours] + local c='[]' + [ -n "${2-}" ] && c="[{\"createdAt\":\"$(iso $((NOW - $2*H)))\"}]" + printf '[{"number":7,"title":"%s","createdAt":"%s","comments":%s},{"number":8,"title":"%s extra","createdAt":"%s","comments":[]}]' \ + "$TITLE" "$(iso $((NOW - $1*H)))" "$c" "$TITLE" "$(iso $((NOW - 99*H)))" +} + +FAILS=0 +# run args... ; sets RC, OUT, and $T/log +run() { + local r="$1" i="$2"; shift 2 + : > "$T/log" + OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_RELEASE="$r" STUB_ISSUES="$i" \ + GH_REPO=o/r RELEASE_SIGNER=ajianaz REMINDER_NOW_EPOCH=$NOW \ + 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")" "$(issue 5)" +t "signed + open reminder -> exit 0" rc_is 0 +t "signed + open reminder -> one comment" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +t "signed + open reminder -> closes #7 only" test "$(nlog '^CLOSE ')" = 1 +grep -q '^CLOSE issue close 7 ' "$T/log"; check "signed -> closed the exact-title issue, not #8" $? + +run "$(rel v1.2.3 "$A_SIGNED")" '[]' +t "signed + no reminder -> nothing mutating" no_mutation + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +t "unsigned + no reminder -> exit 0" rc_is 0 +t "unsigned + no reminder -> one create" test "$(nlog '^CREATE ')" = 1 +grep -qF -- "--title $TITLE " "$T/log"; check "create has the exact title" $? +grep -qF 'scripts/sign-release.sh v1.2.3' "$T/log"; check "create body has the sign command" $? +grep -qF -- '--assignee ajianaz' "$T/log"; check "create assigns the signer" $? + +STUB_FAIL_ASSIGN=1 run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +t "assign fails -> still exit 0" rc_is 0 +t "assign fails -> retried without assignee" test "$(nlog '^CREATE ')" = 2 +t "assign fails -> second create has no --assignee" test "$(nlog "--assignee")" = 1 + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 30 3)" +t "unsigned + reminder younger than interval (recent comment) -> no mutation" no_mutation +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 11)" +t "unsigned + reminder 11h old -> no mutation" no_mutation + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 13)" +t "unsigned + reminder 13h old -> exit 0" rc_is 0 +t "unsigned + older reminder -> exactly one comment, nothing else" test "$(nlog '')" = 1 +grep -q '^COMMENT issue comment 7 .*@ajianaz' "$T/log"; check "comment mentions the signer" $? +grep -q 'about 30 hours' "$T/log"; check "comment says how long it was unsigned" $? + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 40 13)" +t "old issue but comment 13h ago -> comments again" test "$(nlog '^COMMENT ')" = 1 +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 40 2)" +t "old issue but comment 2h ago -> no spam" no_mutation + +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 + no reminder -> no mutating call" no_mutation +echo "$OUT" | grep -q 'dry-run'; check "--dry-run prints the intended action" $? +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 13)" --dry-run +t "--dry-run stale reminder -> no mutating call" no_mutation +run "$(rel v1.2.3 "$A_SIGNED")" "$(issue 5)" --dry-run +t "--dry-run signed + reminder -> no mutating call" no_mutation +echo "$OUT" | grep -q 'dry-run\] gh issue close 7'; check "--dry-run prints the close" $? + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus +t "unknown flag -> exit 2" rc_is 2 + +if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi +echo "all passed" From 0c498779d7482f92c10759a6bb15709cc0963e55 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:09:01 +0700 Subject: [PATCH 2/9] ci(release): workflow that runs the signature reminder after Release and every 6 hours (#1327) --- .../workflows/release-signature-reminder.yml | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/release-signature-reminder.yml diff --git a/.github/workflows/release-signature-reminder.yml b/.github/workflows/release-signature-reminder.yml new file mode 100644 index 00000000..e8ba80a0 --- /dev/null +++ b/.github/workflows/release-signature-reminder.yml @@ -0,0 +1,47 @@ +name: Release Signature Reminder + +# Opens (and re-pings) a "Sign release vX.Y.Z (minisign)" issue while the latest +# release has no checksums-sha256.txt.minisig, and closes it once the signature +# is uploaded. It signs nothing and holds no signing secret: the minisign key +# stays offline (scripts/sign-release.sh, AGENTS.md "Release procedure" step 6). +# +# workflow_run is used because a release created with GITHUB_TOKEN does not +# trigger `release` events; the schedule re-pings and notices the signature. + +on: + workflow_run: + workflows: ["Release"] + types: [completed] + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +concurrency: + group: release-signature-reminder + cancel-in-progress: false + +permissions: + contents: read + issues: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + remind: + name: Remind to sign + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Check the latest release for a signature + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_SIGNER: ajianaz + REMINDER_AFTER_HOURS: "12" + run: bash scripts/release-signature-reminder.sh From 180ad50d8cb29b443e7d547f0d13b54012f227f8 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:09:06 +0700 Subject: [PATCH 3/9] docs: describe the release signature reminder issue (#1327) --- AGENTS.md | 6 ++++++ CHANGELOG.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e528f309..6e99c101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,12 @@ Rust workspace: uteke-core (engine), uteke-cli, uteke-server, uteke-mcp, docgen. (stub gh/cosign, throwaway key; not wired into CI). Verify the `.minisig` is on the release before announcing. Until it is uploaded, `uteke upgrade` fails closed for that release (#1327). + Reminder: `.github/workflows/release-signature-reminder.yml` runs after the + Release workflow and every 6 hours; while the latest release has a bundle but no + `.minisig` it opens the issue `Sign release vX.Y.Z (minisign)` (assigned to the + owner), re-pings it every 12 h and closes it itself once the `.minisig` is + uploaded. It signs nothing and holds no key. Test its script with + `bash scripts/tests/release-signature-reminder.test.sh` (stub gh, not wired into CI). 7. **After the tag.** Merge the homebrew-tap PR the workflow opened (its title/body follow the tap's rules since #1384, and the job already re-verified every SHA256 against `checksums-sha256.txt`; Cora there was fixed in homebrew-tap#6), then diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1a62e6..17a8648b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - **`ORT_LIB_PATH` pointing at a directory no longer leaves uteke without embeddings (#1459)** - the Homebrew shims exported the `libexec` directory (v0.19.0 onwards), `dlopen` failed on it, and every such install silently stored memories FTS5-only (`embedding_written: false`, `uteke verify` MISMATCH). A directory that holds the platform library (`libonnxruntime.so`, `libonnxruntime.dylib` or `onnxruntime.dll`) is now accepted with a warning; a directory without it is a clear error that says to set the library file. The tap fix is codecoradev/homebrew-tap#10 (`brew update && brew reinstall codecoradev/tap/uteke`). Docker and the release tarballs were not affected. +### Changed + +- Internal: `release-signature-reminder` workflow and script open and re-ping a `Sign release vX.Y.Z (minisign)` issue until the offline minisign signature is on the release (#1327). + ## [0.21.0] - 2026-10-13 Minor release. Theme: **honest recall feedback, one access policy for MCP over From 0d626f26c6ca60a9a9271ea072943f2e52adc9e8 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:11:00 +0700 Subject: [PATCH 4/9] ci(release): close reminders for superseded releases (#1327) Also act only on reminder issues authored by the Actions bot or the signer, so a look-alike planted by anyone else is ignored and never blocks the real one. --- AGENTS.md | 3 +- CHANGELOG.md | 2 +- scripts/release-signature-reminder.sh | 45 +++++++++++- .../tests/release-signature-reminder.test.sh | 71 +++++++++++++++++-- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e99c101..1a5e18fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,8 @@ Rust workspace: uteke-core (engine), uteke-cli, uteke-server, uteke-mcp, docgen. Release workflow and every 6 hours; while the latest release has a bundle but no `.minisig` it opens the issue `Sign release vX.Y.Z (minisign)` (assigned to the owner), re-pings it every 12 h and closes it itself once the `.minisig` is - uploaded. It signs nothing and holds no key. Test its script with + uploaded. Reminders for older releases are closed automatically as superseded + (`uteke upgrade` only installs the latest release) or when their `.minisig` appears. Only issues authored by the Actions bot or the owner are acted on. It signs nothing and holds no key. Test its script with `bash scripts/tests/release-signature-reminder.test.sh` (stub gh, not wired into CI). 7. **After the tag.** Merge the homebrew-tap PR the workflow opened (its title/body follow the tap's rules since #1384, and the job already re-verified every SHA256 diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a8648b..290fd3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Changed -- Internal: `release-signature-reminder` workflow and script open and re-ping a `Sign release vX.Y.Z (minisign)` issue until the offline minisign signature is on the release (#1327). +- Internal: `release-signature-reminder` workflow and script open and re-ping a `Sign release vX.Y.Z (minisign)` issue until the offline minisign signature is on the release, and close reminders for superseded releases automatically (#1327). ## [0.21.0] - 2026-10-13 diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh index 98cb9dd7..f585a1f8 100755 --- a/scripts/release-signature-reminder.sh +++ b/scripts/release-signature-reminder.sh @@ -48,6 +48,49 @@ if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then exit 0 fi +# All open reminder issues (exact-title filtering happens in jq). +ISSUES="$(gh issue list --repo "$REPO" --state open --limit 100 \ + --search "Sign release in:title" --json number,title,createdAt,comments,author)" +# Trust boundary: only issues authored by the Actions bot or the signer count. +# Anything else (a look-alike planted by anyone who can open issues) is ignored +# entirely, so it is never commented on or closed and never blocks creation. +# gh shows the bot as app/github-actions; accept the other spellings too. +ISSUES="$(printf '%s' "$ISSUES" | jq -c --arg s "$SIGNER" \ + '[.[] | select(.author.login as $a + | ["app/github-actions","github-actions[bot]","github-actions",$s] | index($a) != null)]')" + +# Stale pass: reminders for releases that are no longer the latest. At most 20 +# per run; only titles matching the pattern exactly are ever touched. +STALE="$(printf '%s' "$ISSUES" | jq -r --arg latest "$TAG" \ + '[.[] | select(.title | test("^Sign release v[0-9]+\\.[0-9]+\\.[0-9]+ \\(minisign\\)$")) + | {number, tag: (.title | capture("^Sign release (?[^ ]+) \\(minisign\\)$").t)} + | select(.tag != $latest)] | sort_by(.number) | .[0:20][] + | "\(.number) \(.tag)"')" +if [ -n "$STALE" ]; then + while read -r SNUM STAG; do + printf '%s' "$STAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || continue + ERR="$(mktemp)" + if OLD="$(gh api "repos/$REPO/releases/tags/$STAG" 2>"$ERR")"; then + if printf '%s' "$OLD" | jq -e '[.assets[]?.name] | index("checksums-sha256.txt.minisig") != null' >/dev/null; then + MSG="Signature found on the release, closing." + else + MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." + fi + elif grep -q '404' "$ERR"; then + MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." + else + echo "Could not look up $STAG for reminder #$SNUM; leaving it open." + rm -f "$ERR"; continue + fi + rm -f "$ERR" + echo "Reminder #$SNUM is for $STAG, not the latest ($TAG); closing." + mutate issue comment "$SNUM" --repo "$REPO" --body "$MSG" + mutate issue close "$SNUM" --repo "$REPO" + done </dev/null } @@ -59,8 +102,6 @@ SIGNED=0 if has_asset "checksums-sha256.txt.minisig"; then SIGNED=1; fi TITLE="Sign release $TAG (minisign)" -ISSUES="$(gh issue list --repo "$REPO" --state open --limit 100 \ - --search "\"$TITLE\" in:title" --json number,title,createdAt,comments)" # Exact title match in jq, lowest number first; empty when none. EXISTING="$(printf '%s' "$ISSUES" | jq -c --arg t "$TITLE" \ '[.[] | select(.title == $t)] | sort_by(.number) | .[0] // empty')" diff --git a/scripts/tests/release-signature-reminder.test.sh b/scripts/tests/release-signature-reminder.test.sh index 8c1c154d..4834950b 100644 --- a/scripts/tests/release-signature-reminder.test.sh +++ b/scripts/tests/release-signature-reminder.test.sh @@ -11,14 +11,20 @@ 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" +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) printf '%s' "$STUB_RELEASE" ;; + api) + case "$2" in + */releases/latest) printf '%s' "$STUB_RELEASE" ;; + */releases/tags/*) + f="$STUB_TAGDIR/${2##*/}.json" + if [ -f "$f" ]; then cat "$f"; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; + esac ;; issue) case "$2" in list) printf '%s' "$STUB_ISSUES" ;; @@ -46,19 +52,23 @@ 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"}]' TITLE="Sign release v1.2.3 (minisign)" +AUTH='"author":{"login":"app/github-actions"}' issue() { # age-hours-of-creation [comment-age-hours] local c='[]' [ -n "${2-}" ] && c="[{\"createdAt\":\"$(iso $((NOW - $2*H)))\"}]" - printf '[{"number":7,"title":"%s","createdAt":"%s","comments":%s},{"number":8,"title":"%s extra","createdAt":"%s","comments":[]}]' \ - "$TITLE" "$(iso $((NOW - $1*H)))" "$c" "$TITLE" "$(iso $((NOW - 99*H)))" + printf '[{"number":7,"title":"%s","createdAt":"%s","comments":%s,%s},{"number":8,"title":"%s extra","createdAt":"%s","comments":[],%s}]' \ + "$TITLE" "$(iso $((NOW - $1*H)))" "$c" "$AUTH" "$TITLE" "$(iso $((NOW - 99*H)))" "$AUTH" } FAILS=0 +oldissue() { # number tag [title-override] [author-login] [age-hours] + printf '{"number":%s,"title":"%s","createdAt":"%s","comments":[],"author":{"login":"%s"}}' "$1" "${3:-Sign release $2 (minisign)}" "$(iso $((NOW - ${5:-50}*H)))" "${4:-app/github-actions}" +} # run args... ; sets RC, OUT, and $T/log run() { local r="$1" i="$2"; shift 2 : > "$T/log" - OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_RELEASE="$r" STUB_ISSUES="$i" \ + 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_FAIL_ASSIGN="${STUB_FAIL_ASSIGN-}" bash "$SCRIPT" "$@" 2>&1)" RC=$? @@ -134,5 +144,56 @@ echo "$OUT" | grep -q 'dry-run\] gh issue close 7'; check "--dry-run prints the run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus t "unknown flag -> exit 2" rc_is 2 +# --- stale pass: reminders for superseded releases --- +rm -f "$T/tags/"* +echo "{\"assets\":$A_SIGNED}" > "$T/tags/v1.0.0.json" +echo "{\"assets\":$A_BUNDLE}" > "$T/tags/v1.1.0.json" +LATEST="$(rel v1.2.3 "$A_SIGNED")" + +run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "old reminder, signed old release -> exit 0" rc_is 0 +grep -q '^COMMENT issue comment 3 .*Signature found on the release, closing' "$T/log"; check "old signed -> Signature found comment" $? +grep -q '^CLOSE issue close 3 ' "$T/log"; check "old signed -> closed" $? + +run "$LATEST" "[$(oldissue 4 v1.1.0)]" +grep -q '^COMMENT issue comment 4 .*Superseded: v1.2.3 is now the latest release' "$T/log"; check "old unsigned -> superseded comment" $? +grep -q '^CLOSE issue close 4 ' "$T/log"; check "old unsigned -> closed" $? + +run "$LATEST" "[$(oldissue 7 v1.2.3 "")]" +t "latest-tag reminder (signed latest) handled only by main logic: one close" test "$(nlog '^CLOSE ')" = 1 +grep -q 'Signature found' "$T/log"; check "latest-tag reminder gets the normal close, not superseded" $? +run "$(rel v1.2.3 "$A_BUNDLE")" "[$(oldissue 7 v1.2.3 "" app/github-actions 2)]" +t "latest-tag unsigned young reminder untouched by stale pass" no_mutation + +run "$LATEST" "[$(oldissue 5 v1.0.0 'Sign release v1.0.0 (minisign) please'),$(oldissue 6 v1.0.0 'Sign release v1.0.0-rc.1 (minisign)'),$(oldissue 9 x 'Unrelated issue')]" +t "non-matching titles never touched" no_mutation + +run "$LATEST" "[$(oldissue 3 v1.0.0),$(oldissue 4 v1.1.0)]" +t "two stale issues -> two closes" test "$(nlog '^CLOSE ')" = 2 +t "two stale issues -> two comments" test "$(nlog '^COMMENT ')" = 2 + +run "$LATEST" "[$(oldissue 8 v0.9.0)]" +grep -q '^COMMENT issue comment 8 .*Superseded' "$T/log"; check "404 on old tag -> superseded" $? +grep -q '^CLOSE issue close 8 ' "$T/log"; check "404 on old tag -> closed" $? + +run "$LATEST" "[$(oldissue 3 v1.0.0)]" --dry-run +t "--dry-run stale pass -> no mutating call" no_mutation + +# --- trust boundary: issue authors --- +BUN="$(rel v1.2.3 "$A_BUNDLE")" +run "$BUN" "[$(oldissue 20 v1.2.3 "" stranger 2)]" +t "stranger look-alike ignored, genuine issue still created" test "$(nlog '^CREATE ')" = 1 +t "stranger look-alike never commented or closed" test "$(nlog '^COMMENT \|^CLOSE ')" = 0 +run "$LATEST" "[$(oldissue 21 v1.2.3 "" stranger 2),$(oldissue 22 v1.0.0 "" stranger)]" +t "stranger look-alikes (latest and stale) never touched when signed" no_mutation +run "$BUN" "[$(oldissue 23 v1.2.3 "" 'github-actions[bot]' 13)]" +grep -q '^COMMENT issue comment 23 ' "$T/log"; check "github-actions[bot] issue is acted on" $? +run "$BUN" "[$(oldissue 24 v1.2.3 "" ajianaz 13)]" +grep -q '^COMMENT issue comment 24 ' "$T/log"; check "RELEASE_SIGNER issue is acted on" $? +run "$LATEST" "[$(oldissue 25 v1.0.0 "" ajianaz)]" +grep -q '^CLOSE issue close 25 ' "$T/log"; check "signer-authored stale issue closed" $? +run "$LATEST" "[$(oldissue 26 v1.0.0 "" stranger)]" +t "stranger stale look-alike not closed" no_mutation + if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi echo "all passed" From 83600df12ed168dc3f9cee8ba0c5067f1fbf5bc7 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:17:00 +0700 Subject: [PATCH 5/9] fix(ci): list reminder issues without the search index, trust only bot/signer comments, harden hours and 404 parsing (#1327) --- scripts/release-signature-reminder.sh | 52 ++++++++++----- .../tests/release-signature-reminder.test.sh | 66 ++++++++++++++++--- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh index f585a1f8..7e5ec9f8 100755 --- a/scripts/release-signature-reminder.sh +++ b/scripts/release-signature-reminder.sh @@ -24,6 +24,7 @@ REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}" 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; } @@ -48,16 +49,27 @@ if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then exit 0 fi -# All open reminder issues (exact-title filtering happens in jq). -ISSUES="$(gh issue list --repo "$REPO" --state open --limit 100 \ - --search "Sign release in:title" --json number,title,createdAt,comments,author)" -# Trust boundary: only issues authored by the Actions bot or the signer count. +# Trust boundary: only issues (and comments) authored by the Actions bot or the +# signer count. The bot spellings need is_bot == true, so a human account that +# is merely named "github-actions" is not trusted; the signer matches on login. # Anything else (a look-alike planted by anyone who can open issues) is ignored -# entirely, so it is never commented on or closed and never blocks creation. +# entirely: never commented on, never closed, never blocks creation. # gh shows the bot as app/github-actions; accept the other spellings too. -ISSUES="$(printf '%s' "$ISSUES" | jq -c --arg s "$SIGNER" \ - '[.[] | select(.author.login as $a - | ["app/github-actions","github-actions[bot]","github-actions",$s] | index($a) != null)]')" +TRUST='def trusted: (.author // {}) as $a | ($a.login // "") as $l + | ($l == $s) or (($a.is_bot == true) + and (["app/github-actions","github-actions[bot]","github-actions"] | index($l) != null));' + +# List open issues WITHOUT --search: the search index lags, so a just-created +# reminder could be invisible to the next run and get duplicated. The listing is +# limited to the 100 most recent open issues (a reminder older than that on a +# huge tracker would be missed; acceptable). Exact-title and author filtering +# happen in jq. +list_reminders() { + gh issue list --repo "$REPO" --state open --limit 100 \ + --json number,title,createdAt,comments,author \ + | jq -c --arg s "$SIGNER" "$TRUST"'[.[] | select(trusted)]' +} +ISSUES="$(list_reminders)" # Stale pass: reminders for releases that are no longer the latest. At most 20 # per run; only titles matching the pattern exactly are ever touched. @@ -76,7 +88,7 @@ if [ -n "$STALE" ]; then else MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." fi - elif grep -q '404' "$ERR"; then + elif grep -q 'HTTP 404' "$ERR"; then MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." else echo "Could not look up $STAG for reminder #$SNUM; leaving it open." @@ -84,8 +96,7 @@ if [ -n "$STALE" ]; then fi rm -f "$ERR" echo "Reminder #$SNUM is for $STAG, not the latest ($TAG); closing." - mutate issue comment "$SNUM" --repo "$REPO" --body "$MSG" - mutate issue close "$SNUM" --repo "$REPO" + mutate issue close "$SNUM" --repo "$REPO" --comment "$MSG" done <&2; exit 1; fi if [ -f "$f" ]; then cat "$f"; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; esac ;; issue) case "$2" in - list) printf '%s' "$STUB_ISSUES" ;; + list) + case "$*" in *--search*) echo "SEARCH_USED $*" >> "$STUB_LOG"; exit 1 ;; esac + n=$(( $(cat "$STUB_LOG.n" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$STUB_LOG.n" + if [ "$n" -gt 1 ] && [ -n "${STUB_ISSUES2-}" ]; then printf '%s' "$STUB_ISSUES2"; else printf '%s' "$STUB_ISSUES"; fi ;; create) echo "CREATE $*" >> "$STUB_LOG" if [ -n "${STUB_FAIL_ASSIGN-}" ]; then @@ -52,24 +56,31 @@ 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"}]' TITLE="Sign release v1.2.3 (minisign)" -AUTH='"author":{"login":"app/github-actions"}' +# author JSON in gh's real shape; "login[:false]" forces is_bot=false +author() { + local l="${1%%:*}" b=false + case "$1" in *:false) ;; app/github-actions|github-actions\[bot\]) b=true ;; esac + printf '{"id":"X","is_bot":%s,"login":"%s","name":"n"}' "$b" "$l" +} +AUTH="\"author\":$(author app/github-actions)" issue() { # age-hours-of-creation [comment-age-hours] local c='[]' - [ -n "${2-}" ] && c="[{\"createdAt\":\"$(iso $((NOW - $2*H)))\"}]" + [ -n "${2-}" ] && c="[{\"author\":$(author "${3:-app/github-actions}"),\"body\":\"b\",\"createdAt\":\"$(iso $((NOW - $2*H)))\"}]" printf '[{"number":7,"title":"%s","createdAt":"%s","comments":%s,%s},{"number":8,"title":"%s extra","createdAt":"%s","comments":[],%s}]' \ "$TITLE" "$(iso $((NOW - $1*H)))" "$c" "$AUTH" "$TITLE" "$(iso $((NOW - 99*H)))" "$AUTH" } FAILS=0 oldissue() { # number tag [title-override] [author-login] [age-hours] - printf '{"number":%s,"title":"%s","createdAt":"%s","comments":[],"author":{"login":"%s"}}' "$1" "${3:-Sign release $2 (minisign)}" "$(iso $((NOW - ${5:-50}*H)))" "${4:-app/github-actions}" + printf '{"number":%s,"title":"%s","createdAt":"%s","comments":[],"author":%s}' "$1" "${3:-Sign release $2 (minisign)}" "$(iso $((NOW - ${5:-50}*H)))" "$(author "${4:-app/github-actions}")" } # run args... ; sets RC, OUT, and $T/log run() { local r="$1" i="$2"; shift 2 - : > "$T/log" + : > "$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=$? } @@ -83,7 +94,8 @@ t() { local n="$1"; shift; if "$@"; then check "$n" 0; else check "$n" 1; fi; } run "$(rel v1.2.3 "$A_SIGNED")" "$(issue 5)" t "signed + open reminder -> exit 0" rc_is 0 -t "signed + open reminder -> one comment" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +grep -q '^CLOSE issue close 7 .*--comment Signature found on the release, closing' "$T/log"; check "signed -> single close call carries the comment" $? +t "signed + open reminder -> no separate comment call" test "$(nlog '^COMMENT ')" = 0 t "signed + open reminder -> closes #7 only" test "$(nlog '^CLOSE ')" = 1 grep -q '^CLOSE issue close 7 ' "$T/log"; check "signed -> closed the exact-title issue, not #8" $? @@ -152,11 +164,11 @@ LATEST="$(rel v1.2.3 "$A_SIGNED")" run "$LATEST" "[$(oldissue 3 v1.0.0)]" t "old reminder, signed old release -> exit 0" rc_is 0 -grep -q '^COMMENT issue comment 3 .*Signature found on the release, closing' "$T/log"; check "old signed -> Signature found comment" $? +grep -q '^CLOSE issue close 3 .*Signature found on the release, closing' "$T/log"; check "old signed -> Signature found comment" $? grep -q '^CLOSE issue close 3 ' "$T/log"; check "old signed -> closed" $? run "$LATEST" "[$(oldissue 4 v1.1.0)]" -grep -q '^COMMENT issue comment 4 .*Superseded: v1.2.3 is now the latest release' "$T/log"; check "old unsigned -> superseded comment" $? +grep -q '^CLOSE issue close 4 .*Superseded: v1.2.3 is now the latest release' "$T/log"; check "old unsigned -> superseded comment" $? grep -q '^CLOSE issue close 4 ' "$T/log"; check "old unsigned -> closed" $? run "$LATEST" "[$(oldissue 7 v1.2.3 "")]" @@ -170,10 +182,10 @@ t "non-matching titles never touched" no_mutation run "$LATEST" "[$(oldissue 3 v1.0.0),$(oldissue 4 v1.1.0)]" t "two stale issues -> two closes" test "$(nlog '^CLOSE ')" = 2 -t "two stale issues -> two comments" test "$(nlog '^COMMENT ')" = 2 +t "two stale issues -> comments ride on the closes" test "$(nlog '^COMMENT ')" = 0 run "$LATEST" "[$(oldissue 8 v0.9.0)]" -grep -q '^COMMENT issue comment 8 .*Superseded' "$T/log"; check "404 on old tag -> superseded" $? +grep -q '^CLOSE issue close 8 .*Superseded' "$T/log"; check "404 on old tag -> superseded" $? grep -q '^CLOSE issue close 8 ' "$T/log"; check "404 on old tag -> closed" $? run "$LATEST" "[$(oldissue 3 v1.0.0)]" --dry-run @@ -195,5 +207,39 @@ grep -q '^CLOSE issue close 25 ' "$T/log"; check "signer-authored stale issue cl run "$LATEST" "[$(oldissue 26 v1.0.0 "" stranger)]" t "stranger stale look-alike not closed" no_mutation +# --- review hardening --- +# listing never uses --search (stub fails the run if it does) +run "$BUN" '[]' +t "listing does not use --search" test "$(nlog SEARCH_USED)" = 0 + +# stranger comment does not reset activity; trusted comment does +run "$BUN" "[$(issue 40 1 stranger | sed 's/^\[//; s/,{"number":8.*$//')]" +t "stranger comment 1h ago + old issue -> still re-pings" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +run "$BUN" "[$(issue 40 1 | sed 's/^\[//; s/,{"number":8.*$//')]" +t "trusted comment 1h ago -> no re-ping" no_mutation +run "$BUN" "[$(issue 40 1 'github-actions:false' | sed 's/^\[//; s/,{"number":8.*$//')]" +t "human named github-actions (is_bot false) comment not trusted -> re-pings" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +run "$BUN" "[$(oldissue 30 v1.2.3 "" 'github-actions:false' 2)]" +t "human 'github-actions' (is_bot false) issue not trusted -> genuine created" test "$(nlog '^CREATE ')" = 1 + +# HOURS=08 +: > "$T/log" +OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_TAGDIR="$T/tags" STUB_RELEASE="$BUN" STUB_ISSUES="$(issue 10 | sed 's/^\[//; s/,{"number":8.*$//; s/^/[/; s/$/]/')" GH_REPO=o/r REMINDER_NOW_EPOCH=$NOW REMINDER_AFTER_HOURS=08 bash "$SCRIPT" 2>&1)"; RC=$? +t "REMINDER_AFTER_HOURS=08 accepted, 10h old -> re-pings" test "$RC" = 0 -a "$(nlog '^COMMENT ')" = 1 +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 + +# 404 parsing +STUB_API_ERR="gh: Internal error mentions 404 (HTTP 502)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "5xx whose text contains 404 -> issue left open" no_mutation +STUB_API_ERR="gh: Server Error (HTTP 500)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "HTTP 500 on old tag -> issue left open" no_mutation +STUB_API_ERR="gh: API rate limit exceeded (HTTP 403)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "rate limit on old tag -> issue left open" no_mutation + +# assign failure: re-list before retrying +STUB_FAIL_ASSIGN=1 STUB_ISSUES2="[$(oldissue 30 v1.2.3 "" app/github-actions 1)]" run "$BUN" '[]' +t "assign fails but issue now exists -> no second create" test "$(nlog '^CREATE ')" = 1 + if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi echo "all passed" From dbe7b56c3b340b158b08c6bb8c9c3546b1eaf72e Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:17:00 +0700 Subject: [PATCH 6/9] docs: wrap the reminder paragraph and state how latest is defined (#1327) --- AGENTS.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1a5e18fd..11ecf184 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,12 +96,17 @@ Rust workspace: uteke-core (engine), uteke-cli, uteke-server, uteke-mcp, docgen. `.minisig` is on the release before announcing. Until it is uploaded, `uteke upgrade` fails closed for that release (#1327). Reminder: `.github/workflows/release-signature-reminder.yml` runs after the - Release workflow and every 6 hours; while the latest release has a bundle but no + Release workflow and every 6 hours. While the latest release has a bundle but no `.minisig` it opens the issue `Sign release vX.Y.Z (minisign)` (assigned to the owner), re-pings it every 12 h and closes it itself once the `.minisig` is - uploaded. Reminders for older releases are closed automatically as superseded - (`uteke upgrade` only installs the latest release) or when their `.minisig` appears. Only issues authored by the Actions bot or the owner are acted on. It signs nothing and holds no key. Test its script with - `bash scripts/tests/release-signature-reminder.test.sh` (stub gh, not wired into CI). + uploaded. Reminders for older releases are closed automatically, as superseded + (`uteke upgrade` only installs the latest release) or when their `.minisig` + appears. "Latest" follows GitHub's definition (by creation date, the same release + `uteke upgrade` installs), so a backport patch published after a newer minor + supersedes the newer release's reminder. Only issues and comments authored by the + Actions bot or the owner are acted on. It signs nothing and holds no key. Test its + script with `bash scripts/tests/release-signature-reminder.test.sh` (stub gh, not + wired into CI). 7. **After the tag.** Merge the homebrew-tap PR the workflow opened (its title/body follow the tap's rules since #1384, and the job already re-verified every SHA256 against `checksums-sha256.txt`; Cora there was fixed in homebrew-tap#6), then From 5b08fe08df14248b49fb5b77189c692ddf590397 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:34:00 +0700 Subject: [PATCH 7/9] refactor(ci): keep the stale-pass jq program in a variable (#1327) Same filter and behaviour (all 69 reminder test checks pass). It also reads as the jq program it is, instead of a quoted string with an inline pipeline. --- scripts/release-signature-reminder.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh index 7e5ec9f8..f47ffbae 100755 --- a/scripts/release-signature-reminder.sh +++ b/scripts/release-signature-reminder.sh @@ -73,11 +73,17 @@ ISSUES="$(list_reminders)" # Stale pass: reminders for releases that are no longer the latest. At most 20 # per run; only titles matching the pattern exactly are ever touched. -STALE="$(printf '%s' "$ISSUES" | jq -r --arg latest "$TAG" \ - '[.[] | select(.title | test("^Sign release v[0-9]+\\.[0-9]+\\.[0-9]+ \\(minisign\\)$")) - | {number, tag: (.title | capture("^Sign release (?[^ ]+) \\(minisign\\)$").t)} - | select(.tag != $latest)] | sort_by(.number) | .[0:20][] - | "\(.number) \(.tag)"')" +# The jq program is kept in a variable (a quoted here-document) so it reads as the +# jq program it is; it only ever receives the issue list as JSON input. +STALE_FILTER="$(cat <<'JQ' +[.[] | select(.title | test("^Sign release v[0-9]+\\.[0-9]+\\.[0-9]+ \\(minisign\\)$")) + | {number, tag: (.title | capture("^Sign release (?[^ ]+) \\(minisign\\)$").t)} + | select(.tag != $latest)] +| sort_by(.number) | .[0:20][] +| "\(.number) \(.tag)" +JQ +)" +STALE="$(printf '%s' "$ISSUES" | jq -r --arg latest "$TAG" "$STALE_FILTER")" if [ -n "$STALE" ]; then while read -r SNUM STAG; do printf '%s' "$STAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || continue From 8045d2f4ead6288eb2b8f902cbe6107aa45d65f1 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 11:40:22 +0700 Subject: [PATCH 8/9] refactor(ci): split the title-matching jq line (#1327) --- scripts/release-signature-reminder.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh index f47ffbae..183a9bb7 100755 --- a/scripts/release-signature-reminder.sh +++ b/scripts/release-signature-reminder.sh @@ -76,7 +76,8 @@ ISSUES="$(list_reminders)" # The jq program is kept in a variable (a quoted here-document) so it reads as the # jq program it is; it only ever receives the issue list as JSON input. STALE_FILTER="$(cat <<'JQ' -[.[] | select(.title | test("^Sign release v[0-9]+\\.[0-9]+\\.[0-9]+ \\(minisign\\)$")) +[.[] | select(.title + | test("^Sign release v[0-9]+\\.[0-9]+\\.[0-9]+ \\(minisign\\)$")) | {number, tag: (.title | capture("^Sign release (?[^ ]+) \\(minisign\\)$").t)} | select(.tag != $latest)] | sort_by(.number) | .[0:20][] From 67c5d2ee2a25a74b065c0b84628ff86c31faf412 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 15 Oct 2026 12:14:33 +0700 Subject: [PATCH 9/9] fix(ci): validate every argument and keep gh away from the loop's stdin (#1327) Found by a local Cora review: only the first argument was validated, and gh calls inside the while-read loop inherited the here-document as stdin. Reject any unknown argument (exit 2, nothing mutating) and give those calls &2; exit 2 ;; -esac +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; } @@ -31,7 +32,9 @@ 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() { - if [ "$DRY" -eq 1 ]; then echo "[dry-run] gh $*"; else gh "$@"; fi + # "$ERR")"; then + if OLD="$(gh api "repos/$REPO/releases/tags/$STAG" 2>"$ERR" /dev/null; then MSG="Signature found on the release, closing." else diff --git a/scripts/tests/release-signature-reminder.test.sh b/scripts/tests/release-signature-reminder.test.sh index db3a4b6f..c143b27a 100644 --- a/scripts/tests/release-signature-reminder.test.sh +++ b/scripts/tests/release-signature-reminder.test.sh @@ -155,6 +155,12 @@ echo "$OUT" | grep -q 'dry-run\] gh issue close 7'; check "--dry-run prints the 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 # --- stale pass: reminders for superseded releases --- rm -f "$T/tags/"*