release-skill 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/CHANGELOG.md +89 -0
  5. package/INSTALL.md +216 -5
  6. package/INSTALL.zh-CN.md +358 -0
  7. package/README.md +411 -67
  8. package/README.zh-CN.md +377 -59
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +79284 -0
  12. package/adapters/claude/bin/release-skill.mjs +34 -0
  13. package/adapters/claude/native/safe-write/binding.gyp +40 -0
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +24 -0
  16. package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
  17. package/adapters/claude/schemas/.render-manifest.json +37 -0
  18. package/adapters/claude/schemas/approval-record.schema.json +115 -0
  19. package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
  20. package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
  21. package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
  22. package/adapters/claude/schemas/evidence-event.schema.json +89 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +860 -0
  24. package/adapters/claude/schemas/release-project.schema.json +736 -0
  25. package/adapters/claude/schemas/release-run.schema.json +342 -0
  26. package/adapters/claude/skills/release-assess/SKILL.md +5 -6
  27. package/adapters/claude/skills/release-help/SKILL.md +14 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +16 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +7 -7
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +6 -6
  31. package/adapters/claude/skills/release-setup/SKILL.md +95 -0
  32. package/adapters/claude/skills/release-verify/SKILL.md +7 -7
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
  35. package/adapters/codex/bin/release-skill.mjs +34 -0
  36. package/adapters/codex/native/safe-write/binding.gyp +40 -0
  37. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  38. package/adapters/codex/native/safe-write/prebuilds.json +24 -0
  39. package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
  40. package/adapters/codex/schemas/.render-manifest.json +37 -0
  41. package/adapters/codex/schemas/approval-record.schema.json +115 -0
  42. package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
  43. package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
  44. package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
  45. package/adapters/codex/schemas/evidence-event.schema.json +89 -0
  46. package/adapters/codex/schemas/release-plan.schema.json +860 -0
  47. package/adapters/codex/schemas/release-project.schema.json +736 -0
  48. package/adapters/codex/schemas/release-run.schema.json +342 -0
  49. package/adapters/codex/skills/release-assess/SKILL.md +12 -6
  50. package/adapters/codex/skills/release-help/SKILL.md +21 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +23 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +14 -7
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +13 -6
  54. package/adapters/codex/skills/release-setup/SKILL.md +102 -0
  55. package/adapters/codex/skills/release-verify/SKILL.md +14 -7
  56. package/bin/release-skill-cli.mjs +807 -0
  57. package/bin/release-skill.bundle.mjs +79284 -0
  58. package/bin/release-skill.mjs +23 -732
  59. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  60. package/native/safe-write/prebuilds.json +22 -2
  61. package/native/safe-write/src/safe_write.cc +11 -2
  62. package/package.json +8 -2
  63. package/references/02-project-config.md +55 -4
  64. package/references/05-evidence-and-errors.md +6 -2
  65. package/schemas/release-plan.schema.json +556 -65
  66. package/schemas/release-project.schema.json +406 -29
  67. package/schemas/release-run.schema.json +165 -18
  68. package/scripts/build-bundle.mjs +133 -0
  69. package/skills/release-assess/SKILL.md +5 -6
  70. package/skills/release-help/SKILL.md +14 -18
  71. package/skills/release-prepare/SKILL.md +16 -6
  72. package/skills/release-publish/SKILL.md +7 -7
  73. package/skills/release-reconcile/SKILL.md +6 -6
  74. package/skills/release-setup/SKILL.md +95 -0
  75. package/skills/release-verify/SKILL.md +7 -7
  76. package/skills-src/release-assess/SKILL.md +5 -6
  77. package/skills-src/release-help/SKILL.md +14 -18
  78. package/skills-src/release-prepare/SKILL.md +16 -6
  79. package/skills-src/release-publish/SKILL.md +7 -7
  80. package/skills-src/release-reconcile/SKILL.md +6 -6
  81. package/skills-src/release-setup/SKILL.md +95 -0
  82. package/skills-src/release-verify/SKILL.md +7 -7
  83. package/src/adapters/contract.mjs +3 -0
  84. package/src/adapters/git-github.mjs +84 -2
  85. package/src/adapters/npm.mjs +5 -13
  86. package/src/adapters/plugin-marketplace.mjs +132 -52
  87. package/src/adapters/push-snapshot.mjs +84 -17
  88. package/src/artifacts/policy.mjs +4 -7
  89. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  90. package/src/commands/prepare.mjs +244 -20
  91. package/src/commands/publish.mjs +46 -0
  92. package/src/commands/reconcile.mjs +152 -0
  93. package/src/commands/setup.mjs +1525 -0
  94. package/src/commands/verify.mjs +122 -26
  95. package/src/core/approval.mjs +4 -6
  96. package/src/core/config.mjs +42 -8
  97. package/src/core/errors.mjs +4 -0
  98. package/src/core/pkg-root.mjs +22 -0
  99. package/src/core/plan.mjs +132 -4
  100. package/src/core/previous-public-baseline.mjs +21 -1
  101. package/src/core/run.mjs +4 -4
  102. package/src/core/trusted-resource.mjs +96 -0
  103. package/src/core/verification-gates.mjs +451 -0
  104. package/src/docs/version-gate.mjs +164 -0
  105. package/src/producers/build-adapters.mjs +512 -55
  106. package/src/snapshot/frozen.mjs +221 -7
  107. package/src/snapshot/public-map.mjs +7 -4
  108. package/src/snapshot/scan.mjs +2 -1
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath } from 'node:fs/promises';
16
- import { join, relative, isAbsolute, resolve } from 'node:path';
16
+ import { dirname, join, relative, isAbsolute, resolve } from 'node:path';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { execFile as execFileCb } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
@@ -30,6 +30,7 @@ import {
30
30
  writeRunAtomic,
31
31
  computeRunDigest,
32
32
  resolveDefaultRunDir,
33
+ createProductionRunDir,
33
34
  } from '../core/run.mjs';
34
35
  import {
35
36
  assertImmutableApprovalAuthority,
@@ -48,6 +49,7 @@ import {
48
49
  registryTokenKey,
49
50
  resolveNpmRegistryAuthToken,
50
51
  } from '../adapters/npm.mjs';
52
+ import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
51
53
 
52
54
  // ---------------------------------------------------------------------------
53
55
  // Constants
@@ -60,6 +62,7 @@ import {
60
62
  const ADAPTER_ACTION_TYPE_MAP = {
61
63
  'push-commit': 'git-push',
62
64
  'push-snapshot': 'push-snapshot',
65
+ 'set-default-branch': 'set-default-branch',
63
66
  'create-tag': 'git-tag',
64
67
  'npm-publish': 'npm-publish',
65
68
  'github-release': 'github-release',
@@ -170,10 +173,12 @@ export async function runSmokeTest(plan, root, options = {}) {
170
173
  passed: true,
171
174
  skipped: true,
172
175
  details: { message: 'No npm distributions in plan; smoke test skipped' },
176
+ gateResults: [],
173
177
  };
174
178
  }
175
179
 
176
180
  const results = [];
181
+ const gateResults = [];
177
182
 
178
183
  for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
179
184
  const packageAtVersion = `${pkgName}@${targetVersion}`;
@@ -237,6 +242,17 @@ export async function runSmokeTest(plan, root, options = {}) {
237
242
  };
238
243
  }
239
244
 
245
+ const pkgRoot = join(installDir, 'node_modules', pkgName);
246
+ gateResults.push(...await runConsumerVerificationGates({
247
+ plan,
248
+ unitId,
249
+ distribution: 'npm',
250
+ executionRoot: pkgRoot,
251
+ evidence: options.evidence,
252
+ env: options.gateEnv ?? process.env,
253
+ fixedEnv: { HOME: installDir },
254
+ }));
255
+
240
256
  // If smokeBin is not configured, install + name/version check is sufficient
241
257
  if (!smokeBin) {
242
258
  results.push({
@@ -277,7 +293,6 @@ export async function runSmokeTest(plan, root, options = {}) {
277
293
  }
278
294
 
279
295
  // Verify bin path does not escape the installed package root
280
- const pkgRoot = join(installDir, 'node_modules', pkgName);
281
296
  const binPath = resolve(pkgRoot, binRelative);
282
297
  const relBin = relative(pkgRoot, binPath);
283
298
  const sep = process.platform === 'win32' ? '\\' : '/';
@@ -321,7 +336,17 @@ export async function runSmokeTest(plan, root, options = {}) {
321
336
  const cliArgs = smokeArgs.length > 0 ? smokeArgs : [];
322
337
  let binResult;
323
338
  try {
324
- binResult = await npmExec.runBin(binPath, cliArgs);
339
+ binResult = await npmExec.runBin(binPath, cliArgs, {
340
+ cwd: pkgRoot,
341
+ env: {
342
+ HOME: installDir,
343
+ TMPDIR: installDir,
344
+ TEMP: installDir,
345
+ TMP: installDir,
346
+ PATH: dirname(process.execPath),
347
+ CI: '1',
348
+ },
349
+ });
325
350
  } catch (binErr) {
326
351
  return {
327
352
  passed: false,
@@ -403,6 +428,7 @@ export async function runSmokeTest(plan, root, options = {}) {
403
428
  distributions: results,
404
429
  count: results.length,
405
430
  },
431
+ gateResults,
406
432
  };
407
433
  } finally {
408
434
  await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
@@ -451,10 +477,14 @@ const defaultNpmExecutor = {
451
477
  return { success: true };
452
478
  } catch (err) {
453
479
  return { success: false, error: err.message };
480
+ } finally {
481
+ await rm(userConfig, { force: true }).catch(() => {});
454
482
  }
455
483
  },
456
- async runBin(binPath, args = []) {
484
+ async runBin(binPath, args = [], options = {}) {
457
485
  return execFile(process.execPath, [binPath, ...args], {
486
+ cwd: options.cwd,
487
+ env: options.env,
458
488
  shell: false,
459
489
  encoding: 'utf8',
460
490
  timeout: 30_000,
@@ -491,6 +521,8 @@ export async function verifyRelease(options) {
491
521
  runDir: runDirOpt,
492
522
  clock: clockOpt,
493
523
  npmExecutor,
524
+ verificationGatesAuthorized,
525
+ gateEnv,
494
526
  } = options ?? {};
495
527
 
496
528
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -504,10 +536,39 @@ export async function verifyRelease(options) {
504
536
  );
505
537
  }
506
538
 
539
+ // Load and validate the plan before creating any evidence directory. A
540
+ // production plan grants authority only to a fresh direct child of its
541
+ // sibling .release-skill/runs directory.
542
+ let planRaw;
543
+ try {
544
+ planRaw = await readFile(planPath, 'utf8');
545
+ } catch (err) {
546
+ throw new ReleaseError(
547
+ GATE_FAILED,
548
+ `cannot read release plan: ${err.message}`,
549
+ { planPath, cause: err.code },
550
+ );
551
+ }
552
+
553
+ let plan;
554
+ try {
555
+ plan = JSON.parse(planRaw);
556
+ } catch (err) {
557
+ throw new ReleaseError(
558
+ GATE_FAILED,
559
+ `release plan is not valid JSON: ${err.message}`,
560
+ { planPath },
561
+ );
562
+ }
563
+ validatePlan(plan);
564
+
507
565
  // --- Set up directories ---
508
566
  const runId = `verify-${Date.now()}`;
509
- const runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
510
- await mkdir(runDir, { recursive: true });
567
+ const requestedRunDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
568
+ const runDir = plan.production
569
+ ? await createProductionRunDir(requestedRunDir, planPath)
570
+ : requestedRunDir;
571
+ if (!plan.production) await mkdir(runDir, { recursive: true });
511
572
 
512
573
  const evidence = createEvidenceWriter({ runDir, command: 'verify', clock: clockFn });
513
574
 
@@ -517,30 +578,23 @@ export async function verifyRelease(options) {
517
578
  // =======================================================================
518
579
  await evidence.append({ phase: 'verify', step: 'plan-load', status: 'started' });
519
580
 
520
- let planRaw;
521
- try {
522
- planRaw = await readFile(planPath, 'utf8');
523
- } catch (err) {
524
- throw new ReleaseError(
525
- GATE_FAILED,
526
- `cannot read release plan: ${err.message}`,
527
- { planPath, cause: err.code },
528
- );
529
- }
530
-
531
- let plan;
532
- try {
533
- plan = JSON.parse(planRaw);
534
- } catch (err) {
581
+ const consumerGates = (plan.verificationGates ?? []).filter((gate) => gate.phase === 'consumer-verify');
582
+ const configuredSmokeBins = (plan.units ?? []).flatMap((unit) => (
583
+ (unit.distributions ?? [])
584
+ .filter((distribution) => distribution.type === 'npm' && distribution.smokeBin)
585
+ .map((distribution) => ({ unitId: unit.id, smokeBin: distribution.smokeBin }))
586
+ ));
587
+ if ((consumerGates.length > 0 || configuredSmokeBins.length > 0) && verificationGatesAuthorized !== true) {
535
588
  throw new ReleaseError(
536
589
  GATE_FAILED,
537
- `release plan is not valid JSON: ${err.message}`,
538
- { planPath },
590
+ `plan declares ${consumerGates.length} consumer verification gate(s) and ` +
591
+ `${configuredSmokeBins.length} npm CLI smoke process(es). ` +
592
+ 'They execute installed project code without an OS or network sandbox. ' +
593
+ 'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
594
+ { gateIds: consumerGates.map((gate) => gate.id), configuredSmokeBins },
539
595
  );
540
596
  }
541
597
 
542
- validatePlan(plan);
543
-
544
598
  await evidence.append({ phase: 'verify', step: 'plan-load', status: 'passed' });
545
599
 
546
600
  // =======================================================================
@@ -659,6 +713,7 @@ export async function verifyRelease(options) {
659
713
  await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'started' });
660
714
 
661
715
  const adapterChecks = [];
716
+ const consumerGateResults = [];
662
717
  const actions = plan.externalActions ?? [];
663
718
  const MARKETPLACE_TYPES = new Set([
664
719
  'claude-marketplace-install',
@@ -770,6 +825,28 @@ export async function verifyRelease(options) {
770
825
  { actionId: action.id, observation: verifyResult.observation },
771
826
  );
772
827
  }
828
+
829
+ const distribution = action.type === 'claude-marketplace-install'
830
+ ? 'claude-plugin'
831
+ : 'codex-plugin';
832
+ const installPath = verifyResult.observation?.installPath;
833
+ consumerGateResults.push(...await runConsumerVerificationGates({
834
+ plan,
835
+ unitId: action.unitId,
836
+ distribution,
837
+ executionRoot: installPath,
838
+ evidence,
839
+ env: gateEnv ?? process.env,
840
+ fixedEnv: action.type === 'claude-marketplace-install'
841
+ ? {
842
+ HOME: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`),
843
+ CLAUDE_CONFIG_DIR: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`, '.claude'),
844
+ }
845
+ : {
846
+ HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
847
+ CODEX_HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
848
+ },
849
+ }));
773
850
  } else {
774
851
  // --- Non-marketplace: read-only adapter.verify() ---
775
852
  const context = {
@@ -825,7 +902,12 @@ export async function verifyRelease(options) {
825
902
 
826
903
  let smokeTest;
827
904
  try {
828
- smokeTest = await runSmokeTest(plan, root, { npmExecutor, baseDir: runDir });
905
+ smokeTest = await runSmokeTest(plan, root, {
906
+ npmExecutor,
907
+ baseDir: runDir,
908
+ evidence,
909
+ gateEnv: gateEnv ?? process.env,
910
+ });
829
911
  } catch (err) {
830
912
  smokeTest = { passed: false, details: { error: err.message } };
831
913
  }
@@ -845,6 +927,17 @@ export async function verifyRelease(options) {
845
927
  );
846
928
  }
847
929
 
930
+ consumerGateResults.push(...(smokeTest.gateResults ?? []));
931
+ const expectedGateIds = consumerGates.map((gate) => gate.id).sort();
932
+ const observedGateIds = consumerGateResults.map((result) => result.id).sort();
933
+ if (JSON.stringify(expectedGateIds) !== JSON.stringify(observedGateIds)) {
934
+ throw new ReleaseError(
935
+ POST_PUBLISH_VERIFY_FAILED,
936
+ 'consumer verification gate execution set does not match the frozen plan',
937
+ { expectedGateIds, observedGateIds },
938
+ );
939
+ }
940
+
848
941
  // =======================================================================
849
942
  // All verifications passed — write verify run
850
943
  // =======================================================================
@@ -875,6 +968,7 @@ export async function verifyRelease(options) {
875
968
  status: check?.status === 'SKIPPED' ? 'skipped' : 'succeeded',
876
969
  };
877
970
  }),
971
+ gateResults: consumerGateResults,
878
972
  startedAt: clockFn(),
879
973
  finishedAt: clockFn(),
880
974
  };
@@ -888,6 +982,7 @@ export async function verifyRelease(options) {
888
982
  runDigest: persistedVerifyRun.runDigest,
889
983
  adapterCheckCount: adapterChecks.length,
890
984
  smokeTestPassed: true,
985
+ consumerGateCount: consumerGateResults.length,
891
986
  completedAt: clockFn(),
892
987
  });
893
988
 
@@ -896,6 +991,7 @@ export async function verifyRelease(options) {
896
991
  status: VERIFIED,
897
992
  adapterChecks,
898
993
  smokeTest,
994
+ gateResults: consumerGateResults,
899
995
  };
900
996
  } catch (err) {
901
997
  await evidence.append({
@@ -19,16 +19,14 @@ import { computePlanDigest } from './plan.mjs';
19
19
  import { sha256Hex } from './digest.mjs';
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import { basename, dirname, join, resolve } from 'node:path';
22
- import { fileURLToPath } from 'node:url';
23
22
  import Ajv from 'ajv';
24
23
  import addFormats from 'ajv-formats';
25
24
  import { WORKSPACE_DIGEST_ALGORITHM } from './baseline.mjs';
25
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
26
26
 
27
- const __dirname = dirname(fileURLToPath(import.meta.url));
28
- const approvalSchema = JSON.parse(await readFile(
29
- join(__dirname, '..', '..', 'schemas', 'approval-record.schema.json'),
30
- 'utf8',
31
- ));
27
+ const approvalSchema = JSON.parse((await readTrustedPackageResource(
28
+ 'schemas/approval-record.schema.json',
29
+ )).toString('utf8'));
32
30
  const approvalAjv = new Ajv({ allErrors: true, strict: false });
33
31
  addFormats(approvalAjv);
34
32
  const validateApprovalSchema = approvalAjv.compile(approvalSchema);
@@ -17,8 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFile } from 'node:fs/promises';
20
- import { resolve, isAbsolute, relative, normalize, dirname } from 'node:path';
21
- import { fileURLToPath } from 'node:url';
20
+ import { resolve, isAbsolute, relative, normalize } from 'node:path';
22
21
  import YAML, { Alias } from 'yaml';
23
22
  import Ajv from 'ajv';
24
23
  import addFormats from 'ajv-formats';
@@ -26,6 +25,7 @@ import { canonicalJson, sha256Hex } from './digest.mjs';
26
25
  import { ReleaseError, CONFIG_INVALID } from './errors.mjs';
27
26
  import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
28
27
  import { isReservedReleaseControlPath } from './baseline.mjs';
28
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Constants
@@ -38,19 +38,19 @@ const DEFAULT_CONFIG_REL = '.release-skill/project.yaml';
38
38
  // Load the formal release-project JSON Schema (single source of truth)
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
- const __dirname = dirname(fileURLToPath(import.meta.url));
42
- const FORMAL_SCHEMA_PATH = resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json');
43
-
44
41
  let RELEASE_PROJECT_SCHEMA;
45
42
  try {
46
- const schemaRaw = await readFile(FORMAL_SCHEMA_PATH, 'utf8');
43
+ const schemaRaw = (await readTrustedPackageResource(
44
+ 'schemas/release-project.schema.json',
45
+ )).toString('utf8');
47
46
  RELEASE_PROJECT_SCHEMA = JSON.parse(schemaRaw);
48
47
  } catch (err) {
48
+ if (err instanceof ReleaseError) throw err;
49
49
  // Fail closed: if the formal schema cannot be loaded, refuse to operate.
50
50
  throw new ReleaseError(
51
51
  CONFIG_INVALID,
52
- `cannot load formal release-project schema: ${err.message}`,
53
- { schemaPath: FORMAL_SCHEMA_PATH, cause: err.code },
52
+ 'cannot parse formal release-project schema',
53
+ { resource: 'schemas/release-project.schema.json', cause: err.code ?? 'PARSE_FAILED' },
54
54
  );
55
55
  }
56
56
 
@@ -437,6 +437,40 @@ export async function loadProjectConfig({ root, configPath } = {}) {
437
437
  }
438
438
  }
439
439
 
440
+ // Verification gates are deliberately cross-referenced here instead of
441
+ // inferred at execution time. A gate must name one existing unit and, for
442
+ // consumer verification, one distribution that the unit actually ships.
443
+ const unitsById = new Map(config.releaseUnits.map((unit) => [unit.id, unit]));
444
+ const gateIds = new Set();
445
+ for (const gate of config.verificationGates ?? []) {
446
+ if (gateIds.has(gate.id)) {
447
+ throw new ReleaseError(
448
+ CONFIG_INVALID,
449
+ `duplicate verification gate id: "${gate.id}"`,
450
+ { gateId: gate.id },
451
+ );
452
+ }
453
+ gateIds.add(gate.id);
454
+ const unit = unitsById.get(gate.scope.unit);
455
+ if (!unit) {
456
+ throw new ReleaseError(
457
+ CONFIG_INVALID,
458
+ `verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
459
+ { gateId: gate.id, unitId: gate.scope.unit },
460
+ );
461
+ }
462
+ if (
463
+ gate.phase === 'consumer-verify' &&
464
+ !(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
465
+ ) {
466
+ throw new ReleaseError(
467
+ CONFIG_INVALID,
468
+ `verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
469
+ { gateId: gate.id, unitId: gate.scope.unit, distribution: gate.scope.distribution },
470
+ );
471
+ }
472
+ }
473
+
440
474
  // --- Compute deterministic digest ---
441
475
  const configDigest = sha256Hex(canonicalJson(config));
442
476
 
@@ -33,6 +33,8 @@ const EXIT_CODE_MAP = Object.freeze({
33
33
  SENSITIVE_CONFLICT: 37,
34
34
  TRANSACTION_INCOMPLETE: 38,
35
35
  SAFE_WRITE_UNAVAILABLE: 39,
36
+ SETUP_DIGEST_MISMATCH: 40,
37
+ CONFIG_EXISTS: 41,
36
38
  });
37
39
 
38
40
  // ---- Error code constants ----
@@ -67,6 +69,8 @@ export const PLAN_STALE = 'PLAN_STALE';
67
69
  export const SENSITIVE_CONFLICT = 'SENSITIVE_CONFLICT';
68
70
  export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
69
71
  export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
72
+ export const SETUP_DIGEST_MISMATCH = 'SETUP_DIGEST_MISMATCH';
73
+ export const CONFIG_EXISTS = 'CONFIG_EXISTS';
70
74
 
71
75
  /**
72
76
  * Typed error for release-skill operations.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Package root resolution for bundled and unbundled execution.
3
+ *
4
+ * In development (unbundled) mode, `import.meta.url` points to the source
5
+ * file under `src/core/`, so `../..` reaches the package root.
6
+ *
7
+ * In bundled mode, the esbuild banner defines `__bundlePkgRoot` before the
8
+ * module body executes, derived deterministically from the bundle's own
9
+ * file path. The env-var override path has been removed to prevent
10
+ * callers from hijacking schema/native resolution.
11
+ *
12
+ * @module core/pkg-root
13
+ */
14
+
15
+ import { dirname, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ // In bundled mode, __bundlePkgRoot is set by the esbuild banner.
19
+ // In development mode, resolve from this source file's location.
20
+ export const PKG_ROOT = typeof __bundlePkgRoot !== 'undefined'
21
+ ? __bundlePkgRoot
22
+ : resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
package/src/core/plan.mjs CHANGED
@@ -17,19 +17,19 @@
17
17
 
18
18
  import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
19
19
  import { basename, dirname, join, resolve, parse, sep } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
20
  import Ajv from 'ajv';
22
21
  import addFormats from 'ajv-formats';
23
22
  import { canonicalJson, sha256Hex } from './digest.mjs';
24
23
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Schema loaded from the authoritative JSON file (single source of truth)
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
- const __dirname = dirname(fileURLToPath(import.meta.url));
31
- const SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-plan.schema.json');
32
- const RELEASE_PLAN_SCHEMA = JSON.parse(await readFile(SCHEMA_PATH, 'utf8'));
30
+ const RELEASE_PLAN_SCHEMA = JSON.parse((await readTrustedPackageResource(
31
+ 'schemas/release-plan.schema.json',
32
+ )).toString('utf8'));
33
33
 
34
34
  // ---------------------------------------------------------------------------
35
35
  // Schema validator (compiled once at module init)
@@ -132,6 +132,31 @@ export function validatePlan(plan) {
132
132
  { validationErrors: errors },
133
133
  );
134
134
  }
135
+
136
+ const unitsById = new Map((plan.units ?? []).map((unit) => [unit.id, unit]));
137
+ const gateIds = new Set();
138
+ for (const gate of plan.verificationGates ?? []) {
139
+ if (gateIds.has(gate.id)) {
140
+ throw new ReleaseError(GATE_FAILED, `duplicate verification gate id: "${gate.id}"`);
141
+ }
142
+ gateIds.add(gate.id);
143
+ const unit = unitsById.get(gate.scope.unit);
144
+ if (!unit) {
145
+ throw new ReleaseError(
146
+ GATE_FAILED,
147
+ `verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
148
+ );
149
+ }
150
+ if (
151
+ gate.phase === 'consumer-verify' &&
152
+ !(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
153
+ ) {
154
+ throw new ReleaseError(
155
+ GATE_FAILED,
156
+ `verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
157
+ );
158
+ }
159
+ }
135
160
  }
136
161
 
137
162
  /**
@@ -260,6 +285,7 @@ const EXPECTED_ADAPTER = {
260
285
  'npm-publish': 'npm',
261
286
  'claude-marketplace-install': 'plugin-marketplace',
262
287
  'codex-marketplace-install': 'plugin-marketplace',
288
+ 'set-default-branch': 'git-github',
263
289
  };
264
290
 
265
291
  /** Required action types for every unit. */
@@ -317,6 +343,7 @@ export function validatePlanActionCompleteness(plan) {
317
343
  const production = plan.production?.mode === 'github-npm-v1';
318
344
  const frozen = unit.frozenSnapshot;
319
345
  const productionConfig = unit.productionConfig ?? {};
346
+ const branchStrategy = productionConfig.branchStrategy;
320
347
  const expectedTag = tagTemplate
321
348
  ? tagTemplate.replace('{version}', targetVersion ?? '')
322
349
  : null;
@@ -346,6 +373,9 @@ export function validatePlanActionCompleteness(plan) {
346
373
  failures.push(`unit "${unitId}" is missing frozenSnapshot for production publish`);
347
374
  }
348
375
  if (production && frozen) {
376
+ if (!['create-release-branch', 'advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
377
+ failures.push(`unit "${unitId}" productionConfig.branchStrategy is missing or invalid`);
378
+ }
349
379
  const expectedBranch = (productionConfig.branchTemplate ?? 'release/{tag}')
350
380
  .replaceAll('{tag}', expectedTag ?? '')
351
381
  .replaceAll('{version}', targetVersion ?? '')
@@ -353,6 +383,37 @@ export function validatePlanActionCompleteness(plan) {
353
383
  if (frozen.branch !== expectedBranch) {
354
384
  failures.push(`unit "${unitId}" frozen branch does not match productionConfig.branchTemplate`);
355
385
  }
386
+ if (frozen.branchStrategy !== branchStrategy) {
387
+ failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
388
+ }
389
+ if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
390
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
391
+ } else if (plan.createdAt !== frozen.commitTimestamp) {
392
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
393
+ }
394
+ if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
395
+ if (unit.previousPublicBaseline?.mode !== 'bound') {
396
+ failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
397
+ }
398
+ if (!frozen.parentCommit || frozen.parentCommit !== unit.previousPublicBaseline?.commit) {
399
+ failures.push(`unit "${unitId}" frozenSnapshot.parentCommit does not match previous public baseline commit`);
400
+ }
401
+ } else if (frozen.parentCommit) {
402
+ failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
403
+ }
404
+ if (
405
+ branchStrategy === 'advance-existing-branch' &&
406
+ unit.previousPublicBaseline?.ref !== `refs/heads/${expectedBranch}`
407
+ ) {
408
+ failures.push(`unit "${unitId}" advance-existing-branch baseline ref does not match the target branch`);
409
+ }
410
+ if (branchStrategy === 'initialize-default-branch') {
411
+ if (productionConfig.setAsDefaultBranch !== true || !productionConfig.expectedCurrentDefaultBranch) {
412
+ failures.push(`unit "${unitId}" initialize-default-branch requires explicit default branch settings`);
413
+ }
414
+ } else if (productionConfig.setAsDefaultBranch === true) {
415
+ failures.push(`unit "${unitId}" setAsDefaultBranch is only valid for initialize-default-branch`);
416
+ }
356
417
  }
357
418
 
358
419
  // Required actions for this unit (always required)
@@ -411,10 +472,19 @@ export function validatePlanActionCompleteness(plan) {
411
472
  _checkRequired(action, 'parameters.branch', action.parameters?.branch, frozen?.branch, unitId, failures);
412
473
  _checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
413
474
  _checkRequired(action, 'parameters.tree', action.parameters?.tree, frozen?.tree, unitId, failures);
475
+ _checkRequired(action, 'parameters.branchStrategy', action.parameters?.branchStrategy, branchStrategy, unitId, failures);
414
476
  _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
415
477
  _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
416
478
  _checkRequired(action, 'expected.tree', action.expected?.tree, frozen?.tree, unitId, failures);
417
479
  _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
480
+ if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
481
+ _checkRequired(action, 'parameters.parentCommit', action.parameters?.parentCommit, frozen?.parentCommit, unitId, failures);
482
+ }
483
+ if (branchStrategy === 'advance-existing-branch') {
484
+ _checkRequired(action, 'parameters.expectedBaselineCommit', action.parameters?.expectedBaselineCommit, frozen?.parentCommit, unitId, failures);
485
+ } else if (action.parameters?.expectedBaselineCommit !== undefined) {
486
+ failures.push(`unit "${unitId}", action "${action.id}": unexpected parameters.expectedBaselineCommit`);
487
+ }
418
488
  } else {
419
489
  _checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
420
490
  }
@@ -692,6 +762,64 @@ export function validatePlanActionCompleteness(plan) {
692
762
  }
693
763
  }
694
764
  }
765
+
766
+ // set-default-branch: only required when productionConfig.setAsDefaultBranch is true
767
+ if (branchStrategy === 'initialize-default-branch') {
768
+ const oldBranch = productionConfig.expectedCurrentDefaultBranch;
769
+ const newBranch = frozen?.branch;
770
+ expectedCount++;
771
+ const expectedActionId = `set-default-branch-${unitId}`;
772
+ const branchActions = actions.filter(
773
+ (a) => a.unitId === unitId && a.type === 'set-default-branch',
774
+ );
775
+
776
+ if (branchActions.length === 0) {
777
+ failures.push(
778
+ `unit "${unitId}": productionConfig.setAsDefaultBranch is true but "set-default-branch" action is missing`,
779
+ );
780
+ } else if (branchActions.length > 1) {
781
+ failures.push(
782
+ `unit "${unitId}": duplicate set-default-branch actions (${branchActions.length} found, expected 1)`,
783
+ );
784
+ } else {
785
+ const action = branchActions[0];
786
+
787
+ if (action.id !== expectedActionId) {
788
+ failures.push(
789
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
790
+ );
791
+ }
792
+ if (action.unitId !== unitId) {
793
+ failures.push(
794
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
795
+ );
796
+ }
797
+ if (action.adapter !== 'git-github') {
798
+ failures.push(
799
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "git-github"`,
800
+ );
801
+ }
802
+ if (action.status !== 'PENDING') {
803
+ failures.push(
804
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
805
+ );
806
+ }
807
+
808
+ // Required parameters
809
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
810
+ _checkRequired(action, 'parameters.oldBranch', action.parameters?.oldBranch, oldBranch, unitId, failures);
811
+ _checkRequired(action, 'parameters.newBranch', action.parameters?.newBranch, newBranch, unitId, failures);
812
+ _checkRequired(action, 'parameters.expectedNewBranchCommit', action.parameters?.expectedNewBranchCommit, frozen?.commit, unitId, failures);
813
+ _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
814
+
815
+ // Required expected
816
+ _checkRequired(action, 'expected.defaultBranch', action.expected?.defaultBranch, newBranch, unitId, failures);
817
+ _checkRequired(action, 'expected.newBranchCommit', action.expected?.newBranchCommit, frozen?.commit, unitId, failures);
818
+ if (Object.keys(action.expected ?? {}).some((key) => !['defaultBranch', 'newBranchCommit'].includes(key))) {
819
+ failures.push(`unit "${unitId}", action "${action.id}": expected may only contain defaultBranch and newBranchCommit`);
820
+ }
821
+ }
822
+ }
695
823
  }
696
824
 
697
825
  // --- Strict count equality: actual must exactly match expected ---