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 diff --git a/AGENTS.md b/AGENTS.md index e528f309..11ecf184 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,18 @@ 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. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1a62e6..290fd3ca 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, and close reminders for superseded releases automatically (#1327). + ## [0.21.0] - 2026-10-13 Minor release. Theme: **honest recall feedback, one access policy for MCP over diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh new file mode 100755 index 00000000..c5c62095 --- /dev/null +++ b/scripts/release-signature-reminder.sh @@ -0,0 +1,191 @@ +#!/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() { + # [^ ]+) \\(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 + ERR="$(mktemp)" + if OLD="$(gh api "repos/$REPO/releases/tags/$STAG" 2>"$ERR" /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 '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." + rm -f "$ERR"; continue + fi + rm -f "$ERR" + echo "Reminder #$SNUM is for $STAG, not the latest ($TAG); closing." + mutate issue close "$SNUM" --repo "$REPO" --comment "$MSG" + done </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)" +# 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 close "$NUM" --repo "$REPO" --comment "Signature found on the release, closing." + 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; checking whether the issue exists before retrying." + # A lost response may still have created it. + AGAIN="$(list_reminders | jq -c --arg t "$TITLE" '[.[] | select(.title == $t)] | length')" + if [ "${AGAIN:-0}" -gt 0 ]; then + echo "Reminder for $TAG already exists; not creating another." + exit 0 + fi + echo "Retrying without an assignee." + gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" + fi + exit 0 +fi + +# Last activity = issue creation or the newest TRUSTED comment (a stranger's +# comment must not silence the reminder). +LAST="$(printf '%s' "$EXISTING" | jq -r --arg s "$SIGNER" "$TRUST"' + ([.createdAt] + [.comments[]? | select(trusted) | .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..c143b27a --- /dev/null +++ b/scripts/tests/release-signature-reminder.test.sh @@ -0,0 +1,251 @@ +#!/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" ;; + */releases/tags/*) + f="$STUB_TAGDIR/${2##*/}.json" + if [ -n "${STUB_API_ERR-}" ]; then echo "$STUB_API_ERR" >&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) + 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 + 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)" +# 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="[{\"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":%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"; 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")" "$(issue 5)" +t "signed + open reminder -> exit 0" rc_is 0 +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" $? + +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 +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/"* +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 '^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 '^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 "")]" +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 -> comments ride on the closes" test "$(nlog '^COMMENT ')" = 0 + +run "$LATEST" "[$(oldissue 8 v0.9.0)]" +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 +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 + +# --- 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"