release-skill 0.9.32 → 0.9.34
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/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.kimi-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/CHANGELOG.md +54 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +31 -13
- package/README.zh-CN.md +17 -13
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/claude/schemas/.render-manifest.json +8 -4
- package/adapters/claude/schemas/release-plan.schema.json +85 -0
- package/adapters/claude/schemas/release-project.schema.json +18 -0
- package/adapters/claude/schemas/test-intake.schema.json +217 -0
- package/adapters/claude/skills/release-assess/SKILL.md +11 -0
- package/adapters/claude/skills/release-config/SKILL.md +6 -0
- package/adapters/claude/skills/release-help/SKILL.md +20 -4
- package/adapters/claude/skills/release-prepare/SKILL.md +11 -2
- package/adapters/claude/skills/release-setup/SKILL.md +15 -2
- package/adapters/claude/skills/release-verify/SKILL.md +2 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/codex/schemas/.render-manifest.json +8 -4
- package/adapters/codex/schemas/release-plan.schema.json +85 -0
- package/adapters/codex/schemas/release-project.schema.json +18 -0
- package/adapters/codex/schemas/test-intake.schema.json +217 -0
- package/adapters/codex/skills/release-assess/SKILL.md +11 -0
- package/adapters/codex/skills/release-config/SKILL.md +6 -0
- package/adapters/codex/skills/release-help/SKILL.md +20 -4
- package/adapters/codex/skills/release-prepare/SKILL.md +11 -2
- package/adapters/codex/skills/release-setup/SKILL.md +15 -2
- package/adapters/codex/skills/release-verify/SKILL.md +2 -0
- package/adapters/cursor/.cursor-plugin/plugin.json +1 -1
- package/adapters/cursor/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/cursor/schemas/.render-manifest.json +8 -4
- package/adapters/cursor/schemas/release-plan.schema.json +85 -0
- package/adapters/cursor/schemas/release-project.schema.json +18 -0
- package/adapters/cursor/schemas/test-intake.schema.json +217 -0
- package/adapters/cursor/skills/release-assess/SKILL.md +11 -0
- package/adapters/cursor/skills/release-config/SKILL.md +6 -0
- package/adapters/cursor/skills/release-help/SKILL.md +20 -4
- package/adapters/cursor/skills/release-prepare/SKILL.md +11 -2
- package/adapters/cursor/skills/release-setup/SKILL.md +15 -2
- package/adapters/cursor/skills/release-verify/SKILL.md +2 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/kimi/schemas/.render-manifest.json +8 -4
- package/adapters/kimi/schemas/release-plan.schema.json +85 -0
- package/adapters/kimi/schemas/release-project.schema.json +18 -0
- package/adapters/kimi/schemas/test-intake.schema.json +217 -0
- package/adapters/kimi/skills/release-assess/SKILL.md +11 -0
- package/adapters/kimi/skills/release-config/SKILL.md +6 -0
- package/adapters/kimi/skills/release-help/SKILL.md +20 -4
- package/adapters/kimi/skills/release-prepare/SKILL.md +11 -2
- package/adapters/kimi/skills/release-setup/SKILL.md +15 -2
- package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
- package/adapters/qoder/.qoder-plugin/plugin.json +1 -1
- package/adapters/qoder/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/qoder/schemas/.render-manifest.json +8 -4
- package/adapters/qoder/schemas/release-plan.schema.json +85 -0
- package/adapters/qoder/schemas/release-project.schema.json +18 -0
- package/adapters/qoder/schemas/test-intake.schema.json +217 -0
- package/adapters/qoder/skills/release-assess/SKILL.md +11 -0
- package/adapters/qoder/skills/release-config/SKILL.md +6 -0
- package/adapters/qoder/skills/release-help/SKILL.md +20 -4
- package/adapters/qoder/skills/release-prepare/SKILL.md +11 -2
- package/adapters/qoder/skills/release-setup/SKILL.md +15 -2
- package/adapters/qoder/skills/release-verify/SKILL.md +2 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +5855 -4045
- package/adapters/workbuddy/schemas/.render-manifest.json +8 -4
- package/adapters/workbuddy/schemas/release-plan.schema.json +85 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +18 -0
- package/adapters/workbuddy/schemas/test-intake.schema.json +217 -0
- package/adapters/workbuddy/skills/release-assess/SKILL.md +11 -0
- package/adapters/workbuddy/skills/release-config/SKILL.md +6 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +20 -4
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +11 -2
- package/adapters/workbuddy/skills/release-setup/SKILL.md +15 -2
- package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
- package/bin/release-skill-cli.mjs +37 -2
- package/bin/release-skill.bundle.mjs +5855 -4045
- package/package.json +1 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +6 -6
- package/references/01-state-machine.md +12 -1
- package/references/02-project-config.md +20 -0
- package/references/05-evidence-and-errors.md +30 -0
- package/schemas/.render-manifest.json +8 -4
- package/schemas/release-plan.schema.json +85 -0
- package/schemas/release-project.schema.json +18 -0
- package/schemas/test-intake.schema.json +217 -0
- package/skills/release-assess/SKILL.md +11 -0
- package/skills/release-config/SKILL.md +6 -0
- package/skills/release-help/SKILL.md +20 -4
- package/skills/release-prepare/SKILL.md +11 -2
- package/skills/release-setup/SKILL.md +15 -2
- package/skills/release-verify/SKILL.md +2 -0
- package/skills-src/release-assess/SKILL.md +11 -0
- package/skills-src/release-config/SKILL.md +6 -0
- package/skills-src/release-help/SKILL.md +20 -4
- package/skills-src/release-prepare/SKILL.md +11 -2
- package/skills-src/release-setup/SKILL.md +15 -2
- package/skills-src/release-verify/SKILL.md +2 -0
- package/src/commands/approve.mjs +13 -0
- package/src/commands/prepare.mjs +501 -5
- package/src/commands/publish.mjs +2 -0
- package/src/commands/reconcile.mjs +2 -0
- package/src/commands/route.mjs +32 -7
- package/src/commands/setup.mjs +13 -6
- package/src/commands/ship.mjs +168 -5
- package/src/core/config.mjs +5 -0
- package/src/core/plan.mjs +3 -1
- package/src/core/test-candidate.mjs +649 -0
- package/src/core/test-intake.mjs +645 -0
package/src/commands/prepare.mjs
CHANGED
|
@@ -36,7 +36,15 @@ import {
|
|
|
36
36
|
} from '../core/public-surface.mjs';
|
|
37
37
|
import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
|
|
38
38
|
import { asError, createEvidenceWriter } from '../core/evidence.mjs';
|
|
39
|
-
import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
39
|
+
import { computePlanDigest, validatePlan, validatePlanActionCompleteness, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
|
|
40
|
+
import { assertTestIntakeDeclaration, readTestRequirements, subjectDigestOf } from '../core/test-intake.mjs';
|
|
41
|
+
import {
|
|
42
|
+
decideTestCandidateHandoff,
|
|
43
|
+
projectPrepared,
|
|
44
|
+
readCandidateEvidenceSha256,
|
|
45
|
+
readTestCandidate,
|
|
46
|
+
writeTestCandidate,
|
|
47
|
+
} from '../core/test-candidate.mjs';
|
|
40
48
|
import { sha256Hex } from '../core/digest.mjs';
|
|
41
49
|
import {
|
|
42
50
|
CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
|
|
@@ -54,6 +62,9 @@ import {
|
|
|
54
62
|
computeFrozenSnapshot,
|
|
55
63
|
normalizeGitTimestamp,
|
|
56
64
|
sealFrozenSnapshot,
|
|
65
|
+
verifyFrozenFile,
|
|
66
|
+
verifyFrozenGitRepository,
|
|
67
|
+
verifyFrozenSnapshot,
|
|
57
68
|
} from '../snapshot/frozen.mjs';
|
|
58
69
|
import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
|
|
59
70
|
import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
|
|
@@ -91,10 +102,11 @@ import {
|
|
|
91
102
|
normalizePostPublishDeclaration,
|
|
92
103
|
orderNormalizedHooks,
|
|
93
104
|
validatePostPublishHookIdUniqueness,
|
|
105
|
+
normalizePostPublishView,
|
|
94
106
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
95
107
|
} from '../core/postpublish.mjs';
|
|
96
|
-
import { freezeExecutionBundle, bundleRootForAuthorityDir } from '../core/postpublish-bundle.mjs';
|
|
97
|
-
import { digestReleaseAssetIdentities } from '../core/release-assets.mjs';
|
|
108
|
+
import { freezeExecutionBundle, bundleRootForAuthorityDir, verifyExecutionBundle } from '../core/postpublish-bundle.mjs';
|
|
109
|
+
import { digestReleaseAssetIdentities, normalizeReleaseAssets } from '../core/release-assets.mjs';
|
|
98
110
|
|
|
99
111
|
// ---------------------------------------------------------------------------
|
|
100
112
|
// 安装契约常量
|
|
@@ -3288,6 +3300,363 @@ function resolveRequestedReleaseUnits(config, requestedUnitIds, retryOptions) {
|
|
|
3288
3300
|
};
|
|
3289
3301
|
}
|
|
3290
3302
|
|
|
3303
|
+
function candidateReprepare(message, drifts, details = {}) {
|
|
3304
|
+
throw new ReleaseError(GATE_FAILED, message, {
|
|
3305
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3306
|
+
drifts,
|
|
3307
|
+
...details,
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
async function recheckCandidateClosure(realRoot, prepared, config, unitIds) {
|
|
3312
|
+
const authority = prepared?.sourceAuthority;
|
|
3313
|
+
if (!authority) return;
|
|
3314
|
+
const stored = authority.inputDigest;
|
|
3315
|
+
if (typeof stored !== 'string' || stored.length === 0) {
|
|
3316
|
+
candidateReprepare('test candidate source inputDigest is missing', ['inputDigest'], {
|
|
3317
|
+
storedInputDigest: stored ?? null,
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
const selected = new Set(unitIds ?? []);
|
|
3321
|
+
const units = (config.releaseUnits ?? []).filter((unit) => selected.has(unit.id));
|
|
3322
|
+
const closure = await computeSourceInputClosure({ units, root: realRoot });
|
|
3323
|
+
if (closure.digest !== stored) {
|
|
3324
|
+
candidateReprepare(
|
|
3325
|
+
`test candidate source inputDigest changed: ${stored} -> ${closure.digest}`,
|
|
3326
|
+
[`inputDigest:${stored}->${closure.digest}`],
|
|
3327
|
+
{ inputDigest: closure.digest, storedInputDigest: stored },
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
async function recheckExecutionBundle(realRoot, prepared) {
|
|
3333
|
+
let declarations;
|
|
3334
|
+
try {
|
|
3335
|
+
declarations = normalizePostPublishView(prepared);
|
|
3336
|
+
} catch (err) {
|
|
3337
|
+
if (!prepared?.postPublish) return;
|
|
3338
|
+
throw err;
|
|
3339
|
+
}
|
|
3340
|
+
const anchor = resolve(realRoot, '.release-skill', 'release-plan.json');
|
|
3341
|
+
for (const declaration of declarations) {
|
|
3342
|
+
if (!declaration?.executionBundle) continue;
|
|
3343
|
+
try {
|
|
3344
|
+
await verifyExecutionBundle({ planPath: anchor, postPublish: declaration });
|
|
3345
|
+
} catch (err) {
|
|
3346
|
+
candidateReprepare(
|
|
3347
|
+
`test candidate execution bundle changed: ${err.message}`,
|
|
3348
|
+
['executionBundle'],
|
|
3349
|
+
{ cause: err.code ?? 'unknown' },
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
function recheckReceipt(prepared) {
|
|
3356
|
+
if (!prepared?.publicSourceAuthorityReceipt) return;
|
|
3357
|
+
const completeness = validatePlanActionCompleteness({
|
|
3358
|
+
...prepared,
|
|
3359
|
+
status: prepared.status ?? 'PREPARED',
|
|
3360
|
+
});
|
|
3361
|
+
const failures = (completeness.details?.failures ?? []).filter((failure) => (
|
|
3362
|
+
String(failure).includes('publicSourceAuthorityReceipt')
|
|
3363
|
+
));
|
|
3364
|
+
if (failures.length > 0) {
|
|
3365
|
+
candidateReprepare(
|
|
3366
|
+
`test candidate publicSourceAuthorityReceipt is incomplete: ${failures.join('; ')}`,
|
|
3367
|
+
['receipt'],
|
|
3368
|
+
{ failures },
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
/**
|
|
3374
|
+
* Reattach one saved test candidate. Does not rebuild and does not write a
|
|
3375
|
+
* second candidate. Frozen git checks run only when the saved unit still
|
|
3376
|
+
* carries a frozenSnapshot.
|
|
3377
|
+
*/
|
|
3378
|
+
async function reattachTestCandidate({
|
|
3379
|
+
realRoot,
|
|
3380
|
+
runDir,
|
|
3381
|
+
releaseDir,
|
|
3382
|
+
evidence,
|
|
3383
|
+
evidenceDir,
|
|
3384
|
+
config,
|
|
3385
|
+
configDigest,
|
|
3386
|
+
production,
|
|
3387
|
+
workflow,
|
|
3388
|
+
offline,
|
|
3389
|
+
version,
|
|
3390
|
+
options,
|
|
3391
|
+
clock,
|
|
3392
|
+
runWarnings,
|
|
3393
|
+
adoptedCandidate,
|
|
3394
|
+
output,
|
|
3395
|
+
}) {
|
|
3396
|
+
const savedOptions = adoptedCandidate.candidate.prepareOptions;
|
|
3397
|
+
const workflowExplicit = options?.workflowExplicit === true;
|
|
3398
|
+
const offlineExplicit = options?.offlineExplicit === true;
|
|
3399
|
+
const versionExplicit = options?.versionExplicit === true;
|
|
3400
|
+
const resolvedWorkflow = workflowExplicit ? workflow : savedOptions.workflow;
|
|
3401
|
+
const resolvedOffline = offlineExplicit ? offline : savedOptions.offline;
|
|
3402
|
+
const resolvedUnitIds = Array.isArray(options?.unitIds)
|
|
3403
|
+
? options.unitIds
|
|
3404
|
+
: savedOptions.unitIds;
|
|
3405
|
+
const extraDrifts = [];
|
|
3406
|
+
if (versionExplicit) {
|
|
3407
|
+
for (const unitId of savedOptions.unitIds) {
|
|
3408
|
+
if (savedOptions.unitVersions[unitId] !== version) extraDrifts.push(`targetVersion.${unitId}`);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
for (const unitId of savedOptions.unitIds) {
|
|
3412
|
+
const unit = (config.releaseUnits ?? []).find((item) => item.id === unitId);
|
|
3413
|
+
if (!unit) {
|
|
3414
|
+
extraDrifts.push(`unit.${unitId}`);
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
try {
|
|
3418
|
+
const onDisk = await resolveUnitVersion(unit, realRoot, undefined);
|
|
3419
|
+
if (onDisk !== savedOptions.unitVersions[unitId]) extraDrifts.push(`unitVersions.${unitId}`);
|
|
3420
|
+
} catch {
|
|
3421
|
+
extraDrifts.push(`unitVersions.${unitId}`);
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
const requirements = await readTestRequirements({
|
|
3425
|
+
root: realRoot,
|
|
3426
|
+
config,
|
|
3427
|
+
selectedUnitIds: savedOptions.unitIds,
|
|
3428
|
+
});
|
|
3429
|
+
const evidenceCandidateSha256 = await readCandidateEvidenceSha256(
|
|
3430
|
+
realRoot,
|
|
3431
|
+
options.candidatePath,
|
|
3432
|
+
);
|
|
3433
|
+
const decision = await decideTestCandidateHandoff({
|
|
3434
|
+
root: realRoot,
|
|
3435
|
+
config,
|
|
3436
|
+
production,
|
|
3437
|
+
candidatePath: options.candidatePath,
|
|
3438
|
+
requirements,
|
|
3439
|
+
prepareOptions: {
|
|
3440
|
+
workflow: resolvedWorkflow,
|
|
3441
|
+
offline: resolvedOffline,
|
|
3442
|
+
production: true,
|
|
3443
|
+
unitIds: resolvedUnitIds,
|
|
3444
|
+
unitVersions: savedOptions.unitVersions,
|
|
3445
|
+
},
|
|
3446
|
+
evidenceCandidateSha256,
|
|
3447
|
+
expectedCandidateSha256: options.expectedCandidateSha256,
|
|
3448
|
+
expectedSubjectDigest: options.expectedSubjectDigest,
|
|
3449
|
+
configDigest,
|
|
3450
|
+
extraDrifts,
|
|
3451
|
+
});
|
|
3452
|
+
const savedPrepared = adoptedCandidate.candidate.prepared;
|
|
3453
|
+
await recheckCandidateClosure(realRoot, savedPrepared, config, savedOptions.unitIds);
|
|
3454
|
+
await recheckExecutionBundle(realRoot, savedPrepared);
|
|
3455
|
+
recheckReceipt(savedPrepared);
|
|
3456
|
+
const now = clock ? clock() : new Date().toISOString();
|
|
3457
|
+
if (decision.kind === 'await-results') {
|
|
3458
|
+
await evidence.append({
|
|
3459
|
+
phase: 'test-candidate',
|
|
3460
|
+
status: 'awaiting',
|
|
3461
|
+
candidatePath: decision.candidatePath,
|
|
3462
|
+
candidateSha256: decision.candidateSha256,
|
|
3463
|
+
subjectDigest: decision.subjectDigest,
|
|
3464
|
+
});
|
|
3465
|
+
await evidence.finish({
|
|
3466
|
+
status: 'NEEDS_INPUT',
|
|
3467
|
+
candidatePath: decision.candidatePath,
|
|
3468
|
+
candidateSha256: decision.candidateSha256,
|
|
3469
|
+
subjectDigest: decision.subjectDigest,
|
|
3470
|
+
completedAt: now,
|
|
3471
|
+
});
|
|
3472
|
+
return {
|
|
3473
|
+
status: 'NEEDS_INPUT',
|
|
3474
|
+
code: 'GATE_FAILED',
|
|
3475
|
+
exitCode: 13,
|
|
3476
|
+
candidatePath: decision.candidatePath,
|
|
3477
|
+
candidateSha256: decision.candidateSha256,
|
|
3478
|
+
subjectDigest: decision.subjectDigest,
|
|
3479
|
+
evidenceDir,
|
|
3480
|
+
requirements: (decision.gaps ?? []).map((item) => item.requirementId).filter(Boolean),
|
|
3481
|
+
gaps: decision.gaps,
|
|
3482
|
+
message: 'testIntake results for this candidate are not accepted yet',
|
|
3483
|
+
nextSteps: [{
|
|
3484
|
+
code: 'TEST_INTAKE_NEEDS_INPUT',
|
|
3485
|
+
message: 'Bind the result file to this subjectDigest and re-run prepare with the same candidate.',
|
|
3486
|
+
}],
|
|
3487
|
+
warnings: runWarnings,
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
if (decision.kind === 'already-accepted') {
|
|
3491
|
+
await evidence.finish({
|
|
3492
|
+
status: decision.status ?? 'PREPARED',
|
|
3493
|
+
planDigest: decision.planDigest,
|
|
3494
|
+
completedAt: now,
|
|
3495
|
+
});
|
|
3496
|
+
return {
|
|
3497
|
+
status: decision.status,
|
|
3498
|
+
planPath: resolve(realRoot, decision.planPath),
|
|
3499
|
+
planDigest: decision.planDigest,
|
|
3500
|
+
evidenceDir,
|
|
3501
|
+
warnings: runWarnings,
|
|
3502
|
+
nextSteps: [],
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
if (decision.prepared?.sourceAuthority?.entries) {
|
|
3506
|
+
try {
|
|
3507
|
+
const dirty = await checkSourceInputDirty({
|
|
3508
|
+
closure: { entries: decision.prepared.sourceAuthority.entries },
|
|
3509
|
+
root: realRoot,
|
|
3510
|
+
});
|
|
3511
|
+
if (dirty.dirty) {
|
|
3512
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate source inputs changed', {
|
|
3513
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3514
|
+
drifts: dirty.dirtyPaths.map((filePath) => `source:${filePath}`),
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
} catch (err) {
|
|
3518
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3519
|
+
throw new ReleaseError(GATE_FAILED, 'test candidate source inputs could not be rechecked', {
|
|
3520
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3521
|
+
drifts: ['sourceAuthority'],
|
|
3522
|
+
cause: err.code ?? 'unknown',
|
|
3523
|
+
});
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
for (const unit of decision.prepared.units ?? []) {
|
|
3527
|
+
const frozen = unit.frozenSnapshot;
|
|
3528
|
+
if (!frozen) continue;
|
|
3529
|
+
try {
|
|
3530
|
+
// snapshotDigest is the pre-seal content hash. sealFrozenSnapshot
|
|
3531
|
+
// changes file mode, and that mode is part of the sealed digest stored
|
|
3532
|
+
// on frozenSnapshot.manifestDigest — the same field publish rechecks.
|
|
3533
|
+
if (frozen.path || frozen.manifestDigest) {
|
|
3534
|
+
await verifyFrozenSnapshot({
|
|
3535
|
+
root: realRoot,
|
|
3536
|
+
snapshotPath: frozen.path,
|
|
3537
|
+
expectedDigest: frozen.manifestDigest,
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
if (frozen.gitObjectDir) {
|
|
3541
|
+
await verifyFrozenGitRepository({
|
|
3542
|
+
root: realRoot,
|
|
3543
|
+
gitObjectDir: frozen.gitObjectDir,
|
|
3544
|
+
commit: frozen.commit,
|
|
3545
|
+
tree: frozen.tree,
|
|
3546
|
+
parentCommit: frozen.parentCommit,
|
|
3547
|
+
commitTimestamp: frozen.commitTimestamp,
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
const npmDistribution = (unit.distributions ?? []).find((dist) => dist.type === 'npm');
|
|
3551
|
+
if (frozen.npm?.tarballPath && npmDistribution) {
|
|
3552
|
+
await verifyFrozenNpmTarballContract({
|
|
3553
|
+
package: npmDistribution.package,
|
|
3554
|
+
version: unit.targetVersion,
|
|
3555
|
+
tarballPath: frozen.npm.tarballPath,
|
|
3556
|
+
tarballSha256: frozen.npm.tarballSha256,
|
|
3557
|
+
integrity: frozen.npm.integrity,
|
|
3558
|
+
}, realRoot);
|
|
3559
|
+
}
|
|
3560
|
+
} catch (err) {
|
|
3561
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3562
|
+
throw new ReleaseError(GATE_FAILED, `test candidate frozen object changed for unit "${unit.id}"`, {
|
|
3563
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3564
|
+
drifts: [`frozen:${unit.id}`],
|
|
3565
|
+
cause: err.code ?? 'unknown',
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
for (const action of decision.prepared.externalActions ?? []) {
|
|
3570
|
+
let assets = [];
|
|
3571
|
+
if (action.parameters?.releaseAssets !== undefined) {
|
|
3572
|
+
try {
|
|
3573
|
+
assets = normalizeReleaseAssets(action.parameters.releaseAssets);
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
throw new ReleaseError(GATE_FAILED, `test candidate release assets could not be rechecked for "${action.id}"`, {
|
|
3576
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3577
|
+
drifts: [`releaseAsset:${action.id}`],
|
|
3578
|
+
cause: err.code ?? 'unknown',
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
for (const releaseAsset of assets) {
|
|
3583
|
+
try {
|
|
3584
|
+
await verifyFrozenFile({
|
|
3585
|
+
root: realRoot,
|
|
3586
|
+
filePath: releaseAsset.path,
|
|
3587
|
+
expectedSha256: releaseAsset.sha256,
|
|
3588
|
+
label: `frozen GitHub Release asset "${releaseAsset.name}"`,
|
|
3589
|
+
});
|
|
3590
|
+
} catch (err) {
|
|
3591
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3592
|
+
throw new ReleaseError(GATE_FAILED, `test candidate release asset changed for "${releaseAsset.name}"`, {
|
|
3593
|
+
reason: 'TEST_CANDIDATE_REPREPARE',
|
|
3594
|
+
drifts: [`releaseAsset:${releaseAsset.name}`],
|
|
3595
|
+
cause: err.code ?? 'unknown',
|
|
3596
|
+
});
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
if (savedOptions.production === true && decision.prepared?.production?.mode !== 'github-npm-v1') {
|
|
3601
|
+
candidateReprepare('test candidate prepared.production does not match production prepare', ['prepared.production']);
|
|
3602
|
+
}
|
|
3603
|
+
const plan = {
|
|
3604
|
+
...decision.prepared,
|
|
3605
|
+
status: 'PREPARED',
|
|
3606
|
+
testAcceptance: decision.testAcceptance,
|
|
3607
|
+
createdAt: now,
|
|
3608
|
+
};
|
|
3609
|
+
try {
|
|
3610
|
+
validatePlan(plan);
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
if (err instanceof ReleaseError && err.details?.reason === 'TEST_CANDIDATE_REPREPARE') throw err;
|
|
3613
|
+
candidateReprepare(`test candidate plan structure is incomplete: ${err.message}`, ['plan-structure']);
|
|
3614
|
+
}
|
|
3615
|
+
const completeness = validatePlanActionCompleteness(plan);
|
|
3616
|
+
if (!completeness.passed) {
|
|
3617
|
+
const failures = completeness.details?.failures ?? [];
|
|
3618
|
+
candidateReprepare(
|
|
3619
|
+
`test candidate action completeness failed: ${failures.join('; ')}`,
|
|
3620
|
+
['action-completeness'],
|
|
3621
|
+
{ failures },
|
|
3622
|
+
);
|
|
3623
|
+
}
|
|
3624
|
+
const latestPlanPath = output ?? resolve(releaseDir, 'release-plan.json');
|
|
3625
|
+
const plannedDigest = computePlanDigest(plan);
|
|
3626
|
+
const immutablePlanPath = resolve(dirname(latestPlanPath), 'plans', `${plannedDigest}.json`);
|
|
3627
|
+
const { planPath: writtenPath, planDigest } = await writePlanImmutable(immutablePlanPath, plan);
|
|
3628
|
+
await writePlanAtomic(latestPlanPath, plan);
|
|
3629
|
+
await writeFrozenMarker(releaseDir, {
|
|
3630
|
+
planDigest,
|
|
3631
|
+
targetVersions: savedOptions.unitVersions,
|
|
3632
|
+
createdAt: plan.createdAt,
|
|
3633
|
+
runId: basename(runDir),
|
|
3634
|
+
});
|
|
3635
|
+
await evidence.append({
|
|
3636
|
+
phase: 'plan-write',
|
|
3637
|
+
status: 'completed',
|
|
3638
|
+
planPath: writtenPath,
|
|
3639
|
+
planDigest,
|
|
3640
|
+
});
|
|
3641
|
+
await evidence.finish({
|
|
3642
|
+
status: 'PREPARED',
|
|
3643
|
+
planPath: writtenPath,
|
|
3644
|
+
planDigest,
|
|
3645
|
+
configDigest,
|
|
3646
|
+
offline,
|
|
3647
|
+
workflowKind: resolvedWorkflow,
|
|
3648
|
+
completedAt: now,
|
|
3649
|
+
});
|
|
3650
|
+
return {
|
|
3651
|
+
planPath: writtenPath,
|
|
3652
|
+
planDigest,
|
|
3653
|
+
evidenceDir,
|
|
3654
|
+
warnings: runWarnings,
|
|
3655
|
+
nextSteps: [],
|
|
3656
|
+
status: 'PREPARED',
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3291
3660
|
export async function prepareRelease(options) {
|
|
3292
3661
|
const {
|
|
3293
3662
|
root,
|
|
@@ -3411,7 +3780,20 @@ export async function prepareRelease(options) {
|
|
|
3411
3780
|
await evidence.append({ phase: 'config', status: 'started' });
|
|
3412
3781
|
|
|
3413
3782
|
const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
|
|
3414
|
-
|
|
3783
|
+
assertTestIntakeDeclaration({ config, production });
|
|
3784
|
+
if (options?.candidatePath && !config.testIntake) {
|
|
3785
|
+
throw new ReleaseError(CONFIG_INVALID, 'candidate path requires testIntake', {
|
|
3786
|
+
reason: 'TEST_CANDIDATE_WITHOUT_INTAKE',
|
|
3787
|
+
field: 'candidatePath',
|
|
3788
|
+
});
|
|
3789
|
+
}
|
|
3790
|
+
const adoptedCandidate = options?.candidatePath
|
|
3791
|
+
? await readTestCandidate({ root: realRoot, candidatePath: options.candidatePath })
|
|
3792
|
+
: null;
|
|
3793
|
+
const selectionUnitIds = adoptedCandidate && !Array.isArray(options?.unitIds)
|
|
3794
|
+
? adoptedCandidate.candidate.prepareOptions.unitIds
|
|
3795
|
+
: unitIds;
|
|
3796
|
+
const releaseSelection = resolveRequestedReleaseUnits(config, selectionUnitIds, {
|
|
3415
3797
|
root,
|
|
3416
3798
|
version,
|
|
3417
3799
|
offline,
|
|
@@ -3455,6 +3837,34 @@ export async function prepareRelease(options) {
|
|
|
3455
3837
|
});
|
|
3456
3838
|
}
|
|
3457
3839
|
|
|
3840
|
+
if (adoptedCandidate) {
|
|
3841
|
+
return await reattachTestCandidate({
|
|
3842
|
+
realRoot,
|
|
3843
|
+
runDir,
|
|
3844
|
+
releaseDir,
|
|
3845
|
+
evidence,
|
|
3846
|
+
evidenceDir,
|
|
3847
|
+
config,
|
|
3848
|
+
configDigest,
|
|
3849
|
+
production,
|
|
3850
|
+
workflow,
|
|
3851
|
+
offline,
|
|
3852
|
+
version,
|
|
3853
|
+
options,
|
|
3854
|
+
clock,
|
|
3855
|
+
runWarnings,
|
|
3856
|
+
adoptedCandidate,
|
|
3857
|
+
output,
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
if (config.testIntake) {
|
|
3861
|
+
await readTestRequirements({
|
|
3862
|
+
root: realRoot,
|
|
3863
|
+
config,
|
|
3864
|
+
selectedUnitIds: releaseSelection.selectedUnitIds,
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3458
3868
|
// --- Step 1-fresh: Bundle freshness gate (BUNDLE_STALE, fail-closed) ---
|
|
3459
3869
|
// 2026-08-18 investigation §4.2: a stale bin/release-skill.bundle.mjs
|
|
3460
3870
|
// used to surface only deep inside test hooks. Compare the deterministic
|
|
@@ -4371,7 +4781,38 @@ export async function prepareRelease(options) {
|
|
|
4371
4781
|
// approval, it cannot be disabled by project overlays. Workflows that
|
|
4372
4782
|
// trim declared hooks record the trim; projects declaring no test hook
|
|
4373
4783
|
// pass vacuously (nothing can run incrementally there).
|
|
4374
|
-
if (
|
|
4784
|
+
if (config.testIntake) {
|
|
4785
|
+
await evidence.append({
|
|
4786
|
+
phase: 'full-test-gate',
|
|
4787
|
+
status: 'replaced',
|
|
4788
|
+
reason: 'testIntake acceptance replaces the vacuous full-test pass; applicable requirements stay required',
|
|
4789
|
+
});
|
|
4790
|
+
if (!skipDeclaredHooks && config.hooks?.test) {
|
|
4791
|
+
const testRecord = hookRecords.find((record) => record.name === 'test');
|
|
4792
|
+
const satisfied = Boolean(
|
|
4793
|
+
testRecord && testRecord.completed && testRecord.testSelection === 'full',
|
|
4794
|
+
);
|
|
4795
|
+
if (!satisfied) {
|
|
4796
|
+
await evidence.append({
|
|
4797
|
+
phase: 'full-test-gate',
|
|
4798
|
+
status: 'blocking',
|
|
4799
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
4800
|
+
cached: Boolean(testRecord?.cached),
|
|
4801
|
+
});
|
|
4802
|
+
throw new ReleaseError(
|
|
4803
|
+
GATE_FAILED,
|
|
4804
|
+
testRecord?.testSelection === 'incremental'
|
|
4805
|
+
? 'full-test freeze gate failed: incremental test selection cannot satisfy the freeze-time requirement of a completed full test run in this prepare run'
|
|
4806
|
+
: 'full-test freeze gate failed: prepare requires a completed full-mode test hook in this run before the plan digest is computed',
|
|
4807
|
+
{
|
|
4808
|
+
gate: 'full-test-freeze',
|
|
4809
|
+
testSelection: testRecord?.testSelection ?? null,
|
|
4810
|
+
cached: Boolean(testRecord?.cached),
|
|
4811
|
+
},
|
|
4812
|
+
);
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
} else if (skipDeclaredHooks) {
|
|
4375
4816
|
await evidence.append({
|
|
4376
4817
|
phase: 'full-test-gate',
|
|
4377
4818
|
status: 'skipped',
|
|
@@ -4977,6 +5418,61 @@ export async function prepareRelease(options) {
|
|
|
4977
5418
|
actionCount: externalActions.length,
|
|
4978
5419
|
});
|
|
4979
5420
|
|
|
5421
|
+
if (config.testIntake) {
|
|
5422
|
+
const requirementsNow = await readTestRequirements({
|
|
5423
|
+
root: realRoot,
|
|
5424
|
+
config,
|
|
5425
|
+
selectedUnitIds: releaseSelection.selectedUnitIds,
|
|
5426
|
+
});
|
|
5427
|
+
const saved = await writeTestCandidate({
|
|
5428
|
+
root: realRoot,
|
|
5429
|
+
runDir,
|
|
5430
|
+
prepared: projectPrepared(plan),
|
|
5431
|
+
requirements: requirementsNow,
|
|
5432
|
+
prepareOptions: {
|
|
5433
|
+
workflow,
|
|
5434
|
+
offline,
|
|
5435
|
+
production: true,
|
|
5436
|
+
unitIds: releaseSelection.selectedUnitIds,
|
|
5437
|
+
unitVersions: Object.fromEntries(
|
|
5438
|
+
configUnits.map((unit, index) => [unit.id, resolvedVersions[index]]),
|
|
5439
|
+
),
|
|
5440
|
+
},
|
|
5441
|
+
});
|
|
5442
|
+
const subjectDigest = subjectDigestOf(projectPrepared(plan));
|
|
5443
|
+
await evidence.append({
|
|
5444
|
+
phase: 'test-candidate',
|
|
5445
|
+
status: 'saved',
|
|
5446
|
+
candidatePath: saved.candidatePath,
|
|
5447
|
+
candidateSha256: saved.sha256,
|
|
5448
|
+
subjectDigest,
|
|
5449
|
+
});
|
|
5450
|
+
await evidence.finish({
|
|
5451
|
+
status: 'NEEDS_INPUT',
|
|
5452
|
+
candidatePath: saved.candidatePath,
|
|
5453
|
+
candidateSha256: saved.sha256,
|
|
5454
|
+
subjectDigest,
|
|
5455
|
+
configDigest,
|
|
5456
|
+
completedAt: (clock ? clock() : new Date().toISOString()),
|
|
5457
|
+
});
|
|
5458
|
+
return {
|
|
5459
|
+
status: 'NEEDS_INPUT',
|
|
5460
|
+
code: 'GATE_FAILED',
|
|
5461
|
+
exitCode: 13,
|
|
5462
|
+
candidatePath: saved.candidatePath,
|
|
5463
|
+
candidateSha256: saved.sha256,
|
|
5464
|
+
subjectDigest,
|
|
5465
|
+
evidenceDir,
|
|
5466
|
+
requirements: requirementsNow.preFreeze.map((item) => item.id),
|
|
5467
|
+
message: 'testIntake candidate saved; results for this object are not accepted yet',
|
|
5468
|
+
nextSteps: [{
|
|
5469
|
+
code: 'TEST_INTAKE_NEEDS_INPUT',
|
|
5470
|
+
message: 'Call TEST on this subject, bind the result file to subjectDigest, then re-run prepare with the same candidate.',
|
|
5471
|
+
}],
|
|
5472
|
+
warnings: runWarnings,
|
|
5473
|
+
};
|
|
5474
|
+
}
|
|
5475
|
+
|
|
4980
5476
|
// --- Step 8: Validate and write plan atomically ---
|
|
4981
5477
|
await evidence.append({ phase: 'plan-write', status: 'started' });
|
|
4982
5478
|
|
package/src/commands/publish.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { PLATFORMS, normalizeHostId } from '../platforms/registry.mjs';
|
|
|
31
31
|
import { deriveSurfaceHostBinding, groupSurfaceHostBindings, pluginRootFromManifestRelativePath } from '../core/surface-host-bindings.mjs';
|
|
32
32
|
|
|
33
33
|
import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
|
|
34
|
+
import { verifyTestAcceptance } from '../core/test-intake.mjs';
|
|
34
35
|
import {
|
|
35
36
|
assertImmutableApprovalAuthority,
|
|
36
37
|
computeApprovalDigest,
|
|
@@ -497,6 +498,7 @@ export async function publishRelease(options) {
|
|
|
497
498
|
}
|
|
498
499
|
validatePlan(plan);
|
|
499
500
|
assertImmutablePlanAuthority(planPath, plan);
|
|
501
|
+
await verifyTestAcceptance({ root, plan });
|
|
500
502
|
const isProductionPlan = plan.production?.mode === 'github-npm-v1';
|
|
501
503
|
|
|
502
504
|
// --- Set up directories ---
|
|
@@ -27,6 +27,7 @@ import { readFile, mkdir } from 'node:fs/promises';
|
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
|
|
29
29
|
import { assertImmutablePlanAuthority, computePlanDigest, validatePlan, validatePlanActionCompleteness } from '../core/plan.mjs';
|
|
30
|
+
import { verifyTestAcceptance } from '../core/test-intake.mjs';
|
|
30
31
|
import {
|
|
31
32
|
assertImmutableApprovalAuthority,
|
|
32
33
|
computeApprovalDigest,
|
|
@@ -148,6 +149,7 @@ export async function reconcileRelease(options) {
|
|
|
148
149
|
}
|
|
149
150
|
validatePlan(plan);
|
|
150
151
|
assertImmutablePlanAuthority(planPath, plan);
|
|
152
|
+
await verifyTestAcceptance({ root, plan });
|
|
151
153
|
const isProductionPlan = plan.production?.mode === 'github-npm-v1';
|
|
152
154
|
|
|
153
155
|
// --- Set up directories ---
|
package/src/commands/route.mjs
CHANGED
|
@@ -354,7 +354,7 @@ export async function classifyBaselineSurface(root, prevCommit) {
|
|
|
354
354
|
* read. Invalid authority is diagnostic, never permission for a new release.
|
|
355
355
|
*
|
|
356
356
|
* @param {string} root - project root directory
|
|
357
|
-
* @returns {Promise<{hasPartialRun: boolean, recoveryActionCode: string|null, runs: object[]}>}
|
|
357
|
+
* @returns {Promise<{hasPartialRun: boolean, recoveryActionCode: string|null, runs: object[], observedRuns: object[]}>}
|
|
358
358
|
*/
|
|
359
359
|
export async function readRunRouting(root, options = {}) {
|
|
360
360
|
const cwd = resolve(root);
|
|
@@ -364,7 +364,9 @@ export async function readRunRouting(root, options = {}) {
|
|
|
364
364
|
try {
|
|
365
365
|
runDirs = await readdir(runsDir);
|
|
366
366
|
} catch (error) {
|
|
367
|
-
if (error.code === 'ENOENT')
|
|
367
|
+
if (error.code === 'ENOENT') {
|
|
368
|
+
return { hasPartialRun: false, recoveryActionCode: null, runs: [], observedRuns: [] };
|
|
369
|
+
}
|
|
368
370
|
const relativePath = relative(cwd, runsDir);
|
|
369
371
|
const diagnostic = {
|
|
370
372
|
code: error.code ?? 'RUNS_DIRECTORY_UNREADABLE',
|
|
@@ -383,6 +385,7 @@ export async function readRunRouting(root, options = {}) {
|
|
|
383
385
|
hasPartialRun: false,
|
|
384
386
|
recoveryActionCode: null,
|
|
385
387
|
runs: [],
|
|
388
|
+
observedRuns: [],
|
|
386
389
|
diagnostics: [{
|
|
387
390
|
path: relativePath,
|
|
388
391
|
runPath: runsDir,
|
|
@@ -455,6 +458,22 @@ export async function readRunRouting(root, options = {}) {
|
|
|
455
458
|
}
|
|
456
459
|
records.push(record);
|
|
457
460
|
}
|
|
461
|
+
// Runs with a null recovery code, and runs seen without a target version,
|
|
462
|
+
// stay out of `runs`. Callers that need the recorded status still see them.
|
|
463
|
+
// Recovery selection below is unchanged.
|
|
464
|
+
const observedRuns = records.flatMap((record) => {
|
|
465
|
+
const run = record.run;
|
|
466
|
+
if (!run?.planPath || !run.status) return [];
|
|
467
|
+
return [{
|
|
468
|
+
runPath: record.runPath ?? null,
|
|
469
|
+
planPath: run.planPath,
|
|
470
|
+
command: run.command ?? null,
|
|
471
|
+
status: run.status,
|
|
472
|
+
sourceRunPath: run.sourceRunPath ?? null,
|
|
473
|
+
...(run.approvalPath ? { approvalPath: run.approvalPath } : {}),
|
|
474
|
+
recoveryActionCode: record.recoveryActionCode ?? null,
|
|
475
|
+
}];
|
|
476
|
+
}).sort((a, b) => String(a.runPath).localeCompare(String(b.runPath)));
|
|
458
477
|
const targetScopedRecords = [];
|
|
459
478
|
const historicalDiagnostics = [];
|
|
460
479
|
for (const record of records) {
|
|
@@ -536,6 +555,7 @@ export async function readRunRouting(root, options = {}) {
|
|
|
536
555
|
hasPartialRun: unresolved.some((r) => r.run?.status === 'PARTIAL'),
|
|
537
556
|
recoveryActionCode,
|
|
538
557
|
runs,
|
|
558
|
+
observedRuns,
|
|
539
559
|
...(unifiedDiagnostics.length > 0 ? { diagnostics: unifiedDiagnostics } : {}),
|
|
540
560
|
};
|
|
541
561
|
}
|
|
@@ -872,14 +892,15 @@ async function isProvenPreAuthorityFailure(runDir, runName) {
|
|
|
872
892
|
|
|
873
893
|
/**
|
|
874
894
|
* Postverify is a downstream run type that predates the generic recovery
|
|
875
|
-
* reader.
|
|
876
|
-
* with
|
|
895
|
+
* reader. Its completed and open records are consumable only when the summary
|
|
896
|
+
* agrees with the sealed status and the verify parent is fully lineage-valid.
|
|
877
897
|
*/
|
|
878
898
|
async function readLegacyPostverifyRecovery(runPath) {
|
|
879
899
|
try {
|
|
880
900
|
const run = await loadRun(runPath, { requireDigest: true });
|
|
881
|
-
|
|
882
|
-
|
|
901
|
+
const acceptedStatuses = new Set(['DISTRIBUTED', 'PARTIAL', 'BLOCKED', 'NEEDS_INPUT']);
|
|
902
|
+
if (run.command !== 'postverify' || !acceptedStatuses.has(run.status)) {
|
|
903
|
+
throw new ReleaseError(GATE_FAILED, 'postverify status is not a recognized completed or open state');
|
|
883
904
|
}
|
|
884
905
|
const planRaw = await readFile(run.planPath, 'utf8');
|
|
885
906
|
const plan = JSON.parse(planRaw);
|
|
@@ -932,7 +953,8 @@ async function readLegacyPostverifyRecovery(runPath) {
|
|
|
932
953
|
}))
|
|
933
954
|
));
|
|
934
955
|
validateRunCheckpointMapping(run, expectedPostVerifyActions);
|
|
935
|
-
if (run.
|
|
956
|
+
if (run.status === 'DISTRIBUTED'
|
|
957
|
+
&& run.checkpoints.some((checkpoint) => !['succeeded', 'skipped', 'NO_CHANGE'].includes(checkpoint.status))) {
|
|
936
958
|
throw new ReleaseError(GATE_FAILED, 'legacy postverify DISTRIBUTED run has incomplete checkpoints');
|
|
937
959
|
}
|
|
938
960
|
return {
|
|
@@ -940,6 +962,9 @@ async function readLegacyPostverifyRecovery(runPath) {
|
|
|
940
962
|
run,
|
|
941
963
|
plan,
|
|
942
964
|
lineage: [{ run: parent, runPath: resolve(run.sourceRunPath) }],
|
|
965
|
+
// Postverify continuation is owned by the existing ship entry point.
|
|
966
|
+
// Route exposes its validated status but does not turn it into a second
|
|
967
|
+
// release recovery action.
|
|
943
968
|
recoveryActionCode: null,
|
|
944
969
|
legacyPostverify: true,
|
|
945
970
|
};
|