Skip to content

Commit 042c9c3

Browse files
committed
fix(observability-map): reconcile a comment the paths no longer reach
The report workflow carried a `paths:` filter, and GitHub evaluates one of those per workflow, so a pull request whose diff stopped matching never started the workflow at all: the resolved state could not fire and a comment from an earlier push stood for ever showing findings that had left the diff. Verified on a throwaway pull request whose only route change was reverted. The case that matters is the one touching a route and other files whose author reverts only the route change, which still has a diff and still does not match. The workflow now runs on every pull request and the gating is internal. The cheap path-detection job also looks for the marker comment, so the report job starts only when the watched paths moved or that comment already exists, and in the second case it reconciles the comment to its resolved state without scanning anything. A pull request with neither pays for that one cheap job. The lookup moving there also retires the sentinel pair the render and upsert steps shared: the job cannot start unless the lookup finished cleanly, and both steps read the id from one job output. Every comment now names the head commit it was rendered for, as a link to the pull request's compare range, because a sticky comment edited in place across pushes otherwise says nothing about which push it reflects. The sha and the URL are passed through the CLI as data, so the renderers stay pure and a local run without them still renders.
1 parent 4b0b243 commit 042c9c3

7 files changed

Lines changed: 463 additions & 118 deletions

File tree

.github/workflows/observability-map.yml

Lines changed: 127 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
name: 🗺️ Observability Map
22

33
on:
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

2428
jobs:
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:
@@ -45,6 +66,54 @@ jobs:
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

internal-packages/observability-map/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,21 @@ prefix matches more than one route rather than silently taking the first`).
2929
## CI
3030

3131
A PR that touches `apps/webapp/app/routes` or this package gets a sticky comment scanning head
32-
against the tip of the base branch, with the score, what changed, and the current fix list. It is
33-
report-only: nothing here fails the build or blocks a merge, and the gate stays deferred until a
32+
against the tip of the base branch, with the score, what changed, and the current fix list. Every
33+
comment names the head commit it was rendered for, as a link to the PR's compare range, because the
34+
comment is edited in place across pushes and otherwise says nothing about which push it reflects. It
35+
is report-only: nothing here fails the build or blocks a merge, and the gate stays deferred until a
3436
later phase decides to add one. See `.github/workflows/observability-map.yml`.
3537

38+
The workflow itself runs on every PR, and the paths above are a gate inside it rather than a `paths:`
39+
filter on the trigger. GitHub evaluates one of those per workflow, so a PR whose diff stops matching
40+
does not start the workflow at all, and the comment an earlier push left then stands for ever showing
41+
findings that are no longer in the diff. The case that matters is not a PR reverted to nothing: it is
42+
one touching a route and other files whose author reverts the route change and keeps the rest, which
43+
still has a diff and still does not match. So a PR with a comment and nothing left to compare gets
44+
the comment reconciled to its resolved state without scanning anything, and a PR with neither pays
45+
for one cheap job that reads the paths and looks for a comment.
46+
3647
This paragraph used to say "merge base", and two reviewers read that against
3748
`github.event.pull_request.base.sha` and reported the workflow as the thing that was wrong. It is
3849
the other way round. `actions/checkout` on a `pull_request` event checks out GitHub's test merge

0 commit comments

Comments
 (0)