diff --git a/scripts/canary-rollout.sh b/scripts/canary-rollout.sh index 7b31427eb..f62a573e0 100755 --- a/scripts/canary-rollout.sh +++ b/scripts/canary-rollout.sh @@ -148,6 +148,14 @@ _gh_move_tag() { # real rejection to surface, not to paper over with a create. local low="${out,,}" if [[ "$low" != *"not found"* && "$low" != *"http 404"* ]]; then + # #749: on a 403 "Resource not accessible by integration" — the failure #745/#746 did NOT + # fix — dump the write token's EFFECTIVE permissions + scope so the next step is decided + # from data, not another guess. Only runs on the 403 branch (a non-404 that is also a + # permission 403), before the ::error:: so the diagnostic and the surfaced rejection are + # co-located in the run log. Best-effort: never alters rc. + if [[ "$low" == *"403"* || "$low" == *"not accessible by integration"* ]]; then + _gh_403_diag "$repo" >&2 || true + fi echo "::error::_gh_move_tag: could not move refs/tags/$tag -> ${sha:0:12} on $repo (HTTP): ${out//$'\n'/ }" >&2 return "$rc" fi @@ -158,6 +166,48 @@ _gh_move_tag() { return "$rc" } +# _gh_403_diag — one-shot effective-permission diagnostic (#749), called only from the +# _gh_move_tag 403 failure path. #745/#746 (repo-scoped write token) did NOT resolve the 403 +# "Resource not accessible by integration" on a protected channel-tag PATCH, so before guessing +# again we DUMP DATA, using the SAME token the PATCH used (_gh_write → CANARY_WRITE_TOKEN when +# set). This distinguishes the two possible root causes: +# (a) the token LACKS effective contents:write → a minting bug (code-fixable), or +# (b) the token HAS contents:write but the release-channel-tags ruleset bypass lapsed → NOT a +# code bug (a maintainer must re-save bypass_actors / open GitHub support). +# Emits, to stderr, inside a foldable ::group::: +# - X-Accepted-GitHub-Permissions from `gh api -i repos/` (the perms the API advertises), +# - total_count from /installation/repositories (how many repos the installation token covers). +# Best-effort: every probe is guarded, and the function always returns 0 — a diagnostic must +# never mask the 403 or change the mover's exit status. +_gh_403_diag() { + local repo="$1" hdr inst count="" + echo "::group::_gh_move_tag 403 effective-permission diagnostic (#749)" + if [ -n "${CANARY_WRITE_TOKEN:-}" ]; then + echo "diag: introspecting with CANARY_WRITE_TOKEN (repo-scoped write token)" + else + echo "diag: introspecting with ambient GH_TOKEN (no CANARY_WRITE_TOKEN set)" + fi + # (1) What permission does the API advertise for this repo endpoint? + hdr="$(_gh_write api -i "repos/$repo" 2>/dev/null | grep -i '^x-accepted-github-permissions:' || true)" + if [ -n "$hdr" ]; then + printf 'diag: %s\n' "${hdr%$'\r'}" + else + echo "diag: X-Accepted-GitHub-Permissions header not present on repos/$repo response" + fi + # (2) How many repositories does the installation token cover? + inst="$(_gh_write api /installation/repositories 2>/dev/null || true)" + if [ -n "$inst" ]; then + count="$(jq -r '.total_count? // empty' <<<"$inst" 2>/dev/null || true)" + fi + if [ -n "$count" ]; then + echo "diag: installation/repositories total_count=${count}" + else + echo "diag: installation/repositories total_count=" + fi + echo "::endgroup::" + return 0 +} + # _gh_create_annotated_tag — create the immutable annotated # tag object pointing at commit on and publish its ref, via the GitHub # API with the release-manager App token (mirrors cut-release.sh's gh_create_annotated_tag). diff --git a/tests/canary_rollout.bats b/tests/canary_rollout.bats index 70395c50a..33d14a41d 100644 --- a/tests/canary_rollout.bats +++ b/tests/canary_rollout.bats @@ -1184,6 +1184,137 @@ GHEOF ! grep -q 'owner-wide' "$TOKEN_LOG" } +# ── #749: one-shot effective-permission diagnostic on a 403 tag-move ──────────── +# #745/#746 (repo-scoped write token) did NOT resolve the 403 "Resource not accessible by +# integration" on a protected channel-tag PATCH. To decide the next step from DATA rather than +# guessing again, the _gh_move_tag 403 failure path (behind #744's un-suppressed error) dumps +# the write token's EFFECTIVE permissions + scope — using the SAME write token the PATCH used — +# so we can tell (a) a token that LACKS effective contents:write (a minting bug, code-fixable) +# from (b) a token that HAS it but is still blocked because the ruleset bypass lapsed (NOT a +# code bug). The diagnostic must run ONLY on a 403 (not on the #743 non-404 422 rejection), +# must still surface the ::error:: + return non-zero, and must not fall back to POST. +_diag_403_stub() { + local scope="${1:-selected}" # selected (repo-scoped, low count) | all (owner-wide, high count) + local install_count + if [ "$scope" = all ]; then install_count=50; else install_count=2; fi + STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH" + export CALL_LOG="$STUB_BIN/calls.log"; : > "$CALL_LOG" + export TOKEN_LOG="$STUB_BIN/tokens.log"; : > "$TOKEN_LOG" + cat > "$STUB_BIN/gh" <}" >> "$TOKEN_LOG" +case "\$*" in + *"-X PATCH"*"git/refs/tags/"*) + echo "PATCH" >> "$CALL_LOG" + echo '{"message":"Resource not accessible by integration","status":"403"}' >&2 + exit 1 ;; + *"-X POST"*"git/refs"*) + echo "POST" >> "$CALL_LOG"; echo '{"ref":"refs/tags/x"}'; exit 0 ;; + *"-i "*"repos/"*) + echo "DIAG_HEADERS" >> "$CALL_LOG" + printf 'HTTP/2.0 403 Forbidden\r\n' + printf 'X-Accepted-GitHub-Permissions: contents=write; contents=read\r\n' + printf '\r\n' + echo '{"full_name":"petry-projects/.github"}' + exit 0 ;; + *"installation/repositories"*) + echo "DIAG_INSTALL" >> "$CALL_LOG" + echo '{"total_count":$install_count}' + exit 0 ;; + *) echo "{}" ;; +esac +GHEOF + chmod +x "$STUB_BIN/gh" +} + +@test "_gh_move_tag: on a 403 dumps the effective-permission diagnostic then surfaces the error (#749)" { + _diag_403_stub selected + run env GH_TOKEN=owner-wide CANARY_WRITE_TOKEN=repo-scoped bash -c \ + "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + [ "$status" -ne 0 ] + [[ "$output" == *"effective-permission diagnostic"* ]] + [[ "$output" == *"X-Accepted-GitHub-Permissions: contents=write"* ]] + [[ "$output" == *"total_count=2"* ]] + [[ "$output" == *"::error::"* ]] + grep -q '^PATCH$' "$CALL_LOG" + grep -q '^DIAG_HEADERS$' "$CALL_LOG" + grep -q '^DIAG_INSTALL$' "$CALL_LOG" + ! grep -q '^POST$' "$CALL_LOG" +} + +@test "_gh_move_tag: the 403 diagnostic introspects through CANARY_WRITE_TOKEN (#749)" { + _diag_403_stub selected + run env GH_TOKEN=owner-wide CANARY_WRITE_TOKEN=repo-scoped bash -c \ + "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + grep -q 'installation/repositories.*repo-scoped' "$TOKEN_LOG" + ! grep -q 'installation/repositories.*owner-wide' "$TOKEN_LOG" +} + +@test "_gh_move_tag: the 403 diagnostic reports the higher total_count for an owner-wide installation token (#749)" { + _diag_403_stub all + run env GH_TOKEN=owner-wide CANARY_WRITE_TOKEN=repo-scoped bash -c \ + "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + [[ "$output" == *"total_count=50"* ]] + [[ "$output" != *"total_count=2"* ]] +} + +@test "_gh_move_tag: a non-403 (422 ruleset) failure does NOT trigger the 403 diagnostic (#749)" { + _move_tag_stub reject + run bash -c "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + [ "$status" -ne 0 ] + [[ "$output" != *"effective-permission diagnostic"* ]] + [[ "$output" == *"::error::"* ]] +} + +@test "_gh_403_diag: empty installation/repositories response does not cause a jq parse error (#749)" { + # Stub: PATCH → 403; installation/repositories → empty (API failure) + STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH" + cat > "$STUB_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +case "$*" in + *"-X PATCH"*"git/refs/tags/"*) + echo '{"message":"Resource not accessible by integration","status":"403"}' >&2; exit 1 ;; + *"-i "*"repos/"*) + printf 'HTTP/2.0 403 Forbidden\r\nX-Accepted-GitHub-Permissions: contents=write\r\n\r\n{}' + exit 0 ;; + *"installation/repositories"*) exit 1 ;; # API failure → empty $inst + *) echo "{}" ;; +esac +GHEOF + chmod +x "$STUB_BIN/gh" + run env GH_TOKEN=tok bash -c \ + "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + [ "$status" -ne 0 ] + [[ "$output" == *"effective-permission diagnostic"* ]] + # Must not print a jq error about parse failure + [[ "$output" != *"parse error"* ]] + [[ "$output" == *"total_count="* ]] +} + +@test "_gh_403_diag: installation/repositories response missing keys does not error (#749)" { + # Stub: installation/repositories returns valid JSON but without the expected keys + STUB_BIN="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"; export PATH="$STUB_BIN:$PATH" + cat > "$STUB_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +case "$*" in + *"-X PATCH"*"git/refs/tags/"*) + echo '{"message":"Resource not accessible by integration","status":"403"}' >&2; exit 1 ;; + *"-i "*"repos/"*) + printf 'HTTP/2.0 403 Forbidden\r\nX-Accepted-GitHub-Permissions: contents=write\r\n\r\n{}' + exit 0 ;; + *"installation/repositories"*) echo '{}' ;; # valid JSON but no total_count + *) echo "{}" ;; +esac +GHEOF + chmod +x "$STUB_BIN/gh" + run env GH_TOKEN=tok bash -c \ + "source '$ORCH' && _gh_move_tag petry-projects/.github agent-shield/v2-ring0 12b0075a9c48000000000000000000000000000" + [ "$status" -ne 0 ] + [[ "$output" == *"effective-permission diagnostic"* ]] + [[ "$output" != *"parse error"* ]] + [[ "$output" == *"total_count="* ]] +} + # ── orchestrator: sync-issues — auto-triage held promotions into tracked issues ── # A held (BLOCKED) promotion files/updates ONE idempotent issue per agent with the failing-run # evidence; a cleared agent's issue auto-closes; the fleet-status table is rendered to the job