release-skill 0.9.3 → 0.9.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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +29 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +47 -15
  10. package/README.zh-CN.md +42 -15
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/consumer-contract-vectors.json +7 -7
  14. package/adapters/claude/bin/foundation-resource-binding.json +1 -1
  15. package/adapters/claude/bin/release-skill.bundle.mjs +651 -190
  16. package/adapters/claude/skills/release-help/SKILL.md +10 -3
  17. package/adapters/claude/skills/release-prepare/SKILL.md +11 -3
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/consumer-contract-vectors.json +7 -7
  20. package/adapters/codex/bin/foundation-resource-binding.json +1 -1
  21. package/adapters/codex/bin/release-skill.bundle.mjs +651 -190
  22. package/adapters/codex/skills/release-help/SKILL.md +10 -3
  23. package/adapters/codex/skills/release-prepare/SKILL.md +11 -3
  24. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  25. package/adapters/kimi/bin/consumer-contract-vectors.json +7 -7
  26. package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
  27. package/adapters/kimi/bin/release-skill.bundle.mjs +651 -190
  28. package/adapters/kimi/skills/release-help/SKILL.md +10 -3
  29. package/adapters/kimi/skills/release-prepare/SKILL.md +11 -3
  30. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  31. package/adapters/workbuddy/bin/consumer-contract-vectors.json +7 -7
  32. package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
  33. package/adapters/workbuddy/bin/release-skill.bundle.mjs +651 -190
  34. package/adapters/workbuddy/skills/release-help/SKILL.md +10 -3
  35. package/adapters/workbuddy/skills/release-prepare/SKILL.md +11 -3
  36. package/bin/consumer-contract-vectors.json +7 -7
  37. package/bin/foundation-resource-binding.json +1 -1
  38. package/bin/release-skill-cli.mjs +26 -2
  39. package/bin/release-skill.bundle.mjs +651 -190
  40. package/package.json +4 -4
  41. package/platform-manifest.json +4 -4
  42. package/references/.render-manifest.json +4 -4
  43. package/references/01-state-machine.md +18 -0
  44. package/references/02-project-config.md +20 -0
  45. package/skills/release-help/SKILL.md +10 -3
  46. package/skills/release-prepare/SKILL.md +11 -3
  47. package/skills-src/release-help/SKILL.md +10 -3
  48. package/skills-src/release-prepare/SKILL.md +11 -3
  49. package/src/commands/post-release-local.mjs +169 -16
  50. package/src/commands/prepare.mjs +183 -7
  51. package/src/commands/ship.mjs +164 -2
  52. package/src/core/foundation-plugin-verification.mjs +22 -2
  53. package/src/producers/foundation-resource-projection.mjs +3 -3
@@ -2917,6 +2917,142 @@ async function runPreHookPublicSurfaceGate({
2917
2917
  }
2918
2918
  }
2919
2919
 
2920
+ /**
2921
+ * Resolve the operator's pre-freeze release-unit request against the complete
2922
+ * validated project configuration. The returned units always follow config
2923
+ * declaration order so plan ordering and digests never depend on argv order.
2924
+ */
2925
+ function prepareRetryPathArg(path) {
2926
+ return relative(process.cwd(), resolve(path)) || '.';
2927
+ }
2928
+
2929
+ function buildPrepareRetryArgv(options, unitIds) {
2930
+ const argv = [
2931
+ 'release-skill',
2932
+ 'prepare',
2933
+ '--root',
2934
+ prepareRetryPathArg(options.root),
2935
+ ];
2936
+ if (typeof options.version === 'string' && options.version.length > 0) {
2937
+ argv.push('--target-version', options.version);
2938
+ }
2939
+ argv.push(options.offline ? '--offline' : '--online');
2940
+ if (options.production) argv.push('--production');
2941
+ argv.push('--workflow', options.workflow);
2942
+ if (typeof options.testSelection === 'string') {
2943
+ argv.push('--test-selection', options.testSelection);
2944
+ }
2945
+ if (typeof options.output === 'string') {
2946
+ argv.push('--output', prepareRetryPathArg(options.output));
2947
+ }
2948
+ // A production --run-dir is single-use by contract and this failed attempt
2949
+ // has already created it. Let the retry allocate a fresh evidence directory.
2950
+ if (options.hookCache === false) argv.push('--no-hook-cache');
2951
+ for (const unitId of unitIds) argv.push('--unit', unitId);
2952
+ return argv;
2953
+ }
2954
+
2955
+ /**
2956
+ * Keep the deferred-unit evidence and operator guidance on one exact argv.
2957
+ * The command points back to the caller's project root and carries the same
2958
+ * prepare mode while selecting only the units that were deferred.
2959
+ */
2960
+ function buildDeferredReleaseDiagnostics(options, deferredUnitIds) {
2961
+ if (deferredUnitIds.length === 0) return null;
2962
+ return {
2963
+ deferredUnitIds: [...deferredUnitIds],
2964
+ nextPrepareArgv: buildPrepareRetryArgv(options, deferredUnitIds),
2965
+ };
2966
+ }
2967
+
2968
+ function resolveRequestedReleaseUnits(config, requestedUnitIds, retryOptions) {
2969
+ const availableUnits = config.releaseUnits ?? [];
2970
+ const availableUnitIds = availableUnits.map((unit) => unit.id);
2971
+ if (requestedUnitIds === undefined) {
2972
+ return {
2973
+ explicit: false,
2974
+ selectedUnits: availableUnits,
2975
+ selectedUnitIds: availableUnitIds,
2976
+ deferredUnitIds: [],
2977
+ receiptSelected: Boolean(config.publicSourceAuthorityReceipt),
2978
+ };
2979
+ }
2980
+ if (!Array.isArray(requestedUnitIds) || requestedUnitIds.length === 0) {
2981
+ throw new ReleaseError(
2982
+ CONFIG_INVALID,
2983
+ 'explicit release-unit selection must contain at least one unit id',
2984
+ { requestedUnitIds, availableUnitIds },
2985
+ );
2986
+ }
2987
+
2988
+ const invalidUnitIds = requestedUnitIds.filter(
2989
+ (unitId) => typeof unitId !== 'string' || unitId.length === 0,
2990
+ );
2991
+ const seen = new Set();
2992
+ const duplicateUnitIds = [];
2993
+ for (const unitId of requestedUnitIds) {
2994
+ if (seen.has(unitId) && !duplicateUnitIds.includes(unitId)) duplicateUnitIds.push(unitId);
2995
+ seen.add(unitId);
2996
+ }
2997
+ const availableSet = new Set(availableUnitIds);
2998
+ const unknownUnitIds = requestedUnitIds.filter(
2999
+ (unitId) => typeof unitId === 'string' && unitId.length > 0 && !availableSet.has(unitId),
3000
+ );
3001
+ if (invalidUnitIds.length > 0 || duplicateUnitIds.length > 0 || unknownUnitIds.length > 0) {
3002
+ throw new ReleaseError(
3003
+ CONFIG_INVALID,
3004
+ 'release-unit selection contains empty, duplicate, or unknown unit ids',
3005
+ {
3006
+ requestedUnitIds,
3007
+ availableUnitIds,
3008
+ invalidUnitIds,
3009
+ duplicateUnitIds,
3010
+ unknownUnitIds,
3011
+ },
3012
+ );
3013
+ }
3014
+
3015
+ const selectedSet = new Set(requestedUnitIds);
3016
+ const selectedUnits = availableUnits.filter((unit) => selectedSet.has(unit.id));
3017
+ const selectedUnitIds = selectedUnits.map((unit) => unit.id);
3018
+ const deferredUnitIds = availableUnitIds.filter((unitId) => !selectedSet.has(unitId));
3019
+ const deferredReleaseDiagnostics = buildDeferredReleaseDiagnostics(retryOptions, deferredUnitIds);
3020
+ const receipt = config.publicSourceAuthorityReceipt;
3021
+ let receiptSelected = false;
3022
+ if (receipt) {
3023
+ const receiptSet = new Set([receipt.coordinatorUnitId, ...receipt.subjectUnitIds]);
3024
+ const dependencyUnitIds = availableUnitIds.filter((unitId) => receiptSet.has(unitId));
3025
+ receiptSelected = dependencyUnitIds.some((unitId) => selectedSet.has(unitId));
3026
+ if (receiptSelected) {
3027
+ const missingUnitIds = dependencyUnitIds.filter((unitId) => !selectedSet.has(unitId));
3028
+ if (missingUnitIds.length > 0) {
3029
+ const retryUnitIds = availableUnitIds.filter(
3030
+ (unitId) => selectedSet.has(unitId) || receiptSet.has(unitId),
3031
+ );
3032
+ throw new ReleaseError(
3033
+ GATE_FAILED,
3034
+ 'release-unit selection does not include the complete public source-authority dependency closure',
3035
+ {
3036
+ selectedUnitIds,
3037
+ missingUnitIds,
3038
+ dependencyUnitIds,
3039
+ retryArgv: buildPrepareRetryArgv(retryOptions, retryUnitIds),
3040
+ },
3041
+ );
3042
+ }
3043
+ }
3044
+ }
3045
+
3046
+ return {
3047
+ explicit: true,
3048
+ selectedUnits,
3049
+ selectedUnitIds,
3050
+ deferredUnitIds,
3051
+ deferredReleaseDiagnostics,
3052
+ receiptSelected,
3053
+ };
3054
+ }
3055
+
2920
3056
  export async function prepareRelease(options) {
2921
3057
  const {
2922
3058
  root,
@@ -2931,6 +3067,7 @@ export async function prepareRelease(options) {
2931
3067
  workflow = 'full',
2932
3068
  observePreviousPublicBaselineFn,
2933
3069
  testSelection = 'full',
3070
+ unitIds,
2934
3071
  } = options ?? {};
2935
3072
 
2936
3073
  // --- Workflow profile (H5) ---
@@ -3039,7 +3176,20 @@ export async function prepareRelease(options) {
3039
3176
  await evidence.append({ phase: 'config', status: 'started' });
3040
3177
 
3041
3178
  const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
3042
- const adoptionWarnings = collectExpectedPublicSurfaceAdoptionWarnings(config);
3179
+ const releaseSelection = resolveRequestedReleaseUnits(config, unitIds, {
3180
+ root,
3181
+ version,
3182
+ offline,
3183
+ production,
3184
+ workflow,
3185
+ testSelection: typeof options?.testSelection === 'string' ? testSelection : undefined,
3186
+ output,
3187
+ hookCache: options?.hookCache,
3188
+ });
3189
+ const configUnits = releaseSelection.selectedUnits;
3190
+ const selectedConfig = { ...config, releaseUnits: configUnits };
3191
+ const selectedUnitIdSet = new Set(releaseSelection.selectedUnitIds);
3192
+ const adoptionWarnings = collectExpectedPublicSurfaceAdoptionWarnings(selectedConfig);
3043
3193
  // Mutable operator-facing warning list: seeded from the adoption warnings,
3044
3194
  // appended by later gates (O5 origin-ahead). Returned as `warnings`.
3045
3195
  const runWarnings = [...adoptionWarnings];
@@ -3057,6 +3207,18 @@ export async function prepareRelease(options) {
3057
3207
  ...warning,
3058
3208
  });
3059
3209
  }
3210
+ if (releaseSelection.explicit) {
3211
+ await evidence.append({
3212
+ phase: 'release-scope',
3213
+ status: 'selected',
3214
+ mode: 'selected',
3215
+ selectedUnitIds: releaseSelection.selectedUnitIds,
3216
+ deferredUnitIds: releaseSelection.deferredUnitIds,
3217
+ ...(releaseSelection.deferredReleaseDiagnostics
3218
+ ? { nextPrepareArgv: releaseSelection.deferredReleaseDiagnostics.nextPrepareArgv }
3219
+ : {}),
3220
+ });
3221
+ }
3060
3222
 
3061
3223
  // --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
3062
3224
  // 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
@@ -3132,7 +3294,6 @@ export async function prepareRelease(options) {
3132
3294
  // gate, baseline, snapshot, remote check, or plan write runs. Units
3133
3295
  // without releaseDocuments keep the exact legacy behaviour (the gate
3134
3296
  // appends no evidence and performs no check).
3135
- const configUnits = config.releaseUnits ?? [];
3136
3297
  const resolvedVersions = await resolveAllUnitVersions(
3137
3298
  configUnits,
3138
3299
  realRoot,
@@ -3330,7 +3491,8 @@ export async function prepareRelease(options) {
3330
3491
  });
3331
3492
  }
3332
3493
 
3333
- const declaredVerificationGates = config.verificationGates ?? [];
3494
+ const declaredVerificationGates = (config.verificationGates ?? [])
3495
+ .filter((gate) => selectedUnitIdSet.has(gate.scope.unit));
3334
3496
  if (declaredVerificationGates.length > 0) {
3335
3497
  await evidence.append({
3336
3498
  phase: 'verification-gate-authorization',
@@ -3435,7 +3597,7 @@ export async function prepareRelease(options) {
3435
3597
  // R93-01: optional ephemeral public-surface check immediately before the
3436
3598
  // first hook. Its staging and closure receipt never enter the plan.
3437
3599
  await runPreHookPublicSurfaceGate({
3438
- config,
3600
+ config: selectedConfig,
3439
3601
  root: realRoot,
3440
3602
  resolvedVersions,
3441
3603
  evidence,
@@ -3894,7 +4056,7 @@ export async function prepareRelease(options) {
3894
4056
 
3895
4057
  // --- Step 5: Build snapshots, scan, and evaluate README ---
3896
4058
  const { unitResults, snapshotDigests } = await processSnapshots(
3897
- config, realRoot, evidence, runDir, production,
4059
+ selectedConfig, realRoot, evidence, runDir, production,
3898
4060
  );
3899
4061
 
3900
4062
  // Snapshot gates always run on disposable writable copies. The public
@@ -4280,7 +4442,7 @@ export async function prepareRelease(options) {
4280
4442
  }
4281
4443
 
4282
4444
  let publicSourceAuthorityReceipt = null;
4283
- if (config.publicSourceAuthorityReceipt) {
4445
+ if (config.publicSourceAuthorityReceipt && releaseSelection.receiptSelected) {
4284
4446
  // P3 (ruling 7): config scenario A (no-publish-needed) means the public
4285
4447
  // bytes are unchanged and nothing will be published — a configured
4286
4448
  // public source receipt must not require or generate a nonexistent
@@ -4524,7 +4686,7 @@ export async function prepareRelease(options) {
4524
4686
  },
4525
4687
  }
4526
4688
  : {}),
4527
- verificationGates: config.verificationGates ?? [],
4689
+ verificationGates: declaredVerificationGates,
4528
4690
  snapshotDigest: overallSnapshotDigest,
4529
4691
  ...(humanConsumersStrategy ? { humanConsumersStrategy } : {}),
4530
4692
  ...(production ? {
@@ -4608,6 +4770,13 @@ export async function prepareRelease(options) {
4608
4770
  // carry no such warning. The prepare command's own defaults are unchanged —
4609
4771
  // this only enriches the result, never flips offline/production.
4610
4772
  const nextSteps = [];
4773
+ if (releaseSelection.explicit && releaseSelection.deferredUnitIds.length > 0) {
4774
+ nextSteps.push({
4775
+ code: 'DEFERRED_RELEASE_UNITS',
4776
+ message: `Prepare the deferred release units separately: ${releaseSelection.deferredUnitIds.join(', ')}`,
4777
+ argv: releaseSelection.deferredReleaseDiagnostics.nextPrepareArgv,
4778
+ });
4779
+ }
4611
4780
  if (!production) {
4612
4781
  nextSteps.push({
4613
4782
  code: 'NON_PRODUCTION_PLAN_NOT_PUBLISHABLE',
@@ -4624,6 +4793,13 @@ export async function prepareRelease(options) {
4624
4793
  evidenceDir,
4625
4794
  warnings: runWarnings,
4626
4795
  nextSteps,
4796
+ ...(releaseSelection.explicit ? {
4797
+ releaseScope: {
4798
+ mode: 'selected',
4799
+ selectedUnitIds: releaseSelection.selectedUnitIds,
4800
+ deferredUnitIds: releaseSelection.deferredUnitIds,
4801
+ },
4802
+ } : {}),
4627
4803
  };
4628
4804
  } catch (err) {
4629
4805
  // Record failure evidence (best effort). A broken clock can make the
@@ -15,11 +15,92 @@ import {
15
15
  } from './post-release-local.mjs';
16
16
  import {
17
17
  ReleaseError,
18
+ CONFIG_INVALID,
18
19
  GATE_FAILED,
19
20
  PLAN_DIGEST_MISMATCH,
20
21
  CONSUMER_VERIFICATION_DEFERRED,
21
22
  } from '../core/errors.mjs';
22
23
 
24
+ function normalizeNewShipUnitIds(releaseUnits, requestedUnitIds) {
25
+ if (requestedUnitIds === undefined) return undefined;
26
+ const availableUnitIds = (releaseUnits ?? []).map((unit) => unit.id);
27
+ if (!Array.isArray(requestedUnitIds) || requestedUnitIds.length === 0) {
28
+ throw new ReleaseError(
29
+ CONFIG_INVALID,
30
+ 'explicit ship release-unit selection must contain at least one unit id',
31
+ { requestedUnitIds, availableUnitIds },
32
+ );
33
+ }
34
+ const requestedSet = new Set(requestedUnitIds);
35
+ const invalid = requestedUnitIds.filter((unitId) => typeof unitId !== 'string' || unitId.length === 0);
36
+ const unknown = requestedUnitIds.filter((unitId) => !availableUnitIds.includes(unitId));
37
+ if (invalid.length > 0 || requestedSet.size !== requestedUnitIds.length || unknown.length > 0) {
38
+ throw new ReleaseError(
39
+ CONFIG_INVALID,
40
+ 'ship release-unit selection contains empty, duplicate, or unknown unit ids',
41
+ { requestedUnitIds, availableUnitIds },
42
+ );
43
+ }
44
+ return availableUnitIds.filter((unitId) => requestedSet.has(unitId));
45
+ }
46
+
47
+ function sameUnitSelection(left, right) {
48
+ return Array.isArray(left)
49
+ && Array.isArray(right)
50
+ && left.length === right.length
51
+ && new Set(left).size === left.length
52
+ && new Set(right).size === right.length
53
+ && left.every((unitId) => right.includes(unitId));
54
+ }
55
+
56
+ function sameUnitOrder(left, right) {
57
+ return Array.isArray(left)
58
+ && Array.isArray(right)
59
+ && left.length === right.length
60
+ && left.every((unitId, index) => unitId === right[index]);
61
+ }
62
+
63
+ function unitScopeMismatch(statePath, stateUnitIds, requestedUnitIds, message) {
64
+ return new ReleaseError(
65
+ GATE_FAILED,
66
+ message,
67
+ {
68
+ reason: 'SHIP_STATE_UNIT_SCOPE_MISMATCH',
69
+ statePath,
70
+ stateUnitIds: stateUnitIds ?? null,
71
+ requestedUnitIds: requestedUnitIds ?? null,
72
+ },
73
+ );
74
+ }
75
+
76
+ function assertFrozenPlanUnitScope({ statePath, stateUnitIds, expectedUnitIds, plan }) {
77
+ const planUnitIds = (plan.units ?? []).map((unit) => unit.id);
78
+ const exactMatch = Array.isArray(expectedUnitIds)
79
+ && planUnitIds.length === expectedUnitIds.length
80
+ && planUnitIds.every((unitId, index) => unitId === expectedUnitIds[index]);
81
+ if (!exactMatch) {
82
+ throw unitScopeMismatch(
83
+ statePath,
84
+ stateUnitIds,
85
+ planUnitIds,
86
+ 'ship state release-unit scope does not match frozen plan.units',
87
+ );
88
+ }
89
+ }
90
+
91
+ async function readFrozenPlanForUnitScope(planPath, statePath, stateUnitIds) {
92
+ try {
93
+ return JSON.parse(await readFile(planPath, 'utf8'));
94
+ } catch (error) {
95
+ throw unitScopeMismatch(
96
+ statePath,
97
+ stateUnitIds,
98
+ null,
99
+ `cannot verify ship state release-unit scope against frozen plan: ${error.message}`,
100
+ );
101
+ }
102
+ }
103
+
23
104
  async function writeJsonAtomic(path, value) {
24
105
  await mkdir(dirname(path), { recursive: true });
25
106
  const { stateDigest: _oldDigest, ...body } = value;
@@ -102,6 +183,7 @@ function publicState(state) {
102
183
  ...(state.hooks && state.hooks.length > 0 ? {
103
184
  hooks: state.hooks,
104
185
  } : {}),
186
+ ...(state.selectedUnitIds ? { selectedUnitIds: state.selectedUnitIds } : {}),
105
187
  ...(state.planPath ? {
106
188
  planPath: state.planPath,
107
189
  planDigest: state.planDigest,
@@ -334,6 +416,24 @@ export async function advanceShip(options = {}, injected = {}) {
334
416
  );
335
417
  }
336
418
  }
419
+ if (state && options.unitIds !== undefined) {
420
+ if (!Array.isArray(state.selectedUnitIds)) {
421
+ throw unitScopeMismatch(
422
+ statePath,
423
+ null,
424
+ options.unitIds,
425
+ 'an existing full-scope or legacy ship state cannot acquire --unit; provide a new --state path',
426
+ );
427
+ }
428
+ if (!sameUnitSelection(state.selectedUnitIds, options.unitIds)) {
429
+ throw unitScopeMismatch(
430
+ statePath,
431
+ state.selectedUnitIds,
432
+ options.unitIds,
433
+ 'ship state release-unit scope does not match the requested --unit values',
434
+ );
435
+ }
436
+ }
337
437
  if (state?.gitTransport) {
338
438
  process.env.RELEASE_SKILL_GIT_TRANSPORT = state.gitTransport;
339
439
  }
@@ -341,6 +441,7 @@ export async function advanceShip(options = {}, injected = {}) {
341
441
  if (!state) {
342
442
  const loaded = await deps.loadProjectConfig({ root });
343
443
  const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
444
+ const selectedUnitIds = normalizeNewShipUnitIds(loaded.config.releaseUnits, options.unitIds);
344
445
  state = {
345
446
  schemaVersion: 2,
346
447
  root,
@@ -348,6 +449,7 @@ export async function advanceShip(options = {}, injected = {}) {
348
449
  targetVersion: options.targetVersion ?? null,
349
450
  configDigest: loaded.configDigest,
350
451
  hooks,
452
+ ...(selectedUnitIds ? { selectedUnitIds } : {}),
351
453
  status: 'NEW',
352
454
  createdAt: new Date().toISOString(),
353
455
  updatedAt: new Date().toISOString(),
@@ -364,8 +466,53 @@ export async function advanceShip(options = {}, injected = {}) {
364
466
  await writeJsonAtomic(statePath, state);
365
467
  }
366
468
 
469
+ if (state.planPath && Array.isArray(state.selectedUnitIds)) {
470
+ const frozenPlan = await readFrozenPlanForUnitScope(
471
+ state.planPath,
472
+ statePath,
473
+ state.selectedUnitIds,
474
+ );
475
+ assertFrozenPlanUnitScope({
476
+ statePath,
477
+ stateUnitIds: state.selectedUnitIds,
478
+ expectedUnitIds: state.selectedUnitIds,
479
+ plan: frozenPlan,
480
+ });
481
+ }
482
+
367
483
  if (state.status === 'NEW') {
368
484
  const loaded = await deps.loadProjectConfig({ root });
485
+ let stateChanged = false;
486
+ if (Array.isArray(state.selectedUnitIds)) {
487
+ const normalizedStateUnitIds = normalizeNewShipUnitIds(
488
+ loaded.config.releaseUnits,
489
+ state.selectedUnitIds,
490
+ );
491
+ if (!sameUnitSelection(normalizedStateUnitIds, state.selectedUnitIds)) {
492
+ throw unitScopeMismatch(
493
+ statePath,
494
+ state.selectedUnitIds,
495
+ normalizedStateUnitIds,
496
+ 'ship state release-unit scope no longer matches project configuration',
497
+ );
498
+ }
499
+ if (!sameUnitOrder(normalizedStateUnitIds, state.selectedUnitIds)) {
500
+ if (state.planPath) {
501
+ throw unitScopeMismatch(
502
+ statePath,
503
+ state.selectedUnitIds,
504
+ normalizedStateUnitIds,
505
+ 'frozen ship state release-unit order no longer matches project configuration',
506
+ );
507
+ }
508
+ state = {
509
+ ...state,
510
+ selectedUnitIds: normalizedStateUnitIds,
511
+ updatedAt: new Date().toISOString(),
512
+ };
513
+ stateChanged = true;
514
+ }
515
+ }
369
516
  if (loaded.configDigest !== state.configDigest) {
370
517
  const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
371
518
  state = {
@@ -375,8 +522,9 @@ export async function advanceShip(options = {}, injected = {}) {
375
522
  status: 'NEW',
376
523
  updatedAt: new Date().toISOString(),
377
524
  };
378
- await writeJsonAtomic(statePath, state);
525
+ stateChanged = true;
379
526
  }
527
+ if (stateChanged) await writeJsonAtomic(statePath, state);
380
528
  const prepared = await deps.prepareRelease({
381
529
  root,
382
530
  version: state.targetVersion ?? undefined,
@@ -385,10 +533,24 @@ export async function advanceShip(options = {}, injected = {}) {
385
533
  hooksAuthorized: true,
386
534
  verificationGatesAuthorized: true,
387
535
  hookCache: true,
536
+ ...(state.selectedUnitIds ? { unitIds: state.selectedUnitIds } : {}),
388
537
  });
538
+ let frozenPlan = null;
539
+ if (Array.isArray(state.selectedUnitIds) || deps.preflightGitTransports) {
540
+ frozenPlan = Array.isArray(state.selectedUnitIds)
541
+ ? await readFrozenPlanForUnitScope(prepared.planPath, statePath, state.selectedUnitIds)
542
+ : JSON.parse(await readFile(prepared.planPath, 'utf8'));
543
+ }
544
+ if (Array.isArray(state.selectedUnitIds)) {
545
+ assertFrozenPlanUnitScope({
546
+ statePath,
547
+ stateUnitIds: state.selectedUnitIds,
548
+ expectedUnitIds: state.selectedUnitIds,
549
+ plan: frozenPlan,
550
+ });
551
+ }
389
552
  let transportPreflight = null;
390
553
  if (deps.preflightGitTransports) {
391
- const frozenPlan = JSON.parse(await readFile(prepared.planPath, 'utf8'));
392
554
  transportPreflight = await deps.preflightGitTransports(frozenPlan);
393
555
  process.env.RELEASE_SKILL_GIT_TRANSPORT = transportPreflight.transport;
394
556
  }
@@ -33,7 +33,6 @@ import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
33
33
  // this value so an upstream contract change fails closed during adoption.
34
34
  const FOUNDATION_PLUGIN_DRIVER_VERSION = '1.0.0';
35
35
  const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
36
- const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
37
36
 
38
37
  function isSafeRelativePath(value) {
39
38
  return typeof value === 'string'
@@ -46,7 +45,28 @@ function isSafeRelativePath(value) {
46
45
  }
47
46
 
48
47
  function decodeCanonicalBase64(value) {
49
- if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) return null;
48
+ if (typeof value !== 'string' || value.length % 4 !== 0) return null;
49
+ let paddingStart = -1;
50
+ for (let index = 0; index < value.length; index += 1) {
51
+ const code = value.charCodeAt(index);
52
+ if (code === 61) {
53
+ if (paddingStart === -1) paddingStart = index;
54
+ continue;
55
+ }
56
+ if (paddingStart !== -1 || !(
57
+ code >= 65 && code <= 90
58
+ || code >= 97 && code <= 122
59
+ || code >= 48 && code <= 57
60
+ || code === 43
61
+ || code === 47
62
+ )) return null;
63
+ }
64
+ if (
65
+ paddingStart !== -1
66
+ && value.length - paddingStart > 2
67
+ || paddingStart !== -1
68
+ && paddingStart % 4 < 2
69
+ ) return null;
50
70
  const decoded = Buffer.from(value, 'base64');
51
71
  return decoded.toString('base64') === value ? decoded : null;
52
72
  }
@@ -55,9 +55,9 @@ import { digestBytes, publishFileOrReplace, readFileStrict, withTemporaryWorkspa
55
55
  import { canonicalJson } from '../core/digest.mjs';
56
56
 
57
57
  export const FOUNDATION_PACKAGES = Object.freeze({
58
- 'skill-family-contracts': '0.15.0',
59
- 'skill-family-engineering-kit': '0.15.0',
60
- 'skill-family-harness-node': '0.15.0',
58
+ 'skill-family-contracts': '0.16.0',
59
+ 'skill-family-engineering-kit': '0.16.0',
60
+ 'skill-family-harness-node': '0.16.0',
61
61
  });
62
62
 
63
63
  export const BINDING_RECORD_PATH = 'bin/foundation-resource-binding.json';