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/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';
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
buildFrozenGitRepository,
|
|
37
38
|
buildFrozenNpmTarball,
|
|
38
39
|
computeFrozenSnapshot,
|
|
40
|
+
normalizeGitTimestamp,
|
|
39
41
|
sealFrozenSnapshot,
|
|
40
42
|
} from '../snapshot/frozen.mjs';
|
|
41
43
|
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
|
|
@@ -483,7 +485,41 @@ async function processSnapshots(config, root, evidence, runDir, production = fal
|
|
|
483
485
|
return { unitResults, snapshotDigests };
|
|
484
486
|
}
|
|
485
487
|
|
|
486
|
-
|
|
488
|
+
function resolveProductionBranch(unit, version) {
|
|
489
|
+
const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
|
|
490
|
+
const tag = tagTemplate.replace('{version}', version);
|
|
491
|
+
const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
|
|
492
|
+
return {
|
|
493
|
+
tag,
|
|
494
|
+
branch: branchTemplate
|
|
495
|
+
.replaceAll('{tag}', tag)
|
|
496
|
+
.replaceAll('{version}', version)
|
|
497
|
+
.replaceAll('{unit}', unit.id),
|
|
498
|
+
branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function normalizedProductionConfig(unit) {
|
|
503
|
+
return {
|
|
504
|
+
...(unit.production ?? {}),
|
|
505
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
506
|
+
branchTemplate: unit.production?.branchTemplate ?? 'release/{tag}',
|
|
507
|
+
branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function buildProductionAssets(
|
|
512
|
+
unitResults,
|
|
513
|
+
resolvedVersions,
|
|
514
|
+
root,
|
|
515
|
+
runDir,
|
|
516
|
+
unitBaselineResults,
|
|
517
|
+
buildGitRepository = buildFrozenGitRepository,
|
|
518
|
+
freezeTimestamp,
|
|
519
|
+
) {
|
|
520
|
+
// The plan freeze timestamp is sampled exactly once by prepareRelease.
|
|
521
|
+
// Every unit reuses this single value; the wall clock is never re-read.
|
|
522
|
+
const canonicalFreezeTimestamp = normalizeGitTimestamp(freezeTimestamp, 'plan freeze timestamp');
|
|
487
523
|
for (const { unit } of unitResults) {
|
|
488
524
|
const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
|
|
489
525
|
if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
|
|
@@ -497,13 +533,7 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
497
533
|
for (let index = 0; index < unitResults.length; index += 1) {
|
|
498
534
|
const { unit, manifest } = unitResults[index];
|
|
499
535
|
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);
|
|
536
|
+
const { tag, branch, branchStrategy } = resolveProductionBranch(unit, version);
|
|
507
537
|
const snapshotPath = relative(root, manifest.outputDir);
|
|
508
538
|
const observed = await computeFrozenSnapshot(manifest.outputDir);
|
|
509
539
|
if (observed.digest !== manifest.snapshotDigest) {
|
|
@@ -521,11 +551,22 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
521
551
|
const sealed = await computeFrozenSnapshot(manifest.outputDir);
|
|
522
552
|
|
|
523
553
|
const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
|
|
524
|
-
const
|
|
554
|
+
const unitBaseline = unitBaselineResults.get(unit.id);
|
|
555
|
+
const parent = branchStrategy === 'create-release-branch'
|
|
556
|
+
? undefined
|
|
557
|
+
: {
|
|
558
|
+
githubHost: unitBaseline.githubHost,
|
|
559
|
+
repo: unitBaseline.repo,
|
|
560
|
+
ref: unitBaseline.ref,
|
|
561
|
+
commit: unitBaseline.commit,
|
|
562
|
+
};
|
|
563
|
+
const git = await buildGitRepository({
|
|
525
564
|
snapshotDir: manifest.outputDir,
|
|
526
565
|
repositoryDir,
|
|
527
566
|
version,
|
|
528
567
|
expectedSnapshotDigest: sealed.digest,
|
|
568
|
+
parent,
|
|
569
|
+
commitTimestamp: canonicalFreezeTimestamp,
|
|
529
570
|
});
|
|
530
571
|
|
|
531
572
|
let npm = null;
|
|
@@ -551,6 +592,9 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
|
|
|
551
592
|
gitObjectDir: relative(root, repositoryDir),
|
|
552
593
|
commit: git.commit,
|
|
553
594
|
tree: git.tree,
|
|
595
|
+
commitTimestamp: canonicalFreezeTimestamp,
|
|
596
|
+
branchStrategy,
|
|
597
|
+
...(git.parentCommit ? { parentCommit: git.parentCommit } : {}),
|
|
554
598
|
branch,
|
|
555
599
|
tag,
|
|
556
600
|
npm: npm ? {
|
|
@@ -725,6 +769,11 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
725
769
|
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
726
770
|
commit: asset.commit,
|
|
727
771
|
tree: asset.tree,
|
|
772
|
+
branchStrategy: asset.branchStrategy,
|
|
773
|
+
...(asset.parentCommit ? { parentCommit: asset.parentCommit } : {}),
|
|
774
|
+
...(asset.branchStrategy === 'advance-existing-branch'
|
|
775
|
+
? { expectedBaselineCommit: asset.parentCommit }
|
|
776
|
+
: {}),
|
|
728
777
|
},
|
|
729
778
|
expected: {
|
|
730
779
|
branch: asset.branch,
|
|
@@ -735,6 +784,24 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
735
784
|
status: 'PENDING',
|
|
736
785
|
});
|
|
737
786
|
|
|
787
|
+
if (asset.branchStrategy === 'initialize-default-branch') {
|
|
788
|
+
actions.push({
|
|
789
|
+
id: `set-default-branch-${unit.id}`,
|
|
790
|
+
type: 'set-default-branch',
|
|
791
|
+
adapter: 'git-github',
|
|
792
|
+
unitId: unit.id,
|
|
793
|
+
parameters: {
|
|
794
|
+
repo: unit.publicRepo,
|
|
795
|
+
githubHost: unit.production?.githubHost ?? 'github.com',
|
|
796
|
+
oldBranch: unit.production.expectedCurrentDefaultBranch,
|
|
797
|
+
newBranch: asset.branch,
|
|
798
|
+
expectedNewBranchCommit: asset.commit,
|
|
799
|
+
},
|
|
800
|
+
expected: { defaultBranch: asset.branch, newBranchCommit: asset.commit },
|
|
801
|
+
status: 'PENDING',
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
738
805
|
// Create tag
|
|
739
806
|
actions.push({
|
|
740
807
|
id: `create-tag-${unit.id}`,
|
|
@@ -908,6 +975,8 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
|
|
|
908
975
|
* the project config declares hooks. Hooks are user-configured arbitrary
|
|
909
976
|
* local processes without filesystem/network isolation. Authorization
|
|
910
977
|
* means the user accepts hook side-effect risks, not that hooks are safe.
|
|
978
|
+
* @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
|
|
979
|
+
* true when project verification gates are declared.
|
|
911
980
|
*
|
|
912
981
|
* @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
|
|
913
982
|
*
|
|
@@ -922,6 +991,7 @@ export async function prepareRelease(options) {
|
|
|
922
991
|
runDir: runDirOpt,
|
|
923
992
|
clock,
|
|
924
993
|
hooksAuthorized,
|
|
994
|
+
verificationGatesAuthorized,
|
|
925
995
|
production = false,
|
|
926
996
|
observePreviousPublicBaselineFn,
|
|
927
997
|
} = options ?? {};
|
|
@@ -1048,6 +1118,43 @@ export async function prepareRelease(options) {
|
|
|
1048
1118
|
});
|
|
1049
1119
|
}
|
|
1050
1120
|
|
|
1121
|
+
const declaredVerificationGates = config.verificationGates ?? [];
|
|
1122
|
+
if (declaredVerificationGates.length > 0) {
|
|
1123
|
+
await evidence.append({
|
|
1124
|
+
phase: 'verification-gate-authorization',
|
|
1125
|
+
status: 'started',
|
|
1126
|
+
gateCount: declaredVerificationGates.length,
|
|
1127
|
+
gates: declaredVerificationGates.map((gate) => ({
|
|
1128
|
+
id: gate.id,
|
|
1129
|
+
phase: gate.phase,
|
|
1130
|
+
unitId: gate.scope.unit,
|
|
1131
|
+
distribution: gate.scope.distribution ?? null,
|
|
1132
|
+
executable: gate.command[0],
|
|
1133
|
+
args: gate.command.slice(1),
|
|
1134
|
+
cwd: gate.cwd,
|
|
1135
|
+
})),
|
|
1136
|
+
});
|
|
1137
|
+
if (verificationGatesAuthorized !== true) {
|
|
1138
|
+
await evidence.append({
|
|
1139
|
+
phase: 'verification-gate-authorization',
|
|
1140
|
+
status: 'denied',
|
|
1141
|
+
gateCount: declaredVerificationGates.length,
|
|
1142
|
+
});
|
|
1143
|
+
throw new ReleaseError(
|
|
1144
|
+
GATE_FAILED,
|
|
1145
|
+
`project declares ${declaredVerificationGates.length} verification gate(s). ` +
|
|
1146
|
+
'They run local project commands without a network sandbox. ' +
|
|
1147
|
+
'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
|
|
1148
|
+
{ gateIds: declaredVerificationGates.map((gate) => gate.id) },
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
await evidence.append({
|
|
1152
|
+
phase: 'verification-gate-authorization',
|
|
1153
|
+
status: 'authorized',
|
|
1154
|
+
gateCount: declaredVerificationGates.length,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1051
1158
|
// --- Step 3: Run declared hooks ---
|
|
1052
1159
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1053
1160
|
await runDeclaredHooks(config, realRoot, evidence);
|
|
@@ -1069,6 +1176,12 @@ export async function prepareRelease(options) {
|
|
|
1069
1176
|
|
|
1070
1177
|
// --- Step 4b: Per-unit previous public baseline observe ---
|
|
1071
1178
|
const configUnits = config.releaseUnits ?? [];
|
|
1179
|
+
const resolvedVersions = await resolveAllUnitVersions(
|
|
1180
|
+
configUnits,
|
|
1181
|
+
realRoot,
|
|
1182
|
+
version,
|
|
1183
|
+
evidence,
|
|
1184
|
+
);
|
|
1072
1185
|
const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
|
|
1073
1186
|
try {
|
|
1074
1187
|
const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
|
|
@@ -1084,11 +1197,57 @@ export async function prepareRelease(options) {
|
|
|
1084
1197
|
}
|
|
1085
1198
|
};
|
|
1086
1199
|
const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
|
|
1200
|
+
const defaultObserveDefaultBranchFn = async (repo, { githubHost = 'github.com' } = {}) => {
|
|
1201
|
+
try {
|
|
1202
|
+
const { stdout } = await execFile(
|
|
1203
|
+
'gh',
|
|
1204
|
+
['api', `repos/${repo}`, '--jq', '.default_branch'],
|
|
1205
|
+
{
|
|
1206
|
+
shell: false,
|
|
1207
|
+
encoding: 'utf8',
|
|
1208
|
+
timeout: 30000,
|
|
1209
|
+
env: { ...process.env, GH_HOST: githubHost },
|
|
1210
|
+
},
|
|
1211
|
+
);
|
|
1212
|
+
return { status: 'observed', defaultBranch: stdout.trim() };
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
return { status: 'unknown', error: error.message };
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
|
|
1087
1218
|
const unitBaselineResults = new Map();
|
|
1088
|
-
for (
|
|
1219
|
+
for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
|
|
1220
|
+
const unit = configUnits[unitIndex];
|
|
1089
1221
|
const ppbConfig = unit.previousPublicBaseline;
|
|
1090
1222
|
if (!ppbConfig) continue;
|
|
1091
1223
|
const productionGithubHost = unit.production?.githubHost ?? 'github.com';
|
|
1224
|
+
const { branch, branchStrategy } = resolveProductionBranch(unit, resolvedVersions[unitIndex]);
|
|
1225
|
+
if (production && ['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
1226
|
+
if (offline) {
|
|
1227
|
+
throw new ReleaseError(
|
|
1228
|
+
GATE_FAILED,
|
|
1229
|
+
`unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare`,
|
|
1230
|
+
{ unitId: unit.id, branchStrategy },
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
if (ppbConfig.mode !== 'bound') {
|
|
1234
|
+
throw new ReleaseError(
|
|
1235
|
+
GATE_FAILED,
|
|
1236
|
+
`unit "${unit.id}" branch strategy "${branchStrategy}" requires previousPublicBaseline.mode=bound`,
|
|
1237
|
+
{ unitId: unit.id, branchStrategy },
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
if (
|
|
1241
|
+
branchStrategy === 'advance-existing-branch' &&
|
|
1242
|
+
ppbConfig.ref !== `refs/heads/${branch}`
|
|
1243
|
+
) {
|
|
1244
|
+
throw new ReleaseError(
|
|
1245
|
+
GATE_FAILED,
|
|
1246
|
+
`unit "${unit.id}" advance-existing-branch baseline ref must equal refs/heads/${branch}`,
|
|
1247
|
+
{ unitId: unit.id, expectedRef: `refs/heads/${branch}`, actualRef: ppbConfig.ref },
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1092
1251
|
const effectivePpbConfig = ppbConfig.mode === 'bound'
|
|
1093
1252
|
? { ...ppbConfig, githubHost: productionGithubHost }
|
|
1094
1253
|
: ppbConfig;
|
|
@@ -1214,6 +1373,37 @@ export async function prepareRelease(options) {
|
|
|
1214
1373
|
status: "completed",
|
|
1215
1374
|
consistent: true,
|
|
1216
1375
|
});
|
|
1376
|
+
|
|
1377
|
+
if (production && branchStrategy === 'initialize-default-branch') {
|
|
1378
|
+
const expectedCurrent = unit.production?.expectedCurrentDefaultBranch;
|
|
1379
|
+
const observedDefault = await observeDefaultBranch(unit.publicRepo, {
|
|
1380
|
+
githubHost: productionGithubHost,
|
|
1381
|
+
});
|
|
1382
|
+
await evidence.append({
|
|
1383
|
+
phase: 'default-branch-observe',
|
|
1384
|
+
unitId: unit.id,
|
|
1385
|
+
status: observedDefault.status,
|
|
1386
|
+
expectedCurrentDefaultBranch: expectedCurrent,
|
|
1387
|
+
observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
|
|
1388
|
+
...(observedDefault.error ? { error: observedDefault.error } : {}),
|
|
1389
|
+
});
|
|
1390
|
+
if (
|
|
1391
|
+
observedDefault.status !== 'observed' ||
|
|
1392
|
+
!observedDefault.defaultBranch ||
|
|
1393
|
+
observedDefault.defaultBranch !== expectedCurrent
|
|
1394
|
+
) {
|
|
1395
|
+
throw new ReleaseError(
|
|
1396
|
+
GATE_FAILED,
|
|
1397
|
+
`unit "${unit.id}" GitHub default branch does not match expectedCurrentDefaultBranch`,
|
|
1398
|
+
{
|
|
1399
|
+
unitId: unit.id,
|
|
1400
|
+
expectedCurrentDefaultBranch: expectedCurrent,
|
|
1401
|
+
observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
|
|
1402
|
+
observationStatus: observedDefault.status,
|
|
1403
|
+
},
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1217
1407
|
}
|
|
1218
1408
|
|
|
1219
1409
|
// --- Step 5: Build snapshots, scan, and evaluate README ---
|
|
@@ -1221,6 +1411,22 @@ export async function prepareRelease(options) {
|
|
|
1221
1411
|
config, realRoot, evidence, runDir, production,
|
|
1222
1412
|
);
|
|
1223
1413
|
|
|
1414
|
+
// Snapshot gates always run on disposable writable copies. The public
|
|
1415
|
+
// snapshot authority is re-digested after every gate and is never exposed
|
|
1416
|
+
// as the gate working directory.
|
|
1417
|
+
const snapshotGateResults = await runSnapshotVerificationGates({
|
|
1418
|
+
gates: declaredVerificationGates,
|
|
1419
|
+
unitResults,
|
|
1420
|
+
runDir,
|
|
1421
|
+
evidence,
|
|
1422
|
+
env: options.gateEnv ?? process.env,
|
|
1423
|
+
});
|
|
1424
|
+
await evidence.append({
|
|
1425
|
+
phase: 'snapshot-verify',
|
|
1426
|
+
status: 'completed',
|
|
1427
|
+
gateCount: snapshotGateResults.length,
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1224
1430
|
// --- Step 6: Remote uniqueness (deferred to publish preflight) ---
|
|
1225
1431
|
// Prepare only observes the previous public baseline (already done above).
|
|
1226
1432
|
// Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
|
|
@@ -1244,16 +1450,28 @@ export async function prepareRelease(options) {
|
|
|
1244
1450
|
// --- Step 7: Build plan object ---
|
|
1245
1451
|
await evidence.append({ phase: 'plan-assembly', status: 'started' });
|
|
1246
1452
|
|
|
1247
|
-
//
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1453
|
+
// Production plans sample their freeze timestamp exactly once, before the
|
|
1454
|
+
// first frozen Git object exists. This single canonical value becomes
|
|
1455
|
+
// GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit,
|
|
1456
|
+
// every unit's frozenSnapshot.commitTimestamp, and plan.createdAt. It is
|
|
1457
|
+
// thereby bound by the plan digest and the approval record; publish,
|
|
1458
|
+
// retry, and reconcile consume it from the frozen plan and never re-read
|
|
1459
|
+
// the wall clock. A missing or invalid injected value fails closed here,
|
|
1460
|
+
// before any Git write.
|
|
1461
|
+
const freezeTimestamp = production
|
|
1462
|
+
? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan freeze timestamp')
|
|
1463
|
+
: null;
|
|
1254
1464
|
|
|
1255
1465
|
const productionAssets = production
|
|
1256
|
-
? await buildProductionAssets(
|
|
1466
|
+
? await buildProductionAssets(
|
|
1467
|
+
unitResults,
|
|
1468
|
+
resolvedVersions,
|
|
1469
|
+
realRoot,
|
|
1470
|
+
runDir,
|
|
1471
|
+
unitBaselineResults,
|
|
1472
|
+
options.buildFrozenGitRepositoryFn ?? buildFrozenGitRepository,
|
|
1473
|
+
freezeTimestamp,
|
|
1474
|
+
)
|
|
1257
1475
|
: null;
|
|
1258
1476
|
|
|
1259
1477
|
const units = unitResults.map(({ unit, manifest }, idx) => {
|
|
@@ -1267,14 +1485,19 @@ export async function prepareRelease(options) {
|
|
|
1267
1485
|
tagTemplate: unit.version?.tagTemplate,
|
|
1268
1486
|
snapshotDigest: snapshotDigests[idx],
|
|
1269
1487
|
...(productionAssets ? {
|
|
1270
|
-
productionConfig: unit
|
|
1488
|
+
productionConfig: normalizedProductionConfig(unit),
|
|
1271
1489
|
frozenSnapshot: {
|
|
1272
1490
|
path: productionAssets[idx].snapshotPath,
|
|
1273
1491
|
manifestDigest: productionAssets[idx].manifestDigest,
|
|
1274
1492
|
gitObjectDir: productionAssets[idx].gitObjectDir,
|
|
1275
1493
|
branch: productionAssets[idx].branch,
|
|
1494
|
+
branchStrategy: productionAssets[idx].branchStrategy,
|
|
1276
1495
|
commit: productionAssets[idx].commit,
|
|
1277
1496
|
tree: productionAssets[idx].tree,
|
|
1497
|
+
commitTimestamp: productionAssets[idx].commitTimestamp,
|
|
1498
|
+
...(productionAssets[idx].parentCommit
|
|
1499
|
+
? { parentCommit: productionAssets[idx].parentCommit }
|
|
1500
|
+
: {}),
|
|
1278
1501
|
npm: productionAssets[idx].npm,
|
|
1279
1502
|
},
|
|
1280
1503
|
} : {}),
|
|
@@ -1300,6 +1523,7 @@ export async function prepareRelease(options) {
|
|
|
1300
1523
|
capturedAt: baseline.capturedAt,
|
|
1301
1524
|
},
|
|
1302
1525
|
configDigest,
|
|
1526
|
+
verificationGates: config.verificationGates ?? [],
|
|
1303
1527
|
snapshotDigest: overallSnapshotDigest,
|
|
1304
1528
|
...(production ? {
|
|
1305
1529
|
production: {
|
|
@@ -1309,7 +1533,7 @@ export async function prepareRelease(options) {
|
|
|
1309
1533
|
} : {}),
|
|
1310
1534
|
units,
|
|
1311
1535
|
externalActions,
|
|
1312
|
-
createdAt: (clock ? clock() : new Date().toISOString()),
|
|
1536
|
+
createdAt: production ? freezeTimestamp : (clock ? clock() : new Date().toISOString()),
|
|
1313
1537
|
};
|
|
1314
1538
|
|
|
1315
1539
|
await evidence.append({
|
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',
|
|
@@ -387,6 +389,7 @@ export async function publishRelease(options) {
|
|
|
387
389
|
gitObjectDir: frozen.gitObjectDir,
|
|
388
390
|
commit: frozen.commit,
|
|
389
391
|
tree: frozen.tree,
|
|
392
|
+
commitTimestamp: frozen.commitTimestamp,
|
|
390
393
|
});
|
|
391
394
|
assertInsideAssetRoot(assetRoot, git.gitDir, 'frozen git object directory');
|
|
392
395
|
if (frozen.npm) {
|
|
@@ -812,6 +815,49 @@ export async function publishRelease(options) {
|
|
|
812
815
|
latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
|
|
813
816
|
}
|
|
814
817
|
|
|
818
|
+
// Close the push -> default-branch TOCTOU window with a final read-only
|
|
819
|
+
// consistency pass. Both the branch tip and default-branch name are bound
|
|
820
|
+
// in the frozen action expectations. A late change keeps the saga PARTIAL
|
|
821
|
+
// and must be resolved by reconcile/human review.
|
|
822
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
823
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
|
|
824
|
+
for (let index = 0; index < orderedActions.length; index += 1) {
|
|
825
|
+
const action = orderedActions[index];
|
|
826
|
+
if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
|
|
827
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
828
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
829
|
+
let observed;
|
|
830
|
+
try {
|
|
831
|
+
observed = await adapter.observe(
|
|
832
|
+
{ actionType: adapterActionType, ...action.parameters, expected: action.expected },
|
|
833
|
+
{ externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
|
|
834
|
+
);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
observed = { observation: null, error: error.message };
|
|
837
|
+
}
|
|
838
|
+
const observation = observed?.observation;
|
|
839
|
+
const observable = observation && !(observed.error && Object.keys(observation).length === 0);
|
|
840
|
+
const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
|
|
841
|
+
if (!comparison.matches) {
|
|
842
|
+
checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
|
|
843
|
+
checkpoints[index].error = observable
|
|
844
|
+
? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
|
|
845
|
+
: `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
|
|
846
|
+
await evidence.append({
|
|
847
|
+
phase: 'safety-gate',
|
|
848
|
+
gate: 'final-branch-consistency',
|
|
849
|
+
status: 'failed',
|
|
850
|
+
actionId: action.id,
|
|
851
|
+
error: checkpoints[index].error,
|
|
852
|
+
});
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
|
|
857
|
+
await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
815
861
|
// Determine overall status
|
|
816
862
|
const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
|
|
817
863
|
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
|
// =======================================================================
|