release-skill 0.9.33 → 0.9.34
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/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/CHANGELOG.md +28 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +31 -13
- package/README.zh-CN.md +17 -13
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/claude/schemas/.render-manifest.json +8 -4
- package/adapters/claude/schemas/release-plan.schema.json +85 -0
- package/adapters/claude/schemas/release-project.schema.json +18 -0
- package/adapters/claude/schemas/test-intake.schema.json +217 -0
- package/adapters/claude/skills/release-help/SKILL.md +4 -1
- package/adapters/claude/skills/release-prepare/SKILL.md +9 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/codex/schemas/.render-manifest.json +8 -4
- package/adapters/codex/schemas/release-plan.schema.json +85 -0
- package/adapters/codex/schemas/release-project.schema.json +18 -0
- package/adapters/codex/schemas/test-intake.schema.json +217 -0
- package/adapters/codex/skills/release-help/SKILL.md +4 -1
- package/adapters/codex/skills/release-prepare/SKILL.md +9 -2
- package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
- package/adapters/cursor/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/cursor/schemas/.render-manifest.json +8 -4
- package/adapters/cursor/schemas/release-plan.schema.json +85 -0
- package/adapters/cursor/schemas/release-project.schema.json +18 -0
- package/adapters/cursor/schemas/test-intake.schema.json +217 -0
- package/adapters/cursor/skills/release-help/SKILL.md +4 -1
- package/adapters/cursor/skills/release-prepare/SKILL.md +9 -2
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/kimi/schemas/.render-manifest.json +8 -4
- package/adapters/kimi/schemas/release-plan.schema.json +85 -0
- package/adapters/kimi/schemas/release-project.schema.json +18 -0
- package/adapters/kimi/schemas/test-intake.schema.json +217 -0
- package/adapters/kimi/skills/release-help/SKILL.md +4 -1
- package/adapters/kimi/skills/release-prepare/SKILL.md +9 -2
- package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/qoder/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/qoder/schemas/.render-manifest.json +8 -4
- package/adapters/qoder/schemas/release-plan.schema.json +85 -0
- package/adapters/qoder/schemas/release-project.schema.json +18 -0
- package/adapters/qoder/schemas/test-intake.schema.json +217 -0
- package/adapters/qoder/skills/release-help/SKILL.md +4 -1
- package/adapters/qoder/skills/release-prepare/SKILL.md +9 -2
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +5847 -4041
- package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
- package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
- package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +4 -1
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +9 -2
- package/bin/release-skill-cli.mjs +37 -2
- package/bin/release-skill.bundle.mjs +5847 -4041
- package/package.json +1 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +6 -6
- package/references/01-state-machine.md +12 -1
- package/references/02-project-config.md +20 -0
- package/references/05-evidence-and-errors.md +30 -0
- package/schemas/.render-manifest.json +8 -4
- package/schemas/release-plan.schema.json +85 -0
- package/schemas/release-project.schema.json +18 -0
- package/schemas/test-intake.schema.json +217 -0
- package/skills/release-help/SKILL.md +4 -1
- package/skills/release-prepare/SKILL.md +9 -2
- package/skills-src/release-help/SKILL.md +4 -1
- package/skills-src/release-prepare/SKILL.md +9 -2
- package/src/commands/approve.mjs +13 -0
- package/src/commands/prepare.mjs +501 -5
- package/src/commands/publish.mjs +2 -0
- package/src/commands/reconcile.mjs +2 -0
- package/src/commands/route.mjs +32 -7
- package/src/commands/ship.mjs +168 -5
- package/src/core/config.mjs +5 -0
- package/src/core/plan.mjs +3 -1
- package/src/core/test-candidate.mjs +649 -0
- package/src/core/test-intake.mjs +645 -0
|
@@ -29,7 +29,7 @@ description: "Discoverable entry point for release-skill: dependency and environ
|
|
|
29
29
|
|
|
30
30
|
治理诊断只运行 release-skill 自己的只读检查程序,不运行目标 Skill、业务脚本、构建、hook 或发布动作,也不把 `prepare`、`verify`、`release-finish` 当作静态治理入口。用户要求实际接入或发布时,把已有授权带到对应原业务入口;各入口继续执行原有确认、副作用和状态机合同。
|
|
31
31
|
|
|
32
|
-
## 0.9.
|
|
32
|
+
## 0.9.34 能力与证据边界
|
|
33
33
|
|
|
34
34
|
四类工程材料接到现有 `release-project` 字段。公开正式材料写入 `publicFiles`(`sourceScope` / `from` / `to` / `mode`)。运行必需资源写入 `requiredPublicFiles`,必须被同一单元 `publicFiles[].to` 精确覆盖。私有正式材料与非正式运行材料不列入 `publicFiles`,需要挡住时使用 `forbiddenPaths` / `forbiddenContentPatterns`。正式不等于公开。是否进入公开快照由 `publicFiles` 是否列出决定。npm 是载荷渠道。`package.json` 的 `files` 与入口闭包和公开映射同时约束。`delivery.include` / `delivery.exclude` 只选择宿主,不是文件包含或排除。排除不得拿掉运行必需资源。互相冲突的配置必须失败关闭。
|
|
35
35
|
|
|
@@ -52,6 +52,8 @@ Qoder 本机更新路径处理维持既有能力,支持 `./adapters/qoder/skil
|
|
|
52
52
|
|
|
53
53
|
在冻结前,`prepare` 或新建 `ship` 状态时可重复传入 `--unit <id>`;未传入则默认选择全部单元。延期单元不进入发布计划,亦不获得发布状态。完整配置、生成物新鲜度检查及顶层 Hook 仍作用于全项目范围。`publicSourceAuthorityReceipt` 的 coordinator 与全部 subjects 必须共同选择。
|
|
54
54
|
|
|
55
|
+
可选 `testIntake` 只在生产 prepare 生效。首次准备返回 `NEEDS_INPUT` 和一份非正式候选,退出码 13;同一候选用 `prepare --candidate <path>` 续接,接受后才有正式计划。候选不能批准或发布,也不能清除 `PARTIAL`、`PUBLISHED` 或 `VERIFIED`。未声明的项目、原有 hook 和完整门保持不变。轻量工作流和单元选择都不免除项目级要求。非生产 prepare 遇到 `testIntake` 返回 `CONFIG_INVALID`。
|
|
56
|
+
|
|
55
57
|
冻结后,发布流程(publish、reconcile、verify、distribute)不再接受 `--unit` 参数,仅以 `plan.units` 为唯一有效范围。
|
|
56
58
|
|
|
57
59
|
每个单元必须声明 `previousPublicBaseline`:只有确认没有前序公开版本时,才使用 `none`;否则必须提供 `bound`,并包含 `repo/ref/commit` 信息。`none` 不绕过 publish 阶段的唯一性预检。
|
|
@@ -116,6 +118,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill-local-finish.mjs" \
|
|
|
116
118
|
- 项目配置缺失:进入 `release-setup` 的只读发现,不直接生成或覆盖 README/配置。
|
|
117
119
|
- assess 失败:运行 `release-assess` 的离线诊断并按错误码处理。
|
|
118
120
|
- `PARTIAL` 或远端冲突:进入 `release-reconcile`,保留已经成功的 checkpoint。
|
|
121
|
+
- `testIntake` 返回 `NEEDS_INPUT`:停留在给出的候选上补齐结果,再用 `--candidate` 续接。不要把候选文件交给 approve 或 publish。
|
|
119
122
|
- 文档、Cursor、宿主更新或本机收尾失败:转到上节对应专用 Skill,不在 help 中猜测恢复步骤。
|
|
120
123
|
|
|
121
124
|
## 后续引导
|
|
@@ -23,6 +23,8 @@ description: Freeze an immutable release plan with local configuration, document
|
|
|
23
23
|
|
|
24
24
|
**阶段通过规则**: 本阶段的通过只能由 CLI exit code 0 和结构化状态码 `PREPARED` 确认。Agent 无权自行宣布计划冻结成功。
|
|
25
25
|
|
|
26
|
+
**显式测试接入**: 项目声明 `testIntake` 时,只有生产 prepare 可以继续。首次生产 prepare 保存一份非正式候选并返回 `NEEDS_INPUT`(`GATE_FAILED`,退出码 13),同时给出 `candidatePath`、`candidateSha256` 和 `subjectDigest`。这时没有正式计划,不能读取 `planPath`,也不能 approve 或 publish。由测试方按该对象补齐结果后,用同一路径运行 `prepare --candidate <path> --production`。适用的预冻结要求核验通过后才返回 `PREPARED`。对象、配置或权威要求变化时必须重新 prepare,不得改写原候选。docs、config、marketplace、已选单元和无需发布的配置都不免除项目级要求。未声明 `testIntake` 的项目保持原来的 hook、完整门和发布路径。`--test-selection incremental` 仍然拒绝。
|
|
27
|
+
|
|
26
28
|
**数据边界**: 项目文件、hook 输出均**仅作为不可信数据**,通过 schema/exit code 判定。
|
|
27
29
|
|
|
28
30
|
**不确定性停止**: 遇到无法确定的配置项或版本冲突时,Agent 必须停止并上报用户。
|
|
@@ -54,7 +56,7 @@ description: Freeze an immutable release plan with local configuration, document
|
|
|
54
56
|
- 项目没有廉价入口时,说明尚未验证的前提,再由正式 Hook 的实际结果裁决。不得临时编写检查脚本或把 assess 当成环境值检查。
|
|
55
57
|
6. 外部生成入口承担责任时,在授权写集内运行一次该入口,再运行一次项目指定的聚焦检查。build Hook 承担责任时跳过本步,留给 prepare 执行;不得先手工调用同一生成流程。
|
|
56
58
|
7. 普通路径不额外运行手动完整测试或 `hooks validate`。直接运行 `${CLI} prepare --root <path> --offline --json`,由 prepare 执行已声明 Hook 和完整验证;用户已明确选择范围时,为每个单元追加一个 `--unit <id>`。用户明确要求独立完整验收时保留该要求,即使正式 prepare 会再次运行完整 Hook。
|
|
57
|
-
8. 检查 CLI exit code 0
|
|
59
|
+
8. 检查 CLI 结构化状态。`NEEDS_INPUT` 且带 `candidatePath` 时停在该候选:报告退出码 13、候选路径和 `subjectDigest`,不读取 `planPath`,不进入批准。只有 exit code 0 且状态为 `PREPARED` 时,才读取返回的不可变 `planPath=plans/<planDigest>.json`,再从该文件读取 `status`、`units` 和 `externalActions`。build Hook 承担生成时,还要从 Hook 输出及 evidence 确认完整依赖链和聚焦检查各执行一次。聚焦检查通过、Hook 通过和 `PREPARED` 是不同结果,不得互相代替。
|
|
58
60
|
9. 读取命令返回的不可变 `planPath` 对应计划,按其中实际存在的 `units`、`externalActions`、`waivers` 和 `postPublish` 整理人可读批准摘要。版本、公开仓库、分支策略、branch/tag、npm 与 GitHub Release 目标、全部外部动作、例外,以及需要独立 checkpoint 批准的 postPublish hook,只展示计划里已经写下的值;没有的字段保持缺失,不猜测。prepare 成功时返回不可变 `planPath`、`planDigest` 等字段,批准摘要来自该计划文件。`planDigest` 仅作为内部绑定字段,不要求用户复制或确认。后续 approve/publish 只能使用该 immutable planPath,等待确认后再 approve。计划批准不包含受限 postPublish hook 的 checkpoint 批准。
|
|
59
61
|
|
|
60
62
|
报告调用次数时,以本轮请求开始到取得结果或停止为计数窗口。分别列出生成命令、聚焦检查、正式 Hook 前提检查和昂贵测试。`prepare` 不是生成命令;一次 Hook 启动也不能证明昂贵测试已经进入测试体。优先使用工具转录、项目夹具输出和 CLI evidence,不能用模型自报替代实际记录。
|
|
@@ -103,6 +105,9 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --offli
|
|
|
103
105
|
--unit <unit-a> --unit <unit-b> --json
|
|
104
106
|
# 生产 happy end:bound 基线必须 online;远端目标唯一性仍由 publish 全局预检
|
|
105
107
|
node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --online --production --json
|
|
108
|
+
# 已声明 testIntake:同一候选补齐结果后续接,不重新生成候选
|
|
109
|
+
node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --online --production \
|
|
110
|
+
--candidate <candidate-path> --json
|
|
106
111
|
```
|
|
107
112
|
|
|
108
113
|
## 执行顺序
|
|
@@ -123,7 +128,9 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --onlin
|
|
|
123
128
|
| RELEASE_DOCS_STALE | 文档相对说明源已陈旧;按详情运行只读演练,展示文件/语种/版本/摘要,经用户授权“本地发布文档写入”后执行写入,审阅提交再重新 prepare |
|
|
124
129
|
| RELEASE_DOCS_INVALID / TRANSLATION_MISSING / CONFLICT / REFRESH_STALE | 修复配置/说明源/目标或重新演练取得新 `refreshDigest`;不得扩大写入范围绕过 |
|
|
125
130
|
| SECRET_DETECTED | 移除密钥并更新 allowlist |
|
|
126
|
-
| CONFIG_INVALID | 先用 assess 定位结构化配置错误;检查 version.source、package.json、环境白名单是否为合法大写名称,以及 `--unit` 是否为空、重复或不在 `releaseUnits[]`
|
|
131
|
+
| CONFIG_INVALID | 先用 assess 定位结构化配置错误;检查 version.source、package.json、环境白名单是否为合法大写名称,以及 `--unit` 是否为空、重复或不在 `releaseUnits[]` 中。修复须使用已知合法值和明确写入授权。非生产 prepare 上的 `testIntake` 属于不支持,不要改成生产命令来绕过未授权的发布 |
|
|
132
|
+
| GATE_FAILED (`TEST_INTAKE_NEEDS_INPUT`) | 候选已保存但结果尚未接受。保持该 `candidatePath`,补齐绑定 `subjectDigest` 的结果后,用 `--candidate` 续接。不要把候选交给 approve 或 publish,也不要重建第二份候选 |
|
|
133
|
+
| GATE_FAILED (`TEST_CANDIDATE_REPREPARE`) | 候选绑定的对象、配置或权威要求已变化。保留原候选字节,重新做一次生产 prepare |
|
|
127
134
|
|
|
128
135
|
## 后续引导
|
|
129
136
|
|
package/src/commands/approve.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
validatePostPublishHookIdUniqueness,
|
|
39
39
|
} from '../core/postpublish.mjs';
|
|
40
40
|
import { WORKSPACE_DIGEST_ALGORITHM } from '../core/baseline.mjs';
|
|
41
|
+
import { projectRootFromPlanPath, verifyTestAcceptance } from '../core/test-intake.mjs';
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// Default clock
|
|
@@ -141,8 +142,20 @@ export async function approvePlan(options) {
|
|
|
141
142
|
);
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
if (plan.candidateVersion === 1 && plan.planVersion == null) {
|
|
146
|
+
throw new ReleaseError(GATE_FAILED, 'a test candidate is not a release plan', {
|
|
147
|
+
reason: 'TEST_CANDIDATE_NOT_PLAN',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
144
151
|
// --- Step 4: Validate the plan ---
|
|
145
152
|
validatePlan(plan);
|
|
153
|
+
if (plan.testAcceptance) {
|
|
154
|
+
await verifyTestAcceptance({
|
|
155
|
+
root: options.root ?? projectRootFromPlanPath(planPath),
|
|
156
|
+
plan,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
146
159
|
|
|
147
160
|
if (
|
|
148
161
|
plan.production?.mode === 'github-npm-v1' &&
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -36,7 +36,15 @@ import {
|
|
|
36
36
|
} from '../core/public-surface.mjs';
|
|
37
37
|
import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
|
|
38
38
|
import { asError, createEvidenceWriter } from '../core/evidence.mjs';
|
|
39
|
-
import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
39
|
+
import { computePlanDigest, validatePlan, validatePlanActionCompleteness, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
40
|
+
import { assertTestIntakeDeclaration, readTestRequirements, subjectDigestOf } from '../core/test-intake.mjs';
|
|
41
|
+
import {
|
|
42
|
+
decideTestCandidateHandoff,
|
|
43
|
+
projectPrepared,
|
|
44
|
+
readCandidateEvidenceSha256,
|
|
45
|
+
readTestCandidate,
|
|
46
|
+
writeTestCandidate,
|
|
47
|
+
} from '../core/test-candidate.mjs';
|
|
40
48
|
import { sha256Hex } from '../core/digest.mjs';
|
|
41
49
|
import {
|
|
42
50
|
CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
|
|
@@ -54,6 +62,9 @@ import {
|
|
|
54
62
|
computeFrozenSnapshot,
|
|
55
63
|
normalizeGitTimestamp,
|
|
56
64
|
sealFrozenSnapshot,
|
|
65
|
+
verifyFrozenFile,
|
|
66
|
+
verifyFrozenGitRepository,
|
|
67
|
+
verifyFrozenSnapshot,
|
|
57
68
|
} from '../snapshot/frozen.mjs';
|
|
58
69
|
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
|
|
59
70
|
import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
|
|
@@ -91,10 +102,11 @@ import {
|
|
|
91
102
|
normalizePostPublishDeclaration,
|
|
92
103
|
orderNormalizedHooks,
|
|
93
104
|
validatePostPublishHookIdUniqueness,
|
|
105
|
+
normalizePostPublishView,
|
|
94
106
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
95
107
|
} from '../core/postpublish.mjs';
|
|
96
|
-
import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
|
|
97
|
-
import { digestReleaseAssetIdentities } from '../core/release-assets.mjs';
|
|
108
|
+
import { freezeExecutionBundle, bundleRootForAuthorityDir, verifyExecutionBundle } from '../core/postpublish-bundle.mjs';
|
|
109
|
+
import { digestReleaseAssetIdentities, normalizeReleaseAssets } from '../core/release-assets.mjs';
|
|
98
110
|
|
|
99
111
|
// ---------------------------------------------------------------------------
|
|
100
112
|
// 安装契约常量
|
|
@@ -3288,6 +3300,363 @@ function resolveRequestedReleaseUnits(config, requestedUnitIds, retryOptions) {
|
|
|
3288
3300
|
};
|
|
3289
3301
|
}
|
|
3290
3302
|
|
|
3303
|
+
function candidateReprepare(message, drifts, details = {}) {
|
|
3304
|
+
throw new ReleaseError(GATE_FAILED, message, {
|
|
3305
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3306
|
+
drifts,
|
|
3307
|
+
...details,
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
async function recheckCandidateClosure(realRoot, prepared, config, unitIds) {
|
|
3312
|
+
const authority = prepared?.sourceAuthority;
|
|
3313
|
+
if (!authority) return;
|
|
3314
|
+
const stored = authority.inputDigest;
|
|
3315
|
+
if (typeof stored !== 'string' || stored.length === 0) {
|
|
3316
|
+
candidateReprepare('test candidate source inputDigest is missing', ['inputDigest'], {
|
|
3317
|
+
storedInputDigest: stored ?? null,
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
const selected = new Set(unitIds ?? []);
|
|
3321
|
+
const units = (config.releaseUnits ?? []).filter((unit) => selected.has(unit.id));
|
|
3322
|
+
const closure = await computeSourceInputClosure({ units, root: realRoot });
|
|
3323
|
+
if (closure.digest !== stored) {
|
|
3324
|
+
candidateReprepare(
|
|
3325
|
+
`test candidate source inputDigest changed: ${stored} -> ${closure.digest}`,
|
|
3326
|
+
[`inputDigest:${stored}->${closure.digest}`],
|
|
3327
|
+
{ inputDigest: closure.digest, storedInputDigest: stored },
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
async function recheckExecutionBundle(realRoot, prepared) {
|
|
3333
|
+
let declarations;
|
|
3334
|
+
try {
|
|
3335
|
+
declarations = normalizePostPublishView(prepared);
|
|
3336
|
+
} catch (err) {
|
|
3337
|
+
if (!prepared?.postPublish) return;
|
|
3338
|
+
throw err;
|
|
3339
|
+
}
|
|
3340
|
+
const anchor = resolve(realRoot, '.release-skill', 'release-plan.json');
|
|
3341
|
+
for (const declaration of declarations) {
|
|
3342
|
+
if (!declaration?.executionBundle) continue;
|
|
3343
|
+
try {
|
|
3344
|
+
await verifyExecutionBundle({ planPath: anchor, postPublish: declaration });
|
|
3345
|
+
} catch (err) {
|
|
3346
|
+
candidateReprepare(
|
|
3347
|
+
`test candidate execution bundle changed: ${err.message}`,
|
|
3348
|
+
['executionBundle'],
|
|
3349
|
+
{ cause: err.code ?? 'unknown' },
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
function recheckReceipt(prepared) {
|
|
3356
|
+
if (!prepared?.publicSourceAuthorityReceipt) return;
|
|
3357
|
+
const completeness = validatePlanActionCompleteness({
|
|
3358
|
+
...prepared,
|
|
3359
|
+
status: prepared.status ?? 'PREPARED',
|
|
3360
|
+
});
|
|
3361
|
+
const failures = (completeness.details?.failures ?? []).filter((failure) => (
|
|
3362
|
+
String(failure).includes('publicSourceAuthorityReceipt')
|
|
3363
|
+
));
|
|
3364
|
+
if (failures.length > 0) {
|
|
3365
|
+
candidateReprepare(
|
|
3366
|
+
`test candidate publicSourceAuthorityReceipt is incomplete: ${failures.join('; ')}`,
|
|
3367
|
+
['receipt'],
|
|
3368
|
+
{ failures },
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
/**
|
|
3374
|
+
* Reattach one saved test candidate. Does not rebuild and does not write a
|
|
3375
|
+
* second candidate. Frozen git checks run only when the saved unit still
|
|
3376
|
+
* carries a frozenSnapshot.
|
|
3377
|
+
*/
|
|
3378
|
+
async function reattachTestCandidate({
|
|
3379
|
+
realRoot,
|
|
3380
|
+
runDir,
|
|
3381
|
+
releaseDir,
|
|
3382
|
+
evidence,
|
|
3383
|
+
evidenceDir,
|
|
3384
|
+
config,
|
|
3385
|
+
configDigest,
|
|
3386
|
+
production,
|
|
3387
|
+
workflow,
|
|
3388
|
+
offline,
|
|
3389
|
+
version,
|
|
3390
|
+
options,
|
|
3391
|
+
clock,
|
|
3392
|
+
runWarnings,
|
|
3393
|
+
adoptedCandidate,
|
|
3394
|
+
output,
|
|
3395
|
+
}) {
|
|
3396
|
+
const savedOptions = adoptedCandidate.candidate.prepareOptions;
|
|
3397
|
+
const workflowExplicit = options?.workflowExplicit === true;
|
|
3398
|
+
const offlineExplicit = options?.offlineExplicit === true;
|
|
3399
|
+
const versionExplicit = options?.versionExplicit === true;
|
|
3400
|
+
const resolvedWorkflow = workflowExplicit ? workflow : savedOptions.workflow;
|
|
3401
|
+
const resolvedOffline = offlineExplicit ? offline : savedOptions.offline;
|
|
3402
|
+
const resolvedUnitIds = Array.isArray(options?.unitIds)
|
|
3403
|
+
? options.unitIds
|
|
3404
|
+
: savedOptions.unitIds;
|
|
3405
|
+
const extraDrifts = [];
|
|
3406
|
+
if (versionExplicit) {
|
|
3407
|
+
for (const unitId of savedOptions.unitIds) {
|
|
3408
|
+
if (savedOptions.unitVersions[unitId] !== version) extraDrifts.push(`targetVersion.${unitId}`);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
for (const unitId of savedOptions.unitIds) {
|
|
3412
|
+
const unit = (config.releaseUnits ?? []).find((item) => item.id === unitId);
|
|
3413
|
+
if (!unit) {
|
|
3414
|
+
extraDrifts.push(`unit.${unitId}`);
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
try {
|
|
3418
|
+
const onDisk = await resolveUnitVersion(unit, realRoot, undefined);
|
|
3419
|
+
if (onDisk !== savedOptions.unitVersions[unitId]) extraDrifts.push(`unitVersions.${unitId}`);
|
|
3420
|
+
} catch {
|
|
3421
|
+
extraDrifts.push(`unitVersions.${unitId}`);
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
const requirements = await readTestRequirements({
|
|
3425
|
+
root: realRoot,
|
|
3426
|
+
config,
|
|
3427
|
+
selectedUnitIds: savedOptions.unitIds,
|
|
3428
|
+
});
|
|
3429
|
+
const evidenceCandidateSha256 = await readCandidateEvidenceSha256(
|
|
3430
|
+
realRoot,
|
|
3431
|
+
options.candidatePath,
|
|
3432
|
+
);
|
|
3433
|
+
const decision = await decideTestCandidateHandoff({
|
|
3434
|
+
root: realRoot,
|
|
3435
|
+
config,
|
|
3436
|
+
production,
|
|
3437
|
+
candidatePath: options.candidatePath,
|
|
3438
|
+
requirements,
|
|
3439
|
+
prepareOptions: {
|
|
3440
|
+
workflow: resolvedWorkflow,
|
|
3441
|
+
offline: resolvedOffline,
|
|
3442
|
+
production: true,
|
|
3443
|
+
unitIds: resolvedUnitIds,
|
|
3444
|
+
unitVersions: savedOptions.unitVersions,
|
|
3445
|
+
},
|
|
3446
|
+
evidenceCandidateSha256,
|
|
3447
|
+
expectedCandidateSha256: options.expectedCandidateSha256,
|
|
3448
|
+
expectedSubjectDigest: options.expectedSubjectDigest,
|
|
3449
|
+
configDigest,
|
|
3450
|
+
extraDrifts,
|
|
3451
|
+
});
|
|
3452
|
+
const savedPrepared = adoptedCandidate.candidate.prepared;
|
|
3453
|
+
await recheckCandidateClosure(realRoot, savedPrepared, config, savedOptions.unitIds);
|
|
3454
|
+
await recheckExecutionBundle(realRoot, savedPrepared);
|
|
3455
|
+
recheckReceipt(savedPrepared);
|
|
3456
|
+
const now = clock ? clock() : new Date().toISOString();
|
|
3457
|
+
if (decision.kind === 'await-results') {
|
|
3458
|
+
await evidence.append({
|
|
3459
|
+
phase: 'test-candidate',
|
|
3460
|
+
status: 'awaiting',
|
|
3461
|
+
candidatePath: decision.candidatePath,
|
|
3462
|
+
candidateSha256: decision.candidateSha256,
|
|
3463
|
+
subjectDigest: decision.subjectDigest,
|
|
3464
|
+
});
|
|
3465
|
+
await evidence.finish({
|
|
3466
|
+
status: 'NEEDS_INPUT',
|
|
3467
|
+
candidatePath: decision.candidatePath,
|
|
3468
|
+
candidateSha256: decision.candidateSha256,
|
|
3469
|
+
subjectDigest: decision.subjectDigest,
|
|
3470
|
+
completedAt: now,
|
|
3471
|
+
});
|
|
3472
|
+
return {
|
|
3473
|
+
status: 'NEEDS_INPUT',
|
|
3474
|
+
code: 'GATE_FAILED',
|
|
3475
|
+
exitCode: 13,
|
|
3476
|
+
candidatePath: decision.candidatePath,
|
|
3477
|
+
candidateSha256: decision.candidateSha256,
|
|
3478
|
+
subjectDigest: decision.subjectDigest,
|
|
3479
|
+
evidenceDir,
|
|
3480
|
+
requirements: (decision.gaps ?? []).map((item) => item.requirementId).filter(Boolean),
|
|
3481
|
+
gaps: decision.gaps,
|
|
3482
|
+
message: 'testIntake results for this candidate are not accepted yet',
|
|
3483
|
+
nextSteps: [{
|
|
3484
|
+
code: 'TEST_INTAKE_NEEDS_INPUT',
|
|
3485
|
+
message: 'Bind the result file to this subjectDigest and re-run prepare with the same candidate.',
|
|
3486
|
+
}],
|
|
3487
|
+
warnings: runWarnings,
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
if (decision.kind === 'already-accepted') {
|
|
3491
|
+
await evidence.finish({
|
|
3492
|
+
status: decision.status ?? 'PREPARED',
|
|
3493
|
+
planDigest: decision.planDigest,
|
|
3494
|
+
completedAt: now,
|
|
3495
|
+
});
|
|
3496
|
+
return {
|
|
3497
|
+
status: decision.status,
|
|
3498
|
+
planPath: resolve(realRoot, decision.planPath),
|
|
3499
|
+
planDigest: decision.planDigest,
|
|
3500
|
+
evidenceDir,
|
|
3501
|
+
warnings: runWarnings,
|
|
3502
|
+
nextSteps: [],
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
if (decision.prepared?.sourceAuthority?.entries) {
|
|
3506
|
+
try {
|
|
3507
|
+
const dirty = await checkSourceInputDirty({
|
|
3508
|
+
closure: { entries: decision.prepared.sourceAuthority.entries },
|
|
3509
|
+
root: realRoot,
|
|
3510
|
+
});
|
|
3511
|
+
if (dirty.dirty) {
|
|
3512
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate source inputs changed', {
|
|
3513
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3514
|
+
drifts: dirty.dirtyPaths.map((filePath) => `source:${filePath}`),
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
} catch (err) {
|
|
3518
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3519
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate source inputs could not be rechecked', {
|
|
3520
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3521
|
+
drifts: ['sourceAuthority'],
|
|
3522
|
+
cause: err.code ?? 'unknown',
|
|
3523
|
+
});
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
for (const unit of decision.prepared.units ?? []) {
|
|
3527
|
+
const frozen = unit.frozenSnapshot;
|
|
3528
|
+
if (!frozen) continue;
|
|
3529
|
+
try {
|
|
3530
|
+
// snapshotDigest is the pre-seal content hash. sealFrozenSnapshot
|
|
3531
|
+
// changes file mode, and that mode is part of the sealed digest stored
|
|
3532
|
+
// on frozenSnapshot.manifestDigest — the same field publish rechecks.
|
|
3533
|
+
if (frozen.path || frozen.manifestDigest) {
|
|
3534
|
+
await verifyFrozenSnapshot({
|
|
3535
|
+
root: realRoot,
|
|
3536
|
+
snapshotPath: frozen.path,
|
|
3537
|
+
expectedDigest: frozen.manifestDigest,
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
if (frozen.gitObjectDir) {
|
|
3541
|
+
await verifyFrozenGitRepository({
|
|
3542
|
+
root: realRoot,
|
|
3543
|
+
gitObjectDir: frozen.gitObjectDir,
|
|
3544
|
+
commit: frozen.commit,
|
|
3545
|
+
tree: frozen.tree,
|
|
3546
|
+
parentCommit: frozen.parentCommit,
|
|
3547
|
+
commitTimestamp: frozen.commitTimestamp,
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
const npmDistribution = (unit.distributions ?? []).find((dist) => dist.type === 'npm');
|
|
3551
|
+
if (frozen.npm?.tarballPath && npmDistribution) {
|
|
3552
|
+
await verifyFrozenNpmTarballContract({
|
|
3553
|
+
package: npmDistribution.package,
|
|
3554
|
+
version: unit.targetVersion,
|
|
3555
|
+
tarballPath: frozen.npm.tarballPath,
|
|
3556
|
+
tarballSha256: frozen.npm.tarballSha256,
|
|
3557
|
+
integrity: frozen.npm.integrity,
|
|
3558
|
+
}, realRoot);
|
|
3559
|
+
}
|
|
3560
|
+
} catch (err) {
|
|
3561
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3562
|
+
throw new ReleaseError(GATE_FAILED, `test candidate frozen object changed for unit "${unit.id}"`, {
|
|
3563
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3564
|
+
drifts: [`frozen:${unit.id}`],
|
|
3565
|
+
cause: err.code ?? 'unknown',
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
for (const action of decision.prepared.externalActions ?? []) {
|
|
3570
|
+
let assets = [];
|
|
3571
|
+
if (action.parameters?.releaseAssets !== undefined) {
|
|
3572
|
+
try {
|
|
3573
|
+
assets = normalizeReleaseAssets(action.parameters.releaseAssets);
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
throw new ReleaseError(GATE_FAILED, `test candidate release assets could not be rechecked for "${action.id}"`, {
|
|
3576
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3577
|
+
drifts: [`releaseAsset:${action.id}`],
|
|
3578
|
+
cause: err.code ?? 'unknown',
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
for (const releaseAsset of assets) {
|
|
3583
|
+
try {
|
|
3584
|
+
await verifyFrozenFile({
|
|
3585
|
+
root: realRoot,
|
|
3586
|
+
filePath: releaseAsset.path,
|
|
3587
|
+
expectedSha256: releaseAsset.sha256,
|
|
3588
|
+
label: `frozen GitHub Release asset "${releaseAsset.name}"`,
|
|
3589
|
+
});
|
|
3590
|
+
} catch (err) {
|
|
3591
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3592
|
+
throw new ReleaseError(GATE_FAILED, `test candidate release asset changed for "${releaseAsset.name}"`, {
|
|
3593
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3594
|
+
drifts: [`releaseAsset:${releaseAsset.name}`],
|
|
3595
|
+
cause: err.code ?? 'unknown',
|
|
3596
|
+
});
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
if (savedOptions.production === true && decision.prepared?.production?.mode !== 'github-npm-v1') {
|
|
3601
|
+
candidateReprepare('test candidate prepared.production does not match production prepare', ['prepared.production']);
|
|
3602
|
+
}
|
|
3603
|
+
const plan = {
|
|
3604
|
+
...decision.prepared,
|
|
3605
|
+
status: 'PREPARED',
|
|
3606
|
+
testAcceptance: decision.testAcceptance,
|
|
3607
|
+
createdAt: now,
|
|
3608
|
+
};
|
|
3609
|
+
try {
|
|
3610
|
+
validatePlan(plan);
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3613
|
+
candidateReprepare(`test candidate plan structure is incomplete: ${err.message}`, ['plan-structure']);
|
|
3614
|
+
}
|
|
3615
|
+
const completeness = validatePlanActionCompleteness(plan);
|
|
3616
|
+
if (!completeness.passed) {
|
|
3617
|
+
const failures = completeness.details?.failures ?? [];
|
|
3618
|
+
candidateReprepare(
|
|
3619
|
+
`test candidate action completeness failed: ${failures.join('; ')}`,
|
|
3620
|
+
['action-completeness'],
|
|
3621
|
+
{ failures },
|
|
3622
|
+
);
|
|
3623
|
+
}
|
|
3624
|
+
const latestPlanPath = output ?? resolve(releaseDir, 'release-plan.json');
|
|
3625
|
+
const plannedDigest = computePlanDigest(plan);
|
|
3626
|
+
const immutablePlanPath = resolve(dirname(latestPlanPath), 'plans', `${plannedDigest}.json`);
|
|
3627
|
+
const { planPath: writtenPath, planDigest } = await writePlanImmutable(immutablePlanPath, plan);
|
|
3628
|
+
await writePlanAtomic(latestPlanPath, plan);
|
|
3629
|
+
await writeFrozenMarker(releaseDir, {
|
|
3630
|
+
planDigest,
|
|
3631
|
+
targetVersions: savedOptions.unitVersions,
|
|
3632
|
+
createdAt: plan.createdAt,
|
|
3633
|
+
runId: basename(runDir),
|
|
3634
|
+
});
|
|
3635
|
+
await evidence.append({
|
|
3636
|
+
phase: 'plan-write',
|
|
3637
|
+
status: 'completed',
|
|
3638
|
+
planPath: writtenPath,
|
|
3639
|
+
planDigest,
|
|
3640
|
+
});
|
|
3641
|
+
await evidence.finish({
|
|
3642
|
+
status: 'PREPARED',
|
|
3643
|
+
planPath: writtenPath,
|
|
3644
|
+
planDigest,
|
|
3645
|
+
configDigest,
|
|
3646
|
+
offline,
|
|
3647
|
+
workflowKind: resolvedWorkflow,
|
|
3648
|
+
completedAt: now,
|
|
3649
|
+
});
|
|
3650
|
+
return {
|
|
3651
|
+
planPath: writtenPath,
|
|
3652
|
+
planDigest,
|
|
3653
|
+
evidenceDir,
|
|
3654
|
+
warnings: runWarnings,
|
|
3655
|
+
nextSteps: [],
|
|
3656
|
+
status: 'PREPARED',
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3291
3660
|
export async function prepareRelease(options) {
|
|
3292
3661
|
const {
|
|
3293
3662
|
root,
|
|
@@ -3411,7 +3780,20 @@ export async function prepareRelease(options) {
|
|
|
3411
3780
|
await evidence.append({ phase: 'config', status: 'started' });
|
|
3412
3781
|
|
|
3413
3782
|
const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
|
|
3414
|
-
|
|
3783
|
+
assertTestIntakeDeclaration({ config, production });
|
|
3784
|
+
if (options?.candidatePath && !config.testIntake) {
|
|
3785
|
+
throw new ReleaseError(CONFIG_INVALID, 'candidate path requires testIntake', {
|
|
3786
|
+
reason: 'TEST_CANDIDATE_WITHOUT_INTAKE',
|
|
3787
|
+
field: 'candidatePath',
|
|
3788
|
+
});
|
|
3789
|
+
}
|
|
3790
|
+
const adoptedCandidate = options?.candidatePath
|
|
3791
|
+
? await readTestCandidate({ root: realRoot, candidatePath: options.candidatePath })
|
|
3792
|
+
: null;
|
|
3793
|
+
const selectionUnitIds = adoptedCandidate && !Array.isArray(options?.unitIds)
|
|
3794
|
+
? adoptedCandidate.candidate.prepareOptions.unitIds
|
|
3795
|
+
: unitIds;
|
|
3796
|
+
const releaseSelection = resolveRequestedReleaseUnits(config, selectionUnitIds, {
|
|
3415
3797
|
root,
|
|
3416
3798
|
version,
|
|
3417
3799
|
offline,
|
|
@@ -3455,6 +3837,34 @@ export async function prepareRelease(options) {
|
|
|
3455
3837
|
});
|
|
3456
3838
|
}
|
|
3457
3839
|
|
|
3840
|
+
if (adoptedCandidate) {
|
|
3841
|
+
return await reattachTestCandidate({
|
|
3842
|
+
realRoot,
|
|
3843
|
+
runDir,
|
|
3844
|
+
releaseDir,
|
|
3845
|
+
evidence,
|
|
3846
|
+
evidenceDir,
|
|
3847
|
+
config,
|
|
3848
|
+
configDigest,
|
|
3849
|
+
production,
|
|
3850
|
+
workflow,
|
|
3851
|
+
offline,
|
|
3852
|
+
version,
|
|
3853
|
+
options,
|
|
3854
|
+
clock,
|
|
3855
|
+
runWarnings,
|
|
3856
|
+
adoptedCandidate,
|
|
3857
|
+
output,
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
if (config.testIntake) {
|
|
3861
|
+
await readTestRequirements({
|
|
3862
|
+
root: realRoot,
|
|
3863
|
+
config,
|
|
3864
|
+
selectedUnitIds: releaseSelection.selectedUnitIds,
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3458
3868
|
// --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
|
|
3459
3869
|
// 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
|
|
3460
3870
|
// used to surface only deep inside test hooks. Compare the deterministic
|
|
@@ -4371,7 +4781,38 @@ export async function prepareRelease(options) {
|
|
|
4371
4781
|
// approval, it cannot be disabled by project overlays. Workflows that
|
|
4372
4782
|
// trim declared hooks record the trim; projects declaring no test hook
|
|
4373
4783
|
// pass vacuously (nothing can run incrementally there).
|
|
4374
|
-
if (
|
|
4784
|
+
if (config.testIntake) {
|
|
4785
|
+
await evidence.append({
|
|
4786
|
+
phase: 'full-test-gate',
|
|
4787
|
+
status: 'replaced',
|
|
4788
|
+
reason: 'testIntake acceptance replaces the vacuous full-test pass; applicable requirements stay required',
|
|
4789
|
+
});
|
|
4790
|
+
if (!skipDeclaredHooks && config.hooks?.test) {
|
|
4791
|
+
const testRecord = hookRecords.find((record) => record.name === 'test');
|
|
4792
|
+
const satisfied = Boolean(
|
|
4793
|
+
testRecord && testRecord.completed && testRecord.testSelection === 'full',
|
|
4794
|
+
);
|
|
4795
|
+
if (!satisfied) {
|
|
4796
|
+
await evidence.append({
|
|
4797
|
+
phase: 'full-test-gate',
|
|
4798
|
+
status: 'blocking',
|
|
4799
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
4800
|
+
cached: Boolean(testRecord?.cached),
|
|
4801
|
+
});
|
|
4802
|
+
throw new ReleaseError(
|
|
4803
|
+
GATE_FAILED,
|
|
4804
|
+
testRecord?.testSelection === 'incremental'
|
|
4805
|
+
? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
|
|
4806
|
+
: 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
|
|
4807
|
+
{
|
|
4808
|
+
gate: 'full-test-freeze',
|
|
4809
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
4810
|
+
cached: Boolean(testRecord?.cached),
|
|
4811
|
+
},
|
|
4812
|
+
);
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
} else if (skipDeclaredHooks) {
|
|
4375
4816
|
await evidence.append({
|
|
4376
4817
|
phase: 'full-test-gate',
|
|
4377
4818
|
status: 'skipped',
|
|
@@ -4977,6 +5418,61 @@ export async function prepareRelease(options) {
|
|
|
4977
5418
|
actionCount: externalActions.length,
|
|
4978
5419
|
});
|
|
4979
5420
|
|
|
5421
|
+
if (config.testIntake) {
|
|
5422
|
+
const requirementsNow = await readTestRequirements({
|
|
5423
|
+
root: realRoot,
|
|
5424
|
+
config,
|
|
5425
|
+
selectedUnitIds: releaseSelection.selectedUnitIds,
|
|
5426
|
+
});
|
|
5427
|
+
const saved = await writeTestCandidate({
|
|
5428
|
+
root: realRoot,
|
|
5429
|
+
runDir,
|
|
5430
|
+
prepared: projectPrepared(plan),
|
|
5431
|
+
requirements: requirementsNow,
|
|
5432
|
+
prepareOptions: {
|
|
5433
|
+
workflow,
|
|
5434
|
+
offline,
|
|
5435
|
+
production: true,
|
|
5436
|
+
unitIds: releaseSelection.selectedUnitIds,
|
|
5437
|
+
unitVersions: Object.fromEntries(
|
|
5438
|
+
configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
|
|
5439
|
+
),
|
|
5440
|
+
},
|
|
5441
|
+
});
|
|
5442
|
+
const subjectDigest = subjectDigestOf(projectPrepared(plan));
|
|
5443
|
+
await evidence.append({
|
|
5444
|
+
phase: 'test-candidate',
|
|
5445
|
+
status: 'saved',
|
|
5446
|
+
candidatePath: saved.candidatePath,
|
|
5447
|
+
candidateSha256: saved.sha256,
|
|
5448
|
+
subjectDigest,
|
|
5449
|
+
});
|
|
5450
|
+
await evidence.finish({
|
|
5451
|
+
status: 'NEEDS_INPUT',
|
|
5452
|
+
candidatePath: saved.candidatePath,
|
|
5453
|
+
candidateSha256: saved.sha256,
|
|
5454
|
+
subjectDigest,
|
|
5455
|
+
configDigest,
|
|
5456
|
+
completedAt: (clock ? clock() : new Date().toISOString()),
|
|
5457
|
+
});
|
|
5458
|
+
return {
|
|
5459
|
+
status: 'NEEDS_INPUT',
|
|
5460
|
+
code: 'GATE_FAILED',
|
|
5461
|
+
exitCode: 13,
|
|
5462
|
+
candidatePath: saved.candidatePath,
|
|
5463
|
+
candidateSha256: saved.sha256,
|
|
5464
|
+
subjectDigest,
|
|
5465
|
+
evidenceDir,
|
|
5466
|
+
requirements: requirementsNow.preFreeze.map((item) => item.id),
|
|
5467
|
+
message: 'testIntake candidate saved; results for this object are not accepted yet',
|
|
5468
|
+
nextSteps: [{
|
|
5469
|
+
code: 'TEST_INTAKE_NEEDS_INPUT',
|
|
5470
|
+
message: 'Call TEST on this subject, bind the result file to subjectDigest, then re-run prepare with the same candidate.',
|
|
5471
|
+
}],
|
|
5472
|
+
warnings: runWarnings,
|
|
5473
|
+
};
|
|
5474
|
+
}
|
|
5475
|
+
|
|
4980
5476
|
// --- Step 8: Validate and write plan atomically ---
|
|
4981
5477
|
await evidence.append({ phase: 'plan-write', status: 'started' });
|
|
4982
5478
|
|
package/src/commands/publish.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { PLATFORMS, normalizeHostId } from '../platforms/registry.mjs';
|
|
|
31
31
|
import { deriveSurfaceHostBinding, groupSurfaceHostBindings, pluginRootFromManifestRelativePath } from '../core/surface-host-bindings.mjs';
|
|
32
32
|
|
|
33
33
|
import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
|
|
34
|
+
import { verifyTestAcceptance } from '../core/test-intake.mjs';
|
|
34
35
|
import {
|
|
35
36
|
assertImmutableApprovalAuthority,
|
|
36
37
|
computeApprovalDigest,
|
|
@@ -497,6 +498,7 @@ export async function publishRelease(options) {
|
|
|
497
498
|
}
|
|
498
499
|
validatePlan(plan);
|
|
499
500
|
assertImmutablePlanAuthority(planPath, plan);
|
|
501
|
+
await verifyTestAcceptance({ root, plan });
|
|
500
502
|
const isProductionPlan = plan.production?.mode === 'github-npm-v1';
|
|
501
503
|
|
|
502
504
|
// --- Set up directories ---
|