signoff-cli 0.0.0-stage → 4.0.1
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/.pre-commit-hooks.yaml +27 -0
- package/CHANGELOG.md +344 -0
- package/LICENSE +21 -0
- package/README.md +540 -3
- package/action.yml +24 -0
- package/bin/signoff.js +218 -0
- package/docs/diagrams/README.md +41 -0
- package/docs/diagrams/signoff-1-capability-en.png +0 -0
- package/docs/diagrams/signoff-1-capability.png +0 -0
- package/docs/diagrams/signoff-2-market-en.png +0 -0
- package/docs/diagrams/signoff-2-market.png +0 -0
- package/docs/diagrams/signoff-3-user-en.png +0 -0
- package/docs/diagrams/signoff-3-user.png +0 -0
- package/docs/diagrams/signoff-4-flow-en.png +0 -0
- package/docs/diagrams/signoff-4-flow.png +0 -0
- package/docs/diagrams/signoff-5-competitors-en.png +0 -0
- package/docs/diagrams/signoff-5-competitors.png +0 -0
- package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
- package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
- package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
- package/docs/diagrams/src/signoff-2-market.mmd +72 -0
- package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
- package/docs/diagrams/src/signoff-3-user.mmd +92 -0
- package/docs/gates.en.md +105 -0
- package/docs/gates.md +88 -0
- package/docs/integrations.md +58 -0
- package/docs/promotion.md +71 -0
- package/docs/signoff.schema.json +108 -0
- package/docs/trust.md +72 -0
- package/gates/guard.py +474 -0
- package/gates/p1_verify.py +111 -0
- package/gates/p2_coverage.py +127 -0
- package/gates/p3_queue.py +186 -0
- package/gates/p4_ledger.py +249 -0
- package/gates/p5_slop.py +109 -0
- package/lib/audit.js +213 -0
- package/lib/bootstrap.js +361 -0
- package/lib/checkpoint.js +184 -0
- package/lib/ci.js +58 -0
- package/lib/core.js +120 -0
- package/lib/cov.js +253 -0
- package/lib/detect.js +169 -0
- package/lib/doctor.js +304 -0
- package/lib/editor.js +97 -0
- package/lib/explain.js +73 -0
- package/lib/guard.js +1229 -0
- package/lib/p1.js +469 -0
- package/lib/p2.js +323 -0
- package/lib/p3.js +633 -0
- package/lib/p4.js +733 -0
- package/lib/p5.js +438 -0
- package/lib/preset.js +66 -0
- package/lib/secrets.js +336 -0
- package/lib/sign.js +196 -0
- package/package.json +56 -6
- package/test/selftest.js +1227 -0
- package/test/selftest.py +190 -0
package/lib/p4.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Gate 4 - risky actions leave a ledger (JavaScript port of gates/p4_ledger.py).
|
|
3
|
+
//
|
|
4
|
+
// The hash chain is byte-compatible with the Python reference: the digest input is
|
|
5
|
+
// `prev + json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":"))`
|
|
6
|
+
// encoded as UTF-8, and each appended line is
|
|
7
|
+
// `json.dumps(record, ensure_ascii=False, sort_keys=True)` (Python's default
|
|
8
|
+
// ", " / ": " separators) + the platform line ending. Python's text mode writes
|
|
9
|
+
// CRLF on Windows, so the line ending follows os.EOL, matching the reference file
|
|
10
|
+
// byte for byte. Ledgers written by either engine verify under both.
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
|
|
17
|
+
const { git, homeDir, sha256 } = require("./core");
|
|
18
|
+
|
|
19
|
+
const ACTIONS = ["config", "profile", "publish"];
|
|
20
|
+
const DECISIONS = ["allow", "deny"];
|
|
21
|
+
// The tuple order used by verify(); sort_keys makes the order irrelevant, but keeping
|
|
22
|
+
// it mirrors the reference so a reader can diff the two files line by line.
|
|
23
|
+
const BODY_KEYS_V1 = ["ts", "action", "target", "detail", "rollback", "decision"];
|
|
24
|
+
// 3.3.0 records also hash the repository they belong to, so a ledger can hold entries
|
|
25
|
+
// from more than one repo without p4 checking the wrong rollback tags. Old records have
|
|
26
|
+
// no `repo` field and are hashed with the v1 key set, which keeps their digest valid.
|
|
27
|
+
const BODY_KEYS_V2 = ["ts", "action", "target", "detail", "rollback", "decision", "repo"];
|
|
28
|
+
const USAGE = "usage: signoff p4 [-h] {ask,confirm,verify,export} ...";
|
|
29
|
+
|
|
30
|
+
// Python's text-mode stdout/stderr translate "\n" to os.linesep, so every line the
|
|
31
|
+
// reference prints ends with CRLF on Windows and LF elsewhere. Matching that keeps the
|
|
32
|
+
// two implementations byte-identical on stdout as well as in the ledger.
|
|
33
|
+
function say(line) {
|
|
34
|
+
process.stdout.write(String(line) + os.EOL);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function warn(line) {
|
|
38
|
+
process.stderr.write(String(line) + os.EOL);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Python-compatible JSON
|
|
43
|
+
|
|
44
|
+
// json.dumps(..., ensure_ascii=False) string escaping: short escapes for \b \f \n \r \t,
|
|
45
|
+
// \u00XX (lowercase hex) for the rest of C0, and every non-ASCII character written raw.
|
|
46
|
+
function pyStr(s) {
|
|
47
|
+
let out = '"';
|
|
48
|
+
for (const ch of s) {
|
|
49
|
+
const c = ch.codePointAt(0);
|
|
50
|
+
if (ch === '"') out += '\\"';
|
|
51
|
+
else if (ch === "\\") out += "\\\\";
|
|
52
|
+
else if (ch === "\b") out += "\\b";
|
|
53
|
+
else if (ch === "\f") out += "\\f";
|
|
54
|
+
else if (ch === "\n") out += "\\n";
|
|
55
|
+
else if (ch === "\r") out += "\\r";
|
|
56
|
+
else if (ch === "\t") out += "\\t";
|
|
57
|
+
else if (c < 0x20) out += "\\u" + c.toString(16).padStart(4, "0");
|
|
58
|
+
else if (c >= 0xd800 && c <= 0xdfff) out += "\\u" + c.toString(16);
|
|
59
|
+
else out += ch;
|
|
60
|
+
}
|
|
61
|
+
return out + '"';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Python renders an int as "12" and a float as "12.0" / "1e-07". JSON.parse gives us a
|
|
65
|
+
// single Number type, so ints are printed as ints and non-ints through repr-ish rules.
|
|
66
|
+
// A float literal inside an edited ledger body is the one shape where the two engines
|
|
67
|
+
// could disagree (JSON.parse has already lost the int/float distinction).
|
|
68
|
+
function pyNum(n) {
|
|
69
|
+
if (Number.isNaN(n)) return "NaN";
|
|
70
|
+
if (!Number.isFinite(n)) return n > 0 ? "Infinity" : "-Infinity";
|
|
71
|
+
if (Number.isInteger(n) && Math.abs(n) < 1e16) return String(n);
|
|
72
|
+
const s = String(n);
|
|
73
|
+
const m = /^(-?\d+(?:\.\d+)?)e([+-])(\d+)$/.exec(s);
|
|
74
|
+
if (m) return `${m[1]}e${m[2]}${m[3].padStart(2, "0")}`;
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// opts.compact -> separators=(",", ":"); otherwise Python's default ", " / ": ".
|
|
79
|
+
// opts.sortKeys -> sort_keys=True (recursive, like Python).
|
|
80
|
+
function dumpsPy(value, opts) {
|
|
81
|
+
const compact = !!(opts && opts.compact);
|
|
82
|
+
const sortKeys = !!(opts && opts.sortKeys);
|
|
83
|
+
const itemSep = compact ? "," : ", ";
|
|
84
|
+
const keySep = compact ? ":" : ": ";
|
|
85
|
+
const enc = (v) => {
|
|
86
|
+
if (v === null || v === undefined) return "null";
|
|
87
|
+
if (v === true) return "true";
|
|
88
|
+
if (v === false) return "false";
|
|
89
|
+
if (typeof v === "string") return pyStr(v);
|
|
90
|
+
if (typeof v === "number") return pyNum(v);
|
|
91
|
+
if (Array.isArray(v)) return "[" + v.map(enc).join(itemSep) + "]";
|
|
92
|
+
const keys = Object.keys(v);
|
|
93
|
+
if (sortKeys) keys.sort();
|
|
94
|
+
return "{" + keys.map((k) => pyStr(k) + keySep + enc(v[k])).join(itemSep) + "}";
|
|
95
|
+
};
|
|
96
|
+
return enc(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// str() as used by the markdown table: None -> "None", True -> "True".
|
|
100
|
+
function pyText(v) {
|
|
101
|
+
if (v === null || v === undefined) return "None";
|
|
102
|
+
if (v === true) return "True";
|
|
103
|
+
if (v === false) return "False";
|
|
104
|
+
if (typeof v === "string") return v;
|
|
105
|
+
return dumpsPy(v);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Ledger
|
|
110
|
+
|
|
111
|
+
function has(rec, k) {
|
|
112
|
+
return Object.prototype.hasOwnProperty.call(rec, k);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// rec.get(k) in the reference: absent -> None.
|
|
116
|
+
function get(rec, k) {
|
|
117
|
+
return has(rec, k) ? rec[k] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function homeDirSafe() {
|
|
121
|
+
try {
|
|
122
|
+
return homeDir();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return process.env.SIGNOFF_HOME || path.join(process.env.HOME || process.env.USERPROFILE || process.cwd(), ".signoff");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A single user-level ledger mixes rollback points of unrelated repositories: a tag
|
|
129
|
+
// that exists in repo A never exists in repo B, so p4 verify failed everywhere except
|
|
130
|
+
// the repo that wrote the entry. The ledger is therefore per repository:
|
|
131
|
+
// SIGNOFF_HOME wins (tests, scripted runs), then the git top level, then the user home
|
|
132
|
+
// for a directory that is not a repository.
|
|
133
|
+
function normRepo(p) {
|
|
134
|
+
let out = String(p || "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
135
|
+
if (process.platform === "win32") out = out.toLowerCase();
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function repoRoot() {
|
|
140
|
+
try {
|
|
141
|
+
const r = git(["rev-parse", "--show-toplevel"]);
|
|
142
|
+
return r && r.code === 0 ? String(r.out || "").trim() : "";
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function repoKey() {
|
|
149
|
+
return normRepo(repoRoot());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function signoffDir() {
|
|
153
|
+
if (process.env.SIGNOFF_HOME) return process.env.SIGNOFF_HOME;
|
|
154
|
+
const top = repoRoot();
|
|
155
|
+
return top ? path.join(top, ".signoff") : homeDirSafe();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ledgerPath() {
|
|
159
|
+
return path.join(signoffDir(), "audit.log");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ticketsDir() {
|
|
163
|
+
return path.join(signoffDir(), "tickets");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function bodyFor(rec) {
|
|
167
|
+
const keys = has(rec, "repo") ? BODY_KEYS_V2 : BODY_KEYS_V1;
|
|
168
|
+
const body = {};
|
|
169
|
+
for (const k of keys) body[k] = get(rec, k);
|
|
170
|
+
return body;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Carry a pre-3.3.0 user-level ledger into the repository the first time it is touched,
|
|
174
|
+
// but only when the whole chain still resolves here - a subset would break prev links.
|
|
175
|
+
function seedLedger() {
|
|
176
|
+
const target = ledgerPath();
|
|
177
|
+
if (fs.existsSync(target) || process.env.SIGNOFF_HOME) return;
|
|
178
|
+
const legacy = path.join(homeDirSafe(), "audit.log");
|
|
179
|
+
if (path.resolve(legacy) === path.resolve(target) || !fs.existsSync(legacy)) return;
|
|
180
|
+
let lines;
|
|
181
|
+
try {
|
|
182
|
+
lines = splitLines(fs.readFileSync(legacy, "utf8"))
|
|
183
|
+
.map((l) => l.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!lines.length) return;
|
|
189
|
+
const allResolve = lines.every((line) => {
|
|
190
|
+
let rec;
|
|
191
|
+
try {
|
|
192
|
+
rec = JSON.parse(line);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const rb = rec && typeof rec.rollback === "string" ? rec.rollback : "";
|
|
197
|
+
if (!rb || rb === "no-git") return true;
|
|
198
|
+
if (rb.indexOf(" ") !== -1) return false;
|
|
199
|
+
const r = git(["rev-parse", "--verify", "--quiet", rb]);
|
|
200
|
+
return !r || r.code === 0 || r.code === 127;
|
|
201
|
+
});
|
|
202
|
+
if (!allResolve) return;
|
|
203
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
204
|
+
fs.writeFileSync(target, lines.join(os.EOL) + os.EOL, "utf8");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Python str.splitlines() boundaries, so a CRLF ledger written on Windows and an LF
|
|
208
|
+
// ledger written elsewhere both parse identically.
|
|
209
|
+
function splitLines(text) {
|
|
210
|
+
return text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function entries() {
|
|
214
|
+
seedLedger();
|
|
215
|
+
const log = ledgerPath();
|
|
216
|
+
if (!fs.existsSync(log)) return [];
|
|
217
|
+
let text = fs.readFileSync(log, "utf8");
|
|
218
|
+
// Windows editors and `Set-Content -Encoding utf8` prepend a BOM; the ledger is
|
|
219
|
+
// still readable content, not a corrupt file.
|
|
220
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const raw of splitLines(text)) {
|
|
223
|
+
const line = raw.trim();
|
|
224
|
+
if (!line) continue;
|
|
225
|
+
try {
|
|
226
|
+
out.push(JSON.parse(line));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error(`unreadable ledger line ${out.length + 1}: ${e && e.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The hash chain: sha256(prev + canonical body), UTF-8.
|
|
235
|
+
function digest(prev, body) {
|
|
236
|
+
return sha256(String(prev) + dumpsPy(body, { compact: true, sortKeys: true }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function timestamp() {
|
|
240
|
+
const d = new Date();
|
|
241
|
+
const p2 = (n) => String(Math.abs(n)).padStart(2, "0");
|
|
242
|
+
const off = -d.getTimezoneOffset();
|
|
243
|
+
const sign = off >= 0 ? "+" : "-";
|
|
244
|
+
const hh = p2(Math.floor(Math.abs(off) / 60));
|
|
245
|
+
const mm = p2(Math.abs(off) % 60);
|
|
246
|
+
// time.strftime("%Y-%m-%dT%H:%M:%S%z") -> 2026-11-06T19:39:01+0800
|
|
247
|
+
return (
|
|
248
|
+
`${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` +
|
|
249
|
+
`T${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}${sign}${hh}${mm}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function gitDescribe() {
|
|
254
|
+
let r;
|
|
255
|
+
try {
|
|
256
|
+
r = git(["describe", "--tags", "--always"]);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return "no-git";
|
|
259
|
+
}
|
|
260
|
+
if (!r || r.code !== 0) return "no-git";
|
|
261
|
+
return String(r.out || "").trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Subcommands
|
|
266
|
+
|
|
267
|
+
function ask(action, target, ttl) {
|
|
268
|
+
const dir = ticketsDir();
|
|
269
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
270
|
+
const token = crypto.randomBytes(4).toString("hex");
|
|
271
|
+
const meta = { token: token, action: action, target: target, expires: Date.now() / 1000 + ttl };
|
|
272
|
+
fs.writeFileSync(path.join(dir, token + ".json"), dumpsPy(meta) + os.EOL, "utf8");
|
|
273
|
+
say(`ticket ${token} action=${action} target=${target} ttl=${ttl}s`);
|
|
274
|
+
say(`confirm with: signoff p4 confirm --ticket ${token} --decision allow --detail "..."`);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function confirm(ticket, decision, detail, rollback) {
|
|
279
|
+
const p = path.join(ticketsDir(), ticket + ".json");
|
|
280
|
+
if (!fs.existsSync(p)) {
|
|
281
|
+
say(`[p4] unknown or already used ticket ${ticket}`);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
let meta;
|
|
285
|
+
try {
|
|
286
|
+
meta = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
287
|
+
} catch (e) {
|
|
288
|
+
say(`[p4] unreadable ticket ${ticket}`);
|
|
289
|
+
return 2;
|
|
290
|
+
}
|
|
291
|
+
if (!meta || typeof meta !== "object") {
|
|
292
|
+
say(`[p4] unreadable ticket ${ticket}`);
|
|
293
|
+
return 2;
|
|
294
|
+
}
|
|
295
|
+
const expires = has(meta, "expires") ? meta.expires : 0;
|
|
296
|
+
if (expires < Date.now() / 1000) {
|
|
297
|
+
try {
|
|
298
|
+
fs.unlinkSync(p);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
/* missing_ok=True */
|
|
301
|
+
}
|
|
302
|
+
say(`[p4] ticket ${ticket} expired`);
|
|
303
|
+
return 2;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
fs.unlinkSync(p);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
/* missing_ok=True */
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const chain = entries();
|
|
312
|
+
const prev = chain.length ? get(chain[chain.length - 1], "hash") || "" : "genesis";
|
|
313
|
+
const body = {
|
|
314
|
+
ts: timestamp(),
|
|
315
|
+
action: meta.action,
|
|
316
|
+
target: has(meta, "target") && meta.target !== null ? meta.target : "",
|
|
317
|
+
detail: detail,
|
|
318
|
+
rollback: rollback,
|
|
319
|
+
decision: decision,
|
|
320
|
+
repo: repoKey(),
|
|
321
|
+
};
|
|
322
|
+
const record = Object.assign({}, body, { prev: prev, hash: digest(prev, body) });
|
|
323
|
+
fs.mkdirSync(path.dirname(ledgerPath()), { recursive: true });
|
|
324
|
+
fs.appendFileSync(ledgerPath(), dumpsPy(record, { sortKeys: true }) + os.EOL, "utf8");
|
|
325
|
+
say(dumpsPy({ ok: decision === "allow", hash: record.hash, rollback: rollback }));
|
|
326
|
+
return decision === "allow" ? 0 : 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Hash-chain problems for one ledger's records. `repo` filtering is verify()'s job;
|
|
330
|
+
// a merged export wants the chain checked for every file it shows.
|
|
331
|
+
function chainProblems(chain) {
|
|
332
|
+
const problems = [];
|
|
333
|
+
let prev = "genesis";
|
|
334
|
+
let i = 0;
|
|
335
|
+
for (const rec of chain) {
|
|
336
|
+
i += 1;
|
|
337
|
+
if (!rec || typeof rec !== "object") throw new Error(`unreadable ledger line ${i}: not an object`);
|
|
338
|
+
const body = bodyFor(rec);
|
|
339
|
+
if (get(rec, "prev") !== prev) problems.push(`line ${i}: prev hash mismatch`);
|
|
340
|
+
if (get(rec, "hash") !== digest(prev, body)) {
|
|
341
|
+
problems.push(`line ${i}: content hash mismatch (edited after the fact)`);
|
|
342
|
+
}
|
|
343
|
+
prev = get(rec, "hash") || "";
|
|
344
|
+
}
|
|
345
|
+
return problems;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readLedgerFile(file) {
|
|
349
|
+
const text = fs.readFileSync(file, "utf8");
|
|
350
|
+
const rows = [];
|
|
351
|
+
text.split(/\r?\n/).forEach((line, idx) => {
|
|
352
|
+
if (!line.trim()) return;
|
|
353
|
+
try {
|
|
354
|
+
rows.push(JSON.parse(line));
|
|
355
|
+
} catch (e) {
|
|
356
|
+
throw new Error(`unreadable ledger line ${idx + 1} in ${file}: not JSON`);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return rows;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function verify() {
|
|
363
|
+
const chain = entries();
|
|
364
|
+
const current = repoKey();
|
|
365
|
+
const problems = [];
|
|
366
|
+
let prev = "genesis";
|
|
367
|
+
let i = 0;
|
|
368
|
+
for (const rec of chain) {
|
|
369
|
+
i += 1;
|
|
370
|
+
if (!rec || typeof rec !== "object") throw new Error(`unreadable ledger line ${i}: not an object`);
|
|
371
|
+
const body = bodyFor(rec);
|
|
372
|
+
if (get(rec, "prev") !== prev) problems.push(`line ${i}: prev hash mismatch`);
|
|
373
|
+
if (get(rec, "hash") !== digest(prev, body)) {
|
|
374
|
+
problems.push(`line ${i}: content hash mismatch (edited after the fact)`);
|
|
375
|
+
}
|
|
376
|
+
prev = get(rec, "hash") || "";
|
|
377
|
+
const owner = get(rec, "repo") || "";
|
|
378
|
+
if (owner && current && owner !== current) continue;
|
|
379
|
+
const rb = get(rec, "rollback") || "";
|
|
380
|
+
if (!rb) {
|
|
381
|
+
problems.push(`line ${i}: no rollback point recorded`);
|
|
382
|
+
} else if (rb !== "no-git") {
|
|
383
|
+
const ref = pyText(rb);
|
|
384
|
+
if (ref.indexOf(" ") !== -1) {
|
|
385
|
+
problems.push(`line ${i}: rollback point '${ref}' is not a git ref`);
|
|
386
|
+
} else {
|
|
387
|
+
// A missing git binary makes the reference skip the check (subprocess raises
|
|
388
|
+
// OSError); core.git reports that as code 127.
|
|
389
|
+
const r = git(["rev-parse", "--verify", "--quiet", ref]);
|
|
390
|
+
if (r && r.code !== 0 && r.code !== 127) {
|
|
391
|
+
problems.push(`line ${i}: rollback point '${ref}' no longer exists`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const ok = problems.length === 0;
|
|
397
|
+
say(dumpsPy({ ok: ok, entries: chain.length, problems: problems }));
|
|
398
|
+
return ok ? 0 : 1;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function exportMd(out, merges, fmt) {
|
|
402
|
+
merges = merges || [];
|
|
403
|
+
const own = entries();
|
|
404
|
+
const rows = own.map((r, idx) => ({
|
|
405
|
+
ts: pyText(get(r, "ts") === null ? "" : get(r, "ts")),
|
|
406
|
+
repo: pyText(get(r, "repo") === null ? "" : get(r, "repo")),
|
|
407
|
+
action: pyText(get(r, "action") === null ? "" : get(r, "action")),
|
|
408
|
+
target: pyText(get(r, "target") === null ? "" : get(r, "target")),
|
|
409
|
+
decision: pyText(get(r, "decision") === null ? "" : get(r, "decision")),
|
|
410
|
+
rollback: pyText(get(r, "rollback") === null ? "" : get(r, "rollback")),
|
|
411
|
+
hash: pyText(get(r, "hash") === null ? "" : get(r, "hash")),
|
|
412
|
+
idx: idx + 1,
|
|
413
|
+
}));
|
|
414
|
+
const problems = [];
|
|
415
|
+
for (const file of merges) {
|
|
416
|
+
if (!fs.existsSync(file)) {
|
|
417
|
+
problems.push(`${file}: not found`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const chain = readLedgerFile(file);
|
|
421
|
+
chainProblems(chain).forEach((msg) => problems.push(`${file}: ${msg}`));
|
|
422
|
+
chain.forEach((r, idx) => {
|
|
423
|
+
rows.push({
|
|
424
|
+
ts: pyText(get(r, "ts") === null ? "" : get(r, "ts")),
|
|
425
|
+
repo: pyText(get(r, "repo") === null ? "" : get(r, "repo")) || path.basename(path.dirname(path.resolve(file))),
|
|
426
|
+
action: pyText(get(r, "action") === null ? "" : get(r, "action")),
|
|
427
|
+
target: pyText(get(r, "target") === null ? "" : get(r, "target")),
|
|
428
|
+
decision: pyText(get(r, "decision") === null ? "" : get(r, "decision")),
|
|
429
|
+
rollback: pyText(get(r, "rollback") === null ? "" : get(r, "rollback")),
|
|
430
|
+
hash: pyText(get(r, "hash") === null ? "" : get(r, "hash")),
|
|
431
|
+
idx: idx + 1,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (rows.length) rows.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : a.idx - b.idx));
|
|
436
|
+
|
|
437
|
+
const format = fmt || "md";
|
|
438
|
+
if (format === "json" || format === "csv") {
|
|
439
|
+
const body =
|
|
440
|
+
format === "json"
|
|
441
|
+
? JSON.stringify(
|
|
442
|
+
rows.map((r) => ({
|
|
443
|
+
ts: r.ts,
|
|
444
|
+
repo: r.repo,
|
|
445
|
+
action: r.action,
|
|
446
|
+
target: r.target,
|
|
447
|
+
decision: r.decision,
|
|
448
|
+
rollback: r.rollback,
|
|
449
|
+
hash: r.hash,
|
|
450
|
+
})),
|
|
451
|
+
null,
|
|
452
|
+
2
|
|
453
|
+
) + os.EOL
|
|
454
|
+
: [
|
|
455
|
+
"ts,repo,action,target,decision,rollback,hash",
|
|
456
|
+
...rows.map((r) =>
|
|
457
|
+
[r.ts, r.repo, r.action, r.target, r.decision, r.rollback, r.hash]
|
|
458
|
+
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
|
459
|
+
.join(",")
|
|
460
|
+
),
|
|
461
|
+
].join(os.EOL) + os.EOL;
|
|
462
|
+
fs.writeFileSync(out, body, "utf8");
|
|
463
|
+
problems.forEach((msg) => warn(`[p4] merge: ${msg}`));
|
|
464
|
+
say(`[p4] ${rows.length} entries written to ${out}`);
|
|
465
|
+
return problems.length ? 1 : 0;
|
|
466
|
+
}
|
|
467
|
+
const header = merges.length
|
|
468
|
+
? ["# SignOff ledger (merged)", "", "| # | ts | repo | action | target | decision | rollback | hash |", "| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
|
469
|
+
: ["# SignOff ledger", "", "| # | action | target | decision | rollback | hash |", "| --- | --- | --- | --- | --- | --- |"];
|
|
470
|
+
const lines = header.slice();
|
|
471
|
+
rows.forEach((r, n) => {
|
|
472
|
+
const cells = merges.length
|
|
473
|
+
? [String(n + 1), r.ts, r.repo, r.action, r.target, r.decision, r.rollback, r.hash.slice(0, 12)]
|
|
474
|
+
: [String(n + 1), r.action, r.target, r.decision, r.rollback, r.hash.slice(0, 12)];
|
|
475
|
+
lines.push("| " + cells.join(" | ") + " |");
|
|
476
|
+
});
|
|
477
|
+
// Path.write_text creates no directories: the reference fails on a missing parent,
|
|
478
|
+
// so a plain write keeps that behaviour instead of core.writeText's mkdir -p.
|
|
479
|
+
fs.writeFileSync(out, lines.join(os.EOL) + os.EOL, "utf8");
|
|
480
|
+
problems.forEach((msg) => warn(`[p4] merge: ${msg}`));
|
|
481
|
+
const extra = rows.length - own.length;
|
|
482
|
+
say(
|
|
483
|
+
merges.length
|
|
484
|
+
? `[p4] ${rows.length} entries (${extra} from ${merges.length} other ledger(s)) written to ${out}`
|
|
485
|
+
: `[p4] ${rows.length} entries written to ${out}`
|
|
486
|
+
);
|
|
487
|
+
return problems.length ? 1 : 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// argv
|
|
492
|
+
|
|
493
|
+
function usageError(msg) {
|
|
494
|
+
warn(USAGE);
|
|
495
|
+
warn(`signoff p4: error: ${msg}`);
|
|
496
|
+
return 2;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function flagValue(parsed, key) {
|
|
500
|
+
const v = parsed[key];
|
|
501
|
+
return v === undefined || v === true ? null : v;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseArgs(argv, spec) {
|
|
505
|
+
const out = Object.assign({}, spec);
|
|
506
|
+
const extra = [];
|
|
507
|
+
for (let i = 0; i < argv.length; i++) {
|
|
508
|
+
const a = argv[i];
|
|
509
|
+
if (a === "--") {
|
|
510
|
+
for (let j = i + 1; j < argv.length; j++) extra.push(argv[j]);
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
if (a.startsWith("--")) {
|
|
514
|
+
const eq = a.indexOf("=");
|
|
515
|
+
const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
516
|
+
if (eq !== -1) {
|
|
517
|
+
out[key] = a.slice(eq + 1);
|
|
518
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
519
|
+
out[key] = argv[++i];
|
|
520
|
+
} else {
|
|
521
|
+
out[key] = true;
|
|
522
|
+
}
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
// `-h` / unknown short flags are handled by main(); anything else is positional.
|
|
526
|
+
if (!a.startsWith("-")) extra.push(a);
|
|
527
|
+
else out._short = a;
|
|
528
|
+
}
|
|
529
|
+
out._extra = extra;
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseIntStrict(flag, raw) {
|
|
534
|
+
const s = String(raw).trim();
|
|
535
|
+
if (!/^[+-]?\d+$/.test(s)) {
|
|
536
|
+
throw { usage: `argument ${flag}: invalid int value: '${raw}'` };
|
|
537
|
+
}
|
|
538
|
+
return parseInt(s, 10);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// `verify --checkpoint` proves three things the plain verify cannot: the ledger was
|
|
542
|
+
// not truncated, it still hashes to the checkpoint, and that checkpoint carries a
|
|
543
|
+
// signature from an allowed key (or says plainly that it does not).
|
|
544
|
+
function verifyCheckpointMode(rest) {
|
|
545
|
+
const cp = require("./checkpoint");
|
|
546
|
+
const asJson = rest.some((a) => a === "--json");
|
|
547
|
+
const allowUnsigned = rest.some((a) => a === "--allow-unsigned");
|
|
548
|
+
let anchor = null;
|
|
549
|
+
for (let i = 0; i < rest.length; i++) {
|
|
550
|
+
const a = rest[i];
|
|
551
|
+
if (a === "--anchor") {
|
|
552
|
+
if (i + 1 >= rest.length) return usageError("argument --anchor: expected one argument");
|
|
553
|
+
anchor = rest[++i];
|
|
554
|
+
} else if (a.startsWith("--anchor=")) {
|
|
555
|
+
anchor = a.slice("--anchor=".length);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
let verdict;
|
|
559
|
+
try {
|
|
560
|
+
verdict = cp.verdict(cp.readCheckpoint(), { anchor: anchor, allowUnsigned: allowUnsigned });
|
|
561
|
+
} catch (e) {
|
|
562
|
+
say(`[p4] unreadable ledger: ${(e && e.message) || e}`);
|
|
563
|
+
return 1;
|
|
564
|
+
}
|
|
565
|
+
if (asJson) {
|
|
566
|
+
say(JSON.stringify(verdict));
|
|
567
|
+
} else {
|
|
568
|
+
for (const line of verdict.problems) say(`[p4] ${line}`);
|
|
569
|
+
say(
|
|
570
|
+
`[p4] ${verdict.ok ? "PASS" : "FAIL"} checkpoint seq=${verdict.seq} state=${verdict.state} anchor=${verdict.anchor}`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return verdict.ok ? 0 : 1;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function main(argv) {
|
|
577
|
+
if (argv.some((a) => a === "-h" || a === "--help")) {
|
|
578
|
+
say(
|
|
579
|
+
[
|
|
580
|
+
"usage: signoff p4 [-h] {ask,confirm,verify,checkpoint,anchor,path,export} ...",
|
|
581
|
+
"",
|
|
582
|
+
"gate 4 risky actions leave a ledger",
|
|
583
|
+
"",
|
|
584
|
+
" ask --action {config,profile,publish} [--target T] [--ttl SEC]",
|
|
585
|
+
" confirm --ticket T [--decision {allow,deny}] [--detail D] [--rollback REF]",
|
|
586
|
+
" verify [--checkpoint [--anchor signoff1:SEQ:HEAD:ROOT]] [--json]",
|
|
587
|
+
" checkpoint [--no-sign] [--json] write a signed checkpoint of the ledger",
|
|
588
|
+
" anchor [--json] print the one line to publish",
|
|
589
|
+
" path where the ledger and checkpoint live",
|
|
590
|
+
" export [--out FILE] [--merge OTHER.jsonl]... [--format {md,json,csv}]",
|
|
591
|
+
].join(os.EOL) + os.EOL
|
|
592
|
+
);
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const cmd = argv[0];
|
|
597
|
+
const rest = argv.slice(1);
|
|
598
|
+
if (!cmd) return usageError("the following arguments are required: cmd");
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
if (cmd === "ask") {
|
|
602
|
+
const p = parseArgs(rest, { action: null, target: "", ttl: 600 });
|
|
603
|
+
if (p._short) return usageError(`unrecognized arguments: ${p._short}`);
|
|
604
|
+
if (p._extra.length) return usageError(`unrecognized arguments: ${p._extra.join(" ")}`);
|
|
605
|
+
const action = flagValue(p, "action");
|
|
606
|
+
if (action === null) return usageError("the following arguments are required: --action");
|
|
607
|
+
if (ACTIONS.indexOf(action) === -1) {
|
|
608
|
+
return usageError(
|
|
609
|
+
`argument --action: invalid choice: '${action}' (choose from ${ACTIONS.map((a) => `'${a}'`).join(", ")})`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
const target = flagValue(p, "target");
|
|
613
|
+
if (target === null) return usageError("argument --target: expected one argument");
|
|
614
|
+
let ttl = p.ttl;
|
|
615
|
+
if (ttl === true) return usageError("argument --ttl: expected one argument");
|
|
616
|
+
if (ttl === null) ttl = 600;
|
|
617
|
+
try {
|
|
618
|
+
ttl = parseIntStrict("--ttl", ttl);
|
|
619
|
+
} catch (e) {
|
|
620
|
+
return usageError(e.usage);
|
|
621
|
+
}
|
|
622
|
+
return ask(action, String(target), ttl);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (cmd === "confirm") {
|
|
626
|
+
const p = parseArgs(rest, { ticket: null, decision: "allow", detail: "", rollback: null });
|
|
627
|
+
if (p._short) return usageError(`unrecognized arguments: ${p._short}`);
|
|
628
|
+
if (p._extra.length) return usageError(`unrecognized arguments: ${p._extra.join(" ")}`);
|
|
629
|
+
const ticket = flagValue(p, "ticket");
|
|
630
|
+
if (ticket === null) return usageError("the following arguments are required: --ticket");
|
|
631
|
+
let decision = p.decision;
|
|
632
|
+
if (decision === true) return usageError("argument --decision: expected one argument");
|
|
633
|
+
if (decision === null) decision = "allow";
|
|
634
|
+
if (DECISIONS.indexOf(decision) === -1) {
|
|
635
|
+
return usageError(`argument --decision: invalid choice: '${decision}' (choose from 'allow', 'deny')`);
|
|
636
|
+
}
|
|
637
|
+
const detail = flagValue(p, "detail");
|
|
638
|
+
if (detail === null) return usageError("argument --detail: expected one argument");
|
|
639
|
+
const rollback = flagValue(p, "rollback");
|
|
640
|
+
// `args.rollback or git_describe()`: an empty string falls back just like None.
|
|
641
|
+
return confirm(String(ticket), decision, String(detail), rollback ? rollback : gitDescribe());
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (cmd === "verify") {
|
|
645
|
+
const p = parseArgs(rest, {});
|
|
646
|
+
if (p._short) return usageError(`unrecognized arguments: ${p._short}`);
|
|
647
|
+
if (p._extra.length) return usageError(`unrecognized arguments: ${p._extra.join(" ")}`);
|
|
648
|
+
const wantsCheckpoint = rest.some((a) => a === "--checkpoint" || a === "--anchor" || a.startsWith("--anchor="));
|
|
649
|
+
if (!wantsCheckpoint) return verify();
|
|
650
|
+
return verifyCheckpointMode(rest);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (cmd === "checkpoint") {
|
|
654
|
+
const cp = require("./checkpoint");
|
|
655
|
+
const noSign = rest.some((a) => a === "--no-sign");
|
|
656
|
+
const asJson = rest.some((a) => a === "--json");
|
|
657
|
+
const made = cp.create({ sign: !noSign });
|
|
658
|
+
if (asJson) {
|
|
659
|
+
say(JSON.stringify(made.checkpoint));
|
|
660
|
+
} else {
|
|
661
|
+
say(`[p4] checkpoint seq=${made.checkpoint.seq} anchor=${made.checkpoint.anchor}`);
|
|
662
|
+
say(`[p4] publish that anchor (release notes, CI summary, a message); later runs prove the ledger was not truncated.`);
|
|
663
|
+
}
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (cmd === "path") {
|
|
668
|
+
const cp = require("./checkpoint");
|
|
669
|
+
const out = { ledger: ledgerPath(), checkpoint: cp.checkpointPath() };
|
|
670
|
+
say(rest.some((a) => a === "--json") ? JSON.stringify(out) : `${out.ledger}${os.EOL}${out.checkpoint}`);
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (cmd === "anchor") {
|
|
675
|
+
const cp = require("./checkpoint");
|
|
676
|
+
const s2 = cp.summarize();
|
|
677
|
+
const anchor = cp.anchorOf(s2.seq, s2.head, s2.root);
|
|
678
|
+
if (rest.some((a) => a === "--json")) {
|
|
679
|
+
say(JSON.stringify({ seq: s2.seq, head: s2.head, root: s2.root, anchor: anchor }));
|
|
680
|
+
} else {
|
|
681
|
+
say(anchor);
|
|
682
|
+
}
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (cmd === "export") {
|
|
687
|
+
const p = parseArgs(rest, { out: "docs/signoff-ledger.md", format: "md" });
|
|
688
|
+
if (p._short) return usageError(`unrecognized arguments: ${p._short}`);
|
|
689
|
+
if (p._extra.length) return usageError(`unrecognized arguments: ${p._extra.join(" ")}`);
|
|
690
|
+
const out = flagValue(p, "out");
|
|
691
|
+
if (out === null) return usageError("argument --out: expected one argument");
|
|
692
|
+
const fmt = flagValue(p, "format");
|
|
693
|
+
if (fmt !== null && ["md", "json", "csv"].indexOf(String(fmt)) === -1) {
|
|
694
|
+
return usageError(`argument --format: invalid choice: '${fmt}' (choose from 'md', 'json', 'csv')`);
|
|
695
|
+
}
|
|
696
|
+
const merges = [];
|
|
697
|
+
for (let i = 0; i < rest.length; i++) {
|
|
698
|
+
const a = rest[i];
|
|
699
|
+
let value;
|
|
700
|
+
if (a === "--merge") {
|
|
701
|
+
if (i + 1 >= rest.length || rest[i + 1].startsWith("-")) {
|
|
702
|
+
return usageError("argument --merge: expected one argument");
|
|
703
|
+
}
|
|
704
|
+
value = rest[++i];
|
|
705
|
+
} else if (a.startsWith("--merge=")) {
|
|
706
|
+
value = a.slice(8);
|
|
707
|
+
} else {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (value) merges.push(...value.split(",").filter(Boolean));
|
|
711
|
+
}
|
|
712
|
+
return exportMd(String(out), merges, fmt === null ? "md" : String(fmt));
|
|
713
|
+
}
|
|
714
|
+
} catch (e) {
|
|
715
|
+
if (e && e.usage) return usageError(e.usage);
|
|
716
|
+
throw e;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return usageError(`invalid choice: '${cmd}' (choose from 'ask', 'confirm', 'verify', 'checkpoint', 'anchor', 'path', 'export')`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// argv = the args after the gate name; everything is printed here, the exit code is
|
|
723
|
+
// returned to bin/signoff.js.
|
|
724
|
+
function run(argv) {
|
|
725
|
+
try {
|
|
726
|
+
return main(argv || []);
|
|
727
|
+
} catch (e) {
|
|
728
|
+
warn(`[p4] ${(e && e.message) || e}`);
|
|
729
|
+
return 1;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
module.exports = { run, dumpsPy, digest, timestamp, entries, ledgerPath, repoKey };
|