cool-workflow 0.2.2 → 0.2.4
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 +13 -6
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +12 -22
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +3 -2
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +31 -6
- 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 +3 -3
- package/dist/core/multi-agent/coordinator.js +5 -5
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- 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 +13 -13
- package/dist/core/state/state-explosion/graph.js +17 -11
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/util/cli-args.js +33 -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 +57 -25
- 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 +251 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +30 -2
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +19 -11
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +218 -29
- 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 +80 -38
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +1 -1
- package/dist/shell/operator-ux-text.js +22 -22
- package/dist/shell/operator-ux.js +15 -14
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +107 -42
- package/dist/shell/reclamation-io.js +88 -10
- package/dist/shell/registry-cli.js +23 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-registry-io.js +78 -19
- package/dist/shell/run-store.js +74 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +17 -9
- package/dist/shell/topology-io.js +36 -5
- package/dist/shell/trust-audit.js +224 -22
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +35 -31
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +28 -19
- package/dist/wiring/capability-table/multi-agent.js +187 -180
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +64 -52
- package/dist/wiring/capability-table/registry-core.js +30 -11
- package/dist/wiring/capability-table/reporting.js +147 -106
- package/dist/wiring/capability-table/scheduling-registry.js +201 -167
- package/dist/wiring/capability-table/state.js +59 -51
- package/dist/wiring/capability-table/trust-ledger.js +52 -28
- package/dist/wiring/capability-table/workflow-apps.js +85 -70
- 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 +26 -16
- 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 +4 -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/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 +31 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +22 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +24 -3
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -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 +3 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +42 -6
- package/scripts/bump-version.js +27 -10
- 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 +0 -27
- package/scripts/release-flow.js +194 -13
- package/scripts/release-oneclick.js +407 -0
- 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 +39 -22
- 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
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// release-oneclick.js — the operator's ONE command for a full release.
|
|
5
|
+
//
|
|
6
|
+
// npm run release -- X.Y.Z
|
|
7
|
+
// node scripts/release-oneclick.js X.Y.Z [--dry-run]
|
|
8
|
+
//
|
|
9
|
+
// This is a pure orchestrator over EXISTING tools (release-flow.js,
|
|
10
|
+
// bump-version.js, sync-project-index.js, gh, git) — it adds no new release
|
|
11
|
+
// logic of its own, only sequencing, resumability, and fail-fast checks.
|
|
12
|
+
//
|
|
13
|
+
// The two-step release model this implements:
|
|
14
|
+
// 1. Ask the agent to "prepare release X.Y.Z" — it drafts the CHANGELOG
|
|
15
|
+
// entry and opens/merges the version-bump PR. This step never touches
|
|
16
|
+
// CW_RELEASE_VERDICT_PRIVKEY.
|
|
17
|
+
// 2. The OPERATOR runs this script, in a shell where
|
|
18
|
+
// CW_RELEASE_VERDICT_PRIVKEY is set. Nothing in this file reads that
|
|
19
|
+
// env var directly — it is forwarded to `release-flow.js`'s existing
|
|
20
|
+
// signVerdictIfConfigured(), unchanged.
|
|
21
|
+
//
|
|
22
|
+
// Stages (each is idempotent — probes current state before acting, so a
|
|
23
|
+
// re-run after a failure resumes rather than repeats):
|
|
24
|
+
// 0. preflight — release-flow.js --cut --preflight-only (fail in seconds,
|
|
25
|
+
// before the gate/vendor-preflight/reviewer spend anything)
|
|
26
|
+
// 1. bump PR — only if package.json is not already at the target
|
|
27
|
+
// version; skipped when the agent's prep PR already landed
|
|
28
|
+
// 2. cut — release-flow.js --cut --version X --push (gate, live
|
|
29
|
+
// vendor preflight, reviewer, sign, tag-only push, Release)
|
|
30
|
+
// 3. record+wait — PR landing the verdict+.sig onto main (informational,
|
|
31
|
+
// no gate implications) + poll release-gate/npm-publish +
|
|
32
|
+
// confirm `npm view` shows the new version
|
|
33
|
+
//
|
|
34
|
+
// Resume: when the vX.Y.Z tag already exists (local or on origin), the cut
|
|
35
|
+
// already happened — stages 0-2 are skipped and the run goes STRAIGHT to
|
|
36
|
+
// stage 3. This is what makes a re-run after a stage-3 death (a red CI run,
|
|
37
|
+
// a network drop, a Ctrl-C mid-wait) pick the release back up instead of
|
|
38
|
+
// being refused by the preflight's own already-tagged check. Every wait has
|
|
39
|
+
// a hard deadline and dies loudly after repeated gh/git failures — a hung
|
|
40
|
+
// wait is indistinguishable from a dead release otherwise.
|
|
41
|
+
//
|
|
42
|
+
// Test seam: CW_ONECLICK_GH_CMD / CW_ONECLICK_GIT_CMD override the gh/git
|
|
43
|
+
// binaries (single executable token, spawned shell:false) for smoke stubs.
|
|
44
|
+
|
|
45
|
+
const fs = require("node:fs");
|
|
46
|
+
const path = require("node:path");
|
|
47
|
+
const { spawnSync } = require("node:child_process");
|
|
48
|
+
|
|
49
|
+
const scriptsDir = __dirname;
|
|
50
|
+
const pluginRoot = path.resolve(scriptsDir, "..");
|
|
51
|
+
const repoRoot = path.resolve(pluginRoot, "..", "..");
|
|
52
|
+
|
|
53
|
+
const argv = process.argv.slice(2);
|
|
54
|
+
const DRY_RUN = argv.includes("--dry-run");
|
|
55
|
+
const version = argv.find((a) => /^\d+\.\d+\.\d+$/.test(a));
|
|
56
|
+
|
|
57
|
+
const GIT_BIN = (process.env.CW_ONECLICK_GIT_CMD || "git").trim();
|
|
58
|
+
const GH_BIN = (process.env.CW_ONECLICK_GH_CMD || "gh").trim();
|
|
59
|
+
|
|
60
|
+
function die(msg, extra) {
|
|
61
|
+
process.stderr.write(`release-oneclick: ${msg}\n`);
|
|
62
|
+
if (extra) process.stderr.write(`${extra}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
function say(msg) {
|
|
66
|
+
process.stdout.write(`${msg}\n`);
|
|
67
|
+
}
|
|
68
|
+
function stage(n, label) {
|
|
69
|
+
say(`\n[${n}/4] ${label}`);
|
|
70
|
+
}
|
|
71
|
+
function git(args, opts = {}) {
|
|
72
|
+
const r = spawnSync(GIT_BIN, args, { cwd: repoRoot, encoding: "utf8", shell: false, ...opts });
|
|
73
|
+
return { code: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
|
|
74
|
+
}
|
|
75
|
+
// Like git(), but stdout is returned VERBATIM — no trim. The record stage
|
|
76
|
+
// copies verdict/.sig bytes out of the tag commit, and those bytes must stay
|
|
77
|
+
// exactly what was signed (a trimmed copy next to the same .sig could never
|
|
78
|
+
// verify again).
|
|
79
|
+
function gitRaw(args, opts = {}) {
|
|
80
|
+
const r = spawnSync(GIT_BIN, args, { cwd: repoRoot, encoding: "buffer", shell: false, ...opts });
|
|
81
|
+
return { code: r.status, out: r.stdout || Buffer.alloc(0), err: (r.stderr || "").toString().trim() };
|
|
82
|
+
}
|
|
83
|
+
function gh(args, opts = {}) {
|
|
84
|
+
const r = spawnSync(GH_BIN, args, { cwd: repoRoot, encoding: "utf8", shell: false, ...opts });
|
|
85
|
+
return { code: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
|
|
86
|
+
}
|
|
87
|
+
function sleep(ms) {
|
|
88
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
89
|
+
}
|
|
90
|
+
// Shared poll helper: every wait in this script has a hard deadline (a wait
|
|
91
|
+
// that can spin forever hides a dead release) and dies after too many gh/git
|
|
92
|
+
// failures in a row (an expired auth or dead network must surface, not hang).
|
|
93
|
+
function poll({ label, timeoutMs, intervalMs, probe }) {
|
|
94
|
+
const deadline = Date.now() + timeoutMs;
|
|
95
|
+
let consecutiveErrors = 0;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const r = probe();
|
|
98
|
+
if (r && r.done) return r.value;
|
|
99
|
+
consecutiveErrors = r && r.error ? consecutiveErrors + 1 : 0;
|
|
100
|
+
if (consecutiveErrors >= 10) die(`${label}: 10 gh/git probes in a row failed — check network/auth and re-run to resume.`);
|
|
101
|
+
if (Date.now() > deadline) die(`${label}: still not done after ${Math.round(timeoutMs / 60000)} minutes — inspect manually, then re-run to resume.`);
|
|
102
|
+
sleep(intervalMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!version) {
|
|
107
|
+
die("usage: node scripts/release-oneclick.js X.Y.Z [--dry-run]");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- resume detection (before anything else) --------------------------------
|
|
111
|
+
// After a successful cut, the vX.Y.Z tag exists — release-flow's preflight
|
|
112
|
+
// would then (correctly, for a FRESH cut) refuse to run. But a re-run of THIS
|
|
113
|
+
// script after a stage-3 failure (a red CI run, a network drop, a Ctrl-C
|
|
114
|
+
// during the wait) must not be told "pick the next version": the release is
|
|
115
|
+
// mid-flight, not done. So: tag already cut -> skip straight to stage 3.
|
|
116
|
+
function alreadyCut() {
|
|
117
|
+
if (git(["tag", "-l", `v${version}`]).out) return true;
|
|
118
|
+
if (DRY_RUN) return false; // offline decision only in dry-run
|
|
119
|
+
const remote = git(["ls-remote", "origin", `refs/tags/v${version}`]);
|
|
120
|
+
return remote.code === 0 && Boolean(remote.out);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- stage 0: preflight -----------------------------------------------------
|
|
124
|
+
function runPreflight() {
|
|
125
|
+
stage(0, "preflight");
|
|
126
|
+
const args = ["scripts/release-flow.js", "--cut", "--version", version, "--push", "--preflight-only"];
|
|
127
|
+
const r = spawnSync("node", args, { cwd: pluginRoot, encoding: "utf8", stdio: "inherit" });
|
|
128
|
+
if (r.status !== 0) {
|
|
129
|
+
die("preflight failed — fix the issue above before re-running (nothing was done yet).");
|
|
130
|
+
}
|
|
131
|
+
if (gh(["--version"]).code !== 0) die("gh CLI not found — required for the bump/verdict PRs and the GitHub Release.");
|
|
132
|
+
if (gh(["auth", "status"]).code !== 0) die("gh is not authenticated — run `gh auth login` first.");
|
|
133
|
+
say("preflight OK");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- stage 1: bump PR (skipped if already at target version) ---------------
|
|
137
|
+
function currentPackageVersion() {
|
|
138
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8"));
|
|
139
|
+
return pkg.version;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function runBumpStage() {
|
|
143
|
+
stage(1, "version bump");
|
|
144
|
+
if (currentPackageVersion() === version) {
|
|
145
|
+
say(`package.json already at ${version} — assuming the prep PR already landed, skipping.`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (DRY_RUN) {
|
|
149
|
+
say(`[dry-run] would: branch off origin/main, bump:version -- ${version} --content, sync:project-index, open + auto-merge a bump PR.`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const fetch = git(["fetch", "origin", "main", "--quiet"]);
|
|
154
|
+
if (fetch.code !== 0) die("git fetch origin main failed — the bump branch must start from the REAL main tip", fetch.err);
|
|
155
|
+
const branch = `release/v${version}-bump`;
|
|
156
|
+
const co = git(["checkout", "-B", branch, "origin/main"]);
|
|
157
|
+
if (co.code !== 0) die("could not branch from origin/main for the bump PR", co.err);
|
|
158
|
+
|
|
159
|
+
const bump = spawnSync("npm", ["run", "bump:version", "--", version, "--content"], { cwd: pluginRoot, stdio: "inherit" });
|
|
160
|
+
if (bump.status !== 0) die("bump:version --content failed");
|
|
161
|
+
|
|
162
|
+
const sync = spawnSync("npm", ["run", "sync:project-index", "--", "--repo-only"], { cwd: pluginRoot, stdio: "inherit" });
|
|
163
|
+
if (sync.status !== 0) die("sync:project-index failed");
|
|
164
|
+
|
|
165
|
+
// tsc's incremental cache can ship a stale dist/version.js after a version
|
|
166
|
+
// edit (documented gotcha) — force a clean rebuild before committing.
|
|
167
|
+
fs.rmSync(path.join(pluginRoot, ".cache"), { recursive: true, force: true });
|
|
168
|
+
const build = spawnSync("npm", ["run", "build"], { cwd: pluginRoot, stdio: "inherit" });
|
|
169
|
+
if (build.status !== 0) die("npm run build failed after bump");
|
|
170
|
+
|
|
171
|
+
const logPath = path.join(repoRoot, "ITERATION_LOG.md");
|
|
172
|
+
const log = fs.readFileSync(logPath, "utf8");
|
|
173
|
+
const entry = [
|
|
174
|
+
"# CW Iteration Log",
|
|
175
|
+
"",
|
|
176
|
+
`## Batch — v${version} version bump (Unreleased)`,
|
|
177
|
+
"",
|
|
178
|
+
`> Release prep for v${version}: bump every structured surface with`,
|
|
179
|
+
`> \`bump:version -- ${version} --content\` and fill the gated content`,
|
|
180
|
+
"> surfaces (CHANGELOG.md, RELEASE.md, docs version lists). The bump",
|
|
181
|
+
"> lands as its own PR before the cut so the cut's own bump:version step",
|
|
182
|
+
"> is a no-op. No behavior change; the version constant is the only src",
|
|
183
|
+
"> edit. Generated by release-oneclick.js's bump stage.",
|
|
184
|
+
"",
|
|
185
|
+
"| cycle | goal | files | tests | gate | tagged |",
|
|
186
|
+
"|-------|------|-------|-------|------|--------|",
|
|
187
|
+
`| - | Bump to v${version} across all structured + content surfaces via release-oneclick.js. | ` +
|
|
188
|
+
"package.json + lockfile + manifests + src/core/version.ts + matching dist/**, CHANGELOG.md, RELEASE.md, docs/*.7.md version lists. | " +
|
|
189
|
+
"Full local gate before PR. | BUILD OK; content-surface gate OK. | no (bump PR; the tag comes from release-flow --cut) |"
|
|
190
|
+
].join("\n");
|
|
191
|
+
fs.writeFileSync(logPath, `${entry}\n\n${log.replace(/^# CW Iteration Log\n\n?/, "")}`);
|
|
192
|
+
|
|
193
|
+
// Tracked files only — NEVER `git add -A` here. Everything the bump stage
|
|
194
|
+
// touches is already tracked (bump surfaces, docs appends, ITERATION_LOG,
|
|
195
|
+
// dist), and an untracked stray (a scratch file, a reviewer transcript with
|
|
196
|
+
// the operator's home path) must not ride into a PR onto main — the exact
|
|
197
|
+
// incident cut()'s staging comment documents from v0.1.96.
|
|
198
|
+
git(["add", "-u"], { cwd: repoRoot });
|
|
199
|
+
const commit = git(["commit", "-m", `chore(release): bump version to ${version}`], { cwd: repoRoot });
|
|
200
|
+
if (commit.code !== 0) die("bump commit failed", commit.err);
|
|
201
|
+
|
|
202
|
+
// --force-with-lease: a prior run may have pushed this branch and then died
|
|
203
|
+
// before the PR merged; the re-run rebuilds the branch from origin/main with
|
|
204
|
+
// a new sha, and a plain push would be rejected non-fast-forward. The lease
|
|
205
|
+
// keeps this safe: it only replaces the remote branch we saw at fetch time.
|
|
206
|
+
const push = git(["push", "-u", "--force-with-lease", "origin", branch], { cwd: repoRoot });
|
|
207
|
+
if (push.code !== 0) die("bump branch push failed", push.err);
|
|
208
|
+
|
|
209
|
+
const pr = gh(["pr", "create", "--base", "main", "--head", branch, "--title", `chore(release): bump version to ${version}`, "--body", "Automated version bump ahead of the gated cut. Generated by release-oneclick.js."]);
|
|
210
|
+
if (pr.code !== 0) die("gh pr create failed for the bump PR", pr.err);
|
|
211
|
+
say(pr.out);
|
|
212
|
+
|
|
213
|
+
const merge = gh(["pr", "merge", branch, "--auto", "--squash"]);
|
|
214
|
+
if (merge.code !== 0) die("gh pr merge --auto failed for the bump PR", merge.err);
|
|
215
|
+
|
|
216
|
+
say("waiting for the bump PR to merge...");
|
|
217
|
+
poll({
|
|
218
|
+
label: "bump PR merge wait",
|
|
219
|
+
timeoutMs: 30 * 60 * 1000,
|
|
220
|
+
intervalMs: 15000,
|
|
221
|
+
probe: () => {
|
|
222
|
+
const view = gh(["pr", "view", branch, "--json", "state"]);
|
|
223
|
+
if (view.code !== 0) return { error: true };
|
|
224
|
+
let state;
|
|
225
|
+
try {
|
|
226
|
+
state = JSON.parse(view.out).state;
|
|
227
|
+
} catch {
|
|
228
|
+
return { error: true };
|
|
229
|
+
}
|
|
230
|
+
if (state === "MERGED") return { done: true };
|
|
231
|
+
if (state === "CLOSED") die("the bump PR was closed without merging");
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
say("bump PR merged.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- stage 2: cut ------------------------------------------------------------
|
|
239
|
+
function runCutStage() {
|
|
240
|
+
stage(2, "cut (gate + vendor preflight + reviewer + sign + tag + push)");
|
|
241
|
+
if (DRY_RUN) {
|
|
242
|
+
say(`[dry-run] would: node scripts/release-flow.js --cut --version ${version} --push`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
git(["fetch", "origin", "main", "--quiet"], { cwd: repoRoot });
|
|
246
|
+
const co = git(["checkout", "-B", `release/v${version}-cut`, "origin/main"], { cwd: repoRoot });
|
|
247
|
+
if (co.code !== 0) die("could not branch from origin/main for the cut", co.err);
|
|
248
|
+
|
|
249
|
+
const r = spawnSync("node", ["scripts/release-flow.js", "--cut", "--version", version, "--push"], {
|
|
250
|
+
cwd: pluginRoot,
|
|
251
|
+
encoding: "utf8",
|
|
252
|
+
stdio: "inherit"
|
|
253
|
+
});
|
|
254
|
+
if (r.status !== 0) die("cut failed — see the release-flow.js output above.");
|
|
255
|
+
say("cut complete: tag pushed, GitHub Release created.");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- stage 3: record on main + wait for CI + confirm npm -------------------
|
|
259
|
+
function verdictPaths() {
|
|
260
|
+
const dir = path.join(repoRoot, ".cw-release");
|
|
261
|
+
const tagCommit = git(["rev-parse", `v${version}^{commit}`]).out;
|
|
262
|
+
const reviewedParent = git(["rev-parse", `v${version}~1`]).out;
|
|
263
|
+
// cut() commits the verdict directly on the reviewed parent, so the
|
|
264
|
+
// filename is keyed on THAT sha, not the tag commit's own sha.
|
|
265
|
+
const verdict = path.join(dir, `review-${reviewedParent}.verdict`);
|
|
266
|
+
const sig = `${verdict}.sig`;
|
|
267
|
+
return { tagCommit, reviewedParent, verdict, sig, dir };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function runRecordAndWaitStage() {
|
|
271
|
+
stage(3, "record on main + wait for CI + confirm npm");
|
|
272
|
+
if (DRY_RUN) {
|
|
273
|
+
say("[dry-run] would: open a PR landing the verdict+.sig onto main, then poll release-gate/npm-publish/npm view.");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const { tagCommit, reviewedParent, verdict, sig } = verdictPaths();
|
|
278
|
+
const relVerdict = path.relative(repoRoot, verdict);
|
|
279
|
+
const relSig = path.relative(repoRoot, sig);
|
|
280
|
+
// Byte-exact copies from the TAG commit — the .sig was made over the
|
|
281
|
+
// verdict file's exact bytes, so a trimmed/normalized copy on main could
|
|
282
|
+
// never verify against the same signature again.
|
|
283
|
+
const verdictBytes = gitRaw(["show", `${tagCommit}:${relVerdict}`], { cwd: repoRoot });
|
|
284
|
+
if (verdictBytes.code === 0) {
|
|
285
|
+
const fetch = git(["fetch", "origin", "main", "--quiet"], { cwd: repoRoot });
|
|
286
|
+
if (fetch.code !== 0) {
|
|
287
|
+
say(`WARN: git fetch failed (${fetch.err}) — skipping the main-record PR (informational only).`);
|
|
288
|
+
} else {
|
|
289
|
+
const branch = `release/v${version}-record`;
|
|
290
|
+
const co = git(["checkout", "-B", branch, "origin/main"], { cwd: repoRoot });
|
|
291
|
+
if (co.code === 0) {
|
|
292
|
+
fs.mkdirSync(path.dirname(verdict), { recursive: true });
|
|
293
|
+
fs.writeFileSync(verdict, verdictBytes.out);
|
|
294
|
+
const sigBytes = gitRaw(["show", `${tagCommit}:${relSig}`], { cwd: repoRoot });
|
|
295
|
+
if (sigBytes.code === 0) fs.writeFileSync(sig, sigBytes.out);
|
|
296
|
+
git(["add", "--", relVerdict], { cwd: repoRoot });
|
|
297
|
+
if (fs.existsSync(sig)) git(["add", "--", relSig], { cwd: repoRoot });
|
|
298
|
+
const status = git(["status", "--porcelain"], { cwd: repoRoot });
|
|
299
|
+
if (status.out) {
|
|
300
|
+
const commit = git(["commit", "-m", `chore(release): record the v${version} reviewer verdict on main`], { cwd: repoRoot });
|
|
301
|
+
if (commit.code === 0) {
|
|
302
|
+
git(["push", "-u", "--force-with-lease", "origin", branch], { cwd: repoRoot });
|
|
303
|
+
const pr = gh(["pr", "create", "--base", "main", "--head", branch, "--title", `chore(release): record the v${version} reviewer verdict on main`, "--body", `Informational — lands the tagged v${version} verdict + signature onto main for the repo's own audit trail. No gate implications (main's required checks don't include release-gate). Reviewed commit: ${reviewedParent}.`]);
|
|
304
|
+
if (pr.code === 0) {
|
|
305
|
+
say(pr.out);
|
|
306
|
+
gh(["pr", "merge", branch, "--auto", "--squash"]);
|
|
307
|
+
} else {
|
|
308
|
+
say(`WARN: could not open the record PR (${pr.err}) — continuing, this is informational only.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
say("main already has this verdict recorded — nothing to do.");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
say(`WARN: no verdict at ${relVerdict} in the tag commit — skipping the main-record PR (informational only, not release-blocking).`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
say(`waiting for release-gate on tag v${version}...`);
|
|
321
|
+
const gateRunId = poll({
|
|
322
|
+
label: "release-gate wait",
|
|
323
|
+
timeoutMs: 45 * 60 * 1000,
|
|
324
|
+
intervalMs: 20000,
|
|
325
|
+
probe: () => {
|
|
326
|
+
const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion"]);
|
|
327
|
+
if (list.code !== 0) return { error: true };
|
|
328
|
+
let runs;
|
|
329
|
+
try {
|
|
330
|
+
runs = JSON.parse(list.out);
|
|
331
|
+
} catch {
|
|
332
|
+
return { error: true };
|
|
333
|
+
}
|
|
334
|
+
const gateRun = runs.find((r) => r.headBranch === `v${version}`);
|
|
335
|
+
if (gateRun && gateRun.status === "completed") {
|
|
336
|
+
if (gateRun.conclusion === "success") return { done: true, value: gateRun.databaseId };
|
|
337
|
+
die(`release-gate FAILED — inspect: gh run view ${gateRun.databaseId} --log-failed`);
|
|
338
|
+
}
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
say(`release-gate: SUCCESS (run ${gateRunId})`);
|
|
343
|
+
|
|
344
|
+
// npm-publish is created only AFTER release-gate completes (workflow_run
|
|
345
|
+
// trigger), so the run to wait for may not exist yet — and the newest
|
|
346
|
+
// completed npm-publish run at this moment is usually the PREVIOUS
|
|
347
|
+
// release's. Only accept a run created at-or-after the moment the gate
|
|
348
|
+
// finished (small clock-skew allowance), and keep waiting until that run
|
|
349
|
+
// exists and completes.
|
|
350
|
+
say("waiting for npm-publish...");
|
|
351
|
+
const gateDoneMs = Date.now();
|
|
352
|
+
poll({
|
|
353
|
+
label: "npm-publish wait",
|
|
354
|
+
timeoutMs: 30 * 60 * 1000,
|
|
355
|
+
intervalMs: 20000,
|
|
356
|
+
probe: () => {
|
|
357
|
+
const list = gh(["run", "list", "--workflow", "npm-publish", "--limit", "5", "--json", "databaseId,status,conclusion,createdAt"]);
|
|
358
|
+
if (list.code !== 0) return { error: true };
|
|
359
|
+
let runs;
|
|
360
|
+
try {
|
|
361
|
+
runs = JSON.parse(list.out);
|
|
362
|
+
} catch {
|
|
363
|
+
return { error: true };
|
|
364
|
+
}
|
|
365
|
+
const fresh = runs
|
|
366
|
+
.filter((r) => Date.parse(r.createdAt) >= gateDoneMs - 120000)
|
|
367
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0];
|
|
368
|
+
if (fresh && fresh.status === "completed") {
|
|
369
|
+
if (fresh.conclusion === "success") return { done: true };
|
|
370
|
+
die(`npm-publish FAILED (conclusion: ${fresh.conclusion}) — inspect: gh run view ${fresh.databaseId} --log-failed`);
|
|
371
|
+
}
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
say("npm-publish: SUCCESS");
|
|
376
|
+
|
|
377
|
+
say("confirming npm...");
|
|
378
|
+
poll({
|
|
379
|
+
label: "npm registry confirmation",
|
|
380
|
+
timeoutMs: 10 * 60 * 1000,
|
|
381
|
+
intervalMs: 15000,
|
|
382
|
+
probe: () => {
|
|
383
|
+
const view = spawnSync("npm", ["view", "cool-workflow", "version"], { encoding: "utf8" });
|
|
384
|
+
if (view.status !== 0) return { error: true };
|
|
385
|
+
return view.stdout.trim() === version ? { done: true } : {};
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
say(`npm view cool-workflow version -> ${version} — confirmed live.`);
|
|
389
|
+
const releaseUrl = gh(["release", "view", `v${version}`, "--json", "url", "--jq", ".url"]);
|
|
390
|
+
say(`\nRELEASE COMPLETE: v${version}`);
|
|
391
|
+
if (releaseUrl.code === 0) say(`GitHub Release: ${releaseUrl.out}`);
|
|
392
|
+
say(`npm: https://www.npmjs.com/package/cool-workflow/v/${version}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (alreadyCut()) {
|
|
396
|
+
say(`tag v${version} already exists — the cut already happened; resuming at stage 3 (CI wait + record + confirm).`);
|
|
397
|
+
if (DRY_RUN) {
|
|
398
|
+
say("[dry-run] would resume at stage 3: record PR + wait for release-gate/npm-publish + npm view confirmation.");
|
|
399
|
+
} else {
|
|
400
|
+
runRecordAndWaitStage();
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
runPreflight();
|
|
404
|
+
runBumpStage();
|
|
405
|
+
runCutStage();
|
|
406
|
+
runRecordAndWaitStage();
|
|
407
|
+
}
|
|
@@ -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
|