release-skill 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +9 -8
- package/README.zh-CN.md +9 -8
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +705 -356
- package/adapters/claude/schemas/release-project.schema.json +8 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +705 -356
- package/adapters/codex/schemas/release-project.schema.json +8 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +705 -356
- package/adapters/kimi/schemas/release-project.schema.json +8 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +705 -356
- package/adapters/workbuddy/schemas/release-project.schema.json +8 -0
- package/bin/release-skill-cli.mjs +11 -0
- package/bin/release-skill.bundle.mjs +705 -356
- package/package.json +1 -1
- package/references/05-evidence-and-errors.md +1 -0
- package/schemas/release-project.schema.json +8 -0
- package/scripts/build-bundle.mjs +11 -2
- package/scripts/sync-public-files.mjs +4 -0
- package/src/commands/prepare.mjs +214 -8
- package/src/commands/verify.mjs +22 -0
- package/src/core/bundle-freshness.mjs +236 -0
- package/src/core/errors.mjs +2 -0
- package/src/core/frozen-marker.mjs +97 -0
- package/src/core/hooks.mjs +12 -1
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
| `NOT_DEFAULT` | 配置分支不是远端实际默认分支 | 默认分支在 prepare 后改变,或配置错误 | 人工确认远端默认分支,更新配置并重新 prepare |
|
|
33
33
|
| `CONTENT_MISMATCH` | 远端默认分支缺少冻结源码内容 | README、版本源、公开映射输入的内容或 mode 不一致 | 人工 merge/adopt/reject;接受的内容进入默认分支后重试 publish |
|
|
34
34
|
| `DIRTY_SOURCE_INPUT` | 源码输入闭包存在未提交变化 | `publicFiles.from` 或 `version.source` 有 staged、unstaged、untracked 变化 | 提交或撤销这些具体输入的变化后重新 prepare;无关 dirty 不受影响 |
|
|
35
|
+
| `BUNDLE_STALE` | bundle 与其源码输入失步(fail-closed 前置门禁) | `bin/release-skill.bundle.mjs` 内嵌的源码摘要与当前 `src/` 摘要不一致,或 bundle/内嵌摘要缺失;在 prepare 加载 config 后的早期阶段检测,任何 workflow 均不可豁免 | 按错误提示在 release-skill 包根运行 `node scripts/build-bundle.mjs`(或 `pnpm build`)重建 bundle 后重新 prepare |
|
|
35
36
|
|
|
36
37
|
**动作状态码** (adapter execute/observe/verify):
|
|
37
38
|
|
|
@@ -873,6 +873,14 @@
|
|
|
873
873
|
},
|
|
874
874
|
"uniqueItems": true,
|
|
875
875
|
"description": "Root-relative globs declaring the hook's full input set (source, scripts, config, lockfiles). The cache key fingerprints the content of every matched file; a glob that matches nothing fails closed. Must cover ALL inputs or the cache can return a false hit."
|
|
876
|
+
},
|
|
877
|
+
"testSelection": {
|
|
878
|
+
"type": "string",
|
|
879
|
+
"enum": [
|
|
880
|
+
"full",
|
|
881
|
+
"incremental"
|
|
882
|
+
],
|
|
883
|
+
"description": "Only meaningful for the test hook: which test selection the hook command runs. Absence or 'full' means the hook runs the full suite, which prepare's built-in full-test freeze gate requires before the plan digest is computed. 'incremental' is reserved for a future preflight mode and is rejected at freeze time ('incremental selection is not allowed at freeze time'). Built-in gate; cannot be disabled by project overlays."
|
|
876
884
|
}
|
|
877
885
|
}
|
|
878
886
|
},
|
package/scripts/build-bundle.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
|
+
import { computeBundleSourceDigest } from '../src/core/bundle-freshness.mjs';
|
|
24
25
|
|
|
25
26
|
const CHECK_MODE = process.argv.includes('--check');
|
|
26
27
|
|
|
@@ -38,7 +39,12 @@ const OUTFILE = join(PKG_ROOT, 'bin', 'release-skill.bundle.mjs');
|
|
|
38
39
|
// dependency: the Claude and Codex adapter closures ship the bundle at a
|
|
39
40
|
// different depth with no package.json next to it, while the npm closure does.
|
|
40
41
|
// Reading package.json here (build input) keeps the output deterministic.
|
|
41
|
-
|
|
42
|
+
//
|
|
43
|
+
// __bundleSourceDigest is the build-time digest of the bundle's source inputs
|
|
44
|
+
// (src/, skills-src/, bin/release-skill-cli.mjs, package.json — algorithm in
|
|
45
|
+
// src/core/bundle-freshness.mjs). prepare's BUNDLE_STALE gate recomputes the
|
|
46
|
+
// digest and fails closed on any mismatch (2026-08-18 investigation §4.2).
|
|
47
|
+
function buildBanner(pkgIdentity, sourceDigest) {
|
|
42
48
|
return `\
|
|
43
49
|
// --- release-skill bundle (deterministic build) ---
|
|
44
50
|
// Compute package root from the bundle's own file location (import.meta.url).
|
|
@@ -51,6 +57,8 @@ const __bundlePkgRoot = __bundleResolve(__bundleDirname(__bundleFileURLToPath(im
|
|
|
51
57
|
const __bundleRealRequire = __bundleCreateRequire(import.meta.url);
|
|
52
58
|
// Package identity injected at build time — closure-independent --version probe.
|
|
53
59
|
const __bundlePkg = Object.freeze(${JSON.stringify(pkgIdentity)});
|
|
60
|
+
// Build-time source digest for the BUNDLE_STALE freshness gate (see above).
|
|
61
|
+
const __bundleSourceDigest = "${sourceDigest}";
|
|
54
62
|
`;
|
|
55
63
|
}
|
|
56
64
|
|
|
@@ -164,7 +172,8 @@ async function buildBundle() {
|
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
const pkgJson = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf-8'));
|
|
167
|
-
const
|
|
175
|
+
const sourceDigest = await computeBundleSourceDigest(PKG_ROOT);
|
|
176
|
+
const banner = buildBanner({ name: pkgJson.name, version: pkgJson.version }, sourceDigest);
|
|
168
177
|
|
|
169
178
|
const identity = Object.freeze({ name: pkgJson.name, version: pkgJson.version });
|
|
170
179
|
|
|
@@ -232,6 +232,9 @@ async function collectEntries(packageRoot) {
|
|
|
232
232
|
// a distribution artifact; the shipped bundle
|
|
233
233
|
// inlines the adopted capability — matches the npm
|
|
234
234
|
// pack closure contract)
|
|
235
|
+
// - .release-skill/ (local release runtime state — runs, plans, locks;
|
|
236
|
+
// not in the package.json files whitelist, so it is
|
|
237
|
+
// never part of the npm pack closure)
|
|
235
238
|
const baseFiles = allFiles.filter((f) => {
|
|
236
239
|
if (f.startsWith('adapters/')) return false;
|
|
237
240
|
if (f.startsWith('test/')) return false;
|
|
@@ -240,6 +243,7 @@ async function collectEntries(packageRoot) {
|
|
|
240
243
|
if (f.startsWith('native/') && f.includes('/build/')) return false;
|
|
241
244
|
if (f.startsWith('scripts/')) return false;
|
|
242
245
|
if (f.startsWith('vendor/')) return false;
|
|
246
|
+
if (f.startsWith('.release-skill/')) return false;
|
|
243
247
|
return true;
|
|
244
248
|
});
|
|
245
249
|
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* @module commands/prepare
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
|
|
21
|
+
import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
|
|
22
22
|
import { readFile, mkdir, readdir, realpath } from 'node:fs/promises';
|
|
23
23
|
import { execFile as execFileCb } from 'node:child_process';
|
|
24
24
|
import { promisify } from 'node:util';
|
|
@@ -53,7 +53,10 @@ import {
|
|
|
53
53
|
normalizeGitTimestamp,
|
|
54
54
|
sealFrozenSnapshot,
|
|
55
55
|
} from '../snapshot/frozen.mjs';
|
|
56
|
-
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT } from '../core/errors.mjs';
|
|
56
|
+
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
|
|
57
|
+
import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
|
|
58
|
+
import { PKG_ROOT } from '../core/pkg-root.mjs';
|
|
59
|
+
import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
|
|
57
60
|
import {
|
|
58
61
|
SOURCE_INPUT_ALGORITHM_VERSION,
|
|
59
62
|
computeSourceInputClosure,
|
|
@@ -224,6 +227,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
224
227
|
// Hooks execution
|
|
225
228
|
// ---------------------------------------------------------------------------
|
|
226
229
|
|
|
230
|
+
/** Maximum number of output lines preserved in a hook-failure tail. */
|
|
231
|
+
const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
|
|
232
|
+
/** Maximum bytes preserved in a hook-failure tail. */
|
|
233
|
+
const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Bound a captured child-output stream to the tail that matters for triage:
|
|
237
|
+
* the last 50 lines, further capped at 8 KB — whichever is smaller.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} [text] - Captured stdout/stderr text.
|
|
240
|
+
* @returns {string} The bounded tail ('' for empty/absent input).
|
|
241
|
+
*/
|
|
242
|
+
export function boundedOutputTail(text) {
|
|
243
|
+
if (typeof text !== 'string' || text.length === 0) return '';
|
|
244
|
+
let lines = text.split('\n');
|
|
245
|
+
// A trailing newline produces an empty final element; drop it so the line
|
|
246
|
+
// budget counts real output lines.
|
|
247
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
248
|
+
lines = lines.slice(0, -1);
|
|
249
|
+
}
|
|
250
|
+
let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
|
|
251
|
+
let joined = tail.join('\n');
|
|
252
|
+
while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
253
|
+
tail = tail.slice(1);
|
|
254
|
+
joined = tail.join('\n');
|
|
255
|
+
}
|
|
256
|
+
if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
257
|
+
// A single line exceeds the byte cap: keep the trailing bytes.
|
|
258
|
+
const buf = Buffer.from(joined, 'utf8');
|
|
259
|
+
joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
|
|
260
|
+
}
|
|
261
|
+
return joined;
|
|
262
|
+
}
|
|
263
|
+
|
|
227
264
|
/**
|
|
228
265
|
* Run all declared project hooks in order: docs, build, test, typecheck.
|
|
229
266
|
*
|
|
@@ -236,6 +273,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
236
273
|
* Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
|
|
237
274
|
* glob that matches nothing fails closed before the hook runs.
|
|
238
275
|
*
|
|
276
|
+
* Failure output passthrough (2026-08-18 investigation §4.1): the executor
|
|
277
|
+
* already captures child stdout/stderr on non-zero exit; on failure this
|
|
278
|
+
* layer writes bounded tails into the hooks evidence event AND echoes them to
|
|
279
|
+
* the current process' stderr, so a failing hook is diagnosable on the
|
|
280
|
+
* terminal without opening evidence.jsonl. Success events carry no tails.
|
|
281
|
+
* Exit-code semantics are unchanged.
|
|
282
|
+
*
|
|
239
283
|
* @param {object} config - The loaded project config.
|
|
240
284
|
* @param {string} root - Absolute project root.
|
|
241
285
|
* @param {object} evidence - The evidence writer.
|
|
@@ -251,7 +295,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
251
295
|
* injectable. Defaults to process.env at the prepare call site, which makes
|
|
252
296
|
* allowlisted keys exported by the invoking shell reach the hook
|
|
253
297
|
* subprocess.
|
|
254
|
-
* @returns {Promise<
|
|
298
|
+
* @returns {Promise<Array<{ name: string, completed: boolean, cached: boolean, testSelection: string | undefined }>>}
|
|
299
|
+
* One record per declared hook that completed (fresh or cached replay).
|
|
300
|
+
* Failures throw instead of returning a record.
|
|
255
301
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
|
|
256
302
|
* throws, or declares a cacheInputs glob that matches no file.
|
|
257
303
|
*/
|
|
@@ -259,15 +305,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
259
305
|
const hookOrder = ['docs', 'build', 'test', 'typecheck'];
|
|
260
306
|
const hooks = config.hooks ?? {};
|
|
261
307
|
const cacheEnabled = options.hookCache !== false;
|
|
308
|
+
const records = [];
|
|
262
309
|
|
|
263
310
|
for (const name of hookOrder) {
|
|
264
311
|
const hook = hooks[name];
|
|
265
312
|
if (!hook) continue;
|
|
266
313
|
|
|
314
|
+
// Test-selection evidence (2026-08-18 investigation §4.4): the test hook
|
|
315
|
+
// records whether it ran the full suite. Absence of a declaration means
|
|
316
|
+
// 'full' — every existing config stays backward compatible.
|
|
317
|
+
const selectionField = name === 'test'
|
|
318
|
+
? { testSelection: hook.testSelection === 'incremental' ? 'incremental' : 'full' }
|
|
319
|
+
: {};
|
|
320
|
+
|
|
267
321
|
await evidence.append({
|
|
268
322
|
phase: 'hooks',
|
|
269
323
|
status: 'started',
|
|
270
324
|
hookName: name,
|
|
325
|
+
...selectionField,
|
|
271
326
|
});
|
|
272
327
|
|
|
273
328
|
// --- Incremental cache lookup (opt-in only; default zero change) ---
|
|
@@ -295,7 +350,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
295
350
|
hookName: name,
|
|
296
351
|
cached: true,
|
|
297
352
|
cacheKey,
|
|
353
|
+
...selectionField,
|
|
298
354
|
});
|
|
355
|
+
records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
|
|
299
356
|
continue;
|
|
300
357
|
}
|
|
301
358
|
}
|
|
@@ -312,6 +369,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
312
369
|
status: 'failed',
|
|
313
370
|
hookName: name,
|
|
314
371
|
error: { code: err.code, message: err.message },
|
|
372
|
+
...selectionField,
|
|
315
373
|
});
|
|
316
374
|
throw new ReleaseError(
|
|
317
375
|
GATE_FAILED,
|
|
@@ -321,16 +379,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
321
379
|
}
|
|
322
380
|
|
|
323
381
|
if (result.exitCode !== 0) {
|
|
382
|
+
const stdoutTail = boundedOutputTail(result.stdout);
|
|
383
|
+
const stderrTail = boundedOutputTail(result.stderr);
|
|
384
|
+
// Echo the captured tails to the current process' stderr so a failing
|
|
385
|
+
// hook is diagnosable on the terminal without opening evidence.jsonl
|
|
386
|
+
// (2026-08-18 investigation §4.1). Exit-code semantics are untouched.
|
|
387
|
+
process.stderr.write(`[release-skill] hook "${name}" failed with exit code ${result.exitCode}\n`);
|
|
388
|
+
if (stdoutTail) {
|
|
389
|
+
process.stderr.write(`[release-skill] hook "${name}" stdout tail:\n${stdoutTail}\n`);
|
|
390
|
+
}
|
|
391
|
+
if (stderrTail) {
|
|
392
|
+
process.stderr.write(`[release-skill] hook "${name}" stderr tail:\n${stderrTail}\n`);
|
|
393
|
+
}
|
|
324
394
|
await evidence.append({
|
|
325
395
|
phase: 'hooks',
|
|
326
396
|
status: 'failed',
|
|
327
397
|
hookName: name,
|
|
328
398
|
exitCode: result.exitCode,
|
|
329
399
|
// Test runners usually emit the actionable failure summary at the
|
|
330
|
-
// end. Preserve bounded tails
|
|
331
|
-
// compiler prelude at the beginning.
|
|
332
|
-
stdoutTail
|
|
333
|
-
stderrTail
|
|
400
|
+
// end. Preserve bounded tails (last 50 lines, capped at 8 KB) of both
|
|
401
|
+
// streams instead of the noisy compiler prelude at the beginning.
|
|
402
|
+
stdoutTail,
|
|
403
|
+
stderrTail,
|
|
404
|
+
...selectionField,
|
|
334
405
|
});
|
|
335
406
|
throw new ReleaseError(
|
|
336
407
|
GATE_FAILED,
|
|
@@ -364,8 +435,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
364
435
|
status: 'completed',
|
|
365
436
|
hookName: name,
|
|
366
437
|
exitCode: 0,
|
|
438
|
+
...selectionField,
|
|
367
439
|
});
|
|
440
|
+
records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
|
|
368
441
|
}
|
|
442
|
+
|
|
443
|
+
return records;
|
|
369
444
|
}
|
|
370
445
|
|
|
371
446
|
// ---------------------------------------------------------------------------
|
|
@@ -1744,6 +1819,7 @@ export async function prepareRelease(options) {
|
|
|
1744
1819
|
production = false,
|
|
1745
1820
|
workflow = 'full',
|
|
1746
1821
|
observePreviousPublicBaselineFn,
|
|
1822
|
+
testSelection = 'full',
|
|
1747
1823
|
} = options ?? {};
|
|
1748
1824
|
|
|
1749
1825
|
// --- Workflow profile (H5) ---
|
|
@@ -1761,6 +1837,24 @@ export async function prepareRelease(options) {
|
|
|
1761
1837
|
{ workflow },
|
|
1762
1838
|
);
|
|
1763
1839
|
}
|
|
1840
|
+
|
|
1841
|
+
// --- Test selection (2026-08-18 investigation §4.4, review §3.4) ---
|
|
1842
|
+
// Design decision: prepare IS the freeze, so incremental test selection is
|
|
1843
|
+
// rejected outright; the flag is reserved for a future preflight mode.
|
|
1844
|
+
if (testSelection === 'incremental') {
|
|
1845
|
+
throw new ReleaseError(
|
|
1846
|
+
GATE_FAILED,
|
|
1847
|
+
'incremental selection is not allowed at freeze time',
|
|
1848
|
+
{ testSelection, reservedFor: 'preflight mode' },
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
if (testSelection !== 'full') {
|
|
1852
|
+
throw new ReleaseError(
|
|
1853
|
+
CONFIG_INVALID,
|
|
1854
|
+
`unknown testSelection "${testSelection}"; expected "full" or "incremental"`,
|
|
1855
|
+
{ testSelection },
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1764
1858
|
const trimmedWorkflow = workflow !== 'full';
|
|
1765
1859
|
const skipDeclaredHooks = trimmedWorkflow;
|
|
1766
1860
|
const skipSnapshotVerifyGates = trimmedWorkflow;
|
|
@@ -1845,6 +1939,44 @@ export async function prepareRelease(options) {
|
|
|
1845
1939
|
});
|
|
1846
1940
|
}
|
|
1847
1941
|
|
|
1942
|
+
// --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
|
|
1943
|
+
// 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
|
|
1944
|
+
// used to surface only deep inside test hooks. Compare the deterministic
|
|
1945
|
+
// source digest embedded in the bundle at build time with the current
|
|
1946
|
+
// sources at the earliest stage — config loaded, before any hook.
|
|
1947
|
+
// This gate is artifact-integrity class, NOT a code-class gate:
|
|
1948
|
+
// docs/config/marketplace workflow trimming must never exempt it.
|
|
1949
|
+
await evidence.append({ phase: 'bundle-freshness', status: 'started' });
|
|
1950
|
+
const bundleFreshnessFn = options.bundleFreshnessFn ?? assertBundleFreshness;
|
|
1951
|
+
let bundleFreshness;
|
|
1952
|
+
try {
|
|
1953
|
+
bundleFreshness = await bundleFreshnessFn(PKG_ROOT);
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
await evidence.append({
|
|
1956
|
+
phase: 'bundle-freshness',
|
|
1957
|
+
status: 'blocking',
|
|
1958
|
+
reason: err.details?.reason ?? null,
|
|
1959
|
+
error: { code: err.code, message: err.message },
|
|
1960
|
+
});
|
|
1961
|
+
throw err;
|
|
1962
|
+
}
|
|
1963
|
+
if (bundleFreshness?.applicable === false) {
|
|
1964
|
+
// Installed distributions ship no mutable src/ next to the bundle;
|
|
1965
|
+
// staleness is a source-checkout concern only.
|
|
1966
|
+
await evidence.append({
|
|
1967
|
+
phase: 'bundle-freshness',
|
|
1968
|
+
status: 'not-applicable',
|
|
1969
|
+
reason: bundleFreshness.reason,
|
|
1970
|
+
});
|
|
1971
|
+
} else {
|
|
1972
|
+
await evidence.append({
|
|
1973
|
+
phase: 'bundle-freshness',
|
|
1974
|
+
status: 'completed',
|
|
1975
|
+
algorithm: bundleFreshness?.algorithm ?? null,
|
|
1976
|
+
sourceDigest: bundleFreshness?.sourceDigest ?? null,
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1848
1980
|
// --- Step 1a: Workflow configuration evidence ---
|
|
1849
1981
|
// Records the deterministic trim set for docs/config/marketplace
|
|
1850
1982
|
// workflows. The trim never weakens the retained gates; it only removes
|
|
@@ -1974,6 +2106,7 @@ export async function prepareRelease(options) {
|
|
|
1974
2106
|
}
|
|
1975
2107
|
|
|
1976
2108
|
// --- Step 3: Run declared hooks ---
|
|
2109
|
+
let hookRecords = [];
|
|
1977
2110
|
if (skipDeclaredHooks) {
|
|
1978
2111
|
await evidence.append({
|
|
1979
2112
|
phase: 'hooks',
|
|
@@ -1982,7 +2115,7 @@ export async function prepareRelease(options) {
|
|
|
1982
2115
|
});
|
|
1983
2116
|
} else {
|
|
1984
2117
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1985
|
-
await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
2118
|
+
hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
1986
2119
|
hookCache: options.hookCache,
|
|
1987
2120
|
// Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
|
|
1988
2121
|
// reads envAllowlist keys exclusively from context.env, so the invoking
|
|
@@ -2608,6 +2741,59 @@ export async function prepareRelease(options) {
|
|
|
2608
2741
|
// --- Step 7: Build plan object ---
|
|
2609
2742
|
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
2610
2743
|
|
|
2744
|
+
// --- Step 7-gate: Full-test freeze gate (hard gate) ---
|
|
2745
|
+
// 2026-08-18 investigation §4.4 / review §3.4: "full test suite before
|
|
2746
|
+
// freeze" is a HARD gate, not a convention. The plan digest may only be
|
|
2747
|
+
// computed after a completed FULL-mode test hook exists in THIS run's
|
|
2748
|
+
// evidence (fresh run or a cached replay of a successful full run).
|
|
2749
|
+
// Built-in and read-only — like secret-scan, plan-digest binding, and
|
|
2750
|
+
// approval, it cannot be disabled by project overlays. Workflows that
|
|
2751
|
+
// trim declared hooks record the trim; projects declaring no test hook
|
|
2752
|
+
// pass vacuously (nothing can run incrementally there).
|
|
2753
|
+
if (skipDeclaredHooks) {
|
|
2754
|
+
await evidence.append({
|
|
2755
|
+
phase: 'full-test-gate',
|
|
2756
|
+
status: 'skipped',
|
|
2757
|
+
reason: `workflow "${workflow}" trims declared hooks`,
|
|
2758
|
+
});
|
|
2759
|
+
} else if (!config.hooks?.test) {
|
|
2760
|
+
await evidence.append({
|
|
2761
|
+
phase: 'full-test-gate',
|
|
2762
|
+
status: 'not-declared',
|
|
2763
|
+
reason: 'no test hook declared; nothing can run incrementally',
|
|
2764
|
+
});
|
|
2765
|
+
} else {
|
|
2766
|
+
const testRecord = hookRecords.find((record) => record.name === 'test');
|
|
2767
|
+
const satisfied = Boolean(
|
|
2768
|
+
testRecord && testRecord.completed && testRecord.testSelection === 'full',
|
|
2769
|
+
);
|
|
2770
|
+
if (!satisfied) {
|
|
2771
|
+
await evidence.append({
|
|
2772
|
+
phase: 'full-test-gate',
|
|
2773
|
+
status: 'blocking',
|
|
2774
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
2775
|
+
cached: Boolean(testRecord?.cached),
|
|
2776
|
+
});
|
|
2777
|
+
throw new ReleaseError(
|
|
2778
|
+
GATE_FAILED,
|
|
2779
|
+
testRecord?.testSelection === 'incremental'
|
|
2780
|
+
? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
|
|
2781
|
+
: 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
|
|
2782
|
+
{
|
|
2783
|
+
gate: 'full-test-freeze',
|
|
2784
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
2785
|
+
cached: Boolean(testRecord?.cached),
|
|
2786
|
+
},
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
await evidence.append({
|
|
2790
|
+
phase: 'full-test-gate',
|
|
2791
|
+
status: 'completed',
|
|
2792
|
+
testSelection: 'full',
|
|
2793
|
+
cached: Boolean(testRecord.cached),
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2611
2797
|
// New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
|
|
2612
2798
|
// §4.2/§7). Production freeze timestamps are derived deterministically
|
|
2613
2799
|
// from the baseline headCommit's committer date, before the first frozen
|
|
@@ -3215,6 +3401,26 @@ export async function prepareRelease(options) {
|
|
|
3215
3401
|
planDigest,
|
|
3216
3402
|
});
|
|
3217
3403
|
|
|
3404
|
+
// --- Write the FROZEN governance marker (§4.5 option 1) ---
|
|
3405
|
+
// Mechanical maintenance only: the marker is a read-only gentlemen's-
|
|
3406
|
+
// agreement signal for cross-repo writers (e.g. skill-family治理 tasks)
|
|
3407
|
+
// that this workspace is mid-release. It is written ONLY after the plan
|
|
3408
|
+
// is frozen, overwritten by every successful prepare, and cleared by
|
|
3409
|
+
// verify only upon VERIFIED. A failed prepare never reaches this point.
|
|
3410
|
+
await writeFrozenMarker(releaseDir, {
|
|
3411
|
+
planDigest,
|
|
3412
|
+
targetVersions: Object.fromEntries(
|
|
3413
|
+
configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
|
|
3414
|
+
),
|
|
3415
|
+
createdAt: plan.createdAt,
|
|
3416
|
+
runId: basename(runDir),
|
|
3417
|
+
});
|
|
3418
|
+
await evidence.append({
|
|
3419
|
+
phase: 'frozen-marker',
|
|
3420
|
+
status: 'written',
|
|
3421
|
+
markerPath: `.release-skill/${FROZEN_MARKER_FILENAME}`,
|
|
3422
|
+
});
|
|
3423
|
+
|
|
3218
3424
|
// --- Write summary ---
|
|
3219
3425
|
await evidence.finish({
|
|
3220
3426
|
status: 'PREPARED',
|
package/src/commands/verify.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from '../core/baseline-advance.mjs';
|
|
55
55
|
import { loadProjectConfig } from '../core/config.mjs';
|
|
56
56
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
57
|
+
import { clearFrozenMarker } from '../core/frozen-marker.mjs';
|
|
57
58
|
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
58
59
|
import {
|
|
59
60
|
normalizeRegistry,
|
|
@@ -1802,6 +1803,27 @@ export async function verifyRelease(options) {
|
|
|
1802
1803
|
};
|
|
1803
1804
|
const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
|
|
1804
1805
|
|
|
1806
|
+
// =======================================================================
|
|
1807
|
+
// FROZEN marker retirement (2026-08-18 investigation §4.5 option 1):
|
|
1808
|
+
// the release reached VERIFIED, so the freeze signal is cleared. Only
|
|
1809
|
+
// this success path clears it — failed/PARTIAL verify runs never reach
|
|
1810
|
+
// here and keep the marker. Best-effort: a removal failure must never
|
|
1811
|
+
// demote VERIFIED.
|
|
1812
|
+
// =======================================================================
|
|
1813
|
+
try {
|
|
1814
|
+
const removed = await clearFrozenMarker(join(root, '.release-skill'));
|
|
1815
|
+
await evidence.append({
|
|
1816
|
+
phase: 'frozen-marker',
|
|
1817
|
+
status: removed ? 'cleared' : 'absent',
|
|
1818
|
+
});
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
await evidence.append({
|
|
1821
|
+
phase: 'frozen-marker',
|
|
1822
|
+
status: 'clear-failed',
|
|
1823
|
+
error: { code: err.code, message: err.message },
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1805
1827
|
// =======================================================================
|
|
1806
1828
|
// Baseline advance: move per-unit previousPublicBaseline to the commit
|
|
1807
1829
|
// that was just verified. Local bookkeeping only — no remote writes.
|