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
package/src/core/run.mjs
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared run validation, loading, and atomic writing for release runs.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - `validateRun(run)` -- validate a run object against the release-run schema
|
|
6
|
+
* - `loadRun(runPath)` -- load, parse, and validate a run file
|
|
7
|
+
* - `writeRunAtomic(runPath, run)` -- validate and write a run file atomically
|
|
8
|
+
*
|
|
9
|
+
* Used by publish, reconcile, and verify commands to ensure run records
|
|
10
|
+
* conform to the formal schema.
|
|
11
|
+
*
|
|
12
|
+
* @module core/run
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
16
|
+
import { realpathSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, resolve, basename, relative, isAbsolute } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import Ajv from 'ajv';
|
|
20
|
+
import addFormats from 'ajv-formats';
|
|
21
|
+
|
|
22
|
+
import { assertImmutablePlanAuthority, computePlanDigest } from './plan.mjs';
|
|
23
|
+
import { sha256Hex } from './digest.mjs';
|
|
24
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const RUN_SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-run.schema.json');
|
|
28
|
+
const RELEASE_RUN_SCHEMA = JSON.parse(await readFile(RUN_SCHEMA_PATH, 'utf8'));
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Default runDir resolution
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the default run directory for a command.
|
|
36
|
+
*
|
|
37
|
+
* Handles both `release-plan.json` (legacy) and `plans/<digest>.json`
|
|
38
|
+
* (immutable authority) plan paths:
|
|
39
|
+
* - `release-plan.json` → `<planDir>/runs/<command>-<ts>`
|
|
40
|
+
* - `plans/<digest>.json` → `<releaseDir>/runs/<command>-<ts>` (sibling of plans/)
|
|
41
|
+
*
|
|
42
|
+
* @param {string} planPath - Absolute path to the plan file.
|
|
43
|
+
* @param {string} command - The command name (publish, reconcile, verify).
|
|
44
|
+
* @returns {string} The resolved default run directory.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveDefaultRunDir(planPath, command, runId = `${command}-${Date.now()}`) {
|
|
47
|
+
const absolute = resolve(planPath);
|
|
48
|
+
const fileName = basename(absolute);
|
|
49
|
+
const parentDir = dirname(absolute);
|
|
50
|
+
|
|
51
|
+
if (fileName === 'release-plan.json') {
|
|
52
|
+
// Legacy path: runs inside the same directory
|
|
53
|
+
return `${parentDir}/runs/${runId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// plans/<digest>.json: resolve to sibling runs/ directory
|
|
57
|
+
if (basename(parentDir) === 'plans') {
|
|
58
|
+
const releaseDir = dirname(parentDir);
|
|
59
|
+
return join(releaseDir, 'runs', runId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fallback: runs in parent directory
|
|
63
|
+
return `${parentDir}/runs/${runId}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Schema validator (compiled once)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
71
|
+
addFormats(ajv);
|
|
72
|
+
const validateRunSchema = ajv.compile(RELEASE_RUN_SCHEMA);
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Public API
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate a run object against the embedded release-run schema.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} run - The run object to validate.
|
|
82
|
+
* @throws {ReleaseError} GATE_FAILED if the run does not match the schema.
|
|
83
|
+
*/
|
|
84
|
+
export function validateRun(run, options = {}) {
|
|
85
|
+
const valid = validateRunSchema(run);
|
|
86
|
+
if (!valid) {
|
|
87
|
+
const errors = validateRunSchema.errors ?? [];
|
|
88
|
+
const summary = errors
|
|
89
|
+
.map((e) => `${e.instancePath || '/'}: ${e.message}`)
|
|
90
|
+
.join('; ');
|
|
91
|
+
throw new ReleaseError(
|
|
92
|
+
GATE_FAILED,
|
|
93
|
+
`release run schema validation failed: ${summary}`,
|
|
94
|
+
{ validationErrors: errors },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const actionIds = new Set();
|
|
99
|
+
for (const checkpoint of run.checkpoints ?? []) {
|
|
100
|
+
if (actionIds.has(checkpoint.actionId)) {
|
|
101
|
+
throw new ReleaseError(
|
|
102
|
+
GATE_FAILED,
|
|
103
|
+
`release run has duplicate checkpoint for action "${checkpoint.actionId}"`,
|
|
104
|
+
{ actionId: checkpoint.actionId },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
actionIds.add(checkpoint.actionId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (options.requireDigest === true && !run.runDigest) {
|
|
111
|
+
throw new ReleaseError(GATE_FAILED, 'release run is missing required runDigest');
|
|
112
|
+
}
|
|
113
|
+
if (run.runDigest && run.runDigest !== computeRunDigest(run)) {
|
|
114
|
+
throw new ReleaseError(
|
|
115
|
+
GATE_FAILED,
|
|
116
|
+
'release run digest does not match its content',
|
|
117
|
+
{ expected: computeRunDigest(run), actual: run.runDigest },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Load, parse, and validate a run file from disk.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} runPath - Absolute path to the run file.
|
|
126
|
+
* @returns {Promise<Object>} The validated run object.
|
|
127
|
+
* @throws {ReleaseError} GATE_FAILED if the file cannot be read, parsed, or validated.
|
|
128
|
+
*/
|
|
129
|
+
export async function loadRun(runPath, options = {}) {
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = await readFile(runPath, 'utf8');
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new ReleaseError(
|
|
135
|
+
GATE_FAILED,
|
|
136
|
+
`cannot read release run: ${err.message}`,
|
|
137
|
+
{ runPath, cause: err.code },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let run;
|
|
142
|
+
try {
|
|
143
|
+
run = JSON.parse(raw);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
throw new ReleaseError(
|
|
146
|
+
GATE_FAILED,
|
|
147
|
+
`release run is not valid JSON: ${err.message}`,
|
|
148
|
+
{ runPath },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
validateRun(run, options);
|
|
153
|
+
if (options.authorityPlanPath) {
|
|
154
|
+
assertImmutableRunAuthority(runPath, options.authorityPlanPath, run);
|
|
155
|
+
}
|
|
156
|
+
return run;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Require a production run authority to live below the sibling runs/
|
|
161
|
+
* directory of plans/<digest>.json. Only exclusive final run files and
|
|
162
|
+
* append-only state sequence slots are accepted.
|
|
163
|
+
*/
|
|
164
|
+
export function assertImmutableRunAuthority(runPath, planPath, run) {
|
|
165
|
+
const absolutePlan = realpathSync(resolve(planPath));
|
|
166
|
+
const planDir = dirname(absolutePlan);
|
|
167
|
+
if (basename(planDir) !== 'plans') {
|
|
168
|
+
throw new ReleaseError(GATE_FAILED, 'run authority requires an immutable plans/<digest>.json plan path');
|
|
169
|
+
}
|
|
170
|
+
let authorityRoot = dirname(planDir);
|
|
171
|
+
let cursor = planDir;
|
|
172
|
+
while (dirname(cursor) !== cursor) {
|
|
173
|
+
if (basename(cursor) === '.release-skill') {
|
|
174
|
+
authorityRoot = cursor;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
cursor = dirname(cursor);
|
|
178
|
+
}
|
|
179
|
+
const runsDir = join(authorityRoot, 'runs');
|
|
180
|
+
const absoluteRun = realpathSync(resolve(runPath));
|
|
181
|
+
const rel = relative(runsDir, absoluteRun);
|
|
182
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
183
|
+
throw new ReleaseError(GATE_FAILED, 'production run authority must be inside the plan sibling runs/ directory');
|
|
184
|
+
}
|
|
185
|
+
const fileName = basename(absoluteRun);
|
|
186
|
+
const isFinal = fileName === 'release-run.json';
|
|
187
|
+
const isState = /^\d{6}\.json$/.test(fileName)
|
|
188
|
+
&& basename(dirname(absoluteRun)) === 'states'
|
|
189
|
+
&& run.stateSequence === Number(fileName.slice(0, 6));
|
|
190
|
+
if (!isFinal && !isState) {
|
|
191
|
+
throw new ReleaseError(
|
|
192
|
+
GATE_FAILED,
|
|
193
|
+
'production run authority must be release-run.json or a bound states/<sequence>.json snapshot',
|
|
194
|
+
{ runPath },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a fresh direct child below the production plan's physical runs/
|
|
201
|
+
* authority. Production commands must call this before writing evidence or
|
|
202
|
+
* authorizing any adapter execute.
|
|
203
|
+
*/
|
|
204
|
+
export async function createProductionRunDir(runDir, planPath) {
|
|
205
|
+
const absolutePlan = realpathSync(resolve(planPath));
|
|
206
|
+
const planDir = dirname(absolutePlan);
|
|
207
|
+
if (basename(planDir) !== 'plans') {
|
|
208
|
+
throw new ReleaseError(GATE_FAILED, 'production run directory requires an immutable plans/<digest>.json authority');
|
|
209
|
+
}
|
|
210
|
+
const authorityRoot = dirname(planDir);
|
|
211
|
+
return createProductionRunDirWithinAuthority(runDir, authorityRoot);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a fresh production prepare run below a physical .release-skill/
|
|
216
|
+
* authority. Prepare has no immutable plan yet, so it binds directly to the
|
|
217
|
+
* project-owned release directory established by the project lock.
|
|
218
|
+
*/
|
|
219
|
+
export async function createProductionPrepareRunDir(runDir, releaseDir) {
|
|
220
|
+
const authorityRoot = resolve(releaseDir);
|
|
221
|
+
let authorityStat;
|
|
222
|
+
try {
|
|
223
|
+
authorityStat = await lstat(authorityRoot);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new ReleaseError(GATE_FAILED, 'cannot inspect production .release-skill authority root', {
|
|
226
|
+
releaseDir: authorityRoot,
|
|
227
|
+
cause: error.code,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (authorityStat.isSymbolicLink() || !authorityStat.isDirectory()) {
|
|
231
|
+
throw new ReleaseError(
|
|
232
|
+
GATE_FAILED,
|
|
233
|
+
'production .release-skill authority root must be a real directory, not a symlink or special file',
|
|
234
|
+
{ releaseDir: authorityRoot },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const physicalAuthorityRoot = realpathSync(authorityRoot);
|
|
238
|
+
if (physicalAuthorityRoot !== authorityRoot) {
|
|
239
|
+
throw new ReleaseError(
|
|
240
|
+
GATE_FAILED,
|
|
241
|
+
'production .release-skill authority root physical identity does not match the project authority',
|
|
242
|
+
{ releaseDir: authorityRoot, physicalReleaseDir: physicalAuthorityRoot },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return createProductionRunDirWithinAuthority(runDir, physicalAuthorityRoot);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function createProductionRunDirWithinAuthority(runDir, authorityRoot) {
|
|
249
|
+
const runsDir = join(authorityRoot, 'runs');
|
|
250
|
+
let runsStat;
|
|
251
|
+
try {
|
|
252
|
+
runsStat = await lstat(runsDir);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error.code !== 'ENOENT') {
|
|
255
|
+
throw new ReleaseError(GATE_FAILED, 'cannot inspect production runs authority root', {
|
|
256
|
+
runsDir,
|
|
257
|
+
cause: error.code,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await mkdir(runsDir, { mode: 0o700 });
|
|
262
|
+
} catch (mkdirError) {
|
|
263
|
+
if (mkdirError.code !== 'EEXIST') {
|
|
264
|
+
throw new ReleaseError(GATE_FAILED, 'cannot create production runs authority root', {
|
|
265
|
+
runsDir,
|
|
266
|
+
cause: mkdirError.code,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
runsStat = await lstat(runsDir);
|
|
271
|
+
}
|
|
272
|
+
if (runsStat.isSymbolicLink() || !runsStat.isDirectory()) {
|
|
273
|
+
throw new ReleaseError(
|
|
274
|
+
GATE_FAILED,
|
|
275
|
+
'production .release-skill/runs authority root must be a real directory, not a symlink or special file',
|
|
276
|
+
{ runsDir },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const physicalRunsDir = realpathSync(runsDir);
|
|
280
|
+
if (physicalRunsDir !== runsDir) {
|
|
281
|
+
throw new ReleaseError(
|
|
282
|
+
GATE_FAILED,
|
|
283
|
+
'production .release-skill/runs authority root physical identity does not match the plan authority',
|
|
284
|
+
{ runsDir, physicalRunsDir },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const requested = resolve(runDir);
|
|
288
|
+
let physicalParent;
|
|
289
|
+
try {
|
|
290
|
+
physicalParent = realpathSync(dirname(requested));
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw new ReleaseError(GATE_FAILED, 'production run directory parent is unavailable', {
|
|
293
|
+
runDir,
|
|
294
|
+
cause: error.code,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (physicalParent !== physicalRunsDir || basename(requested) === '' || basename(requested) === '.' || basename(requested) === '..') {
|
|
298
|
+
throw new ReleaseError(
|
|
299
|
+
GATE_FAILED,
|
|
300
|
+
'production run directory must be a fresh direct child of the immutable .release-skill/runs authority',
|
|
301
|
+
{ runDir, runsDir: physicalRunsDir },
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await lstat(requested);
|
|
306
|
+
throw new ReleaseError(GATE_FAILED, 'production run directory already exists; authority directories cannot be reused', { runDir });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error instanceof ReleaseError) throw error;
|
|
309
|
+
if (error.code !== 'ENOENT') {
|
|
310
|
+
throw new ReleaseError(GATE_FAILED, 'cannot establish fresh production run directory', {
|
|
311
|
+
runDir,
|
|
312
|
+
cause: error.code,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
await mkdir(requested, { mode: 0o700 });
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new ReleaseError(GATE_FAILED, 'cannot exclusively create production run directory', {
|
|
320
|
+
runDir,
|
|
321
|
+
cause: error.code,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const physicalRunDir = realpathSync(requested);
|
|
325
|
+
if (dirname(physicalRunDir) !== physicalRunsDir) {
|
|
326
|
+
throw new ReleaseError(GATE_FAILED, 'production run directory escaped its immutable authority after creation', { runDir });
|
|
327
|
+
}
|
|
328
|
+
return physicalRunDir;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function validateStatePredecessorChain(run, runPath, options = {}) {
|
|
332
|
+
if (run.stateSequence === undefined) {
|
|
333
|
+
if (run.previousStateDigest !== undefined) {
|
|
334
|
+
throw new ReleaseError(GATE_FAILED, 'non-state run cannot claim previousStateDigest');
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
let current = run;
|
|
339
|
+
let currentPath = realpathSync(resolve(runPath));
|
|
340
|
+
let traversed = 0;
|
|
341
|
+
while (true) {
|
|
342
|
+
const sequence = current.stateSequence;
|
|
343
|
+
const expectedName = `${String(sequence).padStart(6, '0')}.json`;
|
|
344
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0 || basename(currentPath) !== expectedName || basename(dirname(currentPath)) !== 'states') {
|
|
345
|
+
throw new ReleaseError(GATE_FAILED, 'run state sequence is not bound to its immutable states/<sequence>.json slot');
|
|
346
|
+
}
|
|
347
|
+
if (sequence === 0) {
|
|
348
|
+
if (current.previousStateDigest !== undefined) {
|
|
349
|
+
throw new ReleaseError(GATE_FAILED, 'initial run state must not claim a predecessor digest');
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!current.previousStateDigest) {
|
|
354
|
+
throw new ReleaseError(GATE_FAILED, 'run state is missing previousStateDigest');
|
|
355
|
+
}
|
|
356
|
+
traversed += 1;
|
|
357
|
+
if (traversed > 100_000) {
|
|
358
|
+
throw new ReleaseError(GATE_FAILED, 'run state predecessor chain exceeds maximum depth');
|
|
359
|
+
}
|
|
360
|
+
const previousPath = join(dirname(currentPath), `${String(sequence - 1).padStart(6, '0')}.json`);
|
|
361
|
+
const previous = await loadRun(previousPath, {
|
|
362
|
+
requireDigest: true,
|
|
363
|
+
...(options.production ? { authorityPlanPath: options.planPath } : {}),
|
|
364
|
+
});
|
|
365
|
+
if (
|
|
366
|
+
previous.stateSequence !== sequence - 1
|
|
367
|
+
|| previous.runId !== current.runId
|
|
368
|
+
|| previous.command !== current.command
|
|
369
|
+
|| previous.planDigest !== current.planDigest
|
|
370
|
+
) {
|
|
371
|
+
throw new ReleaseError(GATE_FAILED, 'run state predecessor does not belong to the same monotonic authority chain');
|
|
372
|
+
}
|
|
373
|
+
if (current.previousStateDigest !== previous.runDigest) {
|
|
374
|
+
throw new ReleaseError(GATE_FAILED, 'run state previousStateDigest does not match the immutable predecessor bytes');
|
|
375
|
+
}
|
|
376
|
+
current = previous;
|
|
377
|
+
currentPath = realpathSync(previousPath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateSourceRunEdge(child, parent) {
|
|
382
|
+
if (child.command === 'reconcile') {
|
|
383
|
+
if (!['publish', 'reconcile'].includes(parent.command) || parent.status !== 'PARTIAL') {
|
|
384
|
+
throw new ReleaseError(
|
|
385
|
+
GATE_FAILED,
|
|
386
|
+
'reconcile lineage must reference a PARTIAL publish or reconcile run',
|
|
387
|
+
{ childCommand: child.command, parentCommand: parent.command, parentStatus: parent.status },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (child.command === 'verify') {
|
|
393
|
+
if (!['publish', 'reconcile'].includes(parent.command) || parent.status !== 'PUBLISHED') {
|
|
394
|
+
throw new ReleaseError(
|
|
395
|
+
GATE_FAILED,
|
|
396
|
+
'verify lineage must reference a PUBLISHED publish or reconcile run',
|
|
397
|
+
{ childCommand: child.command, parentCommand: parent.command, parentStatus: parent.status },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Recursively validate sourceRunId/sourceRunDigest/sourceRunPath references
|
|
405
|
+
* until a publish authority is reached. This prevents a reconcile run from
|
|
406
|
+
* truncating its parent lineage before verify consumes it.
|
|
407
|
+
*/
|
|
408
|
+
export async function validateRunLineage(run, options = {}) {
|
|
409
|
+
const { plan, planPath, runPath, production = false, maxDepth = 16 } = options;
|
|
410
|
+
if (maxDepth < 0) {
|
|
411
|
+
throw new ReleaseError(GATE_FAILED, 'release run lineage exceeds maximum depth');
|
|
412
|
+
}
|
|
413
|
+
validateRun(run, { requireDigest: production });
|
|
414
|
+
if (production) assertImmutableRunAuthority(runPath, planPath, run);
|
|
415
|
+
await validateStatePredecessorChain(run, runPath, { production, planPath });
|
|
416
|
+
validateRunPlanDigest(run, plan, { planPath });
|
|
417
|
+
if (run.command === 'publish') return;
|
|
418
|
+
if (!['reconcile', 'verify'].includes(run.command)) {
|
|
419
|
+
throw new ReleaseError(GATE_FAILED, `unsupported run command in lineage: ${run.command}`);
|
|
420
|
+
}
|
|
421
|
+
if (!run.sourceRunPath || !run.sourceRunId || !run.sourceRunDigest) {
|
|
422
|
+
throw new ReleaseError(GATE_FAILED, `${run.command} run is missing complete source run lineage`);
|
|
423
|
+
}
|
|
424
|
+
const parent = await loadRun(run.sourceRunPath, {
|
|
425
|
+
requireDigest: true,
|
|
426
|
+
...(production ? { authorityPlanPath: planPath } : {}),
|
|
427
|
+
});
|
|
428
|
+
if (parent.runId !== run.sourceRunId || parent.runDigest !== run.sourceRunDigest) {
|
|
429
|
+
throw new ReleaseError(
|
|
430
|
+
GATE_FAILED,
|
|
431
|
+
'source run lineage id/digest does not match referenced immutable run bytes',
|
|
432
|
+
{ sourceRunPath: run.sourceRunPath },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
validateSourceRunEdge(run, parent);
|
|
436
|
+
await validateRunLineage(parent, {
|
|
437
|
+
plan,
|
|
438
|
+
planPath,
|
|
439
|
+
runPath: run.sourceRunPath,
|
|
440
|
+
production,
|
|
441
|
+
maxDepth: maxDepth - 1,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Validate a run's planDigest against a loaded plan.
|
|
447
|
+
*
|
|
448
|
+
* When `requirePresence` is true (default for source runs used by
|
|
449
|
+
* reconcile/verify), a missing planDigest is treated as a validation failure.
|
|
450
|
+
*
|
|
451
|
+
* @param {Object} run - The validated run object.
|
|
452
|
+
* @param {Object} plan - The loaded release plan.
|
|
453
|
+
* @param {Object} [options]
|
|
454
|
+
* @param {boolean} [options.requirePresence=true] - Require planDigest to be present.
|
|
455
|
+
* @throws {ReleaseError} GATE_FAILED if planDigest is missing or does not match.
|
|
456
|
+
*/
|
|
457
|
+
export function validateRunPlanDigest(run, plan, options = {}) {
|
|
458
|
+
const requirePresence = options.requirePresence !== false;
|
|
459
|
+
const expectedDigest = computePlanDigest(plan);
|
|
460
|
+
if (options.planPath) assertImmutablePlanAuthority(options.planPath, plan);
|
|
461
|
+
|
|
462
|
+
if (!run.planDigest) {
|
|
463
|
+
if (requirePresence) {
|
|
464
|
+
throw new ReleaseError(
|
|
465
|
+
GATE_FAILED,
|
|
466
|
+
'source run is missing required planDigest field',
|
|
467
|
+
{ runId: run.runId },
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return; // Optional: silently pass if not required
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (run.planDigest !== expectedDigest) {
|
|
474
|
+
throw new ReleaseError(
|
|
475
|
+
GATE_FAILED,
|
|
476
|
+
`run planDigest mismatch: run says ${String(run.planDigest).slice(0, 16)}..., plan is ${expectedDigest.slice(0, 16)}...`,
|
|
477
|
+
{ runDigest: run.planDigest, planDigest: expectedDigest },
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (plan.production && run.planPath && options.planPath && resolve(run.planPath) !== resolve(options.planPath)) {
|
|
482
|
+
throw new ReleaseError(
|
|
483
|
+
GATE_FAILED,
|
|
484
|
+
'source run immutable plan path does not match the supplied plan authority',
|
|
485
|
+
{ runPlanPath: run.planPath, suppliedPlanPath: options.planPath },
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Validate that a source run's checkpoints map 1:1 to plan actions.
|
|
492
|
+
* Missing or unknown actions fail closed.
|
|
493
|
+
*
|
|
494
|
+
* @param {Object} run - The validated source run object.
|
|
495
|
+
* @param {Object[]} planActions - The plan's externalActions array.
|
|
496
|
+
* @throws {ReleaseError} GATE_FAILED on mapping failures.
|
|
497
|
+
*/
|
|
498
|
+
export function validateRunCheckpointMapping(run, planActions) {
|
|
499
|
+
const seenPlanActionIds = new Set();
|
|
500
|
+
for (const action of planActions) {
|
|
501
|
+
if (seenPlanActionIds.has(action.id)) {
|
|
502
|
+
throw new ReleaseError(
|
|
503
|
+
GATE_FAILED,
|
|
504
|
+
`release plan has duplicate action id "${action.id}"`,
|
|
505
|
+
{ actionId: action.id },
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
seenPlanActionIds.add(action.id);
|
|
509
|
+
}
|
|
510
|
+
const planActionIds = new Set(planActions.map((a) => a.id));
|
|
511
|
+
const planActionsById = new Map(planActions.map((action) => [action.id, action]));
|
|
512
|
+
|
|
513
|
+
// Reject duplicate actionIds in the run's checkpoints
|
|
514
|
+
const seenCheckpointIds = new Set();
|
|
515
|
+
for (const cp of run.checkpoints) {
|
|
516
|
+
if (seenCheckpointIds.has(cp.actionId)) {
|
|
517
|
+
throw new ReleaseError(
|
|
518
|
+
GATE_FAILED,
|
|
519
|
+
`source run has duplicate checkpoint for action "${cp.actionId}"`,
|
|
520
|
+
{ actionId: cp.actionId },
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
seenCheckpointIds.add(cp.actionId);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const runCheckpointIds = new Set(run.checkpoints.map((cp) => cp.actionId));
|
|
527
|
+
|
|
528
|
+
// Every plan action must have a checkpoint
|
|
529
|
+
for (const action of planActions) {
|
|
530
|
+
if (!runCheckpointIds.has(action.id)) {
|
|
531
|
+
throw new ReleaseError(
|
|
532
|
+
GATE_FAILED,
|
|
533
|
+
`source run missing checkpoint for plan action "${action.id}"`,
|
|
534
|
+
{ actionId: action.id },
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Every checkpoint must map to a plan action (no unknown actions)
|
|
540
|
+
for (const cp of run.checkpoints) {
|
|
541
|
+
if (!planActionIds.has(cp.actionId)) {
|
|
542
|
+
throw new ReleaseError(
|
|
543
|
+
GATE_FAILED,
|
|
544
|
+
`source run has checkpoint for unknown action "${cp.actionId}"`,
|
|
545
|
+
{ actionId: cp.actionId },
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const action = planActionsById.get(cp.actionId);
|
|
550
|
+
if (cp.actionType !== action.type) {
|
|
551
|
+
throw new ReleaseError(
|
|
552
|
+
GATE_FAILED,
|
|
553
|
+
`source run checkpoint actionType mismatch for action "${cp.actionId}": run says "${cp.actionType}", plan says "${action.type}"`,
|
|
554
|
+
{ actionId: cp.actionId, runActionType: cp.actionType, planActionType: action.type },
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Compute the SHA-256 digest of a run object.
|
|
562
|
+
*
|
|
563
|
+
* The digest is computed over the JSON serialisation with the `runDigest`
|
|
564
|
+
* field excluded (same convention as computePlanDigest).
|
|
565
|
+
*
|
|
566
|
+
* @param {Object} run - The run object.
|
|
567
|
+
* @returns {string} The hex-encoded SHA-256 digest.
|
|
568
|
+
*/
|
|
569
|
+
export function computeRunDigest(run) {
|
|
570
|
+
const { runDigest: _, ...rest } = run;
|
|
571
|
+
return sha256Hex(JSON.stringify(rest, null, 2));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function sealRun(run) {
|
|
575
|
+
const sealed = { ...run };
|
|
576
|
+
sealed.runDigest = computeRunDigest(sealed);
|
|
577
|
+
validateRun(sealed, { requireDigest: true });
|
|
578
|
+
return sealed;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function syncDirectory(dir) {
|
|
582
|
+
const handle = await open(dir, 'r');
|
|
583
|
+
try {
|
|
584
|
+
await handle.sync();
|
|
585
|
+
} finally {
|
|
586
|
+
await handle.close();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Write a run file atomically to disk.
|
|
592
|
+
*
|
|
593
|
+
* Validates the run against the schema before writing.
|
|
594
|
+
* Uses temp-file + rename for atomicity.
|
|
595
|
+
*
|
|
596
|
+
* **Exclusive-create semantics**: if a file already exists at `runPath`
|
|
597
|
+
* with different bytes, the write fails. This prevents a stale run from
|
|
598
|
+
* silently overwriting a newer run record. Writes are idempotent when
|
|
599
|
+
* the content is byte-identical.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} runPath - Absolute path to write the run to.
|
|
602
|
+
* @param {Object} run - The run object to write.
|
|
603
|
+
* @returns {Promise<void>}
|
|
604
|
+
* @throws {ReleaseError} GATE_FAILED on schema validation failure.
|
|
605
|
+
* @throws {ReleaseError} GATE_FAILED if the file already exists with different bytes.
|
|
606
|
+
*/
|
|
607
|
+
export async function writeRunAtomic(runPath, run) {
|
|
608
|
+
const sealed = sealRun(run);
|
|
609
|
+
const json = JSON.stringify(sealed, null, 2);
|
|
610
|
+
const dir = dirname(runPath);
|
|
611
|
+
const tmpPath = `${dir}/.release-run-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
612
|
+
await mkdir(dir, { recursive: true });
|
|
613
|
+
|
|
614
|
+
const handle = await open(tmpPath, 'wx', 0o600);
|
|
615
|
+
try {
|
|
616
|
+
await handle.writeFile(json, 'utf8');
|
|
617
|
+
await handle.sync();
|
|
618
|
+
} finally {
|
|
619
|
+
await handle.close();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
try {
|
|
624
|
+
await link(tmpPath, runPath);
|
|
625
|
+
await syncDirectory(dir);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (error.code !== 'EEXIST') throw error;
|
|
628
|
+
const existing = await readFile(runPath, 'utf8');
|
|
629
|
+
if (existing !== json) {
|
|
630
|
+
throw new ReleaseError(
|
|
631
|
+
GATE_FAILED,
|
|
632
|
+
'run file already exists with different bytes; exclusive-create rejected overwrite',
|
|
633
|
+
{ runPath },
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
} finally {
|
|
638
|
+
await unlink(tmpPath).catch(() => {});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return Object.freeze(sealed);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Append one immutable, digest-addressed state snapshot for an in-flight run.
|
|
646
|
+
*/
|
|
647
|
+
export async function appendRunState(runDir, sequence, run) {
|
|
648
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0) {
|
|
649
|
+
throw new ReleaseError(GATE_FAILED, 'run state sequence must be a non-negative integer');
|
|
650
|
+
}
|
|
651
|
+
const statesDir = join(runDir, 'states');
|
|
652
|
+
let previousStateDigest;
|
|
653
|
+
if (sequence > 0) {
|
|
654
|
+
const previousPath = join(statesDir, `${String(sequence - 1).padStart(6, '0')}.json`);
|
|
655
|
+
const previous = await loadRun(previousPath, { requireDigest: true });
|
|
656
|
+
if (
|
|
657
|
+
previous.stateSequence !== sequence - 1
|
|
658
|
+
|| previous.runId !== run.runId
|
|
659
|
+
|| previous.command !== run.command
|
|
660
|
+
|| previous.planDigest !== run.planDigest
|
|
661
|
+
) {
|
|
662
|
+
throw new ReleaseError(
|
|
663
|
+
GATE_FAILED,
|
|
664
|
+
'run state predecessor does not belong to the same monotonic authority chain',
|
|
665
|
+
{ sequence, previousPath },
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
previousStateDigest = previous.runDigest;
|
|
669
|
+
}
|
|
670
|
+
const { previousStateDigest: _discarded, ...nextRun } = run;
|
|
671
|
+
const state = sealRun({
|
|
672
|
+
...nextRun,
|
|
673
|
+
stateSequence: sequence,
|
|
674
|
+
...(previousStateDigest ? { previousStateDigest } : {}),
|
|
675
|
+
});
|
|
676
|
+
// The sequence slot itself is exclusive. A second digest at the same
|
|
677
|
+
// sequence is therefore rejected instead of creating a fork.
|
|
678
|
+
const statePath = join(statesDir, `${String(sequence).padStart(6, '0')}.json`);
|
|
679
|
+
await writeRunAtomic(statePath, state);
|
|
680
|
+
return Object.freeze({ state, statePath });
|
|
681
|
+
}
|