cool-workflow 0.2.3 → 0.2.5
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/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +202 -48
- 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 +33 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/io.js +6 -20
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +339 -10
- package/dist/core/format/help.js +80 -4
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +55 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +124 -13
- package/dist/mcp-server.js +20 -0
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +129 -64
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +205 -24
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +47 -10
- package/dist/shell/fs-atomic.js +93 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/multi-agent-cli.js +5 -1
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/pipeline-cli.js +20 -2
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +76 -7
- package/dist/shell/registry-cli.js +4 -0
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-registry-io.js +1 -0
- package/dist/shell/run-store.js +156 -0
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +104 -10
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +79 -5
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +7 -2
- package/dist/wiring/capability-table/exec-backend.js +42 -24
- package/dist/wiring/capability-table/multi-agent.js +69 -69
- package/dist/wiring/capability-table/pipeline.js +60 -28
- package/dist/wiring/capability-table/registry-core.js +29 -6
- package/dist/wiring/capability-table/reporting.js +45 -39
- package/dist/wiring/capability-table/scheduling-registry.js +66 -60
- package/dist/wiring/capability-table/state.js +34 -34
- package/dist/wiring/capability-table/trust-ledger.js +66 -24
- package/dist/wiring/capability-table/workflow-apps.js +15 -15
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +43 -13
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +6 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +43 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +22 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +28 -3
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +26 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +5 -2
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/purity-baseline.json +0 -30
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +264 -41
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +438 -0
- package/scripts/release-tags.js +47 -0
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/scripts/version-sync-check.js +39 -22
- package/ui/workbench/app.css +41 -9
- package/ui/workbench/app.js +160 -27
- package/ui/workbench/index.html +2 -2
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -60
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
package/scripts/release-flow.js
CHANGED
|
@@ -15,15 +15,22 @@
|
|
|
15
15
|
// Modes:
|
|
16
16
|
// node release-flow.js [--check] gate + review, no mutation (default)
|
|
17
17
|
// node release-flow.js --cut --version x.y.z [--push]
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// preflight (fail-fast checks BEFORE the
|
|
19
|
+
// gate/vendor/reviewer spend anything),
|
|
20
|
+
// then gate + review, then bump:version,
|
|
21
|
+
// commit verdict, tag, and — when --push —
|
|
22
|
+
// push ONLY the tag (refs/tags/vx.y.z; the
|
|
23
|
+
// verdict commit is a one-hop leaf off the
|
|
24
|
+
// reviewed main tip, it never lands on a
|
|
25
|
+
// branch) and create the GitHub Release
|
|
26
|
+
// (idempotent; opt out with --no-release)
|
|
22
27
|
// node release-flow.js --release --version x.y.z create-or-skip the GitHub Release for an
|
|
23
28
|
// already-pushed tag (backfill); no gate/cut.
|
|
24
29
|
// Fails closed if gh can't create it; add
|
|
25
30
|
// --soft for best-effort (skip-not-fail).
|
|
26
|
-
// Flags also accepted: --prev-tag <t>, --agent-command "...", --agent-model m, --dry-run
|
|
31
|
+
// Flags also accepted: --prev-tag <t>, --agent-command "...", --agent-model m, --dry-run,
|
|
32
|
+
// --preflight-only (run the cut preflight and stop — used by release-oneclick.js),
|
|
33
|
+
// --allow-stale-head (skip the HEAD == origin/main tip check, deliberate re-cuts only)
|
|
27
34
|
//
|
|
28
35
|
// VERDICT CONTRACT (auto-generated by --cut, never write by hand):
|
|
29
36
|
// Path: .cw-release/review-<FULLSHA>.verdict
|
|
@@ -36,7 +43,7 @@
|
|
|
36
43
|
// the verdict file's exact bytes, writing a sidecar
|
|
37
44
|
// .cw-release/review-<FULLSHA>.verdict.sig (base64 ed25519). Once
|
|
38
45
|
// .cw-release/verdict-signing.pub is committed to the repo, release-gate.yml,
|
|
39
|
-
// npm-publish.yml, and block-unapproved-tag.
|
|
46
|
+
// npm-publish.yml, and block-unapproved-tag.js all start REQUIRING a valid
|
|
40
47
|
// signature in addition to the APPROVED text check — closing the gap where
|
|
41
48
|
// anyone with shell access could hand-write a passing verdict file. Until
|
|
42
49
|
// that public key is committed, every check stays exactly as before
|
|
@@ -44,7 +51,7 @@
|
|
|
44
51
|
//
|
|
45
52
|
// Test seams (smoke/operator only, never the delegated agent):
|
|
46
53
|
// CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
|
|
47
|
-
// (default: `
|
|
54
|
+
// (default: `node <thisdir>/release-gate.js`) so the smoke can exercise the
|
|
48
55
|
// orchestration layer without re-running the full build/test suite.
|
|
49
56
|
// CW_RELEASE_FLOW_GH_CMD overrides the `gh` binary (single executable token,
|
|
50
57
|
// spawned shell:false) so the smoke can stub GitHub Release calls offline.
|
|
@@ -100,15 +107,25 @@ if (top.status !== 0) die("not inside a git work tree");
|
|
|
100
107
|
const repoRoot = top.stdout.trim();
|
|
101
108
|
|
|
102
109
|
const HEAD = git(["rev-parse", "HEAD"]).out;
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
110
|
+
|
|
111
|
+
// ---- release-tag lookup (semver-ordered, NOT git-ancestry-walked) ---------
|
|
112
|
+
// The lookup itself lives in release-tags.js, SHARED with release-gate.js so
|
|
113
|
+
// the two can never drift (the gate used to carry a bash port of it). See
|
|
114
|
+
// that file's header for why this is a semver question, not `git describe`.
|
|
115
|
+
const releaseTags = require("./release-tags.js");
|
|
116
|
+
const parseSemverTag = releaseTags.parseSemverTag;
|
|
117
|
+
const compareSemver = releaseTags.compareSemver;
|
|
118
|
+
const gitTagOut = (args) => git(args).out;
|
|
119
|
+
function listReleaseTagsDesc() {
|
|
120
|
+
return releaseTags.listReleaseTagsDesc(gitTagOut);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The most recent ALREADY-RELEASED tag as of right now, excluding any tag
|
|
124
|
+
// that already points at HEAD (so this works whether run before tagging or
|
|
125
|
+
// re-run on a freshly tagged commit).
|
|
106
126
|
function resolvePrevTag() {
|
|
107
127
|
if (prevTagArg) return prevTagArg;
|
|
108
|
-
|
|
109
|
-
let prev = git(["describe", "--tags", "--abbrev=0"]).out;
|
|
110
|
-
if (prev && headTags.includes(prev)) prev = git(["describe", "--tags", "--abbrev=0", "HEAD^"]).out;
|
|
111
|
-
return prev || "";
|
|
128
|
+
return releaseTags.resolvePrevReleaseTag(gitTagOut);
|
|
112
129
|
}
|
|
113
130
|
const PREV_TAG = resolvePrevTag();
|
|
114
131
|
|
|
@@ -122,7 +139,7 @@ function runGate() {
|
|
|
122
139
|
// the smoke and operator overrides only, never the delegated agent.
|
|
123
140
|
r = spawnSync(override, { cwd: repoRoot, encoding: "utf8", shell: true, stdio: "inherit" });
|
|
124
141
|
} else {
|
|
125
|
-
r = spawnSync(
|
|
142
|
+
r = spawnSync(process.execPath, [path.join(scriptsDir, "release-gate.js")], {
|
|
126
143
|
cwd: repoRoot,
|
|
127
144
|
encoding: "utf8",
|
|
128
145
|
stdio: "inherit"
|
|
@@ -166,17 +183,28 @@ function runVendorPreflight() {
|
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
// ---- 2. independent reviewer, delegated to the configured agent -------------
|
|
169
|
-
// Default reviewer deadline. The zero-trust reviewer re-runs release-gate.
|
|
186
|
+
// Default reviewer deadline. The zero-trust reviewer re-runs release-gate.js,
|
|
170
187
|
// whose sequential test suite alone is ~12 min, then reads + reasons over the
|
|
171
188
|
// diff — so a 10-min default guaranteed a timeout on a real release. 30 min gives
|
|
172
189
|
// headroom; override with CW_AGENT_TIMEOUT_MS (or --agent-timeout-ms).
|
|
173
190
|
const REVIEWER_TIMEOUT_MS = 1800000;
|
|
174
191
|
function reviewerPromptBody() {
|
|
175
192
|
// Reuse the committed reviewer spec as the prompt; strip YAML frontmatter.
|
|
193
|
+
// Fail closed if the spec is gone or empty: a missing/empty spec used to
|
|
194
|
+
// yield "", which shipped the reviewer an EMPTY review prompt (only the
|
|
195
|
+
// candidate context, no instructions) — a review with no rubric is worse
|
|
196
|
+
// than no review, since it still produces a signable verdict. This project's
|
|
197
|
+
// rule is to refuse the unverifiable, not fabricate a pass.
|
|
176
198
|
const specPath = path.join(pluginRoot, "agents", "release-reviewer.md");
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
const rel = path.relative(repoRoot, specPath);
|
|
200
|
+
if (!fs.existsSync(specPath)) {
|
|
201
|
+
die(`reviewer prompt file is missing at ${rel} — will not run a review with an empty prompt. Restore agents/release-reviewer.md.`);
|
|
202
|
+
}
|
|
203
|
+
const body = fs.readFileSync(specPath, "utf8").replace(/^---[\s\S]*?---\n/, "").trim();
|
|
204
|
+
if (!body) {
|
|
205
|
+
die(`reviewer prompt file at ${rel} is empty after its header is removed — will not run a review with an empty prompt.`);
|
|
206
|
+
}
|
|
207
|
+
return body;
|
|
180
208
|
}
|
|
181
209
|
|
|
182
210
|
function buildReviewerInput(resultPath) {
|
|
@@ -215,11 +243,36 @@ function substitute(arg, map) {
|
|
|
215
243
|
return arg.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in map ? String(map[k]) : m));
|
|
216
244
|
}
|
|
217
245
|
|
|
246
|
+
/** True when `line` is a REJECTED verdict line, allowing the markdown
|
|
247
|
+
* decoration a reviewer commonly wraps it in (`**REJECTED**`, `REJECTED:`,
|
|
248
|
+
* `### REJECTED`, `REJECTED.`) — stripped of leading heading/quote/emphasis
|
|
249
|
+
* markers, the line must start with REJECTED not immediately followed by
|
|
250
|
+
* another letter (so "REJECTEDLY speaking..." is still correctly NOT a
|
|
251
|
+
* rejection marker). Must stay at least as permissive as the APPROVED
|
|
252
|
+
* matcher below, or a decorated REJECTED can fall through to it. */
|
|
253
|
+
function isRejectedLine(line) {
|
|
254
|
+
const stripped = line.trim().replace(/^[#>\s]+/, "").replace(/^[*_]+/, "");
|
|
255
|
+
return /^REJECTED(?![A-Za-z])/i.test(stripped);
|
|
256
|
+
}
|
|
257
|
+
|
|
218
258
|
/** Extract verdict lines from agent stdout. Returns the verdict text (first line
|
|
219
259
|
* must be APPROVED <sha> or REJECTED) or null if no valid verdict found.
|
|
220
260
|
* Also logs failures to stderr so the operator can inspect. */
|
|
221
261
|
function extractVerdictFromStdout(stdout, resultPath) {
|
|
222
262
|
const lines = stdout.split(/\r?\n/);
|
|
263
|
+
// Check for REJECTED first, over the WHOLE text, and return right away when
|
|
264
|
+
// found — do not also look for APPROVED in that case. This is the safe,
|
|
265
|
+
// fail-closed order (this project's own rule: fail closed, conservatively).
|
|
266
|
+
// A verbose agent wrapper can echo the required-format example back in its
|
|
267
|
+
// own output (e.g. a report that quotes the prompt's instructions), so the
|
|
268
|
+
// exact text "APPROVED <sha>" can show up even when the real verdict is
|
|
269
|
+
// REJECTED. If BOTH markers are present anywhere in the text, that must
|
|
270
|
+
// read as REJECTED, never as an approval that then gets a real signature.
|
|
271
|
+
const rejectedLine = lines.find((line) => isRejectedLine(line));
|
|
272
|
+
if (rejectedLine) {
|
|
273
|
+
process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
|
|
274
|
+
return rejectedLine.trim();
|
|
275
|
+
}
|
|
223
276
|
const approvedLine = lines.find((line) => /^APPROVED\s+\S+/.test(line.trim()));
|
|
224
277
|
if (approvedLine) {
|
|
225
278
|
const idx = lines.indexOf(approvedLine);
|
|
@@ -231,16 +284,25 @@ function extractVerdictFromStdout(stdout, resultPath) {
|
|
|
231
284
|
say(`reviewer verdict captured from stdout → ${resultPath}`);
|
|
232
285
|
return verdict;
|
|
233
286
|
}
|
|
234
|
-
const rejectedLine = lines.find((line) => /^REJECTED/i.test(line.trim()));
|
|
235
|
-
if (rejectedLine) {
|
|
236
|
-
process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
|
|
237
|
-
return rejectedLine.trim();
|
|
238
|
-
}
|
|
239
287
|
// Partial: print the last 20 lines to help the operator diagnose.
|
|
240
288
|
process.stderr.write(`reviewer stdout had no APPROVED/REJECTED line. Last 20 lines:\n${lines.slice(-20).join("\n").trim()}\n`);
|
|
241
289
|
return null;
|
|
242
290
|
}
|
|
243
291
|
|
|
292
|
+
// The reviewer must never be able to read the verdict signing key or other
|
|
293
|
+
// release secrets. The signing key is the ONLY thing that tells a real
|
|
294
|
+
// reviewer's verdict apart from one an agent fabricated by hand — if the
|
|
295
|
+
// reviewer process itself can read that key, it could sign its own made-up
|
|
296
|
+
// approval, which defeats the whole point of signing. So strip these from
|
|
297
|
+
// the env we hand to the reviewer child process, even though the operator's
|
|
298
|
+
// own shell (this script's own process.env) may have them set.
|
|
299
|
+
const REVIEWER_ENV_DENY = ["CW_RELEASE_VERDICT_PRIVKEY", "CW_AGENT_ATTEST_PRIVKEY", "CW_WORKBENCH_TOKEN"];
|
|
300
|
+
function buildReviewerEnv() {
|
|
301
|
+
const env = { ...process.env, CW_RELEASE_REVIEW: "1" };
|
|
302
|
+
for (const key of REVIEWER_ENV_DENY) delete env[key];
|
|
303
|
+
return env;
|
|
304
|
+
}
|
|
305
|
+
|
|
244
306
|
function delegateReview(resultPath, inputPath) {
|
|
245
307
|
// Reuse the canonical agent-config resolver (flags > env > file).
|
|
246
308
|
let resolveAgentConfig;
|
|
@@ -293,6 +355,13 @@ function delegateReview(resultPath, inputPath) {
|
|
|
293
355
|
// Capture stdout so agents that print verdicts (rather than writing the
|
|
294
356
|
// result file) are supported. Agents that DO write the file still work:
|
|
295
357
|
// the file takes precedence. stderr goes to the terminal for live output.
|
|
358
|
+
// A verdict file left over from an earlier run — or planted by anyone with
|
|
359
|
+
// write access to this checkout — must never survive into a fresh review
|
|
360
|
+
// round. Delete it (and any .sig sidecar) BEFORE the reviewer runs, so the
|
|
361
|
+
// "verdict file already exists" check right after spawnSync only ever sees
|
|
362
|
+
// a file the reviewer wrote THIS round, never a stale or planted one.
|
|
363
|
+
try { fs.unlinkSync(resultPath); } catch {}
|
|
364
|
+
try { fs.unlinkSync(`${resultPath}.sig`); } catch {}
|
|
296
365
|
const r = spawnSync(bin, args, {
|
|
297
366
|
cwd: repoRoot,
|
|
298
367
|
// CW_RELEASE_REVIEW=1 is a vendor-agnostic signal that THIS spawn is a
|
|
@@ -301,7 +370,9 @@ function delegateReview(resultPath, inputPath) {
|
|
|
301
370
|
// exec-capable sandbox — a read-only/low-effort reviewer can't execute the
|
|
302
371
|
// gate it judges and degrades to fabricated verdicts. Preflight liveness
|
|
303
372
|
// probes never set it, so they stay fast and read-only.
|
|
304
|
-
|
|
373
|
+
// buildReviewerEnv() also strips the verdict signing key and other release
|
|
374
|
+
// secrets from what the reviewer child process can see — see its comment.
|
|
375
|
+
env: buildReviewerEnv(),
|
|
305
376
|
encoding: "utf8",
|
|
306
377
|
timeout: cfg.timeoutMs || REVIEWER_TIMEOUT_MS,
|
|
307
378
|
shell: false,
|
|
@@ -362,7 +433,7 @@ function verifyVerdict(resultPath) {
|
|
|
362
433
|
const lines = text.split(/\r?\n/);
|
|
363
434
|
const firstLine = lines[0] || "";
|
|
364
435
|
if (firstLine !== `APPROVED ${HEAD}`) {
|
|
365
|
-
if (firstLine
|
|
436
|
+
if (!isRejectedLine(firstLine)) {
|
|
366
437
|
const normalized = extractVerdictFromStdout(text, resultPath);
|
|
367
438
|
if (normalized && normalized.split(/\r?\n/)[0] === `APPROVED ${HEAD}`) {
|
|
368
439
|
fs.writeFileSync(resultPath, `${normalized}\n`);
|
|
@@ -381,7 +452,11 @@ function resolveVerdictPrivateKey(value) {
|
|
|
381
452
|
const trimmed = String(value).trim();
|
|
382
453
|
if (trimmed.includes("BEGIN") && trimmed.includes("KEY")) return trimmed;
|
|
383
454
|
if (fs.existsSync(trimmed)) return fs.readFileSync(trimmed, "utf8");
|
|
384
|
-
|
|
455
|
+
// NEVER echo the value: if the operator set the var to inline key material
|
|
456
|
+
// that failed the BEGIN/KEY check (e.g. a raw base64 body), interpolating
|
|
457
|
+
// `trimmed` here would print the secret to stderr — which the oneclick flow
|
|
458
|
+
// tees into the operator's release log. Describe it instead.
|
|
459
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY (length ${trimmed.length}) is neither a readable key file path nor an inline PEM (must contain BEGIN and KEY).`);
|
|
385
460
|
}
|
|
386
461
|
|
|
387
462
|
/** Called only after verifyVerdict() has confirmed resultPath's bytes are a
|
|
@@ -444,10 +519,23 @@ function repoSlug() {
|
|
|
444
519
|
return m ? { owner: m[1], repo: m[2] } : null;
|
|
445
520
|
}
|
|
446
521
|
|
|
447
|
-
// The previous release tag relative to a SPECIFIC
|
|
448
|
-
// compare link in the notes.
|
|
522
|
+
// The previous release tag relative to a SPECIFIC target version (not HEAD's
|
|
523
|
+
// ancestry) — for the compare link in the notes. See listReleaseTagsDesc's
|
|
524
|
+
// header comment above for why this is a semver lookup, not `git describe`.
|
|
449
525
|
function prevTagOf(version) {
|
|
450
|
-
|
|
526
|
+
const target = parseSemverTag(`v${version}`);
|
|
527
|
+
if (!target) return "";
|
|
528
|
+
const found = listReleaseTagsDesc().find((t) => compareSemver(t.semver, target) < 0);
|
|
529
|
+
return found ? found.tag : "";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Full regex escape (same shape as parity-check.js's helper — scripts stay
|
|
533
|
+
// zero-dependency single files, so it is copied, not required). The version
|
|
534
|
+
// is SEMVER-validated on every path that reaches here, but escaping every
|
|
535
|
+
// metacharacter costs nothing and keeps the RegExp construction safe on its
|
|
536
|
+
// own terms.
|
|
537
|
+
function escapeRegExp(value) {
|
|
538
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
451
539
|
}
|
|
452
540
|
|
|
453
541
|
// Extract the `## <version>` section body from the CHANGELOG AS SHIPPED AT THE
|
|
@@ -456,7 +544,7 @@ function changelogSection(version) {
|
|
|
456
544
|
const show = git(["show", `v${version}:CHANGELOG.md`]);
|
|
457
545
|
if (show.code !== 0 || !show.out) return "";
|
|
458
546
|
const lines = show.out.split(/\r?\n/);
|
|
459
|
-
const esc = version
|
|
547
|
+
const esc = escapeRegExp(version);
|
|
460
548
|
const startRe = new RegExp(`^## \\[?${esc}\\b`);
|
|
461
549
|
let start = -1;
|
|
462
550
|
for (let i = 0; i < lines.length; i++) if (startRe.test(lines[i])) { start = i; break; }
|
|
@@ -558,10 +646,120 @@ function releaseGitHub(version, { required = false } = {}) {
|
|
|
558
646
|
return true;
|
|
559
647
|
}
|
|
560
648
|
|
|
649
|
+
// ---- 2b. cut preflight (fail fast, BEFORE any paid step) --------------------
|
|
650
|
+
// Every check here answers one question: "will this cut die AFTER the gate,
|
|
651
|
+
// the live vendor preflight, and the reviewer have already run?" Each check
|
|
652
|
+
// was a real post-reviewer failure in the v0.2.3 cut (2026-07-10) — a missing
|
|
653
|
+
// --version, an unsigned verdict the CI gate then rejected, a left-over local
|
|
654
|
+
// tag, and a two-hop recovery that broke the HEAD~1 topology rule. All of
|
|
655
|
+
// them are answerable in under a second, so answer them FIRST.
|
|
656
|
+
//
|
|
657
|
+
// Fixture safety: each check is gated on the thing it checks being present
|
|
658
|
+
// (a committed signing pubkey, a CHANGELOG.md, an `origin` remote), so the
|
|
659
|
+
// smoke fixtures — bare repos with none of those — pass through untouched.
|
|
660
|
+
function preflightCut() {
|
|
661
|
+
say("[0/3] cut preflight");
|
|
662
|
+
// (a) version — cheap and load-bearing for every later step. Checking it
|
|
663
|
+
// here (not only inside cut()) is what stops a full gate+reviewer run from
|
|
664
|
+
// being thrown away over a missing flag.
|
|
665
|
+
if (!cutVersion || !/^\d+\.\d+\.\d+$/.test(cutVersion)) die("--cut requires --version x.y.z");
|
|
666
|
+
|
|
667
|
+
// (b) signing — once .cw-release/verdict-signing.pub is committed, CI's
|
|
668
|
+
// release-gate REQUIRES a valid signature; a cut without the private key
|
|
669
|
+
// configured would push an unsigned verdict that fails only in CI. Fail
|
|
670
|
+
// closed here instead, and also prove the configured key IS the committed
|
|
671
|
+
// key (a wrong key signs "successfully" and still fails in CI).
|
|
672
|
+
const pubPath = path.join(repoRoot, ".cw-release", "verdict-signing.pub");
|
|
673
|
+
if (fs.existsSync(pubPath)) {
|
|
674
|
+
const raw = process.env.CW_RELEASE_VERDICT_PRIVKEY;
|
|
675
|
+
if (!raw || !raw.trim()) {
|
|
676
|
+
die(
|
|
677
|
+
"verdict-signing.pub is committed, so CI requires a SIGNED verdict — set the private key first",
|
|
678
|
+
'fix: export CW_RELEASE_VERDICT_PRIVKEY=~/.cw-keys/verdict-signing.key'
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const pem = resolveVerdictPrivateKey(raw);
|
|
682
|
+
let key;
|
|
683
|
+
try {
|
|
684
|
+
key = crypto.createPrivateKey(pem);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY is not a readable private key: ${error.message}`);
|
|
687
|
+
}
|
|
688
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
689
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY is a ${key.asymmetricKeyType || "unknown"} key, not ed25519 — generate one with scripts/verdict-keygen.js`);
|
|
690
|
+
}
|
|
691
|
+
const derivedPub = crypto.createPublicKey(key).export({ type: "spki", format: "pem" }).toString().trim();
|
|
692
|
+
const committedPub = fs.readFileSync(pubPath, "utf8").trim();
|
|
693
|
+
if (derivedPub !== committedPub) {
|
|
694
|
+
die("CW_RELEASE_VERDICT_PRIVKEY does not match the committed .cw-release/verdict-signing.pub — wrong key; CI would reject its signature");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// (c) tag must not exist yet — a stale local tag makes cut() die at the very
|
|
699
|
+
// last step ("tag 'vX.Y.Z' already exists") after everything else has run.
|
|
700
|
+
if (git(["tag", "-l", `v${cutVersion}`]).out) {
|
|
701
|
+
die(`tag v${cutVersion} already exists locally — delete it first (git tag -d v${cutVersion}) or pick the next version`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// (d) CHANGELOG must already carry the release section — release notes get
|
|
705
|
+
// pasted verbatim into the GitHub Release, and bump:version's content gate
|
|
706
|
+
// would only catch this inside cut(), after the reviewer.
|
|
707
|
+
const changelogPath = path.join(repoRoot, "CHANGELOG.md");
|
|
708
|
+
if (fs.existsSync(changelogPath) && !fs.readFileSync(changelogPath, "utf8").includes(`## ${cutVersion}`)) {
|
|
709
|
+
die(`CHANGELOG.md has no "## ${cutVersion}" section — write the release notes first`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// (e) clean tree (tracked files only — cut()'s `git add -u` can never sweep
|
|
713
|
+
// an untracked stray in, so untracked files are not a cut hazard).
|
|
714
|
+
const dirty = git(["status", "--porcelain", "-uno"]).out;
|
|
715
|
+
if (dirty) die("working tree has tracked modifications — commit or stash them before a cut", dirty);
|
|
716
|
+
|
|
717
|
+
// (f) network-dependent checks, only when this cut will actually push and
|
|
718
|
+
// only when an `origin` remote exists (smoke fixtures have none):
|
|
719
|
+
if (PUSH && git(["remote"]).out.split("\n").includes("origin")) {
|
|
720
|
+
// remote tag must not exist (someone/some run already published this version)
|
|
721
|
+
const remoteTag = git(["ls-remote", "origin", `refs/tags/v${cutVersion}`]);
|
|
722
|
+
// Fail CLOSED on a failed ls-remote: a non-zero exit (network blip, auth
|
|
723
|
+
// error) is NOT evidence the tag is absent. Treating it as "no remote tag"
|
|
724
|
+
// (the old `code === 0 && out` shape) let a transient failure skip the
|
|
725
|
+
// already-published guard and cut a version that may already exist.
|
|
726
|
+
if (remoteTag.code !== 0) {
|
|
727
|
+
die(`could not check origin for tag v${cutVersion} (git ls-remote failed) — refusing to cut blind`, remoteTag.err);
|
|
728
|
+
}
|
|
729
|
+
if (remoteTag.out) {
|
|
730
|
+
die(`tag v${cutVersion} already exists on origin — this version is already published; pick the next version`);
|
|
731
|
+
}
|
|
732
|
+
// HEAD must be the current origin/main tip — cutting a stale HEAD publishes
|
|
733
|
+
// old code (nearly happened on the v0.2.2 cut).
|
|
734
|
+
if (git(["fetch", "origin", "main", "--quiet"]).code !== 0) {
|
|
735
|
+
die("git fetch origin main failed — a --push cut needs the network anyway; fix connectivity first");
|
|
736
|
+
}
|
|
737
|
+
const mainTip = git(["rev-parse", "origin/main"]).out;
|
|
738
|
+
if (mainTip !== HEAD && !has("--allow-stale-head")) {
|
|
739
|
+
die(
|
|
740
|
+
`HEAD (${HEAD.slice(0, 12)}) is not the origin/main tip (${mainTip.slice(0, 12)}) — a cut must release exactly what main has`,
|
|
741
|
+
"fix: git fetch origin main && git checkout -B <branch> origin/main (or pass --allow-stale-head if this is deliberate)"
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
say("cut preflight OK");
|
|
746
|
+
}
|
|
747
|
+
|
|
561
748
|
// ---- 3. optional cut (bump + commit verdict + tag + push) ------------------
|
|
562
749
|
function cut(resultPath, capability) {
|
|
563
750
|
if (!cutVersion || !/^\d+\.\d+\.\d+$/.test(cutVersion)) die("--cut requires --version x.y.z");
|
|
564
|
-
|
|
751
|
+
// HEAD is captured once at process start (const HEAD) and everything the cut
|
|
752
|
+
// signs/tags is bound to it. The gate + vendor preflight + the ~30-minute
|
|
753
|
+
// reviewer all run in the shared checkout in between, and this repo runs
|
|
754
|
+
// concurrent agent sessions — so re-verify HEAD has not moved before we
|
|
755
|
+
// commit the verdict onto (and tag) it. Tagging an interloper commit would
|
|
756
|
+
// publish an immutable vX.Y.Z that CI then rejects (verdict filename != HEAD),
|
|
757
|
+
// burning the version number. Fail closed and let the operator re-run.
|
|
758
|
+
const headNow = git(["rev-parse", "HEAD"]).out;
|
|
759
|
+
if (headNow !== HEAD) {
|
|
760
|
+
die(`HEAD moved during the release flow (expected ${HEAD.slice(0, 12)}, now ${headNow.slice(0, 12)}) — re-run to restart the flow from the current HEAD`);
|
|
761
|
+
}
|
|
762
|
+
if (DRY_RUN) { say(`[dry-run] would: bump:version ${cutVersion}, commit verdict, tag v${cutVersion}${PUSH ? `, push refs/tags/v${cutVersion} (tag only, no branch)` : ""}`); return; }
|
|
565
763
|
const bump = spawnSync("npm", ["run", "bump:version", "--", cutVersion], { cwd: pluginRoot, encoding: "utf8", stdio: "inherit" });
|
|
566
764
|
if (bump.status !== 0) die("bump:version failed");
|
|
567
765
|
// Regenerate the gated project index after the version bump (PR #87 gate).
|
|
@@ -575,21 +773,34 @@ function cut(resultPath, capability) {
|
|
|
575
773
|
// (that tripped pii-redaction-smoke and red-failed release-gate for v0.1.96).
|
|
576
774
|
// `git add -u` touches tracked files only, so no untracked file can be swept in;
|
|
577
775
|
// the verdict (and its .sig sidecar, when verdict signing is configured) are
|
|
578
|
-
// the only new paths the cut is allowed to add.
|
|
579
|
-
git(
|
|
580
|
-
|
|
776
|
+
// the only new paths the cut is allowed to add. Check every add's exit code:
|
|
777
|
+
// a swallowed git-add failure (a read-only object store, a locked index) used
|
|
778
|
+
// to surface only later as a confusing "verdict commit failed" — a cut that
|
|
779
|
+
// fails to stage the SIGNED verdict must die right here, before the commit.
|
|
780
|
+
const addTracked = git(["add", "-u"]);
|
|
781
|
+
if (addTracked.code !== 0) die("git add -u failed while staging the cut", addTracked.err);
|
|
782
|
+
const addVerdict = git(["add", "--", path.relative(repoRoot, resultPath)]);
|
|
783
|
+
if (addVerdict.code !== 0) die("git add of the verdict file failed", addVerdict.err);
|
|
581
784
|
const sigPath = `${resultPath}.sig`;
|
|
582
|
-
if (fs.existsSync(sigPath))
|
|
785
|
+
if (fs.existsSync(sigPath)) {
|
|
786
|
+
const addSig = git(["add", "--", path.relative(repoRoot, sigPath)]);
|
|
787
|
+
if (addSig.code !== 0) die("git add of the verdict signature failed", addSig.err);
|
|
788
|
+
}
|
|
583
789
|
const commit = git(["commit", "-m", `chore(release): record APPROVED reviewer verdict for v${cutVersion}`]);
|
|
584
790
|
if (commit.code !== 0) die("verdict commit failed", commit.err);
|
|
585
791
|
const tag = git(["tag", "-a", `v${cutVersion}`, "-m", `v${cutVersion}: ${capability || "release"}`]);
|
|
586
792
|
if (tag.code !== 0) die("git tag failed", tag.err);
|
|
587
793
|
if (PUSH) {
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
794
|
+
// Push ONLY the tag. The verdict commit is a one-hop leaf on the reviewed
|
|
795
|
+
// main-tip commit, reachable through the tag alone — it does not need to
|
|
796
|
+
// be on any branch. Pushing HEAD here used to (a) hit main's branch
|
|
797
|
+
// protection (enforce_admins blocks even the owner's direct push) or
|
|
798
|
+
// (b) mint a stray remote branch named after whatever branch the cut ran
|
|
799
|
+
// from. Tags are not protected in this repo, and CI's release-gate checks
|
|
800
|
+
// the TAG's own history (verdict at HEAD or HEAD~1), so the tag alone is
|
|
801
|
+
// the complete release artifact — proven live by the v0.2.3 cut.
|
|
802
|
+
const push = git(["push", "origin", `refs/tags/v${cutVersion}`]);
|
|
803
|
+
if (push.code !== 0) die("tag push failed (nothing was pushed)", push.err);
|
|
593
804
|
}
|
|
594
805
|
say(`tagged v${cutVersion}${PUSH ? " and pushed" : " (local only; push when ready)"}`);
|
|
595
806
|
// Finishing step: create the GitHub Release for the just-pushed tag. Only when
|
|
@@ -614,6 +825,18 @@ function main() {
|
|
|
614
825
|
return;
|
|
615
826
|
}
|
|
616
827
|
|
|
828
|
+
// Fail-fast checks FIRST — nothing below (gate, live vendor calls, the
|
|
829
|
+
// reviewer) runs until every cheap precondition holds. --preflight-only
|
|
830
|
+
// lets an orchestrator (release-oneclick.js) run just this step. Without
|
|
831
|
+
// --cut there is no preflight to run, so answering ok:true would be a
|
|
832
|
+
// green light with zero checks done — refuse instead.
|
|
833
|
+
if (has("--preflight-only") && !MODE_CUT) die("--preflight-only requires --cut (there is no preflight to run in check/release mode)");
|
|
834
|
+
if (MODE_CUT) preflightCut();
|
|
835
|
+
if (has("--preflight-only")) {
|
|
836
|
+
process.stdout.write(`${JSON.stringify({ ok: true, mode: "preflight", version: cutVersion || null }, null, 2)}\n`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
617
840
|
const markerDir = path.join(repoRoot, ".cw-release");
|
|
618
841
|
fs.mkdirSync(markerDir, { recursive: true });
|
|
619
842
|
const resultPath = path.join(markerDir, `review-${HEAD}.verdict`);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// release-gate.js — deterministic release checks for cool-workflow.
|
|
4
|
+
// Pass = writes .cw-release/gate-<HEAD-sha>.ok
|
|
5
|
+
// This script encodes everything that does NOT need LLM judgment.
|
|
6
|
+
// (Node port of the former release-gate.sh; behavior kept line for line —
|
|
7
|
+
// same six steps, same messages, same exit map: 0 = PASSED, 1 = REJECTED.)
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
const { spawnSync } = require("node:child_process");
|
|
12
|
+
const { resolvePrevReleaseTag } = require("./release-tags.js");
|
|
13
|
+
|
|
14
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
15
|
+
if (top.status !== 0) {
|
|
16
|
+
process.stderr.write("release-gate: not inside a git work tree\n");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const REPO_ROOT = top.stdout.trim();
|
|
20
|
+
|
|
21
|
+
function gitOut(args) {
|
|
22
|
+
const r = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
|
|
23
|
+
return r.status === 0 ? (r.stdout || "").trim() : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SHA = gitOut(["rev-parse", "HEAD"]);
|
|
27
|
+
let FAIL = 0;
|
|
28
|
+
function say(msg) {
|
|
29
|
+
process.stdout.write(`${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
function fail(msg) {
|
|
32
|
+
say(`GATE FAIL: ${msg}`);
|
|
33
|
+
FAIL = 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve the PREVIOUS release tag by SEMVER ORDER, not ancestry — shared
|
|
37
|
+
// with release-flow.js via release-tags.js (see that file's header for the
|
|
38
|
+
// full story: `git describe --tags` walks ancestry and silently SKIPS the
|
|
39
|
+
// true previous release tag under the tag-only-push design, making the
|
|
40
|
+
// substance/test-evidence/cadence checks below compare against the wrong
|
|
41
|
+
// baseline and pass permanently; ITERATION_LOG cycles 35/36).
|
|
42
|
+
const PREV_TAG = resolvePrevReleaseTag(gitOut);
|
|
43
|
+
|
|
44
|
+
// An empty PREV_TAG is ambiguous: (a) the genuine first release, so skipping
|
|
45
|
+
// substance/evidence/cadence is right, or (b) tags DO exist but this clone can
|
|
46
|
+
// not see them — a shallow clone hides them in history, and a `git clone
|
|
47
|
+
// --no-tags` / `fetch-tags: false` clone of a long-tagged repo shows 0 local
|
|
48
|
+
// tags too. (a) and (b) look IDENTICAL from local state (both give an empty
|
|
49
|
+
// describe and 0 tags), so we can NOT auto-tell them apart. Fail CLOSED: skip
|
|
50
|
+
// the checks ONLY when the operator positively declares a first release with
|
|
51
|
+
// CW_FIRST_RELEASE=1 (explicit + logged, the same shape the cadence HOTFIX
|
|
52
|
+
// override uses). Otherwise REJECT — a mis-fetched clone must never look like a
|
|
53
|
+
// first release and silently pass. Found by a 2026-07-12 security check; the
|
|
54
|
+
// shallow signal alone closed only half the hole (the `--no-tags` full clone
|
|
55
|
+
// still slipped through).
|
|
56
|
+
if (!PREV_TAG) {
|
|
57
|
+
const shallow = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
58
|
+
const IS_SHALLOW = shallow.status === 0 ? shallow.stdout.trim() : "false";
|
|
59
|
+
if (IS_SHALLOW === "true") {
|
|
60
|
+
fail("cannot resolve the previous release tag: this is a shallow git clone (git rev-parse --is-shallow-repository = true), so an older tag may be hidden and substance/test-evidence/cadence cannot be trusted. Fetch full history (actions/checkout with fetch-depth: 0) before running this gate.");
|
|
61
|
+
} else if (process.env.CW_FIRST_RELEASE === "1") {
|
|
62
|
+
say("no previous tag; genuine first release declared (CW_FIRST_RELEASE=1) — substance/evidence/cadence will be skipped");
|
|
63
|
+
} else {
|
|
64
|
+
fail("cannot resolve the previous release tag on a full (non-shallow) clone. Either tags were not fetched (git clone --no-tags, or actions/checkout fetch-tags: false — fetch tags and re-run), or this is the genuine first release (set CW_FIRST_RELEASE=1 to declare that explicitly). Refusing to silently skip substance/test-evidence/cadence.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const MARKER_DIR = path.join(REPO_ROOT, ".cw-release");
|
|
68
|
+
fs.mkdirSync(MARKER_DIR, { recursive: true });
|
|
69
|
+
|
|
70
|
+
function runNpm(args, extraEnv) {
|
|
71
|
+
// stdio ignored on purpose (the old script sent both streams to /dev/null):
|
|
72
|
+
// the gate's verdict is the exit code; a red step is re-run by hand for detail.
|
|
73
|
+
const r = spawnSync("npm", args, {
|
|
74
|
+
cwd: REPO_ROOT,
|
|
75
|
+
stdio: "ignore",
|
|
76
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
77
|
+
});
|
|
78
|
+
return !r.error && r.status === 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- 1. Build & tests (run, don't trust pasted output) -----------------
|
|
82
|
+
say("[1/6] build");
|
|
83
|
+
if (!runNpm(["run", "--prefix", "plugins/cool-workflow", "build"])) fail("build failed");
|
|
84
|
+
|
|
85
|
+
say("[2/6] tests");
|
|
86
|
+
if (!runNpm(["run", "test:gate", "--prefix", "plugins/cool-workflow"], { CW_TEST_CONCURRENCY: "1" })) fail("tests failed");
|
|
87
|
+
|
|
88
|
+
if (PREV_TAG) {
|
|
89
|
+
const RANGE = `${PREV_TAG}..HEAD`;
|
|
90
|
+
const changedFiles = gitOut(["diff", "--name-only", RANGE]).split("\n").filter(Boolean);
|
|
91
|
+
|
|
92
|
+
// --- 2. Substance: changes must exist outside src/types/ and dist/ ---
|
|
93
|
+
// The spec (AGENTS.md / reviewer-agent.md Gate 1) is "at least one changed
|
|
94
|
+
// file outside src/types/ and dist/" — ANY such file (src, scripts, docs,
|
|
95
|
+
// workflows, tests). Count every changed path that is not under those two
|
|
96
|
+
// generated/declaration-only trees; declared-but-unread spec accretion is the
|
|
97
|
+
// reviewer agent's deeper judgment call, not this deterministic floor.
|
|
98
|
+
say("[3/6] substance (diff outside src/types/ and dist/)");
|
|
99
|
+
const SUBSTANCE = changedFiles.filter((f) => !/^plugins\/cool-workflow\/(src\/types\/|dist\/)/.test(f)).length;
|
|
100
|
+
if (!(SUBSTANCE > 0)) fail(`only types/dist changed since ${PREV_TAG} (spec accretion)`);
|
|
101
|
+
|
|
102
|
+
// --- 3. Test evidence: test files must have changed ------------------
|
|
103
|
+
say("[4/6] test evidence");
|
|
104
|
+
const TESTS_CHANGED = changedFiles.filter((f) => /\.(test|spec)\.|\/tests?\//.test(f)).length;
|
|
105
|
+
if (!(TESTS_CHANGED > 0)) fail(`zero test changes since ${PREV_TAG}`);
|
|
106
|
+
|
|
107
|
+
// --- 4. Cadence: >=4 cycles logged OR >=24h since previous tag, or a recorded HOTFIX ---
|
|
108
|
+
say("[5/6] cadence");
|
|
109
|
+
let CYCLES = 0;
|
|
110
|
+
const logDiff = fs.existsSync(path.join(REPO_ROOT, "ITERATION_LOG.md"))
|
|
111
|
+
? gitOut(["diff", RANGE, "--", "ITERATION_LOG.md"]).split("\n")
|
|
112
|
+
: [];
|
|
113
|
+
CYCLES = logDiff.filter((line) => /^\+.*\|/.test(line)).length;
|
|
114
|
+
const PREV_TS = Number(gitOut(["log", "-1", "--format=%ct", PREV_TAG]));
|
|
115
|
+
const NOW_TS = Math.floor(Date.now() / 1000);
|
|
116
|
+
const HOURS = Math.floor((NOW_TS - PREV_TS) / 3600);
|
|
117
|
+
// Hotfix path: an urgent fix may ship inside the cadence window, but ONLY via an
|
|
118
|
+
// EXPLICIT, RECORDED declaration — a "HOTFIX:" line added to ITERATION_LOG.md in this
|
|
119
|
+
// release range, carrying a reason. It is committed (auditable in the tag's history)
|
|
120
|
+
// and echoed here, so the bypass is never silent and a reviewer sees the reason.
|
|
121
|
+
const hotfixLine = logDiff.find((line) => /^\+.*HOTFIX:/.test(line));
|
|
122
|
+
const HOTFIX = hotfixLine ? hotfixLine.replace(/^\+\s*/, "") : "";
|
|
123
|
+
if (CYCLES < 4 && HOURS < 24) {
|
|
124
|
+
if (HOTFIX) {
|
|
125
|
+
say(` cadence bypassed by recorded HOTFIX (${HOURS}h, ${CYCLES} cycle-lines): ${HOTFIX}`);
|
|
126
|
+
} else {
|
|
127
|
+
fail(`cadence: only ${CYCLES} cycles logged and ${HOURS}h since ${PREV_TAG} (need >=4 cycles, >=24h, or a recorded 'HOTFIX:' line in ITERATION_LOG.md)`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
say("[3-5/6] no previous tag; substance/evidence/cadence checks skipped");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- 5. Branch naming: forbid version-number branches -------------------
|
|
135
|
+
say("[6/6] branch naming");
|
|
136
|
+
// On a normal checkout `git rev-parse --abbrev-ref HEAD` is the branch name. On
|
|
137
|
+
// a DETACHED HEAD it prints the literal string "HEAD" — and the tag-push CI
|
|
138
|
+
// (release-gate.yml) ALWAYS checks out the tag, so HEAD is detached there. A
|
|
139
|
+
// literal "HEAD" can never match the version-branch regex below, so this check
|
|
140
|
+
// would silently pass exactly where it is meant to be the backstop. Handle the
|
|
141
|
+
// detached case explicitly: gather the real candidate ref name(s) — the
|
|
142
|
+
// CI-provided source branch (GITHUB_HEAD_REF for a PR, else GITHUB_REF_NAME),
|
|
143
|
+
// plus every local/remote branch whose tip contains this commit — and judge
|
|
144
|
+
// each one. A truly detached checkout with no resolvable branch (a bare
|
|
145
|
+
// `git checkout <sha>`) has no branch to name, so there is nothing to forbid;
|
|
146
|
+
// that is now an explicit, understood pass, not an accidental regex miss.
|
|
147
|
+
const BRANCH = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
|
|
148
|
+
let candidateBranches = [BRANCH];
|
|
149
|
+
if (BRANCH === "HEAD") {
|
|
150
|
+
candidateBranches = [];
|
|
151
|
+
// The CI source branch: a PR run exposes it as GITHUB_HEAD_REF; a plain push
|
|
152
|
+
// exposes the pushed ref as GITHUB_REF_NAME (a tag on a tag push, which simply
|
|
153
|
+
// will not match the feat/ regex — harmless to include).
|
|
154
|
+
for (const envRef of [process.env.GITHUB_HEAD_REF || "", process.env.GITHUB_REF_NAME || ""]) {
|
|
155
|
+
if (envRef) candidateBranches.push(envRef);
|
|
156
|
+
}
|
|
157
|
+
// Every local branch whose tip contains this commit, plus every remote-tracking
|
|
158
|
+
// branch (with its "<remote>/" prefix stripped so the regex still anchors on
|
|
159
|
+
// "feat/"). --format avoids the "* " current-branch marker git branch prints.
|
|
160
|
+
const localC = gitOut(["branch", "--contains", "HEAD", "--format=%(refname:short)"]);
|
|
161
|
+
const remoteC = gitOut(["branch", "-r", "--contains", "HEAD", "--format=%(refname:short)"])
|
|
162
|
+
.split("\n")
|
|
163
|
+
.map((b) => b.replace(/^[^/]+\//, ""))
|
|
164
|
+
.join("\n");
|
|
165
|
+
for (const chunk of [localC, remoteC]) {
|
|
166
|
+
if (chunk) candidateBranches.push(...chunk.split("\n"));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const B of candidateBranches) {
|
|
170
|
+
if (!B || B === "HEAD") continue;
|
|
171
|
+
if (/^feat\/(batch-)?v?[0-9]+/.test(B)) {
|
|
172
|
+
fail(`branch '${B}' is version-number-driven; name the capability instead`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Verdict ------------------------------------------------------------
|
|
177
|
+
if (FAIL !== 0) {
|
|
178
|
+
fs.rmSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), { force: true });
|
|
179
|
+
say(`RELEASE GATE: REJECTED (${SHA})`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fs.writeFileSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), `${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}\n`);
|
|
184
|
+
say(`RELEASE GATE: PASSED (${SHA}) — next step: release-reviewer agent must record APPROVED`);
|