Skip to content

Commit d49feaa

Browse files
kim-emclaude
andcommitted
feat(ci): cross-reference tag review via post-build lint + workflow_run
Replace the text-based cross-reference extractor with an elaboration- based lint step that runs in the build pipeline's `post_steps` job and a `workflow_run`-triggered companion that posts the bot comment. **Why.** The previous text-based extractor (a ~250 LOC byte-level scanner in `scripts/crossref.lean`) was a workaround for not having a built Mathlib environment in the pull_request_target workflow. Now that we run as a post-build lint step, we have a fully elaborated environment and can read `Mathlib.CrossRef.tagExt` directly — cutting ~250 LOC and eliminating the fidelity gap with the Lean parser. **Pieces:** * `scripts/crossref.lean` — the `extract` subcommand is gone. New `check --diff <range> --tsv <path>` subcommand uses `importModules (loadExts := true)` (NOT `withImportModules`, which passes `loadExts := false` and would leave `tagExt` empty), walks `tagExt`, looks up each tagged declaration's source module via `env.getModuleFor?`, and filters by `git diff --name-only` of the PR range. Output is machine-readable TSV (`db\ttag\tdeclName\tfile\tcomment`). No Markdown, no network, no snippets — those are the privileged workflow's job. * `.github/workflows/build_template.yml` — adds three new steps to `post_steps`, after the import-graph upload: - Run `lake env lean --run scripts/crossref.lean check ...` against the diff between the PR base and HEAD. - Write a `crossref-context.json` with PR number + head SHA so the companion workflow doesn't have to rely on the unreliable `workflow_run.pull_requests[0].number`. - Upload both as a single `crossref-context` artifact. * `.github/workflows/crossref_review.yml` — rewritten. Old trigger `pull_request_target` → new trigger `workflow_run`. Downloads the artifact, resolves the PR number through a three-stage fallback chain (artifact JSON → workflow_run payload → `gh pr list` head SHA match), calls the mathlib-ci orchestrator (which now consumes the TSV and fetches snippets in Python), posts/updates/deletes via `update_PR_comment.sh`, and fails the check on `missing` tags. **Privilege model.** The lint step runs with the PR's permissions and produces only machine-readable TSV. The `workflow_run` job runs in the base ref's permission scope (with `pull-requests: write`) and never executes anything from the artifact — it parses the TSV defensively, fetches snippets server-side from three fixed upstream APIs, and builds the Markdown comment in trusted code. Companion mathlib-ci PR: leanprover-community/mathlib-ci#39 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 157091c commit d49feaa

5 files changed

Lines changed: 312 additions & 521 deletions

File tree

.github/workflows/build_template.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,46 @@ jobs:
745745
- name: clean up the import graph file
746746
run: rm import_graph.dot
747747

748+
- name: cross-reference tag review (extract added tags)
749+
id: crossref
750+
if: github.event.pull_request.base.sha != ''
751+
env:
752+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
753+
BASE_REF: ${{ github.event.pull_request.base.ref }}
754+
run: |
755+
set -e
756+
# post_steps checks out with default fetch-depth=1; fetch enough history to diff.
757+
git fetch --no-tags --depth=200 origin "$BASE_REF" || true
758+
: > crossref-added.tsv
759+
lake env lean --run scripts/crossref.lean check \
760+
--diff "${BASE_SHA}..HEAD" \
761+
--tsv crossref-added.tsv
762+
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
763+
764+
- name: cross-reference tag review (artifact context)
765+
if: github.event.pull_request.base.sha != '' && always()
766+
env:
767+
PR_NUMBER: ${{ github.event.pull_request.number }}
768+
HEAD_SHA: ${{ steps.crossref.outputs.head_sha }}
769+
run: |
770+
# Encode the PR number + head SHA into a small JSON so the companion
771+
# workflow_run-triggered workflow can resolve them without depending
772+
# on `github.event.workflow_run.pull_requests[0].number` (which is
773+
# not always populated).
774+
jq -n --arg pr "$PR_NUMBER" --arg sha "$HEAD_SHA" \
775+
'{pr_number: $pr, head_sha: $sha}' > crossref-context.json
776+
777+
- name: upload the cross-reference artifact
778+
if: github.event.pull_request.base.sha != '' && always()
779+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
780+
with:
781+
name: crossref-context
782+
path: |
783+
crossref-added.tsv
784+
crossref-context.json
785+
retention-days: 1
786+
if-no-files-found: warn
787+
748788
- name: check all scripts build successfully
749789
run: |
750790
lake env lean scripts/create_deprecated_modules.lean
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
name: Cross-reference tag review
2+
3+
# Runs after a `build` / `build_fork` workflow finishes. The build's
4+
# `post_steps` job has already produced the `crossref-context` artifact
5+
# (a TSV of cross-reference tags whose declarations live in files the
6+
# PR touched, plus a small JSON with the PR number and head SHA). This
7+
# workflow downloads that artifact in a privileged `workflow_run`
8+
# context, fetches snippets, posts/updates/deletes the bot comment,
9+
# and exits non-zero on any tag that's missing upstream so the
10+
# resulting check status can gate merges.
11+
#
12+
# Privilege model: the post_steps job runs in the PR's permission
13+
# scope and writes only a machine-readable TSV. The TSV is parsed by
14+
# the orchestrator from a trusted mathlib-ci checkout; we never blindly
15+
# post Markdown that originated from PR code.
16+
17+
on:
18+
workflow_run:
19+
workflows:
20+
- "continuous integration"
21+
- "continuous integration (mathlib forks)"
22+
types:
23+
- completed
24+
25+
permissions:
26+
contents: read
27+
pull-requests: write
28+
actions: read
29+
30+
jobs:
31+
crossref-review:
32+
name: post-or-update-crossref-comment
33+
if: github.repository == 'leanprover-community/mathlib4'
34+
runs-on: ubuntu-latest
35+
env:
36+
# Until mathlib-ci#39's successor lands in mathlib-ci master, pin to
37+
# the branch tip containing the new TSV-driven orchestrator.
38+
MATHLIB_CI_REF: b77378efba2bccad82103c9cd4ae08c3bd2c768d
39+
40+
steps:
41+
- name: Download crossref artifact
42+
id: download
43+
uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11 # v6
44+
with:
45+
name: crossref-context
46+
run_id: ${{ github.event.workflow_run.id }}
47+
path: artifact
48+
if_no_artifact_found: warn
49+
50+
- name: Resolve PR number
51+
id: pr
52+
env:
53+
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
54+
HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
55+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
56+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57+
run: |
58+
set -euo pipefail
59+
PR=""
60+
# Preferred: the JSON embedded by post_steps.
61+
if [ -f artifact/crossref-context.json ]; then
62+
PR=$(jq -r '.pr_number // empty' artifact/crossref-context.json)
63+
ART_SHA=$(jq -r '.head_sha // empty' artifact/crossref-context.json)
64+
if [ -n "$ART_SHA" ] && [ "$ART_SHA" != "$HEAD_SHA" ]; then
65+
echo "::warning::Artifact head_sha ($ART_SHA) differs from workflow_run head_sha ($HEAD_SHA)."
66+
fi
67+
fi
68+
# Fallback: workflow_run.pull_requests[0].number (often unreliable).
69+
if [ -z "$PR" ]; then
70+
PR=$(jq -r '.workflow_run.pull_requests[0].number // empty' \
71+
"$GITHUB_EVENT_PATH")
72+
fi
73+
# Last-resort fallback: gh pr list with head sha matching.
74+
if [ -z "$PR" ] && [ -n "$HEAD_REPO" ] && [ -n "$HEAD_BRANCH" ]; then
75+
PR=$(gh pr list --repo "${{ github.repository }}" \
76+
--head "${HEAD_REPO%%/*}:${HEAD_BRANCH}" \
77+
--state open --json number,headRefOid \
78+
--jq "map(select(.headRefOid == \"$HEAD_SHA\")) | first | .number // empty")
79+
fi
80+
echo "number=$PR" >> "$GITHUB_OUTPUT"
81+
if [ -z "$PR" ]; then
82+
echo "Could not resolve PR number. Skipping."
83+
else
84+
echo "Resolved PR number: $PR"
85+
fi
86+
87+
- name: Checkout workflow actions
88+
if: steps.pr.outputs.number != ''
89+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
90+
with:
91+
ref: ${{ github.workflow_sha }}
92+
fetch-depth: 1
93+
sparse-checkout: .github/actions
94+
path: workflow-actions
95+
96+
- name: Get mathlib-ci
97+
if: steps.pr.outputs.number != ''
98+
uses: ./workflow-actions/.github/actions/get-mathlib-ci
99+
with:
100+
ref: ${{ env.MATHLIB_CI_REF }}
101+
102+
- name: Compose comment body
103+
if: steps.pr.outputs.number != ''
104+
id: compose
105+
run: |
106+
set +e
107+
python3 "${CI_SCRIPTS_DIR}/crossref_review/crossref-pr-comment.py" \
108+
--tsv "$(pwd)/artifact/crossref-added.tsv" \
109+
--output "$(pwd)/comment.md"
110+
ec=$?
111+
if [ ! -f comment.md ]; then : > comment.md; fi
112+
echo "exit_code=$ec" >> "$GITHUB_OUTPUT"
113+
echo "---- comment.md ----"
114+
cat comment.md
115+
echo "---- end ----"
116+
117+
- name: Post / update / delete bot comment
118+
if: steps.pr.outputs.number != ''
119+
env:
120+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
121+
GITHUB_REPOSITORY: ${{ github.repository }}
122+
run: |
123+
set -euo pipefail
124+
PR="${{ steps.pr.outputs.number }}"
125+
EXIT="${{ steps.compose.outputs.exit_code }}"
126+
TITLE='### Cross-reference tags added by this PR'
127+
if [ "$EXIT" = "0" ] || [ ! -s comment.md ]; then
128+
existing=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" --paginate \
129+
--jq 'map(select(.body | startswith("### Cross-reference tags added by this PR"))) | first | .id // empty')
130+
if [ -n "${existing:-}" ]; then
131+
echo "Removing stale bot comment $existing."
132+
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$existing"
133+
fi
134+
else
135+
"${CI_SCRIPTS_DIR}/pr_summary/update_PR_comment.sh" \
136+
comment.md "$TITLE" "$PR"
137+
fi
138+
139+
- name: Fail on missing tags
140+
if: steps.pr.outputs.number != ''
141+
env:
142+
EXIT: ${{ steps.compose.outputs.exit_code }}
143+
run: |
144+
set -euo pipefail
145+
# Exit codes from crossref-pr-comment.py:
146+
# 0 = no tags reported (no comment posted; not a failure)
147+
# 1 = tags reported, all resolved
148+
# 2 = some tag missing upstream → fail this check
149+
# 3 = transient network failure → warn but pass
150+
case "$EXIT" in
151+
0|1) echo "OK." ;;
152+
2) echo "::error::One or more cross-reference tags are missing upstream."
153+
exit 1 ;;
154+
3) echo "::warning::Some cross-reference tags could not be resolved due to a network error."
155+
exit 0 ;;
156+
*) echo "::error::Unexpected exit code $EXIT from crossref-pr-comment.py"
157+
exit 1 ;;
158+
esac

docs/workflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Primary trigger for this section: PR/merge-queue events (`pull_request`, `pull_r
5050
| [`check_pr_titles.yaml`](../.github/workflows/check_pr_titles.yaml) | Check PR titles<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/check_pr_titles.yaml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/check_pr_titles.yaml) | Low | `pull_request_target` | Validates PR titles against project conventions and maintains a sticky guidance comment. |
5151
| [`lint_and_suggest_pr.yml`](../.github/workflows/lint_and_suggest_pr.yml) | lint and suggest<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/lint_and_suggest_pr.yml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/lint_and_suggest_pr.yml) | Low | `pull_request` | Runs style linting and suggests changes on pull requests. The suggested changes can be applied by calling `bot_fix_style.yaml`. |
5252
| [`PR_summary.yml`](../.github/workflows/PR_summary.yml) | Post PR summary comment<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/PR_summary.yml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/PR_summary.yml) | Low | `pull_request_target` | On `pull_request_target`, computes PR summary data (imports/declarations/tech debt), manages related labels, and updates PR comments. |
53+
| [`crossref_review.yml`](../.github/workflows/crossref_review.yml) | Cross-reference tag review<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/crossref_review.yml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/crossref_review.yml) | Low | `pull_request_target` | Extracts every `@[stacks]`, `@[kerodon]`, and `@[wikidata]` attribute added by the PR, fetches the upstream snippet for each, posts a once-per-PR summary comment, and fails CI if any tag is missing upstream. |
5354
| [`add_label_from_diff.yaml`](../.github/workflows/add_label_from_diff.yaml) | Autolabel PRs<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/add_label_from_diff.yaml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/add_label_from_diff.yaml) | Low | `pull_request_target, push` | Applies an inferred topic label to newly opened PRs using `lake exe autolabel`. |
5455
| [`label_new_contributor.yml`](../.github/workflows/label_new_contributor.yml) | Label New Contributors<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/label_new_contributor.yml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/label_new_contributor.yml) | Low | `pull_request_target` | Labels PRs from new contributors (based on the number of their PRs merged). |
5556
| [`zulip_emoji_closed_pr.yaml`](../.github/workflows/zulip_emoji_closed_pr.yaml) | Add "closed-pr" emoji in Zulip<br>[![passed/failed](https://img.shields.io/github/actions/workflow/status/leanprover-community/mathlib4/zulip_emoji_closed_pr.yaml?label=status)](https://github.com/leanprover-community/mathlib4/actions/workflows/zulip_emoji_closed_pr.yaml) | Low | `pull_request_target` | Updates Zulip emoji reactions for PR close/reopen events. |

scripts/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,13 @@ to module `Foo.Bar` (no `srcDir` indirection).
252252
`--diff <range>`) walks the Lean source and emits TSV with one row per
253253
cross-reference attribute, paired with the declaration it decorates. A
254254
byte-level scanner skips strings, comments, and modifier keywords correctly.
255-
Used by the cross-reference review CI bot.
255+
256+
The orchestration script driven by `.github/workflows/crossref_review.yml`
257+
lives in the [`mathlib-ci`](https://github.com/leanprover-community/mathlib-ci)
258+
repository at `scripts/crossref_review/crossref-pr-comment.py` — it is kept
259+
there (rather than in this repo) so the workflow can execute it from a
260+
trusted external checkout in the privileged `pull_request_target` context
261+
without running any PR-modified code.
256262

257263
**Managing downstream repos**
258264
- `downstream_repos.yml` contains basic information about significant downstream repositories.

0 commit comments

Comments
 (0)