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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -2,11 +2,11 @@
2
2
  "schemaVersion": "1.0.0",
3
3
  "platformId": "release-skill",
4
4
  "platformName": "release-skill Plugin Platform",
5
- "version": "0.7.6",
5
+ "version": "0.7.7",
6
6
  "logicalRoot": "skills-src/",
7
- "logicalRootDigest": "7889a5f745d1f51ede024d4df68853128f2440a05f430da77a25b78c7c3d396f",
8
- "projectionDigest": "9709f8e22722faaca894d067dce7a9534813a6abf241f00e796a5a47b5f69f72",
9
- "projectionId": "release-skill:platform:0.7.6",
7
+ "logicalRootDigest": "b261a4cc49e6dc52dabddb083c22e414902fb269d5879ad01ef8146ceffd7bc7",
8
+ "projectionDigest": "6629de035a25005fd4676b09a0526a46f992a33d974cadd542b0c964d7f906ff",
9
+ "projectionId": "release-skill:platform:0.7.7",
10
10
  "manifestRequired": true,
11
11
  "projectionSources": {
12
12
  "skills-src-root": {
@@ -27,6 +27,10 @@ project:
27
27
  defaultBranch: <string> # workspace 远端默认分支,必填;不限定为 main
28
28
  sourceRepository: <owner/repo> # workspace 源仓库;生产发布必填
29
29
 
30
+ publicSourceAuthorityReceipt: # 可选;把源码坐标与多个 npm tarball 绑定为公开 Release asset
31
+ coordinatorUnitId: <release-unit-id>
32
+ subjectUnitIds: [<release-unit-id>]
33
+
30
34
  releaseUnits: # 发布单元数组,至少 1 个
31
35
  - id: <string> # 唯一标识
32
36
  source: <string> # 源码相对路径(相对项目根目录)
@@ -108,6 +112,20 @@ policy: # 可选,安全策略
108
112
  forbiddenContentPatterns: [<string>]
109
113
  ```
110
114
 
115
+ `publicSourceAuthorityReceipt` 只适用于 production prepare。`coordinatorUnitId`
116
+ 指定承载该文件的 GitHub Release;`subjectUnitIds` 必须引用现有 release unit,且每个
117
+ unit 恰好声明一个 npm distribution。prepare 等所有 subject tarball 冻结后生成
118
+ `source-authority-receipt.json`,其公开结构固定为:
119
+
120
+ ```json
121
+ {"schemaVersion":1,"kind":"skill-family.source-authority-receipt","sourceRepository":"owner/workspace","sourceBaseCommit":"<full-commit>","subjects":[{"packageName":"@scope/package","version":"1.2.3","filename":"scope-package-1.2.3.tgz","sha256":"<sha256>"}]}
122
+ ```
123
+
124
+ 该文件使用 canonical JSON,不带自摘要,也不包含 release-skill 的计划、运行或批准字段。
125
+ 冻结计划记录文件 SHA-256;publish 将相同字节上传到 coordinator GitHub Release,verify
126
+ 重新下载远端字节并核对 SHA-256。离线消费者只需该文件和其中 subjects 对应的精确 tarball,便可
127
+ 核对包名、版本、文件名、tarball SHA-256、源码仓库与源码基线提交。
128
+
111
129
  `postPublish` 只能位于某个 `releaseUnits[]` 条目内。下面的完整结构样例会由 schema 契约测试直接读取并校验,避免文档层级再次漂移:
112
130
 
113
131
  ```yaml
@@ -53,6 +53,53 @@
53
53
  }
54
54
  }
55
55
  },
56
+ "publicSourceAuthorityReceipt": {
57
+ "type": "object",
58
+ "required": [
59
+ "coordinatorUnitId",
60
+ "subjectUnitIds",
61
+ "asset"
62
+ ],
63
+ "additionalProperties": false,
64
+ "properties": {
65
+ "coordinatorUnitId": {
66
+ "type": "string",
67
+ "pattern": "^(?!\\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$"
68
+ },
69
+ "subjectUnitIds": {
70
+ "type": "array",
71
+ "minItems": 1,
72
+ "uniqueItems": true,
73
+ "items": {
74
+ "type": "string",
75
+ "pattern": "^(?!\\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$"
76
+ }
77
+ },
78
+ "asset": {
79
+ "type": "object",
80
+ "required": [
81
+ "name",
82
+ "path",
83
+ "sha256"
84
+ ],
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "name": {
88
+ "const": "source-authority-receipt.json"
89
+ },
90
+ "path": {
91
+ "type": "string",
92
+ "minLength": 1,
93
+ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
94
+ },
95
+ "sha256": {
96
+ "type": "string",
97
+ "pattern": "^[a-f0-9]{64}$"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ },
56
103
  "baseline": {
57
104
  "type": "object",
58
105
  "required": [
@@ -44,6 +44,30 @@
44
44
  }
45
45
  }
46
46
  },
47
+ "publicSourceAuthorityReceipt": {
48
+ "type": "object",
49
+ "description": "Optional public source-authority receipt uploaded to one coordinator GitHub Release after all subject npm tarballs are frozen.",
50
+ "required": [
51
+ "coordinatorUnitId",
52
+ "subjectUnitIds"
53
+ ],
54
+ "additionalProperties": false,
55
+ "properties": {
56
+ "coordinatorUnitId": {
57
+ "type": "string",
58
+ "pattern": "^(?!\\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$"
59
+ },
60
+ "subjectUnitIds": {
61
+ "type": "array",
62
+ "minItems": 1,
63
+ "uniqueItems": true,
64
+ "items": {
65
+ "type": "string",
66
+ "pattern": "^(?!\\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$"
67
+ }
68
+ }
69
+ }
70
+ },
47
71
  "releaseUnits": {
48
72
  "type": "array",
49
73
  "minItems": 1,
@@ -33,6 +33,10 @@ description: Freeze an immutable release plan with local configuration, document
33
33
 
34
34
  若用户明确要求 GitHub+npm 生产发布,加入 `--production`。该模式还会封存独立
35
35
  Git commit/tree 和 npm tarball,并把路径、SHA/integrity、branch/tag 写入计划。
36
+ 配置声明 `publicSourceAuthorityReceipt` 时,prepare 必须在所有 subject npm tarball
37
+ 冻结后生成 `source-authority-receipt.json`,并把该文件的路径与 SHA-256 绑定到
38
+ coordinator unit 的 `github-release` action。该能力不支持非生产 prepare;不得手工
39
+ 补写 receipt 或把私有 plan/run 字段复制进公开文件。
36
40
  每个 npm tarball 在计划落盘前必须静态验证 `package.json` 的具体
37
41
  `bin`/`main`/`module`/`types`/`typings`/`exports` 入口均为 tarball 内普通文件;
38
42
  该门禁不依赖项目是否配置 `requiredPublicFiles` 或 `smokeBin`。通配符 exports 不做
@@ -33,6 +33,10 @@ description: Freeze an immutable release plan with local configuration, document
33
33
 
34
34
  若用户明确要求 GitHub+npm 生产发布,加入 `--production`。该模式还会封存独立
35
35
  Git commit/tree 和 npm tarball,并把路径、SHA/integrity、branch/tag 写入计划。
36
+ 配置声明 `publicSourceAuthorityReceipt` 时,prepare 必须在所有 subject npm tarball
37
+ 冻结后生成 `source-authority-receipt.json`,并把该文件的路径与 SHA-256 绑定到
38
+ coordinator unit 的 `github-release` action。该能力不支持非生产 prepare;不得手工
39
+ 补写 receipt 或把私有 plan/run 字段复制进公开文件。
36
40
  每个 npm tarball 在计划落盘前必须静态验证 `package.json` 的具体
37
41
  `bin`/`main`/`module`/`types`/`typings`/`exports` 入口均为 tarball 内普通文件;
38
42
  该门禁不依赖项目是否配置 `requiredPublicFiles` 或 `smokeBin`。通配符 exports 不做
@@ -8,8 +8,10 @@ import {
8
8
  createResult,
9
9
  matchObservation,
10
10
  } from './contract.mjs';
11
- import { resolveFrozenPath, verifyFrozenDirectoryStructure } from '../snapshot/frozen.mjs';
11
+ import { resolveFrozenPath, verifyFrozenDirectoryStructure, verifyFrozenFile } from '../snapshot/frozen.mjs';
12
12
  import { githubRepositoryUrl } from './push-snapshot.mjs';
13
+ import { digestReleaseAssetIdentities, normalizeReleaseAssets } from '../core/release-assets.mjs';
14
+ import { sha256Hex } from '../core/digest.mjs';
13
15
 
14
16
  const execFile = promisify(execFileCb);
15
17
  const NAME = 'git-github';
@@ -91,6 +93,82 @@ async function readRemoteBranchCommit(action, exec) {
91
93
  return stdout.trim().split(/\s+/)[0] ?? '';
92
94
  }
93
95
 
96
+ async function validateLocalReleaseAssets(action, context) {
97
+ const assets = normalizeReleaseAssets(action.releaseAssets);
98
+ const verified = [];
99
+ for (const asset of assets) {
100
+ const file = await verifyFrozenFile({
101
+ root: context.root,
102
+ filePath: asset.path,
103
+ expectedSha256: asset.sha256,
104
+ label: `frozen GitHub Release asset "${asset.name}"`,
105
+ });
106
+ verified.push({ ...asset, physical: file.physical });
107
+ }
108
+ return verified;
109
+ }
110
+
111
+ async function readReleaseWithAssets(action, context, exec) {
112
+ const environment = { ...process.env, GH_HOST: action.githubHost ?? 'github.com' };
113
+ const { stdout } = await exec(
114
+ 'gh',
115
+ ['api', `repos/${action.repo}/releases/tags/${action.tag}`],
116
+ { cwd: context.root, shell: false, encoding: 'utf8', env: environment },
117
+ );
118
+ const release = JSON.parse(stdout);
119
+ const commitResult = await exec(
120
+ 'gh',
121
+ ['api', `repos/${action.repo}/git/ref/tags/${action.tag}`, '--jq', '.object.sha'],
122
+ { cwd: context.root, shell: false, encoding: 'utf8', env: environment },
123
+ );
124
+ const plannedAssets = normalizeReleaseAssets(action.releaseAssets);
125
+ const observedAssets = [];
126
+ const missingAssets = [];
127
+ for (const planned of plannedAssets) {
128
+ const matches = (release.assets ?? []).filter((asset) => asset?.name === planned.name);
129
+ if (matches.length > 1) throw new Error(`GitHub Release has duplicate asset name "${planned.name}"`);
130
+ if (matches.length === 0) {
131
+ missingAssets.push(planned.name);
132
+ continue;
133
+ }
134
+ const remote = matches[0];
135
+ if (!Number.isInteger(remote.id)) throw new Error(`GitHub Release asset "${planned.name}" has no stable asset id`);
136
+ const downloaded = await exec(
137
+ 'gh',
138
+ ['api', `repos/${action.repo}/releases/assets/${remote.id}`, '-H', 'Accept: application/octet-stream'],
139
+ { cwd: context.root, shell: false, encoding: null, env: environment, maxBuffer: 64 * 1024 * 1024 },
140
+ );
141
+ const bytes = Buffer.isBuffer(downloaded.stdout)
142
+ ? downloaded.stdout
143
+ : Buffer.from(downloaded.stdout ?? '', 'utf8');
144
+ observedAssets.push({ ...planned, sha256: sha256Hex(bytes) });
145
+ }
146
+ return {
147
+ observation: {
148
+ tag: release.tag_name,
149
+ commit: commitResult.stdout.trim(),
150
+ releaseUrl: release.html_url,
151
+ ...(missingAssets.length > 0
152
+ ? { exists: false, missingReleaseAssets: missingAssets }
153
+ : { releaseAssetsDigest: digestReleaseAssetIdentities(observedAssets) }),
154
+ },
155
+ missingAssets,
156
+ observedAssets,
157
+ };
158
+ }
159
+
160
+ function assertExistingReleaseCompatible(action, state) {
161
+ if (state.observation.tag !== action.tag || state.observation.commit !== action.commit) {
162
+ throw new Error('existing GitHub Release tag or commit conflicts with the frozen plan');
163
+ }
164
+ const expectedByName = new Map(normalizeReleaseAssets(action.releaseAssets).map((asset) => [asset.name, asset]));
165
+ for (const observed of state.observedAssets) {
166
+ if (expectedByName.get(observed.name)?.sha256 !== observed.sha256) {
167
+ throw new Error(`GitHub Release asset "${observed.name}" bytes conflict with the frozen plan`);
168
+ }
169
+ }
170
+ }
171
+
94
172
  export function createGitGithubAdapter(deps = {}) {
95
173
  const exec = deps.exec ?? run;
96
174
  return Object.freeze({
@@ -110,6 +188,7 @@ export function createGitGithubAdapter(deps = {}) {
110
188
  } else if (action.actionType === ActionType.GITHUB_RELEASE) {
111
189
  if (!action.repo || !action.tag) throw new Error('github-release requires repo, tag, and commit');
112
190
  validateOid(action.commit);
191
+ const localAssets = await validateLocalReleaseAssets(action, context);
113
192
  await exec('gh', ['auth', 'status'], {
114
193
  cwd: context.root,
115
194
  shell: false,
@@ -123,12 +202,19 @@ export function createGitGithubAdapter(deps = {}) {
123
202
  throw new Error(`cannot access GitHub repository ${action.repo}: ${err.message}`);
124
203
  });
125
204
  try {
126
- await exec('gh', ['api', `repos/${action.repo}/releases/tags/${action.tag}`], {
127
- cwd: context.root,
128
- shell: false,
129
- env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' },
130
- });
131
- throw new Error(`GitHub Release already exists for ${action.tag}; human intervention required`);
205
+ if (localAssets.length === 0) {
206
+ await exec('gh', ['api', `repos/${action.repo}/releases/tags/${action.tag}`], {
207
+ cwd: context.root,
208
+ shell: false,
209
+ env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' },
210
+ });
211
+ throw new Error(`GitHub Release already exists for ${action.tag}; human intervention required`);
212
+ }
213
+ const state = await readReleaseWithAssets(action, context, exec);
214
+ assertExistingReleaseCompatible(action, state);
215
+ if (state.missingAssets.length === 0) {
216
+ throw new Error(`GitHub Release and assets already exist for ${action.tag}`);
217
+ }
132
218
  } catch (err) {
133
219
  if (err.message?.includes('already exists')) throw err;
134
220
  if (!isNotFound(err)) throw new Error(`cannot determine GitHub Release uniqueness: ${err.message}`);
@@ -192,13 +278,36 @@ export function createGitGithubAdapter(deps = {}) {
192
278
  `refs/tags/${action.tag}`,
193
279
  ], { shell: false });
194
280
  } else if (action.actionType === ActionType.GITHUB_RELEASE) {
195
- await exec('gh', [
196
- 'release', 'create', action.tag,
197
- '--repo', ghRepo(action),
198
- '--verify-tag',
199
- '--title', action.name ?? `Release ${action.tag}`,
200
- '--notes', action.notes ?? `Release ${action.tag}`,
201
- ], { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } });
281
+ const assets = await validateLocalReleaseAssets(action, context);
282
+ let existing = null;
283
+ if (assets.length > 0) {
284
+ try {
285
+ existing = await readReleaseWithAssets(action, context, exec);
286
+ } catch (error) {
287
+ if (!isNotFound(error)) throw error;
288
+ }
289
+ }
290
+ if (existing) {
291
+ assertExistingReleaseCompatible(action, existing);
292
+ const missing = new Set(existing.missingAssets);
293
+ const uploadPaths = assets.filter((asset) => missing.has(asset.name)).map((asset) => asset.physical);
294
+ if (uploadPaths.length > 0) {
295
+ await exec('gh', ['release', 'upload', action.tag, '--repo', ghRepo(action), ...uploadPaths], {
296
+ cwd: context.root,
297
+ shell: false,
298
+ env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' },
299
+ });
300
+ }
301
+ } else {
302
+ await exec('gh', [
303
+ 'release', 'create', action.tag,
304
+ '--repo', ghRepo(action),
305
+ '--verify-tag',
306
+ '--title', action.name ?? `Release ${action.tag}`,
307
+ '--notes', action.notes ?? `Release ${action.tag}`,
308
+ ...assets.map((asset) => asset.physical),
309
+ ], { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } });
310
+ }
202
311
  } else if (action.actionType === ActionType.SET_DEFAULT_BRANCH) {
203
312
  validateDefaultBranchAction(action);
204
313
  const { stdout: current } = await exec('gh', ['api', `repos/${action.repo}`, '--jq', '.default_branch'], {
@@ -242,18 +351,22 @@ export function createGitGithubAdapter(deps = {}) {
242
351
  const { remoteUrl } = await validateTagAction(action, context, exec);
243
352
  observation = { tag: action.tag, commit: await readRemoteTag(action, remoteUrl, exec) };
244
353
  } else if (action.actionType === ActionType.GITHUB_RELEASE) {
245
- const { stdout } = await exec('gh', [
246
- 'release', 'view', action.tag,
247
- '--repo', ghRepo(action),
248
- '--json', 'tagName,url',
249
- ], { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } });
250
- const release = JSON.parse(stdout);
251
- const commitResult = await exec(
252
- 'gh',
253
- ['api', `repos/${action.repo}/git/ref/tags/${action.tag}`, '--jq', '.object.sha'],
254
- { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } },
255
- );
256
- observation = { tag: release.tagName, commit: commitResult.stdout.trim(), releaseUrl: release.url };
354
+ if (normalizeReleaseAssets(action.releaseAssets).length > 0) {
355
+ observation = (await readReleaseWithAssets(action, context, exec)).observation;
356
+ } else {
357
+ const { stdout } = await exec('gh', [
358
+ 'release', 'view', action.tag,
359
+ '--repo', ghRepo(action),
360
+ '--json', 'tagName,url',
361
+ ], { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } });
362
+ const release = JSON.parse(stdout);
363
+ const commitResult = await exec(
364
+ 'gh',
365
+ ['api', `repos/${action.repo}/git/ref/tags/${action.tag}`, '--jq', '.object.sha'],
366
+ { cwd: context.root, shell: false, env: { ...process.env, GH_HOST: action.githubHost ?? 'github.com' } },
367
+ );
368
+ observation = { tag: release.tagName, commit: commitResult.stdout.trim(), releaseUrl: release.url };
369
+ }
257
370
  } else if (action.actionType === ActionType.SET_DEFAULT_BRANCH) {
258
371
  validateDefaultBranchAction(action);
259
372
  const { stdout: current } = await exec('gh', ['api', `repos/${action.repo}`, '--jq', '.default_branch'], {
@@ -25,7 +25,7 @@ import { promisify } from 'node:util';
25
25
 
26
26
  const execFile = promisify(execFileCb);
27
27
 
28
- import { classifyPathInput } from 'skill-family-harness-node';
28
+ import { classifyPathInput, writeFileAtomic } from 'skill-family-harness-node';
29
29
  import { loadProjectConfig } from '../core/config.mjs';
30
30
  import { captureBaseline } from '../core/baseline.mjs';
31
31
  import { runHook } from '../core/hooks.mjs';
@@ -62,6 +62,7 @@ import { PKG_ROOT } from '../core/pkg-root.mjs';
62
62
  import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
63
63
  import {
64
64
  SOURCE_INPUT_ALGORITHM_VERSION,
65
+ createPublicSourceAuthorityReceipt,
65
66
  computeSourceInputClosure,
66
67
  checkSourceInputDirty,
67
68
  verifySnapshotSourcesMatchClosure,
@@ -80,6 +81,7 @@ import {
80
81
  PAYLOAD_SOURCE_TAG_WORKTREE,
81
82
  } from '../core/postpublish.mjs';
82
83
  import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
84
+ import { digestReleaseAssetIdentities } from '../core/release-assets.mjs';
83
85
 
84
86
  // ---------------------------------------------------------------------------
85
87
  // 安装契约常量
@@ -1513,7 +1515,7 @@ export async function resolveExternalMarketplaceFreezes({
1513
1515
  * @param {string} realRoot - The project root for relative path calculation.
1514
1516
  * @returns {object[]} Array of external action descriptors.
1515
1517
  */
1516
- export function buildExternalActions(unitResults, resolvedVersions, productionAssets, externalFreezes = new Map(), frozenDistributions = null) {
1518
+ export function buildExternalActions(unitResults, resolvedVersions, productionAssets, externalFreezes = new Map(), frozenDistributions = null, publicSourceAuthorityReceipt = null) {
1517
1519
  const actions = [];
1518
1520
 
1519
1521
  if (!productionAssets) {
@@ -1785,6 +1787,9 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1785
1787
  }
1786
1788
 
1787
1789
  // GitHub release
1790
+ const releaseAssets = publicSourceAuthorityReceipt?.coordinatorUnitId === unit.id
1791
+ ? [publicSourceAuthorityReceipt.asset]
1792
+ : null;
1788
1793
  actions.push({
1789
1794
  id: `github-release-${unit.id}`,
1790
1795
  type: 'github-release',
@@ -1802,10 +1807,12 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1802
1807
  .replaceAll('{version}', unitVersion)
1803
1808
  .replaceAll('{unit}', unit.id),
1804
1809
  notes: unit.production?.releaseNotes ?? `Release ${resolvedTag}`,
1810
+ ...(releaseAssets ? { releaseAssets } : {}),
1805
1811
  },
1806
1812
  expected: {
1807
1813
  tag: resolvedTag,
1808
1814
  commit: asset.commit,
1815
+ ...(releaseAssets ? { releaseAssetsDigest: digestReleaseAssetIdentities(releaseAssets) } : {}),
1809
1816
  },
1810
1817
  status: 'PENDING',
1811
1818
  });
@@ -3648,7 +3655,68 @@ export async function prepareRelease(options) {
3648
3655
  // 构建冻结分发映射:unitId -> frozen distributions
3649
3656
  const frozenDistributionsMap = new Map(units.map((u) => [u.id, u.distributions]));
3650
3657
 
3651
- let externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets, externalMarketplaceFreezes, frozenDistributionsMap);
3658
+ let publicSourceAuthorityReceipt = null;
3659
+ if (config.publicSourceAuthorityReceipt) {
3660
+ if (!production || !sourceAuthority) {
3661
+ throw new ReleaseError(
3662
+ CONFIG_INVALID,
3663
+ 'publicSourceAuthorityReceipt requires a production prepare with source-authority closure enabled',
3664
+ );
3665
+ }
3666
+ const unitsById = new Map(units.map((unit) => [unit.id, unit]));
3667
+ const subjects = config.publicSourceAuthorityReceipt.subjectUnitIds.map((unitId) => {
3668
+ const unit = unitsById.get(unitId);
3669
+ const npmDistribution = unit?.distributions?.find((distribution) => distribution.type === 'npm');
3670
+ const npm = unit?.frozenSnapshot?.npm;
3671
+ if (!unit || !npmDistribution || !npm) {
3672
+ throw new ReleaseError(
3673
+ GATE_FAILED,
3674
+ `public source-authority subject unit "${unitId}" is missing its frozen npm tarball`,
3675
+ );
3676
+ }
3677
+ return {
3678
+ packageName: npmDistribution.package,
3679
+ version: unit.targetVersion,
3680
+ filename: basename(npm.tarballPath),
3681
+ sha256: npm.tarballSha256,
3682
+ };
3683
+ });
3684
+ const builtReceipt = createPublicSourceAuthorityReceipt({
3685
+ sourceRepository: sourceAuthority.sourceRepository,
3686
+ sourceBaseCommit: baseline.gitHead,
3687
+ subjects,
3688
+ });
3689
+ const assetName = 'source-authority-receipt.json';
3690
+ const assetDirectory = resolve(runDir, 'release-assets');
3691
+ await mkdir(assetDirectory, { recursive: true });
3692
+ await writeFileAtomic(assetDirectory, assetName, builtReceipt.bytes, { mode: 0o644 });
3693
+ publicSourceAuthorityReceipt = {
3694
+ coordinatorUnitId: config.publicSourceAuthorityReceipt.coordinatorUnitId,
3695
+ subjectUnitIds: [...config.publicSourceAuthorityReceipt.subjectUnitIds],
3696
+ asset: {
3697
+ name: assetName,
3698
+ path: relative(realRoot, resolve(assetDirectory, assetName)),
3699
+ sha256: builtReceipt.sha256,
3700
+ },
3701
+ };
3702
+ await evidence.append({
3703
+ phase: 'public-source-authority-receipt',
3704
+ status: 'frozen',
3705
+ coordinatorUnitId: publicSourceAuthorityReceipt.coordinatorUnitId,
3706
+ subjectUnitIds: publicSourceAuthorityReceipt.subjectUnitIds,
3707
+ assetPath: publicSourceAuthorityReceipt.asset.path,
3708
+ assetSha256: publicSourceAuthorityReceipt.asset.sha256,
3709
+ });
3710
+ }
3711
+
3712
+ let externalActions = buildExternalActions(
3713
+ unitResults,
3714
+ resolvedVersions,
3715
+ productionAssets,
3716
+ externalMarketplaceFreezes,
3717
+ frozenDistributionsMap,
3718
+ publicSourceAuthorityReceipt,
3719
+ );
3652
3720
 
3653
3721
  // Compute overall snapshot digest
3654
3722
  const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
@@ -3823,6 +3891,7 @@ export async function prepareRelease(options) {
3823
3891
  } : {}),
3824
3892
  units,
3825
3893
  externalActions,
3894
+ ...(publicSourceAuthorityReceipt ? { publicSourceAuthorityReceipt } : {}),
3826
3895
  ...(frozenPostPublish ? { postPublish: frozenPostPublish } : {}),
3827
3896
  ...(sourceAuthority ? { sourceAuthority } : {}),
3828
3897
  createdAt: production ? createdAtTimestamp : (clock ? clock() : new Date().toISOString()),
@@ -61,6 +61,7 @@ import {
61
61
  GATE_FAILED,
62
62
  CONFIG_MISSING,
63
63
  BASELINE_CHANGED,
64
+ CONTENT_MISMATCH,
64
65
  PARTIAL_RELEASE,
65
66
  CONSUMER_VERIFICATION_DEFERRED,
66
67
  } from '../core/errors.mjs';
@@ -78,6 +79,7 @@ import {
78
79
  verifyFrozenSnapshot,
79
80
  } from '../snapshot/frozen.mjs';
80
81
  import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
82
+ import { normalizeReleaseAssets } from '../core/release-assets.mjs';
81
83
 
82
84
  function assertInsideAssetRoot(assetRoot, candidate, label) {
83
85
  const rel = relative(assetRoot, candidate);
@@ -608,6 +610,17 @@ export async function publishRelease(options) {
608
610
  }, root);
609
611
  }
610
612
  }
613
+ for (const action of plan.externalActions ?? []) {
614
+ for (const releaseAsset of normalizeReleaseAssets(action.parameters?.releaseAssets)) {
615
+ const verifiedAsset = await verifyFrozenFile({
616
+ root,
617
+ filePath: releaseAsset.path,
618
+ expectedSha256: releaseAsset.sha256,
619
+ label: `frozen GitHub Release asset "${releaseAsset.name}"`,
620
+ });
621
+ assertInsideAssetRoot(assetRoot, verifiedAsset.physical, 'frozen GitHub Release asset');
622
+ }
623
+ }
611
624
  await evidence.append({ phase: 'safety-gate', gate: 'frozen-artifacts', status: 'passed' });
612
625
  }
613
626
 
@@ -987,6 +1000,29 @@ export async function publishRelease(options) {
987
1000
  );
988
1001
  }
989
1002
 
1003
+ if (
1004
+ plan.publicSourceAuthorityReceipt
1005
+ && remoteResult.observation?.observedCommit !== plan.baseline.headCommit
1006
+ ) {
1007
+ const observedCommit = remoteResult.observation?.observedCommit ?? null;
1008
+ await evidence.append({
1009
+ phase: 'safety-gate',
1010
+ gate: 'source-authority',
1011
+ status: 'failed',
1012
+ expectedCommit: plan.baseline.headCommit,
1013
+ observedCommit,
1014
+ });
1015
+ throw new ReleaseError(
1016
+ CONTENT_MISMATCH,
1017
+ 'source authority commit gate failed: remote observedCommit does not match the public receipt sourceBaseCommit',
1018
+ {
1019
+ gate: 'source-authority',
1020
+ expectedCommit: plan.baseline.headCommit,
1021
+ observedCommit,
1022
+ },
1023
+ );
1024
+ }
1025
+
990
1026
  // Create receipt for successful verification
991
1027
  sourceAuthorityReceipt = createSourceAuthorityReceipt({
992
1028
  plan,
@@ -46,7 +46,11 @@ import {
46
46
  POST_PUBLISH_VERIFY_FAILED,
47
47
  CONSUMER_VERIFICATION_DEFERRED,
48
48
  } from '../core/errors.mjs';
49
- import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
49
+ import {
50
+ consumePublicSourceAuthorityReceipt,
51
+ verifySourceAuthorityReceipt,
52
+ } from '../core/source-authority.mjs';
53
+ import { verifyFrozenFile } from '../snapshot/frozen.mjs';
50
54
  import {
51
55
  deriveBaselineAdvances,
52
56
  applyBaselineAdvances,
@@ -61,6 +65,7 @@ import {
61
65
  normalizeRegistry,
62
66
  registryTokenKey,
63
67
  resolveNpmRegistryAuthToken,
68
+ verifyFrozenNpmTarballContract,
64
69
  } from '../adapters/npm.mjs';
65
70
  import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
66
71
  import {
@@ -109,6 +114,40 @@ export const VERIFICATION_RESOLVED_TYPES = Object.freeze({
109
114
  NOT_REQUIRED_UNCHANGED: 'NOT_REQUIRED_UNCHANGED',
110
115
  });
111
116
 
117
+ /** Re-read every frozen subject tarball before consuming the public receipt. */
118
+ export async function verifyPublicSourceAuthorityReceiptOffline({ plan, root }) {
119
+ const descriptor = plan.publicSourceAuthorityReceipt;
120
+ const verifiedAsset = await verifyFrozenFile({
121
+ root,
122
+ filePath: descriptor.asset.path,
123
+ expectedSha256: descriptor.asset.sha256,
124
+ label: 'public source-authority receipt',
125
+ });
126
+ const actualSubjects = [];
127
+ for (const unitId of descriptor.subjectUnitIds) {
128
+ const unit = plan.units.find((candidate) => candidate.id === unitId);
129
+ const npmDistribution = unit?.distributions?.find((distribution) => distribution.type === 'npm');
130
+ const npm = unit?.frozenSnapshot?.npm;
131
+ await verifyFrozenNpmTarballContract({
132
+ package: npmDistribution?.package,
133
+ version: unit?.targetVersion,
134
+ tarballPath: npm?.tarballPath,
135
+ tarballSha256: npm?.tarballSha256,
136
+ integrity: npm?.integrity,
137
+ }, root);
138
+ actualSubjects.push({
139
+ packageName: npmDistribution.package,
140
+ version: unit.targetVersion,
141
+ filename: basename(npm.tarballPath),
142
+ sha256: npm.tarballSha256,
143
+ });
144
+ }
145
+ return consumePublicSourceAuthorityReceipt(
146
+ await readFile(verifiedAsset.physical),
147
+ actualSubjects,
148
+ );
149
+ }
150
+
112
151
  /**
113
152
  * Map plan action type to adapter ActionType.
114
153
  * Must match publish.mjs and reconcile.mjs.
@@ -1689,6 +1728,19 @@ export async function verifyRelease(options) {
1689
1728
 
1690
1729
  await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'completed' });
1691
1730
 
1731
+ if (plan.publicSourceAuthorityReceipt) {
1732
+ const consumed = await verifyPublicSourceAuthorityReceiptOffline({ plan, root });
1733
+ await evidence.append({
1734
+ phase: 'public-source-authority-receipt',
1735
+ status: 'passed',
1736
+ verificationMode: 'offline-consumer',
1737
+ sourceRepository: consumed.sourceRepository,
1738
+ sourceBaseCommit: consumed.sourceBaseCommit,
1739
+ subjectCount: consumed.subjects.length,
1740
+ assetSha256: consumed.sha256,
1741
+ });
1742
+ }
1743
+
1692
1744
  // =======================================================================
1693
1745
  // Step 4: Installation smoke test
1694
1746
  // =======================================================================