11name : 🗺️ Observability Map
22
33on :
4+ # No paths filter, deliberately. GitHub evaluates one per workflow, so a pull request whose diff
5+ # stops matching does not start the workflow at all: the resolved state cannot fire and a comment
6+ # from an earlier push stands for ever showing findings that are no longer in the diff. Verified on
7+ # a throwaway pull request whose only route change was reverted, and the realistic case is worse
8+ # than that empty diff, because a pull request touching a route and other files, whose author
9+ # reverts the route change and keeps the rest, still has a non-empty diff that no longer matches.
10+ # The gating moved into the jobs below instead, where it can read whether a comment exists.
411 pull_request :
512 types : [opened, synchronize, reopened]
6- paths :
7- - " apps/webapp/app/routes/**"
8- - " internal-packages/observability-map/**"
913 # The corpus job below is gated to this package's own paths, so a scheduled run is what still
1014 # scans the tree as it drifts. Nightly rather than per route pull request: a new route can make a
1115 # known laundering shape start paying, but that is a property of the tree accumulating, not of any
@@ -22,17 +26,34 @@ permissions:
2226 contents : read
2327
2428jobs :
25- # The workflow's paths filter is the union of what the two jobs below want, because GitHub
26- # evaluates it once per workflow. This narrows it again for the corpus job alone.
29+ # The whole cost of a pull request that touches nothing this workflow watches: a checkout, a paths
30+ # filter and one comment lookup. Everything expensive is gated on this job's outputs, and the
31+ # lookup is here rather than in the report job so that gate can read it and the report job need
32+ # never start.
2733 changes :
28- name : 🔍 Which paths moved
34+ name : 🔍 What moved
2935 # Only the pull request path reads this job's output. On a schedule the action has no base to
3036 # diff, warns that `before` is missing and reports the files in the last commit on main, which
3137 # nothing then consults. Skipping it there keeps the nightly off a job it does not need.
3238 if : github.event_name == 'pull_request'
3339 runs-on : warp-ubuntu-latest-x64-2x
40+ permissions :
41+ contents : read
42+ # Reading the pull request's comments, to find one an earlier push left. Read only: the write
43+ # stays on the report job, which is the only job that posts.
44+ pull-requests : read
3445 outputs :
46+ # The corpus job's gate. Narrower than the report's on purpose: what the corpus measures is
47+ # the tool's resistance to laundering, which only an edit to the tool can weaken.
3548 package : ${{ steps.filter.outputs.package }}
49+ # The report job's gate, the union: a route change moves the report as well.
50+ report : ${{ steps.filter.outputs.package == 'true' || steps.filter.outputs.routes == 'true' }}
51+ # The id of a marker comment an earlier push left, empty if there is none, and the one source
52+ # both the render and upsert steps read it from.
53+ comment : ${{ steps.comment.outputs.id }}
54+ # Set only by a lookup that finished cleanly, so anything else, retries exhausted or the step
55+ # dying somewhere unforeseen, reads as "do not touch this pull request's comments".
56+ lookup : ${{ steps.comment.outputs.ok }}
3657 steps :
3758 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
3859 with :
4566 package:
4667 - 'internal-packages/observability-map/**'
4768 - '.github/workflows/observability-map.yml'
69+ routes:
70+ - 'apps/webapp/app/routes/**'
71+
72+ # Looked up here because the report job's gate needs it: with the watched paths unmoved, a
73+ # pull request that already has a comment gets a resolved state rather than being left with
74+ # findings that no longer exist, and one that does not gets no job at all.
75+ #
76+ # On a failure that outlasts the retries this reports nothing, and the report job's gate reads
77+ # that as "post nothing this run". Guessing is worse than silence: this step is the only thing
78+ # that knows which comment to PATCH, so a guess of "no comment exists" POSTs, which either
79+ # adds a second marker comment beside the stale one or says "the findings an earlier push
80+ # reported are gone" on a pull request that never had findings. Worst case now is no comment
81+ # this run, which the next push fixes.
82+ - name : 🔍 Look for a comment from an earlier push
83+ id : comment
84+ continue-on-error : true
85+ env :
86+ GH_TOKEN : ${{ github.token }}
87+ PR_NUMBER : ${{ github.event.pull_request.number }}
88+ run : |
89+ found=""
90+ ok=""
91+ for attempt in 1 2 3; do
92+ if found=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
93+ --jq '[.[] | select(.body | startswith("<!-- observability-map-report -->"))][0].id // empty'); then
94+ ok=1
95+ break
96+ fi
97+ echo "comment lookup attempt ${attempt} failed" >&2
98+ sleep $((attempt * 5))
99+ done
100+
101+ if [ -z "$ok" ]; then
102+ echo "comment lookup failed after 3 attempts; this run posts nothing" >&2
103+ exit 0
104+ fi
105+
106+ # --paginate runs the jq once per page, so a marker comment on more than one page yields
107+ # one id per page. Unhandled, that puts a newline in the PATCH url and the step dies under
108+ # continue-on-error. The oldest wins: it is the one the upsert has been updating.
109+ count=$(printf '%s\n' "$found" | grep -c '[0-9]' || true)
110+ if [ "$count" -gt 1 ]; then
111+ echo "warning: ${count} marker comments on this pull request; updating the oldest" >&2
112+ fi
113+ {
114+ echo "id=$(printf '%s\n' "$found" | awk 'NF { print $1; exit }')"
115+ echo "ok=ok"
116+ } >> "$GITHUB_OUTPUT"
48117
49118 # The tree-scale mutation corpus: every known laundering shape applied to the whole route tree,
50119 # asserting the score does not rise. Roughly four and a half minutes for 45 entries, which is why
@@ -114,6 +183,7 @@ jobs:
114183 # workflow the all-checks aggregate can see, so a job in this file would report a result nobody
115184 # is required to wait for. See unit-tests-observability-map.yml and the obsmap filter.
116185 report :
186+ needs : changes
117187 runs-on : warp-ubuntu-latest-x64-4x
118188 # Only this job comments, so only this job gets the write.
119189 permissions :
@@ -122,9 +192,25 @@ jobs:
122192 # Fork PRs get a read-only token, so the comment cannot post. Skipping the job beats a red x.
123193 # The event test is what keeps this job off the nightly, which has no pull request to comment on
124194 # and only exists for the corpus job above.
195+ #
196+ # The two output tests are what the workflow-level paths filter used to do, plus the thing it
197+ # could not do. The report has to run when the watched paths moved, and ALSO when they did not
198+ # but a marker comment is already on the pull request, because that comment is the one showing
199+ # findings that have left the diff. Reconciling it needs no scan, so the steps below are gated
200+ # again on the same output.
201+ #
202+ # `needs` carries an implicit success() and that is wanted here: a `changes` job that failed
203+ # knows neither which paths moved nor whether a comment exists, and a report job that ran anyway
204+ # could only guess. Same reason the lookup test is positive rather than a check for a failure
205+ # sentinel: retries exhausted, or the lookup step dying anywhere unforeseen, both leave the
206+ # output unset and both mean the same thing, so neither can be read as "no comment exists" by
207+ # one step and "a comment exists" by another. That disagreement is what the sentinel pair this
208+ # replaces got wrong once already.
125209 if : >-
126210 github.event_name == 'pull_request' &&
127- github.event.pull_request.head.repo.full_name == github.repository
211+ github.event.pull_request.head.repo.full_name == github.repository &&
212+ needs.changes.outputs.lookup == 'ok' &&
213+ (needs.changes.outputs.report == 'true' || needs.changes.outputs.comment != '')
128214 steps :
129215 - name : ⬇️ Checkout repo
130216 uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -163,7 +249,13 @@ jobs:
163249 # `-s` keeps the partial dance honest now the redirect no longer creates the file: a scanner
164250 # that exits 0 without writing takes the else branch and the stale-report comment, instead of
165251 # failing the `mv` and turning the job red.
252+ #
253+ # Gated: this is the expensive half, and the reconcile run has nothing to compare. The steps
254+ # above it are not gated because the renderer is TypeScript in this repo, so reconciling still
255+ # needs the checkout and the install. That is the cost of the reconcile run and it is paid only
256+ # by a pull request that has a comment and no longer matches the paths.
166257 - name : 🔎 Scan head
258+ if : needs.changes.outputs.report == 'true'
167259 run : |
168260 if pnpm --filter @internal/observability-map exec tsx src/cli.ts \
169261 --out=/tmp/head.json.partial && [ -s /tmp/head.json.partial ]; then
@@ -180,6 +272,7 @@ jobs:
180272 # request's own work. A merge base would leave the intervening base-branch commits in the head
181273 # tree and out of the base tree, and blame the pull request for all of them.
182274 - name : 🔎 Scan base with the head's scanner
275+ if : needs.changes.outputs.report == 'true'
183276 run : |
184277 if git worktree add /tmp/base-tree ${{ github.event.pull_request.base.sha }} \
185278 && pnpm --filter @internal/observability-map exec tsx src/cli.ts \
@@ -191,59 +284,26 @@ jobs:
191284 echo "base scan failed or the worktree could not be added; falling back to no base" >&2
192285 fi
193286
194- # Looked up before the render step because the render decision needs it: with no delta to
195- # report, a pull request that already has a comment gets a resolved state rather than being
196- # left with findings that no longer exist, and one that does not gets nothing at all. The
197- # upsert step reuses the id rather than asking twice.
198- #
199- # On a failure that outlasts the retries, both steps below do nothing. Guessing is worse than
200- # silence here: this step is the only thing that knows which comment to PATCH, so a guess of
201- # "a comment exists" still reaches an upsert with no id to patch, which POSTs. That either
202- # adds a second marker comment beside the stale one, or says "the findings an earlier push
203- # reported are gone" on a pull request that never had findings. Worst case now is no comment
204- # this run, which the next push fixes.
205- - name : 🔍 Look for a comment from an earlier push
206- continue-on-error : true
207- env :
208- GH_TOKEN : ${{ github.token }}
209- PR_NUMBER : ${{ github.event.pull_request.number }}
210- run : |
211- rm -f /tmp/existing-comment-id /tmp/comment-lookup-failed
212- found=""
213- ok=""
214- for attempt in 1 2 3; do
215- if found=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
216- --jq '[.[] | select(.body | startswith("<!-- observability-map-report -->"))][0].id // empty'); then
217- ok=1
218- break
219- fi
220- echo "comment lookup attempt ${attempt} failed" >&2
221- sleep $((attempt * 5))
222- done
223-
224- if [ -z "$ok" ]; then
225- touch /tmp/comment-lookup-failed
226- echo "comment lookup failed after 3 attempts; this run posts nothing" >&2
227- exit 0
228- fi
229-
230- # --paginate runs the jq once per page, so a marker comment on more than one page yields
231- # one id per page. Unhandled, that puts a newline in the PATCH url and the step dies under
232- # continue-on-error. The oldest wins: it is the one the upsert has been updating.
233- count=$(printf '%s\n' "$found" | grep -c '[0-9]' || true)
234- if [ "$count" -gt 1 ]; then
235- echo "warning: ${count} marker comments on this pull request; updating the oldest" >&2
236- fi
237- printf '%s\n' "$found" | awk 'NF { print $1; exit }' > /tmp/existing-comment-id
238-
239287 # continue-on-error for the same reason as the scan: a rendering bug must not turn the job
240288 # red. An empty /tmp/comment.md means there is nothing to post, which is a decision
241289 # prCommentCli makes, not this shell.
290+ #
291+ # Both shas are forwarded so every comment this job posts says which commit it was rendered
292+ # for, which a sticky comment edited in place across pushes otherwise never tells you. They go
293+ # through the CLI as data: the renderer builds no URL and reads no environment.
242294 - name : 📝 Render comment
243295 continue-on-error : true
296+ env :
297+ SCANNED : ${{ needs.changes.outputs.report }}
298+ EXISTING_COMMENT : ${{ needs.changes.outputs.comment }}
299+ HEAD_SHA : ${{ github.event.pull_request.head.sha }}
300+ COMPARE_URL : ${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
244301 run : |
245302 rm -f /tmp/comment.md
246- render() { pnpm --filter @internal/observability-map exec tsx src/report/prCommentCli.ts "$@"; }
303+ render() {
304+ pnpm --filter @internal/observability-map exec tsx src/report/prCommentCli.ts \
305+ --commit-sha="$HEAD_SHA" --commit-url="$COMPARE_URL" "$@"
306+ }
247307
248308 # Every write goes through this, so a renderer that exits non-zero never leaves a 0-byte
249309 # comment.md for the upsert to skip in silence.
@@ -256,8 +316,12 @@ jobs:
256316 return 1
257317 }
258318
259- if [ -f /tmp/comment-lookup-failed ]; then
260- echo "the comment lookup failed, so this run posts nothing" >&2
319+ # Nothing this workflow watches moved, so nothing was scanned and there is no delta to
320+ # compute. The job's gate only lets that case through when a comment from an earlier push
321+ # is on the pull request, so there is exactly one thing left to say: what it shows is not
322+ # in this diff any more.
323+ if [ "$SCANNED" != "true" ]; then
324+ emit --resolved || echo "could not render the resolved comment" >&2
261325 exit 0
262326 fi
263327
@@ -272,7 +336,7 @@ jobs:
272336 fi
273337
274338 flags=()
275- if [ -s /tmp/existing-comment-id ]; then
339+ if [ -n "$EXISTING_COMMENT" ]; then
276340 flags=(--existing-comment)
277341 fi
278342
@@ -283,28 +347,23 @@ jobs:
283347
284348 # continue-on-error for the same reason: a transient gh api failure (rate limit, network)
285349 # must not fail the job either. Worst case, the PR gets no comment this run.
350+ #
351+ # The id comes from the same job output the render step read, so the two cannot disagree about
352+ # whether a comment exists. A lookup that did not finish cleanly never reaches either of them:
353+ # the job's gate stops it.
286354 - name : 💬 Upsert PR comment
287355 continue-on-error : true
288356 env :
289357 GH_TOKEN : ${{ github.token }}
290358 PR_NUMBER : ${{ github.event.pull_request.number }}
359+ EXISTING_COMMENT : ${{ needs.changes.outputs.comment }}
291360 run : |
292- # The same sentinel the render step reads, so the two cannot disagree about what a failed
293- # lookup means. Without it this step reads a missing id as "no comment exists" and POSTs.
294- if [ -f /tmp/comment-lookup-failed ]; then
295- echo "the comment lookup failed, so this run posts nothing"
296- exit 0
297- fi
298361 if [ ! -s /tmp/comment.md ]; then
299362 echo "nothing to post: this pull request does not move the report"
300363 exit 0
301364 fi
302- existing=""
303- if [ -f /tmp/existing-comment-id ]; then
304- existing=$(cat /tmp/existing-comment-id)
305- fi
306- if [ -n "$existing" ]; then
307- gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -F body=@/tmp/comment.md
365+ if [ -n "$EXISTING_COMMENT" ]; then
366+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT}" -F body=@/tmp/comment.md
308367 else
309368 gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F body=@/tmp/comment.md
310369 fi
0 commit comments