release-skill 0.7.6 → 0.7.8

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 (61) 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 +40 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +14 -11
  10. package/README.zh-CN.md +13 -10
  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 +1728 -1155
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +47 -0
  16. package/adapters/claude/schemas/release-project.schema.json +24 -0
  17. package/adapters/claude/schemas/release-run.schema.json +41 -1
  18. package/adapters/claude/skills/release-prepare/SKILL.md +4 -0
  19. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  20. package/adapters/codex/bin/release-skill.bundle.mjs +1728 -1155
  21. package/adapters/codex/schemas/.render-manifest.json +6 -6
  22. package/adapters/codex/schemas/release-plan.schema.json +47 -0
  23. package/adapters/codex/schemas/release-project.schema.json +24 -0
  24. package/adapters/codex/schemas/release-run.schema.json +41 -1
  25. package/adapters/codex/skills/release-prepare/SKILL.md +4 -0
  26. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  27. package/adapters/kimi/bin/release-skill.bundle.mjs +1728 -1155
  28. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  29. package/adapters/kimi/schemas/release-plan.schema.json +47 -0
  30. package/adapters/kimi/schemas/release-project.schema.json +24 -0
  31. package/adapters/kimi/schemas/release-run.schema.json +41 -1
  32. package/adapters/kimi/skills/release-prepare/SKILL.md +4 -0
  33. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  34. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1728 -1155
  35. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  36. package/adapters/workbuddy/schemas/release-plan.schema.json +47 -0
  37. package/adapters/workbuddy/schemas/release-project.schema.json +24 -0
  38. package/adapters/workbuddy/schemas/release-run.schema.json +41 -1
  39. package/adapters/workbuddy/skills/release-prepare/SKILL.md +4 -0
  40. package/bin/release-skill-cli.mjs +116 -47
  41. package/bin/release-skill.bundle.mjs +1728 -1155
  42. package/package.json +1 -1
  43. package/platform-manifest.json +4 -4
  44. package/references/.render-manifest.json +6 -6
  45. package/references/01-state-machine.md +2 -2
  46. package/references/02-project-config.md +18 -0
  47. package/references/06-adapter-contract.md +4 -3
  48. package/schemas/.render-manifest.json +6 -6
  49. package/schemas/release-plan.schema.json +47 -0
  50. package/schemas/release-project.schema.json +24 -0
  51. package/schemas/release-run.schema.json +41 -1
  52. package/skills/release-prepare/SKILL.md +4 -0
  53. package/skills-src/release-prepare/SKILL.md +4 -0
  54. package/src/adapters/git-github.mjs +139 -26
  55. package/src/commands/prepare.mjs +72 -3
  56. package/src/commands/publish.mjs +51 -0
  57. package/src/commands/verify.mjs +53 -1
  58. package/src/core/config.mjs +31 -0
  59. package/src/core/plan.mjs +78 -0
  60. package/src/core/release-assets.mjs +54 -0
  61. 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.8",
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.8",
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.8",
10
10
  "manifestRequired": true,
11
11
  "projectionSources": {
12
12
  "skills-src-root": {
@@ -6,12 +6,12 @@
6
6
  "bytes": 6335
7
7
  },
8
8
  "01-state-machine.md": {
9
- "digest": "711265e093be13f582ac8be89c391eff79ac22f56c25b91d1ccaef31856ef269",
10
- "bytes": 12086
9
+ "digest": "5afc8e9c29c0d5fb0de0db8b248bbb812fc715af185ac594a172fe68d9a24fed",
10
+ "bytes": 12617
11
11
  },
12
12
  "02-project-config.md": {
13
- "digest": "ce1986761f2aee9aab2646170cbd9ee3d14b7032db3bfb57b17e7369a45197f2",
14
- "bytes": 21924
13
+ "digest": "09e3c470dea54fe9437528620cc169596e75b5162e137683923347c9cb3702a6",
14
+ "bytes": 23167
15
15
  },
16
16
  "03-readme-quality.md": {
17
17
  "digest": "d74e41052592443ebbd450d0ace5f9b9b19c7a94db50f952b17c1c5a4c5dc230",
@@ -26,8 +26,8 @@
26
26
  "bytes": 12014
27
27
  },
28
28
  "06-adapter-contract.md": {
29
- "digest": "e00c175b24764487618dfdf20dc295f12a7e809f7dbfc69f6936a1e97bd02ab8",
30
- "bytes": 18517
29
+ "digest": "fd58e8a438aaa9a7a4a939ac190dbdd5d11c3fcaca08feaced4a612a6d6dcf3f",
30
+ "bytes": 19206
31
31
  }
32
32
  }
33
33
  }
@@ -148,7 +148,7 @@ postPublish hooks 分两个阶段,各自对应独立的 distribute run:
148
148
 
149
149
  工作区基线(Git tree hash、workspace digest、捕获时间)与生命周期状态(计划 status、createdAt、action 运行时 status)是**记录层审计数据**:继续写入计划与审批文件以备查,但不参与 planVersion 2 的 digest、不使批准失效。publish/reconcile 检测到基线漂移时记录 warning 级证据事件并继续执行,产物完整性由发布前的冻结产物重验兜底。planVersion 1(旧版)计划维持旧规则:Git tree hash 与 workspace digest 变化同样使批准失效,基线漂移硬失败(BASELINE_CHANGED)。
150
150
 
151
- 远端冲突状态(如 tag 已存在、npm 版本冲突等)不在 approval 绑定字段中;它在 publish/reconcile preflight 阶段阻断执行并要求人工决策。
151
+ 远端冲突状态不在 approval 绑定字段中。远端对象已存在但与冻结计划完全一致时,`publish`/`reconcile` 把对应检查点标为 `SKIPPED`;存在且不一致,或无法可靠判断时,preflight 阶段阻断执行并要求人工决策。npm 版本只有在观察到有效且与冻结计划相同的 integrity 时才能进入 `SKIPPED`;integrity 缺失、空白、无法解析或不同都在任何 Adapter execute 前失败关闭。
152
152
 
153
153
  失效后系统回退到 PREPARED 状态,要求重新批准。
154
154
 
@@ -161,7 +161,7 @@ postPublish hooks 分两个阶段,各自对应独立的 distribute run:
161
161
  3. 创建并推送签名或可追溯 tag。
162
162
  4. 发布 npm 包。
163
163
  5. 创建 GitHub Release。
164
- 6. 写入远端 URL、commit、tag、包版本、integrity 和执行结果。
164
+ 6. 写入远端 URL、commit、tag、包版本、integrity 和执行结果。若 npm 检查点因执行前观察结果为 `CONSISTENT` 而跳过,只把本次观察到的 integrity 保存到检查点的 `remoteRef.integrity`;包名、版本、registry、预期 integrity 和 tarball 摘要继续以冻结计划为准。
165
165
 
166
166
  任一步失败都停止后续动作,保存检查点并进入 PARTIAL。已完成的检查点通过 `observe` 验证后在 `reconcile` 中幂等跳过。
167
167
 
@@ -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
@@ -205,6 +205,7 @@ marketplace install action 的安装侧载荷校验以 action 参数 `payloadCon
205
205
 
206
206
  ### 4.1 幂等规则
207
207
 
208
+ - `publish` 在全局 preflight 发现远端对象已存在时,先用同一套 `observe` 分类判断它是已一致还是冲突;已一致的检查点进入 `SKIPPED`,冲突或无法可靠判断时在任何 execute 前失败关闭。
208
209
  - `reconcile` 阶段首先通过 `observe` 查询所有计划操作的远端状态。
209
210
  - 状态为 `CONSISTENT` 的操作幂等跳过,不重新执行。
210
211
  - 状态为 `MISSING` 的操作安全重试。
@@ -226,7 +227,7 @@ marketplace install action 的安装侧载荷校验以 action 参数 `payloadCon
226
227
  - 执行前的 `observe` 结果。
227
228
  - 执行结果(成功/失败/跳过)。
228
229
  - 执行后的 `observe` 验证结果。
229
- - 远端资源标识(commit、tag、版本、URL)。
230
+ - 远端资源标识(commit、tag、版本、URL)。npm 检查点因 `preObserve=CONSISTENT` 而跳过时,可在 `remoteRef.integrity` 保存本次观察到的有效 integrity;其他 npm 身份和摘要继续由冻结计划提供。
230
231
 
231
232
  ---
232
233
 
@@ -238,9 +239,9 @@ marketplace install action 的安装侧载荷校验以 action 参数 `payloadCon
238
239
  2. 重新验证发布计划摘要。
239
240
  3. 检查批准记录是否过期。
240
241
  4. 按 planVersion 分叉复验工作区基线:planVersion 1 计划基线变化即失败(`BASELINE_CHANGED`);planVersion 2 计划基线漂移仅记录 warning 级证据事件并继续,产物身份由外部写之前的冻结产物复验保证。
241
- 5. 执行远程 preflight
242
+ 5. 执行全局远程 preflight;占用类结果先用只读 pre-observe 仲裁,`CONSISTENT` 放行到幂等跳过路径,`CONFLICTING` 或不可可靠观察时失败关闭。
242
243
  6. 按批准的操作列表顺序执行每个检查点。
243
- 7. 每个检查点:preflight -> execute -> observe -> 记录。
244
+ 7. 每个检查点:pre-observe -> preflight -> execute -> post-observe -> 记录。pre-observe 为 `CONSISTENT` 时直接记录 `SKIPPED`,不调用 execute 或 post-observe。
244
245
  8. 任一检查点失败时停止后续动作,计算 PARTIAL(若已有成功检查点)。
245
246
  9. 所有检查点成功后进入 PUBLISHED 状态。
246
247
 
@@ -26,16 +26,16 @@
26
26
  "bytes": 1744
27
27
  },
28
28
  "release-plan.schema.json": {
29
- "digest": "48b7755dfbeee88ccf78c3200000ab2f5dd06db42bc7b423aed0f81a91cc0418",
30
- "bytes": 54672
29
+ "digest": "3d24cb908cdfbfd5d89551b7c6fed5140f6f1448b9920f114264291ca4cf1c52",
30
+ "bytes": 55905
31
31
  },
32
32
  "release-project.schema.json": {
33
- "digest": "cc2061799c3a0dd6daea940148e3497b7a4ae8cafb987bf51de7c34c1fed921d",
34
- "bytes": 46932
33
+ "digest": "b0ba3c46692622f734a80d3f4e3904180bbfd1096ca2d9b6d6fd957917e96f73",
34
+ "bytes": 47688
35
35
  },
36
36
  "release-run.schema.json": {
37
- "digest": "9755a8caa58dd02509ac8e6f869aec5e74d8ae3f7339a3f944977bfad7b729ad",
38
- "bytes": 17897
37
+ "digest": "4a041b8f6488e7d61b7eb271ebdbc2e043731df41c4437783ef63e77f664c192",
38
+ "bytes": 19002
39
39
  }
40
40
  }
41
41
  }
@@ -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,
@@ -199,6 +199,11 @@
199
199
  },
200
200
  "url": {
201
201
  "type": "string"
202
+ },
203
+ "integrity": {
204
+ "type": "string",
205
+ "pattern": "^sha512-[A-Za-z0-9+/]{86}==$",
206
+ "description": "Observed npm dist.integrity when an already-consistent npm publish is skipped"
202
207
  }
203
208
  }
204
209
  },
@@ -280,7 +285,42 @@
280
285
  "minimum": 0,
281
286
  "description": "写入目标的载荷文件数"
282
287
  }
283
- }
288
+ },
289
+ "allOf": [
290
+ {
291
+ "if": {
292
+ "properties": {
293
+ "remoteRef": {
294
+ "type": "object",
295
+ "required": [
296
+ "integrity"
297
+ ]
298
+ }
299
+ },
300
+ "required": [
301
+ "remoteRef"
302
+ ]
303
+ },
304
+ "then": {
305
+ "properties": {
306
+ "actionType": {
307
+ "const": "npm-publish"
308
+ },
309
+ "status": {
310
+ "const": "skipped"
311
+ },
312
+ "preObserve": {
313
+ "const": "CONSISTENT"
314
+ }
315
+ },
316
+ "required": [
317
+ "actionType",
318
+ "status",
319
+ "preObserve"
320
+ ]
321
+ }
322
+ }
323
+ ]
284
324
  }
285
325
  },
286
326
  "sourceAuthorityReceipts": {
@@ -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'], {