|
4 | 4 | # - Variable TOWN_CRIER_URL = https://<app>.fly.dev |
5 | 5 | # - Secret TOWN_CRIER_TOKEN = the bearer token minted for "github-action" |
6 | 6 | # |
7 | | -# Produces BOTH sides of a request's lifecycle, so the bus never drifts from GitHub: |
8 | | -# - announce: when "Agent Review Requested" lands on a PR, tell the crier once. |
9 | | -# - resolve: when that PR closes/merges or its review label is removed, retire its |
10 | | -# thread — otherwise a landed PR sits "open" on the bus forever (there is |
11 | | -# no GitHub->bus merge sync). |
| 7 | +# Produces the FULL lifecycle of a request, so the bus never drifts from GitHub: |
| 8 | +# - announce: "Agent Review Requested" lands on a PR (or a new commit is pushed) -> tell the crier. |
| 9 | +# - resolve (close): the PR closes/merges or its review label is removed -> retire its thread. |
| 10 | +# - resolve (consensus): a 2nd independent approval lands with no outstanding change request -> retire the |
| 11 | +# thread; consensus is reached, so further review turns are just churn. |
| 12 | +# Without these a landed/settled PR sits "open" on the bus forever (there is no GitHub->bus merge sync). |
| 13 | +# The consensus job counts only approvals cast at the CURRENT head, so a stale approval from an earlier push |
| 14 | +# can't satisfy consensus on a reopened thread — robust regardless of the repo's dismiss-stale-reviews setting. |
| 15 | +# The bus never stores approval state; the job reads the live count and calls the EXISTING resolve verb. |
12 | 16 | # Joined harnesses pick up open requests from the bus — this workflow does NOT poll or review. |
13 | 17 | # |
14 | 18 | # Failure policy (two distinct modes, so a real problem is never silently masked): |
15 | 19 | # - MISSING provisioning (no TOWN_CRIER_URL/TOKEN) is a config error -> fail LOUD (red). |
16 | | -# - A bus HICCUP (cold start / transient 5xx / timeout) -> ::warning:: + stay GREEN (fail-open; |
17 | | -# a coordination-layer blip must never red a contributor's PR checks). |
18 | | -# Neither job uses the GITHUB_TOKEN (they auth to the bus with TOWN_CRIER_TOKEN), so permissions |
19 | | -# are dropped to nothing. |
20 | | -name: town-crier producer (announce + resolve) |
| 20 | +# - A bus HICCUP (cold start / transient 5xx / timeout) or an unreadable review list -> ::warning:: + |
| 21 | +# stay GREEN (fail-open; a coordination-layer blip must never red a contributor's PR checks). |
| 22 | +# The announce/resolve jobs use no GITHUB_TOKEN (they auth to the bus with TOWN_CRIER_TOKEN); the consensus |
| 23 | +# job needs pull-requests:read to count approvals, granted at job scope only. |
| 24 | +name: town-crier producer (announce + resolve + consensus) |
21 | 25 |
|
22 | 26 | permissions: {} |
23 | 27 |
|
24 | 28 | on: |
25 | | - pull_request: |
26 | | - # labeled -> first announce; synchronize -> re-announce each new commit (re-review on push); |
27 | | - # closed/unlabeled -> resolve (de-announce). A synchronize on an unlabelled PR is ignored by the if. |
28 | | - types: [labeled, synchronize, unlabeled, closed] |
| 29 | + pull_request: |
| 30 | + # labeled -> first announce; synchronize -> re-announce each new commit (re-review on push); |
| 31 | + # unlabeled/closed -> resolve (de-announce). A synchronize on an unlabelled PR is ignored by the if. |
| 32 | + types: [labeled, synchronize, unlabeled, closed] |
| 33 | + pull_request_review: |
| 34 | + # submitted -> a review landed; the consensus job checks whether 2 independent approvals now agree. |
| 35 | + types: [submitted] |
29 | 36 |
|
30 | 37 | jobs: |
31 | | - announce: |
32 | | - if: >- |
33 | | - (github.event.action == 'labeled' && github.event.label.name == 'Agent Review Requested') || |
34 | | - (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) |
35 | | - runs-on: ubuntu-latest |
36 | | - steps: |
37 | | - - name: Announce to the crier |
38 | | - env: |
39 | | - CRIER_URL: ${{ vars.TOWN_CRIER_URL }} |
40 | | - CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }} |
41 | | - PR_URL: ${{ github.event.pull_request.html_url }} |
42 | | - REPO: ${{ github.repository }} |
43 | | - TITLE: ${{ github.event.pull_request.title }} |
44 | | - REQUESTER: ${{ github.event.pull_request.user.login }} |
45 | | - # The head SHA is the change detector: a re-announce with the same head is a no-op on the |
46 | | - # bus; a new head reopens the thread for a fresh review round. |
47 | | - HEAD_OID: ${{ github.event.pull_request.head.sha }} |
48 | | - run: | |
49 | | - # Missing provisioning is a config error — fail LOUD so it can't pass silently. |
50 | | - if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then |
51 | | - echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret" |
52 | | - exit 1 |
53 | | - fi |
54 | | - # jq builds the JSON so a PR title with quotes can't break the payload. |
55 | | - # A bus hiccup is not the PR's fault — degrade to a warning, keep the check green. |
56 | | - curl -fsS --max-time 10 -X POST "$CRIER_URL/announce" \ |
57 | | - -H "Authorization: Bearer $CRIER_TOKEN" \ |
58 | | - -H "Content-Type: application/json" \ |
59 | | - -d "$(jq -n \ |
60 | | - --arg pr "$PR_URL" \ |
61 | | - --arg repo "$REPO" \ |
62 | | - --arg title "$TITLE" \ |
63 | | - --arg requester "$REQUESTER" \ |
64 | | - --arg head_oid "$HEAD_OID" \ |
65 | | - '{pr_url:$pr, repo:$repo, title:$title, requester:$requester, head_oid:$head_oid}')" \ |
66 | | - || echo "::warning::town-crier announce failed (transient bus issue?) — not blocking the PR" |
| 38 | + announce: |
| 39 | + if: >- |
| 40 | + (github.event.action == 'labeled' && github.event.label.name == 'Agent Review Requested') || |
| 41 | + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) |
| 42 | + runs-on: ubuntu-latest |
| 43 | + steps: |
| 44 | + - name: Announce to the crier |
| 45 | + env: |
| 46 | + CRIER_URL: ${{ vars.TOWN_CRIER_URL }} |
| 47 | + CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }} |
| 48 | + PR_URL: ${{ github.event.pull_request.html_url }} |
| 49 | + REPO: ${{ github.repository }} |
| 50 | + TITLE: ${{ github.event.pull_request.title }} |
| 51 | + REQUESTER: ${{ github.event.pull_request.user.login }} |
| 52 | + # The head SHA is the change detector: a re-announce with the same head is a no-op on the |
| 53 | + # bus; a new head reopens the thread for a fresh review round. |
| 54 | + HEAD_OID: ${{ github.event.pull_request.head.sha }} |
| 55 | + run: | |
| 56 | + # Missing provisioning is a config error — fail LOUD so it can't pass silently. |
| 57 | + if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then |
| 58 | + echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret" |
| 59 | + exit 1 |
| 60 | + fi |
| 61 | + # jq builds the JSON so a PR title with quotes can't break the payload. |
| 62 | + # A bus hiccup is not the PR's fault — degrade to a warning, keep the check green. |
| 63 | + curl -fsS --max-time 10 -X POST "$CRIER_URL/announce" \ |
| 64 | + -H "Authorization: Bearer $CRIER_TOKEN" \ |
| 65 | + -H "Content-Type: application/json" \ |
| 66 | + -d "$(jq -n \ |
| 67 | + --arg pr "$PR_URL" \ |
| 68 | + --arg repo "$REPO" \ |
| 69 | + --arg title "$TITLE" \ |
| 70 | + --arg requester "$REQUESTER" \ |
| 71 | + --arg head_oid "$HEAD_OID" \ |
| 72 | + '{pr_url:$pr, repo:$repo, title:$title, requester:$requester, head_oid:$head_oid}')" \ |
| 73 | + || echo "::warning::town-crier announce failed (transient bus issue?) — not blocking the PR" |
67 | 74 |
|
68 | | - resolve: |
69 | | - if: (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) || (github.event.action == 'unlabeled' && github.event.label.name == 'Agent Review Requested') |
70 | | - runs-on: ubuntu-latest |
71 | | - steps: |
72 | | - - name: Resolve on the crier |
73 | | - env: |
74 | | - CRIER_URL: ${{ vars.TOWN_CRIER_URL }} |
75 | | - CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }} |
76 | | - PR_URL: ${{ github.event.pull_request.html_url }} |
77 | | - ACTION: ${{ github.event.action }} |
78 | | - MERGED: ${{ github.event.pull_request.merged }} |
79 | | - run: | |
80 | | - if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then |
81 | | - echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret" |
82 | | - exit 1 |
83 | | - fi |
84 | | - if [ "$ACTION" = "unlabeled" ]; then |
85 | | - NOTE="review label removed" |
86 | | - elif [ "$MERGED" = "true" ]; then |
87 | | - NOTE="merged" |
88 | | - else |
89 | | - NOTE="closed without merge" |
90 | | - fi |
91 | | - curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \ |
92 | | - -H "Authorization: Bearer $CRIER_TOKEN" \ |
93 | | - -H "Content-Type: application/json" \ |
94 | | - -d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \ |
95 | | - || echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR" |
| 75 | + resolve: |
| 76 | + if: (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'Agent Review Requested')) || (github.event.action == 'unlabeled' && github.event.label.name == 'Agent Review Requested') |
| 77 | + runs-on: ubuntu-latest |
| 78 | + steps: |
| 79 | + - name: Resolve on the crier |
| 80 | + env: |
| 81 | + CRIER_URL: ${{ vars.TOWN_CRIER_URL }} |
| 82 | + CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }} |
| 83 | + PR_URL: ${{ github.event.pull_request.html_url }} |
| 84 | + ACTION: ${{ github.event.action }} |
| 85 | + MERGED: ${{ github.event.pull_request.merged }} |
| 86 | + run: | |
| 87 | + if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then |
| 88 | + echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret" |
| 89 | + exit 1 |
| 90 | + fi |
| 91 | + if [ "$ACTION" = "unlabeled" ]; then |
| 92 | + NOTE="review label removed" |
| 93 | + elif [ "$MERGED" = "true" ]; then |
| 94 | + NOTE="merged" |
| 95 | + else |
| 96 | + NOTE="closed without merge" |
| 97 | + fi |
| 98 | + curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \ |
| 99 | + -H "Authorization: Bearer $CRIER_TOKEN" \ |
| 100 | + -H "Content-Type: application/json" \ |
| 101 | + -d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \ |
| 102 | + || echo "::warning::town-crier resolve failed (transient bus issue?) — not blocking the PR" |
| 103 | +
|
| 104 | + consensus: |
| 105 | + # A review landed — if it's an approval on a labelled PR, retire the bus thread once TWO independent |
| 106 | + # approvals agree with no outstanding change request. We read the live count (filtered to the current |
| 107 | + # head, so stale approvals can't satisfy consensus) rather than tracking it on the bus. |
| 108 | + if: >- |
| 109 | + github.event.review.state == 'approved' && |
| 110 | + contains(github.event.pull_request.labels.*.name, 'Agent Review Requested') |
| 111 | + runs-on: ubuntu-latest |
| 112 | + permissions: |
| 113 | + pull-requests: read # read the PR's reviews to count approvals (GITHUB_TOKEN, this job only) |
| 114 | + steps: |
| 115 | + - name: Resolve on consensus |
| 116 | + env: |
| 117 | + CRIER_URL: ${{ vars.TOWN_CRIER_URL }} |
| 118 | + CRIER_TOKEN: ${{ secrets.TOWN_CRIER_TOKEN }} |
| 119 | + GH_TOKEN: ${{ github.token }} |
| 120 | + REPO: ${{ github.repository }} |
| 121 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 122 | + PR_URL: ${{ github.event.pull_request.html_url }} |
| 123 | + HEAD_OID: ${{ github.event.pull_request.head.sha }} |
| 124 | + run: | |
| 125 | + if [ -z "$CRIER_URL" ] || [ -z "$CRIER_TOKEN" ]; then |
| 126 | + echo "::error::town-crier not provisioned — set the TOWN_CRIER_URL variable + TOWN_CRIER_TOKEN secret" |
| 127 | + exit 1 |
| 128 | + fi |
| 129 | + # Tally the LATEST opinionated review per reviewer (ignore COMMENTED/PENDING), counting an |
| 130 | + # approval ONLY if it was cast at the CURRENT head — a stale approval from an earlier push must |
| 131 | + # not satisfy consensus on a reopened thread (correct regardless of dismiss_stale_reviews). |
| 132 | + # NDJSON via --paginate --jq '.[]' then slurp with jq -s — avoids `gh api --slurp` (newer-gh only). |
| 133 | + tally="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" --jq '.[]' \ |
| 134 | + | jq -s --arg head "$HEAD_OID" ' |
| 135 | + map(select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED")) |
| 136 | + | group_by(.user.login) | map(max_by(.submitted_at)) |
| 137 | + | {approved: (map(select(.state == "APPROVED" and .commit_id == $head)) | length), |
| 138 | + changes: (map(select(.state == "CHANGES_REQUESTED")) | length), |
| 139 | + who: (map(select(.state == "APPROVED" and .commit_id == $head)) | map(.user.login) | join(", "))}' \ |
| 140 | + )" || { echo "::warning::could not read PR reviews (transient?) — not blocking the PR"; exit 0; } |
| 141 | + approved="$(printf '%s' "$tally" | jq -r '.approved')" |
| 142 | + changes="$(printf '%s' "$tally" | jq -r '.changes')" |
| 143 | + who="$(printf '%s' "$tally" | jq -r '.who')" |
| 144 | + echo "approvals=$approved changes_requested=$changes approvers=[$who]" |
| 145 | + # Consensus = >= 2 distinct CURRENT-HEAD approvals AND no outstanding change request. |
| 146 | + if [ "${approved:-0}" -lt 2 ] || [ "${changes:-0}" -ne 0 ]; then |
| 147 | + echo "no consensus yet — leaving the thread open for another turn" |
| 148 | + exit 0 |
| 149 | + fi |
| 150 | + NOTE="consensus: ${approved} approvals (${who}) @${HEAD_OID:0:12}" |
| 151 | + curl -fsS --max-time 10 -X POST "$CRIER_URL/resolve" \ |
| 152 | + -H "Authorization: Bearer $CRIER_TOKEN" \ |
| 153 | + -H "Content-Type: application/json" \ |
| 154 | + -d "$(jq -n --arg pr "$PR_URL" --arg note "$NOTE" '{pr_url:$pr, note:$note}')" \ |
| 155 | + || echo "::warning::town-crier consensus-resolve failed (transient bus issue?) — not blocking the PR" |
0 commit comments