Repository navigation
151 lines (138 loc) · 6.01 KB
/
Copy pathmergecode.yml
File metadata and controls
151 lines (138 loc) · 6.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
name: mergecode
# Judges every pull request with MergeCode and posts the verdict as a sticky comment.
#
# Trust model:
# * The judge (MergeCode itself) and its config are built from the BASE commit, so a
# PR cannot weaken the judge that evaluates it.
# * The `judge` job runs PR code (verification commands) with a read-only token and
# no secrets. Its output is treated as untrusted by the `comment` job.
# * The `comment` job holds `pull-requests: write` but never executes PR code. It is
# skipped for fork PRs (their token is read-only anyway).
on:
pull_request:
permissions: {}
concurrency:
group: mergecode-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
judge:
name: judge
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# refs/pull/N/merge plus its first parent (the base tip).
fetch-depth: 2
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Prepare base worktree, PR diff and task
env:
TASK_TITLE: ${{ github.event.pull_request.title }}
TASK_BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
git worktree add --detach "$RUNNER_TEMP/base" HEAD^1
git -c core.quotePath=false diff --binary HEAD^1 HEAD > "$RUNNER_TEMP/pr.diff"
printf '%s\n\n%s\n' "$TASK_TITLE" "$TASK_BODY" > "$RUNNER_TEMP/task.md"
mkdir -p "$RUNNER_TEMP/out"
- name: Build the judge from the base commit
run: |
set -euo pipefail
cd "$RUNNER_TEMP/base"
npm ci --ignore-scripts --no-audit --no-fund
npm run build
- name: Judge
id: judge
run: |
set +e
node "$RUNNER_TEMP/base/dist/cli.js" judge \
--repo "$RUNNER_TEMP/base" \
--task "$RUNNER_TEMP/task.md" \
--patch "$RUNNER_TEMP/pr.diff" \
--out "$RUNNER_TEMP/out"
code=$?
echo "exit=$code" >> "$GITHUB_OUTPUT"
exit 0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: mergecode-report
path: ${{ runner.temp }}/out/
if-no-files-found: ignore
retention-days: 14
- name: Enforce verdict
# 0 MERGE, 1 REQUEST_CHANGES (advisory), 2 REJECT, 3 error. Fail closed on 2/3/missing,
# and on 0/1 without a report (a crash also exits non-zero).
env:
EXIT_CODE: ${{ steps.judge.outputs.exit }}
run: |
if [ ! -s "$RUNNER_TEMP/out/report.md" ]; then
echo "::error::MergeCode did not produce a report (exit '$EXIT_CODE')"; exit 1
fi
# A crash also exits non-zero: require run.json's verdict to agree with the exit code.
VERDICT=$(node -e 'try { process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).verdict)) } catch { process.stdout.write("missing") }' "$RUNNER_TEMP/out/run.json")
case "$EXIT_CODE:$VERDICT" in
0:MERGE|1:REQUEST_CHANGES) echo "MergeCode verdict: $VERDICT" ;;
2:REJECT) echo "::error::MergeCode verdict: REJECT"; exit 1 ;;
*) echo "::error::MergeCode exit code '$EXIT_CODE' does not match verdict '$VERDICT'"; exit 1 ;;
esac
comment:
name: comment
needs: judge
if: >-
always() &&
github.event.pull_request.head.repo.full_name == github.repository &&
(needs.judge.result == 'success' || needs.judge.result == 'failure')
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
pull-requests: write # create/update the single sticky verdict comment
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: mergecode-report
path: ${{ runner.temp }}/out
- name: Post or update the verdict comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
REPORT_DIR: ${{ runner.temp }}/out
with:
script: |
const fs = require("fs");
const path = require("path");
const marker = "<!-- mergecode-verdict -->";
const file = path.join(process.env.REPORT_DIR, "report.md");
// The report was produced in a job that ran PR code: treat it as untrusted text.
let report = fs.existsSync(file)
? fs.readFileSync(file, "utf8")
: "# MergeCode\n\nThe judge did not produce a report. See the workflow logs.";
// PR code ran in the job that wrote this file, so it may be forged even though
// MergeCode renders it inert. Neutralise mentions, HTML, links and images again.
report = report
.replaceAll(marker, "")
.replace(/@/g, "@\u200b")
.replace(/</g, "<")
.replace(/\]\(/g, "]\u200b(")
.replace(/:\/\//g, ":\u200b//")
.replace(/\bwww\./gi, (m) => `${m.slice(0, 3)}\u200b.`)
.slice(0, 60000);
const body = `${marker}\n${report}`;
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number, per_page: 100,
});
const existing = comments.find(
(c) => c.user?.login === "github-actions[bot]" && c.body?.startsWith(marker),
);
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}