release-skill 0.7.5 → 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 (60) 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 +37 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +22 -11
  10. package/README.zh-CN.md +17 -8
  11. package/SECURITY.md +12 -6
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +1729 -1159
  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/skills/release-prepare/SKILL.md +5 -1
  18. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  19. package/adapters/codex/bin/release-skill.bundle.mjs +1729 -1159
  20. package/adapters/codex/schemas/release-plan.schema.json +47 -0
  21. package/adapters/codex/schemas/release-project.schema.json +24 -0
  22. package/adapters/codex/skills/release-assess/SKILL.md +1 -1
  23. package/adapters/codex/skills/release-config/SKILL.md +1 -1
  24. package/adapters/codex/skills/release-docs/SKILL.md +1 -1
  25. package/adapters/codex/skills/release-help/SKILL.md +1 -1
  26. package/adapters/codex/skills/release-marketplace/SKILL.md +1 -1
  27. package/adapters/codex/skills/release-prepare/SKILL.md +6 -2
  28. package/adapters/codex/skills/release-publish/SKILL.md +1 -1
  29. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  30. package/adapters/codex/skills/release-setup/SKILL.md +1 -1
  31. package/adapters/codex/skills/release-verify/SKILL.md +1 -1
  32. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  33. package/adapters/kimi/bin/release-skill.bundle.mjs +1729 -1159
  34. package/adapters/kimi/schemas/release-plan.schema.json +47 -0
  35. package/adapters/kimi/schemas/release-project.schema.json +24 -0
  36. package/adapters/kimi/skills/release-prepare/SKILL.md +5 -1
  37. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  38. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1729 -1159
  39. package/adapters/workbuddy/schemas/release-plan.schema.json +47 -0
  40. package/adapters/workbuddy/schemas/release-project.schema.json +24 -0
  41. package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -1
  42. package/bin/release-skill-cli.mjs +116 -47
  43. package/bin/release-skill.bundle.mjs +1729 -1159
  44. package/package.json +1 -1
  45. package/platform-manifest.json +4 -4
  46. package/references/.render-manifest.json +4 -4
  47. package/references/02-project-config.md +73 -29
  48. package/schemas/release-plan.schema.json +47 -0
  49. package/schemas/release-project.schema.json +24 -0
  50. package/skills/release-prepare/SKILL.md +5 -1
  51. package/skills-src/release-prepare/SKILL.md +5 -1
  52. package/src/adapters/git-github.mjs +139 -26
  53. package/src/commands/prepare.mjs +76 -6
  54. package/src/commands/publish.mjs +36 -0
  55. package/src/commands/verify.mjs +53 -1
  56. package/src/core/config.mjs +31 -0
  57. package/src/core/plan.mjs +78 -0
  58. package/src/core/release-assets.mjs +54 -0
  59. package/src/core/source-authority.mjs +127 -3
  60. package/src/producers/build-adapters.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.7.5",
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.5",
5
+ "version": "0.7.7",
6
6
  "logicalRoot": "skills-src/",
7
- "logicalRootDigest": "3767fc36f42ca561f5e839cea7b7e5bc16d08c47867b5bd6086f25efd0cb0f06",
8
- "projectionDigest": "419f7f9601015459956e10aae74f42240ab2ac248238ff7494f3e761643d1749",
9
- "projectionId": "release-skill:platform:0.7.5",
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": {
@@ -10,8 +10,8 @@
10
10
  "bytes": 12086
11
11
  },
12
12
  "02-project-config.md": {
13
- "digest": "f866490d5897c2baf38fd3a6770964c052bea4418f034b75965ecfe49c2f4387",
14
- "bytes": 21212
13
+ "digest": "ce1986761f2aee9aab2646170cbd9ee3d14b7032db3bfb57b17e7369a45197f2",
14
+ "bytes": 21924
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": "4b1ec9ba843345df47c1d133a8bf1aa644bf12a4ecf760cb1d566a43f1e9a9d2",
30
- "bytes": 17282
29
+ "digest": "e00c175b24764487618dfdf20dc295f12a7e809f7dbfc69f6936a1e97bd02ab8",
30
+ "bytes": 18517
31
31
  }
32
32
  }
33
33
  }
@@ -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> # 源码相对路径(相对项目根目录)
@@ -70,6 +74,20 @@ releaseUnits: # 发布单元数组,至少 1 个
70
74
  versionMarkers: # 可选;id 唯一
71
75
  - id: <string>
72
76
  pattern: <string> # 含 {version} 占位符的精确标记模式
77
+ postPublish: # 可选;发布单元级的发布后分发配置
78
+ commitIdentity:
79
+ name: <string>
80
+ email: <string>
81
+ targets: # 可选;镜像或市场索引目标
82
+ - id: <string>
83
+ kind: payload-mirror # 或 marketplace-index
84
+ remoteUrl: <string> # file:// 或 https://;禁止 ssh
85
+ visibility: public # 或 private / internal
86
+ branch: <string>
87
+ hooks: # 可选;命名 preset 或自定义 command hook
88
+ - id: <string>
89
+ command: [<executable>, <arg>]
90
+ phase: distribute # 或 postVerify
73
91
 
74
92
  verificationGates: # 可选;定义会进入冻结计划摘要
75
93
  - id: <stable-id>
@@ -83,43 +101,69 @@ verificationGates: # 可选;定义会进入冻结计划摘要
83
101
  envAllowlist: []
84
102
 
85
103
  hooks: # 可选,命令 hooks
104
+ lint: <hook>
86
105
  docs: <hook>
87
106
  build: <hook>
88
107
  test: <hook>
89
108
  typecheck: <hook>
90
- lint: <hook>
91
-
92
- postPublish: # 可选;发布后分发配置(W2 closure)
93
- distribute: # 分发行动数组
94
- - actionType: <string> # "distribute-mirror" | "distribute-marketplace-index"
95
- adapter: <string> # "distribute-git" | "distribute-marketplace"
96
- kind: <string> # "payload-mirror" | "marketplace-index-form"
97
- targetId: <string> # 目标标识
98
- remoteUrl: <string> # file:// 或 https://;禁止 ssh
99
- branch: <string> # 分支名,不得以 '-' 开头
100
- tag: <string> # tag 模板,用于版本验证
101
- commitIdentity: # 冻结提交身份
102
- name: <string> # 禁止以 '-' 开头
103
- email: <string> # RFC 5322 valid
104
- payloadDir: <string> # 相对路径
105
- dryRun?: boolean # 可选;只本地执行
106
- materialize?: { # 可选;缺省时载荷由冻结 publicFiles 经 Foundation 托管投影物化
107
- command: <string> # 可执行文件
108
- args: [<string>] # 参数数组,禁止 shell 字符串
109
- outputMarker?: <string> # 输出标记,用于验证
110
- requireReport: {} # 前置报告要求
111
- urlDependsOn: [<string>]> # 依赖动作完成
112
- onConflict?: <string> # "FAIL" (默认) | "RETRY"
113
- retryPolicy?: { # 可选;重试策略
114
- maxAttempts: number
115
- backoffMs: number
116
- }
117
109
 
118
110
  policy: # 可选,安全策略
119
111
  forbiddenPaths: [<string>]
120
112
  forbiddenContentPatterns: [<string>]
121
113
  ```
122
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
+
129
+ `postPublish` 只能位于某个 `releaseUnits[]` 条目内。下面的完整结构样例会由 schema 契约测试直接读取并校验,避免文档层级再次漂移:
130
+
131
+ ```yaml
132
+ # schema-example: postpublish-release-unit
133
+ apiVersion: release-skill/v1
134
+ kind: ReleaseProject
135
+ project:
136
+ name: postpublish-example
137
+ defaultBranch: main
138
+ releaseUnits:
139
+ - id: postpublish-example
140
+ source: .
141
+ publicRepo: example/postpublish-example
142
+ version:
143
+ source: package.json
144
+ tagTemplate: v{version}
145
+ distributions:
146
+ - type: npm
147
+ package: postpublish-example
148
+ registry: https://registry.npmjs.org
149
+ publisher: example
150
+ publicFiles:
151
+ - from: README.md
152
+ to: README.md
153
+ mode: preserve
154
+ requiredPublicFiles: [README.md]
155
+ previousPublicBaseline:
156
+ mode: none
157
+ postPublish:
158
+ commitIdentity:
159
+ name: release-bot
160
+ email: release-bot@example.com
161
+ hooks:
162
+ - id: notify-downstream
163
+ command: [node, scripts/notify-downstream.mjs]
164
+ phase: postVerify
165
+ ```
166
+
123
167
  ---
124
168
 
125
169
  ## 3. 公开源码镜像与分发包
@@ -218,7 +262,7 @@ squash、rebase 和默认分支上的无关后续提交可以通过;冲突解
218
262
  | `timeoutMs` | 整数 | 否 | 最小 1000,最大 7200000(2 小时) |
219
263
  | `envAllowlist` | 字符串数组 | 否 | 每个元素匹配 `^[A-Z_][A-Z0-9_]*$`;无重复元素 |
220
264
 
221
- hook 执行规则:
265
+ hook 执行顺序固定为 `lint → docs → build → test → typecheck`,优先运行成本最低的检查。执行规则如下:
222
266
 
223
267
  - 使用 `execFile`,不使用 `exec` 或 `{ shell: true }`。
224
268
  - 通过 `realpath` 解析 cwd,拒绝逃逸到项目根目录之外。
@@ -254,7 +298,7 @@ hooks:
254
298
 
255
299
  `snapshot-verify` 获得冻结公开快照的一次性可写副本;副本销毁后重新摘要原快照,失败时还没有远端写入。`consumer-verify` 只在精确 npm 或 Claude/Codex 隔离安装成功后,从安装根执行;结果摘要进入 verify run,失败不得达到 VERIFIED。
256
300
 
257
- gate 与 legacy hook 都是无网络沙箱的本地进程,必须显式确认副作用。release-skill 会限制环境、路径、超时和证据输出,但不能替项目保证其命令不会写文件或访问网络。Git push、tag、默认分支、GitHub Release 和 npm publish 只能是冻结计划里的 adapter action,不能注册为 hook/gate。
301
+ gate 与 legacy hook 都是没有网络沙箱的本地进程。在 `.release-skill/project.yaml` 中配置命令即完成授权,调用对应命令时直接执行,不再增加触发前确认点。旧参数 `--acknowledge-hook-side-effects` 和 `--acknowledge-gate-side-effects` 只作为无效果的兼容输入保留。release-skill 会限制环境、路径、超时和证据输出,但不能替项目保证命令不会写文件或访问网络。恢复触发前强制确认门属于后续设计变更,0.7.6 不提供该确认门。Git push、tag、默认分支、GitHub Release 和 npm publish 只能是冻结计划里的 adapter action,不能注册为 hook/gate。
258
302
 
259
303
  ### 4.2 首次接入 setup
260
304
 
@@ -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,
@@ -13,7 +13,7 @@ description: Freeze an immutable release plan with local configuration, document
13
13
 
14
14
  运行项目构建/测试 hook,生成公开快照并扫描泄漏,冻结不可变发布计划。prepare 自身不调用发布 adapter,但会执行用户配置的 hook。
15
15
 
16
- **Hook 授权契约(配置时刻即授权,FM-16 处置 A)**: hook 是任意本地进程——在 `.release-skill/project.yaml` 中配置 hook 命令即完成授权,构成「配置时刻即授权」的显式契约。hook 不提供沙箱、无文件系统/网络隔离、触发前无确认点,命令调用本身即授权执行已配置的 hook 和 gate,不再设置额外人工授权环节;hook 可能产生项目目录外的副作用或远端写入,配置者须对其内容负责。恢复「触发前强制确认门」属于后续加固项(属设计变更,需随新版本引入),当前版本不提供该确认门。
16
+ **Hook 授权契约(配置时刻即授权,FM-16 处置 A)**: hook 是任意本地进程——在 `.release-skill/project.yaml` 中配置 hook 命令即完成授权,构成「配置时刻即授权」的显式契约。hook 不提供沙箱、无文件系统/网络隔离、触发前无确认点,命令调用本身即授权执行已配置的 hook 和 gate,不再设置额外人工授权环节;hook 可能产生项目目录外的副作用或远端写入,配置者须对其内容负责。旧参数 `--acknowledge-hook-side-effects` 和 `--acknowledge-gate-side-effects` 仍可解析,但只作为无效果的兼容输入,不能改变授权或执行语义。恢复「触发前强制确认门」属于后续加固项(属设计变更,需随新版本引入),当前版本不提供该确认门。
17
17
 
18
18
  **阶段通过规则**: 本阶段的通过只能由 CLI exit code 0 和结构化状态码 `PREPARED` 确认。Agent 无权自行宣布计划冻结成功。
19
19
 
@@ -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 不做
@@ -13,7 +13,7 @@ description: Freeze an immutable release plan with local configuration, document
13
13
 
14
14
  运行项目构建/测试 hook,生成公开快照并扫描泄漏,冻结不可变发布计划。prepare 自身不调用发布 adapter,但会执行用户配置的 hook。
15
15
 
16
- **Hook 授权契约(配置时刻即授权,FM-16 处置 A)**: hook 是任意本地进程——在 `.release-skill/project.yaml` 中配置 hook 命令即完成授权,构成「配置时刻即授权」的显式契约。hook 不提供沙箱、无文件系统/网络隔离、触发前无确认点,命令调用本身即授权执行已配置的 hook 和 gate,不再设置额外人工授权环节;hook 可能产生项目目录外的副作用或远端写入,配置者须对其内容负责。恢复「触发前强制确认门」属于后续加固项(属设计变更,需随新版本引入),当前版本不提供该确认门。
16
+ **Hook 授权契约(配置时刻即授权,FM-16 处置 A)**: hook 是任意本地进程——在 `.release-skill/project.yaml` 中配置 hook 命令即完成授权,构成「配置时刻即授权」的显式契约。hook 不提供沙箱、无文件系统/网络隔离、触发前无确认点,命令调用本身即授权执行已配置的 hook 和 gate,不再设置额外人工授权环节;hook 可能产生项目目录外的副作用或远端写入,配置者须对其内容负责。旧参数 `--acknowledge-hook-side-effects` 和 `--acknowledge-gate-side-effects` 仍可解析,但只作为无效果的兼容输入,不能改变授权或执行语义。恢复「触发前强制确认门」属于后续加固项(属设计变更,需随新版本引入),当前版本不提供该确认门。
17
17
 
18
18
  **阶段通过规则**: 本阶段的通过只能由 CLI exit code 0 和结构化状态码 `PREPARED` 确认。Agent 无权自行宣布计划冻结成功。
19
19
 
@@ -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'], {