release-skill 0.7.6 → 0.7.7

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 (48) 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 +20 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +15 -12
  10. package/README.zh-CN.md +14 -11
  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/release-skill.bundle.mjs +1725 -1156
  14. package/adapters/claude/schemas/release-plan.schema.json +47 -0
  15. package/adapters/claude/schemas/release-project.schema.json +24 -0
  16. package/adapters/claude/skills/release-prepare/SKILL.md +4 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +1725 -1156
  19. package/adapters/codex/schemas/release-plan.schema.json +47 -0
  20. package/adapters/codex/schemas/release-project.schema.json +24 -0
  21. package/adapters/codex/skills/release-prepare/SKILL.md +4 -0
  22. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  23. package/adapters/kimi/bin/release-skill.bundle.mjs +1725 -1156
  24. package/adapters/kimi/schemas/release-plan.schema.json +47 -0
  25. package/adapters/kimi/schemas/release-project.schema.json +24 -0
  26. package/adapters/kimi/skills/release-prepare/SKILL.md +4 -0
  27. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  28. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1725 -1156
  29. package/adapters/workbuddy/schemas/release-plan.schema.json +47 -0
  30. package/adapters/workbuddy/schemas/release-project.schema.json +24 -0
  31. package/adapters/workbuddy/skills/release-prepare/SKILL.md +4 -0
  32. package/bin/release-skill-cli.mjs +116 -47
  33. package/bin/release-skill.bundle.mjs +1725 -1156
  34. package/package.json +1 -1
  35. package/platform-manifest.json +4 -4
  36. package/references/02-project-config.md +18 -0
  37. package/schemas/release-plan.schema.json +47 -0
  38. package/schemas/release-project.schema.json +24 -0
  39. package/skills/release-prepare/SKILL.md +4 -0
  40. package/skills-src/release-prepare/SKILL.md +4 -0
  41. package/src/adapters/git-github.mjs +139 -26
  42. package/src/commands/prepare.mjs +72 -3
  43. package/src/commands/publish.mjs +36 -0
  44. package/src/commands/verify.mjs +53 -1
  45. package/src/core/config.mjs +31 -0
  46. package/src/core/plan.mjs +78 -0
  47. package/src/core/release-assets.mjs +54 -0
  48. package/src/core/source-authority.mjs +127 -3
@@ -504,6 +504,37 @@ export async function loadProjectConfig({ root, configPath } = {}) {
504
504
  // inferred at execution time. A gate must name one existing unit and, for
505
505
  // consumer verification, one distribution that the unit actually ships.
506
506
  const unitsById = new Map(config.releaseUnits.map((unit) => [unit.id, unit]));
507
+ const publicReceipt = config.publicSourceAuthorityReceipt;
508
+ if (publicReceipt) {
509
+ if (!config.project?.sourceRepository) {
510
+ throw new ReleaseError(
511
+ CONFIG_INVALID,
512
+ 'publicSourceAuthorityReceipt requires project.sourceRepository',
513
+ );
514
+ }
515
+ if (!unitsById.has(publicReceipt.coordinatorUnitId)) {
516
+ throw new ReleaseError(
517
+ CONFIG_INVALID,
518
+ `publicSourceAuthorityReceipt references unknown coordinator unit "${publicReceipt.coordinatorUnitId}"`,
519
+ );
520
+ }
521
+ for (const unitId of publicReceipt.subjectUnitIds) {
522
+ const unit = unitsById.get(unitId);
523
+ if (!unit) {
524
+ throw new ReleaseError(
525
+ CONFIG_INVALID,
526
+ `publicSourceAuthorityReceipt references unknown subject unit "${unitId}"`,
527
+ );
528
+ }
529
+ const npmDistributions = (unit.distributions ?? []).filter((distribution) => distribution.type === 'npm');
530
+ if (npmDistributions.length !== 1) {
531
+ throw new ReleaseError(
532
+ CONFIG_INVALID,
533
+ `publicSourceAuthorityReceipt subject unit "${unitId}" must declare exactly one npm distribution`,
534
+ );
535
+ }
536
+ }
537
+ }
507
538
  const gateIds = new Set();
508
539
  for (const gate of config.verificationGates ?? []) {
509
540
  if (gateIds.has(gate.id)) {
package/src/core/plan.mjs CHANGED
@@ -26,6 +26,8 @@ import { canonicalJson, sha256Hex } from './digest.mjs';
26
26
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
27
27
  import { readTrustedPackageResource } from './trusted-resource.mjs';
28
28
  import { computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from './installation-contract.mjs';
29
+ import { createPublicSourceAuthorityReceipt } from './source-authority.mjs';
30
+ import { digestReleaseAssetIdentities, normalizeReleaseAssets } from './release-assets.mjs';
29
31
  // NOTE (T2.2 step 3): plan.mjs and the platform registry form a module cycle
30
32
  // (plan -> registry -> platforms/kimi -> plan, because the kimi strategies
31
33
  // bind computePlanDigest). PLATFORMS is therefore only ever read inside
@@ -450,6 +452,57 @@ export function validatePlanActionCompleteness(plan, options = {}) {
450
452
  const units = Array.isArray(plan.units) ? plan.units : [];
451
453
  const actions = Array.isArray(plan.externalActions) ? plan.externalActions : [];
452
454
  const expectedAdapterMap = buildExpectedAdapterMap();
455
+ let publicReceiptBinding = null;
456
+ if (plan.publicSourceAuthorityReceipt) {
457
+ try {
458
+ if (plan.production?.mode !== 'github-npm-v1') {
459
+ failures.push('publicSourceAuthorityReceipt requires a production plan');
460
+ }
461
+ if (!plan.sourceAuthority?.sourceRepository || !plan.baseline?.headCommit) {
462
+ failures.push('publicSourceAuthorityReceipt requires sourceAuthority.sourceRepository and baseline.headCommit');
463
+ }
464
+ const descriptor = plan.publicSourceAuthorityReceipt;
465
+ const coordinator = units.find((unit) => unit.id === descriptor.coordinatorUnitId);
466
+ if (!coordinator) failures.push(`publicSourceAuthorityReceipt references unknown coordinator unit "${descriptor.coordinatorUnitId}"`);
467
+ const subjectIds = descriptor.subjectUnitIds ?? [];
468
+ if (new Set(subjectIds).size !== subjectIds.length) {
469
+ failures.push('publicSourceAuthorityReceipt subjectUnitIds must be unique');
470
+ }
471
+ const subjects = subjectIds.map((unitId) => {
472
+ const unit = units.find((candidate) => candidate.id === unitId);
473
+ if (!unit) throw new Error(`unknown subject unit "${unitId}"`);
474
+ const npmDistributions = (unit.distributions ?? []).filter((distribution) => distribution.type === 'npm');
475
+ if (npmDistributions.length !== 1 || !unit.frozenSnapshot?.npm) {
476
+ throw new Error(`subject unit "${unitId}" must bind exactly one frozen npm tarball`);
477
+ }
478
+ return {
479
+ packageName: npmDistributions[0].package,
480
+ version: unit.targetVersion,
481
+ filename: basename(unit.frozenSnapshot.npm.tarballPath),
482
+ sha256: unit.frozenSnapshot.npm.tarballSha256,
483
+ };
484
+ });
485
+ const rebuilt = createPublicSourceAuthorityReceipt({
486
+ sourceRepository: plan.sourceAuthority?.sourceRepository,
487
+ sourceBaseCommit: plan.baseline?.headCommit,
488
+ subjects,
489
+ });
490
+ const assets = normalizeReleaseAssets([descriptor.asset]);
491
+ if (descriptor.asset.name !== 'source-authority-receipt.json') {
492
+ failures.push('publicSourceAuthorityReceipt asset name must be source-authority-receipt.json');
493
+ }
494
+ if (descriptor.asset.sha256 !== rebuilt.sha256) {
495
+ failures.push('publicSourceAuthorityReceipt asset SHA-256 does not match the mechanically derived receipt bytes');
496
+ }
497
+ publicReceiptBinding = {
498
+ coordinatorUnitId: descriptor.coordinatorUnitId,
499
+ assets,
500
+ assetsDigest: digestReleaseAssetIdentities(assets),
501
+ };
502
+ } catch (error) {
503
+ failures.push(`publicSourceAuthorityReceipt is invalid: ${error.message}`);
504
+ }
505
+ }
453
506
 
454
507
  // --- Must have at least one action ---
455
508
  if (actions.length === 0) {
@@ -666,6 +719,31 @@ export function validatePlanActionCompleteness(plan, options = {}) {
666
719
  _checkRequired(action, 'parameters.notes', action.parameters?.notes, productionConfig.releaseNotes ?? `Release ${expectedTag}`, unitId, failures);
667
720
  _checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
668
721
  _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
722
+ if (publicReceiptBinding?.coordinatorUnitId === unitId) {
723
+ try {
724
+ const actualAssets = normalizeReleaseAssets(action.parameters?.releaseAssets);
725
+ if (canonicalJson(actualAssets) !== canonicalJson(publicReceiptBinding.assets)) {
726
+ failures.push(`unit "${unitId}", action "${action.id}": parameters.releaseAssets do not match publicSourceAuthorityReceipt.asset`);
727
+ }
728
+ } catch (error) {
729
+ failures.push(`unit "${unitId}", action "${action.id}": invalid parameters.releaseAssets: ${error.message}`);
730
+ }
731
+ _checkRequired(
732
+ action,
733
+ 'expected.releaseAssetsDigest',
734
+ action.expected?.releaseAssetsDigest,
735
+ publicReceiptBinding.assetsDigest,
736
+ unitId,
737
+ failures,
738
+ );
739
+ } else {
740
+ if (action.parameters?.releaseAssets !== undefined) {
741
+ failures.push(`unit "${unitId}", action "${action.id}": unexpected parameters.releaseAssets`);
742
+ }
743
+ if (action.expected?.releaseAssetsDigest !== undefined) {
744
+ failures.push(`unit "${unitId}", action "${action.id}": unexpected expected.releaseAssetsDigest`);
745
+ }
746
+ }
669
747
  }
670
748
  }
671
749
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Frozen GitHub Release asset identities.
3
+ *
4
+ * This module owns only release-domain mapping: a plan freezes local asset
5
+ * paths and their already-computed SHA-256 values, while remote observation
6
+ * reduces the downloaded bytes to the same name/digest identity list.
7
+ */
8
+
9
+ import { basename } from 'node:path';
10
+
11
+ import { canonicalJson, sha256Hex } from './digest.mjs';
12
+
13
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
14
+ const SAFE_ASSET_NAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/u;
15
+
16
+ /** Validate and normalize a plan's optional frozen Release assets. */
17
+ export function normalizeReleaseAssets(assets) {
18
+ if (assets === undefined) return [];
19
+ if (!Array.isArray(assets) || assets.length === 0) {
20
+ throw new TypeError('releaseAssets must be a non-empty array when present');
21
+ }
22
+ const names = new Set();
23
+ const normalized = assets.map((asset, index) => {
24
+ if (!asset || typeof asset !== 'object' || Array.isArray(asset)) {
25
+ throw new TypeError(`releaseAssets[${index}] must be an object`);
26
+ }
27
+ const keys = Object.keys(asset).sort();
28
+ if (canonicalJson(keys) !== canonicalJson(['name', 'path', 'sha256'])) {
29
+ throw new TypeError(`releaseAssets[${index}] must contain only name, path, and sha256`);
30
+ }
31
+ if (typeof asset.path !== 'string' || asset.path.length === 0) {
32
+ throw new TypeError(`releaseAssets[${index}].path must be a non-empty project-relative path`);
33
+ }
34
+ if (!SAFE_ASSET_NAME_RE.test(asset.name ?? '') || basename(asset.path) !== asset.name) {
35
+ throw new TypeError(`releaseAssets[${index}].name must be a safe basename matching path`);
36
+ }
37
+ if (!SHA256_RE.test(asset.sha256 ?? '')) {
38
+ throw new TypeError(`releaseAssets[${index}].sha256 must be a lowercase SHA-256 digest`);
39
+ }
40
+ if (names.has(asset.name)) {
41
+ throw new TypeError(`releaseAssets contains duplicate asset name "${asset.name}"`);
42
+ }
43
+ names.add(asset.name);
44
+ return Object.freeze({ name: asset.name, path: asset.path, sha256: asset.sha256 });
45
+ });
46
+ normalized.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
47
+ return Object.freeze(normalized);
48
+ }
49
+
50
+ /** Digest only public remote identity, never local paths. */
51
+ export function digestReleaseAssetIdentities(assets) {
52
+ const identities = normalizeReleaseAssets(assets).map(({ name, sha256 }) => ({ name, sha256 }));
53
+ return sha256Hex(canonicalJson(identities));
54
+ }
@@ -27,9 +27,115 @@ import {
27
27
  const execFile = promisify(execFileCb);
28
28
  const REPOSITORY_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u;
29
29
  const GIT_MODE_RE = /^(?:100644|100755)$/u;
30
+ const COMMIT_RE = /^[a-f0-9]{40,64}$/u;
31
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
32
+ const NPM_PACKAGE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/u;
33
+ const TARBALL_FILENAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9@][A-Za-z0-9._@+-]*\.tgz$/u;
30
34
 
31
35
  export const SOURCE_INPUT_ALGORITHM_VERSION = 1;
32
36
 
37
+ /**
38
+ * Build the intentionally small public source-authority receipt.
39
+ *
40
+ * The receipt is an external release asset. It contains no plan/run fields
41
+ * and no self digest; the frozen action binds the canonical bytes instead.
42
+ */
43
+ export function createPublicSourceAuthorityReceipt({
44
+ sourceRepository,
45
+ sourceBaseCommit,
46
+ subjects,
47
+ }) {
48
+ if (!REPOSITORY_RE.test(sourceRepository ?? '')) {
49
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt requires an explicit GitHub owner/repo');
50
+ }
51
+ if (!COMMIT_RE.test(sourceBaseCommit ?? '')) {
52
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt requires a full hexadecimal source base commit');
53
+ }
54
+ if (!Array.isArray(subjects) || subjects.length === 0) {
55
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt requires at least one npm tarball subject');
56
+ }
57
+ const packageNames = new Set();
58
+ const normalizedSubjects = subjects.map((subject, index) => {
59
+ const keys = Object.keys(subject ?? {}).sort();
60
+ if (canonicalJson(keys) !== canonicalJson(['filename', 'packageName', 'sha256', 'version'])) {
61
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority subject ${index} has an invalid field set`);
62
+ }
63
+ if (!NPM_PACKAGE_RE.test(subject.packageName ?? '')) {
64
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority subject ${index} has an invalid packageName`);
65
+ }
66
+ if (packageNames.has(subject.packageName)) {
67
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority receipt has duplicate packageName "${subject.packageName}"`);
68
+ }
69
+ packageNames.add(subject.packageName);
70
+ if (typeof subject.version !== 'string' || subject.version.length === 0) {
71
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority subject "${subject.packageName}" has an invalid version`);
72
+ }
73
+ if (!TARBALL_FILENAME_RE.test(subject.filename ?? '')) {
74
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority subject "${subject.packageName}" has an invalid tarball filename`);
75
+ }
76
+ if (!SHA256_RE.test(subject.sha256 ?? '')) {
77
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority subject "${subject.packageName}" has an invalid SHA-256`);
78
+ }
79
+ return {
80
+ packageName: subject.packageName,
81
+ version: subject.version,
82
+ filename: subject.filename,
83
+ sha256: subject.sha256,
84
+ };
85
+ });
86
+ normalizedSubjects.sort((left, right) => (
87
+ left.packageName < right.packageName ? -1 : left.packageName > right.packageName ? 1 : 0
88
+ ));
89
+ const receipt = {
90
+ schemaVersion: 1,
91
+ kind: 'skill-family.source-authority-receipt',
92
+ sourceRepository,
93
+ sourceBaseCommit,
94
+ subjects: normalizedSubjects,
95
+ };
96
+ const bytes = Buffer.from(canonicalJson(receipt), 'utf8');
97
+ return Object.freeze({ receipt: Object.freeze(receipt), bytes, sha256: sha256Hex(bytes) });
98
+ }
99
+
100
+ /** Parse public bytes and verify them against actual offline tarball facts. */
101
+ export function consumePublicSourceAuthorityReceipt(bytes, actualSubjects) {
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes));
105
+ } catch (error) {
106
+ throw new ReleaseError(CONFIG_INVALID, `public source-authority receipt is not valid JSON: ${error.message}`);
107
+ }
108
+ const topKeys = Object.keys(parsed ?? {}).sort();
109
+ if (canonicalJson(topKeys) !== canonicalJson([
110
+ 'kind', 'schemaVersion', 'sourceBaseCommit', 'sourceRepository', 'subjects',
111
+ ])) {
112
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt has an invalid field set');
113
+ }
114
+ if (parsed.schemaVersion !== 1 || parsed.kind !== 'skill-family.source-authority-receipt') {
115
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt kind or schemaVersion is unsupported');
116
+ }
117
+ const rebuilt = createPublicSourceAuthorityReceipt(parsed);
118
+ if (!Buffer.from(bytes).equals(rebuilt.bytes)) {
119
+ throw new ReleaseError(CONFIG_INVALID, 'public source-authority receipt bytes are not canonical JSON');
120
+ }
121
+ if (actualSubjects !== undefined) {
122
+ const actual = createPublicSourceAuthorityReceipt({
123
+ sourceRepository: parsed.sourceRepository,
124
+ sourceBaseCommit: parsed.sourceBaseCommit,
125
+ subjects: actualSubjects,
126
+ }).receipt.subjects;
127
+ if (canonicalJson(actual) !== canonicalJson(rebuilt.receipt.subjects)) {
128
+ throw new ReleaseError(CONTENT_MISMATCH, 'public source-authority receipt subjects do not match actual tarballs');
129
+ }
130
+ }
131
+ return Object.freeze({
132
+ sourceRepository: rebuilt.receipt.sourceRepository,
133
+ sourceBaseCommit: rebuilt.receipt.sourceBaseCommit,
134
+ subjects: rebuilt.receipt.subjects,
135
+ sha256: rebuilt.sha256,
136
+ });
137
+ }
138
+
33
139
  /**
34
140
  * Compute the deterministic source-input closure for release units.
35
141
  *
@@ -325,6 +431,7 @@ async function verifyWithInjectedReader({
325
431
  sourceRepository,
326
432
  }) {
327
433
  const mismatchedPaths = [];
434
+ let observedCommit;
328
435
  for (const entry of closure.entries) {
329
436
  let result;
330
437
  try {
@@ -334,6 +441,12 @@ async function verifyWithInjectedReader({
334
441
  }
335
442
  const classified = classifyRemoteStatus(result, sourceRepository, defaultBranch);
336
443
  if (classified) return classified;
444
+ if (result.observedCommit !== undefined) {
445
+ if (observedCommit !== undefined && observedCommit !== result.observedCommit) {
446
+ return failure(REMOTE_UNAVAILABLE, 'remote source reader returned inconsistent observed commits');
447
+ }
448
+ observedCommit = result.observedCommit;
449
+ }
337
450
  if (
338
451
  sha256Hex(Buffer.isBuffer(result.content) ? result.content : Buffer.from(result.content))
339
452
  !== entry.digest
@@ -349,6 +462,7 @@ async function verifyWithInjectedReader({
349
462
  observation: {
350
463
  defaultBranch,
351
464
  entryCount: closure.entries.length,
465
+ ...(observedCommit ? { observedCommit } : {}),
352
466
  sourceRepository,
353
467
  },
354
468
  };
@@ -515,12 +629,22 @@ export function verifySourceAuthorityReceipt({ plan, run }) {
515
629
  && receipt.planDigest === plan.digest
516
630
  && receipt.result === 'CONSISTENT'
517
631
  ));
518
- return matching
519
- ? { passed: true, receipt: matching }
520
- : {
632
+ if (!matching) {
633
+ return {
521
634
  passed: false,
522
635
  reason: 'publish run has no CONSISTENT source-authority receipt bound to this plan digest',
523
636
  };
637
+ }
638
+ if (
639
+ plan.publicSourceAuthorityReceipt
640
+ && matching.observedCommit !== plan.baseline?.headCommit
641
+ ) {
642
+ return {
643
+ passed: false,
644
+ reason: 'publish source-authority receipt observedCommit does not match the public receipt sourceBaseCommit',
645
+ };
646
+ }
647
+ return { passed: true, receipt: matching };
524
648
  }
525
649
 
526
650
  /** Create the digest-bound receipt persisted by publish. */