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 d1bfb2f..4e79b66 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -6,18 +6,21 @@ 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 -# 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: + if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -55,6 +58,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 +71,7 @@ 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 + 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")" @@ -94,22 +95,35 @@ 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 - checks_ready=true - break + 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 ! grep -Eq 'no (required )?checks reported' <<< "$checks_probe"; then + if [ "$checks_status" -eq 0 ] || [ "$checks_status" -eq 8 ]; then + if jq -e --argjson expected "$expected_checks" '$expected - (map(.name)) | length == 0' <<< "$checks_probe" >/dev/null; then + checks_ready=true + break + fi + 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 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 + 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 f6afb53..f4b26f1 100755 --- a/scripts/ci/check-release-workflow.sh +++ b/scripts/ci/check-release-workflow.sh @@ -2,13 +2,29 @@ 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 +} + for required in \ + " if: \${{ github.ref == 'refs/heads/main' }}" \ "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)\"" \ + "if [ \"\$checks_status\" -eq 0 ] || [ \"\$checks_status\" -eq 8 ]; then" \ 'no (required )?checks reported' \ - "gh pr checks \"\$number\" --required --watch --fail-fast" \ + "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")' \ "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 +32,30 @@ 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 +} + +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 + 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 +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