release-skill 0.2.9 → 0.3.0

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 (36) 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 +21 -0
  7. package/INSTALL.md +4 -4
  8. package/INSTALL.zh-CN.md +4 -4
  9. package/README.md +38 -7
  10. package/README.zh-CN.md +36 -7
  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 +3908 -2864
  14. package/adapters/claude/skills/release-help/SKILL.md +9 -1
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +3908 -2864
  17. package/adapters/codex/skills/release-help/SKILL.md +9 -1
  18. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  19. package/adapters/kimi/bin/release-skill.bundle.mjs +3908 -2864
  20. package/adapters/kimi/skills/release-help/SKILL.md +9 -1
  21. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  22. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3908 -2864
  23. package/adapters/workbuddy/skills/release-help/SKILL.md +9 -1
  24. package/bin/release-skill-cli.mjs +176 -4
  25. package/bin/release-skill.bundle.mjs +3908 -2864
  26. package/package.json +1 -1
  27. package/skills/release-help/SKILL.md +9 -1
  28. package/skills-src/release-help/SKILL.md +9 -1
  29. package/src/adapters/plugin-marketplace.mjs +1 -1
  30. package/src/adapters/push-snapshot.mjs +3 -1
  31. package/src/commands/attest.mjs +195 -0
  32. package/src/commands/hooks.mjs +46 -0
  33. package/src/commands/ship.mjs +356 -0
  34. package/src/commands/verify.mjs +147 -4
  35. package/src/core/git-transport.mjs +93 -0
  36. package/src/core/release-metadata.mjs +105 -0
@@ -43,6 +43,7 @@ import {
43
43
  GATE_FAILED,
44
44
  CONFIG_MISSING,
45
45
  POST_PUBLISH_VERIFY_FAILED,
46
+ CONSUMER_VERIFICATION_DEFERRED,
46
47
  } from '../core/errors.mjs';
47
48
  import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
48
49
  import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
@@ -67,6 +68,20 @@ import {
67
68
  buildDirectoryFileIndex,
68
69
  checkNpmEntryClosure,
69
70
  } from '../npm/npm-entry-closure.mjs';
71
+ import {
72
+ KIMI_ATTESTATION_FILE,
73
+ KIMI_REQUIREMENT_FILE,
74
+ kimiAuthorityDir,
75
+ resolveBoundPlanDigest,
76
+ validateKimiAttestation,
77
+ } from '../platforms/kimi.mjs';
78
+ import {
79
+ CODEBUDDY_ATTESTATION_FILE,
80
+ CODEBUDDY_REQUIREMENT_FILE,
81
+ codebuddyAuthorityDir,
82
+ resolveCodeBuddyBoundPlanDigest,
83
+ validateCodeBuddyAttestation,
84
+ } from '../platforms/codebuddy.mjs';
70
85
 
71
86
  // ---------------------------------------------------------------------------
72
87
  // Constants
@@ -120,6 +135,103 @@ function isValidDigest(digest) {
120
135
  return typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest);
121
136
  }
122
137
 
138
+ /**
139
+ * Generate every manual consumer requirement before any expensive automatic
140
+ * consumer install starts. This turns a sequence of "install Kimi, rerun,
141
+ * then discover CodeBuddy" failures into one actionable response.
142
+ */
143
+ async function collectMissingManualAttestations({
144
+ actions,
145
+ adapterRegistry,
146
+ plan,
147
+ root,
148
+ runDir,
149
+ clockFn,
150
+ }) {
151
+ const manual = actions.filter((action) => (
152
+ action.type === 'kimi-marketplace-install'
153
+ || action.type === 'codebuddy-marketplace-install'
154
+ ));
155
+ if (manual.length === 0) return [];
156
+
157
+ const missing = [];
158
+ for (const action of manual) {
159
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
160
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
161
+ // Tests and embedders may intentionally register a fully automatic
162
+ // adapter for these action types. Only the real platform adapter owns the
163
+ // interactive manual-install protocol and its stable authority files.
164
+ if (adapter.name !== 'plugin-marketplace') continue;
165
+ const context = {
166
+ externalWritesAuthorized: false,
167
+ isolatedConsumerWritesAuthorized: true,
168
+ plan,
169
+ baseline: plan.baseline,
170
+ root,
171
+ runDir,
172
+ };
173
+ const actionInput = { actionType: adapterActionType, ...action.parameters };
174
+ const preflight = await adapter.preflight(actionInput, context);
175
+ if (preflight.status !== 'PREFLIGHT_PASSED') {
176
+ throw new ReleaseError(
177
+ POST_PUBLISH_VERIFY_FAILED,
178
+ `manual consumer preflight did not pass for action "${action.id}": ${preflight.error}`,
179
+ { actionId: action.id, platform: action.type.split('-')[0] },
180
+ );
181
+ }
182
+ const executed = await adapter.execute(actionInput, context);
183
+ if (executed.status !== 'EXECUTED') {
184
+ throw new ReleaseError(
185
+ POST_PUBLISH_VERIFY_FAILED,
186
+ `cannot generate manual consumer requirement for action "${action.id}": ${executed.error}`,
187
+ { actionId: action.id, platform: action.type.split('-')[0] },
188
+ );
189
+ }
190
+
191
+ const isKimi = action.type === 'kimi-marketplace-install';
192
+ const planDigest = isKimi
193
+ ? resolveBoundPlanDigest(context)
194
+ : await resolveCodeBuddyBoundPlanDigest(context);
195
+ const authorityDir = isKimi
196
+ ? kimiAuthorityDir(context, planDigest, action.parameters.plugin)
197
+ : codebuddyAuthorityDir(context, planDigest, action.parameters.plugin);
198
+ const requirementPath = join(
199
+ authorityDir,
200
+ isKimi ? KIMI_REQUIREMENT_FILE : CODEBUDDY_REQUIREMENT_FILE,
201
+ );
202
+ const attestationPath = join(
203
+ authorityDir,
204
+ isKimi ? KIMI_ATTESTATION_FILE : CODEBUDDY_ATTESTATION_FILE,
205
+ );
206
+
207
+ let valid = false;
208
+ let reason = 'attestation file is missing';
209
+ try {
210
+ const attestation = JSON.parse(await readFile(attestationPath, 'utf8'));
211
+ const validation = isKimi
212
+ ? validateKimiAttestation(attestation, action.parameters, clockFn(), planDigest)
213
+ : validateCodeBuddyAttestation(attestation, action.parameters, clockFn(), planDigest);
214
+ valid = validation.valid;
215
+ reason = validation.error;
216
+ } catch (error) {
217
+ if (error?.code !== 'ENOENT') reason = `attestation cannot be read: ${error.message}`;
218
+ }
219
+ if (!valid) {
220
+ missing.push({
221
+ actionId: action.id,
222
+ platform: isKimi ? 'kimi' : 'codebuddy',
223
+ plugin: action.parameters.plugin,
224
+ version: action.parameters.version,
225
+ planDigest,
226
+ requirementPath,
227
+ attestationPath,
228
+ reason,
229
+ });
230
+ }
231
+ }
232
+ return missing;
233
+ }
234
+
123
235
  // ---------------------------------------------------------------------------
124
236
  // Smoke test
125
237
  // ---------------------------------------------------------------------------
@@ -1000,7 +1112,33 @@ export async function verifyRelease(options) {
1000
1112
  }
1001
1113
  }
1002
1114
 
1003
- for (const action of actions) {
1115
+ const missingManualAttestations = await collectMissingManualAttestations({
1116
+ actions,
1117
+ adapterRegistry,
1118
+ plan,
1119
+ root,
1120
+ runDir,
1121
+ clockFn,
1122
+ });
1123
+ if (missingManualAttestations.length > 0) {
1124
+ await evidence.append({
1125
+ phase: 'verify',
1126
+ step: 'manual-attestations',
1127
+ status: 'needs-input',
1128
+ requirements: missingManualAttestations,
1129
+ });
1130
+ throw new ReleaseError(
1131
+ CONSUMER_VERIFICATION_DEFERRED,
1132
+ `${missingManualAttestations.length} manual consumer attestation(s) are required`,
1133
+ { requirements: missingManualAttestations },
1134
+ );
1135
+ }
1136
+
1137
+ // Every action is identity-bound and adapters receive read-only or
1138
+ // per-action isolated consumer paths. Run independent checks concurrently;
1139
+ // the evidence writer serializes append operations and result arrays are
1140
+ // sorted afterwards for deterministic receipts.
1141
+ const actionResults = await Promise.allSettled(actions.map(async (action) => {
1004
1142
  const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
1005
1143
 
1006
1144
  // Skip meta-checkpoints
@@ -1011,7 +1149,7 @@ export async function verifyRelease(options) {
1011
1149
  status: 'SKIPPED',
1012
1150
  reason: 'meta-checkpoint',
1013
1151
  });
1014
- continue;
1152
+ return;
1015
1153
  }
1016
1154
 
1017
1155
  let adapter;
@@ -1142,7 +1280,7 @@ export async function verifyRelease(options) {
1142
1280
  installationContractDigest: currentDigest,
1143
1281
  });
1144
1282
 
1145
- continue;
1283
+ return;
1146
1284
  }
1147
1285
  }
1148
1286
 
@@ -1305,7 +1443,12 @@ export async function verifyRelease(options) {
1305
1443
  );
1306
1444
  }
1307
1445
  }
1308
- }
1446
+ }));
1447
+ adapterChecks.sort((a, b) => a.actionId.localeCompare(b.actionId));
1448
+ consumerVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
1449
+ consumerGateResults.sort((a, b) => a.id.localeCompare(b.id));
1450
+ const rejectedAction = actionResults.find((result) => result.status === 'rejected');
1451
+ if (rejectedAction) throw rejectedAction.reason;
1309
1452
 
1310
1453
  await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'completed' });
1311
1454
 
@@ -0,0 +1,93 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ import { ReleaseError, REMOTE_UNAVAILABLE, REMOTE_CONFLICT } from './errors.mjs';
5
+
6
+ const execFile = promisify(execFileCb);
7
+
8
+ function urls(repo, host) {
9
+ if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
10
+ throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires owner/name repositories');
11
+ }
12
+ const safeHost = host ?? 'github.com';
13
+ if (!/^[A-Za-z0-9.-]+$/.test(safeHost)) {
14
+ throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires a safe GitHub hostname');
15
+ }
16
+ return {
17
+ https: `https://${safeHost}/${repo}.git`,
18
+ ssh: `git@${safeHost}:${repo}.git`,
19
+ };
20
+ }
21
+
22
+ function identity(stdout) {
23
+ const lines = String(stdout).trim().split('\n').filter(Boolean);
24
+ const head = lines.find((line) => /^[a-f0-9]{40,64}\s+HEAD$/.test(line.trim()));
25
+ const symref = lines.find((line) => line.startsWith('ref: '));
26
+ return `${symref ?? ''}\n${head ?? ''}`;
27
+ }
28
+
29
+ export async function preflightGitTransports(plan, options = {}) {
30
+ const exec = options.exec ?? ((command, args, execOptions) => execFile(command, args, execOptions));
31
+ const repositories = new Map();
32
+ for (const action of plan.externalActions ?? []) {
33
+ const repo = action.parameters?.repo;
34
+ if (!repo) continue;
35
+ const host = action.parameters?.githubHost ?? 'github.com';
36
+ repositories.set(`${host}/${repo}`, { repo, host });
37
+ }
38
+ const observations = [];
39
+ for (const { repo, host } of repositories.values()) {
40
+ const candidates = urls(repo, host);
41
+ const observation = { repo, host };
42
+ for (const transport of ['https', 'ssh']) {
43
+ try {
44
+ const { stdout } = await exec(
45
+ 'git',
46
+ ['ls-remote', '--symref', candidates[transport], 'HEAD'],
47
+ { shell: false, encoding: 'utf8', timeout: 30_000 },
48
+ );
49
+ observation[transport] = {
50
+ status: 'available',
51
+ identity: identity(stdout),
52
+ };
53
+ } catch (error) {
54
+ observation[transport] = {
55
+ status: 'unavailable',
56
+ error: error.message,
57
+ };
58
+ }
59
+ }
60
+ if (observation.https.status === 'unavailable' && observation.ssh.status === 'unavailable') {
61
+ throw new ReleaseError(
62
+ REMOTE_UNAVAILABLE,
63
+ `neither HTTPS nor SSH can read ${repo}`,
64
+ { repository: repo, transports: observation },
65
+ );
66
+ }
67
+ if (
68
+ observation.https.status === 'available'
69
+ && observation.ssh.status === 'available'
70
+ && observation.https.identity !== observation.ssh.identity
71
+ ) {
72
+ throw new ReleaseError(
73
+ REMOTE_CONFLICT,
74
+ `HTTPS and SSH resolve different remote identities for ${repo}`,
75
+ { repository: repo },
76
+ );
77
+ }
78
+ observations.push(observation);
79
+ }
80
+ const transport = observations.some((entry) => entry.https.status === 'unavailable')
81
+ ? 'ssh'
82
+ : 'https';
83
+ if (
84
+ transport === 'ssh'
85
+ && observations.some((entry) => entry.ssh.status !== 'available')
86
+ ) {
87
+ throw new ReleaseError(
88
+ REMOTE_UNAVAILABLE,
89
+ 'no single safe Git transport is available for every release repository',
90
+ );
91
+ }
92
+ return { transport, repositories: observations };
93
+ }
@@ -0,0 +1,105 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import YAML from 'yaml';
4
+
5
+ import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from './errors.mjs';
6
+
7
+ function assertOid(value, label) {
8
+ if (typeof value !== 'string' || !/^[a-f0-9]{40,64}$/.test(value)) {
9
+ throw new ReleaseError(GATE_FAILED, `${label} is not a full Git object id`);
10
+ }
11
+ }
12
+
13
+ /**
14
+ * After VERIFIED, advance the project-only previous-public baseline to the
15
+ * exact public commit already frozen, published and verified. This prepares
16
+ * the next release without asking maintainers to copy commits from terminal
17
+ * output. It never edits public files or performs remote writes.
18
+ */
19
+ export async function updatePreviousPublicBaselines(options = {}) {
20
+ const root = resolve(options.root ?? process.cwd());
21
+ const planPath = resolve(options.planPath ?? '');
22
+ const configPath = resolve(root, '.release-skill', 'project.yaml');
23
+ let plan;
24
+ let configRaw;
25
+ let configStat;
26
+ try {
27
+ [plan, configRaw, configStat] = await Promise.all([
28
+ readFile(planPath, 'utf8').then(JSON.parse),
29
+ readFile(configPath, 'utf8'),
30
+ lstat(configPath),
31
+ ]);
32
+ } catch (error) {
33
+ throw new ReleaseError(
34
+ CONFIG_INVALID,
35
+ `cannot load release metadata authorities: ${error.message}`,
36
+ );
37
+ }
38
+ if (!configStat.isFile() || configStat.isSymbolicLink()) {
39
+ throw new ReleaseError(CONFIG_INVALID, 'project config must be a regular non-symlink file');
40
+ }
41
+
42
+ const doc = YAML.parseDocument(configRaw, { uniqueKeys: true });
43
+ if (doc.errors.length > 0) {
44
+ throw new ReleaseError(CONFIG_INVALID, `project config YAML is invalid: ${doc.errors[0].message}`);
45
+ }
46
+ const releaseUnits = doc.get('releaseUnits', true);
47
+ if (!YAML.isSeq(releaseUnits)) {
48
+ throw new ReleaseError(CONFIG_INVALID, 'project config releaseUnits must be a sequence');
49
+ }
50
+ const configUnits = new Map();
51
+ for (const node of releaseUnits.items) {
52
+ const id = node?.get?.('id');
53
+ if (typeof id === 'string') configUnits.set(id, node);
54
+ }
55
+
56
+ const updates = [];
57
+ for (const unit of plan.units ?? []) {
58
+ const configUnit = configUnits.get(unit.id);
59
+ if (!configUnit) {
60
+ throw new ReleaseError(CONFIG_INVALID, `frozen unit "${unit.id}" is absent from project config`);
61
+ }
62
+ const frozen = unit.frozenSnapshot;
63
+ assertOid(frozen?.commit, `unit "${unit.id}" frozen commit`);
64
+ assertOid(frozen?.tree, `unit "${unit.id}" frozen tree`);
65
+ if (!/^[a-f0-9]{64}$/.test(frozen?.manifestDigest ?? '')) {
66
+ throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" frozen manifest digest is invalid`);
67
+ }
68
+ const old = configUnit.get('previousPublicBaseline', true)?.toJSON?.() ?? {};
69
+ const next = {
70
+ mode: 'bound',
71
+ repo: old.repo ?? unit.publicRepo,
72
+ ref: old.ref ?? `refs/heads/${frozen.branch}`,
73
+ commit: frozen.commit,
74
+ tree: frozen.tree,
75
+ manifestDigest: frozen.manifestDigest,
76
+ };
77
+ if (!next.repo || !next.ref) {
78
+ throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" cannot derive its next public baseline identity`);
79
+ }
80
+ configUnit.set('previousPublicBaseline', next);
81
+ updates.push({
82
+ id: unit.id,
83
+ previousCommit: old.commit ?? null,
84
+ commit: frozen.commit,
85
+ });
86
+ }
87
+
88
+ const nextRaw = doc.toString();
89
+ if (nextRaw === configRaw) {
90
+ return { status: 'UNCHANGED', configPath, units: updates };
91
+ }
92
+ const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
93
+ try {
94
+ await writeFile(tempPath, nextRaw, {
95
+ encoding: 'utf8',
96
+ mode: configStat.mode & 0o777,
97
+ flag: 'wx',
98
+ });
99
+ await rename(tempPath, configPath);
100
+ } catch (error) {
101
+ await rm(tempPath, { force: true }).catch(() => {});
102
+ throw new ReleaseError(CONFIG_INVALID, `cannot update project release metadata: ${error.message}`);
103
+ }
104
+ return { status: 'UPDATED', configPath, units: updates };
105
+ }