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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +38 -7
- package/README.zh-CN.md +36 -7
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/claude/skills/release-help/SKILL.md +9 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/codex/skills/release-help/SKILL.md +9 -1
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/kimi/skills/release-help/SKILL.md +9 -1
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/workbuddy/skills/release-help/SKILL.md +9 -1
- package/bin/release-skill-cli.mjs +176 -4
- package/bin/release-skill.bundle.mjs +3908 -2864
- package/package.json +1 -1
- package/skills/release-help/SKILL.md +9 -1
- package/skills-src/release-help/SKILL.md +9 -1
- package/src/adapters/plugin-marketplace.mjs +1 -1
- package/src/adapters/push-snapshot.mjs +3 -1
- package/src/commands/attest.mjs +195 -0
- package/src/commands/hooks.mjs +46 -0
- package/src/commands/ship.mjs +356 -0
- package/src/commands/verify.mjs +147 -4
- package/src/core/git-transport.mjs +93 -0
- package/src/core/release-metadata.mjs +105 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|