release-skill 0.5.1 → 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 +27 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +15 -14
- package/README.zh-CN.md +16 -15
- 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 +3779 -769
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +401 -2
- package/adapters/claude/schemas/release-project.schema.json +299 -0
- package/adapters/claude/schemas/release-run.schema.json +59 -6
- package/adapters/claude/skills/release-config/SKILL.md +140 -0
- package/adapters/claude/skills/release-docs/SKILL.md +106 -0
- package/adapters/claude/skills/release-help/SKILL.md +39 -0
- package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/claude/skills/release-publish/SKILL.md +25 -6
- package/adapters/claude/skills/release-verify/SKILL.md +10 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +401 -2
- package/adapters/codex/schemas/release-project.schema.json +299 -0
- package/adapters/codex/schemas/release-run.schema.json +59 -6
- package/adapters/codex/skills/release-config/SKILL.md +147 -0
- package/adapters/codex/skills/release-docs/SKILL.md +113 -0
- package/adapters/codex/skills/release-help/SKILL.md +39 -0
- package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/codex/skills/release-publish/SKILL.md +25 -6
- package/adapters/codex/skills/release-verify/SKILL.md +10 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +401 -2
- package/adapters/kimi/schemas/release-project.schema.json +299 -0
- package/adapters/kimi/schemas/release-run.schema.json +59 -6
- package/adapters/kimi/skills/release-config/SKILL.md +147 -0
- package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
- package/adapters/kimi/skills/release-help/SKILL.md +39 -0
- package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
- package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
- package/adapters/workbuddy/schemas/release-project.schema.json +299 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
- package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
- package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
- package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
- package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
- package/bin/release-skill-cli.mjs +246 -7
- package/bin/release-skill.bundle.mjs +3779 -769
- package/package.json +3 -2
- package/platform-manifest.json +359 -0
- package/references/.render-manifest.json +9 -9
- package/references/02-project-config.md +35 -0
- package/references/05-evidence-and-errors.md +45 -0
- package/references/06-adapter-contract.md +13 -0
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +401 -2
- package/schemas/release-project.schema.json +299 -0
- package/schemas/release-run.schema.json +59 -6
- package/scripts/build-bundle.mjs +11 -2
- package/scripts/sync-public-files.mjs +4 -0
- package/skills/release-config/SKILL.md +140 -0
- package/skills/release-docs/SKILL.md +106 -0
- package/skills/release-help/SKILL.md +39 -0
- package/skills/release-marketplace/SKILL.md +147 -0
- package/skills/release-publish/SKILL.md +25 -6
- package/skills/release-verify/SKILL.md +10 -0
- package/skills-src/release-config/SKILL.md +140 -0
- package/skills-src/release-docs/SKILL.md +106 -0
- package/skills-src/release-help/SKILL.md +39 -0
- package/skills-src/release-marketplace/SKILL.md +147 -0
- package/skills-src/release-publish/SKILL.md +25 -6
- package/skills-src/release-verify/SKILL.md +10 -0
- package/src/adapters/contract.mjs +5 -0
- package/src/adapters/distribute-git.mjs +625 -0
- package/src/commands/distribute.mjs +1078 -0
- package/src/commands/lineage.mjs +616 -0
- package/src/commands/prepare.mjs +548 -83
- package/src/commands/route.mjs +686 -0
- package/src/commands/ship.mjs +45 -1
- package/src/commands/verify.mjs +198 -0
- package/src/core/baseline-advance.mjs +185 -0
- package/src/core/bundle-freshness.mjs +236 -0
- package/src/core/checkpoints.mjs +25 -0
- package/src/core/errors.mjs +2 -0
- package/src/core/frozen-marker.mjs +97 -0
- package/src/core/hooks.mjs +12 -1
- package/src/core/postpublish.mjs +315 -0
package/src/commands/prepare.mjs
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* @module commands/prepare
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
|
|
22
|
-
import { readFile, mkdir, realpath } from 'node:fs/promises';
|
|
21
|
+
import { resolve, relative, isAbsolute, normalize, dirname, basename } from 'node:path';
|
|
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';
|
|
25
25
|
|
|
@@ -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,
|
|
@@ -67,6 +70,7 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
|
|
|
67
70
|
import { PLATFORMS } from '../platforms/registry.mjs';
|
|
68
71
|
import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
|
|
69
72
|
import { buildInstallationContract, computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from '../core/installation-contract.mjs';
|
|
73
|
+
import { validatePostPublishDeclaration, PAYLOAD_SOURCE_TAG_WORKTREE } from '../core/postpublish.mjs';
|
|
70
74
|
|
|
71
75
|
// ---------------------------------------------------------------------------
|
|
72
76
|
// 安装契约常量
|
|
@@ -223,6 +227,40 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
223
227
|
// Hooks execution
|
|
224
228
|
// ---------------------------------------------------------------------------
|
|
225
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
|
+
|
|
226
264
|
/**
|
|
227
265
|
* Run all declared project hooks in order: docs, build, test, typecheck.
|
|
228
266
|
*
|
|
@@ -235,6 +273,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
235
273
|
* Failures (non-zero exit or HOOK_TIMEOUT) are never cached. A `cacheInputs`
|
|
236
274
|
* glob that matches nothing fails closed before the hook runs.
|
|
237
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
|
+
*
|
|
238
283
|
* @param {object} config - The loaded project config.
|
|
239
284
|
* @param {string} root - Absolute project root.
|
|
240
285
|
* @param {object} evidence - The evidence writer.
|
|
@@ -250,7 +295,9 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
250
295
|
* injectable. Defaults to process.env at the prepare call site, which makes
|
|
251
296
|
* allowlisted keys exported by the invoking shell reach the hook
|
|
252
297
|
* subprocess.
|
|
253
|
-
* @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.
|
|
254
301
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
|
|
255
302
|
* throws, or declares a cacheInputs glob that matches no file.
|
|
256
303
|
*/
|
|
@@ -258,15 +305,24 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
258
305
|
const hookOrder = ['docs', 'build', 'test', 'typecheck'];
|
|
259
306
|
const hooks = config.hooks ?? {};
|
|
260
307
|
const cacheEnabled = options.hookCache !== false;
|
|
308
|
+
const records = [];
|
|
261
309
|
|
|
262
310
|
for (const name of hookOrder) {
|
|
263
311
|
const hook = hooks[name];
|
|
264
312
|
if (!hook) continue;
|
|
265
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
|
+
|
|
266
321
|
await evidence.append({
|
|
267
322
|
phase: 'hooks',
|
|
268
323
|
status: 'started',
|
|
269
324
|
hookName: name,
|
|
325
|
+
...selectionField,
|
|
270
326
|
});
|
|
271
327
|
|
|
272
328
|
// --- Incremental cache lookup (opt-in only; default zero change) ---
|
|
@@ -294,7 +350,9 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
294
350
|
hookName: name,
|
|
295
351
|
cached: true,
|
|
296
352
|
cacheKey,
|
|
353
|
+
...selectionField,
|
|
297
354
|
});
|
|
355
|
+
records.push({ name, completed: true, cached: true, testSelection: selectionField.testSelection });
|
|
298
356
|
continue;
|
|
299
357
|
}
|
|
300
358
|
}
|
|
@@ -311,6 +369,7 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
311
369
|
status: 'failed',
|
|
312
370
|
hookName: name,
|
|
313
371
|
error: { code: err.code, message: err.message },
|
|
372
|
+
...selectionField,
|
|
314
373
|
});
|
|
315
374
|
throw new ReleaseError(
|
|
316
375
|
GATE_FAILED,
|
|
@@ -320,16 +379,29 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
320
379
|
}
|
|
321
380
|
|
|
322
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
|
+
}
|
|
323
394
|
await evidence.append({
|
|
324
395
|
phase: 'hooks',
|
|
325
396
|
status: 'failed',
|
|
326
397
|
hookName: name,
|
|
327
398
|
exitCode: result.exitCode,
|
|
328
399
|
// Test runners usually emit the actionable failure summary at the
|
|
329
|
-
// end. Preserve bounded tails
|
|
330
|
-
// compiler prelude at the beginning.
|
|
331
|
-
stdoutTail
|
|
332
|
-
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,
|
|
333
405
|
});
|
|
334
406
|
throw new ReleaseError(
|
|
335
407
|
GATE_FAILED,
|
|
@@ -363,8 +435,12 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
363
435
|
status: 'completed',
|
|
364
436
|
hookName: name,
|
|
365
437
|
exitCode: 0,
|
|
438
|
+
...selectionField,
|
|
366
439
|
});
|
|
440
|
+
records.push({ name, completed: true, cached: false, testSelection: selectionField.testSelection });
|
|
367
441
|
}
|
|
442
|
+
|
|
443
|
+
return records;
|
|
368
444
|
}
|
|
369
445
|
|
|
370
446
|
// ---------------------------------------------------------------------------
|
|
@@ -1693,6 +1769,43 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
|
|
|
1693
1769
|
*
|
|
1694
1770
|
* @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
|
|
1695
1771
|
*/
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Read the newest frozen plan from `.release-skill/plans/` (digest-addressed
|
|
1775
|
+
* immutable plans written by writePlanImmutable). Used by the config workflow
|
|
1776
|
+
* decision to compare public bytes (per-unit snapshotDigest) against the
|
|
1777
|
+
* latest frozen state. Returns null when no readable plan exists.
|
|
1778
|
+
*
|
|
1779
|
+
* @param {string} root - project root directory
|
|
1780
|
+
* @returns {Promise<{ plan: object, fileName: string } | null>}
|
|
1781
|
+
*/
|
|
1782
|
+
export async function readLatestFrozenPlan(root) {
|
|
1783
|
+
const plansDir = resolve(root, '.release-skill', 'plans');
|
|
1784
|
+
let files;
|
|
1785
|
+
try {
|
|
1786
|
+
files = await readdir(plansDir);
|
|
1787
|
+
} catch {
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
let best = null;
|
|
1791
|
+
for (const file of files.sort()) {
|
|
1792
|
+
if (!file.endsWith('.json')) continue;
|
|
1793
|
+
let plan;
|
|
1794
|
+
try {
|
|
1795
|
+
plan = JSON.parse(await readFile(resolve(plansDir, file), 'utf8'));
|
|
1796
|
+
} catch {
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.units)) continue;
|
|
1800
|
+
const ts = plan.createdAt ? new Date(plan.createdAt).getTime() : NaN;
|
|
1801
|
+
const effectiveTs = Number.isFinite(ts) ? ts : 0;
|
|
1802
|
+
if (!best || effectiveTs > best.ts) {
|
|
1803
|
+
best = { plan, fileName: file, ts: effectiveTs };
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return best ? { plan: best.plan, fileName: best.fileName } : null;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1696
1809
|
export async function prepareRelease(options) {
|
|
1697
1810
|
const {
|
|
1698
1811
|
root,
|
|
@@ -1704,9 +1817,50 @@ export async function prepareRelease(options) {
|
|
|
1704
1817
|
hooksAuthorized,
|
|
1705
1818
|
verificationGatesAuthorized,
|
|
1706
1819
|
production = false,
|
|
1820
|
+
workflow = 'full',
|
|
1707
1821
|
observePreviousPublicBaselineFn,
|
|
1822
|
+
testSelection = 'full',
|
|
1708
1823
|
} = options ?? {};
|
|
1709
1824
|
|
|
1825
|
+
// --- Workflow profile (H5) ---
|
|
1826
|
+
// 'full' runs the complete gate set. 'docs', 'config' and 'marketplace'
|
|
1827
|
+
// trim only code-class gates — declared hooks, snapshot-verify gates,
|
|
1828
|
+
// source-authority closure, and skill-resource-closure — and record the
|
|
1829
|
+
// trim as `workflowDecision` bound into the plan digest. All other gates
|
|
1830
|
+
// (docs freshness, public surface, baseline, snapshots, remote checks,
|
|
1831
|
+
// plan freeze, consumer-verify gates) run identically for every workflow.
|
|
1832
|
+
const WORKFLOW_KINDS = new Set(['full', 'docs', 'config', 'marketplace']);
|
|
1833
|
+
if (!WORKFLOW_KINDS.has(workflow)) {
|
|
1834
|
+
throw new ReleaseError(
|
|
1835
|
+
CONFIG_INVALID,
|
|
1836
|
+
`unknown workflow kind "${workflow}"; expected one of ${[...WORKFLOW_KINDS].sort().join(', ')}`,
|
|
1837
|
+
{ workflow },
|
|
1838
|
+
);
|
|
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
|
+
}
|
|
1858
|
+
const trimmedWorkflow = workflow !== 'full';
|
|
1859
|
+
const skipDeclaredHooks = trimmedWorkflow;
|
|
1860
|
+
const skipSnapshotVerifyGates = trimmedWorkflow;
|
|
1861
|
+
const skipSourceAuthorityClosure = trimmedWorkflow;
|
|
1862
|
+
const skipSkillResourceClosure = trimmedWorkflow;
|
|
1863
|
+
|
|
1710
1864
|
// --- Validate root ---
|
|
1711
1865
|
if (!root || typeof root !== 'string') {
|
|
1712
1866
|
throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
|
|
@@ -1785,6 +1939,66 @@ export async function prepareRelease(options) {
|
|
|
1785
1939
|
});
|
|
1786
1940
|
}
|
|
1787
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
|
+
|
|
1980
|
+
// --- Step 1a: Workflow configuration evidence ---
|
|
1981
|
+
// Records the deterministic trim set for docs/config/marketplace
|
|
1982
|
+
// workflows. The trim never weakens the retained gates; it only removes
|
|
1983
|
+
// code-class gates that a non-code change surface cannot exercise.
|
|
1984
|
+
await evidence.append({
|
|
1985
|
+
phase: 'workflow',
|
|
1986
|
+
status: 'configured',
|
|
1987
|
+
workflowKind: workflow,
|
|
1988
|
+
trimmedGates: trimmedWorkflow
|
|
1989
|
+
? ['declared-hooks', 'snapshot-verify-gates', 'source-authority-closure', 'skill-resource-closure']
|
|
1990
|
+
: [],
|
|
1991
|
+
retainedGates: [
|
|
1992
|
+
'docs-freshness',
|
|
1993
|
+
'public-surface',
|
|
1994
|
+
'baseline',
|
|
1995
|
+
'snapshots',
|
|
1996
|
+
'remote-check',
|
|
1997
|
+
'plan-freeze',
|
|
1998
|
+
'consumer-verify',
|
|
1999
|
+
],
|
|
2000
|
+
});
|
|
2001
|
+
|
|
1788
2002
|
// --- Step 1b: Resolve authoritative versions and gate release-document
|
|
1789
2003
|
// freshness BEFORE hook authorization ---
|
|
1790
2004
|
// Authoritative versions resolve exactly once here and are reused by
|
|
@@ -1811,11 +2025,46 @@ export async function prepareRelease(options) {
|
|
|
1811
2025
|
reasonTag: 'RELEASE_DOCS_STALE',
|
|
1812
2026
|
});
|
|
1813
2027
|
|
|
2028
|
+
// --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
|
|
2029
|
+
// The per-unit postPublish block drives the post-publish distribute
|
|
2030
|
+
// command. Validate it here, before any hook, baseline, snapshot, remote
|
|
2031
|
+
// check, or plan write, so an unsafe declaration fails closed with zero
|
|
2032
|
+
// side effects. This is the runtime re-check on top of the config JSON
|
|
2033
|
+
// schema: plans frozen by older schema versions must not be able to
|
|
2034
|
+
// smuggle shell strings, option-like executables, or secret-ish env
|
|
2035
|
+
// keys through. A plan binds exactly one declaration; multiple units
|
|
2036
|
+
// declaring postPublish is a hard gate failure.
|
|
2037
|
+
const postPublishDeclarations = configUnits
|
|
2038
|
+
.map((unit, index) => ({ unit, index }))
|
|
2039
|
+
.filter(({ unit }) => unit.postPublish !== undefined);
|
|
2040
|
+
if (postPublishDeclarations.length > 1) {
|
|
2041
|
+
throw new ReleaseError(
|
|
2042
|
+
GATE_FAILED,
|
|
2043
|
+
`multiple units declare postPublish (${postPublishDeclarations.map(({ unit }) => unit.id).join(', ')}); a release plan binds exactly one postPublish declaration`,
|
|
2044
|
+
{ unitIds: postPublishDeclarations.map(({ unit }) => unit.id) },
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
let postPublishDeclaration = null;
|
|
2048
|
+
if (postPublishDeclarations.length === 1) {
|
|
2049
|
+
const { unit, index } = postPublishDeclarations[0];
|
|
2050
|
+
validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
|
|
2051
|
+
postPublishDeclaration = { unit, index };
|
|
2052
|
+
await evidence.append({
|
|
2053
|
+
phase: 'postpublish-declaration',
|
|
2054
|
+
status: 'validated',
|
|
2055
|
+
unitId: unit.id,
|
|
2056
|
+
targetCount: unit.postPublish.targets.length,
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1814
2060
|
// --- Step 2: Hook authorization gate ---
|
|
1815
2061
|
// Hooks are user-configured arbitrary local processes without filesystem
|
|
1816
2062
|
// or network isolation. They may write outside the project, access local
|
|
1817
2063
|
// credentials, or make network calls. The user must explicitly accept
|
|
1818
2064
|
// these risks before any hook is executed.
|
|
2065
|
+
// docs/config/marketplace workflows trim code-class hooks entirely
|
|
2066
|
+
// (H5): a non-code change surface cannot exercise them, so they are
|
|
2067
|
+
// recorded as skipped rather than authorized-and-run.
|
|
1819
2068
|
const declaredHooks = Object.entries(config.hooks ?? {})
|
|
1820
2069
|
.filter(([, hook]) => hook && hook.command)
|
|
1821
2070
|
.map(([name, hook]) => ({
|
|
@@ -1829,7 +2078,7 @@ export async function prepareRelease(options) {
|
|
|
1829
2078
|
// execution of configured commands. Old acknowledgement parameters
|
|
1830
2079
|
// (--acknowledge-hook-side-effects, --acknowledge-gate-side-effects) are
|
|
1831
2080
|
// accepted as no-effect compatibility inputs but are not required.
|
|
1832
|
-
if (declaredHooks.length > 0) {
|
|
2081
|
+
if (!skipDeclaredHooks && declaredHooks.length > 0) {
|
|
1833
2082
|
await evidence.append({
|
|
1834
2083
|
phase: 'hook-authorization',
|
|
1835
2084
|
status: 'authorized',
|
|
@@ -1857,16 +2106,25 @@ export async function prepareRelease(options) {
|
|
|
1857
2106
|
}
|
|
1858
2107
|
|
|
1859
2108
|
// --- Step 3: Run declared hooks ---
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2109
|
+
let hookRecords = [];
|
|
2110
|
+
if (skipDeclaredHooks) {
|
|
2111
|
+
await evidence.append({
|
|
2112
|
+
phase: 'hooks',
|
|
2113
|
+
status: 'skipped',
|
|
2114
|
+
reason: `workflow "${workflow}" trims code-class hooks; declared hooks: ${declaredHooks.length}`,
|
|
2115
|
+
});
|
|
2116
|
+
} else {
|
|
2117
|
+
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
2118
|
+
hookRecords = await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
2119
|
+
hookCache: options.hookCache,
|
|
2120
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
|
|
2121
|
+
// reads envAllowlist keys exclusively from context.env, so the invoking
|
|
2122
|
+
// shell's environment is injected here explicitly. Allowlist semantics
|
|
2123
|
+
// are unchanged — only allowlisted keys from this map reach the child.
|
|
2124
|
+
env: options.env ?? process.env,
|
|
2125
|
+
});
|
|
2126
|
+
await evidence.append({ phase: 'hooks', status: 'completed' });
|
|
2127
|
+
}
|
|
1870
2128
|
|
|
1871
2129
|
// --- Step 3b: Re-check release-document freshness AFTER hooks ---
|
|
1872
2130
|
// Declared hooks run as arbitrary local processes; they may rewrite a
|
|
@@ -1968,7 +2226,7 @@ export async function prepareRelease(options) {
|
|
|
1968
2226
|
|
|
1969
2227
|
let sourceAuthority = null;
|
|
1970
2228
|
let sourceInputClosure = null;
|
|
1971
|
-
if (production) {
|
|
2229
|
+
if (production && !skipSourceAuthorityClosure) {
|
|
1972
2230
|
if (!sourceRepository || typeof sourceRepository !== 'string') {
|
|
1973
2231
|
throw new ReleaseError(
|
|
1974
2232
|
CONFIG_MISSING,
|
|
@@ -2038,6 +2296,12 @@ export async function prepareRelease(options) {
|
|
|
2038
2296
|
inputDigest: sourceInputClosure.digest,
|
|
2039
2297
|
remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
|
|
2040
2298
|
});
|
|
2299
|
+
} else if (production) {
|
|
2300
|
+
await evidence.append({
|
|
2301
|
+
phase: 'source-authority',
|
|
2302
|
+
status: 'skipped',
|
|
2303
|
+
reason: `workflow "${workflow}" trims the source-authority content closure gate`,
|
|
2304
|
+
});
|
|
2041
2305
|
}
|
|
2042
2306
|
|
|
2043
2307
|
// --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
|
|
@@ -2358,24 +2622,38 @@ export async function prepareRelease(options) {
|
|
|
2358
2622
|
// Snapshot gates always run on disposable writable copies. The public
|
|
2359
2623
|
// snapshot authority is re-digested after every gate and is never exposed
|
|
2360
2624
|
// as the gate working directory.
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2625
|
+
// docs/config/marketplace workflows trim snapshot-verify gates (H5):
|
|
2626
|
+
// they are declared code-class gates that a non-code change surface
|
|
2627
|
+
// cannot exercise. Consumer-verify gates (phase 'consumer-verify') are
|
|
2628
|
+
// NOT part of this skip — they run during verify regardless of workflow.
|
|
2629
|
+
if (skipSnapshotVerifyGates) {
|
|
2630
|
+
await evidence.append({
|
|
2631
|
+
phase: 'snapshot-verify',
|
|
2632
|
+
status: 'skipped',
|
|
2633
|
+
reason: `workflow "${workflow}" trims snapshot-verify gates; declared gates: ${declaredVerificationGates.length}`,
|
|
2634
|
+
});
|
|
2635
|
+
} else {
|
|
2636
|
+
const snapshotGateResults = await runSnapshotVerificationGates({
|
|
2637
|
+
gates: declaredVerificationGates,
|
|
2638
|
+
unitResults,
|
|
2639
|
+
runDir,
|
|
2640
|
+
evidence,
|
|
2641
|
+
env: options.gateEnv ?? process.env,
|
|
2642
|
+
});
|
|
2643
|
+
await evidence.append({
|
|
2644
|
+
phase: 'snapshot-verify',
|
|
2645
|
+
status: 'completed',
|
|
2646
|
+
gateCount: snapshotGateResults.length,
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2373
2649
|
|
|
2374
2650
|
// Bind the remote source-authority proof to the exact bytes that entered
|
|
2375
2651
|
// the frozen snapshots, not merely to an earlier read of the workspace.
|
|
2376
2652
|
// Then re-read the complete closure and dirty state once more so version
|
|
2377
2653
|
// sources and non-snapshot closure entries cannot drift during prepare.
|
|
2378
|
-
|
|
2654
|
+
// Trimmed together with Step 3c when a docs/config/marketplace workflow
|
|
2655
|
+
// skips the source-authority closure.
|
|
2656
|
+
if (production && !skipSourceAuthorityClosure) {
|
|
2379
2657
|
const snapshotSourceResult = verifySnapshotSourcesMatchClosure({
|
|
2380
2658
|
closure: sourceInputClosure,
|
|
2381
2659
|
unitResults,
|
|
@@ -2463,6 +2741,59 @@ export async function prepareRelease(options) {
|
|
|
2463
2741
|
// --- Step 7: Build plan object ---
|
|
2464
2742
|
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
2465
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
|
+
|
|
2466
2797
|
// New prepares emit planVersion 2 (design: t1-2-digest-decoupling.md
|
|
2467
2798
|
// §4.2/§7). Production freeze timestamps are derived deterministically
|
|
2468
2799
|
// from the baseline headCommit's committer date, before the first frozen
|
|
@@ -2502,59 +2833,72 @@ export async function prepareRelease(options) {
|
|
|
2502
2833
|
// publish will re-verify, rather than the writable pre-freeze staging tree.
|
|
2503
2834
|
// Non-production plans scan the final staging tree at the same point.
|
|
2504
2835
|
// This gate is built in, read-only, and cannot be disabled by overlays.
|
|
2836
|
+
// docs/config/marketplace workflows trim it (H5): the plan omits
|
|
2837
|
+
// `skillResourceClosure` entirely, so publish's conditional re-check
|
|
2838
|
+
// (guarded by plan.skillResourceClosure) and verify's receipt comparison
|
|
2839
|
+
// both skip it consistently. The trim is recorded in workflowDecision.
|
|
2505
2840
|
const skillResourceClosureResults = [];
|
|
2506
|
-
|
|
2841
|
+
if (skipSkillResourceClosure) {
|
|
2507
2842
|
await evidence.append({
|
|
2508
2843
|
phase: 'skill-resource-closure',
|
|
2509
|
-
status: '
|
|
2510
|
-
|
|
2844
|
+
status: 'skipped',
|
|
2845
|
+
reason: `workflow "${workflow}" trims the skill resource closure gate`,
|
|
2846
|
+
unitCount: unitResults.length,
|
|
2511
2847
|
});
|
|
2848
|
+
} else {
|
|
2849
|
+
for (const { unit, manifest } of unitResults) {
|
|
2850
|
+
await evidence.append({
|
|
2851
|
+
phase: 'skill-resource-closure',
|
|
2852
|
+
status: 'started',
|
|
2853
|
+
unitId: unit.id,
|
|
2854
|
+
});
|
|
2512
2855
|
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2856
|
+
const closureResult = await checkSkillResourceClosure({
|
|
2857
|
+
snapshotDir: manifest.outputDir,
|
|
2858
|
+
host: 'root',
|
|
2859
|
+
});
|
|
2517
2860
|
|
|
2518
|
-
|
|
2519
|
-
|
|
2861
|
+
const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
|
|
2862
|
+
skillResourceClosureResults.push(receipt);
|
|
2863
|
+
|
|
2864
|
+
if (closureResult.findings.length > 0) {
|
|
2865
|
+
await evidence.append({
|
|
2866
|
+
phase: 'skill-resource-closure',
|
|
2867
|
+
status: 'blocking',
|
|
2868
|
+
unitId: unit.id,
|
|
2869
|
+
findingCount: closureResult.findings.length,
|
|
2870
|
+
findings: closureResult.findings.map((f) => ({
|
|
2871
|
+
skill: f.skill,
|
|
2872
|
+
line: f.line,
|
|
2873
|
+
reference: f.reference,
|
|
2874
|
+
classification: f.classification,
|
|
2875
|
+
code: f.code,
|
|
2876
|
+
})),
|
|
2877
|
+
});
|
|
2878
|
+
throw new ReleaseError(
|
|
2879
|
+
GATE_FAILED,
|
|
2880
|
+
`skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
|
|
2881
|
+
{
|
|
2882
|
+
unitId: unit.id,
|
|
2883
|
+
findingCount: closureResult.findings.length,
|
|
2884
|
+
findings: closureResult.findings,
|
|
2885
|
+
},
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2520
2888
|
|
|
2521
|
-
if (closureResult.findings.length > 0) {
|
|
2522
2889
|
await evidence.append({
|
|
2523
2890
|
phase: 'skill-resource-closure',
|
|
2524
|
-
status: '
|
|
2891
|
+
status: 'completed',
|
|
2525
2892
|
unitId: unit.id,
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
})),
|
|
2893
|
+
checkerVersion: closureResult.checkerVersion,
|
|
2894
|
+
surfaceCount: receipt.surfaceCount,
|
|
2895
|
+
skillCount: receipt.skillCount,
|
|
2896
|
+
referenceCount: closureResult.referenceCount,
|
|
2897
|
+
sourceOnlyCount: closureResult.sourceOnlyCount,
|
|
2898
|
+
findingCount: 0,
|
|
2899
|
+
receiptDigest: closureResult.receiptDigest,
|
|
2534
2900
|
});
|
|
2535
|
-
throw new ReleaseError(
|
|
2536
|
-
GATE_FAILED,
|
|
2537
|
-
`skill resource closure gate failed for unit "${unit.id}": ${closureResult.findings.length} finding(s)`,
|
|
2538
|
-
{
|
|
2539
|
-
unitId: unit.id,
|
|
2540
|
-
findingCount: closureResult.findings.length,
|
|
2541
|
-
findings: closureResult.findings,
|
|
2542
|
-
},
|
|
2543
|
-
);
|
|
2544
2901
|
}
|
|
2545
|
-
|
|
2546
|
-
await evidence.append({
|
|
2547
|
-
phase: 'skill-resource-closure',
|
|
2548
|
-
status: 'completed',
|
|
2549
|
-
unitId: unit.id,
|
|
2550
|
-
checkerVersion: closureResult.checkerVersion,
|
|
2551
|
-
surfaceCount: receipt.surfaceCount,
|
|
2552
|
-
skillCount: receipt.skillCount,
|
|
2553
|
-
referenceCount: closureResult.referenceCount,
|
|
2554
|
-
sourceOnlyCount: closureResult.sourceOnlyCount,
|
|
2555
|
-
findingCount: 0,
|
|
2556
|
-
receiptDigest: closureResult.receiptDigest,
|
|
2557
|
-
});
|
|
2558
2902
|
}
|
|
2559
2903
|
|
|
2560
2904
|
// Freeze external independent marketplace HEADs (production + online only):
|
|
@@ -2887,20 +3231,114 @@ export async function prepareRelease(options) {
|
|
|
2887
3231
|
// 构建冻结分发映射:unitId -> frozen distributions
|
|
2888
3232
|
const frozenDistributionsMap = new Map(units.map((u) => [u.id, u.distributions]));
|
|
2889
3233
|
|
|
2890
|
-
|
|
3234
|
+
let externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
|
|
2891
3235
|
|
|
2892
3236
|
// Compute overall snapshot digest
|
|
2893
3237
|
const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
|
|
2894
3238
|
|
|
3239
|
+
// --- Step 6b: Workflow decision (H5) ---
|
|
3240
|
+
// config workflow: compare per-unit snapshotDigests with the latest
|
|
3241
|
+
// frozen plan. Identical public bytes → no publish path (all publish-class
|
|
3242
|
+
// external actions are dropped from the plan, so approve/publish execute
|
|
3243
|
+
// nothing). No comparable plan or any byte difference → publish-needed
|
|
3244
|
+
// (fail-safe). The decision is bound into the plan digest as
|
|
3245
|
+
// `workflowDecision`, making the trim immutable and auditable.
|
|
3246
|
+
let workflowDecision = null;
|
|
3247
|
+
if (workflow === 'config') {
|
|
3248
|
+
const previousPlan = await readLatestFrozenPlan(realRoot);
|
|
3249
|
+
let publishPath;
|
|
3250
|
+
let decision;
|
|
3251
|
+
if (!previousPlan) {
|
|
3252
|
+
publishPath = 'publish-needed';
|
|
3253
|
+
decision = 'indeterminable';
|
|
3254
|
+
} else {
|
|
3255
|
+
const prevDigests = new Map(
|
|
3256
|
+
(previousPlan.plan.units ?? []).map((u) => [u.id, u.snapshotDigest]),
|
|
3257
|
+
);
|
|
3258
|
+
const unchanged =
|
|
3259
|
+
units.length > 0 && units.every((u) => prevDigests.get(u.id) === u.snapshotDigest);
|
|
3260
|
+
publishPath = unchanged ? 'no-publish-needed' : 'publish-needed';
|
|
3261
|
+
decision = unchanged ? 'public-bytes-unchanged' : 'public-bytes-changed';
|
|
3262
|
+
}
|
|
3263
|
+
workflowDecision = {
|
|
3264
|
+
workflowKind: workflow,
|
|
3265
|
+
decision,
|
|
3266
|
+
publishPath,
|
|
3267
|
+
trimmedGates: [
|
|
3268
|
+
'declared-hooks',
|
|
3269
|
+
'snapshot-verify-gates',
|
|
3270
|
+
'source-authority-closure',
|
|
3271
|
+
'skill-resource-closure',
|
|
3272
|
+
],
|
|
3273
|
+
...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
|
|
3274
|
+
};
|
|
3275
|
+
if (publishPath === 'no-publish-needed') {
|
|
3276
|
+
externalActions = [];
|
|
3277
|
+
}
|
|
3278
|
+
await evidence.append({
|
|
3279
|
+
phase: 'workflow-decision',
|
|
3280
|
+
status: publishPath,
|
|
3281
|
+
decision,
|
|
3282
|
+
...(previousPlan ? { comparedPlan: previousPlan.fileName } : {}),
|
|
3283
|
+
actionCount: externalActions.length,
|
|
3284
|
+
});
|
|
3285
|
+
} else if (trimmedWorkflow) {
|
|
3286
|
+
workflowDecision = {
|
|
3287
|
+
workflowKind: workflow,
|
|
3288
|
+
decision: 'code-gates-trimmed',
|
|
3289
|
+
publishPath: 'publish-needed',
|
|
3290
|
+
trimmedGates: [
|
|
3291
|
+
'declared-hooks',
|
|
3292
|
+
'snapshot-verify-gates',
|
|
3293
|
+
'source-authority-closure',
|
|
3294
|
+
'skill-resource-closure',
|
|
3295
|
+
],
|
|
3296
|
+
};
|
|
3297
|
+
await evidence.append({
|
|
3298
|
+
phase: 'workflow-decision',
|
|
3299
|
+
status: 'publish-needed',
|
|
3300
|
+
decision: 'code-gates-trimmed',
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
|
|
2895
3304
|
// Detect human consumer platforms (Kimi/CodeBuddy) in the plan.
|
|
2896
3305
|
// When present, new plans mark them as non-blocking manual follow-up tasks.
|
|
2897
3306
|
const hasHumanConsumerActions = externalActions.some(
|
|
2898
3307
|
(a) => a.type === 'kimi-marketplace-install' || a.type === 'codebuddy-marketplace-install',
|
|
2899
3308
|
);
|
|
2900
3309
|
|
|
3310
|
+
// --- Fold the validated postPublish declaration into the frozen plan ---
|
|
3311
|
+
// Bindings: tag (tagTemplate rendered at the resolved target version —
|
|
3312
|
+
// the same computation create-tag will use), tagCommit (the frozen
|
|
3313
|
+
// production asset commit the tag will point at; only production
|
|
3314
|
+
// prepares can freeze it — distribute fails closed when it is absent),
|
|
3315
|
+
// unitId (declaring unit), and payloadSource "tag-worktree" (R1 timing
|
|
3316
|
+
// contract: payload may only come from the detached worktree at
|
|
3317
|
+
// tagCommit, never from workspace state). planVersion 2 record-layer
|
|
3318
|
+
// stripping does not strip this block, so every declaration detail is
|
|
3319
|
+
// bound into the plan digest.
|
|
3320
|
+
let frozenPostPublish = null;
|
|
3321
|
+
if (postPublishDeclaration) {
|
|
3322
|
+
const { unit, index } = postPublishDeclaration;
|
|
3323
|
+
const { tag } = resolveProductionBranch(unit, resolvedVersions[index]);
|
|
3324
|
+
frozenPostPublish = {
|
|
3325
|
+
...structuredClone(unit.postPublish),
|
|
3326
|
+
tag,
|
|
3327
|
+
...(productionAssets ? { tagCommit: productionAssets[index].commit } : {}),
|
|
3328
|
+
unitId: unit.id,
|
|
3329
|
+
payloadSource: PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
|
|
2901
3333
|
const plan = {
|
|
2902
3334
|
planVersion: 2,
|
|
2903
3335
|
status: 'PREPARED',
|
|
3336
|
+
// Workflow profile (H5): 'full' for the complete gate set;
|
|
3337
|
+
// 'docs'/'config'/'marketplace' for trimmed code-class gates. Both
|
|
3338
|
+
// fields are binding-layer (not stripped from the plan digest), so the
|
|
3339
|
+
// trim is frozen immutably with the plan.
|
|
3340
|
+
workflowKind: workflow,
|
|
3341
|
+
...(workflowDecision ? { workflowDecision } : {}),
|
|
2904
3342
|
baseline: {
|
|
2905
3343
|
gitTreeHash: baseline.gitTreeHash,
|
|
2906
3344
|
headCommit: baseline.gitHead,
|
|
@@ -2910,14 +3348,18 @@ export async function prepareRelease(options) {
|
|
|
2910
3348
|
capturedAt: baseline.capturedAt,
|
|
2911
3349
|
},
|
|
2912
3350
|
configDigest,
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
3351
|
+
...(skillResourceClosureResults.length > 0
|
|
3352
|
+
? {
|
|
3353
|
+
skillResourceClosure: {
|
|
3354
|
+
checkerVersion: SKILL_RESOURCE_CHECKER_VERSION,
|
|
3355
|
+
unitReceipts: skillResourceClosureResults,
|
|
3356
|
+
totalSkillCount: skillResourceClosureResults.reduce((sum, item) => sum + item.skillCount, 0),
|
|
3357
|
+
totalReferenceCount: skillResourceClosureResults.reduce((sum, item) => sum + item.referenceCount, 0),
|
|
3358
|
+
totalSourceOnlyCount: skillResourceClosureResults.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
|
|
3359
|
+
totalFindingCount: 0,
|
|
3360
|
+
},
|
|
3361
|
+
}
|
|
3362
|
+
: {}),
|
|
2921
3363
|
verificationGates: config.verificationGates ?? [],
|
|
2922
3364
|
snapshotDigest: overallSnapshotDigest,
|
|
2923
3365
|
...(hasHumanConsumerActions ? { humanConsumersStrategy: 'manualFollowUps' } : {}),
|
|
@@ -2929,6 +3371,7 @@ export async function prepareRelease(options) {
|
|
|
2929
3371
|
} : {}),
|
|
2930
3372
|
units,
|
|
2931
3373
|
externalActions,
|
|
3374
|
+
...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
|
|
2932
3375
|
...(sourceAuthority ? { sourceAuthority } : {}),
|
|
2933
3376
|
createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
|
|
2934
3377
|
};
|
|
@@ -2958,6 +3401,26 @@ export async function prepareRelease(options) {
|
|
|
2958
3401
|
planDigest,
|
|
2959
3402
|
});
|
|
2960
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
|
+
|
|
2961
3424
|
// --- Write summary ---
|
|
2962
3425
|
await evidence.finish({
|
|
2963
3426
|
status: 'PREPARED',
|
|
@@ -2968,6 +3431,8 @@ export async function prepareRelease(options) {
|
|
|
2968
3431
|
unitCount: units.length,
|
|
2969
3432
|
actionCount: externalActions.length,
|
|
2970
3433
|
offline,
|
|
3434
|
+
workflowKind: workflow,
|
|
3435
|
+
...(workflowDecision ? { workflowDecision } : {}),
|
|
2971
3436
|
completedAt: (clock ? clock() : new Date().toISOString()),
|
|
2972
3437
|
});
|
|
2973
3438
|
|