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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -12,7 +12,7 @@ description: "Discoverable entry point for release-skill: dependency and environ
12
12
  ## 职责
13
13
 
14
14
  - 依赖和环境检查:Node.js >= 22、Git 决定本地准备就绪度;npm/gh 另行决定生产依赖就绪度
15
- - 能力说明:缺少配置时走 `help → setup → assess`;已有配置的安全默认路径是 `help → assess → prepare --offline`;已有公开版本的生产闭环是显式的 `prepare --online --production → approve → publish → verify`
15
+ - 能力说明:缺少配置时走 `help → setup → assess`;已有配置的安全默认路径是 `help → assess → prepare --offline`;日常生产发布优先使用可恢复的 `ship`,兼容的分阶段闭环仍是 `prepare --online --production → approve → publish → verify`
16
16
  - 最小示例:展示从 release-help 到 release-assess 的最短路径
17
17
  - 只读诊断:运行 dry-run 检查,不修改任何文件
18
18
  - 故障引导:根据错误码指向对应的修复 Skill
@@ -35,6 +35,14 @@ description: "Discoverable entry point for release-skill: dependency and environ
35
35
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" help --json
36
36
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" setup --root <path> --json
37
37
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" assess --root <path> --offline --json
38
+ # 日常发布快速路径:最多两个授权摘要,状态文件可恢复
39
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" ship --root <path> --target-version <version> --json
40
+ # 开发阶段执行声明 hooks 并生成 prepare 可复用的内容绑定收据
41
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" hooks validate --root <path> \
42
+ --acknowledge-hook-side-effects --json
43
+ # Kimi/CodeBuddy 安装完成后记录人工事实,不手改 JSON
44
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" attest --root <path> \
45
+ --platform <kimi|codebuddy> --plugin <id> --result <passed|failed> --actor <person> --json
38
46
  # 发布文档刷新:默认只读演练
39
47
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" docs refresh --unit <id> --json
40
48
  # 摘要确认后的本地写入(三项绑定缺一不可)
@@ -12,7 +12,7 @@ description: "Discoverable entry point for release-skill: dependency and environ
12
12
  ## 职责
13
13
 
14
14
  - 依赖和环境检查:Node.js >= 22、Git 决定本地准备就绪度;npm/gh 另行决定生产依赖就绪度
15
- - 能力说明:缺少配置时走 `help → setup → assess`;已有配置的安全默认路径是 `help → assess → prepare --offline`;已有公开版本的生产闭环是显式的 `prepare --online --production → approve → publish → verify`
15
+ - 能力说明:缺少配置时走 `help → setup → assess`;已有配置的安全默认路径是 `help → assess → prepare --offline`;日常生产发布优先使用可恢复的 `ship`,兼容的分阶段闭环仍是 `prepare --online --production → approve → publish → verify`
16
16
  - 最小示例:展示从 release-help 到 release-assess 的最短路径
17
17
  - 只读诊断:运行 dry-run 检查,不修改任何文件
18
18
  - 故障引导:根据错误码指向对应的修复 Skill
@@ -35,6 +35,14 @@ description: "Discoverable entry point for release-skill: dependency and environ
35
35
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" help --json
36
36
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" setup --root <path> --json
37
37
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" assess --root <path> --offline --json
38
+ # 日常发布快速路径:最多两个授权摘要,状态文件可恢复
39
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" ship --root <path> --target-version <version> --json
40
+ # 开发阶段执行声明 hooks 并生成 prepare 可复用的内容绑定收据
41
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" hooks validate --root <path> \
42
+ --acknowledge-hook-side-effects --json
43
+ # Kimi/CodeBuddy 安装完成后记录人工事实,不手改 JSON
44
+ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" attest --root <path> \
45
+ --platform <kimi|codebuddy> --plugin <id> --result <passed|failed> --actor <person> --json
38
46
  # 发布文档刷新:默认只读演练
39
47
  node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" docs refresh --unit <id> --json
40
48
  # 摘要确认后的本地写入(三项绑定缺一不可)
@@ -476,7 +476,7 @@ async function resolveInstalledPayloadSubpath(snapshotDir, sourceEntries, action
476
476
  return extractDeclaredPluginSource(consumer, matches[0]);
477
477
  }
478
478
 
479
- async function verifyInstalledMarketplacePayload(action, context, installPath, consumer) {
479
+ export async function verifyInstalledMarketplacePayload(action, context, installPath, consumer) {
480
480
  const sourcePath = await resolveFrozenPath(
481
481
  context.root,
482
482
  action.snapshotPath,
@@ -32,7 +32,9 @@ export function githubRepositoryUrl(repo, host = 'github.com') {
32
32
  ) {
33
33
  throw new Error('githubHost must be a valid hostname');
34
34
  }
35
- return `https://${host}/${repo}.git`;
35
+ return process.env.RELEASE_SKILL_GIT_TRANSPORT === 'ssh'
36
+ ? `git@${host}:${repo}.git`
37
+ : `https://${host}/${repo}.git`;
36
38
  }
37
39
 
38
40
  function validateOid(value, label) {
@@ -0,0 +1,195 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+
4
+ import { SAFE_ID_RE, writeEvidenceAtomic } from '../adapters/contract.mjs';
5
+ import { ReleaseError, GATE_FAILED, MISSING_PARAMETERS } from '../core/errors.mjs';
6
+
7
+ const PLATFORM = Object.freeze({
8
+ kimi: {
9
+ directory: 'kimi-attestations',
10
+ requirementFile: 'release-skill-kimi-manual-install.json',
11
+ },
12
+ codebuddy: {
13
+ directory: 'codebuddy-attestations',
14
+ requirementFile: 'release-skill-codebuddy-manual-install.json',
15
+ },
16
+ });
17
+
18
+ async function validateInstalledConsumerClosure({
19
+ root,
20
+ platform,
21
+ requirement,
22
+ installPath,
23
+ }) {
24
+ const planPath = resolve(root, '.release-skill', 'plans', `${requirement.planDigest}.json`);
25
+ const [
26
+ { validatePlan, computePlanDigest, assertImmutablePlanAuthority },
27
+ { createPluginMarketplaceAdapter, verifyInstalledMarketplacePayload },
28
+ ] = await Promise.all([
29
+ import('../core/plan.mjs'),
30
+ import('../adapters/plugin-marketplace.mjs'),
31
+ ]);
32
+ let plan;
33
+ try {
34
+ plan = JSON.parse(await readFile(planPath, 'utf8'));
35
+ } catch (error) {
36
+ throw new ReleaseError(
37
+ GATE_FAILED,
38
+ `cannot read the frozen plan required to verify the installed ${platform} payload: ${error.message}`,
39
+ );
40
+ }
41
+ validatePlan(plan);
42
+ assertImmutablePlanAuthority(planPath, plan);
43
+ if (computePlanDigest(plan) !== requirement.planDigest) {
44
+ throw new ReleaseError(GATE_FAILED, 'manual requirement planDigest does not match the frozen plan');
45
+ }
46
+ const actionType = `${platform}-marketplace-install`;
47
+ const action = (plan.externalActions ?? []).find((candidate) => (
48
+ candidate.type === actionType
49
+ && candidate.parameters?.plugin === requirement.plugin
50
+ && candidate.parameters?.version === requirement.version
51
+ ));
52
+ if (!action) {
53
+ throw new ReleaseError(GATE_FAILED, `frozen plan has no matching ${platform} install action`);
54
+ }
55
+ const adapter = createPluginMarketplaceAdapter();
56
+ const context = {
57
+ externalWritesAuthorized: false,
58
+ isolatedConsumerWritesAuthorized: false,
59
+ plan,
60
+ baseline: plan.baseline,
61
+ root,
62
+ };
63
+ const actionInput = { actionType, ...action.parameters };
64
+ const preflight = await adapter.preflight(actionInput, context);
65
+ if (preflight.status !== 'PREFLIGHT_PASSED') {
66
+ throw new ReleaseError(
67
+ GATE_FAILED,
68
+ `${platform} installed-payload preflight failed: ${preflight.error}`,
69
+ );
70
+ }
71
+ const binding = await verifyInstalledMarketplacePayload(
72
+ actionInput,
73
+ context,
74
+ installPath,
75
+ platform,
76
+ );
77
+ return {
78
+ payloadDigest: action.parameters.manifestDigest,
79
+ manifestDigest: binding.manifestDigest,
80
+ extraInstalledPaths: binding.extraInstalledPaths ?? [],
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Record the human fact needed by an interactive-only consumer. Identity
86
+ * fields come exclusively from the generated requirement; the operator only
87
+ * supplies the result, actor and optional observed install facts.
88
+ */
89
+ export async function recordManualAttestation(options = {}, injected = {}) {
90
+ const {
91
+ root = process.cwd(),
92
+ platform,
93
+ plugin,
94
+ actor,
95
+ result,
96
+ installPath,
97
+ installChannel,
98
+ note,
99
+ clock = () => new Date().toISOString(),
100
+ } = options;
101
+ const descriptor = PLATFORM[platform];
102
+ if (!descriptor || !SAFE_ID_RE.test(plugin ?? '')) {
103
+ throw new ReleaseError(
104
+ MISSING_PARAMETERS,
105
+ 'attest requires --platform <kimi|codebuddy> and a safe --plugin <id>',
106
+ );
107
+ }
108
+ if (typeof actor !== 'string' || actor.trim() === '') {
109
+ throw new ReleaseError(MISSING_PARAMETERS, 'attest requires --actor <person>');
110
+ }
111
+ if (result !== 'passed' && result !== 'failed') {
112
+ throw new ReleaseError(MISSING_PARAMETERS, 'attest requires --result <passed|failed>');
113
+ }
114
+
115
+ const authorityDir = resolve(root, '.release-skill', descriptor.directory, plugin);
116
+ const requirementPath = join(authorityDir, descriptor.requirementFile);
117
+ let requirement;
118
+ try {
119
+ requirement = JSON.parse(await readFile(requirementPath, 'utf8'));
120
+ } catch (error) {
121
+ throw new ReleaseError(
122
+ GATE_FAILED,
123
+ `cannot read generated ${platform} requirement: ${error.message}`,
124
+ { platform, plugin, requirementPath },
125
+ );
126
+ }
127
+ if (
128
+ requirement.platform !== platform
129
+ || requirement.plugin !== plugin
130
+ || typeof requirement.version !== 'string'
131
+ || !/^[a-f0-9]{64}$/.test(requirement.planDigest ?? '')
132
+ || typeof requirement.attestationFile !== 'string'
133
+ ) {
134
+ throw new ReleaseError(
135
+ GATE_FAILED,
136
+ `generated ${platform} requirement has invalid identity fields`,
137
+ { platform, plugin, requirementPath },
138
+ );
139
+ }
140
+ if (result === 'passed' && (typeof installPath !== 'string' || installPath === '')) {
141
+ throw new ReleaseError(
142
+ MISSING_PARAMETERS,
143
+ `${platform} passed attestation requires --install-path for installed payload verification`,
144
+ );
145
+ }
146
+ const needsInstallChannel = Object.hasOwn(requirement.attestationTemplate ?? {}, 'installChannel');
147
+ if (
148
+ result === 'passed'
149
+ && needsInstallChannel
150
+ && installChannel !== 'desktop'
151
+ && installChannel !== 'cli'
152
+ ) {
153
+ throw new ReleaseError(
154
+ MISSING_PARAMETERS,
155
+ `${platform} passed attestation requires --install-channel <desktop|cli>`,
156
+ );
157
+ }
158
+
159
+ const installBinding = result === 'passed'
160
+ ? await (injected.validateInstalledConsumerClosure ?? validateInstalledConsumerClosure)({
161
+ root,
162
+ platform,
163
+ requirement,
164
+ installPath,
165
+ })
166
+ : null;
167
+ const receipt = {
168
+ platform,
169
+ version: requirement.version,
170
+ planDigest: requirement.planDigest,
171
+ result,
172
+ actor: actor.trim(),
173
+ confirmedAt: clock(),
174
+ ...(installPath ? { installPath } : {}),
175
+ ...(installChannel ? { installChannel } : {}),
176
+ ...(note ? { note } : {}),
177
+ ...(installBinding ? {
178
+ payloadDigest: installBinding.payloadDigest,
179
+ installedClosureVerified: true,
180
+ extraInstalledPaths: installBinding.extraInstalledPaths,
181
+ } : {}),
182
+ };
183
+ const attestationPath = join(authorityDir, requirement.attestationFile);
184
+ await writeEvidenceAtomic(attestationPath, receipt);
185
+ return {
186
+ command: 'attest',
187
+ status: 'RECORDED',
188
+ platform,
189
+ plugin,
190
+ version: requirement.version,
191
+ planDigest: requirement.planDigest,
192
+ requirementPath,
193
+ attestationPath,
194
+ };
195
+ }
@@ -0,0 +1,46 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { loadProjectConfig } from '../core/config.mjs';
5
+ import { createEvidenceWriter } from '../core/evidence.mjs';
6
+ import { runDeclaredHooks } from './prepare.mjs';
7
+ import { ReleaseError, GATE_FAILED } from '../core/errors.mjs';
8
+
9
+ /**
10
+ * Run the declared development gates and populate the exact same content-bound
11
+ * hook receipts consumed by prepare. A later ship/prepare reuses only
12
+ * cacheable hooks whose declared input closure is byte-identical.
13
+ */
14
+ export async function validateDeclaredHooks(options = {}) {
15
+ const {
16
+ root = process.cwd(),
17
+ hooksAuthorized,
18
+ hookCache = true,
19
+ runDir = resolve(root, '.release-skill', 'runs', `hooks-${Date.now()}`),
20
+ } = options;
21
+ if (hooksAuthorized !== true) {
22
+ throw new ReleaseError(
23
+ GATE_FAILED,
24
+ 'hooks validate executes declared project commands; pass --acknowledge-hook-side-effects',
25
+ );
26
+ }
27
+ const { config, configDigest } = await loadProjectConfig({ root });
28
+ await mkdir(runDir, { recursive: true });
29
+ const evidence = createEvidenceWriter({
30
+ runDir,
31
+ command: 'hooks-validate',
32
+ clock: () => new Date().toISOString(),
33
+ });
34
+ const startedAt = new Date().toISOString();
35
+ await runDeclaredHooks(config, root, evidence, undefined, { hookCache });
36
+ return {
37
+ command: 'hooks validate',
38
+ status: 'PASSED',
39
+ configDigest,
40
+ hooks: Object.keys(config.hooks ?? {}).sort(),
41
+ cacheEnabled: hookCache,
42
+ evidenceDir: runDir,
43
+ startedAt,
44
+ finishedAt: new Date().toISOString(),
45
+ };
46
+ }
@@ -0,0 +1,356 @@
1
+ import { readFile, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
5
+ import {
6
+ ReleaseError,
7
+ GATE_FAILED,
8
+ PLAN_DIGEST_MISMATCH,
9
+ CONSUMER_VERIFICATION_DEFERRED,
10
+ } from '../core/errors.mjs';
11
+
12
+ async function writeJsonAtomic(path, value) {
13
+ await mkdir(dirname(path), { recursive: true });
14
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
15
+ const { stateDigest: _oldDigest, ...body } = value;
16
+ const sealed = {
17
+ ...body,
18
+ stateDigest: sha256Hex(canonicalJson(body)),
19
+ };
20
+ try {
21
+ await writeFile(temp, `${JSON.stringify(sealed, null, 2)}\n`, {
22
+ encoding: 'utf8',
23
+ mode: 0o600,
24
+ flag: 'wx',
25
+ });
26
+ await rename(temp, path);
27
+ } catch (error) {
28
+ await rm(temp, { force: true }).catch(() => {});
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ async function readState(path) {
34
+ try {
35
+ const stat = await lstat(path);
36
+ if (!stat.isFile() || stat.isSymbolicLink()) {
37
+ throw new Error('ship state must be a regular non-symlink file');
38
+ }
39
+ const state = JSON.parse(await readFile(path, 'utf8'));
40
+ const { stateDigest, ...body } = state;
41
+ if (
42
+ typeof stateDigest !== 'string'
43
+ || stateDigest !== sha256Hex(canonicalJson(body))
44
+ || body.statePath !== path
45
+ ) {
46
+ throw new Error('ship state digest or authority path does not match');
47
+ }
48
+ return state;
49
+ } catch (error) {
50
+ if (error?.code === 'ENOENT') return null;
51
+ throw new ReleaseError(GATE_FAILED, `cannot read ship state: ${error.message}`, { statePath: path });
52
+ }
53
+ }
54
+
55
+ async function defaultDependencies() {
56
+ const [
57
+ configModule,
58
+ prepareModule,
59
+ approveModule,
60
+ publishModule,
61
+ reconcileModule,
62
+ verifyModule,
63
+ transportModule,
64
+ metadataModule,
65
+ ] = await Promise.all([
66
+ import('../core/config.mjs'),
67
+ import('./prepare.mjs'),
68
+ import('./approve.mjs'),
69
+ import('./publish.mjs'),
70
+ import('./reconcile.mjs'),
71
+ import('./verify.mjs'),
72
+ import('../core/git-transport.mjs'),
73
+ import('../core/release-metadata.mjs'),
74
+ ]);
75
+ return {
76
+ loadProjectConfig: configModule.loadProjectConfig,
77
+ prepareRelease: prepareModule.prepareRelease,
78
+ approvePlan: approveModule.approvePlan,
79
+ publishRelease: publishModule.publishRelease,
80
+ reconcileRelease: reconcileModule.reconcileRelease,
81
+ verifyRelease: verifyModule.verifyRelease,
82
+ preflightGitTransports: transportModule.preflightGitTransports,
83
+ updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
84
+ };
85
+ }
86
+
87
+ function publicState(state) {
88
+ return {
89
+ command: 'ship',
90
+ status: state.status,
91
+ statePath: state.statePath,
92
+ targetVersion: state.targetVersion,
93
+ ...(state.hookAuthorizationDigest ? {
94
+ hookAuthorizationDigest: state.hookAuthorizationDigest,
95
+ hooks: state.hooks,
96
+ } : {}),
97
+ ...(state.planPath ? {
98
+ planPath: state.planPath,
99
+ planDigest: state.planDigest,
100
+ evidenceDir: state.evidenceDir,
101
+ warnings: state.warnings ?? [],
102
+ } : {}),
103
+ ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
104
+ ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
105
+ ...(state.requirements ? { requirements: state.requirements } : {}),
106
+ ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
107
+ verificationGateAuthorizationIncludedInPlanApproval:
108
+ state.status !== 'NEEDS_HOOK_AUTHORIZATION',
109
+ postVerifyMetadataUpdateIncludedInPlanApproval:
110
+ state.status !== 'NEEDS_HOOK_AUTHORIZATION',
111
+ };
112
+ }
113
+
114
+ function hookAuthority(loaded, targetVersion) {
115
+ const hooks = Object.keys(loaded.config.hooks ?? {}).sort();
116
+ return {
117
+ hooks,
118
+ digest: sha256Hex(canonicalJson({
119
+ kind: 'release-skill-hook-authorization/v1',
120
+ configDigest: loaded.configDigest,
121
+ hooks: loaded.config.hooks ?? {},
122
+ targetVersion: targetVersion ?? null,
123
+ })),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Advance one durable production release. Re-running is safe: the state file
129
+ * carries the immutable plan, approval and source-run paths so the command
130
+ * resumes instead of reconstructing authority from terminal/chat output.
131
+ */
132
+ export async function advanceShip(options = {}, injected = {}) {
133
+ const root = resolve(options.root ?? process.cwd());
134
+ const statePath = resolve(
135
+ options.statePath ?? resolve(root, '.release-skill', 'ships', 'current.json'),
136
+ );
137
+ const deps = Object.keys(injected).length > 0
138
+ ? injected
139
+ : await defaultDependencies();
140
+ let state = await readState(statePath);
141
+ if (state?.gitTransport) {
142
+ process.env.RELEASE_SKILL_GIT_TRANSPORT = state.gitTransport;
143
+ }
144
+
145
+ if (!state) {
146
+ const loaded = await deps.loadProjectConfig({ root });
147
+ const authority = hookAuthority(loaded, options.targetVersion);
148
+ const hooks = authority.hooks;
149
+ const hookAuthorizationDigest = authority.digest;
150
+ state = {
151
+ schemaVersion: 1,
152
+ root,
153
+ statePath,
154
+ targetVersion: options.targetVersion ?? null,
155
+ configDigest: loaded.configDigest,
156
+ hooks,
157
+ hookAuthorizationDigest,
158
+ status: hooks.length > 0 ? 'NEEDS_HOOK_AUTHORIZATION' : 'NEW',
159
+ createdAt: new Date().toISOString(),
160
+ updatedAt: new Date().toISOString(),
161
+ };
162
+ await writeJsonAtomic(statePath, state);
163
+ if (hooks.length > 0 && options.hookAuthorizationDigest !== hookAuthorizationDigest) {
164
+ return publicState(state);
165
+ }
166
+ }
167
+
168
+ if (state.status === 'NEEDS_HOOK_AUTHORIZATION') {
169
+ const loaded = await deps.loadProjectConfig({ root });
170
+ const current = hookAuthority(loaded, state.targetVersion);
171
+ if (
172
+ loaded.configDigest !== state.configDigest
173
+ || current.digest !== state.hookAuthorizationDigest
174
+ ) {
175
+ state = {
176
+ ...state,
177
+ configDigest: loaded.configDigest,
178
+ hooks: current.hooks,
179
+ hookAuthorizationDigest: current.digest,
180
+ updatedAt: new Date().toISOString(),
181
+ };
182
+ await writeJsonAtomic(statePath, state);
183
+ if (verified.status === 'VERIFIED' && deps.updatePreviousPublicBaselines) {
184
+ try {
185
+ state.metadataUpdate = await deps.updatePreviousPublicBaselines({
186
+ root,
187
+ planPath: state.planPath,
188
+ });
189
+ } catch (error) {
190
+ state.metadataUpdate = {
191
+ status: 'FAILED',
192
+ error: error.message,
193
+ };
194
+ }
195
+ state.updatedAt = new Date().toISOString();
196
+ await writeJsonAtomic(statePath, state);
197
+ }
198
+ return publicState(state);
199
+ }
200
+ if (options.hookAuthorizationDigest !== state.hookAuthorizationDigest) {
201
+ if (options.hookAuthorizationDigest) {
202
+ throw new ReleaseError(
203
+ PLAN_DIGEST_MISMATCH,
204
+ 'hook authorization digest does not match the current config, hooks and target version',
205
+ );
206
+ }
207
+ return publicState(state);
208
+ }
209
+ state.hookAuthorizedBy = options.actor ?? null;
210
+ state.status = 'NEW';
211
+ state.updatedAt = new Date().toISOString();
212
+ await writeJsonAtomic(statePath, state);
213
+ }
214
+
215
+ if (state.status === 'NEW') {
216
+ const loaded = await deps.loadProjectConfig({ root });
217
+ if (loaded.configDigest !== state.configDigest) {
218
+ const current = hookAuthority(loaded, state.targetVersion);
219
+ state = {
220
+ ...state,
221
+ configDigest: loaded.configDigest,
222
+ hooks: current.hooks,
223
+ hookAuthorizationDigest: current.digest,
224
+ status: current.hooks.length > 0 ? 'NEEDS_HOOK_AUTHORIZATION' : 'NEW',
225
+ updatedAt: new Date().toISOString(),
226
+ };
227
+ await writeJsonAtomic(statePath, state);
228
+ if (state.status === 'NEEDS_HOOK_AUTHORIZATION') return publicState(state);
229
+ }
230
+ const prepared = await deps.prepareRelease({
231
+ root,
232
+ version: state.targetVersion ?? undefined,
233
+ offline: false,
234
+ production: true,
235
+ hooksAuthorized: true,
236
+ verificationGatesAuthorized: false,
237
+ hookCache: true,
238
+ });
239
+ let transportPreflight = null;
240
+ if (deps.preflightGitTransports) {
241
+ const frozenPlan = JSON.parse(await readFile(prepared.planPath, 'utf8'));
242
+ transportPreflight = await deps.preflightGitTransports(frozenPlan);
243
+ process.env.RELEASE_SKILL_GIT_TRANSPORT = transportPreflight.transport;
244
+ }
245
+ state = {
246
+ ...state,
247
+ status: 'NEEDS_PLAN_APPROVAL',
248
+ planPath: prepared.planPath,
249
+ planDigest: prepared.planDigest,
250
+ evidenceDir: prepared.evidenceDir,
251
+ warnings: prepared.warnings,
252
+ ...(transportPreflight ? {
253
+ gitTransport: transportPreflight.transport,
254
+ gitTransportPreflight: transportPreflight.repositories,
255
+ } : {}),
256
+ updatedAt: new Date().toISOString(),
257
+ };
258
+ await writeJsonAtomic(statePath, state);
259
+ }
260
+
261
+ if (state.status === 'NEEDS_PLAN_APPROVAL') {
262
+ if (!options.planApprovalDigest) return publicState(state);
263
+ if (options.planApprovalDigest !== state.planDigest) {
264
+ throw new ReleaseError(PLAN_DIGEST_MISMATCH, 'ship plan approval digest does not match the frozen plan');
265
+ }
266
+ if (!options.actor) {
267
+ throw new ReleaseError(GATE_FAILED, 'ship plan approval requires --actor <person>');
268
+ }
269
+ const approval = await deps.approvePlan({
270
+ planPath: state.planPath,
271
+ expectedDigest: state.planDigest,
272
+ actor: options.actor,
273
+ });
274
+ state = {
275
+ ...state,
276
+ status: 'APPROVED',
277
+ approvalPath: approval.approvalPath,
278
+ verificationGatesAuthorized: true,
279
+ approvedBy: options.actor,
280
+ updatedAt: new Date().toISOString(),
281
+ };
282
+ await writeJsonAtomic(statePath, state);
283
+ }
284
+
285
+ if (state.status === 'APPROVED' || state.status === 'PUBLISHING') {
286
+ if (!options.adapterRegistry) {
287
+ throw new ReleaseError(GATE_FAILED, 'ship requires an adapter registry to publish');
288
+ }
289
+ state.status = 'PUBLISHING';
290
+ await writeJsonAtomic(statePath, state);
291
+ const published = await deps.publishRelease({
292
+ planPath: state.planPath,
293
+ approvalPath: state.approvalPath,
294
+ adapterRegistry: options.adapterRegistry,
295
+ root,
296
+ productionMode: true,
297
+ productionConfirmation: state.planDigest,
298
+ });
299
+ state = {
300
+ ...state,
301
+ status: published.status,
302
+ sourceRunPath: published.runPath,
303
+ updatedAt: new Date().toISOString(),
304
+ };
305
+ await writeJsonAtomic(statePath, state);
306
+ }
307
+
308
+ if (state.status === 'PARTIAL') {
309
+ const reconciled = await deps.reconcileRelease({
310
+ planPath: state.planPath,
311
+ sourceRunPath: state.sourceRunPath,
312
+ approvalPath: state.approvalPath,
313
+ adapterRegistry: options.adapterRegistry,
314
+ root,
315
+ productionConfirmation: state.planDigest,
316
+ });
317
+ state = {
318
+ ...state,
319
+ status: reconciled.status,
320
+ sourceRunPath: reconciled.runPath,
321
+ updatedAt: new Date().toISOString(),
322
+ };
323
+ await writeJsonAtomic(statePath, state);
324
+ }
325
+
326
+ if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
327
+ try {
328
+ const verified = await deps.verifyRelease({
329
+ planPath: state.planPath,
330
+ sourceRunPath: state.sourceRunPath,
331
+ adapterRegistry: options.adapterRegistry,
332
+ root,
333
+ verificationGatesAuthorized: state.verificationGatesAuthorized === true,
334
+ });
335
+ state = {
336
+ ...state,
337
+ status: verified.status,
338
+ verifyRunPath: verified.runPath,
339
+ requirements: undefined,
340
+ updatedAt: new Date().toISOString(),
341
+ };
342
+ await writeJsonAtomic(statePath, state);
343
+ } catch (error) {
344
+ if (error?.code !== CONSUMER_VERIFICATION_DEFERRED) throw error;
345
+ state = {
346
+ ...state,
347
+ status: 'NEEDS_MANUAL_ATTESTATIONS',
348
+ requirements: error.details?.requirements ?? [],
349
+ updatedAt: new Date().toISOString(),
350
+ };
351
+ await writeJsonAtomic(statePath, state);
352
+ }
353
+ }
354
+
355
+ return publicState(state);
356
+ }