release-skill 0.1.1 → 0.1.3
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 +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/INSTALL.md +179 -5
- package/INSTALL.zh-CN.md +320 -0
- package/README.md +347 -67
- package/README.zh-CN.md +318 -59
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/skills/release-help/SKILL.md +7 -4
- package/adapters/claude/skills/release-prepare/SKILL.md +11 -1
- package/adapters/claude/skills/release-publish/SKILL.md +6 -3
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/claude/skills/release-setup/SKILL.md +111 -0
- package/adapters/claude/skills/release-verify/SKILL.md +5 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/skills/release-help/SKILL.md +7 -4
- package/adapters/codex/skills/release-prepare/SKILL.md +11 -1
- package/adapters/codex/skills/release-publish/SKILL.md +6 -3
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/skills/release-setup/SKILL.md +111 -0
- package/adapters/codex/skills/release-verify/SKILL.md +5 -2
- package/bin/release-skill.mjs +65 -9
- 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 +3 -1
- package/references/02-project-config.md +54 -3
- package/references/05-evidence-and-errors.md +6 -2
- package/schemas/release-plan.schema.json +550 -65
- package/schemas/release-project.schema.json +398 -29
- package/schemas/release-run.schema.json +165 -18
- package/skills/release-help/SKILL.md +7 -4
- package/skills/release-prepare/SKILL.md +11 -1
- package/skills/release-publish/SKILL.md +6 -3
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills/release-setup/SKILL.md +111 -0
- package/skills/release-verify/SKILL.md +5 -2
- package/skills-src/release-help/SKILL.md +7 -4
- package/skills-src/release-prepare/SKILL.md +11 -1
- package/skills-src/release-publish/SKILL.md +6 -3
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-setup/SKILL.md +111 -0
- package/skills-src/release-verify/SKILL.md +5 -2
- package/src/adapters/contract.mjs +3 -0
- package/src/adapters/git-github.mjs +84 -2
- package/src/adapters/plugin-marketplace.mjs +65 -21
- package/src/adapters/push-snapshot.mjs +84 -17
- package/src/commands/prepare.mjs +223 -20
- package/src/commands/publish.mjs +45 -0
- package/src/commands/reconcile.mjs +152 -0
- package/src/commands/setup.mjs +886 -0
- package/src/commands/verify.mjs +122 -26
- package/src/core/config.mjs +34 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/plan.mjs +123 -0
- package/src/core/previous-public-baseline.mjs +21 -1
- package/src/core/verification-gates.mjs +451 -0
- package/src/snapshot/frozen.mjs +89 -5
package/src/commands/prepare.mjs
CHANGED
|
@@ -25,6 +25,7 @@ const execFile = promisify(execFileCb);
|
|
|
25
25
|
import { loadProjectConfig } from '../core/config.mjs';
|
|
26
26
|
import { captureBaseline } from '../core/baseline.mjs';
|
|
27
27
|
import { runHook } from '../core/hooks.mjs';
|
|
28
|
+
import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
|
|
28
29
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
29
30
|
import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
30
31
|
import { sha256Hex } from '../core/digest.mjs';
|
|
@@ -483,7 +484,37 @@ async function processSnapshots(config, root, evidence, runDir, production = fal
|
|
|
483
484
|
return { unitResults, snapshotDigests };
|
|
484
485
|
}
|
|
485
486
|
|
|
486
|
-
|
|
487
|
+
function resolveProductionBranch(unit, version) {
|
|
488
|
+
const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
|
|
489
|
+
const tag = tagTemplate.replace('{version}', version);
|
|
490
|
+
const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
|
|
491
|
+
return {
|
|
492
|
+
tag,
|
|
493
|
+
branch: branchTemplate
|
|
494
|
+
.replaceAll('{tag}', tag)
|
|
495
|
+
.replaceAll('{version}', version)
|
|
496
|
+
.replaceAll('{unit}', unit.id),
|
|
497
|
+
branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function normalizedProductionConfig(unit) {
|
|
502
|
+
return {
|
|
503
|
+
...(unit.production ?? {}),
|
|
504
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
505
|
+
branchTemplate: unit.production?.branchTemplate ?? 'release/{tag}',
|
|
506
|
+
branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function buildProductionAssets(
|
|
511
|
+
unitResults,
|
|
512
|
+
resolvedVersions,
|
|
513
|
+
root,
|
|
514
|
+
runDir,
|
|
515
|
+
unitBaselineResults,
|
|
516
|
+
buildGitRepository = buildFrozenGitRepository,
|
|
517
|
+
) {
|
|
487
518
|
for (const { unit } of unitResults) {
|
|
488
519
|
const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
|
|
489
520
|
if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
|
|
@@ -497,13 +528,7 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
497
528
|
for (let index = 0; index < unitResults.length; index += 1) {
|
|
498
529
|
const { unit, manifest } = unitResults[index];
|
|
499
530
|
const version = resolvedVersions[index];
|
|
500
|
-
const
|
|
501
|
-
const tag = tagTemplate.replace('{version}', version);
|
|
502
|
-
const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
|
|
503
|
-
const branch = branchTemplate
|
|
504
|
-
.replaceAll('{tag}', tag)
|
|
505
|
-
.replaceAll('{version}', version)
|
|
506
|
-
.replaceAll('{unit}', unit.id);
|
|
531
|
+
const { tag, branch, branchStrategy } = resolveProductionBranch(unit, version);
|
|
507
532
|
const snapshotPath = relative(root, manifest.outputDir);
|
|
508
533
|
const observed = await computeFrozenSnapshot(manifest.outputDir);
|
|
509
534
|
if (observed.digest !== manifest.snapshotDigest) {
|
|
@@ -521,11 +546,21 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
521
546
|
const sealed = await computeFrozenSnapshot(manifest.outputDir);
|
|
522
547
|
|
|
523
548
|
const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
|
|
524
|
-
const
|
|
549
|
+
const unitBaseline = unitBaselineResults.get(unit.id);
|
|
550
|
+
const parent = branchStrategy === 'create-release-branch'
|
|
551
|
+
? undefined
|
|
552
|
+
: {
|
|
553
|
+
githubHost: unitBaseline.githubHost,
|
|
554
|
+
repo: unitBaseline.repo,
|
|
555
|
+
ref: unitBaseline.ref,
|
|
556
|
+
commit: unitBaseline.commit,
|
|
557
|
+
};
|
|
558
|
+
const git = await buildGitRepository({
|
|
525
559
|
snapshotDir: manifest.outputDir,
|
|
526
560
|
repositoryDir,
|
|
527
561
|
version,
|
|
528
562
|
expectedSnapshotDigest: sealed.digest,
|
|
563
|
+
parent,
|
|
529
564
|
});
|
|
530
565
|
|
|
531
566
|
let npm = null;
|
|
@@ -551,6 +586,8 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
551
586
|
gitObjectDir: relative(root, repositoryDir),
|
|
552
587
|
commit: git.commit,
|
|
553
588
|
tree: git.tree,
|
|
589
|
+
branchStrategy,
|
|
590
|
+
...(git.parentCommit ? { parentCommit: git.parentCommit } : {}),
|
|
554
591
|
branch,
|
|
555
592
|
tag,
|
|
556
593
|
npm: npm ? {
|
|
@@ -725,6 +762,11 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
725
762
|
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
726
763
|
commit: asset.commit,
|
|
727
764
|
tree: asset.tree,
|
|
765
|
+
branchStrategy: asset.branchStrategy,
|
|
766
|
+
...(asset.parentCommit ? { parentCommit: asset.parentCommit } : {}),
|
|
767
|
+
...(asset.branchStrategy === 'advance-existing-branch'
|
|
768
|
+
? { expectedBaselineCommit: asset.parentCommit }
|
|
769
|
+
: {}),
|
|
728
770
|
},
|
|
729
771
|
expected: {
|
|
730
772
|
branch: asset.branch,
|
|
@@ -735,6 +777,24 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
735
777
|
status: 'PENDING',
|
|
736
778
|
});
|
|
737
779
|
|
|
780
|
+
if (asset.branchStrategy === 'initialize-default-branch') {
|
|
781
|
+
actions.push({
|
|
782
|
+
id: `set-default-branch-${unit.id}`,
|
|
783
|
+
type: 'set-default-branch',
|
|
784
|
+
adapter: 'git-github',
|
|
785
|
+
unitId: unit.id,
|
|
786
|
+
parameters: {
|
|
787
|
+
repo: unit.publicRepo,
|
|
788
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
789
|
+
oldBranch: unit.production.expectedCurrentDefaultBranch,
|
|
790
|
+
newBranch: asset.branch,
|
|
791
|
+
expectedNewBranchCommit: asset.commit,
|
|
792
|
+
},
|
|
793
|
+
expected: { defaultBranch: asset.branch, newBranchCommit: asset.commit },
|
|
794
|
+
status: 'PENDING',
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
738
798
|
// Create tag
|
|
739
799
|
actions.push({
|
|
740
800
|
id: `create-tag-${unit.id}`,
|
|
@@ -908,6 +968,8 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
908
968
|
* the project config declares hooks. Hooks are user-configured arbitrary
|
|
909
969
|
* local processes without filesystem/network isolation. Authorization
|
|
910
970
|
* means the user accepts hook side-effect risks, not that hooks are safe.
|
|
971
|
+
* @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
|
|
972
|
+
* true when project verification gates are declared.
|
|
911
973
|
*
|
|
912
974
|
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
|
|
913
975
|
*
|
|
@@ -922,6 +984,7 @@ export async function prepareRelease(options) {
|
|
|
922
984
|
runDir: runDirOpt,
|
|
923
985
|
clock,
|
|
924
986
|
hooksAuthorized,
|
|
987
|
+
verificationGatesAuthorized,
|
|
925
988
|
production = false,
|
|
926
989
|
observePreviousPublicBaselineFn,
|
|
927
990
|
} = options ?? {};
|
|
@@ -1048,6 +1111,43 @@ export async function prepareRelease(options) {
|
|
|
1048
1111
|
});
|
|
1049
1112
|
}
|
|
1050
1113
|
|
|
1114
|
+
const declaredVerificationGates = config.verificationGates ?? [];
|
|
1115
|
+
if (declaredVerificationGates.length > 0) {
|
|
1116
|
+
await evidence.append({
|
|
1117
|
+
phase: 'verification-gate-authorization',
|
|
1118
|
+
status: 'started',
|
|
1119
|
+
gateCount: declaredVerificationGates.length,
|
|
1120
|
+
gates: declaredVerificationGates.map((gate) => ({
|
|
1121
|
+
id: gate.id,
|
|
1122
|
+
phase: gate.phase,
|
|
1123
|
+
unitId: gate.scope.unit,
|
|
1124
|
+
distribution: gate.scope.distribution ?? null,
|
|
1125
|
+
executable: gate.command[0],
|
|
1126
|
+
args: gate.command.slice(1),
|
|
1127
|
+
cwd: gate.cwd,
|
|
1128
|
+
})),
|
|
1129
|
+
});
|
|
1130
|
+
if (verificationGatesAuthorized !== true) {
|
|
1131
|
+
await evidence.append({
|
|
1132
|
+
phase: 'verification-gate-authorization',
|
|
1133
|
+
status: 'denied',
|
|
1134
|
+
gateCount: declaredVerificationGates.length,
|
|
1135
|
+
});
|
|
1136
|
+
throw new ReleaseError(
|
|
1137
|
+
GATE_FAILED,
|
|
1138
|
+
`project declares ${declaredVerificationGates.length} verification gate(s). ` +
|
|
1139
|
+
'They run local project commands without a network sandbox. ' +
|
|
1140
|
+
'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
|
|
1141
|
+
{ gateIds: declaredVerificationGates.map((gate) => gate.id) },
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
await evidence.append({
|
|
1145
|
+
phase: 'verification-gate-authorization',
|
|
1146
|
+
status: 'authorized',
|
|
1147
|
+
gateCount: declaredVerificationGates.length,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1051
1151
|
// --- Step 3: Run declared hooks ---
|
|
1052
1152
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1053
1153
|
await runDeclaredHooks(config, realRoot, evidence);
|
|
@@ -1069,6 +1169,12 @@ export async function prepareRelease(options) {
|
|
|
1069
1169
|
|
|
1070
1170
|
// --- Step 4b: Per-unit previous public baseline observe ---
|
|
1071
1171
|
const configUnits = config.releaseUnits ?? [];
|
|
1172
|
+
const resolvedVersions = await resolveAllUnitVersions(
|
|
1173
|
+
configUnits,
|
|
1174
|
+
realRoot,
|
|
1175
|
+
version,
|
|
1176
|
+
evidence,
|
|
1177
|
+
);
|
|
1072
1178
|
const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
|
|
1073
1179
|
try {
|
|
1074
1180
|
const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
|
|
@@ -1084,11 +1190,57 @@ export async function prepareRelease(options) {
|
|
|
1084
1190
|
}
|
|
1085
1191
|
};
|
|
1086
1192
|
const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
|
|
1193
|
+
const defaultObserveDefaultBranchFn = async (repo, { githubHost = 'github.com' } = {}) => {
|
|
1194
|
+
try {
|
|
1195
|
+
const { stdout } = await execFile(
|
|
1196
|
+
'gh',
|
|
1197
|
+
['api', `repos/${repo}`, '--jq', '.default_branch'],
|
|
1198
|
+
{
|
|
1199
|
+
shell: false,
|
|
1200
|
+
encoding: 'utf8',
|
|
1201
|
+
timeout: 30000,
|
|
1202
|
+
env: { ...process.env, GH_HOST: githubHost },
|
|
1203
|
+
},
|
|
1204
|
+
);
|
|
1205
|
+
return { status: 'observed', defaultBranch: stdout.trim() };
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
return { status: 'unknown', error: error.message };
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
|
|
1087
1211
|
const unitBaselineResults = new Map();
|
|
1088
|
-
for (
|
|
1212
|
+
for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
|
|
1213
|
+
const unit = configUnits[unitIndex];
|
|
1089
1214
|
const ppbConfig = unit.previousPublicBaseline;
|
|
1090
1215
|
if (!ppbConfig) continue;
|
|
1091
1216
|
const productionGithubHost = unit.production?.githubHost ?? 'github.com';
|
|
1217
|
+
const { branch, branchStrategy } = resolveProductionBranch(unit, resolvedVersions[unitIndex]);
|
|
1218
|
+
if (production && ['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
1219
|
+
if (offline) {
|
|
1220
|
+
throw new ReleaseError(
|
|
1221
|
+
GATE_FAILED,
|
|
1222
|
+
`unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare`,
|
|
1223
|
+
{ unitId: unit.id, branchStrategy },
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
if (ppbConfig.mode !== 'bound') {
|
|
1227
|
+
throw new ReleaseError(
|
|
1228
|
+
GATE_FAILED,
|
|
1229
|
+
`unit "${unit.id}" branch strategy "${branchStrategy}" requires previousPublicBaseline.mode=bound`,
|
|
1230
|
+
{ unitId: unit.id, branchStrategy },
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
if (
|
|
1234
|
+
branchStrategy === 'advance-existing-branch' &&
|
|
1235
|
+
ppbConfig.ref !== `refs/heads/${branch}`
|
|
1236
|
+
) {
|
|
1237
|
+
throw new ReleaseError(
|
|
1238
|
+
GATE_FAILED,
|
|
1239
|
+
`unit "${unit.id}" advance-existing-branch baseline ref must equal refs/heads/${branch}`,
|
|
1240
|
+
{ unitId: unit.id, expectedRef: `refs/heads/${branch}`, actualRef: ppbConfig.ref },
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1092
1244
|
const effectivePpbConfig = ppbConfig.mode === 'bound'
|
|
1093
1245
|
? { ...ppbConfig, githubHost: productionGithubHost }
|
|
1094
1246
|
: ppbConfig;
|
|
@@ -1214,6 +1366,37 @@ export async function prepareRelease(options) {
|
|
|
1214
1366
|
status: "completed",
|
|
1215
1367
|
consistent: true,
|
|
1216
1368
|
});
|
|
1369
|
+
|
|
1370
|
+
if (production && branchStrategy === 'initialize-default-branch') {
|
|
1371
|
+
const expectedCurrent = unit.production?.expectedCurrentDefaultBranch;
|
|
1372
|
+
const observedDefault = await observeDefaultBranch(unit.publicRepo, {
|
|
1373
|
+
githubHost: productionGithubHost,
|
|
1374
|
+
});
|
|
1375
|
+
await evidence.append({
|
|
1376
|
+
phase: 'default-branch-observe',
|
|
1377
|
+
unitId: unit.id,
|
|
1378
|
+
status: observedDefault.status,
|
|
1379
|
+
expectedCurrentDefaultBranch: expectedCurrent,
|
|
1380
|
+
observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
|
|
1381
|
+
...(observedDefault.error ? { error: observedDefault.error } : {}),
|
|
1382
|
+
});
|
|
1383
|
+
if (
|
|
1384
|
+
observedDefault.status !== 'observed' ||
|
|
1385
|
+
!observedDefault.defaultBranch ||
|
|
1386
|
+
observedDefault.defaultBranch !== expectedCurrent
|
|
1387
|
+
) {
|
|
1388
|
+
throw new ReleaseError(
|
|
1389
|
+
GATE_FAILED,
|
|
1390
|
+
`unit "${unit.id}" GitHub default branch does not match expectedCurrentDefaultBranch`,
|
|
1391
|
+
{
|
|
1392
|
+
unitId: unit.id,
|
|
1393
|
+
expectedCurrentDefaultBranch: expectedCurrent,
|
|
1394
|
+
observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
|
|
1395
|
+
observationStatus: observedDefault.status,
|
|
1396
|
+
},
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1217
1400
|
}
|
|
1218
1401
|
|
|
1219
1402
|
// --- Step 5: Build snapshots, scan, and evaluate README ---
|
|
@@ -1221,6 +1404,22 @@ export async function prepareRelease(options) {
|
|
|
1221
1404
|
config, realRoot, evidence, runDir, production,
|
|
1222
1405
|
);
|
|
1223
1406
|
|
|
1407
|
+
// Snapshot gates always run on disposable writable copies. The public
|
|
1408
|
+
// snapshot authority is re-digested after every gate and is never exposed
|
|
1409
|
+
// as the gate working directory.
|
|
1410
|
+
const snapshotGateResults = await runSnapshotVerificationGates({
|
|
1411
|
+
gates: declaredVerificationGates,
|
|
1412
|
+
unitResults,
|
|
1413
|
+
runDir,
|
|
1414
|
+
evidence,
|
|
1415
|
+
env: options.gateEnv ?? process.env,
|
|
1416
|
+
});
|
|
1417
|
+
await evidence.append({
|
|
1418
|
+
phase: 'snapshot-verify',
|
|
1419
|
+
status: 'completed',
|
|
1420
|
+
gateCount: snapshotGateResults.length,
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1224
1423
|
// --- Step 6: Remote uniqueness (deferred to publish preflight) ---
|
|
1225
1424
|
// Prepare only observes the previous public baseline (already done above).
|
|
1226
1425
|
// Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
|
|
@@ -1244,16 +1443,15 @@ export async function prepareRelease(options) {
|
|
|
1244
1443
|
// --- Step 7: Build plan object ---
|
|
1245
1444
|
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
1246
1445
|
|
|
1247
|
-
// Resolve versions for all units
|
|
1248
|
-
const resolvedVersions = await resolveAllUnitVersions(
|
|
1249
|
-
config.releaseUnits ?? [],
|
|
1250
|
-
realRoot,
|
|
1251
|
-
version,
|
|
1252
|
-
evidence,
|
|
1253
|
-
);
|
|
1254
|
-
|
|
1255
1446
|
const productionAssets = production
|
|
1256
|
-
? await buildProductionAssets(
|
|
1447
|
+
? await buildProductionAssets(
|
|
1448
|
+
unitResults,
|
|
1449
|
+
resolvedVersions,
|
|
1450
|
+
realRoot,
|
|
1451
|
+
runDir,
|
|
1452
|
+
unitBaselineResults,
|
|
1453
|
+
options.buildFrozenGitRepositoryFn ?? buildFrozenGitRepository,
|
|
1454
|
+
)
|
|
1257
1455
|
: null;
|
|
1258
1456
|
|
|
1259
1457
|
const units = unitResults.map(({ unit, manifest }, idx) => {
|
|
@@ -1267,14 +1465,18 @@ export async function prepareRelease(options) {
|
|
|
1267
1465
|
tagTemplate: unit.version?.tagTemplate,
|
|
1268
1466
|
snapshotDigest: snapshotDigests[idx],
|
|
1269
1467
|
...(productionAssets ? {
|
|
1270
|
-
productionConfig: unit
|
|
1468
|
+
productionConfig: normalizedProductionConfig(unit),
|
|
1271
1469
|
frozenSnapshot: {
|
|
1272
1470
|
path: productionAssets[idx].snapshotPath,
|
|
1273
1471
|
manifestDigest: productionAssets[idx].manifestDigest,
|
|
1274
1472
|
gitObjectDir: productionAssets[idx].gitObjectDir,
|
|
1275
1473
|
branch: productionAssets[idx].branch,
|
|
1474
|
+
branchStrategy: productionAssets[idx].branchStrategy,
|
|
1276
1475
|
commit: productionAssets[idx].commit,
|
|
1277
1476
|
tree: productionAssets[idx].tree,
|
|
1477
|
+
...(productionAssets[idx].parentCommit
|
|
1478
|
+
? { parentCommit: productionAssets[idx].parentCommit }
|
|
1479
|
+
: {}),
|
|
1278
1480
|
npm: productionAssets[idx].npm,
|
|
1279
1481
|
},
|
|
1280
1482
|
} : {}),
|
|
@@ -1300,6 +1502,7 @@ export async function prepareRelease(options) {
|
|
|
1300
1502
|
capturedAt: baseline.capturedAt,
|
|
1301
1503
|
},
|
|
1302
1504
|
configDigest,
|
|
1505
|
+
verificationGates: config.verificationGates ?? [],
|
|
1303
1506
|
snapshotDigest: overallSnapshotDigest,
|
|
1304
1507
|
...(production ? {
|
|
1305
1508
|
production: {
|
package/src/commands/publish.mjs
CHANGED
|
@@ -75,6 +75,7 @@ const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
|
|
|
75
75
|
const CHECKPOINT_ORDER = [
|
|
76
76
|
'push-commit',
|
|
77
77
|
'push-snapshot',
|
|
78
|
+
'set-default-branch',
|
|
78
79
|
'create-tag',
|
|
79
80
|
'npm-publish',
|
|
80
81
|
'github-release',
|
|
@@ -92,6 +93,7 @@ const CHECKPOINT_ORDER = [
|
|
|
92
93
|
const ADAPTER_ACTION_TYPE_MAP = {
|
|
93
94
|
'push-commit': 'git-push',
|
|
94
95
|
'push-snapshot': 'push-snapshot',
|
|
96
|
+
'set-default-branch': 'set-default-branch',
|
|
95
97
|
'create-tag': 'git-tag',
|
|
96
98
|
'npm-publish': 'npm-publish',
|
|
97
99
|
'github-release': 'github-release',
|
|
@@ -812,6 +814,49 @@ export async function publishRelease(options) {
|
|
|
812
814
|
latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
|
|
813
815
|
}
|
|
814
816
|
|
|
817
|
+
// Close the push -> default-branch TOCTOU window with a final read-only
|
|
818
|
+
// consistency pass. Both the branch tip and default-branch name are bound
|
|
819
|
+
// in the frozen action expectations. A late change keeps the saga PARTIAL
|
|
820
|
+
// and must be resolved by reconcile/human review.
|
|
821
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
822
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
|
|
823
|
+
for (let index = 0; index < orderedActions.length; index += 1) {
|
|
824
|
+
const action = orderedActions[index];
|
|
825
|
+
if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
|
|
826
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
827
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
828
|
+
let observed;
|
|
829
|
+
try {
|
|
830
|
+
observed = await adapter.observe(
|
|
831
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
832
|
+
{ externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
|
|
833
|
+
);
|
|
834
|
+
} catch (error) {
|
|
835
|
+
observed = { observation: null, error: error.message };
|
|
836
|
+
}
|
|
837
|
+
const observation = observed?.observation;
|
|
838
|
+
const observable = observation && !(observed.error && Object.keys(observation).length === 0);
|
|
839
|
+
const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
|
|
840
|
+
if (!comparison.matches) {
|
|
841
|
+
checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
|
|
842
|
+
checkpoints[index].error = observable
|
|
843
|
+
? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
|
|
844
|
+
: `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
|
|
845
|
+
await evidence.append({
|
|
846
|
+
phase: 'safety-gate',
|
|
847
|
+
gate: 'final-branch-consistency',
|
|
848
|
+
status: 'failed',
|
|
849
|
+
actionId: action.id,
|
|
850
|
+
error: checkpoints[index].error,
|
|
851
|
+
});
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
856
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
815
860
|
// Determine overall status
|
|
816
861
|
const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
|
|
817
862
|
const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED');
|
|
@@ -71,6 +71,7 @@ import { matchObservation } from '../adapters/contract.mjs';
|
|
|
71
71
|
const CHECKPOINT_ORDER = [
|
|
72
72
|
'push-commit',
|
|
73
73
|
'push-snapshot',
|
|
74
|
+
'set-default-branch',
|
|
74
75
|
'create-tag',
|
|
75
76
|
'npm-publish',
|
|
76
77
|
'github-release',
|
|
@@ -85,6 +86,7 @@ const CHECKPOINT_ORDER = [
|
|
|
85
86
|
const ADAPTER_ACTION_TYPE_MAP = {
|
|
86
87
|
'push-commit': 'git-push',
|
|
87
88
|
'push-snapshot': 'push-snapshot',
|
|
89
|
+
'set-default-branch': 'set-default-branch',
|
|
88
90
|
'create-tag': 'git-tag',
|
|
89
91
|
'npm-publish': 'npm-publish',
|
|
90
92
|
'github-release': 'github-release',
|
|
@@ -444,6 +446,13 @@ export async function reconcileRelease(options) {
|
|
|
444
446
|
baseline,
|
|
445
447
|
observeFn: ppbObserveFn,
|
|
446
448
|
evidence,
|
|
449
|
+
acceptedSuccessorCommits: (plan.externalActions ?? [])
|
|
450
|
+
.filter((action) => (
|
|
451
|
+
action.unitId === unit.id &&
|
|
452
|
+
action.type === 'push-snapshot' &&
|
|
453
|
+
action.parameters?.branchStrategy === 'advance-existing-branch'
|
|
454
|
+
))
|
|
455
|
+
.map((action) => action.parameters.commit),
|
|
447
456
|
});
|
|
448
457
|
if (!observed.consistent) {
|
|
449
458
|
throw new ReleaseError(
|
|
@@ -667,6 +676,59 @@ export async function reconcileRelease(options) {
|
|
|
667
676
|
continue;
|
|
668
677
|
}
|
|
669
678
|
|
|
679
|
+
// An advancing push has two safe observable states during recovery:
|
|
680
|
+
// the exact frozen predecessor (retry is still possible) or the exact
|
|
681
|
+
// planned successor (the release already advanced it). Any third tip is
|
|
682
|
+
// a real remote conflict. Generic create-only logic cannot distinguish
|
|
683
|
+
// the predecessor from an unexpected pre-existing branch.
|
|
684
|
+
if (
|
|
685
|
+
action.type === 'push-snapshot' &&
|
|
686
|
+
action.parameters?.branchStrategy === 'advance-existing-branch'
|
|
687
|
+
) {
|
|
688
|
+
const observeResult = await adapter.observe(
|
|
689
|
+
{ actionType: adapterActionType, ...action.parameters },
|
|
690
|
+
context,
|
|
691
|
+
);
|
|
692
|
+
const observation = observeResult?.observation;
|
|
693
|
+
if (!observation || (observeResult.error && Object.keys(observation).length === 0)) {
|
|
694
|
+
throw new ReleaseError(
|
|
695
|
+
REMOTE_CONFLICT,
|
|
696
|
+
`advancing action "${action.id}" remote state is unobservable`,
|
|
697
|
+
{ actionId: action.id, observeError: observeResult?.error },
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const actual = observation.commit ?? observation.remoteCommit ?? '';
|
|
701
|
+
const predecessor = action.parameters.expectedBaselineCommit;
|
|
702
|
+
const successor = action.parameters.commit;
|
|
703
|
+
if (actual === successor) {
|
|
704
|
+
actionResults.set(action.id, sourceCp.status === 'succeeded' ? 'succeeded' : 'skipped');
|
|
705
|
+
await evidence.append({
|
|
706
|
+
phase: 'reconcile-observe',
|
|
707
|
+
actionId: action.id,
|
|
708
|
+
actionType: action.type,
|
|
709
|
+
decision: 'advance-already-at-planned-successor',
|
|
710
|
+
sourceStatus: sourceCp.status,
|
|
711
|
+
});
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (actual === predecessor && sourceCp.status !== 'succeeded') {
|
|
715
|
+
actionsToRetry.push(action);
|
|
716
|
+
await evidence.append({
|
|
717
|
+
phase: 'reconcile-observe',
|
|
718
|
+
actionId: action.id,
|
|
719
|
+
actionType: action.type,
|
|
720
|
+
decision: 'retry-advance-from-frozen-predecessor',
|
|
721
|
+
sourceStatus: sourceCp.status,
|
|
722
|
+
});
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
throw new ReleaseError(
|
|
726
|
+
REMOTE_CONFLICT,
|
|
727
|
+
`Remote branch conflict for advancing action "${action.id}": expected predecessor ${predecessor} or planned successor ${successor}, got ${actual || 'missing'}`,
|
|
728
|
+
{ actionId: action.id, predecessor, successor, actual, sourceStatus: sourceCp.status },
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
670
732
|
// -------------------------------------------------------------------
|
|
671
733
|
// Non-marketplace actions: observe-based consistency checks
|
|
672
734
|
// -------------------------------------------------------------------
|
|
@@ -761,6 +823,57 @@ export async function reconcileRelease(options) {
|
|
|
761
823
|
);
|
|
762
824
|
}
|
|
763
825
|
|
|
826
|
+
if (action.type === 'set-default-branch') {
|
|
827
|
+
const current = observeResult.observation.defaultBranch;
|
|
828
|
+
const observedNewBranchCommit = observeResult.observation.newBranchCommit;
|
|
829
|
+
if (observedNewBranchCommit !== action.parameters.expectedNewBranchCommit) {
|
|
830
|
+
await evidence.append({
|
|
831
|
+
phase: 'reconcile-observe',
|
|
832
|
+
actionId: action.id,
|
|
833
|
+
actionType: action.type,
|
|
834
|
+
decision: 'remote-conflict',
|
|
835
|
+
expectedNewBranchCommit: action.parameters.expectedNewBranchCommit,
|
|
836
|
+
observedNewBranchCommit: observedNewBranchCommit ?? null,
|
|
837
|
+
});
|
|
838
|
+
throw new ReleaseError(
|
|
839
|
+
REMOTE_CONFLICT,
|
|
840
|
+
`Remote target branch commit conflict for action "${action.id}": expected ` +
|
|
841
|
+
`"${action.parameters.expectedNewBranchCommit}", got "${observedNewBranchCommit ?? 'missing'}"`,
|
|
842
|
+
{
|
|
843
|
+
actionId: action.id,
|
|
844
|
+
expectedNewBranchCommit: action.parameters.expectedNewBranchCommit,
|
|
845
|
+
observedNewBranchCommit: observedNewBranchCommit ?? null,
|
|
846
|
+
},
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
if (current === action.parameters.newBranch) {
|
|
850
|
+
actionResults.set(action.id, 'skipped');
|
|
851
|
+
await evidence.append({
|
|
852
|
+
phase: 'reconcile-observe',
|
|
853
|
+
actionId: action.id,
|
|
854
|
+
actionType: action.type,
|
|
855
|
+
decision: 'skip-remote-consistent',
|
|
856
|
+
});
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (current === action.parameters.oldBranch) {
|
|
860
|
+
actionsToRetry.push(action);
|
|
861
|
+
await evidence.append({
|
|
862
|
+
phase: 'reconcile-observe',
|
|
863
|
+
actionId: action.id,
|
|
864
|
+
actionType: action.type,
|
|
865
|
+
decision: 'retry-default-branch-still-old',
|
|
866
|
+
});
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
throw new ReleaseError(
|
|
870
|
+
REMOTE_CONFLICT,
|
|
871
|
+
`Remote default branch conflict for action "${action.id}": expected old ` +
|
|
872
|
+
`"${action.parameters.oldBranch}" or new "${action.parameters.newBranch}", got "${current}"`,
|
|
873
|
+
{ actionId: action.id, observedDefaultBranch: current },
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
764
877
|
const explicitlyMissing = observeResult.observation.exists === false
|
|
765
878
|
|| observeResult.observation.remoteCommit === ''
|
|
766
879
|
|| observeResult.observation.commit === ''
|
|
@@ -1137,6 +1250,45 @@ export async function reconcileRelease(options) {
|
|
|
1137
1250
|
}
|
|
1138
1251
|
}
|
|
1139
1252
|
|
|
1253
|
+
// Final branch/default-branch consistency closes late drift after a retry
|
|
1254
|
+
// or after the first observation pass. The set-default action expectation
|
|
1255
|
+
// includes both the branch name and its exact planned commit.
|
|
1256
|
+
if (!retryFailed && planActions.every((action) => ['succeeded', 'skipped'].includes(actionResults.get(action.id)))) {
|
|
1257
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
|
|
1258
|
+
for (const action of planActions) {
|
|
1259
|
+
if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
|
|
1260
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1261
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
1262
|
+
let observed;
|
|
1263
|
+
try {
|
|
1264
|
+
observed = await adapter.observe(
|
|
1265
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
1266
|
+
context,
|
|
1267
|
+
);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
observed = { observation: null, error: error.message };
|
|
1270
|
+
}
|
|
1271
|
+
const observation = observed?.observation;
|
|
1272
|
+
const observable = observation && !(observed.error && Object.keys(observation).length === 0);
|
|
1273
|
+
const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
|
|
1274
|
+
if (!comparison.matches) {
|
|
1275
|
+
actionResults.set(action.id, observable ? 'failed' : 'uncertain');
|
|
1276
|
+
retryFailed = true;
|
|
1277
|
+
await evidence.append({
|
|
1278
|
+
phase: 'safety-gate',
|
|
1279
|
+
gate: 'final-branch-consistency',
|
|
1280
|
+
status: 'failed',
|
|
1281
|
+
actionId: action.id,
|
|
1282
|
+
error: observable ? comparison.mismatches.join('; ') : observed?.error ?? 'empty observation',
|
|
1283
|
+
});
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (!retryFailed) {
|
|
1288
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1140
1292
|
// =======================================================================
|
|
1141
1293
|
// Determine final status
|
|
1142
1294
|
// =======================================================================
|