also looking at this
fix(ci): bot commits land through a checked pull request, not a bypass
#274
10 files
+621
-23
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,154 @@ | ||
| 1 | name: Merge via PR | |
| 2 | description: >- | |
| 3 | Land an already-committed change on the default branch through a pull request | |
| 4 | that actually runs the required checks. | |
| 5 | ||
| 6 | inputs: | |
| 7 | token: | |
| 8 | description: "GitHub token with contents, pull-requests and actions write." | |
| 9 | required: true | |
| 10 | branch-prefix: | |
| 11 | description: "Branch namespace for the pull request's head." | |
| 12 | required: false | |
| 13 | default: 'chore/automated' | |
| 14 | title: | |
| 15 | description: "Pull request title. Defaults to the commit subject." | |
| 16 | required: false | |
| 17 | default: '' | |
| 18 | body: | |
| 19 | description: "Pull request body." | |
| 20 | required: false | |
| 21 | default: '' | |
| 22 | checks: | |
| 23 | description: "Comma-separated required check names to wait for." | |
| 24 | required: false | |
| 25 | default: 'lint,typecheck,test,zig-tests' | |
| 26 | workflow: | |
| 27 | description: "Workflow dispatched to produce those checks." | |
| 28 | required: false | |
| 29 | default: 'ci.yml' | |
| 30 | timeout-minutes: | |
| 31 | description: "How long to wait for the checks before giving up." | |
| 32 | required: false | |
| 33 | default: '40' | |
| 34 | ||
| 35 | outputs: | |
| 36 | merged: | |
| 37 | description: "true when the pull request was merged." | |
| 38 | value: ${{ steps.land.outputs.merged }} | |
| 39 | pr-number: | |
| 40 | description: "Number of the pull request that was opened." | |
| 41 | value: ${{ steps.land.outputs.pr-number }} | |
| 42 | pr-url: | |
| 43 | description: "URL of the pull request that was opened." | |
| 44 | value: ${{ steps.land.outputs.pr-url }} | |
| 45 | ||
| 46 | runs: | |
| 47 | using: composite | |
| 48 | steps: | |
| 49 | # A bot cannot push to the default branch: the branch ruleset requires the | |
| 50 | # CI contexts, and `github-actions[bot]` holds no repository role, so its | |
| 51 | # push is refused with GH013. Going through a pull request is the honest | |
| 52 | # answer — the generated catalog gets checked like anything else — but it | |
| 53 | # needs one piece of care. GitHub suppresses events caused by GITHUB_TOKEN, | |
| 54 | # which covers a pull request opened with it just as it covers a push, so | |
| 55 | # the `pull_request` trigger never fires and nothing would ever report | |
| 56 | # against the required contexts. Dispatching CI at the head branch does | |
| 57 | # report: a check run is attached to a commit, and that commit is the one | |
| 58 | # the pull request is gated on. | |
| 59 | - name: Land the commit through a pull request | |
| 60 | id: land | |
| 61 | shell: bash | |
| 62 | env: | |
| 63 | GH_TOKEN: ${{ inputs.token }} | |
| 64 | REPO: ${{ github.repository }} | |
| 65 | BASE: ${{ github.event.repository.default_branch || 'main' }} | |
| 66 | PREFIX: ${{ inputs.branch-prefix }} | |
| 67 | TITLE_IN: ${{ inputs.title }} | |
| 68 | BODY_IN: ${{ inputs.body }} | |
| 69 | CHECKS: ${{ inputs.checks }} | |
| 70 | WORKFLOW: ${{ inputs.workflow }} | |
| 71 | TIMEOUT_MIN: ${{ inputs.timeout-minutes }} | |
| 72 | RUN_ID: ${{ github.run_id }} | |
| 73 | RUN_ATTEMPT: ${{ github.run_attempt }} | |
| 74 | run: | | |
| 75 | set -uo pipefail | |
| 76 | ||
| 77 | BRANCH="${PREFIX}/${RUN_ID}-${RUN_ATTEMPT}" | |
| 78 | SHA="$(git rev-parse HEAD)" | |
| 79 | TITLE="${TITLE_IN:-$(git log -1 --pretty=%s)}" | |
| 80 | echo "merged=false" >> "$GITHUB_OUTPUT" | |
| 81 | ||
| 82 | # Each run's commit regenerates the whole of what the last one wrote, so | |
| 83 | # an older open pull request from this same prefix is superseded rather | |
| 84 | # than merely stale. Closing it keeps a red or slow run from piling up a | |
| 85 | # queue of pull requests that all say the same thing. | |
| 86 | gh pr list --repo "$REPO" --state open --json number,headRefName \ | |
| 87 | --jq ".[] | select(.headRefName | startswith(\"${PREFIX}/\")) | .number" 2>/dev/null \ | |
| 88 | | while read -r stale; do | |
| 89 | [ -n "$stale" ] || continue | |
| 90 | echo "Superseding #${stale}" | |
| 91 | gh pr close "$stale" --repo "$REPO" --delete-branch \ | |
| 92 | --comment "Superseded by a newer automated run." || true | |
| 93 | done | |
| 94 | ||
| 95 | if ! git push origin "HEAD:refs/heads/${BRANCH}"; then | |
| 96 | echo "::error::could not push ${BRANCH}" | |
| 97 | exit 1 | |
| 98 | fi | |
| 99 | ||
| 100 | PR_URL="$(gh pr create --repo "$REPO" --base "$BASE" --head "$BRANCH" \ | |
| 101 | --title "$TITLE" --body "${BODY_IN:-Opened automatically so the required checks run on generated output.}")" | |
| 102 | if [ -z "$PR_URL" ]; then | |
| 103 | echo "::error::could not open a pull request for ${BRANCH}" | |
| 104 | git push origin --delete "$BRANCH" || true | |
| 105 | exit 1 | |
| 106 | fi | |
| 107 | PR_NUMBER="${PR_URL##*/}" | |
| 108 | echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| 109 | echo "pr-url=${PR_URL}" >> "$GITHUB_OUTPUT" | |
| 110 | echo "Opened ${PR_URL}" | |
| 111 | ||
| 112 | if ! gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$BRANCH"; then | |
| 113 | echo "::error::could not dispatch ${WORKFLOW} at ${BRANCH}; nothing would report on ${SHA}" | |
| 114 | exit 1 | |
| 115 | fi | |
| 116 | ||
| 117 | IFS=',' read -ra WANTED <<< "$CHECKS" | |
| 118 | DEADLINE=$(( $(date +%s) + TIMEOUT_MIN * 60 )) | |
| 119 | while :; do | |
| 120 | if [ "$(date +%s)" -ge "$DEADLINE" ]; then | |
| 121 | echo "::error::${CHECKS} did not all report on ${SHA} within ${TIMEOUT_MIN}m; ${PR_URL} is left open" | |
| 122 | exit 1 | |
| 123 | fi | |
| 124 | ||
| 125 | RUNS="$(gh api "repos/${REPO}/commits/${SHA}/check-runs?per_page=100" \ | |
| 126 | --jq '.check_runs[] | "\(.name)\t\(.status)\t\(.conclusion // "")"' 2>/dev/null)" | |
| 127 | ||
| 128 | PASSED=0 | |
| 129 | FAILED='' | |
| 130 | for want in "${WANTED[@]}"; do | |
| 131 | line="$(printf '%s\n' "$RUNS" | awk -F'\t' -v n="$want" '$1 == n { print; exit }')" | |
| 132 | [ -n "$line" ] || continue | |
| 133 | status="$(printf '%s' "$line" | cut -f2)" | |
| 134 | [ "$status" = "completed" ] || continue | |
| 135 | case "$(printf '%s' "$line" | cut -f3)" in | |
| 136 | success|skipped|neutral) PASSED=$((PASSED + 1)) ;; | |
| 137 | *) FAILED="${FAILED:+$FAILED, }${want}" ;; | |
| 138 | esac | |
| 139 | done | |
| 140 | ||
| 141 | if [ -n "$FAILED" ]; then | |
| 142 | echo "::error::required checks failed on generated output: ${FAILED}. ${PR_URL} is left open to show why." | |
| 143 | exit 1 | |
| 144 | fi | |
| 145 | [ "$PASSED" -eq "${#WANTED[@]}" ] && break | |
| 146 | sleep 20 | |
| 147 | done | |
| 148 | ||
| 149 | if ! gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch; then | |
| 150 | echo "::error:__stx_checks="eyJzdGF0ZSI6Im5ldXRyYWwiLCJjb3VudHMiOnsiZmFpbHVyZSI6MCwicGVuZGluZyI6MCwic3VjY2VzcyI6MCwibmV1dHJhbCI6MH0sImVudHJpZXMiOltdLCJyZXF1aXJlZCI6eyJzYXRpc2ZpZWQiOnRydWUsIm1pc3NpbmciOltdLCJmYWlsaW5nIjpbXSwicGVuZGluZyI6W119LCJhbm5vdGF0aW9ucyI6W119" passed but ${PR_URL} would not merge; it is left open" | |
| 151 | exit 1 | |
| 152 | fi | |
| 153 | echo "merged=true" >> "$GITHUB_OUTPUT" | |
| 154 | echo "Merged ${PR_URL}" | |
| @@ -142,6 +142,10 @@ jobs: | ||
| 142 | 142 | # the commit — the gate cannot let a stale manifest through. |
| 143 | 143 | record: |
| 144 | 144 | needs: [publish-ubuntu, publish-macos] |
| 145 | permissions: | |
| 146 | contents: write | |
| 147 | actions: write | |
| 148 | pull-requests: write | |
| 145 | 149 | if: >- |
| 146 | 150 | always() && |
| 147 | 151 | needs.publish-ubuntu.result == 'success' && |
| @@ -160,17 +164,14 @@ jobs: | ||
| 160 | 164 | - name: Record verified registry state |
| 161 | 165 | working-directory: packages/ts-pantry |
| 162 | 166 | run: bun scripts/check-desktop-updates.ts --require-current --commit |
| 163 | - name: Push verified manifest commit | |
| 164 | working-directory: packages/ts-pantry | |
| 165 | run: | | |
| 166 | for attempt in 1 2 3 4 5; do | |
| 167 | if git push; then | |
| 168 | echo "push ok (attempt $attempt)" | |
| 169 | exit 0 | |
| 170 | fi | |
| 171 | echo "push rejected (attempt $attempt); rebasing onto latest main" | |
| 172 | git fetch origin main | |
| 173 | git rebase origin/main | |
| 174 | done | |
| 175 | echo "ERROR: could not push manifest after 5 attempts" >&2 | |
| 176 | exit 1 | |
| 167 | # The rebase-and-retry loop this replaces answered a race with another | |
| 168 | # push. It cannot answer the ruleset: main requires the CI contexts, a | |
| 169 | # bot push holds no repository role to be excused by, and retrying a | |
| 170 | # GH013 five times only rebases five times before failing anyway. | |
| 171 | - name: Land the verified manifest through a pull request | |
| 172 | uses: ./.github/actions/merge-via-pr | |
| 173 | with: | |
| 174 | token: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
| 175 | branch-prefix: chore/desktop-manifest | |
| 176 | body: | | |
| 177 | Verified desktop manifest state from `check-desktop-updates.yml`. | |
| @@ -53,10 +53,12 @@ jobs: | ||
| 53 | 53 | update-packages: |
| 54 | 54 | name: Update packages and index |
| 55 | 55 | runs-on: ubuntu-latest |
| 56 | timeout-minutes: 30 | |
| 56 | # Covers the sweep plus the wait for CI to report on the pull request. | |
| 57 | timeout-minutes: 80 | |
| 57 | 58 | permissions: |
| 58 | 59 | contents: write |
| 59 | 60 | actions: write |
| 61 | pull-requests: write | |
| 60 | 62 | |
| 61 | 63 | steps: |
| 62 | 64 | - name: Checkout repository |
| @@ -221,7 +223,7 @@ jobs: | ||
| 221 | 223 | fi |
| 222 | 224 | echo "safe_to_commit=true" >> $GITHUB_OUTPUT |
| 223 | 225 | |
| 224 | - name: Commit and push changes | |
| 226 | - name: Commit changes | |
| 225 | 227 | id: commit |
| 226 | 228 | if: steps.git-check.outputs.changes == 'true' && steps.deletion-check.outputs.safe_to_commit == 'true' |
| 227 | 229 | run: | |
| @@ -234,12 +236,32 @@ jobs: | ||
| 234 | 236 | test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" |
| 235 | 237 | git add packages/ts-pantry/src/packages/ packages/ts-pantry/src/generated-package-names.ts packages/zig/src/packages/generated.zig packages/action/dist/index.js packages/action/dist/post.js |
| 236 | 238 | git commit -F /tmp/pantry-update-commit-message.txt |
| 237 | git push origin HEAD:refs/heads/main | |
| 238 | 239 | |
| 239 | echo "committed=true" >> $GITHUB_OUTPUT | |
| 240 | # This commit used to go straight to main. It cannot any more: main | |
| 241 | # requires the CI contexts, and a bot push carries no repository role to | |
| 242 | # be excused by, so the push comes back GH013. The alternative to a pull | |
| 243 | # request was excusing the Actions app from the checks, which would have | |
| 244 | # exempted every workflow that ever pushes rather than this one, and | |
| 245 | # would have kept the generated catalog the only thing on main nobody | |
| 246 | # checks. So it is checked now, like everything else. | |
| 247 | - name: Land the update through a pull request | |
| 248 | id: land | |
| 249 | if: steps.commit.conclusion == 'success' | |
| 250 | uses: ./.github/actions/merge-via-pr | |
| 251 | with: | |
| 252 | token: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} | |
| 253 | branch-prefix: chore/package-updates | |
| 254 | body: | | |
| 255 | Automated catalog refresh from `update-packages.yml`. | |
| 256 | ||
| 257 | Opened rather than pushed because `main` requires the CI contexts | |
| 258 | and a bot push holds no role to be excused by. CI is dispatched at | |
| 259 | this branch explicitly: a pull request opened with `GITHUB_TOKEN` | |
| 260 | does not fire `pull_request`, so without the dispatch nothing would | |
| 261 | ever report here. | |
| 240 | 262 | |
| 241 | 263 | - name: Publish updated packages to the registry |
| 242 | if: steps.commit.outputs.committed == 'true' | |
| 264 | if: steps.land.outputs.merged == 'true' | |
| 243 | 265 | env: |
| 244 | 266 | GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} |
| 245 | 267 | run: | |