1- # On a PR that touches card scripts, runs a deterministic linter + a Scryfall
2- # frame fact-check and posts terse inline comments. It is purely ADVISORY: it
3- # never fails the check, it only comments . A reviewer still decides what to act on.
1+ # Posts terse, advisory inline comments on PRs that touch card scripts: a
2+ # deterministic DSL linter, a Scryfall frame fact-check, and engine-validated API
3+ # names. It only comments; it never fails a check . A reviewer decides what to act on.
44#
5- # Trigger: pull_request_target, because almost all PRs come from forks and a plain
6- # pull_request token is read-only (can't comment). pull_request_target runs in the
7- # base repo with a write token, so the hard rule is: NEVER execute PR code.
8- # This workflow upholds that — it runs only the BASE repo's scripts (checked out
9- # from the target branch) and pulls in just the PR 's `cardsfolder/*.txt` files as
10- # DATA to lint. The changed-file filter excludes `.github/**`, so a PR cannot swap
11- # in its own linter, and nothing from the PR is ever built or executed .
5+ # Why this is a separate workflow from the build. The analysis runs inside the
6+ # build (the Card-script review test in `mvn test` + the linter here), but a
7+ # `pull_request` build has a READ-ONLY token on fork PRs — almost all card PRs —
8+ # so it cannot post comments. `workflow_run` runs after the build completes in the
9+ # base-repo context with a write token, which is GitHub 's supported pattern for
10+ # "untrusted build produces findings, a privileged job posts them". The build
11+ # itself stays on the safe read-only token; only this poster can write .
1212#
13- # Stable across engine changes: the linter derives "known params" from the corpus
14- # itself, and the Scryfall side only compares stable frame fields (name/type/P-T/
15- # cost/loyalty). Neither is coupled to how a card ability is scripted.
13+ # Trust boundary: the build that produced the artifact ran PR code, so the
14+ # downloaded files are treated as DATA only (parsed, never executed). The linter
15+ # and corpus are the BASE repo's, checked out fresh here; the PR's card files are
16+ # pulled in as data to lint, never run.
1617
1718name : Card-script review
1819
1920on :
20- pull_request_target :
21- paths :
22- - ' forge-gui/res/cardsfolder/** '
21+ workflow_run :
22+ workflows : ["Test build"]
23+ types : [completed]
2324
2425permissions :
2526 contents : read
27+ actions : read
2628 pull-requests : write
2729
2830concurrency :
29- group : card-script-review-${{ github.event.pull_request.number }}
31+ group : card-script-review-${{ github.event.workflow_run.head_branch }}
3032 cancel-in-progress : true
3133
3234jobs :
3335 review :
3436 runs-on : ubuntu-latest
37+ # Only PR builds carry review data; push builds have nothing to comment on.
38+ if : github.event.workflow_run.event == 'pull_request'
3539 steps :
36- # Trusted base checkout: the scripts we run and the card corpus the linter
37- # compares against. NOT the PR's code.
38- - name : Check out base repo
39- uses : actions/checkout@v4
40-
41- - name : Set up Python
42- uses : actions/setup-python@v5
40+ - name : Download review data from the build
41+ uses : actions/download-artifact@v4
4342 with :
44- python-version : ' 3.11'
43+ name : card-script-review
44+ run-id : ${{ github.event.workflow_run.id }}
45+ github-token : ${{ secrets.GITHUB_TOKEN }}
46+ path : review-data
47+ continue-on-error : true # no artifact (non-card PR) -> nothing to do
4548
46- - name : List changed card files
47- uses : actions/github-script@v7
49+ - name : Resolve PR and changed cards
50+ id : pr
51+ uses : actions/github-script@v8
4852 with :
4953 script : |
5054 const fs = require('fs');
55+ let meta;
56+ try { meta = JSON.parse(fs.readFileSync('review-data/card-script-pr-meta.json', 'utf8')); }
57+ catch (e) { core.info('no PR metadata — nothing to review'); return; }
58+ const pull_number = meta.number;
5159 const files = await github.paginate(github.rest.pulls.listFiles, {
52- owner: context.repo.owner,
53- repo: context.repo.repo,
54- pull_number: context.issue.number,
55- });
56- // Only cardsfolder .txt files — this also guarantees we never pull a
57- // PR's version of .github/** (incl. the scripts we're about to run).
60+ owner: context.repo.owner, repo: context.repo.repo, pull_number });
5861 const cards = files.filter(f => f.status !== 'removed'
5962 && f.filename.startsWith('forge-gui/res/cardsfolder/')
6063 && f.filename.endsWith('.txt'));
64+ if (!cards.length) { core.info('no card files changed'); return; }
65+ core.setOutput('pull_number', String(pull_number));
66+ core.setOutput('head_sha', meta.head_sha);
6167 fs.writeFileSync('changed_files.txt', cards.map(f => f.filename).join('\n'));
6268
63- // For each card, the set of NEW-file line numbers it actually adds or
64- // changes (the '+' lines of the diff). We only comment on these — a
65- // finding on a line the PR didn't touch is pre-existing and out of scope.
66- // For an added file the whole file is '+' lines, so nothing is lost.
69+ // For each card, the NEW-file line numbers it adds or changes (the '+'
70+ // lines). We only comment on these — a finding on an untouched line of an
71+ // edited file is pre-existing and out of scope.
6772 const addedLines = {};
6873 for (const f of cards) {
69- const lines = [];
70- let newLine = 0;
74+ const lines = []; let newLine = 0;
7175 for (const ln of (f.patch || '').split('\n')) {
7276 const h = ln.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
7377 if (h) { newLine = parseInt(h[1], 10); continue; }
7478 if (ln.startsWith('\\')) continue; // "\ No newline at end of file"
7579 if (ln.startsWith('+')) { lines.push(newLine); newLine++; }
76- else if (ln.startsWith('-')) { /* old-side only: no new line */ }
80+ else if (ln.startsWith('-')) { /* old-side only */ }
7781 else { newLine++; } // context line
7882 }
7983 addedLines[f.filename] = lines;
8084 }
8185 fs.writeFileSync('diff_lines.json', JSON.stringify(addedLines));
8286 core.info(`${cards.length} changed card file(s)`);
8387
84- # Bring in the PR's versions of those card files as plain data, overwriting
88+ - name : Check out base scripts and corpus
89+ if : steps.pr.outputs.pull_number != ''
90+ uses : actions/checkout@v5
91+
92+ - name : Set up Python
93+ if : steps.pr.outputs.pull_number != ''
94+ uses : actions/setup-python@v6
95+ with :
96+ python-version : ' 3.11'
97+
98+ # Bring in the PR's versions of the changed cards as plain data, overwriting
8599 # the base copies in place. We only read them; we never run them.
86100 - name : Materialize PR card files (data only)
101+ if : steps.pr.outputs.pull_number != ''
87102 env :
88- PR_NUMBER : ${{ github.event.pull_request.number }}
103+ PR_NUMBER : ${{ steps.pr.outputs.pull_number }}
89104 run : |
90105 [ -s changed_files.txt ] || { echo "no card files changed"; exit 0; }
91106 git fetch --no-tags --depth=1 origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head"
92- # `|| [ -n "$f" ]` processes the final line even when the file has no
93- # trailing newline (changed_files.txt is written with join('\n')).
107+ # `|| [ -n "$f" ]` processes the final line even without a trailing newline.
94108 while IFS= read -r f || [ -n "$f" ]; do
95109 [ -n "$f" ] || continue
96110 mkdir -p "$(dirname "$f")"
97111 git show "refs/remotes/pr/head:$f" > "$f" || echo "skip $f"
98112 done < changed_files.txt
99113
100114 - name : Run review checks
115+ if : steps.pr.outputs.pull_number != ''
101116 continue-on-error : true # advisory: a script hiccup must never fail the PR
102117 env :
103- PYTHONIOENCODING : utf-8 # the comments contain '→'; don't depend on runner locale
118+ PYTHONIOENCODING : utf-8 # comments contain '→'; don't depend on runner locale
104119 run : |
105- python .github/scripts/card_script_review.py changed_files.txt > comments.json
120+ JF=review-data/card-script-findings.json
121+ ARGS=""; [ -f "$JF" ] && ARGS="--java-findings $JF"
122+ python .github/scripts/card_script_review.py $ARGS changed_files.txt > comments.json
106123 echo "---- comments ----"
107124 cat comments.json
108125
109126 - name : Post inline comments
110- uses : actions/github-script@v7
127+ if : steps.pr.outputs.pull_number != ''
128+ uses : actions/github-script@v8
129+ env :
130+ PULL_NUMBER : ${{ steps.pr.outputs.pull_number }}
131+ HEAD_SHA : ${{ steps.pr.outputs.head_sha }}
111132 with :
112133 script : |
113134 const fs = require('fs');
@@ -119,12 +140,10 @@ jobs:
119140 if (!comments.length) { core.info('no findings'); return; }
120141
121142 const { owner, repo } = context.repo;
122- const pull_number = context.issue.number ;
123- const commit_id = context.payload.pull_request.head.sha ;
143+ const pull_number = Number(process.env.PULL_NUMBER) ;
144+ const commit_id = process.env.HEAD_SHA ;
124145
125- // Diff-lines-only: comment only on lines the PR added or changed. A
126- // finding on an untouched line of an edited file is pre-existing and
127- // out of scope, so we skip it (no summary comment).
146+ // Diff-lines-only: comment only on lines the PR added or changed.
128147 const inDiff = (c) => (diffLines[c.path] || []).includes(c.line);
129148
130149 // De-dupe against what the bot already said on this PR (re-runs on push).
0 commit comments