release-skill 0.1.4 → 0.1.6
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/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +104 -0
- package/INSTALL.md +81 -1
- package/INSTALL.zh-CN.md +69 -1
- package/README.md +233 -8
- package/README.zh-CN.md +188 -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 +14164 -9912
- package/adapters/claude/bin/release-skill.mjs +24 -4
- package/adapters/claude/native/safe-write/binding.gyp +2 -1
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -10
- package/adapters/claude/schemas/release-project.schema.json +141 -0
- package/adapters/claude/skills/release-help/SKILL.md +21 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
- package/adapters/claude/skills/release-publish/SKILL.md +3 -1
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
- package/adapters/codex/bin/release-skill.mjs +24 -4
- package/adapters/codex/native/safe-write/binding.gyp +2 -1
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -10
- package/adapters/codex/schemas/release-project.schema.json +141 -0
- package/adapters/codex/skills/release-help/SKILL.md +21 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
- package/adapters/codex/skills/release-publish/SKILL.md +3 -1
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/bin/release-skill-cli.mjs +163 -4
- package/bin/release-skill.bundle.mjs +14164 -9912
- package/bin/release-skill.mjs +24 -4
- package/native/safe-write/binding.gyp +2 -1
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +1 -1
- package/package.json +2 -2
- package/references/.render-manifest.json +4 -4
- package/references/02-project-config.md +24 -0
- package/references/05-evidence-and-errors.md +5 -0
- package/schemas/.render-manifest.json +10 -10
- package/schemas/release-project.schema.json +141 -0
- package/scripts/build-bundle.mjs +15 -2
- package/skills/release-help/SKILL.md +21 -0
- package/skills/release-prepare/SKILL.md +17 -6
- package/skills/release-publish/SKILL.md +3 -1
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-help/SKILL.md +21 -0
- package/skills-src/release-prepare/SKILL.md +17 -6
- package/skills-src/release-publish/SKILL.md +3 -1
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/src/adapters/plugin-marketplace.mjs +70 -3
- package/src/artifacts/transaction-journal.mjs +1126 -105
- package/src/artifacts/transaction.mjs +313 -130
- package/src/commands/docs.mjs +332 -0
- package/src/commands/prepare.mjs +324 -17
- package/src/commands/reconcile.mjs +4 -1
- package/src/commands/verify.mjs +4 -1
- package/src/core/errors.mjs +64 -2
- package/src/core/plan.mjs +59 -1
- package/src/core/redact.mjs +206 -0
- package/src/docs/changelog-renderer.mjs +853 -0
- package/src/docs/config.mjs +337 -0
- package/src/docs/notes-loader.mjs +432 -0
- package/src/docs/notes.mjs +553 -0
- package/src/docs/readme-renderer.mjs +647 -0
- package/src/docs/refresh-planner.mjs +542 -0
- package/src/docs/refresh-service.mjs +675 -0
package/src/commands/prepare.mjs
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs the full prepare pipeline in order:
|
|
5
5
|
* 1. Load and validate project configuration
|
|
6
|
+
* 1b. Resolve authoritative versions and gate release-document freshness
|
|
7
|
+
* (read-only; blocks stale docs before hooks, baseline, snapshots,
|
|
8
|
+
* remote checks, and plan write; re-checked after hooks)
|
|
6
9
|
* 2. Capture Git baseline (HEAD, tree hash, dirty files)
|
|
7
10
|
* 3. Run project-declared hooks (build, test)
|
|
8
11
|
* 4. For each release unit: build snapshot, scan for leakage, evaluate README
|
|
@@ -40,7 +43,7 @@ import {
|
|
|
40
43
|
normalizeGitTimestamp,
|
|
41
44
|
sealFrozenSnapshot,
|
|
42
45
|
} from '../snapshot/frozen.mjs';
|
|
43
|
-
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
|
|
46
|
+
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE } from '../core/errors.mjs';
|
|
44
47
|
import { acquireProjectLock } from '../artifacts/project-lock.mjs';
|
|
45
48
|
import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
|
|
46
49
|
import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
|
|
@@ -54,19 +57,21 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
|
|
|
54
57
|
* Resolve the target version for a release unit.
|
|
55
58
|
*
|
|
56
59
|
* Resolution rules:
|
|
57
|
-
* 1.
|
|
58
|
-
*
|
|
60
|
+
* 1. The version is read AUTHORITATIVELY from
|
|
61
|
+
* `<root>/<unit.source>/<unit.version.source>`; it is never overridden.
|
|
62
|
+
* 2. An explicitVersion (when provided) is only a consistency ASSERTION:
|
|
63
|
+
* a mismatch fails closed with GATE_FAILED.
|
|
59
64
|
* 3. Reject: absolute path, path escape, missing file, invalid JSON,
|
|
60
65
|
* missing/empty version field.
|
|
61
66
|
* 4. For v0.1: if multiple units resolve to different versions, fail closed.
|
|
62
67
|
*
|
|
63
68
|
* @param {object} unit - The release unit configuration.
|
|
64
69
|
* @param {string} root - Absolute project root.
|
|
65
|
-
* @param {string} [explicitVersion] - Explicit version
|
|
66
|
-
* @returns {Promise<string>} The resolved version string.
|
|
70
|
+
* @param {string} [explicitVersion] - Explicit version consistency assertion.
|
|
71
|
+
* @returns {Promise<string>} The resolved authoritative version string.
|
|
67
72
|
* @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
|
|
68
73
|
*/
|
|
69
|
-
async function resolveUnitVersion(unit, root, explicitVersion) {
|
|
74
|
+
export async function resolveUnitVersion(unit, root, explicitVersion) {
|
|
70
75
|
// Validate unit.version.source exists
|
|
71
76
|
const versionSource = unit.version?.source;
|
|
72
77
|
if (!versionSource || typeof versionSource !== 'string') {
|
|
@@ -157,7 +162,7 @@ async function resolveUnitVersion(unit, root, explicitVersion) {
|
|
|
157
162
|
* @returns {Promise<string[]>} Array of resolved versions (one per unit).
|
|
158
163
|
* @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
|
|
159
164
|
*/
|
|
160
|
-
async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
|
|
165
|
+
export async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
|
|
161
166
|
await evidence.append({ phase: 'version-resolution', status: 'started' });
|
|
162
167
|
|
|
163
168
|
const resolvedVersions = [];
|
|
@@ -198,10 +203,12 @@ async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
|
|
|
198
203
|
* @param {object} config - The loaded project config.
|
|
199
204
|
* @param {string} root - Absolute project root.
|
|
200
205
|
* @param {object} evidence - The evidence writer.
|
|
206
|
+
* @param {Function} [hookFn] - Hook runner (default runHook); tests inject a
|
|
207
|
+
* spy that records call order while delegating to the real implementation.
|
|
201
208
|
* @returns {Promise<void>}
|
|
202
209
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code.
|
|
203
210
|
*/
|
|
204
|
-
async function runDeclaredHooks(config, root, evidence) {
|
|
211
|
+
async function runDeclaredHooks(config, root, evidence, hookFn = runHook) {
|
|
205
212
|
const hookOrder = ['docs', 'build', 'test', 'typecheck'];
|
|
206
213
|
const hooks = config.hooks ?? {};
|
|
207
214
|
|
|
@@ -217,7 +224,7 @@ async function runDeclaredHooks(config, root, evidence) {
|
|
|
217
224
|
|
|
218
225
|
let result;
|
|
219
226
|
try {
|
|
220
|
-
result = await
|
|
227
|
+
result = await hookFn(hook, { root });
|
|
221
228
|
} catch (err) {
|
|
222
229
|
await evidence.append({
|
|
223
230
|
phase: 'hooks',
|
|
@@ -260,6 +267,222 @@ async function runDeclaredHooks(config, root, evidence) {
|
|
|
260
267
|
}
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Release-documents freshness gate
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the release-documents planner the freshness gate runs with.
|
|
276
|
+
*
|
|
277
|
+
* An injected planner (test spy or documented bypass) is returned unchanged.
|
|
278
|
+
* Otherwise the default planner is loaded LAZILY from the refresh service:
|
|
279
|
+
* src/docs/** joins the published package snapshot at the public-asset
|
|
280
|
+
* generation stage, so prepare.mjs must not carry a static import of it —
|
|
281
|
+
* every staged runtime file must stay importable from the minimal public
|
|
282
|
+
* distribution. The load happens only when a release unit actually
|
|
283
|
+
* configures releaseDocuments, and an unavailable module fails closed —
|
|
284
|
+
* the freshness gate is never silently skipped.
|
|
285
|
+
*
|
|
286
|
+
* @param {Function} [injected] - Injected planner (takes precedence).
|
|
287
|
+
* @returns {Promise<Function>} The planner to use.
|
|
288
|
+
* @throws {ReleaseError} GATE_FAILED when the default planner is unavailable.
|
|
289
|
+
*/
|
|
290
|
+
async function resolveReleaseDocsPlanFn(injected) {
|
|
291
|
+
if (typeof injected === 'function') return injected;
|
|
292
|
+
let loaded;
|
|
293
|
+
try {
|
|
294
|
+
loaded = await import('../docs/refresh-service.mjs');
|
|
295
|
+
} catch (err) {
|
|
296
|
+
throw new ReleaseError(
|
|
297
|
+
GATE_FAILED,
|
|
298
|
+
'the release-documents refresh planner is unavailable; the freshness gate cannot run',
|
|
299
|
+
{ reason: 'RELEASE_DOCS_PLAN_UNAVAILABLE', cause: err?.code ?? 'UNKNOWN' },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (typeof loaded.planReleaseDocsRefreshForUnit !== 'function') {
|
|
303
|
+
throw new ReleaseError(
|
|
304
|
+
GATE_FAILED,
|
|
305
|
+
'the release-documents refresh planner is unavailable; the freshness gate cannot run',
|
|
306
|
+
{ reason: 'RELEASE_DOCS_PLAN_UNAVAILABLE' },
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return loaded.planReleaseDocsRefreshForUnit;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Read-only release-documents freshness gate
|
|
314
|
+
* (2026-07-21-release-docs-command-and-prepare-gate §5).
|
|
315
|
+
*
|
|
316
|
+
* Runs the SAME read-only refresh planner the standalone `docs refresh`
|
|
317
|
+
* dry-run uses, for every release unit that configures `releaseDocuments`:
|
|
318
|
+
*
|
|
319
|
+
* - no unit configures releaseDocuments → no check runs and no
|
|
320
|
+
* docs-freshness evidence is appended (legacy behaviour preserved);
|
|
321
|
+
* - a clean unit appends a `completed` docs-freshness event carrying its
|
|
322
|
+
* unitId and refreshDigest;
|
|
323
|
+
* - the first `changes` result appends a `blocking` event and throws
|
|
324
|
+
* RELEASE_DOCS_STALE with the exact dry-run/write argv the operator needs
|
|
325
|
+
* to refresh (canonical relative paths only — never bodies, absolute
|
|
326
|
+
* paths, or serialized bytes).
|
|
327
|
+
*
|
|
328
|
+
* When `expectedBindings` is supplied (the post-hook pass), a plan that
|
|
329
|
+
* re-renders clean is STILL compared against the pre-hook binding: hooks
|
|
330
|
+
* run as arbitrary local processes and may rewrite bytes the renderers
|
|
331
|
+
* deliberately preserve (for example text outside the managed regions).
|
|
332
|
+
* Any divergence from the validated binding — the refreshDigest or any
|
|
333
|
+
* per-target old digest — fails closed with RELEASE_DOCS_STALE so an
|
|
334
|
+
* inconsistent plan can never be frozen.
|
|
335
|
+
*
|
|
336
|
+
* The gate is strictly read-only: prepare never writes README/CHANGELOG
|
|
337
|
+
* implicitly. Refreshed bytes enter the baseline/workspace/snapshot/plan
|
|
338
|
+
* digests through the standalone docs command, which naturally invalidates
|
|
339
|
+
* approvals bound to the pre-refresh plan digest.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} options
|
|
342
|
+
* @param {object[]} options.units - Release units from the loaded config.
|
|
343
|
+
* @param {string[]} options.resolvedVersions - Authoritative versions,
|
|
344
|
+
* index-aligned with `units` (resolved once before hooks, reused after).
|
|
345
|
+
* @param {string} options.root - Absolute project root.
|
|
346
|
+
* @param {object} options.config - The loaded project config.
|
|
347
|
+
* @param {object} options.evidence - The evidence writer.
|
|
348
|
+
* @param {Function} [options.planFn] - Read-only planner; tests inject spies
|
|
349
|
+
* or documented bypasses. When omitted, the shared
|
|
350
|
+
* planReleaseDocsRefreshForUnit is loaded lazily (see
|
|
351
|
+
* resolveReleaseDocsPlanFn — keeps the public boundary import-clean and
|
|
352
|
+
* fails closed when unavailable).
|
|
353
|
+
* @param {string} options.reasonTag - Gate-pass identifier bound into the
|
|
354
|
+
* evidence and error details ('RELEASE_DOCS_STALE' before hooks,
|
|
355
|
+
* 'CHANGES_AFTER_HOOKS' after hooks).
|
|
356
|
+
* @param {Map<string, { refreshDigest: string, files: Map<string, string> }>}
|
|
357
|
+
* [options.expectedBindings] - Pre-hook bindings (unitId → refreshDigest +
|
|
358
|
+
* canonical target path → old digest) the post-hook pass fails closed
|
|
359
|
+
* against. Omitted on the pre-hook pass.
|
|
360
|
+
* @returns {Promise<Map<string, { refreshDigest: string, files: Map<string, string> }>>}
|
|
361
|
+
* The bindings observed on this pass (empty when no unit configures
|
|
362
|
+
* releaseDocuments); the pre-hook pass result feeds the post-hook pass.
|
|
363
|
+
* @throws {ReleaseError} RELEASE_DOCS_STALE when any configured unit is stale
|
|
364
|
+
* or drifted from the expected binding.
|
|
365
|
+
*/
|
|
366
|
+
async function runReleaseDocsFreshnessGate({
|
|
367
|
+
units,
|
|
368
|
+
resolvedVersions,
|
|
369
|
+
root,
|
|
370
|
+
config,
|
|
371
|
+
evidence,
|
|
372
|
+
planFn,
|
|
373
|
+
reasonTag,
|
|
374
|
+
expectedBindings = null,
|
|
375
|
+
}) {
|
|
376
|
+
const configured = [];
|
|
377
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
378
|
+
const unit = units[index];
|
|
379
|
+
if (unit && unit.releaseDocuments !== undefined && unit.releaseDocuments !== null) {
|
|
380
|
+
configured.push({ unit, index });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const bindings = new Map();
|
|
384
|
+
if (configured.length === 0) return bindings;
|
|
385
|
+
|
|
386
|
+
const effectivePlanFn = await resolveReleaseDocsPlanFn(planFn);
|
|
387
|
+
|
|
388
|
+
await evidence.append({ phase: 'docs-freshness', status: 'started', reasonTag });
|
|
389
|
+
|
|
390
|
+
for (const { unit, index } of configured) {
|
|
391
|
+
const { display } = await effectivePlanFn({
|
|
392
|
+
root,
|
|
393
|
+
config,
|
|
394
|
+
unit,
|
|
395
|
+
version: resolvedVersions[index],
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
bindings.set(unit.id, {
|
|
399
|
+
refreshDigest: display.refreshDigest,
|
|
400
|
+
files: new Map(display.files.map((file) => [file.path, file.oldDigest])),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
if (display.status === 'clean') {
|
|
404
|
+
// Bound-change detection (post-hook pass): a hook may rewrite bytes
|
|
405
|
+
// the renderers preserve, which still re-plan clean. Compare against
|
|
406
|
+
// the pre-hook binding and fail closed on ANY divergence.
|
|
407
|
+
const expected = expectedBindings?.get(unit.id);
|
|
408
|
+
const driftedFiles = expected
|
|
409
|
+
? display.files.filter((file) => expected.files.get(file.path) !== file.oldDigest)
|
|
410
|
+
: [];
|
|
411
|
+
if (expected && (display.refreshDigest !== expected.refreshDigest || driftedFiles.length > 0)) {
|
|
412
|
+
await evidence.append({
|
|
413
|
+
phase: 'docs-freshness',
|
|
414
|
+
status: 'blocking',
|
|
415
|
+
unitId: unit.id,
|
|
416
|
+
reason: reasonTag,
|
|
417
|
+
refreshDigest: display.refreshDigest,
|
|
418
|
+
changedPaths: driftedFiles.map((file) => file.path),
|
|
419
|
+
});
|
|
420
|
+
throw new ReleaseError(
|
|
421
|
+
RELEASE_DOCS_STALE,
|
|
422
|
+
`release documents for unit "${unit.id}" changed after hooks`,
|
|
423
|
+
{
|
|
424
|
+
reason: reasonTag,
|
|
425
|
+
unitId: unit.id,
|
|
426
|
+
version: resolvedVersions[index],
|
|
427
|
+
refreshDigest: display.refreshDigest,
|
|
428
|
+
changedPaths: driftedFiles.map((file) => file.path),
|
|
429
|
+
files: driftedFiles.map(({ path, kind, locale, change, oldDigest, newDigest }) => ({
|
|
430
|
+
path,
|
|
431
|
+
kind,
|
|
432
|
+
locale,
|
|
433
|
+
change,
|
|
434
|
+
oldDigest,
|
|
435
|
+
newDigest,
|
|
436
|
+
})),
|
|
437
|
+
dryRunArgv: [...display.nextCommand.argv],
|
|
438
|
+
writeArgv: display.nextCommand.writeArgv ? [...display.nextCommand.writeArgv] : null,
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
await evidence.append({
|
|
443
|
+
phase: 'docs-freshness',
|
|
444
|
+
status: 'completed',
|
|
445
|
+
unitId: unit.id,
|
|
446
|
+
refreshDigest: display.refreshDigest,
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const changedFiles = display.files.filter((file) => file.changed);
|
|
452
|
+
await evidence.append({
|
|
453
|
+
phase: 'docs-freshness',
|
|
454
|
+
status: 'blocking',
|
|
455
|
+
unitId: unit.id,
|
|
456
|
+
reason: reasonTag,
|
|
457
|
+
refreshDigest: display.refreshDigest,
|
|
458
|
+
changedPaths: changedFiles.map((file) => file.path),
|
|
459
|
+
});
|
|
460
|
+
throw new ReleaseError(
|
|
461
|
+
RELEASE_DOCS_STALE,
|
|
462
|
+
`release documents are stale for unit "${unit.id}"`,
|
|
463
|
+
{
|
|
464
|
+
reason: reasonTag,
|
|
465
|
+
unitId: unit.id,
|
|
466
|
+
version: resolvedVersions[index],
|
|
467
|
+
refreshDigest: display.refreshDigest,
|
|
468
|
+
changedPaths: changedFiles.map((file) => file.path),
|
|
469
|
+
files: changedFiles.map(({ path, kind, locale, change, oldDigest, newDigest }) => ({
|
|
470
|
+
path,
|
|
471
|
+
kind,
|
|
472
|
+
locale,
|
|
473
|
+
change,
|
|
474
|
+
oldDigest,
|
|
475
|
+
newDigest,
|
|
476
|
+
})),
|
|
477
|
+
dryRunArgv: [...display.nextCommand.argv],
|
|
478
|
+
writeArgv: [...display.nextCommand.writeArgv],
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return bindings;
|
|
484
|
+
}
|
|
485
|
+
|
|
263
486
|
// ---------------------------------------------------------------------------
|
|
264
487
|
// Snapshot pipeline
|
|
265
488
|
// ---------------------------------------------------------------------------
|
|
@@ -690,6 +913,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
690
913
|
const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
|
|
691
914
|
if (claudeDist) {
|
|
692
915
|
const identity = marketplaceIdentity(claudeDist);
|
|
916
|
+
const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
|
|
693
917
|
actions.push({
|
|
694
918
|
id: `claude-marketplace-install-${unit.id}`,
|
|
695
919
|
type: 'claude-marketplace-install',
|
|
@@ -702,6 +926,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
702
926
|
repo: unit.publicRepo,
|
|
703
927
|
version,
|
|
704
928
|
entrySkill: identity.entrySkill,
|
|
929
|
+
timeoutMs: claudeTimeoutMs,
|
|
705
930
|
},
|
|
706
931
|
expected: {
|
|
707
932
|
installed: true,
|
|
@@ -716,6 +941,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
716
941
|
const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
|
|
717
942
|
if (codexDist) {
|
|
718
943
|
const identity = marketplaceIdentity(codexDist);
|
|
944
|
+
const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
|
|
719
945
|
actions.push({
|
|
720
946
|
id: `codex-marketplace-install-${unit.id}`,
|
|
721
947
|
type: 'codex-marketplace-install',
|
|
@@ -728,6 +954,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
728
954
|
repo: unit.publicRepo,
|
|
729
955
|
version,
|
|
730
956
|
entrySkill: identity.entrySkill,
|
|
957
|
+
timeoutMs: codexTimeoutMs,
|
|
731
958
|
},
|
|
732
959
|
expected: {
|
|
733
960
|
installed: true,
|
|
@@ -884,6 +1111,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
884
1111
|
const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
|
|
885
1112
|
if (claudeDist) {
|
|
886
1113
|
const identity = marketplaceIdentity(claudeDist);
|
|
1114
|
+
const claudeTimeoutMs = Number.isInteger(claudeDist.timeoutMs) ? claudeDist.timeoutMs : 300000;
|
|
887
1115
|
actions.push({
|
|
888
1116
|
id: `claude-marketplace-install-${unit.id}`,
|
|
889
1117
|
type: 'claude-marketplace-install',
|
|
@@ -899,6 +1127,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
899
1127
|
entrySkill: identity.entrySkill,
|
|
900
1128
|
snapshotPath: asset.snapshotPath,
|
|
901
1129
|
manifestDigest: asset.manifestDigest,
|
|
1130
|
+
timeoutMs: claudeTimeoutMs,
|
|
902
1131
|
},
|
|
903
1132
|
expected: {
|
|
904
1133
|
installed: true,
|
|
@@ -918,6 +1147,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
918
1147
|
const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
|
|
919
1148
|
if (codexDist) {
|
|
920
1149
|
const identity = marketplaceIdentity(codexDist);
|
|
1150
|
+
const codexTimeoutMs = Number.isInteger(codexDist.timeoutMs) ? codexDist.timeoutMs : 300000;
|
|
921
1151
|
actions.push({
|
|
922
1152
|
id: `codex-marketplace-install-${unit.id}`,
|
|
923
1153
|
type: 'codex-marketplace-install',
|
|
@@ -933,6 +1163,7 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
933
1163
|
entrySkill: identity.entrySkill,
|
|
934
1164
|
snapshotPath: asset.snapshotPath,
|
|
935
1165
|
manifestDigest: asset.manifestDigest,
|
|
1166
|
+
timeoutMs: codexTimeoutMs,
|
|
936
1167
|
},
|
|
937
1168
|
expected: {
|
|
938
1169
|
installed: true,
|
|
@@ -977,6 +1208,13 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
977
1208
|
* means the user accepts hook side-effect risks, not that hooks are safe.
|
|
978
1209
|
* @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
|
|
979
1210
|
* true when project verification gates are declared.
|
|
1211
|
+
* @param {Function} [options.releaseDocsPlanFn] - Read-only release-documents
|
|
1212
|
+
* planner used by the freshness gate (default
|
|
1213
|
+
* planReleaseDocsRefreshForUnit); tests inject spies or documented
|
|
1214
|
+
* bypasses. Prepare itself never writes README/CHANGELOG.
|
|
1215
|
+
* @param {Function} [options.runHookFn] - Hook runner passed to
|
|
1216
|
+
* runDeclaredHooks (default runHook); tests inject a spy that records call
|
|
1217
|
+
* order while delegating to the real implementation.
|
|
980
1218
|
*
|
|
981
1219
|
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
|
|
982
1220
|
*
|
|
@@ -1066,6 +1304,32 @@ export async function prepareRelease(options) {
|
|
|
1066
1304
|
configDigest,
|
|
1067
1305
|
});
|
|
1068
1306
|
|
|
1307
|
+
// --- Step 1b: Resolve authoritative versions and gate release-document
|
|
1308
|
+
// freshness BEFORE hook authorization ---
|
|
1309
|
+
// Authoritative versions resolve exactly once here and are reused by
|
|
1310
|
+
// every downstream consumer, so a hook can never silently switch the
|
|
1311
|
+
// version a plan binds. Units that configure releaseDocuments must be
|
|
1312
|
+
// clean under the read-only refresh planner before any hook, verification
|
|
1313
|
+
// gate, baseline, snapshot, remote check, or plan write runs. Units
|
|
1314
|
+
// without releaseDocuments keep the exact legacy behaviour (the gate
|
|
1315
|
+
// appends no evidence and performs no check).
|
|
1316
|
+
const configUnits = config.releaseUnits ?? [];
|
|
1317
|
+
const resolvedVersions = await resolveAllUnitVersions(
|
|
1318
|
+
configUnits,
|
|
1319
|
+
realRoot,
|
|
1320
|
+
version,
|
|
1321
|
+
evidence,
|
|
1322
|
+
);
|
|
1323
|
+
const preHookDocsBindings = await runReleaseDocsFreshnessGate({
|
|
1324
|
+
units: configUnits,
|
|
1325
|
+
resolvedVersions,
|
|
1326
|
+
root: realRoot,
|
|
1327
|
+
config,
|
|
1328
|
+
evidence,
|
|
1329
|
+
planFn: options.releaseDocsPlanFn,
|
|
1330
|
+
reasonTag: 'RELEASE_DOCS_STALE',
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1069
1333
|
// --- Step 2: Hook authorization gate ---
|
|
1070
1334
|
// Hooks are user-configured arbitrary local processes without filesystem
|
|
1071
1335
|
// or network isolation. They may write outside the project, access local
|
|
@@ -1157,9 +1421,55 @@ export async function prepareRelease(options) {
|
|
|
1157
1421
|
|
|
1158
1422
|
// --- Step 3: Run declared hooks ---
|
|
1159
1423
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1160
|
-
await runDeclaredHooks(config, realRoot, evidence);
|
|
1424
|
+
await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook);
|
|
1161
1425
|
await evidence.append({ phase: 'hooks', status: 'completed' });
|
|
1162
1426
|
|
|
1427
|
+
// --- Step 3b: Re-check release-document freshness AFTER hooks ---
|
|
1428
|
+
// Declared hooks run as arbitrary local processes; they may rewrite a
|
|
1429
|
+
// release unit's authoritative version source or any declared release
|
|
1430
|
+
// document. Re-bind the pre-hook authoritative versions and re-run the
|
|
1431
|
+
// same read-only planner gate; any drift or new change fails closed
|
|
1432
|
+
// BEFORE the baseline, snapshots, remote checks, and plan write, so an
|
|
1433
|
+
// inconsistent plan can never be frozen. Skipped entirely when no unit
|
|
1434
|
+
// configures releaseDocuments (legacy behaviour preserved).
|
|
1435
|
+
if (configUnits.some((unit) => unit && unit.releaseDocuments)) {
|
|
1436
|
+
for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
|
|
1437
|
+
const postHookVersion = await resolveUnitVersion(
|
|
1438
|
+
configUnits[unitIndex],
|
|
1439
|
+
realRoot,
|
|
1440
|
+
version,
|
|
1441
|
+
);
|
|
1442
|
+
if (postHookVersion !== resolvedVersions[unitIndex]) {
|
|
1443
|
+
await evidence.append({
|
|
1444
|
+
phase: 'docs-freshness',
|
|
1445
|
+
status: 'blocking',
|
|
1446
|
+
unitId: configUnits[unitIndex].id,
|
|
1447
|
+
reason: 'VERSION_DRIFT_AFTER_HOOKS',
|
|
1448
|
+
});
|
|
1449
|
+
throw new ReleaseError(
|
|
1450
|
+
RELEASE_DOCS_STALE,
|
|
1451
|
+
`release unit "${configUnits[unitIndex].id}" authoritative version changed after hooks`,
|
|
1452
|
+
{
|
|
1453
|
+
reason: 'VERSION_DRIFT_AFTER_HOOKS',
|
|
1454
|
+
unitId: configUnits[unitIndex].id,
|
|
1455
|
+
version: resolvedVersions[unitIndex],
|
|
1456
|
+
currentVersion: postHookVersion,
|
|
1457
|
+
},
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
await runReleaseDocsFreshnessGate({
|
|
1462
|
+
units: configUnits,
|
|
1463
|
+
resolvedVersions,
|
|
1464
|
+
root: realRoot,
|
|
1465
|
+
config,
|
|
1466
|
+
evidence,
|
|
1467
|
+
planFn: options.releaseDocsPlanFn,
|
|
1468
|
+
reasonTag: 'CHANGES_AFTER_HOOKS',
|
|
1469
|
+
expectedBindings: preHookDocsBindings,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1163
1473
|
// --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
|
|
1164
1474
|
// reflects any file changes introduced by hooks) ---
|
|
1165
1475
|
await evidence.append({ phase: 'baseline', status: 'started' });
|
|
@@ -1175,13 +1485,10 @@ export async function prepareRelease(options) {
|
|
|
1175
1485
|
});
|
|
1176
1486
|
|
|
1177
1487
|
// --- Step 4b: Per-unit previous public baseline observe ---
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
version,
|
|
1183
|
-
evidence,
|
|
1184
|
-
);
|
|
1488
|
+
// `configUnits` and `resolvedVersions` were resolved once in Step 1b
|
|
1489
|
+
// (before hook authorization and the docs freshness gate) and are reused
|
|
1490
|
+
// here verbatim, so the frozen plan binds exactly the versions the
|
|
1491
|
+
// pre-hook gate validated.
|
|
1185
1492
|
const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
|
|
1186
1493
|
try {
|
|
1187
1494
|
const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
|
|
@@ -222,7 +222,10 @@ export async function reconcileRelease(options) {
|
|
|
222
222
|
// =======================================================================
|
|
223
223
|
await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'started' });
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
// Use legacyCompatibility: old PARTIAL plans (pre-v0.1.5) lack
|
|
226
|
+
// parameters.timeoutMs. Reconcile must still pass these plans,
|
|
227
|
+
// while strict mode (prepare/approve/publish) rejects them.
|
|
228
|
+
const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
|
|
226
229
|
if (!completenessResult.passed) {
|
|
227
230
|
await evidence.append({
|
|
228
231
|
phase: 'safety-gate',
|
package/src/commands/verify.mjs
CHANGED
|
@@ -669,7 +669,10 @@ export async function verifyRelease(options) {
|
|
|
669
669
|
}
|
|
670
670
|
|
|
671
671
|
// Validate plan action completeness before checkpoint mapping
|
|
672
|
-
|
|
672
|
+
// Use legacyCompatibility: old plans (pre-v0.1.5) lack
|
|
673
|
+
// parameters.timeoutMs. Verify must still pass these plans,
|
|
674
|
+
// while strict mode (prepare/approve/publish) rejects them.
|
|
675
|
+
const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
|
|
673
676
|
if (!completenessResult.passed) {
|
|
674
677
|
throw new ReleaseError(
|
|
675
678
|
GATE_FAILED,
|
package/src/core/errors.mjs
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
// Stable error codes and exit codes for the release-skill system.
|
|
2
2
|
// Error codes are grouped by phase; each maps to a unique stable exit code.
|
|
3
3
|
|
|
4
|
+
// Defect #3 choke-point dependency: the deep redaction authority lives in
|
|
5
|
+
// core/redact.mjs (pure, zero-dependency). It must load WITHOUT any top-level
|
|
6
|
+
// await: a TLA here made esbuild turn this module's bundled init block async,
|
|
7
|
+
// and the artifacts tree/entry import cycle then deadlocked during bundled
|
|
8
|
+
// command initialization, so `await import(bundlePath)` in
|
|
9
|
+
// bin/release-skill.mjs never settled and Node exited with code 13
|
|
10
|
+
// "Detected unsettled top-level await" instead of the real business exit code
|
|
11
|
+
// (AC-7). Two TLA-free mechanisms install the redactor instead:
|
|
12
|
+
//
|
|
13
|
+
// 1. Eager registration: real entries (bin/release-skill-cli.mjs, therefore
|
|
14
|
+
// also the self-contained bundle and the Claude/Codex adapters built from
|
|
15
|
+
// it) import errors.mjs and redact.mjs statically and call
|
|
16
|
+
// registerPathRedactor() synchronously before any command code runs —
|
|
17
|
+
// deterministic in source and bundled form, no microtask window.
|
|
18
|
+
// 2. Self-load fallback: the fire-and-forget dynamic import below covers
|
|
19
|
+
// consumers that import errors.mjs alone (unit tests and the
|
|
20
|
+
// artifacts-safe-fs production-loader subprocess fixtures). It is
|
|
21
|
+
// deliberately NOT awaited: this module stays synchronously loadable (no
|
|
22
|
+
// TLA in the module graph), and a missing redact.mjs (isolated fixtures
|
|
23
|
+
// that copy errors.mjs alone) simply degrades to the identity function via
|
|
24
|
+
// the rejection handler. In every real deployment redact.mjs ships
|
|
25
|
+
// alongside errors.mjs (enforced by error-path-redaction.test.mjs and the
|
|
26
|
+
// bundle/adapters --check gates), so the constructor below always redacts
|
|
27
|
+
// in production.
|
|
28
|
+
let redactSensitivePaths = (value) => value;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Install the path-redaction authority used by the ReleaseError choke point.
|
|
32
|
+
* Idempotent; non-function arguments are ignored so a bad caller can never
|
|
33
|
+
* disable redaction. Called eagerly by CLI entries (static import, before any
|
|
34
|
+
* command runs) and by the self-load fallback below.
|
|
35
|
+
*
|
|
36
|
+
* @param {(value: unknown) => unknown} fn deep redaction from core/redact.mjs.
|
|
37
|
+
*/
|
|
38
|
+
export function registerPathRedactor(fn) {
|
|
39
|
+
if (typeof fn === 'function') redactSensitivePaths = fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fire-and-forget self-load (never awaited → no top-level await). The
|
|
43
|
+
// rejection handler is attached synchronously in the same tick, so an absent
|
|
44
|
+
// redact.mjs degrades to identity without ever producing an unhandled
|
|
45
|
+
// rejection or keeping the event loop alive.
|
|
46
|
+
import('./redact.mjs').then(
|
|
47
|
+
(mod) => registerPathRedactor(mod.redactSensitivePaths),
|
|
48
|
+
() => { /* isolated copy without redact.mjs: keep identity redaction */ },
|
|
49
|
+
);
|
|
50
|
+
|
|
4
51
|
/** @type {Readonly<Record<string, number>>} */
|
|
5
52
|
const EXIT_CODE_MAP = Object.freeze({
|
|
6
53
|
CONFIG_INVALID: 10,
|
|
@@ -35,6 +82,11 @@ const EXIT_CODE_MAP = Object.freeze({
|
|
|
35
82
|
SAFE_WRITE_UNAVAILABLE: 39,
|
|
36
83
|
SETUP_DIGEST_MISMATCH: 40,
|
|
37
84
|
CONFIG_EXISTS: 41,
|
|
85
|
+
RELEASE_DOCS_INVALID: 42,
|
|
86
|
+
RELEASE_DOCS_TRANSLATION_MISSING: 43,
|
|
87
|
+
RELEASE_DOCS_CONFLICT: 44,
|
|
88
|
+
RELEASE_DOCS_REFRESH_STALE: 45,
|
|
89
|
+
RELEASE_DOCS_STALE: 46,
|
|
38
90
|
});
|
|
39
91
|
|
|
40
92
|
// ---- Error code constants ----
|
|
@@ -71,6 +123,11 @@ export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
|
|
|
71
123
|
export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
|
|
72
124
|
export const SETUP_DIGEST_MISMATCH = 'SETUP_DIGEST_MISMATCH';
|
|
73
125
|
export const CONFIG_EXISTS = 'CONFIG_EXISTS';
|
|
126
|
+
export const RELEASE_DOCS_INVALID = 'RELEASE_DOCS_INVALID';
|
|
127
|
+
export const RELEASE_DOCS_TRANSLATION_MISSING = 'RELEASE_DOCS_TRANSLATION_MISSING';
|
|
128
|
+
export const RELEASE_DOCS_CONFLICT = 'RELEASE_DOCS_CONFLICT';
|
|
129
|
+
export const RELEASE_DOCS_REFRESH_STALE = 'RELEASE_DOCS_REFRESH_STALE';
|
|
130
|
+
export const RELEASE_DOCS_STALE = 'RELEASE_DOCS_STALE';
|
|
74
131
|
|
|
75
132
|
/**
|
|
76
133
|
* Typed error for release-skill operations.
|
|
@@ -82,10 +139,15 @@ export const CONFIG_EXISTS = 'CONFIG_EXISTS';
|
|
|
82
139
|
*/
|
|
83
140
|
export class ReleaseError extends Error {
|
|
84
141
|
constructor(code, message, details = {}, exitCode) {
|
|
85
|
-
|
|
142
|
+
// Defect #3 choke point: redact absolute filesystem paths from the
|
|
143
|
+
// message and details before assignment so every consumer (all CLI
|
|
144
|
+
// catches in text and JSON modes, recoverable-error propagation chains,
|
|
145
|
+
// and toJSON) can only observe redacted values. Error codes, exit codes,
|
|
146
|
+
// and the envelope key set are unchanged; redaction is value-level only.
|
|
147
|
+
super(redactSensitivePaths(message));
|
|
86
148
|
this.name = 'ReleaseError';
|
|
87
149
|
this.code = code;
|
|
88
|
-
this.details = details;
|
|
150
|
+
this.details = redactSensitivePaths(details);
|
|
89
151
|
this.exitCode = exitCode ?? EXIT_CODE_MAP[code] ?? 1;
|
|
90
152
|
|
|
91
153
|
// Maintain proper stack trace in V8 environments
|