release-skill 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +89 -0
- package/INSTALL.md +216 -5
- package/INSTALL.zh-CN.md +358 -0
- package/README.md +411 -67
- package/README.zh-CN.md +377 -59
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +79284 -0
- package/adapters/claude/bin/release-skill.mjs +34 -0
- package/adapters/claude/native/safe-write/binding.gyp +40 -0
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +24 -0
- package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/claude/schemas/.render-manifest.json +37 -0
- package/adapters/claude/schemas/approval-record.schema.json +115 -0
- package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
- package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
- package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
- package/adapters/claude/schemas/evidence-event.schema.json +89 -0
- package/adapters/claude/schemas/release-plan.schema.json +860 -0
- package/adapters/claude/schemas/release-project.schema.json +736 -0
- package/adapters/claude/schemas/release-run.schema.json +342 -0
- package/adapters/claude/skills/release-assess/SKILL.md +5 -6
- package/adapters/claude/skills/release-help/SKILL.md +14 -18
- package/adapters/claude/skills/release-prepare/SKILL.md +16 -6
- package/adapters/claude/skills/release-publish/SKILL.md +7 -7
- package/adapters/claude/skills/release-reconcile/SKILL.md +6 -6
- package/adapters/claude/skills/release-setup/SKILL.md +95 -0
- package/adapters/claude/skills/release-verify/SKILL.md +7 -7
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
- package/adapters/codex/bin/release-skill.mjs +34 -0
- package/adapters/codex/native/safe-write/binding.gyp +40 -0
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +24 -0
- package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/codex/schemas/.render-manifest.json +37 -0
- package/adapters/codex/schemas/approval-record.schema.json +115 -0
- package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
- package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
- package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
- package/adapters/codex/schemas/evidence-event.schema.json +89 -0
- package/adapters/codex/schemas/release-plan.schema.json +860 -0
- package/adapters/codex/schemas/release-project.schema.json +736 -0
- package/adapters/codex/schemas/release-run.schema.json +342 -0
- package/adapters/codex/skills/release-assess/SKILL.md +12 -6
- package/adapters/codex/skills/release-help/SKILL.md +21 -18
- package/adapters/codex/skills/release-prepare/SKILL.md +23 -6
- package/adapters/codex/skills/release-publish/SKILL.md +14 -7
- package/adapters/codex/skills/release-reconcile/SKILL.md +13 -6
- package/adapters/codex/skills/release-setup/SKILL.md +102 -0
- package/adapters/codex/skills/release-verify/SKILL.md +14 -7
- package/bin/release-skill-cli.mjs +807 -0
- package/bin/release-skill.bundle.mjs +79284 -0
- package/bin/release-skill.mjs +23 -732
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +22 -2
- package/native/safe-write/src/safe_write.cc +11 -2
- package/package.json +8 -2
- package/references/02-project-config.md +55 -4
- package/references/05-evidence-and-errors.md +6 -2
- package/schemas/release-plan.schema.json +556 -65
- package/schemas/release-project.schema.json +406 -29
- package/schemas/release-run.schema.json +165 -18
- package/scripts/build-bundle.mjs +133 -0
- package/skills/release-assess/SKILL.md +5 -6
- package/skills/release-help/SKILL.md +14 -18
- package/skills/release-prepare/SKILL.md +16 -6
- package/skills/release-publish/SKILL.md +7 -7
- package/skills/release-reconcile/SKILL.md +6 -6
- package/skills/release-setup/SKILL.md +95 -0
- package/skills/release-verify/SKILL.md +7 -7
- package/skills-src/release-assess/SKILL.md +5 -6
- package/skills-src/release-help/SKILL.md +14 -18
- package/skills-src/release-prepare/SKILL.md +16 -6
- package/skills-src/release-publish/SKILL.md +7 -7
- package/skills-src/release-reconcile/SKILL.md +6 -6
- package/skills-src/release-setup/SKILL.md +95 -0
- package/skills-src/release-verify/SKILL.md +7 -7
- package/src/adapters/contract.mjs +3 -0
- package/src/adapters/git-github.mjs +84 -2
- package/src/adapters/npm.mjs +5 -13
- package/src/adapters/plugin-marketplace.mjs +132 -52
- package/src/adapters/push-snapshot.mjs +84 -17
- package/src/artifacts/policy.mjs +4 -7
- package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
- package/src/commands/prepare.mjs +244 -20
- package/src/commands/publish.mjs +46 -0
- package/src/commands/reconcile.mjs +152 -0
- package/src/commands/setup.mjs +1525 -0
- package/src/commands/verify.mjs +122 -26
- package/src/core/approval.mjs +4 -6
- package/src/core/config.mjs +42 -8
- package/src/core/errors.mjs +4 -0
- package/src/core/pkg-root.mjs +22 -0
- package/src/core/plan.mjs +132 -4
- package/src/core/previous-public-baseline.mjs +21 -1
- package/src/core/run.mjs +4 -4
- package/src/core/trusted-resource.mjs +96 -0
- package/src/core/verification-gates.mjs +451 -0
- package/src/docs/version-gate.mjs +164 -0
- package/src/producers/build-adapters.mjs +512 -55
- package/src/snapshot/frozen.mjs +221 -7
- package/src/snapshot/public-map.mjs +7 -4
- package/src/snapshot/scan.mjs +2 -1
|
@@ -88,7 +88,7 @@ export async function observePreviousPublicBaseline({ baseline, observeFn, evide
|
|
|
88
88
|
* Evidence collector for audit trail.
|
|
89
89
|
* @returns {Promise<{ consistent: true } | { consistent: false, error: string, detail?: Record<string, unknown> }>}
|
|
90
90
|
*/
|
|
91
|
-
export async function reObservePreviousPublicBaseline({ baseline, observeFn, evidence }) {
|
|
91
|
+
export async function reObservePreviousPublicBaseline({ baseline, observeFn, evidence, acceptedSuccessorCommits = [] }) {
|
|
92
92
|
if (!baseline || typeof baseline !== 'object') {
|
|
93
93
|
return { consistent: true };
|
|
94
94
|
}
|
|
@@ -114,6 +114,26 @@ export async function reObservePreviousPublicBaseline({ baseline, observeFn, evi
|
|
|
114
114
|
return { consistent: true };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
if (
|
|
118
|
+
result.status === 'drifted' &&
|
|
119
|
+
typeof result.actual === 'string' &&
|
|
120
|
+
acceptedSuccessorCommits.includes(result.actual)
|
|
121
|
+
) {
|
|
122
|
+
evidence?.append({
|
|
123
|
+
phase: 're-observe-previous-public-baseline',
|
|
124
|
+
status: 'consistent-planned-successor',
|
|
125
|
+
repo,
|
|
126
|
+
ref,
|
|
127
|
+
baselineCommit: commit,
|
|
128
|
+
observedCommit: result.actual,
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
consistent: true,
|
|
132
|
+
observed: { ...result },
|
|
133
|
+
state: 'planned-successor',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
// Drifted or unknown -- return soft failure for reconcile to handle
|
|
118
138
|
return {
|
|
119
139
|
consistent: false,
|
package/src/core/run.mjs
CHANGED
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
16
16
|
import { realpathSync } from 'node:fs';
|
|
17
17
|
import { dirname, join, resolve, basename, relative, isAbsolute } from 'node:path';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
18
|
import Ajv from 'ajv';
|
|
20
19
|
import addFormats from 'ajv-formats';
|
|
21
20
|
|
|
22
21
|
import { assertImmutablePlanAuthority, computePlanDigest } from './plan.mjs';
|
|
23
22
|
import { sha256Hex } from './digest.mjs';
|
|
24
23
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
24
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const RELEASE_RUN_SCHEMA = JSON.parse((await readTrustedPackageResource(
|
|
27
|
+
'schemas/release-run.schema.json',
|
|
28
|
+
)).toString('utf8'));
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Default runDir resolution
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read package-owned runtime resources without following untrusted links.
|
|
3
|
+
* Installed adapters use this for schemas and native manifests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
8
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { CONFIG_INVALID, ReleaseError } from './errors.mjs';
|
|
11
|
+
import { PKG_ROOT } from './pkg-root.mjs';
|
|
12
|
+
|
|
13
|
+
function fail(code, resource, reason) {
|
|
14
|
+
throw new ReleaseError(
|
|
15
|
+
code,
|
|
16
|
+
`package resource is untrusted or unavailable: ${resource}`,
|
|
17
|
+
{ resource, reason },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lexicalPath(resource, code) {
|
|
22
|
+
if (typeof resource !== 'string' || resource.length === 0 || isAbsolute(resource)) {
|
|
23
|
+
fail(code, String(resource), 'INVALID_RESOURCE_PATH');
|
|
24
|
+
}
|
|
25
|
+
const path = resolve(PKG_ROOT, resource);
|
|
26
|
+
const rel = relative(PKG_ROOT, path);
|
|
27
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
28
|
+
fail(code, resource, 'RESOURCE_PATH_ESCAPE');
|
|
29
|
+
}
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertStat(stat, resource, code) {
|
|
34
|
+
if (stat.isSymbolicLink()) fail(code, resource, 'SYMLINK');
|
|
35
|
+
if (!stat.isFile()) fail(code, resource, 'NOT_REGULAR_FILE');
|
|
36
|
+
if (stat.nlink !== 1) fail(code, resource, 'UNEXPECTED_HARDLINK_COUNT');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertPhysicalContainment(physicalRoot, physicalPath, resource, code) {
|
|
40
|
+
const rel = relative(physicalRoot, physicalPath);
|
|
41
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
42
|
+
fail(code, resource, 'PHYSICAL_PATH_ESCAPE');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readTrustedPackageResourceSync(resource, { code = CONFIG_INVALID } = {}) {
|
|
47
|
+
const path = lexicalPath(resource, code);
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = lstatSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
fail(code, resource, 'MISSING');
|
|
53
|
+
}
|
|
54
|
+
assertStat(stat, resource, code);
|
|
55
|
+
|
|
56
|
+
let physicalRoot;
|
|
57
|
+
let physicalPath;
|
|
58
|
+
try {
|
|
59
|
+
physicalRoot = realpathSync(PKG_ROOT);
|
|
60
|
+
physicalPath = realpathSync(path);
|
|
61
|
+
} catch {
|
|
62
|
+
fail(code, resource, 'REALPATH_FAILED');
|
|
63
|
+
}
|
|
64
|
+
assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(physicalPath);
|
|
67
|
+
} catch {
|
|
68
|
+
fail(code, resource, 'READ_FAILED');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readTrustedPackageResource(resource, { code = CONFIG_INVALID } = {}) {
|
|
73
|
+
const path = lexicalPath(resource, code);
|
|
74
|
+
let stat;
|
|
75
|
+
try {
|
|
76
|
+
stat = await lstat(path);
|
|
77
|
+
} catch {
|
|
78
|
+
fail(code, resource, 'MISSING');
|
|
79
|
+
}
|
|
80
|
+
assertStat(stat, resource, code);
|
|
81
|
+
|
|
82
|
+
let physicalRoot;
|
|
83
|
+
let physicalPath;
|
|
84
|
+
try {
|
|
85
|
+
physicalRoot = await realpath(PKG_ROOT);
|
|
86
|
+
physicalPath = await realpath(path);
|
|
87
|
+
} catch {
|
|
88
|
+
fail(code, resource, 'REALPATH_FAILED');
|
|
89
|
+
}
|
|
90
|
+
assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
|
|
91
|
+
try {
|
|
92
|
+
return await readFile(physicalPath);
|
|
93
|
+
} catch {
|
|
94
|
+
fail(code, resource, 'READ_FAILED');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic project verification gates.
|
|
3
|
+
*
|
|
4
|
+
* Gates are local checks only. They use spawn without a shell, receive a
|
|
5
|
+
* minimal environment, and never participate in remote publication writes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from 'node:fs/promises';
|
|
11
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
14
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
15
|
+
import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
|
|
16
|
+
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
17
|
+
|
|
18
|
+
const OUTPUT_LIMIT_BYTES = 1024 * 1024;
|
|
19
|
+
const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
20
|
+
const PLATFORM_ENV = process.platform === 'win32'
|
|
21
|
+
? ['PATH', 'SystemRoot', 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP']
|
|
22
|
+
: ['PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR'];
|
|
23
|
+
|
|
24
|
+
function gateError(gate, message, details = {}) {
|
|
25
|
+
return new ReleaseError(GATE_FAILED, `verification gate "${gate?.id ?? 'unknown'}" ${message}`, {
|
|
26
|
+
gateId: gate?.id,
|
|
27
|
+
...details,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isInside(parent, candidate) {
|
|
32
|
+
const rel = relative(parent, candidate);
|
|
33
|
+
const separator = process.platform === 'win32' ? '\\' : '/';
|
|
34
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${separator}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchesSubset(actual, expected) {
|
|
38
|
+
if (expected === null || typeof expected !== 'object' || Array.isArray(expected)) {
|
|
39
|
+
return actual === expected;
|
|
40
|
+
}
|
|
41
|
+
if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false;
|
|
42
|
+
return Object.entries(expected).every(([key, value]) => (
|
|
43
|
+
Object.hasOwn(actual, key) && matchesSubset(actual[key], value)
|
|
44
|
+
));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateGate(gate) {
|
|
48
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
49
|
+
throw gateError(gate, 'must be an object');
|
|
50
|
+
}
|
|
51
|
+
if (typeof gate.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(gate.id)) {
|
|
52
|
+
throw gateError(gate, 'has an invalid id');
|
|
53
|
+
}
|
|
54
|
+
if (!['snapshot-verify', 'consumer-verify'].includes(gate.phase)) {
|
|
55
|
+
throw gateError(gate, 'has an invalid phase');
|
|
56
|
+
}
|
|
57
|
+
if (!gate.scope || typeof gate.scope.unit !== 'string') {
|
|
58
|
+
throw gateError(gate, 'must declare scope.unit');
|
|
59
|
+
}
|
|
60
|
+
if (gate.phase === 'consumer-verify' && !['npm', 'claude-plugin', 'codex-plugin'].includes(gate.scope.distribution)) {
|
|
61
|
+
throw gateError(gate, 'consumer-verify must declare a supported scope.distribution');
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(gate.command) || gate.command.length === 0 || gate.command.some((value) => typeof value !== 'string')) {
|
|
64
|
+
throw gateError(gate, 'command must be a non-empty string array');
|
|
65
|
+
}
|
|
66
|
+
if (gate.cwd !== undefined && typeof gate.cwd !== 'string') {
|
|
67
|
+
throw gateError(gate, 'cwd must be a string');
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1 || gate.timeoutMs > 7_200_000) {
|
|
70
|
+
throw gateError(gate, 'timeoutMs must be an integer between 1 and 7200000');
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(gate.envAllowlist) || gate.envAllowlist.some((key) => typeof key !== 'string' || !ENV_KEY_PATTERN.test(key))) {
|
|
73
|
+
throw gateError(gate, 'envAllowlist must contain uppercase environment names');
|
|
74
|
+
}
|
|
75
|
+
if (gate.expectedJson !== undefined && (
|
|
76
|
+
!gate.expectedJson || typeof gate.expectedJson !== 'object' || Array.isArray(gate.expectedJson)
|
|
77
|
+
)) {
|
|
78
|
+
throw gateError(gate, 'expectedJson must be an object');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function filteredEnv(allowlist, suppliedEnv, fixedEnv = {}) {
|
|
83
|
+
const result = {};
|
|
84
|
+
for (const key of PLATFORM_ENV) {
|
|
85
|
+
if (process.env[key] !== undefined) result[key] = process.env[key];
|
|
86
|
+
}
|
|
87
|
+
for (const key of allowlist) {
|
|
88
|
+
if (suppliedEnv?.[key] !== undefined) result[key] = String(suppliedEnv[key]);
|
|
89
|
+
}
|
|
90
|
+
for (const [key, value] of Object.entries(fixedEnv)) {
|
|
91
|
+
if (value !== undefined) result[key] = String(value);
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveSafeCwd(executionRoot, cwd, gate) {
|
|
97
|
+
const rootStat = await lstat(executionRoot).catch((error) => {
|
|
98
|
+
throw gateError(gate, 'execution root is missing', { cause: error.code });
|
|
99
|
+
});
|
|
100
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
101
|
+
throw gateError(gate, 'execution root must be a real directory');
|
|
102
|
+
}
|
|
103
|
+
const rootReal = await realpath(executionRoot);
|
|
104
|
+
const lexical = resolve(rootReal, cwd ?? '.');
|
|
105
|
+
if (!isInside(rootReal, lexical)) throw gateError(gate, 'cwd escapes the execution root');
|
|
106
|
+
|
|
107
|
+
const rel = relative(rootReal, lexical);
|
|
108
|
+
let current = rootReal;
|
|
109
|
+
for (const segment of rel.split(/[\\/]/).filter(Boolean)) {
|
|
110
|
+
current = join(current, segment);
|
|
111
|
+
const stat = await lstat(current).catch((error) => {
|
|
112
|
+
throw gateError(gate, 'cwd does not exist', { cause: error.code });
|
|
113
|
+
});
|
|
114
|
+
if (stat.isSymbolicLink()) throw gateError(gate, 'cwd contains a symlink');
|
|
115
|
+
}
|
|
116
|
+
const physical = await realpath(lexical);
|
|
117
|
+
if (!isInside(rootReal, physical)) throw gateError(gate, 'cwd resolves outside the execution root');
|
|
118
|
+
return physical;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function outputSummary(value) {
|
|
122
|
+
const text = value ?? '';
|
|
123
|
+
return {
|
|
124
|
+
bytes: Buffer.byteLength(text),
|
|
125
|
+
sha256: createHash('sha256').update(text).digest('hex'),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function executeGateProcess(executable, args, options) {
|
|
130
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
131
|
+
const useProcessGroup = process.platform !== 'win32';
|
|
132
|
+
const child = spawn(executable, args, {
|
|
133
|
+
cwd: options.cwd,
|
|
134
|
+
env: options.env,
|
|
135
|
+
shell: false,
|
|
136
|
+
detached: useProcessGroup,
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
});
|
|
139
|
+
const stdoutChunks = [];
|
|
140
|
+
const stderrChunks = [];
|
|
141
|
+
let stdoutBytes = 0;
|
|
142
|
+
let stderrBytes = 0;
|
|
143
|
+
let terminationReason = null;
|
|
144
|
+
let hardKillTimer = null;
|
|
145
|
+
|
|
146
|
+
const signalTree = (signal) => {
|
|
147
|
+
try {
|
|
148
|
+
if (useProcessGroup && child.pid) process.kill(-child.pid, signal);
|
|
149
|
+
else child.kill(signal);
|
|
150
|
+
} catch {
|
|
151
|
+
// A process that already exited is handled by close below.
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const terminate = (reason) => {
|
|
155
|
+
if (terminationReason) return;
|
|
156
|
+
terminationReason = reason;
|
|
157
|
+
signalTree('SIGTERM');
|
|
158
|
+
hardKillTimer = setTimeout(() => signalTree('SIGKILL'), 500);
|
|
159
|
+
hardKillTimer.unref?.();
|
|
160
|
+
};
|
|
161
|
+
const append = (chunks, chunk, currentBytes) => {
|
|
162
|
+
const remaining = Math.max(0, OUTPUT_LIMIT_BYTES - currentBytes);
|
|
163
|
+
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
|
|
164
|
+
};
|
|
165
|
+
child.stdout.on('data', (chunk) => {
|
|
166
|
+
append(stdoutChunks, chunk, stdoutBytes);
|
|
167
|
+
stdoutBytes += chunk.length;
|
|
168
|
+
if (stdoutBytes > OUTPUT_LIMIT_BYTES) terminate('output-limit');
|
|
169
|
+
});
|
|
170
|
+
child.stderr.on('data', (chunk) => {
|
|
171
|
+
append(stderrChunks, chunk, stderrBytes);
|
|
172
|
+
stderrBytes += chunk.length;
|
|
173
|
+
if (stderrBytes > OUTPUT_LIMIT_BYTES) terminate('output-limit');
|
|
174
|
+
});
|
|
175
|
+
const timeoutTimer = setTimeout(() => terminate('timeout'), options.timeoutMs);
|
|
176
|
+
timeoutTimer.unref?.();
|
|
177
|
+
child.on('error', (error) => {
|
|
178
|
+
clearTimeout(timeoutTimer);
|
|
179
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
180
|
+
error.stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
181
|
+
error.stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
182
|
+
error.failureKind = terminationReason ?? 'spawn-error';
|
|
183
|
+
rejectPromise(error);
|
|
184
|
+
});
|
|
185
|
+
child.on('close', (code, signal) => {
|
|
186
|
+
clearTimeout(timeoutTimer);
|
|
187
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
188
|
+
const result = {
|
|
189
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
190
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
191
|
+
exitCode: code,
|
|
192
|
+
signal,
|
|
193
|
+
};
|
|
194
|
+
if (terminationReason || code !== 0) {
|
|
195
|
+
const error = new Error(terminationReason ?? `process exited with code ${code}`);
|
|
196
|
+
Object.assign(error, result, {
|
|
197
|
+
failureKind: terminationReason ?? 'non-zero-exit',
|
|
198
|
+
killed: Boolean(terminationReason),
|
|
199
|
+
});
|
|
200
|
+
rejectPromise(error);
|
|
201
|
+
} else {
|
|
202
|
+
resolvePromise(result);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function appendFailureEvidence({
|
|
209
|
+
evidence,
|
|
210
|
+
gate,
|
|
211
|
+
gateDigest,
|
|
212
|
+
inputDigest,
|
|
213
|
+
startedAt,
|
|
214
|
+
failureKind,
|
|
215
|
+
exitCode,
|
|
216
|
+
signal,
|
|
217
|
+
stdout,
|
|
218
|
+
stderr,
|
|
219
|
+
}) {
|
|
220
|
+
const stdoutInfo = outputSummary(stdout);
|
|
221
|
+
const stderrInfo = outputSummary(stderr);
|
|
222
|
+
await evidence?.append({
|
|
223
|
+
phase: gate.phase,
|
|
224
|
+
status: 'failed',
|
|
225
|
+
decision: 'fail-closed',
|
|
226
|
+
gateId: gate.id,
|
|
227
|
+
gateDigest,
|
|
228
|
+
inputDigest,
|
|
229
|
+
failureKind,
|
|
230
|
+
startedAt,
|
|
231
|
+
finishedAt: new Date().toISOString(),
|
|
232
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
233
|
+
signal: signal ?? null,
|
|
234
|
+
stdoutBytes: stdoutInfo.bytes,
|
|
235
|
+
stdoutSha256: stdoutInfo.sha256,
|
|
236
|
+
stderrBytes: stderrInfo.bytes,
|
|
237
|
+
stderrSha256: stderrInfo.sha256,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Execute one frozen gate definition against an isolated execution root. */
|
|
242
|
+
export async function runVerificationGate({
|
|
243
|
+
gate,
|
|
244
|
+
executionRoot,
|
|
245
|
+
evidence,
|
|
246
|
+
env = {},
|
|
247
|
+
fixedEnv = {},
|
|
248
|
+
inputDigest: expectedInputDigest,
|
|
249
|
+
}) {
|
|
250
|
+
validateGate(gate);
|
|
251
|
+
const cwd = await resolveSafeCwd(executionRoot, gate.cwd, gate);
|
|
252
|
+
const [executable, ...args] = gate.command;
|
|
253
|
+
const gateDigest = sha256Hex(canonicalJson(gate));
|
|
254
|
+
const observedInput = await computeFrozenSnapshot(executionRoot);
|
|
255
|
+
if (expectedInputDigest && observedInput.digest !== expectedInputDigest) {
|
|
256
|
+
throw gateError(gate, 'execution input changed before process start', {
|
|
257
|
+
expectedInputDigest,
|
|
258
|
+
observedInputDigest: observedInput.digest,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const inputDigest = observedInput.digest;
|
|
262
|
+
const startedAt = new Date().toISOString();
|
|
263
|
+
await evidence?.append({
|
|
264
|
+
phase: gate.phase,
|
|
265
|
+
status: 'started',
|
|
266
|
+
gateId: gate.id,
|
|
267
|
+
gateDigest,
|
|
268
|
+
inputDigest,
|
|
269
|
+
unitId: gate.scope.unit,
|
|
270
|
+
...(gate.scope.distribution ? { distribution: gate.scope.distribution } : {}),
|
|
271
|
+
executable,
|
|
272
|
+
args,
|
|
273
|
+
cwd: gate.cwd ?? '.',
|
|
274
|
+
timeoutMs: gate.timeoutMs,
|
|
275
|
+
envAllowlist: gate.envAllowlist,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let stdout = '';
|
|
279
|
+
let stderr = '';
|
|
280
|
+
try {
|
|
281
|
+
const result = await executeGateProcess(executable, args, {
|
|
282
|
+
cwd,
|
|
283
|
+
env: filteredEnv(gate.envAllowlist, env, fixedEnv),
|
|
284
|
+
timeoutMs: gate.timeoutMs,
|
|
285
|
+
});
|
|
286
|
+
stdout = result.stdout ?? '';
|
|
287
|
+
stderr = result.stderr ?? '';
|
|
288
|
+
} catch (error) {
|
|
289
|
+
stdout = error.stdout ?? '';
|
|
290
|
+
stderr = error.stderr ?? '';
|
|
291
|
+
const failureKind = error.failureKind ?? 'non-zero-exit';
|
|
292
|
+
await appendFailureEvidence({
|
|
293
|
+
evidence,
|
|
294
|
+
gate,
|
|
295
|
+
gateDigest,
|
|
296
|
+
inputDigest,
|
|
297
|
+
startedAt,
|
|
298
|
+
failureKind,
|
|
299
|
+
exitCode: error.exitCode,
|
|
300
|
+
signal: error.signal,
|
|
301
|
+
stdout,
|
|
302
|
+
stderr,
|
|
303
|
+
});
|
|
304
|
+
throw gateError(gate, `failed (${failureKind})`, { failureKind });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (gate.expectedJson !== undefined) {
|
|
308
|
+
let actual;
|
|
309
|
+
try {
|
|
310
|
+
actual = JSON.parse(stdout);
|
|
311
|
+
} catch {
|
|
312
|
+
await appendFailureEvidence({
|
|
313
|
+
evidence, gate, gateDigest, inputDigest, startedAt, failureKind: 'invalid-json', exitCode: 0, signal: null, stdout, stderr,
|
|
314
|
+
});
|
|
315
|
+
throw gateError(gate, 'returned invalid JSON', { failureKind: 'invalid-json' });
|
|
316
|
+
}
|
|
317
|
+
if (!matchesSubset(actual, gate.expectedJson)) {
|
|
318
|
+
await appendFailureEvidence({
|
|
319
|
+
evidence, gate, gateDigest, inputDigest, startedAt, failureKind: 'json-mismatch', exitCode: 0, signal: null, stdout, stderr,
|
|
320
|
+
});
|
|
321
|
+
throw gateError(gate, 'JSON output does not match expectedJson', { failureKind: 'json-mismatch' });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const stdoutInfo = outputSummary(stdout);
|
|
326
|
+
const stderrInfo = outputSummary(stderr);
|
|
327
|
+
const result = {
|
|
328
|
+
id: gate.id,
|
|
329
|
+
phase: gate.phase,
|
|
330
|
+
unitId: gate.scope.unit,
|
|
331
|
+
...(gate.scope.distribution ? { distribution: gate.scope.distribution } : {}),
|
|
332
|
+
gateDigest,
|
|
333
|
+
inputDigest,
|
|
334
|
+
status: 'passed',
|
|
335
|
+
startedAt,
|
|
336
|
+
finishedAt: new Date().toISOString(),
|
|
337
|
+
exitCode: 0,
|
|
338
|
+
stdoutBytes: stdoutInfo.bytes,
|
|
339
|
+
stdoutSha256: stdoutInfo.sha256,
|
|
340
|
+
stderrBytes: stderrInfo.bytes,
|
|
341
|
+
stderrSha256: stderrInfo.sha256,
|
|
342
|
+
};
|
|
343
|
+
await evidence?.append({ ...result, phase: gate.phase, status: 'completed', gateId: gate.id });
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function makeTreeWritable(root) {
|
|
348
|
+
const stat = await lstat(root);
|
|
349
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('gate copy root must be a real directory');
|
|
350
|
+
await chmod(root, stat.mode | 0o700);
|
|
351
|
+
for (const child of await readdir(root, { withFileTypes: true })) {
|
|
352
|
+
const absolute = join(root, child.name);
|
|
353
|
+
const childStat = await lstat(absolute);
|
|
354
|
+
if (childStat.isSymbolicLink()) throw new Error('gate copy must not contain symlinks');
|
|
355
|
+
if (childStat.isDirectory()) await makeTreeWritable(absolute);
|
|
356
|
+
else if (childStat.isFile() && childStat.nlink === 1) await chmod(absolute, childStat.mode | 0o600);
|
|
357
|
+
else throw new Error('gate copy must contain only single-link regular files');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Run snapshot gates on disposable writable copies and recheck the authority. */
|
|
362
|
+
export async function runSnapshotVerificationGates({
|
|
363
|
+
gates = [],
|
|
364
|
+
unitResults,
|
|
365
|
+
runDir,
|
|
366
|
+
evidence,
|
|
367
|
+
env = {},
|
|
368
|
+
copySnapshot = cp,
|
|
369
|
+
}) {
|
|
370
|
+
const snapshotGates = gates.filter((item) => item.phase === 'snapshot-verify');
|
|
371
|
+
if (snapshotGates.length === 0) return [];
|
|
372
|
+
const byUnit = new Map(unitResults.map((item) => [item.unit.id, item]));
|
|
373
|
+
const gateRoot = join(runDir, 'snapshot-gates');
|
|
374
|
+
await mkdir(gateRoot, { recursive: true });
|
|
375
|
+
const results = [];
|
|
376
|
+
|
|
377
|
+
for (const gate of snapshotGates) {
|
|
378
|
+
validateGate(gate);
|
|
379
|
+
const unitResult = byUnit.get(gate.scope.unit);
|
|
380
|
+
if (!unitResult) throw gateError(gate, 'references an unknown release unit');
|
|
381
|
+
const source = unitResult.manifest.outputDir;
|
|
382
|
+
const before = await computeFrozenSnapshot(source);
|
|
383
|
+
const copyDir = resolveUnitScopedPath(gateRoot, gate.id);
|
|
384
|
+
try {
|
|
385
|
+
await copySnapshot(source, copyDir, {
|
|
386
|
+
recursive: true,
|
|
387
|
+
dereference: false,
|
|
388
|
+
errorOnExist: true,
|
|
389
|
+
force: false,
|
|
390
|
+
});
|
|
391
|
+
const copied = await computeFrozenSnapshot(copyDir);
|
|
392
|
+
if (copied.digest !== before.digest) {
|
|
393
|
+
throw gateError(gate, 'copied execution input does not match the frozen snapshot authority', {
|
|
394
|
+
authorityDigest: before.digest,
|
|
395
|
+
copiedInputDigest: copied.digest,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
await makeTreeWritable(copyDir);
|
|
399
|
+
const executionInput = await computeFrozenSnapshot(copyDir);
|
|
400
|
+
if (executionInput.digest !== copied.digest) {
|
|
401
|
+
throw gateError(gate, 'execution input changed while making the disposable copy writable', {
|
|
402
|
+
copiedInputDigest: copied.digest,
|
|
403
|
+
executionInputDigest: executionInput.digest,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
results.push(await runVerificationGate({
|
|
407
|
+
gate,
|
|
408
|
+
executionRoot: copyDir,
|
|
409
|
+
evidence,
|
|
410
|
+
env,
|
|
411
|
+
fixedEnv: { HOME: copyDir },
|
|
412
|
+
inputDigest: executionInput.digest,
|
|
413
|
+
}));
|
|
414
|
+
const after = await computeFrozenSnapshot(source);
|
|
415
|
+
if (after.digest !== before.digest) {
|
|
416
|
+
throw gateError(gate, 'changed the frozen snapshot authority', {
|
|
417
|
+
beforeDigest: before.digest,
|
|
418
|
+
afterDigest: after.digest,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
await rm(copyDir, { recursive: true, force: true }).catch(() => {});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return results;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function selectConsumerVerificationGates(plan, unitId, distribution) {
|
|
429
|
+
return (plan.verificationGates ?? []).filter((gate) => (
|
|
430
|
+
gate.phase === 'consumer-verify' &&
|
|
431
|
+
gate.scope?.unit === unitId &&
|
|
432
|
+
gate.scope?.distribution === distribution
|
|
433
|
+
));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Run all exact unit/distribution consumer gates from the frozen plan. */
|
|
437
|
+
export async function runConsumerVerificationGates({
|
|
438
|
+
plan,
|
|
439
|
+
unitId,
|
|
440
|
+
distribution,
|
|
441
|
+
executionRoot,
|
|
442
|
+
evidence,
|
|
443
|
+
env = {},
|
|
444
|
+
fixedEnv = {},
|
|
445
|
+
}) {
|
|
446
|
+
const results = [];
|
|
447
|
+
for (const gate of selectConsumerVerificationGates(plan, unitId, distribution)) {
|
|
448
|
+
results.push(await runVerificationGate({ gate, executionRoot, evidence, env, fixedEnv }));
|
|
449
|
+
}
|
|
450
|
+
return results;
|
|
451
|
+
}
|