replume-cli 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/dist/cli.js +3979 -2314
- package/package.json +3 -2
- package/templates/pr-check-publish.mjs +102 -20
- package/templates/pr-check.yml +8 -2
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replume-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"replume": "dist/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"build": "tsup src/cli.ts --format esm --target node20 --out-dir dist --shims",
|
|
9
|
+
"build": "tsup src/cli.ts --format esm --target node20 --out-dir dist --shims --env.REPLUME_TELEMETRY_DEFAULT ${REPLUME_TELEMETRY_DEFAULT:-on}",
|
|
10
10
|
"test": "vitest run",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
|
+
"check:error-codes": "node scripts/check-error-codes.mjs",
|
|
12
13
|
"check-context": "node scripts/check-context.mjs",
|
|
13
14
|
"prepublishOnly": "npm run build && npm run typecheck && npm run check-context && npm test",
|
|
14
15
|
"eval": "node eval/run.mjs"
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// Copy beside the workflow as .github/replume-pr-check-publish.mjs on the trusted base branch.
|
|
2
2
|
// The publisher reads the verify artifact; --check-policy reads committed policy from the PR base.
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Before 0.2.6 every workflow shared this marker; such a comment is taken over, so an upgrade does not leave two.
|
|
8
|
+
const legacyMarker = "<!-- replume-pr-check -->";
|
|
9
|
+
const safeKey = (value) => String(value ?? "").replace(/[^A-Za-z0-9 ._/-]+/g, "-").trim().slice(0, 120);
|
|
10
|
+
// One comment for each workflow and job. A matrix job sets REPLUME_CHECK_JOB to tell its legs apart.
|
|
11
|
+
const checkKey = (env) => `${safeKey(env.GITHUB_WORKFLOW) || "workflow"} / ${safeKey(env.REPLUME_CHECK_JOB || env.GITHUB_JOB) || "job"}`;
|
|
12
|
+
const markerFor = (env) => `<!-- replume-pr-check: ${checkKey(env)} -->`;
|
|
13
|
+
const EARLIER_RUNS = 10;
|
|
14
|
+
const runTag = /<!-- replume-run: sha=([0-9a-f]{7,40}) run=(\S+) attempt=(\S+) verdict=(PASS|FAIL|INCOMPLETE) -->/;
|
|
15
|
+
const earlierLine = /^- `[0-9a-f]{7}` run \S+ attempt \S+: \*\*(?:PASS|FAIL|INCOMPLETE)\*\*$/;
|
|
8
16
|
const text = (value) => String(value ?? "").replace(/[\r\n\t]+/g, " ").replace(/[<>`]/g, "").slice(0, 350);
|
|
9
17
|
const items = (entries) => entries.length ? entries.map((entry) => `- ${entry}`).join("\n") : "- None recorded.";
|
|
10
18
|
const contractPath = ".replume/contract.yaml";
|
|
@@ -46,11 +54,28 @@ function result(reportPath, exitPath, policyPath = join(dirname(reportPath), "po
|
|
|
46
54
|
try { if (existsSync(exitPath)) exit = readFileSync(exitPath, "utf8").trim(); } catch { /* incomplete below */ }
|
|
47
55
|
const expected = report?.verdict === "pass" ? "0" : report?.verdict === "fail" ? "1" : report?.verdict === "incomplete" ? "2" : "";
|
|
48
56
|
const verdict = expected && exit === expected ? report.verdict.toUpperCase() : "INCOMPLETE";
|
|
57
|
+
// never green on nothing: a PASS has to name a check that ran
|
|
58
|
+
if (verdict === "PASS" && !(Array.isArray(report.done) && report.done.length)) return { report, verdict: "INCOMPLETE", reason: "Verification reported PASS but names no check that ran." };
|
|
49
59
|
return { report: report && typeof report === "object" ? report : null, verdict,
|
|
50
60
|
reason: verdict === "INCOMPLETE" && expected !== exit ? "Verification did not produce a usable, matching result." : "" };
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
/** The earlier runs kept in a comment, plus the run that comment showed, as one list. */
|
|
64
|
+
function earlierRuns(previous) {
|
|
65
|
+
const block = /<!-- replume-earlier -->\n([\s\S]*?)\n<!-- \/replume-earlier -->/.exec(previous ?? "")?.[1] ?? "";
|
|
66
|
+
return block.split("\n").filter((line) => earlierLine.test(line));
|
|
67
|
+
}
|
|
68
|
+
const runLine = (sha, run, attempt, verdict) => `- \`${String(sha).slice(0, 7)}\` run ${run} attempt ${attempt}: **${verdict}**`;
|
|
69
|
+
function foldedRuns(lines) {
|
|
70
|
+
if (!lines.length) return [];
|
|
71
|
+
return [`<details><summary>Earlier runs (${lines.length})</summary>`, "", "<!-- replume-earlier -->", ...lines, "<!-- /replume-earlier -->", "", "</details>", ""];
|
|
72
|
+
}
|
|
73
|
+
/** Puts the earlier runs, newest first, into a body that has none yet. */
|
|
74
|
+
function withEarlier(body, lines) {
|
|
75
|
+
return lines.length ? `${body}\n\n${foldedRuns(lines.slice(0, EARLIER_RUNS)).join("\n")}`.trimEnd() : body;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function commentBody(reportPath, exitPath, policyPath, env = {}) {
|
|
54
79
|
const { report, verdict, reason } = result(reportPath, exitPath, policyPath);
|
|
55
80
|
const done = Array.isArray(report?.done) ? report.done : [];
|
|
56
81
|
const failed = Array.isArray(report?.failed) ? report.failed : [];
|
|
@@ -68,7 +93,7 @@ export function commentBody(reportPath, exitPath, policyPath) {
|
|
|
68
93
|
"CI did not observe agent steps; this result covers the commit diff and checks run here.",
|
|
69
94
|
];
|
|
70
95
|
if (reason) unchecked.unshift(reason);
|
|
71
|
-
return [
|
|
96
|
+
return [markerFor(env), `<!-- replume-run: sha=${/^[0-9a-f]{7,40}$/i.test(env.HEAD_SHA ?? "") ? env.HEAD_SHA.toLowerCase() : "0000000"} run=${safeKey(env.GITHUB_RUN_ID).replace(/\s/g, "-") || "-"} attempt=${safeKey(env.GITHUB_RUN_ATTEMPT).replace(/\s/g, "-") || "1"} verdict=${verdict} -->`, `**Replume: ${verdict}${reason ? ` — ${text(reason)}` : ""}**`, "",
|
|
72
97
|
"## Verified", items(verified), "",
|
|
73
98
|
...(failures.length ? ["## Failed", items(failures), ""] : []),
|
|
74
99
|
"## Changed beyond the task", items(beyond), "",
|
|
@@ -84,10 +109,17 @@ async function api(path, method, token, body) {
|
|
|
84
109
|
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json" },
|
|
85
110
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
86
111
|
});
|
|
87
|
-
if (!response.ok) throw new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`);
|
|
112
|
+
if (!response.ok) throw Object.assign(new Error(`GitHub ${method} ${path} returned HTTP ${response.status}`), { status: response.status });
|
|
88
113
|
return response.json();
|
|
89
114
|
}
|
|
90
115
|
|
|
116
|
+
// A fork's token and a Dependabot token are read-only: GitHub answers 403, or 404 for what the token may not see.
|
|
117
|
+
const readOnlyError = (error) => error?.status === 403 || error?.status === 404;
|
|
118
|
+
function toRunPage(env, body) {
|
|
119
|
+
if (!env.GITHUB_STEP_SUMMARY) return;
|
|
120
|
+
try { appendFileSync(env.GITHUB_STEP_SUMMARY, `${body}\n`); } catch { /* the page is an extra; the job's exit is the result */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
91
123
|
export async function publish(env = process.env) {
|
|
92
124
|
const repo = env.GITHUB_REPOSITORY, pr = Number(env.PR_NUMBER), sha = env.HEAD_SHA, token = env.GITHUB_TOKEN;
|
|
93
125
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo ?? "") || !Number.isSafeInteger(pr) || pr < 1 || !/^[a-fA-F0-9]{40}$/.test(sha ?? "") || !token)
|
|
@@ -95,23 +127,71 @@ export async function publish(env = process.env) {
|
|
|
95
127
|
const reportPath = env.REPLUME_REPORT || "replume-ci/report.json";
|
|
96
128
|
const exitPath = env.REPLUME_VERIFY_EXIT_FILE || "replume-ci/exit.txt";
|
|
97
129
|
const policyPath = env.REPLUME_POLICY_NOTE || join(dirname(reportPath), "policy.txt");
|
|
98
|
-
const
|
|
130
|
+
const fresh = commentBody(reportPath, exitPath, policyPath, env);
|
|
99
131
|
const verdict = result(reportPath, exitPath, policyPath).verdict;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (existing || comments.length < 100) break;
|
|
132
|
+
// the run's result stays on its own page in every case, with or without a comment
|
|
133
|
+
toRunPage(env, fresh);
|
|
134
|
+
if (env.HEAD_REPO && env.HEAD_REPO !== repo) {
|
|
135
|
+
process.stdout.write(`Replume: ${verdict}; a fork's PR: no comment and no check from this token. The result is on the run page.\n`);
|
|
136
|
+
return;
|
|
106
137
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
const marker = markerFor(env);
|
|
139
|
+
const ours = (entry, prefix) => entry?.body?.split("\n")[0] === prefix && entry?.user?.login === "github-actions[bot]";
|
|
140
|
+
const notes = [];
|
|
141
|
+
let action = "no comment";
|
|
142
|
+
// a check is not green because a comment could not be written, or the other way round: each has its own outcome
|
|
143
|
+
try {
|
|
144
|
+
let existing = null, legacy = null;
|
|
145
|
+
for (let page = 1; page <= 20; page++) {
|
|
146
|
+
const comments = await api(`/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, "GET", token);
|
|
147
|
+
if (!Array.isArray(comments)) throw new Error("GitHub comments response was not a list");
|
|
148
|
+
existing = comments.find((entry) => ours(entry, marker)) ?? null;
|
|
149
|
+
legacy ??= comments.find((entry) => ours(entry, legacyMarker)) ?? null;
|
|
150
|
+
if (existing || comments.length < 100) break;
|
|
151
|
+
}
|
|
152
|
+
const current = existing ?? legacy;
|
|
153
|
+
const prior = runTag.exec(current?.body ?? "");
|
|
154
|
+
const earlier = earlierRuns(current?.body);
|
|
155
|
+
const thisRun = runTag.exec(fresh);
|
|
156
|
+
const sameRun = prior && thisRun && prior[2] === thisRun[2] && prior[3] === thisRun[3];
|
|
157
|
+
// a rerun of an older commit does not take the place of the newest commit's result: it goes under Earlier runs
|
|
158
|
+
let newest = null;
|
|
159
|
+
try { newest = (await api(`/repos/${repo}/pulls/${pr}`, "GET", token))?.head?.sha ?? null; } catch { /* unknown: treat this run as the newest */ }
|
|
160
|
+
const stale = newest && newest.toLowerCase() !== sha.toLowerCase() && current;
|
|
161
|
+
const body = stale
|
|
162
|
+
? current.body.replace(/\n\n<details><summary>Earlier runs[\s\S]*$/, "").concat(`\n\n${foldedRuns([runLine(sha, thisRun[2], thisRun[3], verdict), ...earlier].slice(0, EARLIER_RUNS)).join("\n")}`).trimEnd()
|
|
163
|
+
: withEarlier(fresh, sameRun || !prior ? earlier : [runLine(prior[1], prior[2], prior[3], prior[4]), ...earlier]);
|
|
164
|
+
if (current) await api(`/repos/${repo}/issues/comments/${current.id}`, "PATCH", token, { body });
|
|
165
|
+
else await api(`/repos/${repo}/issues/${pr}/comments`, "POST", token, { body });
|
|
166
|
+
action = current ? "updated one PR comment" : "posted one PR comment";
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (!readOnlyError(error)) throw error;
|
|
169
|
+
notes.push("the token cannot write comments here (a fork or a read-only token), so the result is on the run page");
|
|
170
|
+
}
|
|
171
|
+
// neutral counts as passing for a required check, so INCOMPLETE is not neutral: nothing that was not checked turns a PR green
|
|
172
|
+
const conclusion = verdict === "PASS" ? "success" : verdict === "FAIL" ? "failure" : "action_required";
|
|
173
|
+
const runPage = env.GITHUB_RUN_ID ? `${(env.GITHUB_SERVER_URL || "https://github.com").replace(/\/$/, "")}/${repo}/actions/runs/${env.GITHUB_RUN_ID}` : null;
|
|
174
|
+
let check = conclusion;
|
|
175
|
+
try {
|
|
176
|
+
await api(`/repos/${repo}/check-runs`, "POST", token, {
|
|
177
|
+
name: "Replume / task", head_sha: sha, status: "completed", conclusion, ...(runPage ? { details_url: runPage } : {}),
|
|
178
|
+
output: { title: `Replume: ${verdict}`, summary: fresh.slice(0, 60000) },
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (!readOnlyError(error)) throw error;
|
|
182
|
+
check = "not written";
|
|
183
|
+
notes.push("the token cannot write checks here");
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(`Replume: ${verdict}; ${action}; check ${check}.${notes.length ? ` ${notes.join("; ")}.` : ""}\n`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The verify job's last step: the run page gets the result, and only PASS exits 0. Needs no token, so it works on a fork. */
|
|
189
|
+
export function requirePass(dir, env = process.env) {
|
|
190
|
+
const reportPath = join(dir, "report.json"), exitPath = join(dir, "exit.txt"), policyPath = join(dir, "policy.txt");
|
|
191
|
+
toRunPage(env, commentBody(reportPath, exitPath, policyPath, env));
|
|
192
|
+
const { verdict } = result(reportPath, exitPath, policyPath);
|
|
193
|
+
process.stdout.write(`Replume: ${verdict}\n`);
|
|
194
|
+
return verdict === "PASS" ? 0 : verdict === "FAIL" ? 1 : 2;
|
|
115
195
|
}
|
|
116
196
|
|
|
117
197
|
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
@@ -121,5 +201,7 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
|
121
201
|
writeFileSync(process.argv[4], reason ? `${reason}\n` : "");
|
|
122
202
|
if (reason) { process.stdout.write(`Replume: INCOMPLETE — ${reason}\n`); process.exitCode = 2; }
|
|
123
203
|
} catch (error) { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 2; }
|
|
204
|
+
} else if (process.argv[2] === "--require") {
|
|
205
|
+
process.exitCode = requirePass(process.argv[3]);
|
|
124
206
|
} else publish().catch((error) => { process.stderr.write(`replume-pr-check: ${error.message}\n`); process.exitCode = 1; });
|
|
125
207
|
}
|
package/templates/pr-check.yml
CHANGED
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
with:
|
|
22
22
|
node-version: '20'
|
|
23
23
|
- run: mkdir -p "$RUNNER_TEMP/replume-ci"
|
|
24
|
-
- run: npm install -g replume-cli@0.2.
|
|
24
|
+
- run: npm install -g replume-cli@0.2.5
|
|
25
25
|
- name: Install project dependencies
|
|
26
26
|
if: ${{ hashFiles('package-lock.json') != '' }}
|
|
27
27
|
run: npm ci
|
|
@@ -56,12 +56,16 @@ jobs:
|
|
|
56
56
|
if: always()
|
|
57
57
|
shell: bash
|
|
58
58
|
run: |
|
|
59
|
+
if grep -qs -- '--require' "$RUNNER_TEMP/replume-base-publisher.mjs"; then
|
|
60
|
+
node "$RUNNER_TEMP/replume-base-publisher.mjs" --require "$RUNNER_TEMP/replume-ci"
|
|
61
|
+
exit $?
|
|
62
|
+
fi
|
|
59
63
|
if [ ! -f "$RUNNER_TEMP/replume-ci/exit.txt" ]; then exit 2; fi
|
|
60
64
|
exit "$(cat "$RUNNER_TEMP/replume-ci/exit.txt")"
|
|
61
65
|
|
|
62
66
|
publish:
|
|
63
67
|
name: Replume / PR result
|
|
64
|
-
if: ${{
|
|
68
|
+
if: ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository }}
|
|
65
69
|
needs: verify
|
|
66
70
|
runs-on: ubuntu-latest
|
|
67
71
|
timeout-minutes: 5
|
|
@@ -88,6 +92,8 @@ jobs:
|
|
|
88
92
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
89
93
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
90
94
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
95
|
+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
|
96
|
+
REPLUME_CHECK_JOB: verify
|
|
91
97
|
REPLUME_REPORT: replume-ci/report.json
|
|
92
98
|
REPLUME_VERIFY_EXIT_FILE: replume-ci/exit.txt
|
|
93
99
|
REPLUME_POLICY_NOTE: replume-ci/policy.txt
|