release-skill 0.9.32 → 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 +54 -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 +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/claude/skills/release-config/SKILL.md +6 -0
- package/adapters/claude/skills/release-help/SKILL.md +20 -4
- package/adapters/claude/skills/release-prepare/SKILL.md +11 -2
- package/adapters/claude/skills/release-setup/SKILL.md +15 -2
- package/adapters/claude/skills/release-verify/SKILL.md +2 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/codex/skills/release-config/SKILL.md +6 -0
- package/adapters/codex/skills/release-help/SKILL.md +20 -4
- package/adapters/codex/skills/release-prepare/SKILL.md +11 -2
- package/adapters/codex/skills/release-setup/SKILL.md +15 -2
- package/adapters/codex/skills/release-verify/SKILL.md +2 -0
- package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
- package/adapters/cursor/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/cursor/skills/release-config/SKILL.md +6 -0
- package/adapters/cursor/skills/release-help/SKILL.md +20 -4
- package/adapters/cursor/skills/release-prepare/SKILL.md +11 -2
- package/adapters/cursor/skills/release-setup/SKILL.md +15 -2
- package/adapters/cursor/skills/release-verify/SKILL.md +2 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/kimi/skills/release-config/SKILL.md +6 -0
- package/adapters/kimi/skills/release-help/SKILL.md +20 -4
- package/adapters/kimi/skills/release-prepare/SKILL.md +11 -2
- package/adapters/kimi/skills/release-setup/SKILL.md +15 -2
- package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
- package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/qoder/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/qoder/skills/release-config/SKILL.md +6 -0
- package/adapters/qoder/skills/release-help/SKILL.md +20 -4
- package/adapters/qoder/skills/release-prepare/SKILL.md +11 -2
- package/adapters/qoder/skills/release-setup/SKILL.md +15 -2
- package/adapters/qoder/skills/release-verify/SKILL.md +2 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/adapters/workbuddy/skills/release-config/SKILL.md +6 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +20 -4
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +11 -2
- package/adapters/workbuddy/skills/release-setup/SKILL.md +15 -2
- package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
- package/bin/release-skill-cli.mjs +37 -2
- package/bin/release-skill.bundle.mjs +5855 -4045
- 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-assess/SKILL.md +11 -0
- package/skills/release-config/SKILL.md +6 -0
- package/skills/release-help/SKILL.md +20 -4
- package/skills/release-prepare/SKILL.md +11 -2
- package/skills/release-setup/SKILL.md +15 -2
- package/skills/release-verify/SKILL.md +2 -0
- package/skills-src/release-assess/SKILL.md +11 -0
- package/skills-src/release-config/SKILL.md +6 -0
- package/skills-src/release-help/SKILL.md +20 -4
- package/skills-src/release-prepare/SKILL.md +11 -2
- package/skills-src/release-setup/SKILL.md +15 -2
- package/skills-src/release-verify/SKILL.md +2 -0
- 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/setup.mjs +13 -6
- 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
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One test-intake candidate: exclusive save, strict read, and the no-git
|
|
3
|
+
* handoff decision shared by prepare and ship.
|
|
4
|
+
*
|
|
5
|
+
* Frozen git verification stays in prepare. This module does not run git.
|
|
6
|
+
*
|
|
7
|
+
* @module core/test-candidate
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { lstat, readdir, realpath } from 'node:fs/promises';
|
|
11
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import Ajv from 'ajv';
|
|
13
|
+
import addFormats from 'ajv-formats';
|
|
14
|
+
import {
|
|
15
|
+
publishFileExclusive,
|
|
16
|
+
readFileStrict,
|
|
17
|
+
HARNESS_ERROR_KINDS,
|
|
18
|
+
} from 'skill-family-harness-node';
|
|
19
|
+
import { readTrustedPackageResourceSync } from './trusted-resource.mjs';
|
|
20
|
+
import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from './errors.mjs';
|
|
21
|
+
import { computePlanDigest } from './plan.mjs';
|
|
22
|
+
import { acceptTestResults } from './test-intake.mjs';
|
|
23
|
+
|
|
24
|
+
const CANDIDATE_REL = /^\.release-skill\/runs\/[^/]+\/test-candidate\.json$/;
|
|
25
|
+
const TERMINAL_SHIP_STATUSES = new Set(['PARTIAL', 'PUBLISHED', 'VERIFIED']);
|
|
26
|
+
|
|
27
|
+
function writerVersion() {
|
|
28
|
+
// Adapter closures do not ship package.json; use the package identity
|
|
29
|
+
// injected by the bundle banner. Source mode still reads the trusted file.
|
|
30
|
+
if (typeof __bundlePkg !== 'undefined' && __bundlePkg
|
|
31
|
+
&& typeof __bundlePkg.version === 'string' && __bundlePkg.version.length > 0) {
|
|
32
|
+
return __bundlePkg.version;
|
|
33
|
+
}
|
|
34
|
+
const packageJson = JSON.parse(readTrustedPackageResourceSync('package.json').toString('utf8'));
|
|
35
|
+
return packageJson.version;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WRITER_VERSION = writerVersion();
|
|
39
|
+
|
|
40
|
+
const INTAKE_SCHEMA = JSON.parse(readTrustedPackageResourceSync(
|
|
41
|
+
'schemas/test-intake.schema.json',
|
|
42
|
+
).toString('utf8'));
|
|
43
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
44
|
+
addFormats(ajv);
|
|
45
|
+
ajv.addSchema(INTAKE_SCHEMA);
|
|
46
|
+
const validateCandidate = ajv.compile({ $ref: `${INTAKE_SCHEMA.$id}#/definitions/candidate` });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Final plan content without release qualification fields.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} plan
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
export function projectPrepared(plan) {
|
|
55
|
+
const {
|
|
56
|
+
status: _status,
|
|
57
|
+
digest: _digest,
|
|
58
|
+
testAcceptance: _testAcceptance,
|
|
59
|
+
...rest
|
|
60
|
+
} = plan;
|
|
61
|
+
return rest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function forbidPreparedKeys(prepared) {
|
|
65
|
+
for (const key of ['status', 'digest', 'testAcceptance']) {
|
|
66
|
+
if (prepared && Object.prototype.hasOwnProperty.call(prepared, key)) {
|
|
67
|
+
throw new ReleaseError(
|
|
68
|
+
CONFIG_INVALID,
|
|
69
|
+
`test candidate prepared must not include ${key}`,
|
|
70
|
+
{ field: 'prepared', reason: 'TEST_INTAKE_PATH' },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} root
|
|
78
|
+
* @param {string} candidatePath
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalCandidatePath(root, candidatePath) {
|
|
82
|
+
if (typeof root !== 'string' || root.length === 0 || typeof candidatePath !== 'string') {
|
|
83
|
+
throw new ReleaseError(CONFIG_INVALID, 'candidate path must be .release-skill/runs/<run>/test-candidate.json', {
|
|
84
|
+
reason: 'TEST_INTAKE_PATH',
|
|
85
|
+
field: 'candidatePath',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const absRoot = resolve(root);
|
|
89
|
+
const abs = isAbsolute(candidatePath) ? resolve(candidatePath) : resolve(absRoot, candidatePath);
|
|
90
|
+
const rel = relative(absRoot, abs).split(sep).join('/');
|
|
91
|
+
if (!CANDIDATE_REL.test(rel)) {
|
|
92
|
+
throw new ReleaseError(CONFIG_INVALID, 'candidate path must be .release-skill/runs/<run>/test-candidate.json', {
|
|
93
|
+
reason: 'TEST_INTAKE_PATH',
|
|
94
|
+
field: 'candidatePath',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return rel;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function schemaFailure(validator) {
|
|
101
|
+
const errors = validator.errors ?? [];
|
|
102
|
+
const summary = errors.map((entry) => `${entry.instancePath || '/'} ${entry.message}`).join('; ');
|
|
103
|
+
throw new ReleaseError(CONFIG_INVALID, `test candidate schema validation failed: ${summary}`, {
|
|
104
|
+
field: 'test-candidate',
|
|
105
|
+
validationErrors: errors.map((entry) => ({
|
|
106
|
+
instancePath: entry.instancePath,
|
|
107
|
+
message: entry.message,
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fileRefOf(requirements) {
|
|
113
|
+
if (requirements?.file?.path && requirements?.file?.sha256) {
|
|
114
|
+
return { path: requirements.file.path, sha256: requirements.file.sha256 };
|
|
115
|
+
}
|
|
116
|
+
if (requirements?.path && requirements?.sha256) {
|
|
117
|
+
return { path: requirements.path, sha256: requirements.sha256 };
|
|
118
|
+
}
|
|
119
|
+
throw new ReleaseError(CONFIG_INVALID, 'test candidate requirements file reference is missing', {
|
|
120
|
+
reason: 'TEST_INTAKE_PATH',
|
|
121
|
+
field: 'requirements',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function harnessKind(err) {
|
|
126
|
+
return err?.details?.kind ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {{ root: string, runDir: string, prepared: object, requirements: object, prepareOptions: object }} options
|
|
131
|
+
*/
|
|
132
|
+
export async function writeTestCandidate({ root, runDir, prepared, requirements, prepareOptions }) {
|
|
133
|
+
const preparedBody = projectPrepared(prepared);
|
|
134
|
+
forbidPreparedKeys(preparedBody);
|
|
135
|
+
const runRel = relative(resolve(root), resolve(runDir)).split(sep).join('/');
|
|
136
|
+
if (!runRel || runRel.startsWith('..') || runRel.split('/').includes('..')) {
|
|
137
|
+
throw new ReleaseError(CONFIG_INVALID, 'test candidate run directory escapes the project root', {
|
|
138
|
+
reason: 'TEST_INTAKE_PATH',
|
|
139
|
+
field: 'runDir',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const candidatePath = `${runRel}/test-candidate.json`;
|
|
143
|
+
if (!CANDIDATE_REL.test(candidatePath)) {
|
|
144
|
+
throw new ReleaseError(CONFIG_INVALID, 'test candidate path is not under .release-skill/runs/<run>/', {
|
|
145
|
+
reason: 'TEST_INTAKE_PATH',
|
|
146
|
+
field: 'candidatePath',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const candidate = {
|
|
150
|
+
candidateVersion: 1,
|
|
151
|
+
writerVersion: WRITER_VERSION,
|
|
152
|
+
prepareOptions,
|
|
153
|
+
requirements: fileRefOf(requirements),
|
|
154
|
+
prepared: preparedBody,
|
|
155
|
+
};
|
|
156
|
+
if (!validateCandidate(candidate)) schemaFailure(validateCandidate);
|
|
157
|
+
let receipt;
|
|
158
|
+
try {
|
|
159
|
+
receipt = await publishFileExclusive(
|
|
160
|
+
root,
|
|
161
|
+
candidatePath,
|
|
162
|
+
`${JSON.stringify(candidate, null, 2)}\n`,
|
|
163
|
+
{ mode: 0o600, createParents: false },
|
|
164
|
+
);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const kind = harnessKind(err);
|
|
167
|
+
if (kind === HARNESS_ERROR_KINDS.EXCLUSIVE_PUBLISH_CONFLICT) {
|
|
168
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate already exists and was not overwritten', {
|
|
169
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
170
|
+
field: 'candidatePath',
|
|
171
|
+
cause: kind,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate could not be saved', {
|
|
175
|
+
reason: kind ?? 'unknown',
|
|
176
|
+
field: 'candidatePath',
|
|
177
|
+
cause: kind ?? 'unknown',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return { candidate, sha256: receipt.sha256, candidatePath };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {{ root: string, candidatePath: string }} options
|
|
185
|
+
*/
|
|
186
|
+
export async function readTestCandidate({ root, candidatePath }) {
|
|
187
|
+
const rel = canonicalCandidatePath(root, candidatePath);
|
|
188
|
+
let receipt;
|
|
189
|
+
try {
|
|
190
|
+
receipt = await readFileStrict(root, rel, { encoding: 'utf8' });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const kind = harnessKind(err);
|
|
193
|
+
const reason = kind === HARNESS_ERROR_KINDS.MISSING_RESOURCE
|
|
194
|
+
? 'TEST_ACCEPTANCE_MISSING'
|
|
195
|
+
: (kind === HARNESS_ERROR_KINDS.CONTENT_GUARD_REJECTED || kind === HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY)
|
|
196
|
+
? 'TEST_ACCEPTANCE_TAMPERED'
|
|
197
|
+
: (kind ?? 'unknown');
|
|
198
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate could not be read', {
|
|
199
|
+
reason,
|
|
200
|
+
field: 'candidatePath',
|
|
201
|
+
cause: kind ?? 'unknown',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
let candidate;
|
|
205
|
+
try {
|
|
206
|
+
candidate = JSON.parse(receipt.content);
|
|
207
|
+
} catch {
|
|
208
|
+
throw new ReleaseError(CONFIG_INVALID, 'test candidate is not valid JSON', {
|
|
209
|
+
field: 'candidatePath',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (!validateCandidate(candidate)) schemaFailure(validateCandidate);
|
|
213
|
+
forbidPreparedKeys(candidate.prepared);
|
|
214
|
+
return { candidate, sha256: receipt.sha256, candidatePath: rel };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the first run's candidate digest from evidence.jsonl.
|
|
219
|
+
* Missing evidence returns null. This reader does not run git.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} root
|
|
222
|
+
* @param {string} candidatePath
|
|
223
|
+
* @returns {Promise<string|null>}
|
|
224
|
+
*/
|
|
225
|
+
export async function readCandidateEvidenceSha256(root, candidatePath) {
|
|
226
|
+
const rel = canonicalCandidatePath(root, candidatePath);
|
|
227
|
+
const evidenceRel = rel.replace(/\/test-candidate\.json$/, '/evidence.jsonl');
|
|
228
|
+
let receipt;
|
|
229
|
+
try {
|
|
230
|
+
receipt = await readFileStrict(root, evidenceRel, { encoding: 'utf8' });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (harnessKind(err) === HARNESS_ERROR_KINDS.MISSING_RESOURCE) return null;
|
|
233
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate evidence could not be read', {
|
|
234
|
+
reason: 'TEST_CANDIDATE_EVIDENCE_MISSING',
|
|
235
|
+
cause: harnessKind(err) ?? 'unknown',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
let sha = null;
|
|
239
|
+
for (const line of receipt.content.split('\n')) {
|
|
240
|
+
if (!line.trim()) continue;
|
|
241
|
+
let event;
|
|
242
|
+
try {
|
|
243
|
+
event = JSON.parse(line);
|
|
244
|
+
} catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (event?.phase === 'test-candidate' && event?.status === 'saved') {
|
|
248
|
+
const found = event.details?.candidateSha256;
|
|
249
|
+
if (typeof found === 'string' && found.length > 0) sha = found;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return sha;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sameStringList(left, right) {
|
|
256
|
+
const a = [...(left ?? [])].sort();
|
|
257
|
+
const b = [...(right ?? [])].sort();
|
|
258
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sameVersions(left, right) {
|
|
262
|
+
const leftKeys = Object.keys(left ?? {}).sort();
|
|
263
|
+
const rightKeys = Object.keys(right ?? {}).sort();
|
|
264
|
+
if (leftKeys.join('\0') !== rightKeys.join('\0')) return false;
|
|
265
|
+
return leftKeys.every((key) => left[key] === right[key]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function targetVersionsOf(plan) {
|
|
269
|
+
return [...new Set((Array.isArray(plan?.units) ? plan.units : [])
|
|
270
|
+
.map((unit) => unit?.targetVersion)
|
|
271
|
+
.filter((version) => typeof version === 'string' && version.length > 0))];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function sameTargetVersions(left, right) {
|
|
275
|
+
return left.length === right.length && left.every((version) => right.includes(version));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Formal plans under `.release-skill/plans`. The convenience pointer is not
|
|
280
|
+
* an acceptance basis. Does not run git.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} root
|
|
283
|
+
*/
|
|
284
|
+
async function readFormalPlans(root) {
|
|
285
|
+
const plansPath = resolve(root, '.release-skill', 'plans');
|
|
286
|
+
let names;
|
|
287
|
+
try {
|
|
288
|
+
names = await readdir(plansPath);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err?.code === 'ENOENT') {
|
|
291
|
+
try {
|
|
292
|
+
await lstat(plansPath);
|
|
293
|
+
} catch (statError) {
|
|
294
|
+
if (statError?.code === 'ENOENT') return [];
|
|
295
|
+
throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plans could not be read', {
|
|
296
|
+
reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
|
|
297
|
+
field: 'plansPath',
|
|
298
|
+
cause: statError?.code ?? 'unknown',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plans could not be read', {
|
|
303
|
+
reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
|
|
304
|
+
field: 'plansPath',
|
|
305
|
+
cause: err?.code ?? 'unknown',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const plans = [];
|
|
309
|
+
for (const name of names) {
|
|
310
|
+
if (!name.endsWith('.json')) continue;
|
|
311
|
+
const rel = `.release-skill/plans/${name}`;
|
|
312
|
+
let receipt;
|
|
313
|
+
try {
|
|
314
|
+
receipt = await readFileStrict(root, rel, { encoding: 'utf8' });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
const cause = harnessKind(err) ?? 'unknown';
|
|
317
|
+
throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plan could not be read', {
|
|
318
|
+
reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
|
|
319
|
+
field: 'planPath',
|
|
320
|
+
path: rel,
|
|
321
|
+
cause,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
let plan;
|
|
325
|
+
try {
|
|
326
|
+
plan = JSON.parse(receipt.content);
|
|
327
|
+
} catch {
|
|
328
|
+
throw new ReleaseError(CONFIG_INVALID, 'formal test acceptance plan is not valid JSON', {
|
|
329
|
+
reason: 'TEST_CANDIDATE_AUTHORITY_INVALID',
|
|
330
|
+
field: 'planPath',
|
|
331
|
+
path: rel,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
plans.push({
|
|
335
|
+
planPath: rel,
|
|
336
|
+
planDigest: typeof plan?.digest === 'string' ? plan.digest : name.slice(0, -'.json'.length),
|
|
337
|
+
status: plan?.status,
|
|
338
|
+
subjectDigest: typeof plan?.testAcceptance?.subjectDigest === 'string'
|
|
339
|
+
? plan.testAcceptance.subjectDigest
|
|
340
|
+
: null,
|
|
341
|
+
targetVersions: targetVersionsOf(plan),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return plans;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function sameAuthorityFile(left, right) {
|
|
348
|
+
const identity = async (value) => {
|
|
349
|
+
const absolute = resolve(value);
|
|
350
|
+
try {
|
|
351
|
+
return await realpath(absolute);
|
|
352
|
+
} catch {
|
|
353
|
+
return absolute;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
return (await identity(left)) === (await identity(right));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function authoritativeRunStatus(root, hit, targetVersions) {
|
|
360
|
+
const targetVersion = targetVersions.length === 1 ? targetVersions[0] : undefined;
|
|
361
|
+
let routing;
|
|
362
|
+
try {
|
|
363
|
+
const { readRunRouting } = await import('../commands/route.mjs');
|
|
364
|
+
routing = await readRunRouting(root, targetVersion ? { targetVersion } : {});
|
|
365
|
+
} catch {
|
|
366
|
+
return hit.status;
|
|
367
|
+
}
|
|
368
|
+
const formal = resolve(root, hit.planPath);
|
|
369
|
+
const bound = [];
|
|
370
|
+
for (const run of routing?.observedRuns ?? []) {
|
|
371
|
+
if (!run?.planPath || !run.status) continue;
|
|
372
|
+
if (await sameAuthorityFile(run.planPath, formal)) bound.push(run);
|
|
373
|
+
}
|
|
374
|
+
// A finished verify sits beside its PUBLISHED parent. The parent is lineage,
|
|
375
|
+
// not a second acceptance status. postverify is a later local finishing
|
|
376
|
+
// fact: a completed DISTRIBUTED leaf must not replace VERIFIED or ask for
|
|
377
|
+
// a new approval, and a failed or unfinished postverify must not be
|
|
378
|
+
// reported as DISTRIBUTED. It is not a ship terminal status.
|
|
379
|
+
const releaseRuns = bound.filter((run) => run.command !== 'postverify');
|
|
380
|
+
const leaves = [];
|
|
381
|
+
for (const run of releaseRuns) {
|
|
382
|
+
let ancestor = false;
|
|
383
|
+
for (const child of releaseRuns) {
|
|
384
|
+
if (child === run || !child.sourceRunPath || !run.runPath) continue;
|
|
385
|
+
if (await sameAuthorityFile(child.sourceRunPath, run.runPath)) {
|
|
386
|
+
ancestor = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!ancestor) leaves.push(run.status);
|
|
391
|
+
}
|
|
392
|
+
const statuses = [...new Set(leaves)];
|
|
393
|
+
if (statuses.length > 1) {
|
|
394
|
+
throw new ReleaseError(GATE_FAILED, 'formal plan runs disagree on the authoritative status', {
|
|
395
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
396
|
+
drifts: ['acceptance-basis'],
|
|
397
|
+
statuses,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return statuses.length === 1 ? statuses[0] : hit.status;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Plans already accepted for this subject. Does not run git.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} root
|
|
407
|
+
* @param {string} subjectDigest
|
|
408
|
+
* @returns {Promise<Array<{ planPath: string, planDigest: string, status: string }>>}
|
|
409
|
+
*/
|
|
410
|
+
export async function locateAcceptedPlans(root, subjectDigest) {
|
|
411
|
+
const plans = await readFormalPlans(root);
|
|
412
|
+
return plans
|
|
413
|
+
.filter((plan) => plan.subjectDigest === subjectDigest)
|
|
414
|
+
.map(({ planPath, planDigest, status }) => ({ planPath, planDigest, status }));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Pure handoff over already loaded documents. Does not run git and does not
|
|
419
|
+
* allocate a second candidate on reattach.
|
|
420
|
+
*/
|
|
421
|
+
export async function decideTestCandidateHandoff({
|
|
422
|
+
root,
|
|
423
|
+
runDir,
|
|
424
|
+
config,
|
|
425
|
+
production,
|
|
426
|
+
candidatePath,
|
|
427
|
+
prepared,
|
|
428
|
+
requirements,
|
|
429
|
+
prepareOptions,
|
|
430
|
+
evidenceCandidateSha256,
|
|
431
|
+
expectedCandidateSha256,
|
|
432
|
+
expectedSubjectDigest,
|
|
433
|
+
configDigest,
|
|
434
|
+
extraDrifts = [],
|
|
435
|
+
}) {
|
|
436
|
+
if (!config?.testIntake) {
|
|
437
|
+
if (candidatePath) {
|
|
438
|
+
throw new ReleaseError(CONFIG_INVALID, 'candidate path requires testIntake', {
|
|
439
|
+
reason: 'TEST_CANDIDATE_WITHOUT_INTAKE',
|
|
440
|
+
field: 'candidatePath',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return { kind: 'legacy', writePlan: true, rebuild: true };
|
|
444
|
+
}
|
|
445
|
+
if (production !== true) {
|
|
446
|
+
throw new ReleaseError(CONFIG_INVALID, 'testIntake is unsupported outside production prepare', {
|
|
447
|
+
reason: 'TEST_INTAKE_NON_PRODUCTION',
|
|
448
|
+
field: 'testIntake',
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
if (!candidatePath) {
|
|
452
|
+
const saved = await writeTestCandidate({ root, runDir, prepared, requirements, prepareOptions });
|
|
453
|
+
const subjectDigest = computePlanDigest(saved.candidate.prepared);
|
|
454
|
+
return {
|
|
455
|
+
kind: 'save-candidate',
|
|
456
|
+
status: 'NEEDS_INPUT',
|
|
457
|
+
writePlan: false,
|
|
458
|
+
rebuild: false,
|
|
459
|
+
code: 'GATE_FAILED',
|
|
460
|
+
candidatePath: saved.candidatePath,
|
|
461
|
+
candidateSha256: saved.sha256,
|
|
462
|
+
subjectDigest,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const loaded = await readTestCandidate({ root, candidatePath });
|
|
467
|
+
if (expectedCandidateSha256 && expectedCandidateSha256 !== loaded.sha256) {
|
|
468
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate digest does not match the ship record', {
|
|
469
|
+
reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
|
|
470
|
+
field: 'candidatePath',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (!evidenceCandidateSha256) {
|
|
474
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate evidence digest is missing', {
|
|
475
|
+
reason: 'TEST_CANDIDATE_EVIDENCE_MISSING',
|
|
476
|
+
field: 'candidatePath',
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (evidenceCandidateSha256 !== loaded.sha256) {
|
|
480
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate evidence digest does not match the candidate bytes', {
|
|
481
|
+
reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
|
|
482
|
+
field: 'candidatePath',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const options = loaded.candidate.prepareOptions;
|
|
487
|
+
const drifts = [...extraDrifts];
|
|
488
|
+
if (loaded.candidate.writerVersion !== WRITER_VERSION) drifts.push('writerVersion');
|
|
489
|
+
if (loaded.candidate.prepared?.configDigest !== configDigest) drifts.push('configDigest');
|
|
490
|
+
const requirementsRef = fileRefOf(requirements);
|
|
491
|
+
if (requirementsRef.sha256 !== loaded.candidate.requirements.sha256) drifts.push('requirements.sha256');
|
|
492
|
+
if (requirementsRef.path !== loaded.candidate.requirements.path) drifts.push('requirements.path');
|
|
493
|
+
if (prepareOptions.workflow !== options.workflow) drifts.push('prepareOptions.workflow');
|
|
494
|
+
if (prepareOptions.offline !== options.offline) drifts.push('prepareOptions.offline');
|
|
495
|
+
if (prepareOptions.production !== options.production) drifts.push('prepareOptions.production');
|
|
496
|
+
if (!sameStringList(prepareOptions.unitIds, options.unitIds)) drifts.push('prepareOptions.unitIds');
|
|
497
|
+
if (!sameVersions(prepareOptions.unitVersions, options.unitVersions)) drifts.push('prepareOptions.unitVersions');
|
|
498
|
+
if (drifts.length > 0) {
|
|
499
|
+
throw new ReleaseError(GATE_FAILED, `test candidate requires a new prepare: ${drifts.join(', ')}`, {
|
|
500
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
501
|
+
drifts,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const subjectDigest = computePlanDigest(loaded.candidate.prepared);
|
|
506
|
+
if (expectedSubjectDigest && expectedSubjectDigest !== subjectDigest) {
|
|
507
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate subjectDigest does not match the ship record', {
|
|
508
|
+
reason: 'TEST_CANDIDATE_DIGEST_MISMATCH',
|
|
509
|
+
field: 'subjectDigest',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const formalPlans = await readFormalPlans(root);
|
|
514
|
+
const targetVersions = targetVersionsOf(loaded.candidate.prepared);
|
|
515
|
+
const differentBasis = formalPlans.filter((plan) => (
|
|
516
|
+
sameTargetVersions(plan.targetVersions, targetVersions) && plan.subjectDigest !== subjectDigest
|
|
517
|
+
));
|
|
518
|
+
if (differentBasis.length > 0) {
|
|
519
|
+
throw new ReleaseError(GATE_FAILED, 'a different formal acceptance basis already exists for this target', {
|
|
520
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
521
|
+
drifts: ['acceptance-basis'],
|
|
522
|
+
planPaths: differentBasis.map((plan) => plan.planPath),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const hits = formalPlans.filter((plan) => plan.subjectDigest === subjectDigest);
|
|
526
|
+
if (hits.length > 1) {
|
|
527
|
+
throw new ReleaseError(GATE_FAILED, 'multiple formal plans match this test candidate subject', {
|
|
528
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
529
|
+
drifts: ['multiple-accepted-plans'],
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (hits.length === 1) {
|
|
533
|
+
return {
|
|
534
|
+
kind: 'already-accepted',
|
|
535
|
+
status: await authoritativeRunStatus(root, hits[0], targetVersions),
|
|
536
|
+
writePlan: false,
|
|
537
|
+
rebuild: false,
|
|
538
|
+
planPath: hits[0].planPath,
|
|
539
|
+
planDigest: hits[0].planDigest,
|
|
540
|
+
candidatePath: loaded.candidatePath,
|
|
541
|
+
candidateSha256: loaded.sha256,
|
|
542
|
+
subjectDigest,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const acceptance = await acceptTestResults({
|
|
547
|
+
root,
|
|
548
|
+
requirements,
|
|
549
|
+
prepared: loaded.candidate.prepared,
|
|
550
|
+
});
|
|
551
|
+
if (!acceptance.accepted) {
|
|
552
|
+
return {
|
|
553
|
+
kind: 'await-results',
|
|
554
|
+
status: 'NEEDS_INPUT',
|
|
555
|
+
writePlan: false,
|
|
556
|
+
rebuild: false,
|
|
557
|
+
code: 'GATE_FAILED',
|
|
558
|
+
candidatePath: loaded.candidatePath,
|
|
559
|
+
candidateSha256: loaded.sha256,
|
|
560
|
+
subjectDigest,
|
|
561
|
+
gaps: acceptance.gaps,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
kind: 'accept',
|
|
566
|
+
status: 'PREPARED',
|
|
567
|
+
writePlan: true,
|
|
568
|
+
rebuild: false,
|
|
569
|
+
testAcceptance: acceptance.testAcceptance,
|
|
570
|
+
prepared: loaded.candidate.prepared,
|
|
571
|
+
candidatePath: loaded.candidatePath,
|
|
572
|
+
candidateSha256: loaded.sha256,
|
|
573
|
+
subjectDigest,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Ship continuation for a persisted NEW record. Terminal external successes
|
|
579
|
+
* are never reattached.
|
|
580
|
+
*/
|
|
581
|
+
export function resolveShipCandidateContinuation({ state, loadedConfigDigest, requestedUnitIds }) {
|
|
582
|
+
if (TERMINAL_SHIP_STATUSES.has(state?.status)) {
|
|
583
|
+
return { kind: 'leave-external-success', rebuild: false };
|
|
584
|
+
}
|
|
585
|
+
if (state?.status !== 'NEW' || !state.testCandidate) {
|
|
586
|
+
return { kind: 'legacy-new', rebuild: true, refreshConfigDigest: true };
|
|
587
|
+
}
|
|
588
|
+
if (state.configDigest !== loadedConfigDigest) {
|
|
589
|
+
return { kind: 'conflict', rebuild: false, reason: 'TEST_CANDIDATE_CONFIG_CONFLICT' };
|
|
590
|
+
}
|
|
591
|
+
if (requestedUnitIds !== undefined) {
|
|
592
|
+
if (!sameStringList(requestedUnitIds, state.selectedUnitIds ?? [])) {
|
|
593
|
+
return { kind: 'conflict', rebuild: false, reason: 'TEST_CANDIDATE_UNIT_CONFLICT' };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
kind: 'reattach',
|
|
598
|
+
rebuild: false,
|
|
599
|
+
refreshConfigDigest: false,
|
|
600
|
+
candidatePath: state.testCandidate.path,
|
|
601
|
+
candidateSha256: state.testCandidate.sha256,
|
|
602
|
+
subjectDigest: state.testCandidate.subjectDigest,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Map a prepare return onto the persisted ship record.
|
|
608
|
+
* NEEDS_INPUT stays NEW and keeps the same candidate.
|
|
609
|
+
* PARTIAL, PUBLISHED, and VERIFIED keep that external status and the candidate.
|
|
610
|
+
*/
|
|
611
|
+
export function applyShipPrepareOutcome(state, prepared) {
|
|
612
|
+
if (prepared?.status === 'NEEDS_INPUT') {
|
|
613
|
+
const next = {
|
|
614
|
+
...state,
|
|
615
|
+
status: 'NEW',
|
|
616
|
+
testCandidate: prepared.testCandidate ?? state.testCandidate ?? {
|
|
617
|
+
path: prepared.candidatePath,
|
|
618
|
+
sha256: prepared.candidateSha256,
|
|
619
|
+
subjectDigest: prepared.subjectDigest,
|
|
620
|
+
evidenceDir: prepared.evidenceDir,
|
|
621
|
+
},
|
|
622
|
+
pendingTestRequirements: prepared.requirements ?? prepared.pendingTestRequirements ?? state.pendingTestRequirements,
|
|
623
|
+
};
|
|
624
|
+
delete next.planPath;
|
|
625
|
+
delete next.planDigest;
|
|
626
|
+
return next;
|
|
627
|
+
}
|
|
628
|
+
if (TERMINAL_SHIP_STATUSES.has(prepared?.status)) {
|
|
629
|
+
return {
|
|
630
|
+
...state,
|
|
631
|
+
status: prepared.status,
|
|
632
|
+
planPath: prepared.planPath,
|
|
633
|
+
planDigest: prepared.planDigest,
|
|
634
|
+
evidenceDir: prepared.evidenceDir ?? state.evidenceDir,
|
|
635
|
+
warnings: prepared.warnings ?? state.warnings,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const next = {
|
|
639
|
+
...state,
|
|
640
|
+
status: 'NEEDS_PLAN_APPROVAL',
|
|
641
|
+
planPath: prepared.planPath,
|
|
642
|
+
planDigest: prepared.planDigest,
|
|
643
|
+
evidenceDir: prepared.evidenceDir ?? state.evidenceDir,
|
|
644
|
+
warnings: prepared.warnings ?? state.warnings,
|
|
645
|
+
};
|
|
646
|
+
delete next.testCandidate;
|
|
647
|
+
delete next.pendingTestRequirements;
|
|
648
|
+
return next;
|
|
649
|
+
}
|