From e973fd0d80bad544b4516a736b175442aa6b6a19 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 1 Sep 2026 00:06:32 -0400 Subject: [PATCH 1/4] fix(release): read checks with workflow token --- .github/workflows/release-please.yml | 19 +++++++++++-------- scripts/ci/check-release-workflow.sh | 13 +++++++++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d1bfb2f..855d7fa 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -11,10 +11,12 @@ concurrency: group: release-please-${{ github.ref }} cancel-in-progress: false -# Least privilege for the default GITHUB_TOKEN: the release work runs on the -# GitHub App token minted below, so the default token needs no write scope. +# The default GITHUB_TOKEN reads PR checks. Release writes use the scoped +# GitHub App token minted below. permissions: + checks: read contents: read + pull-requests: read jobs: release-please: @@ -55,6 +57,7 @@ jobs: # included so a workflow fix can recover a stalled release. - name: Merge non-major release PRs env: + CHECKS_TOKEN: ${{ github.token }} GH_TOKEN: ${{ steps.app-token.outputs.token }} GH_REPO: ${{ github.repository }} run: | @@ -67,10 +70,6 @@ jobs: exit 0 fi current_major="${current_version%%.*}" - if ! gh api "repos/${GH_REPO}/branches/main/protection/required_status_checks" --jq '.contexts[]' | grep -Fxq 'check / check'; then - echo "Required release check 'check / check' is not configured; merging nothing." - exit 1 - fi gh pr list --state open --label 'autorelease: pending' --json number --jq '.[].number' | while read -r number; do metadata="$(gh pr view "$number" --json author,headRefName,headRefOid,title)" author="$(jq -r '.author.login' <<< "$metadata")" @@ -94,7 +93,11 @@ jobs: echo "Waiting for required checks on release PR #${number} (${current_version} -> ${new_version})." checks_ready=false for attempt in {1..30}; do - if checks_probe="$(gh pr checks "$number" --required --json name 2>&1)"; then + if checks_probe="$(GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --required --json name 2>&1)"; then + if ! jq -e 'map(.name) | index("check / check") != null' <<< "$checks_probe" >/dev/null; then + echo "Required release check 'check / check' is not configured; merging nothing." >&2 + exit 1 + fi checks_ready=true break fi @@ -109,7 +112,7 @@ jobs: echo "Required checks did not register for release PR #${number}." >&2 exit 1 fi - gh pr checks "$number" --required --watch --fail-fast + GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --required --watch --fail-fast echo "Squash-merging release PR #${number} with the Volcano app ruleset bypass." gh pr merge "$number" --admin --squash --match-head-commit "$head_oid" done diff --git a/scripts/ci/check-release-workflow.sh b/scripts/ci/check-release-workflow.sh index f6afb53..59fb107 100755 --- a/scripts/ci/check-release-workflow.sh +++ b/scripts/ci/check-release-workflow.sh @@ -4,11 +4,15 @@ set -euo pipefail workflow=".github/workflows/release-please.yml" for required in \ "gh pr list --state open --label 'autorelease: pending'" \ - 'required_status_checks' \ + 'checks: read' \ + 'pull-requests: read' \ + "CHECKS_TOKEN: \${{ github.token }}" \ "head_oid=\"\$(jq -r '.headRefOid' <<< \"\$metadata\")\"" \ 'for attempt in {1..30}' \ + "checks_probe=\"\$(GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --json name 2>&1)\"" \ 'no (required )?checks reported' \ - "gh pr checks \"\$number\" --required --watch --fail-fast" \ + 'map(.name) | index("check / check") != null' \ + "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --watch --fail-fast" \ "gh pr merge \"\$number\" --admin --squash --match-head-commit \"\$head_oid\""; do grep -Fq -- "$required" "$workflow" || { echo "release workflow is missing: $required" >&2 @@ -16,6 +20,11 @@ for required in \ } done +if grep -Fq 'branches/main/protection' "$workflow"; then + echo "release workflow must not require administration access to read branch protection" >&2 + exit 1 +fi + if grep -Eq 'gh[[:space:]]+pr[[:space:]]+merge[[:space:]].*--auto([[:space:]]|$)' "$workflow"; then echo "release workflow must use the app bypass instead of review-gated auto-merge" >&2 exit 1 From 49813229be9b4c47eb614953af9d055d7f949721 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 1 Sep 2026 00:23:22 -0400 Subject: [PATCH 2/4] fix(release): secure pending check waits --- .github/workflows/check.yml | 1 + .github/workflows/release-please.yml | 10 ++++++++-- scripts/ci/check-release-workflow.sh | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b464d03..3e5442d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -86,6 +86,7 @@ jobs: fi echo "OK: no version-file changes outside a Release Please PR." - run: make openapi-generated-check + - run: make release-workflow-check - run: make lint - run: make test - run: go build ./... diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 855d7fa..1e5e1b0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -6,9 +6,9 @@ on: - main workflow_dispatch: -# Serialize release runs so overlapping pushes don't race the release PR/tag. +# Serialize all release runs so manual and push events cannot race. concurrency: - group: release-please-${{ github.ref }} + group: release-please cancel-in-progress: false # The default GITHUB_TOKEN reads PR checks. Release writes use the scoped @@ -20,6 +20,7 @@ permissions: jobs: release-please: + if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -94,6 +95,11 @@ jobs: checks_ready=false for attempt in {1..30}; do if checks_probe="$(GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --required --json name 2>&1)"; then + checks_status=0 + else + checks_status=$? + fi + if [ "$checks_status" -eq 0 ] || [ "$checks_status" -eq 8 ]; then if ! jq -e 'map(.name) | index("check / check") != null' <<< "$checks_probe" >/dev/null; then echo "Required release check 'check / check' is not configured; merging nothing." >&2 exit 1 diff --git a/scripts/ci/check-release-workflow.sh b/scripts/ci/check-release-workflow.sh index 59fb107..7eccfb6 100755 --- a/scripts/ci/check-release-workflow.sh +++ b/scripts/ci/check-release-workflow.sh @@ -2,7 +2,14 @@ set -euo pipefail workflow=".github/workflows/release-please.yml" +check_workflow=".github/workflows/check.yml" +grep -Fxq -- ' group: release-please' "$workflow" || { + echo "release workflow must serialize all refs in one concurrency group" >&2 + exit 1 +} + for required in \ + " if: \${{ github.ref == 'refs/heads/main' }}" \ "gh pr list --state open --label 'autorelease: pending'" \ 'checks: read' \ 'pull-requests: read' \ @@ -10,6 +17,7 @@ for required in \ "head_oid=\"\$(jq -r '.headRefOid' <<< \"\$metadata\")\"" \ 'for attempt in {1..30}' \ "checks_probe=\"\$(GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --json name 2>&1)\"" \ + "if [ \"\$checks_status\" -eq 0 ] || [ \"\$checks_status\" -eq 8 ]; then" \ 'no (required )?checks reported' \ 'map(.name) | index("check / check") != null' \ "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --watch --fail-fast" \ @@ -20,6 +28,11 @@ for required in \ } done +grep -Fq -- 'run: make release-workflow-check' "$check_workflow" || { + echo "CI does not run the release workflow check" >&2 + exit 1 +} + if grep -Fq 'branches/main/protection' "$workflow"; then echo "release workflow must not require administration access to read branch protection" >&2 exit 1 From 326435f1ad83e0dd77f4bd19fcc64b4f9b1cb50a Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 1 Sep 2026 00:36:47 -0400 Subject: [PATCH 3/4] fix(release): require every check to pass --- .github/workflows/release-please.yml | 7 +++++++ scripts/ci/check-release-workflow.sh | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 1e5e1b0..b278473 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -119,6 +119,13 @@ jobs: exit 1 fi GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --required --watch --fail-fast + GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --watch --fail-fast + all_checks="$(GH_TOKEN="$CHECKS_TOKEN" gh pr checks "$number" --json bucket,name)" + if ! jq -e 'length > 0 and all(.[]; .bucket == "pass" or .bucket == "skipping")' <<< "$all_checks" >/dev/null; then + echo "Not all checks passed for release PR #${number}; merging nothing." >&2 + printf '%s\n' "$all_checks" >&2 + exit 1 + fi echo "Squash-merging release PR #${number} with the Volcano app ruleset bypass." gh pr merge "$number" --admin --squash --match-head-commit "$head_oid" done diff --git a/scripts/ci/check-release-workflow.sh b/scripts/ci/check-release-workflow.sh index 7eccfb6..b712c95 100755 --- a/scripts/ci/check-release-workflow.sh +++ b/scripts/ci/check-release-workflow.sh @@ -21,6 +21,8 @@ for required in \ 'no (required )?checks reported' \ 'map(.name) | index("check / check") != null' \ "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --watch --fail-fast" \ + "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --watch --fail-fast" \ + 'all(.[]; .bucket == "pass" or .bucket == "skipping")' \ "gh pr merge \"\$number\" --admin --squash --match-head-commit \"\$head_oid\""; do grep -Fq -- "$required" "$workflow" || { echo "release workflow is missing: $required" >&2 @@ -33,6 +35,13 @@ grep -Fq -- 'run: make release-workflow-check' "$check_workflow" || { exit 1 } +check_success='length > 0 and all(.[]; .bucket == "pass" or .bucket == "skipping")' +jq -e "$check_success" <<< '[{"bucket":"pass"},{"bucket":"skipping"}]' >/dev/null +if jq -e "$check_success" <<< '[{"bucket":"pass"},{"bucket":"fail"}]' >/dev/null; then + echo "release check policy accepted a failed non-required check" >&2 + exit 1 +fi + if grep -Fq 'branches/main/protection' "$workflow"; then echo "release workflow must not require administration access to read branch protection" >&2 exit 1 From f72c002cc402a7d46a38cfb9935cc39dc563de16 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 1 Sep 2026 00:49:24 -0400 Subject: [PATCH 4/4] fix(release): require complete server checks --- .github/workflows/release-please.yml | 14 ++++++-------- scripts/ci/check-release-workflow.sh | 11 ++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b278473..4e79b66 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -71,6 +71,7 @@ jobs: exit 0 fi current_major="${current_version%%.*}" + expected_checks='["Analyze (actions)","Analyze (go)","Analyze (javascript-typescript)","Analyze (python)","Analyze (ruby)","CodeQL","check / check","check / localmode-e2e","license/cla"]' gh pr list --state open --label 'autorelease: pending' --json number --jq '.[].number' | while read -r number; do metadata="$(gh pr view "$number" --json author,headRefName,headRefOid,title)" author="$(jq -r '.author.login' <<< "$metadata")" @@ -100,18 +101,15 @@ jobs: checks_status=$? fi if [ "$checks_status" -eq 0 ] || [ "$checks_status" -eq 8 ]; then - if ! jq -e 'map(.name) | index("check / check") != null' <<< "$checks_probe" >/dev/null; then - echo "Required release check 'check / check' is not configured; merging nothing." >&2 - exit 1 + if jq -e --argjson expected "$expected_checks" '$expected - (map(.name)) | length == 0' <<< "$checks_probe" >/dev/null; then + checks_ready=true + break fi - checks_ready=true - break - fi - if ! grep -Eq 'no (required )?checks reported' <<< "$checks_probe"; then + elif ! grep -Eq 'no (required )?checks reported' <<< "$checks_probe"; then printf '%s\n' "$checks_probe" >&2 exit 1 fi - echo "Required checks are not registered yet (attempt ${attempt}/30)." + echo "Required checks are not all registered yet (attempt ${attempt}/30)." sleep 10 done if [ "$checks_ready" != true ]; then diff --git a/scripts/ci/check-release-workflow.sh b/scripts/ci/check-release-workflow.sh index b712c95..f4b26f1 100755 --- a/scripts/ci/check-release-workflow.sh +++ b/scripts/ci/check-release-workflow.sh @@ -3,6 +3,7 @@ set -euo pipefail workflow=".github/workflows/release-please.yml" check_workflow=".github/workflows/check.yml" +expected_checks='["Analyze (actions)","Analyze (go)","Analyze (javascript-typescript)","Analyze (python)","Analyze (ruby)","CodeQL","check / check","check / localmode-e2e","license/cla"]' grep -Fxq -- ' group: release-please' "$workflow" || { echo "release workflow must serialize all refs in one concurrency group" >&2 exit 1 @@ -19,7 +20,8 @@ for required in \ "checks_probe=\"\$(GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --json name 2>&1)\"" \ "if [ \"\$checks_status\" -eq 0 ] || [ \"\$checks_status\" -eq 8 ]; then" \ 'no (required )?checks reported' \ - 'map(.name) | index("check / check") != null' \ + "expected_checks='$expected_checks'" \ + "\$expected - (map(.name)) | length == 0" \ "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --required --watch --fail-fast" \ "GH_TOKEN=\"\$CHECKS_TOKEN\" gh pr checks \"\$number\" --watch --fail-fast" \ 'all(.[]; .bucket == "pass" or .bucket == "skipping")' \ @@ -35,6 +37,13 @@ grep -Fq -- 'run: make release-workflow-check' "$check_workflow" || { exit 1 } +required_checks='[{"name":"Analyze (actions)"},{"name":"Analyze (go)"},{"name":"Analyze (javascript-typescript)"},{"name":"Analyze (python)"},{"name":"Analyze (ruby)"},{"name":"CodeQL"},{"name":"check / check"},{"name":"check / localmode-e2e"},{"name":"license/cla"}]' +jq -e --argjson expected "$expected_checks" '$expected - (map(.name)) | length == 0' <<< "$required_checks" >/dev/null +if jq -e --argjson expected "$expected_checks" '$expected - (map(.name)) | length == 0' <<< '[{"name":"check / check"}]' >/dev/null; then + echo "release check policy accepted an incomplete required-check set" >&2 + exit 1 +fi + check_success='length > 0 and all(.[]; .bucket == "pass" or .bucket == "skipping")' jq -e "$check_success" <<< '[{"bucket":"pass"},{"bucket":"skipping"}]' >/dev/null if jq -e "$check_success" <<< '[{"bucket":"pass"},{"bucket":"fail"}]' >/dev/null; then