cool-workflow 0.2.1 → 0.2.3
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +29 -91
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +6 -2
- package/dist/cli/parseargv.js +6 -2
- package/dist/core/capability-data.js +271 -0
- package/dist/core/capability-table.js +16 -3504
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +50 -8
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +6 -5
- package/dist/core/multi-agent/coordinator.js +9 -8
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/commit-gate.js +2 -1
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +16 -15
- package/dist/core/state/state-explosion/graph.js +26 -19
- package/dist/core/state/state-explosion/helpers.js +2 -1
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/trust/ledger.js +2 -1
- package/dist/core/types/execution-backend.js +18 -0
- package/dist/core/types/observability.js +7 -0
- package/dist/core/util/collate.js +23 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +99 -23
- package/dist/shell/commit-summary.js +2 -1
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +269 -59
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +8 -7
- package/dist/shell/execution-backend/agent.js +20 -1
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +4 -1
- package/dist/shell/execution-backend/registry.js +2 -1
- package/dist/shell/execution-backend/types.js +0 -9
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +232 -30
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +82 -39
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +13 -12
- package/dist/shell/onramp.js +17 -1
- package/dist/shell/operator-ux-text.js +24 -23
- package/dist/shell/operator-ux.js +22 -20
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +168 -111
- package/dist/shell/reclamation-io.js +16 -5
- package/dist/shell/registry-cli.js +19 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +4 -2
- package/dist/shell/run-registry-io.js +77 -19
- package/dist/shell/run-store.js +91 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +19 -10
- package/dist/shell/topology-io.js +38 -6
- package/dist/shell/trust-audit.js +264 -23
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +41 -29
- package/dist/shell/worker-isolation.js +34 -9
- package/dist/shell/workflow-app-loader.js +3 -2
- package/dist/wiring/capability-table/basics.js +91 -0
- package/dist/wiring/capability-table/exec-backend.js +171 -0
- package/dist/wiring/capability-table/index.js +42 -0
- package/dist/wiring/capability-table/multi-agent.js +630 -0
- package/dist/wiring/capability-table/parity.js +467 -0
- package/dist/wiring/capability-table/pipeline.js +292 -0
- package/dist/wiring/capability-table/registry-core.js +208 -0
- package/dist/wiring/capability-table/reporting.js +435 -0
- package/dist/wiring/capability-table/scheduling-registry.js +592 -0
- package/dist/wiring/capability-table/state.js +181 -0
- package/dist/wiring/capability-table/trust-ledger.js +158 -0
- package/dist/wiring/capability-table/workflow-apps.js +381 -0
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +13 -3
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +24 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/index.md +1 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +48 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +4 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +43 -13
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +71 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +4 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +23 -2
- package/scripts/bump-version.js +24 -2
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +71 -0
- package/scripts/purity-gate.js +239 -0
- package/scripts/release-check.js +8 -1
- package/scripts/release-flow.js +57 -1
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +33 -12
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
- package/workflows/README.md +19 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// purity-gate — fail closed if src/ crosses a layer boundary the rebuild
|
|
5
|
+
// docs claim is enforced, or if src/core/** reads an impure primitive
|
|
6
|
+
// (node:fs/child_process/net/http, process.env, process.cwd(), Date.now(),
|
|
7
|
+
// new Date(), Math.random()), beyond a committed, itemized baseline.
|
|
8
|
+
//
|
|
9
|
+
// Why this exists: docs/rebuild/PLAN.md and AGENTS.md both describe core/
|
|
10
|
+
// as pure (no IO) and shell/ as the only impure layer, and say this is
|
|
11
|
+
// "enforced by a lint rule". No such lint exists anywhere in scripts/ or
|
|
12
|
+
// package.json — only `tsc --noEmit`, which does not check import
|
|
13
|
+
// direction. The one place the rule is already broken is the hub itself:
|
|
14
|
+
// core/capability-table.ts imports ~30 things from ../shell.
|
|
15
|
+
//
|
|
16
|
+
// This is a RATCHET, not a clean-slate rule (matching dist-drift-check.js's
|
|
17
|
+
// and version-sync-check.js's style: node + git only, no new dependency).
|
|
18
|
+
// scripts/purity-baseline.json lists every violation that exists TODAY.
|
|
19
|
+
// The gate fails on:
|
|
20
|
+
// - a violation NOT in the baseline (a NEW break), and
|
|
21
|
+
// - a baseline entry that no longer matches reality (STALE — either the
|
|
22
|
+
// violation was fixed, in which case delete the entry, or the count
|
|
23
|
+
// changed, in which case update it consciously).
|
|
24
|
+
// Fail-closed both directions, same as dist-drift-check.js's added/changed/
|
|
25
|
+
// removed three-way diff.
|
|
26
|
+
//
|
|
27
|
+
// Layers (by top-level directory under src/):
|
|
28
|
+
// core/** -> may only import core/** (+ node:path, node:crypto)
|
|
29
|
+
// shell/** -> may import core/** or shell/**
|
|
30
|
+
// wiring/** -> may import core/**, shell/**, or wiring/** (not yet used;
|
|
31
|
+
// reserved for the capability-table split)
|
|
32
|
+
// cli/** -> may import core/**, shell/**, or cli/** (never mcp/**)
|
|
33
|
+
// mcp/** -> may import core/**, shell/**, or mcp/** (never cli/**)
|
|
34
|
+
//
|
|
35
|
+
// Approach: a plain-text specifier scan (import/export-from/require), not a
|
|
36
|
+
// real TS parser — this codebase uses only those three forms, verified
|
|
37
|
+
// against every current violation this baseline lists.
|
|
38
|
+
|
|
39
|
+
const fs = require("node:fs");
|
|
40
|
+
const path = require("node:path");
|
|
41
|
+
|
|
42
|
+
const packageDir = path.resolve(__dirname, "..");
|
|
43
|
+
const srcDir = path.join(packageDir, "src");
|
|
44
|
+
const baselinePath = path.join(__dirname, "purity-baseline.json");
|
|
45
|
+
|
|
46
|
+
const CORE_ALLOWED_BUILTINS = new Set(["node:path", "node:crypto"]);
|
|
47
|
+
const IMPURE_PATTERNS = ["process.env", "process.cwd()", "Date.now()", "new Date()", "Math.random()"];
|
|
48
|
+
|
|
49
|
+
const ALLOWED_TARGET_LAYERS = {
|
|
50
|
+
core: new Set(["core"]),
|
|
51
|
+
shell: new Set(["core", "shell"]),
|
|
52
|
+
wiring: new Set(["core", "shell", "wiring"]),
|
|
53
|
+
cli: new Set(["core", "shell", "cli"]),
|
|
54
|
+
mcp: new Set(["core", "shell", "mcp"]),
|
|
55
|
+
other: new Set(["core", "shell", "wiring", "cli", "mcp", "other"]),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function layerOf(relPath) {
|
|
59
|
+
if (relPath.startsWith("core/")) return "core";
|
|
60
|
+
if (relPath.startsWith("shell/")) return "shell";
|
|
61
|
+
if (relPath.startsWith("wiring/")) return "wiring";
|
|
62
|
+
if (relPath.startsWith("cli/") || relPath === "cli") return "cli";
|
|
63
|
+
if (relPath.startsWith("mcp/") || relPath === "mcp-server") return "mcp";
|
|
64
|
+
return "other";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function listTsFiles(dir) {
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
const full = path.join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory()) out.push(...listTsFiles(full));
|
|
72
|
+
else if (entry.name.endsWith(".ts")) out.push(full);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// This codebase's file-header comments routinely quote the EXACT patterns
|
|
78
|
+
// this gate looks for, in prose explaining why a file is pure (e.g. "never
|
|
79
|
+
// a top-level `require(\"node:fs\")`", "no fs, no process.env, no clock").
|
|
80
|
+
// A naive text scan reads those quotes as violations. Strip `//` and
|
|
81
|
+
// `/* */` comments before scanning (string/template literal contents are
|
|
82
|
+
// left untouched, so a real `from "..."` clause is never damaged); newlines
|
|
83
|
+
// are preserved so extractSpecifiers' `(?:^|\n)` anchor still lines up.
|
|
84
|
+
function stripComments(text) {
|
|
85
|
+
let out = "";
|
|
86
|
+
let mode = "code"; // "code" | "line" | "block" | quote character
|
|
87
|
+
for (let i = 0; i < text.length; i++) {
|
|
88
|
+
const c = text[i];
|
|
89
|
+
const c2 = text[i + 1];
|
|
90
|
+
if (mode === "line") {
|
|
91
|
+
if (c === "\n") { mode = "code"; out += c; }
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (mode === "block") {
|
|
95
|
+
if (c === "*" && c2 === "/") { mode = "code"; i++; continue; }
|
|
96
|
+
if (c === "\n") out += c;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (mode === '"' || mode === "'" || mode === "`") {
|
|
100
|
+
out += c;
|
|
101
|
+
if (c === "\\") { out += c2 ?? ""; i++; continue; }
|
|
102
|
+
if (c === mode) mode = "code";
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === "/" && c2 === "/") { mode = "line"; i++; continue; }
|
|
106
|
+
if (c === "/" && c2 === "*") { mode = "block"; i++; continue; }
|
|
107
|
+
if (c === '"' || c === "'" || c === "`") { mode = c; out += c; continue; }
|
|
108
|
+
out += c;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every `from "spec"` (import or export-from, type or value — a type-only
|
|
114
|
+
// import still names a module, so it counts the same way a real dependency
|
|
115
|
+
// scan would) plus every `require("spec")`. Non-greedy across `from` finds
|
|
116
|
+
// each import's own clause without needing a real parser.
|
|
117
|
+
function extractSpecifiers(codeText) {
|
|
118
|
+
const specs = [];
|
|
119
|
+
const fromRe = /(?:^|\n)\s*(?:import|export)\s[\s\S]*?from\s+["']([^"']+)["']/g;
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = fromRe.exec(codeText))) specs.push(m[1]);
|
|
122
|
+
const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
|
|
123
|
+
while ((m = requireRe.exec(codeText))) specs.push(m[1]);
|
|
124
|
+
return specs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function countOccurrences(text, needle) {
|
|
128
|
+
let count = 0;
|
|
129
|
+
let index = 0;
|
|
130
|
+
while ((index = text.indexOf(needle, index)) !== -1) {
|
|
131
|
+
count += 1;
|
|
132
|
+
index += needle.length;
|
|
133
|
+
}
|
|
134
|
+
return count;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function relSrcPath(absPath) {
|
|
138
|
+
return path.relative(srcDir, absPath).split(path.sep).join("/");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scan() {
|
|
142
|
+
const layerViolations = {}; // relFile -> string[] (specs that cross a disallowed layer)
|
|
143
|
+
const builtinViolations = {}; // relFile -> string[] (banned node builtins imported from core)
|
|
144
|
+
const clockEnvCounts = {}; // relFile -> { pattern: count } (core only, count > 0)
|
|
145
|
+
|
|
146
|
+
for (const absFile of listTsFiles(srcDir)) {
|
|
147
|
+
const relFile = relSrcPath(absFile);
|
|
148
|
+
const fileLayer = layerOf(relFile.replace(/\.ts$/, ""));
|
|
149
|
+
const text = stripComments(fs.readFileSync(absFile, "utf8"));
|
|
150
|
+
|
|
151
|
+
for (const spec of extractSpecifiers(text)) {
|
|
152
|
+
if (spec.startsWith(".")) {
|
|
153
|
+
const targetAbs = path.resolve(path.dirname(absFile), spec);
|
|
154
|
+
const targetRel = relSrcPath(targetAbs);
|
|
155
|
+
const targetLayer = layerOf(targetRel);
|
|
156
|
+
if (!ALLOWED_TARGET_LAYERS[fileLayer].has(targetLayer)) {
|
|
157
|
+
(layerViolations[relFile] = layerViolations[relFile] || []).push(spec);
|
|
158
|
+
}
|
|
159
|
+
} else if (spec.startsWith("node:")) {
|
|
160
|
+
if (fileLayer === "core" && !CORE_ALLOWED_BUILTINS.has(spec)) {
|
|
161
|
+
(builtinViolations[relFile] = builtinViolations[relFile] || []).push(spec);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Bare package specifiers (no "." or "node:" prefix): this package
|
|
165
|
+
// has zero runtime dependencies, so there are none to check.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (fileLayer === "core") {
|
|
169
|
+
const counts = {};
|
|
170
|
+
for (const pattern of IMPURE_PATTERNS) {
|
|
171
|
+
const n = countOccurrences(text, pattern);
|
|
172
|
+
if (n > 0) counts[pattern] = n;
|
|
173
|
+
}
|
|
174
|
+
if (Object.keys(counts).length > 0) clockEnvCounts[relFile] = counts;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const bucket of [layerViolations, builtinViolations]) {
|
|
179
|
+
for (const file of Object.keys(bucket)) bucket[file] = [...new Set(bucket[file])].sort();
|
|
180
|
+
}
|
|
181
|
+
return { layerViolations, builtinViolations, clockEnvCounts };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sortedKeys(obj) {
|
|
185
|
+
return Object.keys(obj).sort();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function diffListBuckets(actual, baseline, label, problems) {
|
|
189
|
+
const files = new Set([...sortedKeys(actual), ...sortedKeys(baseline)]);
|
|
190
|
+
for (const file of [...files].sort()) {
|
|
191
|
+
const actualList = actual[file] || [];
|
|
192
|
+
const baseList = baseline[file] || [];
|
|
193
|
+
const actualSet = new Set(actualList);
|
|
194
|
+
const baseSet = new Set(baseList);
|
|
195
|
+
const added = actualList.filter((s) => !baseSet.has(s));
|
|
196
|
+
const removed = baseList.filter((s) => !actualSet.has(s));
|
|
197
|
+
for (const spec of added) problems.push(`NEW ${label}: ${file} imports ${spec} (not in purity-baseline.json)`);
|
|
198
|
+
for (const spec of removed) problems.push(`STALE ${label} baseline entry: ${file} no longer imports ${spec} — delete it from purity-baseline.json`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function diffCountBuckets(actual, baseline, problems) {
|
|
203
|
+
const files = new Set([...sortedKeys(actual), ...sortedKeys(baseline)]);
|
|
204
|
+
for (const file of [...files].sort()) {
|
|
205
|
+
const actualCounts = actual[file] || {};
|
|
206
|
+
const baseCounts = baseline[file] || {};
|
|
207
|
+
const patterns = new Set([...Object.keys(actualCounts), ...Object.keys(baseCounts)]);
|
|
208
|
+
for (const pattern of [...patterns].sort()) {
|
|
209
|
+
const a = actualCounts[pattern] || 0;
|
|
210
|
+
const b = baseCounts[pattern] || 0;
|
|
211
|
+
if (a !== b) {
|
|
212
|
+
problems.push(`clock/env count drift: ${file} has ${a} occurrence(s) of "${pattern}", purity-baseline.json expects ${b} — update the baseline consciously (this catches new AND removed occurrences)`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function main() {
|
|
219
|
+
if (!fs.existsSync(baselinePath)) {
|
|
220
|
+
process.stderr.write(`purity gate: missing ${path.relative(packageDir, baselinePath)}\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
224
|
+
const actual = scan();
|
|
225
|
+
|
|
226
|
+
const problems = [];
|
|
227
|
+
diffListBuckets(actual.layerViolations, baseline.layerViolations || {}, "layer violation", problems);
|
|
228
|
+
diffListBuckets(actual.builtinViolations, baseline.builtinViolations || {}, "core builtin violation", problems);
|
|
229
|
+
diffCountBuckets(actual.clockEnvCounts, baseline.clockEnvCounts || {}, problems);
|
|
230
|
+
|
|
231
|
+
if (problems.length > 0) {
|
|
232
|
+
process.stderr.write(`purity gate: ${problems.length} problem(s)\n`);
|
|
233
|
+
for (const p of problems) process.stderr.write(` ${p}\n`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
process.stdout.write("purity gate: src/ matches the committed baseline (no new core/shell layer breaks, no clock/env drift).\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main();
|
package/scripts/release-check.js
CHANGED
|
@@ -39,6 +39,7 @@ const checks = [
|
|
|
39
39
|
"docs/run-retention-reclamation.7.md",
|
|
40
40
|
"docs/durable-state-and-locking.7.md",
|
|
41
41
|
"docs/security-trust-hardening.7.md",
|
|
42
|
+
"docs/trust-audit-anchor.7.md",
|
|
42
43
|
"../../CHANGELOG.md",
|
|
43
44
|
"../../RELEASE.md"
|
|
44
45
|
]) {
|
|
@@ -58,6 +59,7 @@ const checks = [
|
|
|
58
59
|
// `dist:check` builds from src/ AND fails closed if the committed dist/ drifted
|
|
59
60
|
// from that fresh build — strictly stronger than a bare `npm run build`.
|
|
60
61
|
{ name: "dist freshness", command: ["npm", "run", "dist:check"] },
|
|
62
|
+
{ name: "core/shell purity", command: ["npm", "run", "purity:check"] },
|
|
61
63
|
{ name: "type check", command: ["npm", "run", "check"] },
|
|
62
64
|
{ name: "onramp contract", command: ["npm", "run", "onramp:check"] },
|
|
63
65
|
{ name: "run-state schema consistency", command: ["node", "scripts/validate-run-state-schema.js"] },
|
|
@@ -66,6 +68,11 @@ const checks = [
|
|
|
66
68
|
// (release-gate.sh) forces CW_TEST_CONCURRENCY=1 to stay sequential as the
|
|
67
69
|
// deterministic backstop.
|
|
68
70
|
{ name: "tests", command: ["npm", "run", "test:ci"] },
|
|
71
|
+
// Pure core/ unit tests (test/*.test.js) — a separate suite from the smoke
|
|
72
|
+
// tests above (see test/run-unit.js's header). --skip-tests also skips
|
|
73
|
+
// this one, matching "tests": ci.yml runs test:unit directly, so this
|
|
74
|
+
// entry would just repeat it a second time in the release-check pass.
|
|
75
|
+
{ name: "unit tests", command: ["npm", "run", "test:unit"] },
|
|
69
76
|
{ name: "canonical apps", command: ["npm", "run", "canonical-apps"] },
|
|
70
77
|
{ name: "golden path", command: ["npm", "run", "golden-path"] },
|
|
71
78
|
{ name: "CLI MCP parity", command: ["npm", "run", "parity:check"] },
|
|
@@ -88,7 +95,7 @@ function main() {
|
|
|
88
95
|
process.stdout.write(`release:check ${check.name} ... `);
|
|
89
96
|
const started = Date.now();
|
|
90
97
|
try {
|
|
91
|
-
if (skipTests && check.name === "tests") {
|
|
98
|
+
if (skipTests && (check.name === "tests" || check.name === "unit tests")) {
|
|
92
99
|
results.push({ name: check.name, ok: true, skipped: true, elapsedMs: 0 });
|
|
93
100
|
process.stdout.write("skipped\n");
|
|
94
101
|
continue;
|
package/scripts/release-flow.js
CHANGED
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
// Both release-flow.js --cut and CI's release-gate.yml verify this exact path and
|
|
32
32
|
// format. The file MUST be committed to the tag's history.
|
|
33
33
|
//
|
|
34
|
+
// VERDICT SIGNING (optional, opt-in — see scripts/verdict-keygen.js):
|
|
35
|
+
// Set CW_RELEASE_VERDICT_PRIVKEY (a path or inline PEM) and this script signs
|
|
36
|
+
// the verdict file's exact bytes, writing a sidecar
|
|
37
|
+
// .cw-release/review-<FULLSHA>.verdict.sig (base64 ed25519). Once
|
|
38
|
+
// .cw-release/verdict-signing.pub is committed to the repo, release-gate.yml,
|
|
39
|
+
// npm-publish.yml, and block-unapproved-tag.sh all start REQUIRING a valid
|
|
40
|
+
// signature in addition to the APPROVED text check — closing the gap where
|
|
41
|
+
// anyone with shell access could hand-write a passing verdict file. Until
|
|
42
|
+
// that public key is committed, every check stays exactly as before
|
|
43
|
+
// (grep-only) — this is inert by default, never a silent behavior change.
|
|
44
|
+
//
|
|
34
45
|
// Test seams (smoke/operator only, never the delegated agent):
|
|
35
46
|
// CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
|
|
36
47
|
// (default: `bash <thisdir>/release-gate.sh`) so the smoke can exercise the
|
|
@@ -48,6 +59,7 @@ const fs = require("node:fs");
|
|
|
48
59
|
const path = require("node:path");
|
|
49
60
|
const https = require("node:https");
|
|
50
61
|
const http = require("node:http");
|
|
62
|
+
const crypto = require("node:crypto");
|
|
51
63
|
const { spawnSync } = require("node:child_process");
|
|
52
64
|
|
|
53
65
|
const scriptsDir = __dirname;
|
|
@@ -364,6 +376,46 @@ function verifyVerdict(resultPath) {
|
|
|
364
376
|
return cap;
|
|
365
377
|
}
|
|
366
378
|
|
|
379
|
+
// ---- verdict signing (optional, opt-in — see the header comment) -----------
|
|
380
|
+
function resolveVerdictPrivateKey(value) {
|
|
381
|
+
const trimmed = String(value).trim();
|
|
382
|
+
if (trimmed.includes("BEGIN") && trimmed.includes("KEY")) return trimmed;
|
|
383
|
+
if (fs.existsSync(trimmed)) return fs.readFileSync(trimmed, "utf8");
|
|
384
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY does not point to a readable key file or inline PEM: ${trimmed}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Called only after verifyVerdict() has confirmed resultPath's bytes are a
|
|
388
|
+
* genuine "APPROVED <HEAD>" verdict. A no-op unless CW_RELEASE_VERDICT_PRIVKEY
|
|
389
|
+
* is set. When it IS set, a signing failure fails closed (die()) rather than
|
|
390
|
+
* silently emitting an unsigned verdict — an operator who deliberately
|
|
391
|
+
* configured a signing key should never end up with the same on-disk state
|
|
392
|
+
* as one who didn't. */
|
|
393
|
+
function signVerdictIfConfigured(resultPath) {
|
|
394
|
+
const raw = process.env.CW_RELEASE_VERDICT_PRIVKEY;
|
|
395
|
+
if (!raw || !raw.trim()) return;
|
|
396
|
+
const pem = resolveVerdictPrivateKey(raw);
|
|
397
|
+
const sigPath = `${resultPath}.sig`;
|
|
398
|
+
try {
|
|
399
|
+
const key = crypto.createPrivateKey(pem);
|
|
400
|
+
// crypto.sign(null, ...) does NOT throw for an RSA or EC key — it silently
|
|
401
|
+
// signs with that algorithm's own default digest, producing a
|
|
402
|
+
// syntactically valid signature that can never verify against the
|
|
403
|
+
// committed ed25519 public key. Catching that here, at signing time,
|
|
404
|
+
// means a misconfigured key dies closed locally instead of only being
|
|
405
|
+
// caught later by CI (or not at all, if the repo hasn't committed
|
|
406
|
+
// verdict-signing.pub yet).
|
|
407
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
408
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY is a ${key.asymmetricKeyType || "unknown"} key, not ed25519 — generate one with scripts/verdict-keygen.js`);
|
|
409
|
+
}
|
|
410
|
+
const message = fs.readFileSync(resultPath);
|
|
411
|
+
const signature = crypto.sign(null, message, key).toString("base64");
|
|
412
|
+
fs.writeFileSync(sigPath, `${signature}\n`);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
die(`failed to sign the verdict with CW_RELEASE_VERDICT_PRIVKEY: ${error.message}`);
|
|
415
|
+
}
|
|
416
|
+
say(`verdict signed → ${path.relative(repoRoot, sigPath)}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
367
419
|
// ---- GitHub Release (optional, presentation/distribution) ------------------
|
|
368
420
|
// NOT a correctness gate: the load-bearing artifacts (the tag + the
|
|
369
421
|
// provenance-attested npm publish) already exist when this runs. So `gh` is NOT
|
|
@@ -522,9 +574,12 @@ function cut(resultPath, capability) {
|
|
|
522
574
|
// operator's local home path) must never ride into the immutable tag commit
|
|
523
575
|
// (that tripped pii-redaction-smoke and red-failed release-gate for v0.1.96).
|
|
524
576
|
// `git add -u` touches tracked files only, so no untracked file can be swept in;
|
|
525
|
-
// the verdict
|
|
577
|
+
// the verdict (and its .sig sidecar, when verdict signing is configured) are
|
|
578
|
+
// the only new paths the cut is allowed to add.
|
|
526
579
|
git(["add", "-u"]);
|
|
527
580
|
git(["add", "--", path.relative(repoRoot, resultPath)]);
|
|
581
|
+
const sigPath = `${resultPath}.sig`;
|
|
582
|
+
if (fs.existsSync(sigPath)) git(["add", "--", path.relative(repoRoot, sigPath)]);
|
|
528
583
|
const commit = git(["commit", "-m", `chore(release): record APPROVED reviewer verdict for v${cutVersion}`]);
|
|
529
584
|
if (commit.code !== 0) die("verdict commit failed", commit.err);
|
|
530
585
|
const tag = git(["tag", "-a", `v${cutVersion}`, "-m", `v${cutVersion}: ${capability || "release"}`]);
|
|
@@ -569,6 +624,7 @@ function main() {
|
|
|
569
624
|
fs.writeFileSync(inputPath, buildReviewerInput(resultPath));
|
|
570
625
|
delegateReview(resultPath, inputPath);
|
|
571
626
|
const capability = verifyVerdict(resultPath);
|
|
627
|
+
signVerdictIfConfigured(resultPath);
|
|
572
628
|
|
|
573
629
|
if (MODE_CUT) cut(resultPath, capability);
|
|
574
630
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// verdict-keygen.js — generate the ed25519 keypair for release-verdict
|
|
5
|
+
// signing. Same pattern as scripts/agents/cw-attest-keygen.js (that keypair
|
|
6
|
+
// signs AGENT USAGE telemetry; this one signs the RELEASE VERDICT — kept as
|
|
7
|
+
// two separate trust domains with two separate keypairs, never shared).
|
|
8
|
+
//
|
|
9
|
+
// The PRIVATE key stays with whoever runs `release-flow.js --cut`/`--check`
|
|
10
|
+
// (CW_RELEASE_VERDICT_PRIVKEY) and NEVER gets committed. The PUBLIC key is
|
|
11
|
+
// committed to the repo at .cw-release/verdict-signing.pub so CI's
|
|
12
|
+
// release-gate.yml, npm-publish.yml, and the local block-unapproved-tag.sh
|
|
13
|
+
// hook can all verify a committed verdict without holding any secret.
|
|
14
|
+
//
|
|
15
|
+
// Verification is opt-in and backward compatible: as long as
|
|
16
|
+
// .cw-release/verdict-signing.pub is absent, every verifier falls back to the
|
|
17
|
+
// original `grep -q '^APPROVED'` check. Once the public key is committed,
|
|
18
|
+
// every verifier starts requiring a valid signature too.
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// node verdict-keygen.js [--out-dir DIR] # writes verdict-signing.key + .pub
|
|
22
|
+
// node verdict-keygen.js --print # print both PEMs to stdout, write nothing
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const crypto = require("node:crypto");
|
|
26
|
+
const path = require("node:path");
|
|
27
|
+
const { spawnSync } = require("node:child_process");
|
|
28
|
+
|
|
29
|
+
function arg(name, fallback) {
|
|
30
|
+
const i = process.argv.indexOf(name);
|
|
31
|
+
return i >= 0 ? process.argv[i + 1] : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// .cw-release/ lives at the REPO ROOT, not necessarily at process.cwd() —
|
|
35
|
+
// this script is documented to be run from plugins/cool-workflow (RELEASE.md's
|
|
36
|
+
// checklist cwd), where a bare ".cw-release/verdict-signing.pub" would
|
|
37
|
+
// silently resolve under plugins/cool-workflow/.cw-release/ instead, a path no
|
|
38
|
+
// verifier ever looks at. Resolve the real root so the printed command is
|
|
39
|
+
// correct regardless of where this script was invoked from.
|
|
40
|
+
function repoRootPubkeyPath() {
|
|
41
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
42
|
+
const root = r.status === 0 ? r.stdout.trim() : undefined;
|
|
43
|
+
return root ? path.join(root, ".cw-release", "verdict-signing.pub") : ".cw-release/verdict-signing.pub";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
47
|
+
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
48
|
+
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
49
|
+
|
|
50
|
+
if (process.argv.includes("--print")) {
|
|
51
|
+
process.stdout.write(`# PRIVATE (release operator — CW_RELEASE_VERDICT_PRIVKEY, never commit)\n${privatePem}\n# PUBLIC (commit as .cw-release/verdict-signing.pub)\n${publicPem}\n`);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const outDir = path.resolve(arg("--out-dir", process.cwd()));
|
|
56
|
+
const keyPath = path.join(outDir, "verdict-signing.key");
|
|
57
|
+
const pubPath = path.join(outDir, "verdict-signing.pub");
|
|
58
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
59
|
+
// Private key: owner-only perms (0600). NEVER commit this file.
|
|
60
|
+
fs.writeFileSync(keyPath, privatePem, { mode: 0o600 });
|
|
61
|
+
fs.writeFileSync(pubPath, publicPem);
|
|
62
|
+
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
[
|
|
65
|
+
`Wrote ed25519 keypair:`,
|
|
66
|
+
` private (operator, keep off the repo): ${keyPath} (chmod 0600)`,
|
|
67
|
+
` public (commit to the repo): ${pubPath}`,
|
|
68
|
+
``,
|
|
69
|
+
`Release operator (local machine, never committed):`,
|
|
70
|
+
` export CW_RELEASE_VERDICT_PRIVKEY="${keyPath}"`,
|
|
71
|
+
``,
|
|
72
|
+
`Repo (commit this so CI/hook can verify — path is repo-root-anchored,`,
|
|
73
|
+
`correct no matter which directory you run this from):`,
|
|
74
|
+
` cp "${pubPath}" "${repoRootPubkeyPath()}"`,
|
|
75
|
+
` git -C "$(git rev-parse --show-toplevel)" add .cw-release/verdict-signing.pub`,
|
|
76
|
+
` git -C "$(git rev-parse --show-toplevel)" commit -m "chore: add release-verdict signing public key"`,
|
|
77
|
+
``,
|
|
78
|
+
`Once .cw-release/verdict-signing.pub is committed, release-gate.yml, npm-publish.yml,`,
|
|
79
|
+
`and block-unapproved-tag.sh all start REQUIRING a valid signature on top of the`,
|
|
80
|
+
`existing APPROVED text check — an unsigned or hand-written verdict fails closed.`,
|
|
81
|
+
``
|
|
82
|
+
].join("\n")
|
|
83
|
+
);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# verify-bump-reproduction.sh — closes the HEAD~1 verdict-replay bypass.
|
|
3
|
+
#
|
|
4
|
+
# release-gate.yml / npm-publish.yml tolerate a verdict written for the tag
|
|
5
|
+
# commit's PARENT (release-flow.js's cut() reviews content, then adds a
|
|
6
|
+
# mechanical version-bump + the verdict as a NEW child commit that actually
|
|
7
|
+
# gets tagged). A validly-signed verdict at the parent proves someone once
|
|
8
|
+
# approved THAT commit — it does NOT prove the child (the thing actually being
|
|
9
|
+
# tagged) is the deterministic bump cut() would have produced, rather than an
|
|
10
|
+
# attacker's own commit smuggling in arbitrary changes on top of an old,
|
|
11
|
+
# genuinely-approved parent (a real signature on a REPLAYED verdict).
|
|
12
|
+
#
|
|
13
|
+
# This script independently reproduces the bump: checks out the approved
|
|
14
|
+
# parent into a scratch worktree, runs the SAME bump:version/sync:project-index
|
|
15
|
+
# steps cut() runs, stages the SAME way cut() stages (git add -u + the
|
|
16
|
+
# explicit verdict/.sig paths, never -A), and requires the resulting tree to
|
|
17
|
+
# match the ACTUAL tagged commit's tree byte-for-byte. Any difference —
|
|
18
|
+
# anywhere, including a single added file — fails closed.
|
|
19
|
+
#
|
|
20
|
+
# Usage: verify-bump-reproduction.sh <approved-parent-sha> <tagged-sha> <verdict-repo-relative-path> [sig-repo-relative-path]
|
|
21
|
+
#
|
|
22
|
+
# Exit 0: the tagged commit's tree matches the reproduced bump exactly.
|
|
23
|
+
# Exit 1: mismatch, or any step failed (install, bump, sync, worktree, etc).
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
# Where THIS SCRIPT ITSELF lives — distinct from REPO_ROOT (the git repo being
|
|
27
|
+
# operated ON, resolved below from the invocation cwd). These coincide in
|
|
28
|
+
# ordinary production use (the script runs from within the same checkout it
|
|
29
|
+
# verifies), but conflating them is wrong: REPO_ROOT could be an ARBITRARY git
|
|
30
|
+
# context (e.g. a test harness's throwaway clone of old history, which never
|
|
31
|
+
# has this script's own sibling files, since they postdate whatever was
|
|
32
|
+
# cloned). Sibling files this script depends on must be found relative to
|
|
33
|
+
# where it is installed, never relative to the repo it happens to be pointed at.
|
|
34
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
35
|
+
|
|
36
|
+
PARENT="${1:-}"
|
|
37
|
+
TAGGED="${2:-}"
|
|
38
|
+
VERDICT_REL="${3:-}"
|
|
39
|
+
SIG_REL="${4:-}"
|
|
40
|
+
if [[ -z "$PARENT" || -z "$TAGGED" || -z "$VERDICT_REL" ]]; then
|
|
41
|
+
echo "usage: verify-bump-reproduction.sh <parent-sha> <tagged-sha> <verdict-rel-path> [sig-rel-path]" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)" || exit 1
|
|
46
|
+
SCRATCH_PARENT="$(mktemp -d)"
|
|
47
|
+
SCRATCH="$SCRATCH_PARENT/wt"
|
|
48
|
+
cleanup() {
|
|
49
|
+
git -C "$REPO_ROOT" worktree remove --force "$SCRATCH" >/dev/null 2>&1
|
|
50
|
+
rm -rf "$SCRATCH_PARENT"
|
|
51
|
+
}
|
|
52
|
+
trap cleanup EXIT
|
|
53
|
+
|
|
54
|
+
if ! git -C "$REPO_ROOT" worktree add --quiet --detach "$SCRATCH" "$PARENT" 2>/dev/null; then
|
|
55
|
+
echo "verify-bump-reproduction: could not create a scratch worktree at $PARENT" >&2
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# The target version comes from the TAGGED commit (not the parent, which may
|
|
60
|
+
# still be pre-bump) — read via git plumbing, no separate checkout needed.
|
|
61
|
+
VERSION="$(git -C "$REPO_ROOT" show "$TAGGED:plugins/cool-workflow/package.json" 2>/dev/null | node -e '
|
|
62
|
+
let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{ try { process.stdout.write(JSON.parse(s).version||""); } catch { process.stdout.write(""); } });
|
|
63
|
+
')"
|
|
64
|
+
if [[ -z "$VERSION" ]]; then
|
|
65
|
+
echo "verify-bump-reproduction: could not read package.json version from tagged commit $TAGGED" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# sync-project-index.js embeds a wall-clock "Generated on <date>" line. Pin it
|
|
70
|
+
# to the TAGGED commit's own committer date (UTC) via a Node --require preload
|
|
71
|
+
# that overrides the global Date constructor (fake-date-for-reproduction.js),
|
|
72
|
+
# NOT an application-level env var: the scratch worktree runs the APPROVED
|
|
73
|
+
# PARENT's OWN checked-out copy of sync-project-index.js, which for every
|
|
74
|
+
# release cut before this mechanism existed has no idea any such env var is
|
|
75
|
+
# meant to be read — an app-level opt-in is a no-op against code that doesn't
|
|
76
|
+
# know to opt in, so it can only ever "work" for releases cut after the
|
|
77
|
+
# mechanism landed (confirmed empirically: re-running an already-shipped
|
|
78
|
+
# release's real approved-parent/tagged pair on a later calendar day produced
|
|
79
|
+
# a tree mismatch whose ONLY diff was that one date line). A global override
|
|
80
|
+
# of the Date constructor, injected before any application code runs, is
|
|
81
|
+
# transparent to the code being executed regardless of which version it is.
|
|
82
|
+
CUT_DATE="$(TZ=UTC git -C "$REPO_ROOT" show -s --format=%cd --date=format-local:%Y-%m-%d "$TAGGED" 2>/dev/null)"
|
|
83
|
+
if [[ -z "$CUT_DATE" ]]; then
|
|
84
|
+
echo "verify-bump-reproduction: could not read the tagged commit's committer date" >&2
|
|
85
|
+
exit 1
|
|
86
|
+
fi
|
|
87
|
+
FAKE_DATE_PRELOAD="$SCRIPT_DIR/fake-date-for-reproduction.js"
|
|
88
|
+
|
|
89
|
+
# Each step's exit code is checked EXPLICITLY and immediately (not via a
|
|
90
|
+
# `cmd1 && cmd2 && cmd3` chain) — a chain's overall success can silently
|
|
91
|
+
# survive an earlier step's failure if a future edit ever loosens it (e.g.
|
|
92
|
+
# `&&` accidentally becoming `;`), which would flip this fail-closed gate to
|
|
93
|
+
# fail-open without changing its outward shape. stdout/stderr from each step
|
|
94
|
+
# are captured (not discarded to /dev/null) so a real failure — a transient
|
|
95
|
+
# npm registry hiccup vs. an actual reproduction mismatch — is distinguishable
|
|
96
|
+
# in the workflow log instead of both collapsing into the same generic message.
|
|
97
|
+
run_step() {
|
|
98
|
+
local label="$1"
|
|
99
|
+
shift
|
|
100
|
+
local out
|
|
101
|
+
out="$("$@" 2>&1)"
|
|
102
|
+
local code=$?
|
|
103
|
+
if [[ $code -ne 0 ]]; then
|
|
104
|
+
echo "verify-bump-reproduction: $label failed (exit $code):" >&2
|
|
105
|
+
echo "$out" >&2
|
|
106
|
+
return 1
|
|
107
|
+
fi
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
(
|
|
112
|
+
cd "$SCRATCH/plugins/cool-workflow" || exit 1
|
|
113
|
+
export NODE_OPTIONS="--require $FAKE_DATE_PRELOAD"
|
|
114
|
+
export CW_FAKE_DATE="$CUT_DATE"
|
|
115
|
+
run_step "npm install" npm install --no-package-lock --ignore-scripts || exit 1
|
|
116
|
+
run_step "bump:version" npm run bump:version -- "$VERSION" || exit 1
|
|
117
|
+
run_step "sync:project-index" npm run sync:project-index -- --repo-only || exit 1
|
|
118
|
+
)
|
|
119
|
+
BUMP_STATUS=$?
|
|
120
|
+
if [[ $BUMP_STATUS -ne 0 ]]; then
|
|
121
|
+
echo "verify-bump-reproduction: reproducing the bump for $PARENT -> v$VERSION failed" >&2
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
mkdir -p "$SCRATCH/.cw-release"
|
|
126
|
+
cp "$REPO_ROOT/$VERDICT_REL" "$SCRATCH/$VERDICT_REL" || exit 1
|
|
127
|
+
if [[ -n "$SIG_REL" && -f "$REPO_ROOT/$SIG_REL" ]]; then
|
|
128
|
+
cp "$REPO_ROOT/$SIG_REL" "$SCRATCH/$SIG_REL" || exit 1
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# Mirror cut()'s OWN staging exactly (git-add -u + the explicit verdict/.sig
|
|
132
|
+
# paths) — never -A, so an untracked stray left by npm/tooling in the scratch
|
|
133
|
+
# worktree can never silently ride into what we compare.
|
|
134
|
+
git -C "$SCRATCH" add -u
|
|
135
|
+
git -C "$SCRATCH" add -- "$VERDICT_REL"
|
|
136
|
+
if [[ -n "$SIG_REL" && -f "$SCRATCH/$SIG_REL" ]]; then
|
|
137
|
+
git -C "$SCRATCH" add -- "$SIG_REL"
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
EXPECTED_TREE="$(git -C "$SCRATCH" write-tree)"
|
|
141
|
+
ACTUAL_TREE="$(git -C "$REPO_ROOT" rev-parse "$TAGGED^{tree}")"
|
|
142
|
+
|
|
143
|
+
if [[ "$EXPECTED_TREE" != "$ACTUAL_TREE" ]]; then
|
|
144
|
+
echo "verify-bump-reproduction: $TAGGED's tree ($ACTUAL_TREE) does not match the deterministic bump reproduced from approved parent $PARENT ($EXPECTED_TREE) — refusing" >&2
|
|
145
|
+
exit 1
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
exit 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// verify-verdict-signature.js — verify an ed25519 signature over a committed
|
|
5
|
+
// release verdict file, against the committed public key
|
|
6
|
+
// (.cw-release/verdict-signing.pub). Portable (node + node:crypto only) so
|
|
7
|
+
// CI (release-gate.yml, npm-publish.yml) and the local block-unapproved-tag.sh
|
|
8
|
+
// hook can all shell out to the SAME check instead of three drifting copies.
|
|
9
|
+
//
|
|
10
|
+
// Exit 0: signature verifies. Exit 1: missing file, unreadable key, or a
|
|
11
|
+
// signature that does not match (tampered verdict, wrong key, hand-written
|
|
12
|
+
// verdict with no real signature). Never throws uncaught — callers rely on
|
|
13
|
+
// the exit code alone, not stderr text.
|
|
14
|
+
//
|
|
15
|
+
// Usage: node verify-verdict-signature.js <verdict-file> <sig-file> <pubkey-file>
|
|
16
|
+
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const crypto = require("node:crypto");
|
|
19
|
+
|
|
20
|
+
function fail(message) {
|
|
21
|
+
process.stderr.write(`verify-verdict-signature: ${message}\n`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const [, , verdictPath, sigPath, pubkeyPath] = process.argv;
|
|
26
|
+
if (!verdictPath || !sigPath || !pubkeyPath) {
|
|
27
|
+
process.stderr.write("usage: verify-verdict-signature.js <verdict-file> <sig-file> <pubkey-file>\n");
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let message;
|
|
32
|
+
try {
|
|
33
|
+
message = fs.readFileSync(verdictPath);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
fail(`cannot read verdict file ${verdictPath}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let signature;
|
|
39
|
+
try {
|
|
40
|
+
signature = Buffer.from(fs.readFileSync(sigPath, "utf8").trim(), "base64");
|
|
41
|
+
if (signature.length === 0) fail(`signature file ${sigPath} is empty`);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
fail(`cannot read signature file ${sigPath}: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let publicKey;
|
|
47
|
+
try {
|
|
48
|
+
publicKey = crypto.createPublicKey(fs.readFileSync(pubkeyPath, "utf8"));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
fail(`cannot read public key ${pubkeyPath}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let ok = false;
|
|
54
|
+
try {
|
|
55
|
+
ok = crypto.verify(null, message, publicKey, signature);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
fail(`verification error: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!ok) fail(`signature does not match ${verdictPath} (tampered, wrong key, or not a real signature)`);
|
|
61
|
+
process.exit(0);
|