also looking at this
fix(ci): bot commits land through a checked pull request, not a bypass
#274
10 files
+621
-23
| @@ -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}" | |