From bdf04dad248db47193493c6931cdda92f72c0e54 Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 27 Oct 2026 13:29:23 +0200 Subject: [PATCH] fix(promote): say in the job summary why GitHub refused a tag push channel-tag.sh now captures a failed push's stderr and puts GitHub's own refusal lines on stdout, which Promote and Publish release add to the step summary. A denial to github-actions[bot] points at the App secrets; an App denial or a missing workflows permission points at the installation's permissions and a re-run for a fresh token. Unknown refusals are quoted without a hint. The 2026-10-27 failure was GitHub refusing the App's git access org-wide, not this workflow. Refs #215. Co-Authored-By: Claude Opus 5.5 --- scripts/channel-tag.sh | 20 +++++++++++++++++++- scripts/channel-tag.test.sh | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scripts/channel-tag.sh b/scripts/channel-tag.sh index f130b14..d011345 100755 --- a/scripts/channel-tag.sh +++ b/scripts/channel-tag.sh @@ -85,7 +85,25 @@ version_at() { push_tag() { # push_tag [--force] [ "$push" = 1 ] || return 0 - git ${remote_opts[@]+"${remote_opts[@]}"} push ${2:+"$2"} "$remote" "refs/tags/$1:refs/tags/$1" + local err rc=0 + err="$(git ${remote_opts[@]+"${remote_opts[@]}"} push ${2:+"$2"} "$remote" "refs/tags/$1:refs/tags/$1" 2>&1 >/dev/null)" || rc=$? + [ -z "$err" ] || printf '%s\n' "$err" >&2 + [ "$rc" = 0 ] || explain_refusal "$1" "$err" + return "$rc" +} + +# Puts GitHub's own refusal on stdout, which the workflows add to the step +# summary, so a red run explains itself without its log. The hint after it +# covers the two refusals these workflows have actually met. +explain_refusal() { # explain_refusal + echo "push: GitHub refused $1, so it did not move:" + sed -nE '/^(remote|fatal|error):|\[remote rejected\]/{s/[[:space:]]*$//; s/^/push: /; p;}' <<<"$2" + case "$2" in + *"denied to github-actions[bot]"*) + echo "push: that is GITHUB_TOKEN, which may not push workflow-file changes. Set GH_APP_CLIENT_ID and GH_APP_PRIVATE_KEY so the App pushes instead." ;; + *"Permission to "*" denied to "*|*"workflows"*" permission"*) + echo "push: check that the App installation still has contents: write and workflows: write, then re-run the job for a fresh token. If a fresh token is refused too, an org owner re-saves the installation's permissions." ;; + esac } move_tag() { # move_tag diff --git a/scripts/channel-tag.test.sh b/scripts/channel-tag.test.sh index 0b4852a..880d551 100755 --- a/scripts/channel-tag.test.sh +++ b/scripts/channel-tag.test.sh @@ -18,6 +18,7 @@ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$3', got '$2')" ne() { if [ "$2" != "$3" ]; then ok "$1"; else bad "$1 (both '$2')"; fi; } yes() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; } no() { local d="$1"; shift; if "$@" >/dev/null 2>&1; then bad "$d"; else ok "$d"; fi; } +has() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1 (no '$3' in '$2')" ;; esac; } export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null @@ -288,6 +289,30 @@ eq "a token push clears the persisted auth header" "$(grep -c '^-c http.https:// (cd "$R4" && PATH="$SHIM:$PATH" "$SCRIPT" promote --version 1.5.0 >/dev/null 2>&1) eq "a plain push leaves the header alone" "$(grep -c 'extraheader' "$TMP/git.log")" 0 +# ---------------------------------------------------------------- refused push +# A pre-receive hook stands in for GitHub refusing the push the way it did on +# 2026-10-27, so the summary text is checked against the real git output. +R6="$TMP/r6"; make_repo "$R6" 1; release "$R6" 1.0.0; git -C "$R6" tag v1 v1.0.0 +git -C "$R6" push -q origin v1 +release "$R6" 1.1.0 +run "$R6" pin >/dev/null +refuse() { printf '#!/bin/sh\necho \047%s\047\nexit 1\n' "$1" > "$R6.git/hooks/pre-receive"; chmod +x "$R6.git/hooks/pre-receive"; } +refuse "Permission to o/r.git denied to some-app[bot]." +out="$(run "$R6" promote)"; rc=$? +ne "a refused push fails the promotion" "$rc" 0 +eq "and the remote v1 stays" "$(remote_sha "$R6" v1)" "$(sha "$R6" v1.0.0)" +has "the summary names the refused tag" "$out" "push: GitHub refused v1, so it did not move:" +has "and quotes GitHub's own words" "$out" "push: remote: Permission to o/r.git denied to some-app[bot]." +has "an App refusal points at the installation" "$out" "re-run the job for a fresh token" +refuse "Permission to o/r.git denied to github-actions[bot]." +has "a GITHUB_TOKEN refusal points at the App secrets" "$(run "$R6" promote)" "push: that is GITHUB_TOKEN" +refuse 'refusing to allow a GitHub App to create or update workflow .github/workflows/wf.yml without "workflows" permission' +has "a missing workflows permission points at the installation" "$(run "$R6" promote)" "contents: write and workflows: write" +refuse "some other rejection" +out="$(run "$R6" promote)" +has "an unknown rejection is still quoted" "$out" "push: remote: some other rejection" +no "and gets no guessed hint" grep -qE 'fresh token|GITHUB_TOKEN' <<<"$out" + echo echo "$pass passed, $fail failed" [ "$fail" -eq 0 ]