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
package/src/commands/verify.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath } from 'node:fs/promises';
|
|
16
|
-
import { join, relative, isAbsolute, resolve } from 'node:path';
|
|
16
|
+
import { dirname, join, relative, isAbsolute, resolve } from 'node:path';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { execFile as execFileCb } from 'node:child_process';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
writeRunAtomic,
|
|
31
31
|
computeRunDigest,
|
|
32
32
|
resolveDefaultRunDir,
|
|
33
|
+
createProductionRunDir,
|
|
33
34
|
} from '../core/run.mjs';
|
|
34
35
|
import {
|
|
35
36
|
assertImmutableApprovalAuthority,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
registryTokenKey,
|
|
49
50
|
resolveNpmRegistryAuthToken,
|
|
50
51
|
} from '../adapters/npm.mjs';
|
|
52
|
+
import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
|
|
51
53
|
|
|
52
54
|
// ---------------------------------------------------------------------------
|
|
53
55
|
// Constants
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
const ADAPTER_ACTION_TYPE_MAP = {
|
|
61
63
|
'push-commit': 'git-push',
|
|
62
64
|
'push-snapshot': 'push-snapshot',
|
|
65
|
+
'set-default-branch': 'set-default-branch',
|
|
63
66
|
'create-tag': 'git-tag',
|
|
64
67
|
'npm-publish': 'npm-publish',
|
|
65
68
|
'github-release': 'github-release',
|
|
@@ -170,10 +173,12 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
170
173
|
passed: true,
|
|
171
174
|
skipped: true,
|
|
172
175
|
details: { message: 'No npm distributions in plan; smoke test skipped' },
|
|
176
|
+
gateResults: [],
|
|
173
177
|
};
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
const results = [];
|
|
181
|
+
const gateResults = [];
|
|
177
182
|
|
|
178
183
|
for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
|
|
179
184
|
const packageAtVersion = `${pkgName}@${targetVersion}`;
|
|
@@ -237,6 +242,17 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
237
242
|
};
|
|
238
243
|
}
|
|
239
244
|
|
|
245
|
+
const pkgRoot = join(installDir, 'node_modules', pkgName);
|
|
246
|
+
gateResults.push(...await runConsumerVerificationGates({
|
|
247
|
+
plan,
|
|
248
|
+
unitId,
|
|
249
|
+
distribution: 'npm',
|
|
250
|
+
executionRoot: pkgRoot,
|
|
251
|
+
evidence: options.evidence,
|
|
252
|
+
env: options.gateEnv ?? process.env,
|
|
253
|
+
fixedEnv: { HOME: installDir },
|
|
254
|
+
}));
|
|
255
|
+
|
|
240
256
|
// If smokeBin is not configured, install + name/version check is sufficient
|
|
241
257
|
if (!smokeBin) {
|
|
242
258
|
results.push({
|
|
@@ -277,7 +293,6 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
// Verify bin path does not escape the installed package root
|
|
280
|
-
const pkgRoot = join(installDir, 'node_modules', pkgName);
|
|
281
296
|
const binPath = resolve(pkgRoot, binRelative);
|
|
282
297
|
const relBin = relative(pkgRoot, binPath);
|
|
283
298
|
const sep = process.platform === 'win32' ? '\\' : '/';
|
|
@@ -321,7 +336,17 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
321
336
|
const cliArgs = smokeArgs.length > 0 ? smokeArgs : [];
|
|
322
337
|
let binResult;
|
|
323
338
|
try {
|
|
324
|
-
binResult = await npmExec.runBin(binPath, cliArgs
|
|
339
|
+
binResult = await npmExec.runBin(binPath, cliArgs, {
|
|
340
|
+
cwd: pkgRoot,
|
|
341
|
+
env: {
|
|
342
|
+
HOME: installDir,
|
|
343
|
+
TMPDIR: installDir,
|
|
344
|
+
TEMP: installDir,
|
|
345
|
+
TMP: installDir,
|
|
346
|
+
PATH: dirname(process.execPath),
|
|
347
|
+
CI: '1',
|
|
348
|
+
},
|
|
349
|
+
});
|
|
325
350
|
} catch (binErr) {
|
|
326
351
|
return {
|
|
327
352
|
passed: false,
|
|
@@ -403,6 +428,7 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
403
428
|
distributions: results,
|
|
404
429
|
count: results.length,
|
|
405
430
|
},
|
|
431
|
+
gateResults,
|
|
406
432
|
};
|
|
407
433
|
} finally {
|
|
408
434
|
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
@@ -451,10 +477,14 @@ const defaultNpmExecutor = {
|
|
|
451
477
|
return { success: true };
|
|
452
478
|
} catch (err) {
|
|
453
479
|
return { success: false, error: err.message };
|
|
480
|
+
} finally {
|
|
481
|
+
await rm(userConfig, { force: true }).catch(() => {});
|
|
454
482
|
}
|
|
455
483
|
},
|
|
456
|
-
async runBin(binPath, args = []) {
|
|
484
|
+
async runBin(binPath, args = [], options = {}) {
|
|
457
485
|
return execFile(process.execPath, [binPath, ...args], {
|
|
486
|
+
cwd: options.cwd,
|
|
487
|
+
env: options.env,
|
|
458
488
|
shell: false,
|
|
459
489
|
encoding: 'utf8',
|
|
460
490
|
timeout: 30_000,
|
|
@@ -491,6 +521,8 @@ export async function verifyRelease(options) {
|
|
|
491
521
|
runDir: runDirOpt,
|
|
492
522
|
clock: clockOpt,
|
|
493
523
|
npmExecutor,
|
|
524
|
+
verificationGatesAuthorized,
|
|
525
|
+
gateEnv,
|
|
494
526
|
} = options ?? {};
|
|
495
527
|
|
|
496
528
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -504,10 +536,39 @@ export async function verifyRelease(options) {
|
|
|
504
536
|
);
|
|
505
537
|
}
|
|
506
538
|
|
|
539
|
+
// Load and validate the plan before creating any evidence directory. A
|
|
540
|
+
// production plan grants authority only to a fresh direct child of its
|
|
541
|
+
// sibling .release-skill/runs directory.
|
|
542
|
+
let planRaw;
|
|
543
|
+
try {
|
|
544
|
+
planRaw = await readFile(planPath, 'utf8');
|
|
545
|
+
} catch (err) {
|
|
546
|
+
throw new ReleaseError(
|
|
547
|
+
GATE_FAILED,
|
|
548
|
+
`cannot read release plan: ${err.message}`,
|
|
549
|
+
{ planPath, cause: err.code },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let plan;
|
|
554
|
+
try {
|
|
555
|
+
plan = JSON.parse(planRaw);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
throw new ReleaseError(
|
|
558
|
+
GATE_FAILED,
|
|
559
|
+
`release plan is not valid JSON: ${err.message}`,
|
|
560
|
+
{ planPath },
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
validatePlan(plan);
|
|
564
|
+
|
|
507
565
|
// --- Set up directories ---
|
|
508
566
|
const runId = `verify-${Date.now()}`;
|
|
509
|
-
const
|
|
510
|
-
|
|
567
|
+
const requestedRunDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
|
|
568
|
+
const runDir = plan.production
|
|
569
|
+
? await createProductionRunDir(requestedRunDir, planPath)
|
|
570
|
+
: requestedRunDir;
|
|
571
|
+
if (!plan.production) await mkdir(runDir, { recursive: true });
|
|
511
572
|
|
|
512
573
|
const evidence = createEvidenceWriter({ runDir, command: 'verify', clock: clockFn });
|
|
513
574
|
|
|
@@ -517,30 +578,23 @@ export async function verifyRelease(options) {
|
|
|
517
578
|
// =======================================================================
|
|
518
579
|
await evidence.append({ phase: 'verify', step: 'plan-load', status: 'started' });
|
|
519
580
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
{ planPath, cause: err.code },
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
let plan;
|
|
532
|
-
try {
|
|
533
|
-
plan = JSON.parse(planRaw);
|
|
534
|
-
} catch (err) {
|
|
581
|
+
const consumerGates = (plan.verificationGates ?? []).filter((gate) => gate.phase === 'consumer-verify');
|
|
582
|
+
const configuredSmokeBins = (plan.units ?? []).flatMap((unit) => (
|
|
583
|
+
(unit.distributions ?? [])
|
|
584
|
+
.filter((distribution) => distribution.type === 'npm' && distribution.smokeBin)
|
|
585
|
+
.map((distribution) => ({ unitId: unit.id, smokeBin: distribution.smokeBin }))
|
|
586
|
+
));
|
|
587
|
+
if ((consumerGates.length > 0 || configuredSmokeBins.length > 0) && verificationGatesAuthorized !== true) {
|
|
535
588
|
throw new ReleaseError(
|
|
536
589
|
GATE_FAILED,
|
|
537
|
-
`
|
|
538
|
-
{
|
|
590
|
+
`plan declares ${consumerGates.length} consumer verification gate(s) and ` +
|
|
591
|
+
`${configuredSmokeBins.length} npm CLI smoke process(es). ` +
|
|
592
|
+
'They execute installed project code without an OS or network sandbox. ' +
|
|
593
|
+
'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
|
|
594
|
+
{ gateIds: consumerGates.map((gate) => gate.id), configuredSmokeBins },
|
|
539
595
|
);
|
|
540
596
|
}
|
|
541
597
|
|
|
542
|
-
validatePlan(plan);
|
|
543
|
-
|
|
544
598
|
await evidence.append({ phase: 'verify', step: 'plan-load', status: 'passed' });
|
|
545
599
|
|
|
546
600
|
// =======================================================================
|
|
@@ -659,6 +713,7 @@ export async function verifyRelease(options) {
|
|
|
659
713
|
await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'started' });
|
|
660
714
|
|
|
661
715
|
const adapterChecks = [];
|
|
716
|
+
const consumerGateResults = [];
|
|
662
717
|
const actions = plan.externalActions ?? [];
|
|
663
718
|
const MARKETPLACE_TYPES = new Set([
|
|
664
719
|
'claude-marketplace-install',
|
|
@@ -770,6 +825,28 @@ export async function verifyRelease(options) {
|
|
|
770
825
|
{ actionId: action.id, observation: verifyResult.observation },
|
|
771
826
|
);
|
|
772
827
|
}
|
|
828
|
+
|
|
829
|
+
const distribution = action.type === 'claude-marketplace-install'
|
|
830
|
+
? 'claude-plugin'
|
|
831
|
+
: 'codex-plugin';
|
|
832
|
+
const installPath = verifyResult.observation?.installPath;
|
|
833
|
+
consumerGateResults.push(...await runConsumerVerificationGates({
|
|
834
|
+
plan,
|
|
835
|
+
unitId: action.unitId,
|
|
836
|
+
distribution,
|
|
837
|
+
executionRoot: installPath,
|
|
838
|
+
evidence,
|
|
839
|
+
env: gateEnv ?? process.env,
|
|
840
|
+
fixedEnv: action.type === 'claude-marketplace-install'
|
|
841
|
+
? {
|
|
842
|
+
HOME: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`),
|
|
843
|
+
CLAUDE_CONFIG_DIR: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`, '.claude'),
|
|
844
|
+
}
|
|
845
|
+
: {
|
|
846
|
+
HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
|
|
847
|
+
CODEX_HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
|
|
848
|
+
},
|
|
849
|
+
}));
|
|
773
850
|
} else {
|
|
774
851
|
// --- Non-marketplace: read-only adapter.verify() ---
|
|
775
852
|
const context = {
|
|
@@ -825,7 +902,12 @@ export async function verifyRelease(options) {
|
|
|
825
902
|
|
|
826
903
|
let smokeTest;
|
|
827
904
|
try {
|
|
828
|
-
smokeTest = await runSmokeTest(plan, root, {
|
|
905
|
+
smokeTest = await runSmokeTest(plan, root, {
|
|
906
|
+
npmExecutor,
|
|
907
|
+
baseDir: runDir,
|
|
908
|
+
evidence,
|
|
909
|
+
gateEnv: gateEnv ?? process.env,
|
|
910
|
+
});
|
|
829
911
|
} catch (err) {
|
|
830
912
|
smokeTest = { passed: false, details: { error: err.message } };
|
|
831
913
|
}
|
|
@@ -845,6 +927,17 @@ export async function verifyRelease(options) {
|
|
|
845
927
|
);
|
|
846
928
|
}
|
|
847
929
|
|
|
930
|
+
consumerGateResults.push(...(smokeTest.gateResults ?? []));
|
|
931
|
+
const expectedGateIds = consumerGates.map((gate) => gate.id).sort();
|
|
932
|
+
const observedGateIds = consumerGateResults.map((result) => result.id).sort();
|
|
933
|
+
if (JSON.stringify(expectedGateIds) !== JSON.stringify(observedGateIds)) {
|
|
934
|
+
throw new ReleaseError(
|
|
935
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
936
|
+
'consumer verification gate execution set does not match the frozen plan',
|
|
937
|
+
{ expectedGateIds, observedGateIds },
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
848
941
|
// =======================================================================
|
|
849
942
|
// All verifications passed — write verify run
|
|
850
943
|
// =======================================================================
|
|
@@ -875,6 +968,7 @@ export async function verifyRelease(options) {
|
|
|
875
968
|
status: check?.status === 'SKIPPED' ? 'skipped' : 'succeeded',
|
|
876
969
|
};
|
|
877
970
|
}),
|
|
971
|
+
gateResults: consumerGateResults,
|
|
878
972
|
startedAt: clockFn(),
|
|
879
973
|
finishedAt: clockFn(),
|
|
880
974
|
};
|
|
@@ -888,6 +982,7 @@ export async function verifyRelease(options) {
|
|
|
888
982
|
runDigest: persistedVerifyRun.runDigest,
|
|
889
983
|
adapterCheckCount: adapterChecks.length,
|
|
890
984
|
smokeTestPassed: true,
|
|
985
|
+
consumerGateCount: consumerGateResults.length,
|
|
891
986
|
completedAt: clockFn(),
|
|
892
987
|
});
|
|
893
988
|
|
|
@@ -896,6 +991,7 @@ export async function verifyRelease(options) {
|
|
|
896
991
|
status: VERIFIED,
|
|
897
992
|
adapterChecks,
|
|
898
993
|
smokeTest,
|
|
994
|
+
gateResults: consumerGateResults,
|
|
899
995
|
};
|
|
900
996
|
} catch (err) {
|
|
901
997
|
await evidence.append({
|
package/src/core/approval.mjs
CHANGED
|
@@ -19,16 +19,14 @@ import { computePlanDigest } from './plan.mjs';
|
|
|
19
19
|
import { sha256Hex } from './digest.mjs';
|
|
20
20
|
import { readFile } from 'node:fs/promises';
|
|
21
21
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
22
|
import Ajv from 'ajv';
|
|
24
23
|
import addFormats from 'ajv-formats';
|
|
25
24
|
import { WORKSPACE_DIGEST_ALGORITHM } from './baseline.mjs';
|
|
25
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'utf8',
|
|
31
|
-
));
|
|
27
|
+
const approvalSchema = JSON.parse((await readTrustedPackageResource(
|
|
28
|
+
'schemas/approval-record.schema.json',
|
|
29
|
+
)).toString('utf8'));
|
|
32
30
|
const approvalAjv = new Ajv({ allErrors: true, strict: false });
|
|
33
31
|
addFormats(approvalAjv);
|
|
34
32
|
const validateApprovalSchema = approvalAjv.compile(approvalSchema);
|
package/src/core/config.mjs
CHANGED
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFile } from 'node:fs/promises';
|
|
20
|
-
import { resolve, isAbsolute, relative, normalize
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { resolve, isAbsolute, relative, normalize } from 'node:path';
|
|
22
21
|
import YAML, { Alias } from 'yaml';
|
|
23
22
|
import Ajv from 'ajv';
|
|
24
23
|
import addFormats from 'ajv-formats';
|
|
@@ -26,6 +25,7 @@ import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
|
26
25
|
import { ReleaseError, CONFIG_INVALID } from './errors.mjs';
|
|
27
26
|
import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
|
|
28
27
|
import { isReservedReleaseControlPath } from './baseline.mjs';
|
|
28
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Constants
|
|
@@ -38,19 +38,19 @@ const DEFAULT_CONFIG_REL = '.release-skill/project.yaml';
|
|
|
38
38
|
// Load the formal release-project JSON Schema (single source of truth)
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
40
|
|
|
41
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
42
|
-
const FORMAL_SCHEMA_PATH = resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json');
|
|
43
|
-
|
|
44
41
|
let RELEASE_PROJECT_SCHEMA;
|
|
45
42
|
try {
|
|
46
|
-
const schemaRaw = await
|
|
43
|
+
const schemaRaw = (await readTrustedPackageResource(
|
|
44
|
+
'schemas/release-project.schema.json',
|
|
45
|
+
)).toString('utf8');
|
|
47
46
|
RELEASE_PROJECT_SCHEMA = JSON.parse(schemaRaw);
|
|
48
47
|
} catch (err) {
|
|
48
|
+
if (err instanceof ReleaseError) throw err;
|
|
49
49
|
// Fail closed: if the formal schema cannot be loaded, refuse to operate.
|
|
50
50
|
throw new ReleaseError(
|
|
51
51
|
CONFIG_INVALID,
|
|
52
|
-
|
|
53
|
-
{
|
|
52
|
+
'cannot parse formal release-project schema',
|
|
53
|
+
{ resource: 'schemas/release-project.schema.json', cause: err.code ?? 'PARSE_FAILED' },
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -437,6 +437,40 @@ export async function loadProjectConfig({ root, configPath } = {}) {
|
|
|
437
437
|
}
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// Verification gates are deliberately cross-referenced here instead of
|
|
441
|
+
// inferred at execution time. A gate must name one existing unit and, for
|
|
442
|
+
// consumer verification, one distribution that the unit actually ships.
|
|
443
|
+
const unitsById = new Map(config.releaseUnits.map((unit) => [unit.id, unit]));
|
|
444
|
+
const gateIds = new Set();
|
|
445
|
+
for (const gate of config.verificationGates ?? []) {
|
|
446
|
+
if (gateIds.has(gate.id)) {
|
|
447
|
+
throw new ReleaseError(
|
|
448
|
+
CONFIG_INVALID,
|
|
449
|
+
`duplicate verification gate id: "${gate.id}"`,
|
|
450
|
+
{ gateId: gate.id },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
gateIds.add(gate.id);
|
|
454
|
+
const unit = unitsById.get(gate.scope.unit);
|
|
455
|
+
if (!unit) {
|
|
456
|
+
throw new ReleaseError(
|
|
457
|
+
CONFIG_INVALID,
|
|
458
|
+
`verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
|
|
459
|
+
{ gateId: gate.id, unitId: gate.scope.unit },
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (
|
|
463
|
+
gate.phase === 'consumer-verify' &&
|
|
464
|
+
!(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
|
|
465
|
+
) {
|
|
466
|
+
throw new ReleaseError(
|
|
467
|
+
CONFIG_INVALID,
|
|
468
|
+
`verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
|
|
469
|
+
{ gateId: gate.id, unitId: gate.scope.unit, distribution: gate.scope.distribution },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
440
474
|
// --- Compute deterministic digest ---
|
|
441
475
|
const configDigest = sha256Hex(canonicalJson(config));
|
|
442
476
|
|
package/src/core/errors.mjs
CHANGED
|
@@ -33,6 +33,8 @@ const EXIT_CODE_MAP = Object.freeze({
|
|
|
33
33
|
SENSITIVE_CONFLICT: 37,
|
|
34
34
|
TRANSACTION_INCOMPLETE: 38,
|
|
35
35
|
SAFE_WRITE_UNAVAILABLE: 39,
|
|
36
|
+
SETUP_DIGEST_MISMATCH: 40,
|
|
37
|
+
CONFIG_EXISTS: 41,
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
// ---- Error code constants ----
|
|
@@ -67,6 +69,8 @@ export const PLAN_STALE = 'PLAN_STALE';
|
|
|
67
69
|
export const SENSITIVE_CONFLICT = 'SENSITIVE_CONFLICT';
|
|
68
70
|
export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
|
|
69
71
|
export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
|
|
72
|
+
export const SETUP_DIGEST_MISMATCH = 'SETUP_DIGEST_MISMATCH';
|
|
73
|
+
export const CONFIG_EXISTS = 'CONFIG_EXISTS';
|
|
70
74
|
|
|
71
75
|
/**
|
|
72
76
|
* Typed error for release-skill operations.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package root resolution for bundled and unbundled execution.
|
|
3
|
+
*
|
|
4
|
+
* In development (unbundled) mode, `import.meta.url` points to the source
|
|
5
|
+
* file under `src/core/`, so `../..` reaches the package root.
|
|
6
|
+
*
|
|
7
|
+
* In bundled mode, the esbuild banner defines `__bundlePkgRoot` before the
|
|
8
|
+
* module body executes, derived deterministically from the bundle's own
|
|
9
|
+
* file path. The env-var override path has been removed to prevent
|
|
10
|
+
* callers from hijacking schema/native resolution.
|
|
11
|
+
*
|
|
12
|
+
* @module core/pkg-root
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
// In bundled mode, __bundlePkgRoot is set by the esbuild banner.
|
|
19
|
+
// In development mode, resolve from this source file's location.
|
|
20
|
+
export const PKG_ROOT = typeof __bundlePkgRoot !== 'undefined'
|
|
21
|
+
? __bundlePkgRoot
|
|
22
|
+
: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
package/src/core/plan.mjs
CHANGED
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
|
|
19
19
|
import { basename, dirname, join, resolve, parse, sep } from 'node:path';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import Ajv from 'ajv';
|
|
22
21
|
import addFormats from 'ajv-formats';
|
|
23
22
|
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
24
23
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
24
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Schema loaded from the authoritative JSON file (single source of truth)
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const RELEASE_PLAN_SCHEMA = JSON.parse((await readTrustedPackageResource(
|
|
31
|
+
'schemas/release-plan.schema.json',
|
|
32
|
+
)).toString('utf8'));
|
|
33
33
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
// Schema validator (compiled once at module init)
|
|
@@ -132,6 +132,31 @@ export function validatePlan(plan) {
|
|
|
132
132
|
{ validationErrors: errors },
|
|
133
133
|
);
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
const unitsById = new Map((plan.units ?? []).map((unit) => [unit.id, unit]));
|
|
137
|
+
const gateIds = new Set();
|
|
138
|
+
for (const gate of plan.verificationGates ?? []) {
|
|
139
|
+
if (gateIds.has(gate.id)) {
|
|
140
|
+
throw new ReleaseError(GATE_FAILED, `duplicate verification gate id: "${gate.id}"`);
|
|
141
|
+
}
|
|
142
|
+
gateIds.add(gate.id);
|
|
143
|
+
const unit = unitsById.get(gate.scope.unit);
|
|
144
|
+
if (!unit) {
|
|
145
|
+
throw new ReleaseError(
|
|
146
|
+
GATE_FAILED,
|
|
147
|
+
`verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
gate.phase === 'consumer-verify' &&
|
|
152
|
+
!(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
|
|
153
|
+
) {
|
|
154
|
+
throw new ReleaseError(
|
|
155
|
+
GATE_FAILED,
|
|
156
|
+
`verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
/**
|
|
@@ -260,6 +285,7 @@ const EXPECTED_ADAPTER = {
|
|
|
260
285
|
'npm-publish': 'npm',
|
|
261
286
|
'claude-marketplace-install': 'plugin-marketplace',
|
|
262
287
|
'codex-marketplace-install': 'plugin-marketplace',
|
|
288
|
+
'set-default-branch': 'git-github',
|
|
263
289
|
};
|
|
264
290
|
|
|
265
291
|
/** Required action types for every unit. */
|
|
@@ -317,6 +343,7 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
317
343
|
const production = plan.production?.mode === 'github-npm-v1';
|
|
318
344
|
const frozen = unit.frozenSnapshot;
|
|
319
345
|
const productionConfig = unit.productionConfig ?? {};
|
|
346
|
+
const branchStrategy = productionConfig.branchStrategy;
|
|
320
347
|
const expectedTag = tagTemplate
|
|
321
348
|
? tagTemplate.replace('{version}', targetVersion ?? '')
|
|
322
349
|
: null;
|
|
@@ -346,6 +373,9 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
346
373
|
failures.push(`unit "${unitId}" is missing frozenSnapshot for production publish`);
|
|
347
374
|
}
|
|
348
375
|
if (production && frozen) {
|
|
376
|
+
if (!['create-release-branch', 'advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
377
|
+
failures.push(`unit "${unitId}" productionConfig.branchStrategy is missing or invalid`);
|
|
378
|
+
}
|
|
349
379
|
const expectedBranch = (productionConfig.branchTemplate ?? 'release/{tag}')
|
|
350
380
|
.replaceAll('{tag}', expectedTag ?? '')
|
|
351
381
|
.replaceAll('{version}', targetVersion ?? '')
|
|
@@ -353,6 +383,37 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
353
383
|
if (frozen.branch !== expectedBranch) {
|
|
354
384
|
failures.push(`unit "${unitId}" frozen branch does not match productionConfig.branchTemplate`);
|
|
355
385
|
}
|
|
386
|
+
if (frozen.branchStrategy !== branchStrategy) {
|
|
387
|
+
failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
|
|
388
|
+
}
|
|
389
|
+
if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
|
|
390
|
+
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
|
|
391
|
+
} else if (plan.createdAt !== frozen.commitTimestamp) {
|
|
392
|
+
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
|
|
393
|
+
}
|
|
394
|
+
if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
395
|
+
if (unit.previousPublicBaseline?.mode !== 'bound') {
|
|
396
|
+
failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
|
|
397
|
+
}
|
|
398
|
+
if (!frozen.parentCommit || frozen.parentCommit !== unit.previousPublicBaseline?.commit) {
|
|
399
|
+
failures.push(`unit "${unitId}" frozenSnapshot.parentCommit does not match previous public baseline commit`);
|
|
400
|
+
}
|
|
401
|
+
} else if (frozen.parentCommit) {
|
|
402
|
+
failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
branchStrategy === 'advance-existing-branch' &&
|
|
406
|
+
unit.previousPublicBaseline?.ref !== `refs/heads/${expectedBranch}`
|
|
407
|
+
) {
|
|
408
|
+
failures.push(`unit "${unitId}" advance-existing-branch baseline ref does not match the target branch`);
|
|
409
|
+
}
|
|
410
|
+
if (branchStrategy === 'initialize-default-branch') {
|
|
411
|
+
if (productionConfig.setAsDefaultBranch !== true || !productionConfig.expectedCurrentDefaultBranch) {
|
|
412
|
+
failures.push(`unit "${unitId}" initialize-default-branch requires explicit default branch settings`);
|
|
413
|
+
}
|
|
414
|
+
} else if (productionConfig.setAsDefaultBranch === true) {
|
|
415
|
+
failures.push(`unit "${unitId}" setAsDefaultBranch is only valid for initialize-default-branch`);
|
|
416
|
+
}
|
|
356
417
|
}
|
|
357
418
|
|
|
358
419
|
// Required actions for this unit (always required)
|
|
@@ -411,10 +472,19 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
411
472
|
_checkRequired(action, 'parameters.branch', action.parameters?.branch, frozen?.branch, unitId, failures);
|
|
412
473
|
_checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
|
|
413
474
|
_checkRequired(action, 'parameters.tree', action.parameters?.tree, frozen?.tree, unitId, failures);
|
|
475
|
+
_checkRequired(action, 'parameters.branchStrategy', action.parameters?.branchStrategy, branchStrategy, unitId, failures);
|
|
414
476
|
_checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
|
|
415
477
|
_checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
|
|
416
478
|
_checkRequired(action, 'expected.tree', action.expected?.tree, frozen?.tree, unitId, failures);
|
|
417
479
|
_checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
480
|
+
if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
481
|
+
_checkRequired(action, 'parameters.parentCommit', action.parameters?.parentCommit, frozen?.parentCommit, unitId, failures);
|
|
482
|
+
}
|
|
483
|
+
if (branchStrategy === 'advance-existing-branch') {
|
|
484
|
+
_checkRequired(action, 'parameters.expectedBaselineCommit', action.parameters?.expectedBaselineCommit, frozen?.parentCommit, unitId, failures);
|
|
485
|
+
} else if (action.parameters?.expectedBaselineCommit !== undefined) {
|
|
486
|
+
failures.push(`unit "${unitId}", action "${action.id}": unexpected parameters.expectedBaselineCommit`);
|
|
487
|
+
}
|
|
418
488
|
} else {
|
|
419
489
|
_checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
|
|
420
490
|
}
|
|
@@ -692,6 +762,64 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
692
762
|
}
|
|
693
763
|
}
|
|
694
764
|
}
|
|
765
|
+
|
|
766
|
+
// set-default-branch: only required when productionConfig.setAsDefaultBranch is true
|
|
767
|
+
if (branchStrategy === 'initialize-default-branch') {
|
|
768
|
+
const oldBranch = productionConfig.expectedCurrentDefaultBranch;
|
|
769
|
+
const newBranch = frozen?.branch;
|
|
770
|
+
expectedCount++;
|
|
771
|
+
const expectedActionId = `set-default-branch-${unitId}`;
|
|
772
|
+
const branchActions = actions.filter(
|
|
773
|
+
(a) => a.unitId === unitId && a.type === 'set-default-branch',
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
if (branchActions.length === 0) {
|
|
777
|
+
failures.push(
|
|
778
|
+
`unit "${unitId}": productionConfig.setAsDefaultBranch is true but "set-default-branch" action is missing`,
|
|
779
|
+
);
|
|
780
|
+
} else if (branchActions.length > 1) {
|
|
781
|
+
failures.push(
|
|
782
|
+
`unit "${unitId}": duplicate set-default-branch actions (${branchActions.length} found, expected 1)`,
|
|
783
|
+
);
|
|
784
|
+
} else {
|
|
785
|
+
const action = branchActions[0];
|
|
786
|
+
|
|
787
|
+
if (action.id !== expectedActionId) {
|
|
788
|
+
failures.push(
|
|
789
|
+
`unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
if (action.unitId !== unitId) {
|
|
793
|
+
failures.push(
|
|
794
|
+
`unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (action.adapter !== 'git-github') {
|
|
798
|
+
failures.push(
|
|
799
|
+
`unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "git-github"`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
if (action.status !== 'PENDING') {
|
|
803
|
+
failures.push(
|
|
804
|
+
`unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Required parameters
|
|
809
|
+
_checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
|
|
810
|
+
_checkRequired(action, 'parameters.oldBranch', action.parameters?.oldBranch, oldBranch, unitId, failures);
|
|
811
|
+
_checkRequired(action, 'parameters.newBranch', action.parameters?.newBranch, newBranch, unitId, failures);
|
|
812
|
+
_checkRequired(action, 'parameters.expectedNewBranchCommit', action.parameters?.expectedNewBranchCommit, frozen?.commit, unitId, failures);
|
|
813
|
+
_checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
|
|
814
|
+
|
|
815
|
+
// Required expected
|
|
816
|
+
_checkRequired(action, 'expected.defaultBranch', action.expected?.defaultBranch, newBranch, unitId, failures);
|
|
817
|
+
_checkRequired(action, 'expected.newBranchCommit', action.expected?.newBranchCommit, frozen?.commit, unitId, failures);
|
|
818
|
+
if (Object.keys(action.expected ?? {}).some((key) => !['defaultBranch', 'newBranchCommit'].includes(key))) {
|
|
819
|
+
failures.push(`unit "${unitId}", action "${action.id}": expected may only contain defaultBranch and newBranchCommit`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
695
823
|
}
|
|
696
824
|
|
|
697
825
|
// --- Strict count equality: actual must exactly match expected ---
|