release-skill 0.1.1
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/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prepare command: freeze a release plan with snapshots and gates.
|
|
3
|
+
*
|
|
4
|
+
* Runs the full prepare pipeline in order:
|
|
5
|
+
* 1. Load and validate project configuration
|
|
6
|
+
* 2. Capture Git baseline (HEAD, tree hash, dirty files)
|
|
7
|
+
* 3. Run project-declared hooks (build, test)
|
|
8
|
+
* 4. For each release unit: build snapshot, scan for leakage, evaluate README
|
|
9
|
+
* 5. Check remote tag / version uniqueness (skipped in --offline mode)
|
|
10
|
+
* 6. Assemble and validate the release plan against the plan schema
|
|
11
|
+
* 7. Write the plan atomically
|
|
12
|
+
*
|
|
13
|
+
* If any gate fails, no PREPARED plan is written.
|
|
14
|
+
*
|
|
15
|
+
* @module commands/prepare
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { resolve, relative, isAbsolute, normalize, dirname } from 'node:path';
|
|
19
|
+
import { readFile, mkdir, realpath } from 'node:fs/promises';
|
|
20
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
|
|
23
|
+
const execFile = promisify(execFileCb);
|
|
24
|
+
|
|
25
|
+
import { loadProjectConfig } from '../core/config.mjs';
|
|
26
|
+
import { captureBaseline } from '../core/baseline.mjs';
|
|
27
|
+
import { runHook } from '../core/hooks.mjs';
|
|
28
|
+
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
29
|
+
import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
30
|
+
import { sha256Hex } from '../core/digest.mjs';
|
|
31
|
+
import { buildPublicStaging } from '../snapshot/public-map.mjs';
|
|
32
|
+
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
33
|
+
import { scanSnapshot } from '../snapshot/scan.mjs';
|
|
34
|
+
import { evaluateReadme } from '../readme/contract.mjs';
|
|
35
|
+
import {
|
|
36
|
+
buildFrozenGitRepository,
|
|
37
|
+
buildFrozenNpmTarball,
|
|
38
|
+
computeFrozenSnapshot,
|
|
39
|
+
sealFrozenSnapshot,
|
|
40
|
+
} from '../snapshot/frozen.mjs';
|
|
41
|
+
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
|
|
42
|
+
import { acquireProjectLock } from '../artifacts/project-lock.mjs';
|
|
43
|
+
import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
|
|
44
|
+
import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
|
|
45
|
+
import { createProductionPrepareRunDir } from '../core/run.mjs';
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Version resolution
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the target version for a release unit.
|
|
53
|
+
*
|
|
54
|
+
* Resolution rules:
|
|
55
|
+
* 1. If explicitVersion is provided, use it (overrides everything).
|
|
56
|
+
* 2. Otherwise, read from `<root>/<unit.source>/<unit.version.source>`.
|
|
57
|
+
* 3. Reject: absolute path, path escape, missing file, invalid JSON,
|
|
58
|
+
* missing/empty version field.
|
|
59
|
+
* 4. For v0.1: if multiple units resolve to different versions, fail closed.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} unit - The release unit configuration.
|
|
62
|
+
* @param {string} root - Absolute project root.
|
|
63
|
+
* @param {string} [explicitVersion] - Explicit version override.
|
|
64
|
+
* @returns {Promise<string>} The resolved version string.
|
|
65
|
+
* @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
|
|
66
|
+
*/
|
|
67
|
+
async function resolveUnitVersion(unit, root, explicitVersion) {
|
|
68
|
+
// Validate unit.version.source exists
|
|
69
|
+
const versionSource = unit.version?.source;
|
|
70
|
+
if (!versionSource || typeof versionSource !== 'string') {
|
|
71
|
+
throw new ReleaseError(
|
|
72
|
+
CONFIG_INVALID,
|
|
73
|
+
`unit "${unit.id}" missing version.source configuration`,
|
|
74
|
+
{ unitId: unit.id },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reject absolute paths
|
|
79
|
+
if (isAbsolute(versionSource)) {
|
|
80
|
+
throw new ReleaseError(
|
|
81
|
+
CONFIG_INVALID,
|
|
82
|
+
`unit "${unit.id}" version.source must be a relative path, got absolute: "${versionSource}"`,
|
|
83
|
+
{ unitId: unit.id, versionSource },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Resolve and normalize the path
|
|
88
|
+
const unitRoot = resolve(root, unit.source);
|
|
89
|
+
const resolvedPath = resolve(unitRoot, versionSource);
|
|
90
|
+
const normalizedPath = normalize(resolvedPath);
|
|
91
|
+
|
|
92
|
+
// Reject path escapes (must stay within unit root)
|
|
93
|
+
const rel = relative(unitRoot, normalizedPath);
|
|
94
|
+
if (rel.startsWith('..') || rel === '..' || isAbsolute(rel)) {
|
|
95
|
+
throw new ReleaseError(
|
|
96
|
+
CONFIG_INVALID,
|
|
97
|
+
`unit "${unit.id}" version.source escapes unit root: "${versionSource}"`,
|
|
98
|
+
{ unitId: unit.id, versionSource, resolved: normalizedPath },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Read the file
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = await readFile(normalizedPath, 'utf8');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new ReleaseError(
|
|
108
|
+
CONFIG_INVALID,
|
|
109
|
+
`unit "${unit.id}" cannot read version file "${versionSource}": ${err.message}`,
|
|
110
|
+
{ unitId: unit.id, versionSource, cause: err.code },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Parse JSON
|
|
115
|
+
let pkg;
|
|
116
|
+
try {
|
|
117
|
+
pkg = JSON.parse(content);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new ReleaseError(
|
|
120
|
+
CONFIG_INVALID,
|
|
121
|
+
`unit "${unit.id}" invalid JSON in "${versionSource}": ${err.message}`,
|
|
122
|
+
{ unitId: unit.id, versionSource },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Extract version field
|
|
127
|
+
const version = pkg?.version;
|
|
128
|
+
if (!version || typeof version !== 'string' || version.trim().length === 0) {
|
|
129
|
+
throw new ReleaseError(
|
|
130
|
+
CONFIG_INVALID,
|
|
131
|
+
`unit "${unit.id}" missing or empty version field in "${versionSource}"`,
|
|
132
|
+
{ unitId: unit.id, versionSource, found: version },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const authoritativeVersion = version.trim();
|
|
137
|
+
if (explicitVersion && explicitVersion !== authoritativeVersion) {
|
|
138
|
+
throw new ReleaseError(
|
|
139
|
+
GATE_FAILED,
|
|
140
|
+
`unit "${unit.id}" explicit version "${explicitVersion}" does not match authoritative version "${authoritativeVersion}" from "${versionSource}"`,
|
|
141
|
+
{ unitId: unit.id, explicitVersion, authoritativeVersion, versionSource },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return authoritativeVersion;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve versions for all release units independently.
|
|
150
|
+
*
|
|
151
|
+
* @param {object[]} units - Array of release unit configurations.
|
|
152
|
+
* @param {string} root - Absolute project root.
|
|
153
|
+
* @param {string} [explicitVersion] - Explicit version override.
|
|
154
|
+
* @param {object} evidence - The evidence writer.
|
|
155
|
+
* @returns {Promise<string[]>} Array of resolved versions (one per unit).
|
|
156
|
+
* @throws {ReleaseError} CONFIG_INVALID or GATE_FAILED on any validation failure.
|
|
157
|
+
*/
|
|
158
|
+
async function resolveAllUnitVersions(units, root, explicitVersion, evidence) {
|
|
159
|
+
await evidence.append({ phase: 'version-resolution', status: 'started' });
|
|
160
|
+
|
|
161
|
+
const resolvedVersions = [];
|
|
162
|
+
for (const unit of units) {
|
|
163
|
+
try {
|
|
164
|
+
const version = await resolveUnitVersion(unit, root, explicitVersion);
|
|
165
|
+
resolvedVersions.push(version);
|
|
166
|
+
|
|
167
|
+
} catch (err) {
|
|
168
|
+
await evidence.append({
|
|
169
|
+
phase: 'version-resolution',
|
|
170
|
+
status: 'failed',
|
|
171
|
+
unitId: unit.id,
|
|
172
|
+
error: { code: err.code, message: err.message },
|
|
173
|
+
});
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await evidence.append({
|
|
179
|
+
phase: 'version-resolution',
|
|
180
|
+
status: 'completed',
|
|
181
|
+
unitCount: units.length,
|
|
182
|
+
resolvedVersions: Object.fromEntries(units.map((unit, index) => [unit.id, resolvedVersions[index]])),
|
|
183
|
+
explicitVersion: !!explicitVersion,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return resolvedVersions;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Hooks execution
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run all declared project hooks in order: docs, build, test, typecheck.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} config - The loaded project config.
|
|
197
|
+
* @param {string} root - Absolute project root.
|
|
198
|
+
* @param {object} evidence - The evidence writer.
|
|
199
|
+
* @returns {Promise<void>}
|
|
200
|
+
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code.
|
|
201
|
+
*/
|
|
202
|
+
async function runDeclaredHooks(config, root, evidence) {
|
|
203
|
+
const hookOrder = ['docs', 'build', 'test', 'typecheck'];
|
|
204
|
+
const hooks = config.hooks ?? {};
|
|
205
|
+
|
|
206
|
+
for (const name of hookOrder) {
|
|
207
|
+
const hook = hooks[name];
|
|
208
|
+
if (!hook) continue;
|
|
209
|
+
|
|
210
|
+
await evidence.append({
|
|
211
|
+
phase: 'hooks',
|
|
212
|
+
status: 'started',
|
|
213
|
+
hookName: name,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
let result;
|
|
217
|
+
try {
|
|
218
|
+
result = await runHook(hook, { root });
|
|
219
|
+
} catch (err) {
|
|
220
|
+
await evidence.append({
|
|
221
|
+
phase: 'hooks',
|
|
222
|
+
status: 'failed',
|
|
223
|
+
hookName: name,
|
|
224
|
+
error: { code: err.code, message: err.message },
|
|
225
|
+
});
|
|
226
|
+
throw new ReleaseError(
|
|
227
|
+
GATE_FAILED,
|
|
228
|
+
`hook "${name}" failed: ${err.message}`,
|
|
229
|
+
{ hookName: name, cause: err.code },
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (result.exitCode !== 0) {
|
|
234
|
+
await evidence.append({
|
|
235
|
+
phase: 'hooks',
|
|
236
|
+
status: 'failed',
|
|
237
|
+
hookName: name,
|
|
238
|
+
exitCode: result.exitCode,
|
|
239
|
+
// Test runners usually emit the actionable failure summary at the
|
|
240
|
+
// end. Preserve bounded tails of both streams instead of the noisy
|
|
241
|
+
// compiler prelude at the beginning.
|
|
242
|
+
stdoutTail: result.stdout.slice(-4000),
|
|
243
|
+
stderrTail: result.stderr.slice(-4000),
|
|
244
|
+
});
|
|
245
|
+
throw new ReleaseError(
|
|
246
|
+
GATE_FAILED,
|
|
247
|
+
`hook "${name}" exited with code ${result.exitCode}`,
|
|
248
|
+
{ hookName: name, exitCode: result.exitCode },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await evidence.append({
|
|
253
|
+
phase: 'hooks',
|
|
254
|
+
status: 'completed',
|
|
255
|
+
hookName: name,
|
|
256
|
+
exitCode: 0,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Snapshot pipeline
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build snapshots for all release units, scan for leakage, and evaluate README.
|
|
267
|
+
*
|
|
268
|
+
* @param {object} config - The loaded project config.
|
|
269
|
+
* @param {string} root - Absolute project root.
|
|
270
|
+
* @param {object} evidence - The evidence writer.
|
|
271
|
+
* @param {string} runDir - The run directory for temp snapshot storage.
|
|
272
|
+
* @returns {Promise<{ unitResults: object[], snapshotDigests: string[] }>}
|
|
273
|
+
* @throws {ReleaseError} GATE_FAILED on any snapshot/scan/readme gate failure.
|
|
274
|
+
*/
|
|
275
|
+
async function processSnapshots(config, root, evidence, runDir, production = false) {
|
|
276
|
+
const units = config.releaseUnits ?? [];
|
|
277
|
+
const unitResults = [];
|
|
278
|
+
const snapshotDigests = [];
|
|
279
|
+
|
|
280
|
+
for (const unit of units) {
|
|
281
|
+
const outputDir = resolveUnitScopedPath(resolve(runDir, 'snapshots'), unit.id);
|
|
282
|
+
|
|
283
|
+
// --- Build snapshot ---
|
|
284
|
+
await evidence.append({
|
|
285
|
+
phase: 'snapshot',
|
|
286
|
+
status: 'started',
|
|
287
|
+
unitId: unit.id,
|
|
288
|
+
source: unit.source,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
let manifest;
|
|
292
|
+
try {
|
|
293
|
+
// All units use explicit public file mappings — no implicit
|
|
294
|
+
// git/package.json collection.
|
|
295
|
+
const publicManifest = await buildPublicStaging({
|
|
296
|
+
sourceRoot: root,
|
|
297
|
+
unit,
|
|
298
|
+
outputDir,
|
|
299
|
+
});
|
|
300
|
+
// Adapt the public manifest to the shape expected by downstream code.
|
|
301
|
+
manifest = {
|
|
302
|
+
entries: publicManifest.entries,
|
|
303
|
+
files: publicManifest.entries.map((e) => e.path).sort(),
|
|
304
|
+
totalSize: publicManifest.totalSize,
|
|
305
|
+
fileCount: publicManifest.fileCount,
|
|
306
|
+
contentHash: publicManifest.contentHash,
|
|
307
|
+
snapshotDigest: publicManifest.contentHash,
|
|
308
|
+
source: unit.source,
|
|
309
|
+
outputDir: publicManifest.outputDir,
|
|
310
|
+
};
|
|
311
|
+
} catch (err) {
|
|
312
|
+
await evidence.append({
|
|
313
|
+
phase: 'snapshot',
|
|
314
|
+
status: 'failed',
|
|
315
|
+
unitId: unit.id,
|
|
316
|
+
error: { code: err.code, message: err.message },
|
|
317
|
+
});
|
|
318
|
+
// Preserve original stable error codes (PUBLIC_FILE_MISSING,
|
|
319
|
+
// SNAPSHOT_FIDELITY_FAILED, etc.) — do not wrap into GATE_FAILED.
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
snapshotDigests.push(manifest.snapshotDigest);
|
|
324
|
+
|
|
325
|
+
await evidence.append({
|
|
326
|
+
phase: 'snapshot',
|
|
327
|
+
status: 'completed',
|
|
328
|
+
unitId: unit.id,
|
|
329
|
+
snapshotDigest: manifest.snapshotDigest,
|
|
330
|
+
fileCount: manifest.fileCount,
|
|
331
|
+
totalSize: manifest.totalSize,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// --- Scan for leakage ---
|
|
335
|
+
await evidence.append({
|
|
336
|
+
phase: 'scan',
|
|
337
|
+
status: 'started',
|
|
338
|
+
unitId: unit.id,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const findings = await scanSnapshot({
|
|
342
|
+
snapshotDir: outputDir,
|
|
343
|
+
policy: {
|
|
344
|
+
forbiddenPaths: config.policy?.forbiddenPaths ?? [],
|
|
345
|
+
forbiddenContentPatterns: config.policy?.forbiddenContentPatterns ?? [],
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Check for fatal findings (secrets, forbidden paths, forbidden content)
|
|
350
|
+
const FATAL_KINDS = new Set(['SECRET_DETECTED', 'PUBLIC_PATH_FORBIDDEN', 'FORBIDDEN_CONTENT_DETECTED']);
|
|
351
|
+
const fatalFindings = findings.filter((f) => FATAL_KINDS.has(f.kind));
|
|
352
|
+
|
|
353
|
+
if (fatalFindings.length > 0) {
|
|
354
|
+
await evidence.append({
|
|
355
|
+
phase: 'scan',
|
|
356
|
+
status: 'failed',
|
|
357
|
+
unitId: unit.id,
|
|
358
|
+
findings: fatalFindings.map((f) => ({
|
|
359
|
+
kind: f.kind,
|
|
360
|
+
file: f.file,
|
|
361
|
+
line: f.line,
|
|
362
|
+
message: f.message,
|
|
363
|
+
})),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Use the specific error code for the first finding kind
|
|
367
|
+
const primaryKind = fatalFindings[0].kind;
|
|
368
|
+
const errorCode = primaryKind === 'FORBIDDEN_CONTENT_DETECTED'
|
|
369
|
+
? FORBIDDEN_CONTENT_DETECTED
|
|
370
|
+
: GATE_FAILED;
|
|
371
|
+
throw new ReleaseError(
|
|
372
|
+
errorCode,
|
|
373
|
+
`leakage scan failed for unit "${unit.id}": ${fatalFindings.length} finding(s)`,
|
|
374
|
+
{ unitId: unit.id, findings: fatalFindings },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Non-fatal findings (stale build artifacts) are logged but allowed
|
|
379
|
+
const nonFatalFindings = findings.filter((f) => !FATAL_KINDS.has(f.kind));
|
|
380
|
+
|
|
381
|
+
await evidence.append({
|
|
382
|
+
phase: 'scan',
|
|
383
|
+
status: 'completed',
|
|
384
|
+
unitId: unit.id,
|
|
385
|
+
fatalCount: 0,
|
|
386
|
+
nonFatalCount: nonFatalFindings.length,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// --- Evaluate README ---
|
|
390
|
+
await evidence.append({
|
|
391
|
+
phase: 'readme',
|
|
392
|
+
status: 'started',
|
|
393
|
+
unitId: unit.id,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
let readmeReport;
|
|
397
|
+
try {
|
|
398
|
+
readmeReport = await evaluateReadme({
|
|
399
|
+
snapshotDir: outputDir,
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
await evidence.append({
|
|
403
|
+
phase: 'readme',
|
|
404
|
+
status: 'failed',
|
|
405
|
+
unitId: unit.id,
|
|
406
|
+
error: { code: err.code, message: err.message },
|
|
407
|
+
});
|
|
408
|
+
throw new ReleaseError(
|
|
409
|
+
GATE_FAILED,
|
|
410
|
+
`README evaluation failed for unit "${unit.id}": ${err.message}`,
|
|
411
|
+
{ unitId: unit.id, cause: err.code },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Check required README markers — blocking finding for production prepare (Item 23)
|
|
416
|
+
if (readmeReport.missing.length > 0) {
|
|
417
|
+
if (production) {
|
|
418
|
+
await evidence.append({
|
|
419
|
+
phase: 'readme',
|
|
420
|
+
status: 'blocking',
|
|
421
|
+
unitId: unit.id,
|
|
422
|
+
missingMarkers: readmeReport.missing,
|
|
423
|
+
});
|
|
424
|
+
throw new ReleaseError(
|
|
425
|
+
GATE_FAILED,
|
|
426
|
+
`README missing required markers for unit "${unit.id}": ${readmeReport.missing.join(', ')}`,
|
|
427
|
+
{ unitId: unit.id, missingMarkers: readmeReport.missing },
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
// Non-production: warn but don't block
|
|
431
|
+
await evidence.append({
|
|
432
|
+
phase: 'readme',
|
|
433
|
+
status: 'warning',
|
|
434
|
+
unitId: unit.id,
|
|
435
|
+
missingMarkers: readmeReport.missing,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Check readability (Item 23): installation, example, diagnosis — blocking for production
|
|
440
|
+
const rc = readmeReport.readabilityChecks;
|
|
441
|
+
const missingReadability = [];
|
|
442
|
+
if (rc && !rc.hasInstall) missingReadability.push('install command');
|
|
443
|
+
if (rc && !rc.hasMinimalExample) missingReadability.push('minimal example');
|
|
444
|
+
if (rc && !rc.hasFailureDiagnosis) missingReadability.push('failure diagnosis');
|
|
445
|
+
if (missingReadability.length > 0) {
|
|
446
|
+
if (production) {
|
|
447
|
+
await evidence.append({
|
|
448
|
+
phase: 'readme',
|
|
449
|
+
status: 'blocking',
|
|
450
|
+
unitId: unit.id,
|
|
451
|
+
missingReadability,
|
|
452
|
+
});
|
|
453
|
+
throw new ReleaseError(
|
|
454
|
+
GATE_FAILED,
|
|
455
|
+
`README missing readability requirements for unit "${unit.id}": ${missingReadability.join(', ')}`,
|
|
456
|
+
{ unitId: unit.id, missingReadability },
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
// Non-production: warn but don't block
|
|
460
|
+
await evidence.append({
|
|
461
|
+
phase: 'readme',
|
|
462
|
+
status: 'warning',
|
|
463
|
+
unitId: unit.id,
|
|
464
|
+
missingReadability,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await evidence.append({
|
|
469
|
+
phase: 'readme',
|
|
470
|
+
status: 'completed',
|
|
471
|
+
unitId: unit.id,
|
|
472
|
+
presentMarkers: readmeReport.present,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
unitResults.push({
|
|
476
|
+
unit,
|
|
477
|
+
manifest,
|
|
478
|
+
readmeReport,
|
|
479
|
+
nonFatalFindings,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { unitResults, snapshotDigests };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function buildProductionAssets(unitResults, resolvedVersions, root, runDir) {
|
|
487
|
+
for (const { unit } of unitResults) {
|
|
488
|
+
const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
|
|
489
|
+
if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
|
|
490
|
+
throw new ReleaseError(
|
|
491
|
+
GATE_FAILED,
|
|
492
|
+
`production npm distribution for unit "${unit.id}" requires explicit access: public or restricted`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const assets = [];
|
|
497
|
+
for (let index = 0; index < unitResults.length; index += 1) {
|
|
498
|
+
const { unit, manifest } = unitResults[index];
|
|
499
|
+
const version = resolvedVersions[index];
|
|
500
|
+
const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
|
|
501
|
+
const tag = tagTemplate.replace('{version}', version);
|
|
502
|
+
const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
|
|
503
|
+
const branch = branchTemplate
|
|
504
|
+
.replaceAll('{tag}', tag)
|
|
505
|
+
.replaceAll('{version}', version)
|
|
506
|
+
.replaceAll('{unit}', unit.id);
|
|
507
|
+
const snapshotPath = relative(root, manifest.outputDir);
|
|
508
|
+
const observed = await computeFrozenSnapshot(manifest.outputDir);
|
|
509
|
+
if (observed.digest !== manifest.snapshotDigest) {
|
|
510
|
+
throw new ReleaseError(
|
|
511
|
+
GATE_FAILED,
|
|
512
|
+
`snapshot digest changed before production asset freeze for unit "${unit.id}"`,
|
|
513
|
+
{ expected: manifest.snapshotDigest, observed: observed.digest },
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Freeze the byte/mode authority before deriving either distribution.
|
|
518
|
+
// Git and npm must consume the same immutable snapshot, never two reads of
|
|
519
|
+
// a writable staging directory separated by an attacker-controlled gap.
|
|
520
|
+
await sealFrozenSnapshot(manifest.outputDir);
|
|
521
|
+
const sealed = await computeFrozenSnapshot(manifest.outputDir);
|
|
522
|
+
|
|
523
|
+
const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
|
|
524
|
+
const git = await buildFrozenGitRepository({
|
|
525
|
+
snapshotDir: manifest.outputDir,
|
|
526
|
+
repositoryDir,
|
|
527
|
+
version,
|
|
528
|
+
expectedSnapshotDigest: sealed.digest,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
let npm = null;
|
|
532
|
+
const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
|
|
533
|
+
if (npmDistribution) {
|
|
534
|
+
npm = await buildFrozenNpmTarball({
|
|
535
|
+
snapshotDir: manifest.outputDir,
|
|
536
|
+
tarballDir: resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id),
|
|
537
|
+
expectedSnapshotDigest: sealed.digest,
|
|
538
|
+
});
|
|
539
|
+
await verifyFrozenNpmTarballIdentity({
|
|
540
|
+
package: npmDistribution.package,
|
|
541
|
+
version,
|
|
542
|
+
tarballPath: relative(root, npm.tarballPath),
|
|
543
|
+
tarballSha256: npm.sha256,
|
|
544
|
+
integrity: npm.integrity,
|
|
545
|
+
}, root);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
assets.push({
|
|
549
|
+
snapshotPath,
|
|
550
|
+
manifestDigest: sealed.digest,
|
|
551
|
+
gitObjectDir: relative(root, repositoryDir),
|
|
552
|
+
commit: git.commit,
|
|
553
|
+
tree: git.tree,
|
|
554
|
+
branch,
|
|
555
|
+
tag,
|
|
556
|
+
npm: npm ? {
|
|
557
|
+
tarballPath: relative(root, npm.tarballPath),
|
|
558
|
+
tarballSha256: npm.sha256,
|
|
559
|
+
integrity: npm.integrity,
|
|
560
|
+
size: npm.size,
|
|
561
|
+
} : null,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return assets;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// External actions generation
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Build the list of external actions that would be taken during publish.
|
|
573
|
+
*
|
|
574
|
+
* Each action is in PENDING status. Actions are generated per unit and
|
|
575
|
+
* include: push-snapshot, create-tag, npm-publish, github-release.
|
|
576
|
+
*
|
|
577
|
+
* @param {object[]} unitResults - Results from processSnapshots.
|
|
578
|
+
* @param {string} planVersion - The target version.
|
|
579
|
+
* @param {string} realRoot - The project root for relative path calculation.
|
|
580
|
+
* @returns {object[]} Array of external action descriptors.
|
|
581
|
+
*/
|
|
582
|
+
function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
583
|
+
const actions = [];
|
|
584
|
+
|
|
585
|
+
const marketplaceIdentity = (distribution) => ({
|
|
586
|
+
plugin: distribution.plugin,
|
|
587
|
+
marketplace: distribution.marketplace,
|
|
588
|
+
entrySkill: distribution.entrySkill,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (!productionAssets) {
|
|
592
|
+
for (let index = 0; index < unitResults.length; index += 1) {
|
|
593
|
+
const { unit } = unitResults[index];
|
|
594
|
+
const version = resolvedVersions[index];
|
|
595
|
+
const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
|
|
596
|
+
const tag = tagTemplate.replace('{version}', version);
|
|
597
|
+
actions.push({
|
|
598
|
+
id: `push-snapshot-${unit.id}`,
|
|
599
|
+
type: 'push-snapshot',
|
|
600
|
+
adapter: 'git-github',
|
|
601
|
+
unitId: unit.id,
|
|
602
|
+
parameters: { source: unit.source, publicRepo: unit.publicRepo, version, cwd: unit.source },
|
|
603
|
+
expected: { tag },
|
|
604
|
+
status: 'PENDING',
|
|
605
|
+
});
|
|
606
|
+
actions.push({
|
|
607
|
+
id: `create-tag-${unit.id}`,
|
|
608
|
+
type: 'create-tag',
|
|
609
|
+
adapter: 'git-github',
|
|
610
|
+
unitId: unit.id,
|
|
611
|
+
parameters: { tagTemplate, publicRepo: unit.publicRepo, version },
|
|
612
|
+
status: 'PENDING',
|
|
613
|
+
});
|
|
614
|
+
const npmDistribution = (unit.distributions ?? []).find((item) => item.type === 'npm');
|
|
615
|
+
if (npmDistribution) {
|
|
616
|
+
actions.push({
|
|
617
|
+
id: `npm-publish-${unit.id}`,
|
|
618
|
+
type: 'npm-publish',
|
|
619
|
+
adapter: 'npm',
|
|
620
|
+
unitId: unit.id,
|
|
621
|
+
parameters: {
|
|
622
|
+
package: npmDistribution.package,
|
|
623
|
+
version,
|
|
624
|
+
cwd: unit.source,
|
|
625
|
+
registry: npmDistribution.registry,
|
|
626
|
+
publisher: npmDistribution.publisher,
|
|
627
|
+
},
|
|
628
|
+
expected: {
|
|
629
|
+
package: npmDistribution.package,
|
|
630
|
+
version,
|
|
631
|
+
registry: npmDistribution.registry,
|
|
632
|
+
publisher: npmDistribution.publisher,
|
|
633
|
+
},
|
|
634
|
+
status: 'PENDING',
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
actions.push({
|
|
638
|
+
id: `github-release-${unit.id}`,
|
|
639
|
+
type: 'github-release',
|
|
640
|
+
adapter: 'github',
|
|
641
|
+
unitId: unit.id,
|
|
642
|
+
parameters: { publicRepo: unit.publicRepo, version },
|
|
643
|
+
status: 'PENDING',
|
|
644
|
+
});
|
|
645
|
+
// Consumer marketplace install actions (only when distribution declared)
|
|
646
|
+
const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
|
|
647
|
+
if (claudeDist) {
|
|
648
|
+
const identity = marketplaceIdentity(claudeDist);
|
|
649
|
+
actions.push({
|
|
650
|
+
id: `claude-marketplace-install-${unit.id}`,
|
|
651
|
+
type: 'claude-marketplace-install',
|
|
652
|
+
adapter: 'plugin-marketplace',
|
|
653
|
+
unitId: unit.id,
|
|
654
|
+
parameters: {
|
|
655
|
+
consumer: 'claude',
|
|
656
|
+
plugin: identity.plugin,
|
|
657
|
+
marketplace: identity.marketplace,
|
|
658
|
+
repo: unit.publicRepo,
|
|
659
|
+
version,
|
|
660
|
+
entrySkill: identity.entrySkill,
|
|
661
|
+
},
|
|
662
|
+
expected: {
|
|
663
|
+
installed: true,
|
|
664
|
+
plugin: identity.plugin,
|
|
665
|
+
marketplace: identity.marketplace,
|
|
666
|
+
version,
|
|
667
|
+
entrySkill: identity.entrySkill,
|
|
668
|
+
},
|
|
669
|
+
status: 'PENDING',
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
|
|
673
|
+
if (codexDist) {
|
|
674
|
+
const identity = marketplaceIdentity(codexDist);
|
|
675
|
+
actions.push({
|
|
676
|
+
id: `codex-marketplace-install-${unit.id}`,
|
|
677
|
+
type: 'codex-marketplace-install',
|
|
678
|
+
adapter: 'plugin-marketplace',
|
|
679
|
+
unitId: unit.id,
|
|
680
|
+
parameters: {
|
|
681
|
+
consumer: 'codex',
|
|
682
|
+
plugin: identity.plugin,
|
|
683
|
+
marketplace: identity.marketplace,
|
|
684
|
+
repo: unit.publicRepo,
|
|
685
|
+
version,
|
|
686
|
+
entrySkill: identity.entrySkill,
|
|
687
|
+
},
|
|
688
|
+
expected: {
|
|
689
|
+
installed: true,
|
|
690
|
+
plugin: identity.plugin,
|
|
691
|
+
marketplace: identity.marketplace,
|
|
692
|
+
version,
|
|
693
|
+
entrySkill: identity.entrySkill,
|
|
694
|
+
},
|
|
695
|
+
status: 'PENDING',
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return actions;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (let index = 0; index < unitResults.length; index += 1) {
|
|
703
|
+
const { unit } = unitResults[index];
|
|
704
|
+
const unitVersion = resolvedVersions[index];
|
|
705
|
+
const asset = productionAssets[index];
|
|
706
|
+
const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
|
|
707
|
+
const resolvedTag = asset.tag;
|
|
708
|
+
|
|
709
|
+
// Push snapshot
|
|
710
|
+
actions.push({
|
|
711
|
+
id: `push-snapshot-${unit.id}`,
|
|
712
|
+
type: 'push-snapshot',
|
|
713
|
+
adapter: 'git-github',
|
|
714
|
+
unitId: unit.id,
|
|
715
|
+
parameters: {
|
|
716
|
+
source: unit.source,
|
|
717
|
+
publicRepo: unit.publicRepo,
|
|
718
|
+
version: unitVersion,
|
|
719
|
+
cwd: unit.source,
|
|
720
|
+
snapshotPath: asset.snapshotPath,
|
|
721
|
+
manifestDigest: asset.manifestDigest,
|
|
722
|
+
gitObjectDir: asset.gitObjectDir,
|
|
723
|
+
branch: asset.branch,
|
|
724
|
+
repo: unit.publicRepo,
|
|
725
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
726
|
+
commit: asset.commit,
|
|
727
|
+
tree: asset.tree,
|
|
728
|
+
},
|
|
729
|
+
expected: {
|
|
730
|
+
branch: asset.branch,
|
|
731
|
+
commit: asset.commit,
|
|
732
|
+
tree: asset.tree,
|
|
733
|
+
manifestDigest: asset.manifestDigest,
|
|
734
|
+
},
|
|
735
|
+
status: 'PENDING',
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// Create tag
|
|
739
|
+
actions.push({
|
|
740
|
+
id: `create-tag-${unit.id}`,
|
|
741
|
+
type: 'create-tag',
|
|
742
|
+
adapter: 'git-github',
|
|
743
|
+
unitId: unit.id,
|
|
744
|
+
parameters: {
|
|
745
|
+
tagTemplate,
|
|
746
|
+
publicRepo: unit.publicRepo,
|
|
747
|
+
version: unitVersion,
|
|
748
|
+
tag: resolvedTag,
|
|
749
|
+
repo: unit.publicRepo,
|
|
750
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
751
|
+
gitObjectDir: asset.gitObjectDir,
|
|
752
|
+
commit: asset.commit,
|
|
753
|
+
},
|
|
754
|
+
expected: { tag: resolvedTag, commit: asset.commit },
|
|
755
|
+
status: 'PENDING',
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// npm publish (only for npm distributions)
|
|
759
|
+
const npmDist = (unit.distributions ?? []).find((d) => d.type === 'npm');
|
|
760
|
+
if (npmDist) {
|
|
761
|
+
actions.push({
|
|
762
|
+
id: `npm-publish-${unit.id}`,
|
|
763
|
+
type: 'npm-publish',
|
|
764
|
+
adapter: 'npm',
|
|
765
|
+
unitId: unit.id,
|
|
766
|
+
parameters: {
|
|
767
|
+
package: npmDist.package,
|
|
768
|
+
version: unitVersion,
|
|
769
|
+
cwd: unit.source,
|
|
770
|
+
tarballPath: asset.npm.tarballPath,
|
|
771
|
+
tarballSha256: asset.npm.tarballSha256,
|
|
772
|
+
integrity: asset.npm.integrity,
|
|
773
|
+
access: npmDist.access,
|
|
774
|
+
provenance: npmDist.provenance === true,
|
|
775
|
+
...(npmDist.tag ? { tag: npmDist.tag } : {}),
|
|
776
|
+
registry: npmDist.registry,
|
|
777
|
+
publisher: npmDist.publisher,
|
|
778
|
+
},
|
|
779
|
+
expected: {
|
|
780
|
+
package: npmDist.package,
|
|
781
|
+
version: unitVersion,
|
|
782
|
+
integrity: asset.npm.integrity,
|
|
783
|
+
registry: npmDist.registry,
|
|
784
|
+
publisher: npmDist.publisher,
|
|
785
|
+
},
|
|
786
|
+
status: 'PENDING',
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// GitHub release
|
|
791
|
+
actions.push({
|
|
792
|
+
id: `github-release-${unit.id}`,
|
|
793
|
+
type: 'github-release',
|
|
794
|
+
adapter: 'github',
|
|
795
|
+
unitId: unit.id,
|
|
796
|
+
parameters: {
|
|
797
|
+
publicRepo: unit.publicRepo,
|
|
798
|
+
version: unitVersion,
|
|
799
|
+
tag: resolvedTag,
|
|
800
|
+
repo: unit.publicRepo,
|
|
801
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
802
|
+
commit: asset.commit,
|
|
803
|
+
name: (unit.production?.releaseTitleTemplate ?? 'Release {tag}')
|
|
804
|
+
.replaceAll('{tag}', resolvedTag)
|
|
805
|
+
.replaceAll('{version}', unitVersion)
|
|
806
|
+
.replaceAll('{unit}', unit.id),
|
|
807
|
+
notes: unit.production?.releaseNotes ?? `Release ${resolvedTag}`,
|
|
808
|
+
},
|
|
809
|
+
expected: {
|
|
810
|
+
tag: resolvedTag,
|
|
811
|
+
commit: asset.commit,
|
|
812
|
+
},
|
|
813
|
+
status: 'PENDING',
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// Consumer marketplace install actions (only when distribution declared)
|
|
817
|
+
const claudeDist = (unit.distributions ?? []).find((d) => d.type === 'claude-plugin');
|
|
818
|
+
if (claudeDist) {
|
|
819
|
+
const identity = marketplaceIdentity(claudeDist);
|
|
820
|
+
actions.push({
|
|
821
|
+
id: `claude-marketplace-install-${unit.id}`,
|
|
822
|
+
type: 'claude-marketplace-install',
|
|
823
|
+
adapter: 'plugin-marketplace',
|
|
824
|
+
unitId: unit.id,
|
|
825
|
+
parameters: {
|
|
826
|
+
consumer: 'claude',
|
|
827
|
+
plugin: identity.plugin,
|
|
828
|
+
marketplace: identity.marketplace,
|
|
829
|
+
repo: unit.publicRepo,
|
|
830
|
+
ref: resolvedTag,
|
|
831
|
+
version: unitVersion,
|
|
832
|
+
entrySkill: identity.entrySkill,
|
|
833
|
+
snapshotPath: asset.snapshotPath,
|
|
834
|
+
manifestDigest: asset.manifestDigest,
|
|
835
|
+
},
|
|
836
|
+
expected: {
|
|
837
|
+
installed: true,
|
|
838
|
+
consumer: 'claude',
|
|
839
|
+
plugin: identity.plugin,
|
|
840
|
+
marketplace: identity.marketplace,
|
|
841
|
+
repo: unit.publicRepo,
|
|
842
|
+
version: unitVersion,
|
|
843
|
+
ref: resolvedTag,
|
|
844
|
+
entrySkill: identity.entrySkill,
|
|
845
|
+
entrySkillFound: true,
|
|
846
|
+
manifestDigest: asset.manifestDigest,
|
|
847
|
+
},
|
|
848
|
+
status: 'PENDING',
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
const codexDist = (unit.distributions ?? []).find((d) => d.type === 'codex-plugin');
|
|
852
|
+
if (codexDist) {
|
|
853
|
+
const identity = marketplaceIdentity(codexDist);
|
|
854
|
+
actions.push({
|
|
855
|
+
id: `codex-marketplace-install-${unit.id}`,
|
|
856
|
+
type: 'codex-marketplace-install',
|
|
857
|
+
adapter: 'plugin-marketplace',
|
|
858
|
+
unitId: unit.id,
|
|
859
|
+
parameters: {
|
|
860
|
+
consumer: 'codex',
|
|
861
|
+
plugin: identity.plugin,
|
|
862
|
+
marketplace: identity.marketplace,
|
|
863
|
+
repo: unit.publicRepo,
|
|
864
|
+
ref: resolvedTag,
|
|
865
|
+
version: unitVersion,
|
|
866
|
+
entrySkill: identity.entrySkill,
|
|
867
|
+
snapshotPath: asset.snapshotPath,
|
|
868
|
+
manifestDigest: asset.manifestDigest,
|
|
869
|
+
},
|
|
870
|
+
expected: {
|
|
871
|
+
installed: true,
|
|
872
|
+
consumer: 'codex',
|
|
873
|
+
plugin: identity.plugin,
|
|
874
|
+
marketplace: identity.marketplace,
|
|
875
|
+
repo: unit.publicRepo,
|
|
876
|
+
version: unitVersion,
|
|
877
|
+
ref: resolvedTag,
|
|
878
|
+
entrySkill: identity.entrySkill,
|
|
879
|
+
entrySkillFound: true,
|
|
880
|
+
manifestDigest: asset.manifestDigest,
|
|
881
|
+
},
|
|
882
|
+
status: 'PENDING',
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return actions;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
// Public API
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Run the full prepare pipeline and freeze a release plan.
|
|
896
|
+
*
|
|
897
|
+
* @param {Object} options
|
|
898
|
+
* @param {string} options.root - Absolute path to the project root.
|
|
899
|
+
* @param {string} [options.version] - Target version override. If not provided,
|
|
900
|
+
* each unit's version is read from its configured source.
|
|
901
|
+
* @param {boolean} [options.offline=true] - Skip remote checks when true.
|
|
902
|
+
* @param {string} [options.output] - Path to write the plan. Defaults to
|
|
903
|
+
* `<root>/.release-skill/release-plan.json`.
|
|
904
|
+
* @param {string} [options.runDir] - Directory for evidence. Defaults to
|
|
905
|
+
* `<root>/.release-skill/runs/prepare-<timestamp>`.
|
|
906
|
+
* @param {() => string} [options.clock] - Clock function for timestamps.
|
|
907
|
+
* @param {boolean} [options.hooksAuthorized] - Must be explicitly `true` when
|
|
908
|
+
* the project config declares hooks. Hooks are user-configured arbitrary
|
|
909
|
+
* local processes without filesystem/network isolation. Authorization
|
|
910
|
+
* means the user accepts hook side-effect risks, not that hooks are safe.
|
|
911
|
+
*
|
|
912
|
+
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
|
|
913
|
+
*
|
|
914
|
+
* @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
|
|
915
|
+
*/
|
|
916
|
+
export async function prepareRelease(options) {
|
|
917
|
+
const {
|
|
918
|
+
root,
|
|
919
|
+
version,
|
|
920
|
+
offline = true,
|
|
921
|
+
output,
|
|
922
|
+
runDir: runDirOpt,
|
|
923
|
+
clock,
|
|
924
|
+
hooksAuthorized,
|
|
925
|
+
production = false,
|
|
926
|
+
observePreviousPublicBaselineFn,
|
|
927
|
+
} = options ?? {};
|
|
928
|
+
|
|
929
|
+
// --- Validate root ---
|
|
930
|
+
if (!root || typeof root !== 'string') {
|
|
931
|
+
throw new ReleaseError(CONFIG_INVALID, 'root must be a non-empty string');
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Resolve root to real path (follows system symlinks like macOS /var → /private/var).
|
|
935
|
+
// This ensures outputDir paths use the real filesystem path, avoiding false
|
|
936
|
+
// positives in ancestor symlink checks.
|
|
937
|
+
let realRoot;
|
|
938
|
+
try {
|
|
939
|
+
realRoot = await realpath(root);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
throw new ReleaseError(
|
|
942
|
+
CONFIG_INVALID,
|
|
943
|
+
`cannot resolve root path: ${err.message}`,
|
|
944
|
+
{ root, cause: err.code },
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (production) {
|
|
949
|
+
const canonicalOutput = resolve(realRoot, '.release-skill', 'release-plan.json');
|
|
950
|
+
if (output && resolve(output) !== canonicalOutput) {
|
|
951
|
+
throw new ReleaseError(
|
|
952
|
+
GATE_FAILED,
|
|
953
|
+
'production prepare requires the canonical .release-skill/release-plan.json output; custom --output is supported only outside production',
|
|
954
|
+
{ output: resolve(output), expected: canonicalOutput },
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// --- Acquire project lock (shared domain with all mutating artifact commands) ---
|
|
960
|
+
const lock = await acquireProjectLock({ root: realRoot, command: 'prepare', mode: 'exclusive' });
|
|
961
|
+
|
|
962
|
+
// --- Set up directories ---
|
|
963
|
+
// Use realRoot for directory construction to avoid system symlink issues
|
|
964
|
+
// (e.g., macOS /var → /private/var) in outputDir ancestor checks.
|
|
965
|
+
const releaseDir = resolve(realRoot, '.release-skill');
|
|
966
|
+
const runId = `prepare-${Date.now()}`;
|
|
967
|
+
const rawRunDir = runDirOpt ?? resolve(releaseDir, 'runs', runId);
|
|
968
|
+
let runDir;
|
|
969
|
+
try {
|
|
970
|
+
if (production) {
|
|
971
|
+
runDir = await createProductionPrepareRunDir(rawRunDir, releaseDir);
|
|
972
|
+
} else {
|
|
973
|
+
await mkdir(rawRunDir, { recursive: true });
|
|
974
|
+
// Resolve after mkdir to canonicalize system aliases such as /var → /private/var.
|
|
975
|
+
runDir = await realpath(rawRunDir);
|
|
976
|
+
}
|
|
977
|
+
} catch (error) {
|
|
978
|
+
await lock.release();
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
const evidenceDir = runDir;
|
|
982
|
+
|
|
983
|
+
// --- Evidence writer ---
|
|
984
|
+
const evidence = createEvidenceWriter({ runDir, command: 'prepare', clock });
|
|
985
|
+
|
|
986
|
+
try {
|
|
987
|
+
// --- Step 1: Load and validate config ---
|
|
988
|
+
await evidence.append({ phase: 'config', status: 'started' });
|
|
989
|
+
|
|
990
|
+
const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
|
|
991
|
+
|
|
992
|
+
await evidence.append({
|
|
993
|
+
phase: 'config',
|
|
994
|
+
status: 'completed',
|
|
995
|
+
configPath: relative(realRoot, configPath),
|
|
996
|
+
configDigest,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// --- Step 2: Hook authorization gate ---
|
|
1000
|
+
// Hooks are user-configured arbitrary local processes without filesystem
|
|
1001
|
+
// or network isolation. They may write outside the project, access local
|
|
1002
|
+
// credentials, or make network calls. The user must explicitly accept
|
|
1003
|
+
// these risks before any hook is executed.
|
|
1004
|
+
const declaredHooks = Object.entries(config.hooks ?? {})
|
|
1005
|
+
.filter(([, hook]) => hook && hook.command)
|
|
1006
|
+
.map(([name, hook]) => ({
|
|
1007
|
+
name,
|
|
1008
|
+
executable: hook.command[0],
|
|
1009
|
+
args: hook.command.slice(1),
|
|
1010
|
+
cwd: hook.cwd ?? '.',
|
|
1011
|
+
}));
|
|
1012
|
+
|
|
1013
|
+
if (declaredHooks.length > 0) {
|
|
1014
|
+
await evidence.append({
|
|
1015
|
+
phase: 'hook-authorization',
|
|
1016
|
+
status: 'started',
|
|
1017
|
+
hookCount: declaredHooks.length,
|
|
1018
|
+
hooks: declaredHooks.map((h) => `${h.name}: ${h.executable} ${h.args.join(' ')}`),
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
if (hooksAuthorized !== true) {
|
|
1022
|
+
const hookList = declaredHooks
|
|
1023
|
+
.map((h) => ` - ${h.name}: executable="${h.executable}", args=[${h.args.join(', ')}], cwd="${h.cwd}"`)
|
|
1024
|
+
.join('\n');
|
|
1025
|
+
|
|
1026
|
+
await evidence.append({
|
|
1027
|
+
phase: 'hook-authorization',
|
|
1028
|
+
status: 'denied',
|
|
1029
|
+
reason: 'hooks not explicitly authorized',
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
throw new ReleaseError(
|
|
1033
|
+
GATE_FAILED,
|
|
1034
|
+
`project declares ${declaredHooks.length} hook(s) that will be executed as arbitrary local processes.\n` +
|
|
1035
|
+
`These hooks are NOT sandboxed — they may write to the filesystem outside the project, ` +
|
|
1036
|
+
`access local credentials, or make network calls.\n` +
|
|
1037
|
+
`The following hooks will run:\n${hookList}\n\n` +
|
|
1038
|
+
`To proceed, pass --acknowledge-hook-side-effects (CLI) or hooksAuthorized=true (API). ` +
|
|
1039
|
+
`Authorization means you accept hook side-effect risks; it does NOT make hooks safe.`,
|
|
1040
|
+
{ hookNames: declaredHooks.map((h) => h.name), hookCount: declaredHooks.length },
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
await evidence.append({
|
|
1045
|
+
phase: 'hook-authorization',
|
|
1046
|
+
status: 'authorized',
|
|
1047
|
+
hookCount: declaredHooks.length,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// --- Step 3: Run declared hooks ---
|
|
1052
|
+
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1053
|
+
await runDeclaredHooks(config, realRoot, evidence);
|
|
1054
|
+
await evidence.append({ phase: 'hooks', status: 'completed' });
|
|
1055
|
+
|
|
1056
|
+
// --- Step 4: Capture Git baseline (AFTER hooks, so workspaceDigest
|
|
1057
|
+
// reflects any file changes introduced by hooks) ---
|
|
1058
|
+
await evidence.append({ phase: 'baseline', status: 'started' });
|
|
1059
|
+
|
|
1060
|
+
const baseline = await captureBaseline(realRoot);
|
|
1061
|
+
|
|
1062
|
+
await evidence.append({
|
|
1063
|
+
phase: 'baseline',
|
|
1064
|
+
status: 'completed',
|
|
1065
|
+
gitTreeHash: baseline.gitTreeHash,
|
|
1066
|
+
headCommit: baseline.gitHead,
|
|
1067
|
+
dirtyFileCount: baseline.statusEntries.length,
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
// --- Step 4b: Per-unit previous public baseline observe ---
|
|
1071
|
+
const configUnits = config.releaseUnits ?? [];
|
|
1072
|
+
const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
|
|
1073
|
+
try {
|
|
1074
|
+
const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
|
|
1075
|
+
shell: false, encoding: "utf8", timeout: 30000,
|
|
1076
|
+
});
|
|
1077
|
+
const lines = stdout.trim().split("\n").filter(l => l.length > 0);
|
|
1078
|
+
if (lines.length === 0) return { status: "drifted", actual: null, diff: "ref not found on remote" };
|
|
1079
|
+
const [remoteCommit] = lines[0].split("\t");
|
|
1080
|
+
if (remoteCommit === expectedCommit) return { status: "consistent", actual: remoteCommit };
|
|
1081
|
+
return { status: "drifted", actual: remoteCommit, diff: "expected " + expectedCommit + ", got " + remoteCommit };
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
return { status: "unknown", error: err.message };
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
|
|
1087
|
+
const unitBaselineResults = new Map();
|
|
1088
|
+
for (const unit of configUnits) {
|
|
1089
|
+
const ppbConfig = unit.previousPublicBaseline;
|
|
1090
|
+
if (!ppbConfig) continue;
|
|
1091
|
+
const productionGithubHost = unit.production?.githubHost ?? 'github.com';
|
|
1092
|
+
const effectivePpbConfig = ppbConfig.mode === 'bound'
|
|
1093
|
+
? { ...ppbConfig, githubHost: productionGithubHost }
|
|
1094
|
+
: ppbConfig;
|
|
1095
|
+
assertPreviousPublicBaselineTarget({
|
|
1096
|
+
baseline: effectivePpbConfig,
|
|
1097
|
+
githubHost: productionGithubHost,
|
|
1098
|
+
publicRepo: unit.publicRepo,
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
if (ppbConfig.mode === "none") {
|
|
1102
|
+
unitBaselineResults.set(unit.id, {
|
|
1103
|
+
mode: "none",
|
|
1104
|
+
status: "consistent",
|
|
1105
|
+
});
|
|
1106
|
+
await evidence.append({
|
|
1107
|
+
phase: "previous-public-baseline",
|
|
1108
|
+
unitId: unit.id,
|
|
1109
|
+
status: "skipped",
|
|
1110
|
+
reason: "fresh repository",
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (offline) {
|
|
1116
|
+
// Production + bound + offline: fail closed before plan write
|
|
1117
|
+
if (production) {
|
|
1118
|
+
await evidence.append({
|
|
1119
|
+
phase: "previous-public-baseline",
|
|
1120
|
+
unitId: unit.id,
|
|
1121
|
+
status: "blocking",
|
|
1122
|
+
repo: ppbConfig.repo,
|
|
1123
|
+
ref: ppbConfig.ref,
|
|
1124
|
+
commit: ppbConfig.commit,
|
|
1125
|
+
reason: "production bound baseline requires --online observation",
|
|
1126
|
+
});
|
|
1127
|
+
throw new ReleaseError(
|
|
1128
|
+
GATE_FAILED,
|
|
1129
|
+
`unit "${unit.id}" has bound previousPublicBaseline but production prepare uses --offline. ` +
|
|
1130
|
+
`Must use --online to observe the previous public baseline before freezing a production plan.`,
|
|
1131
|
+
{ unitId: unit.id, repo: ppbConfig.repo, ref: ppbConfig.ref },
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
// Non-production offline: record unobserved-offline for local assessment
|
|
1135
|
+
unitBaselineResults.set(unit.id, {
|
|
1136
|
+
mode: "bound",
|
|
1137
|
+
githubHost: productionGithubHost,
|
|
1138
|
+
repo: ppbConfig.repo,
|
|
1139
|
+
ref: ppbConfig.ref,
|
|
1140
|
+
commit: ppbConfig.commit,
|
|
1141
|
+
status: "unobserved-offline",
|
|
1142
|
+
});
|
|
1143
|
+
await evidence.append({
|
|
1144
|
+
phase: "previous-public-baseline",
|
|
1145
|
+
unitId: unit.id,
|
|
1146
|
+
status: "unobserved-offline",
|
|
1147
|
+
reason: "offline mode",
|
|
1148
|
+
});
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Online bound: observe the remote ref
|
|
1153
|
+
await evidence.append({
|
|
1154
|
+
phase: "previous-public-baseline",
|
|
1155
|
+
unitId: unit.id,
|
|
1156
|
+
status: "started",
|
|
1157
|
+
repo: ppbConfig.repo,
|
|
1158
|
+
ref: ppbConfig.ref,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
let result;
|
|
1162
|
+
try {
|
|
1163
|
+
result = await observePreviousPublicBaseline({
|
|
1164
|
+
baseline: effectivePpbConfig,
|
|
1165
|
+
observeFn,
|
|
1166
|
+
evidence,
|
|
1167
|
+
});
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
// Observe failed (drifted or unknown): write blocking evidence with resolution options
|
|
1170
|
+
const observation = err?.details ?? {};
|
|
1171
|
+
const mappingDiff = observation.diff
|
|
1172
|
+
? { status: "available", summary: observation.diff }
|
|
1173
|
+
: {
|
|
1174
|
+
status: "unavailable",
|
|
1175
|
+
reason: observation.error
|
|
1176
|
+
? `remote mapping observation failed: ${observation.error}`
|
|
1177
|
+
: "remote ref-to-commit mapping could not be determined",
|
|
1178
|
+
};
|
|
1179
|
+
await evidence.append({
|
|
1180
|
+
phase: "previous-public-baseline",
|
|
1181
|
+
unitId: unit.id,
|
|
1182
|
+
status: "blocking",
|
|
1183
|
+
repo: ppbConfig.repo,
|
|
1184
|
+
ref: ppbConfig.ref,
|
|
1185
|
+
expected: ppbConfig.commit,
|
|
1186
|
+
expectedCommit: ppbConfig.commit,
|
|
1187
|
+
actual: observation.actual ?? null,
|
|
1188
|
+
diff: observation.diff ?? null,
|
|
1189
|
+
mappingDiff,
|
|
1190
|
+
contentDiff: {
|
|
1191
|
+
status: "unavailable",
|
|
1192
|
+
reason: "the default previous-baseline observer resolves only ref-to-commit mapping and does not fetch remote content",
|
|
1193
|
+
},
|
|
1194
|
+
error: { code: err.code, message: err.message },
|
|
1195
|
+
resolutionOptions: ["merge", "adopt", "reject"],
|
|
1196
|
+
guidance: "把已采用改动合并回 human-owned 权威源后重新 prepare",
|
|
1197
|
+
});
|
|
1198
|
+
throw err;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
unitBaselineResults.set(unit.id, {
|
|
1202
|
+
mode: "bound",
|
|
1203
|
+
githubHost: productionGithubHost,
|
|
1204
|
+
repo: ppbConfig.repo,
|
|
1205
|
+
ref: ppbConfig.ref,
|
|
1206
|
+
commit: ppbConfig.commit,
|
|
1207
|
+
observedCommit: result?.observed?.actual ?? ppbConfig.commit,
|
|
1208
|
+
observedAt: (clock ? clock() : new Date().toISOString()),
|
|
1209
|
+
status: "consistent",
|
|
1210
|
+
});
|
|
1211
|
+
await evidence.append({
|
|
1212
|
+
phase: "previous-public-baseline",
|
|
1213
|
+
unitId: unit.id,
|
|
1214
|
+
status: "completed",
|
|
1215
|
+
consistent: true,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// --- Step 5: Build snapshots, scan, and evaluate README ---
|
|
1220
|
+
const { unitResults, snapshotDigests } = await processSnapshots(
|
|
1221
|
+
config, realRoot, evidence, runDir, production,
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
// --- Step 6: Remote uniqueness (deferred to publish preflight) ---
|
|
1225
|
+
// Prepare only observes the previous public baseline (already done above).
|
|
1226
|
+
// Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
|
|
1227
|
+
// to the publish phase's global preflight, which runs before any execute.
|
|
1228
|
+
if (!offline) {
|
|
1229
|
+
await evidence.append({
|
|
1230
|
+
phase: 'remote-check',
|
|
1231
|
+
status: 'deferred',
|
|
1232
|
+
reason: 'remote uniqueness checks (tag, GitHub Release, npm version) deferred to publish global preflight',
|
|
1233
|
+
});
|
|
1234
|
+
} else if (production) {
|
|
1235
|
+
await evidence.append({
|
|
1236
|
+
phase: 'remote-check',
|
|
1237
|
+
status: 'deferred',
|
|
1238
|
+
reason: 'offline production prepare is allowed only for an explicit fresh baseline; target branch, tag, GitHub Release, and npm uniqueness are deferred to publish global preflight before any execute',
|
|
1239
|
+
});
|
|
1240
|
+
} else {
|
|
1241
|
+
await evidence.append({ phase: 'remote-check', status: 'skipped', reason: 'offline mode' });
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// --- Step 7: Build plan object ---
|
|
1245
|
+
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
1246
|
+
|
|
1247
|
+
// Resolve versions for all units
|
|
1248
|
+
const resolvedVersions = await resolveAllUnitVersions(
|
|
1249
|
+
config.releaseUnits ?? [],
|
|
1250
|
+
realRoot,
|
|
1251
|
+
version,
|
|
1252
|
+
evidence,
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
const productionAssets = production
|
|
1256
|
+
? await buildProductionAssets(unitResults, resolvedVersions, realRoot, runDir)
|
|
1257
|
+
: null;
|
|
1258
|
+
|
|
1259
|
+
const units = unitResults.map(({ unit, manifest }, idx) => {
|
|
1260
|
+
const unitVersion = resolvedVersions[idx];
|
|
1261
|
+
const unitBaseline = unitBaselineResults.get(unit.id);
|
|
1262
|
+
return {
|
|
1263
|
+
id: unit.id,
|
|
1264
|
+
targetVersion: unitVersion,
|
|
1265
|
+
source: unit.source,
|
|
1266
|
+
publicRepo: unit.publicRepo,
|
|
1267
|
+
tagTemplate: unit.version?.tagTemplate,
|
|
1268
|
+
snapshotDigest: snapshotDigests[idx],
|
|
1269
|
+
...(productionAssets ? {
|
|
1270
|
+
productionConfig: unit.production ?? {},
|
|
1271
|
+
frozenSnapshot: {
|
|
1272
|
+
path: productionAssets[idx].snapshotPath,
|
|
1273
|
+
manifestDigest: productionAssets[idx].manifestDigest,
|
|
1274
|
+
gitObjectDir: productionAssets[idx].gitObjectDir,
|
|
1275
|
+
branch: productionAssets[idx].branch,
|
|
1276
|
+
commit: productionAssets[idx].commit,
|
|
1277
|
+
tree: productionAssets[idx].tree,
|
|
1278
|
+
npm: productionAssets[idx].npm,
|
|
1279
|
+
},
|
|
1280
|
+
} : {}),
|
|
1281
|
+
distributions: unit.distributions,
|
|
1282
|
+
...(unitBaseline ? { previousPublicBaseline: unitBaseline } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
const externalActions = buildExternalActions(unitResults, resolvedVersions, productionAssets);
|
|
1287
|
+
|
|
1288
|
+
// Compute overall snapshot digest
|
|
1289
|
+
const overallSnapshotDigest = sha256Hex(snapshotDigests.join(':'));
|
|
1290
|
+
|
|
1291
|
+
const plan = {
|
|
1292
|
+
planVersion: 1,
|
|
1293
|
+
status: 'PREPARED',
|
|
1294
|
+
baseline: {
|
|
1295
|
+
gitTreeHash: baseline.gitTreeHash,
|
|
1296
|
+
headCommit: baseline.gitHead,
|
|
1297
|
+
workspaceDigestAlgorithm: baseline.workspaceDigestAlgorithm,
|
|
1298
|
+
workspaceDigest: baseline.workspaceDigest,
|
|
1299
|
+
dirtyFiles: baseline.statusEntries,
|
|
1300
|
+
capturedAt: baseline.capturedAt,
|
|
1301
|
+
},
|
|
1302
|
+
configDigest,
|
|
1303
|
+
snapshotDigest: overallSnapshotDigest,
|
|
1304
|
+
...(production ? {
|
|
1305
|
+
production: {
|
|
1306
|
+
mode: 'github-npm-v1',
|
|
1307
|
+
assetRoot: relative(realRoot, runDir),
|
|
1308
|
+
},
|
|
1309
|
+
} : {}),
|
|
1310
|
+
units,
|
|
1311
|
+
externalActions,
|
|
1312
|
+
createdAt: (clock ? clock() : new Date().toISOString()),
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
await evidence.append({
|
|
1316
|
+
phase: 'plan-assembly',
|
|
1317
|
+
status: 'completed',
|
|
1318
|
+
unitCount: units.length,
|
|
1319
|
+
actionCount: externalActions.length,
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// --- Step 8: Validate and write plan atomically ---
|
|
1323
|
+
await evidence.append({ phase: 'plan-write', status: 'started' });
|
|
1324
|
+
|
|
1325
|
+
const latestPlanPath = output ?? resolve(releaseDir, 'release-plan.json');
|
|
1326
|
+
const plannedDigest = computePlanDigest(plan);
|
|
1327
|
+
const immutablePlanPath = resolve(dirname(latestPlanPath), 'plans', `${plannedDigest}.json`);
|
|
1328
|
+
const { planPath: writtenPath, planDigest } = await writePlanImmutable(immutablePlanPath, plan);
|
|
1329
|
+
// This is a convenience copy only. All downstream authority uses the
|
|
1330
|
+
// digest-addressed immutable path returned above.
|
|
1331
|
+
await writePlanAtomic(latestPlanPath, plan);
|
|
1332
|
+
|
|
1333
|
+
await evidence.append({
|
|
1334
|
+
phase: 'plan-write',
|
|
1335
|
+
status: 'completed',
|
|
1336
|
+
planPath: writtenPath,
|
|
1337
|
+
planDigest,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// --- Write summary ---
|
|
1341
|
+
await evidence.finish({
|
|
1342
|
+
status: 'PREPARED',
|
|
1343
|
+
planPath: writtenPath,
|
|
1344
|
+
planDigest,
|
|
1345
|
+
configDigest,
|
|
1346
|
+
snapshotDigest: overallSnapshotDigest,
|
|
1347
|
+
unitCount: units.length,
|
|
1348
|
+
actionCount: externalActions.length,
|
|
1349
|
+
offline,
|
|
1350
|
+
completedAt: (clock ? clock() : new Date().toISOString()),
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
planPath: writtenPath,
|
|
1355
|
+
planDigest,
|
|
1356
|
+
evidenceDir,
|
|
1357
|
+
};
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
// Record failure evidence
|
|
1360
|
+
await evidence.append({
|
|
1361
|
+
phase: 'prepare',
|
|
1362
|
+
status: 'failed',
|
|
1363
|
+
error: { code: err.code, message: err.message },
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
await evidence.finish({
|
|
1367
|
+
status: 'FAILED',
|
|
1368
|
+
error: { code: err.code, message: err.message },
|
|
1369
|
+
failedAt: (clock ? clock() : new Date().toISOString()),
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
throw err;
|
|
1373
|
+
} finally {
|
|
1374
|
+
// Release project lock — always, even on failure
|
|
1375
|
+
await lock.release();
|
|
1376
|
+
}
|
|
1377
|
+
}
|