release-skill 0.8.0 → 0.9.0
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/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +65 -7
- package/INSTALL.md +14 -2
- package/INSTALL.zh-CN.md +12 -2
- package/README.md +51 -31
- package/README.zh-CN.md +33 -20
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/foundation-resource-binding.json +1 -1
- package/adapters/claude/bin/prebuild-manifest.json +8 -4
- package/adapters/claude/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/adapters/claude/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/adapters/claude/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/adapters/claude/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/adapters/claude/bin/registry.json +22 -1
- package/adapters/claude/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/claude/bin/release-skill.bundle.mjs +7152 -4231
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +43 -5
- package/adapters/claude/schemas/release-run.schema.json +35 -1
- package/adapters/claude/skills/release-finish/SKILL.md +65 -0
- package/adapters/claude/skills/release-verify/SKILL.md +6 -1
- package/adapters/claude/src/schemas/filesystem-tree-observation.schema.json +55 -0
- package/adapters/claude/src/schemas/host-verification-result.schema.json +2 -2
- package/adapters/claude/src/schemas/plugin-verification-request.schema.json +374 -0
- package/adapters/claude/src/schemas/plugin-verification-result.schema.json +799 -0
- package/adapters/claude/src/schemas/watchdog-termination-envelope.schema.json +16 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/foundation-resource-binding.json +1 -1
- package/adapters/codex/bin/prebuild-manifest.json +8 -4
- package/adapters/codex/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/adapters/codex/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/adapters/codex/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/adapters/codex/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/adapters/codex/bin/registry.json +22 -1
- package/adapters/codex/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/codex/bin/release-skill.bundle.mjs +7152 -4231
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +43 -5
- package/adapters/codex/schemas/release-run.schema.json +35 -1
- package/adapters/codex/skills/release-finish/SKILL.md +72 -0
- package/adapters/codex/skills/release-verify/SKILL.md +6 -1
- package/adapters/codex/src/schemas/filesystem-tree-observation.schema.json +55 -0
- package/adapters/codex/src/schemas/host-verification-result.schema.json +2 -2
- package/adapters/codex/src/schemas/plugin-verification-request.schema.json +374 -0
- package/adapters/codex/src/schemas/plugin-verification-result.schema.json +799 -0
- package/adapters/codex/src/schemas/watchdog-termination-envelope.schema.json +16 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/foundation-resource-binding.json +1 -1
- package/adapters/kimi/bin/prebuild-manifest.json +8 -4
- package/adapters/kimi/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/adapters/kimi/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/adapters/kimi/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/adapters/kimi/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/adapters/kimi/bin/registry.json +22 -1
- package/adapters/kimi/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/kimi/bin/release-skill.bundle.mjs +7152 -4231
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +43 -5
- package/adapters/kimi/schemas/release-run.schema.json +35 -1
- package/adapters/kimi/skills/release-finish/SKILL.md +72 -0
- package/adapters/kimi/skills/release-verify/SKILL.md +6 -1
- package/adapters/kimi/src/schemas/filesystem-tree-observation.schema.json +55 -0
- package/adapters/kimi/src/schemas/host-verification-result.schema.json +2 -2
- package/adapters/kimi/src/schemas/plugin-verification-request.schema.json +374 -0
- package/adapters/kimi/src/schemas/plugin-verification-result.schema.json +799 -0
- package/adapters/kimi/src/schemas/watchdog-termination-envelope.schema.json +16 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/foundation-resource-binding.json +1 -1
- package/adapters/workbuddy/bin/prebuild-manifest.json +8 -4
- package/adapters/workbuddy/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/adapters/workbuddy/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/adapters/workbuddy/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/adapters/workbuddy/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/adapters/workbuddy/bin/registry.json +22 -1
- package/adapters/workbuddy/bin/release-skill-local-finish.mjs +4 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +7152 -4231
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/release-plan.schema.json +43 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +35 -1
- package/adapters/workbuddy/skills/release-finish/SKILL.md +65 -0
- package/adapters/workbuddy/skills/release-verify/SKILL.md +6 -1
- package/adapters/workbuddy/src/schemas/filesystem-tree-observation.schema.json +55 -0
- package/adapters/workbuddy/src/schemas/host-verification-result.schema.json +2 -2
- package/adapters/workbuddy/src/schemas/plugin-verification-request.schema.json +374 -0
- package/adapters/workbuddy/src/schemas/plugin-verification-result.schema.json +799 -0
- package/adapters/workbuddy/src/schemas/watchdog-termination-envelope.schema.json +16 -0
- package/bin/error-codes.json +1 -1
- package/bin/foundation-resource-binding.json +1 -1
- package/bin/prebuild-manifest.json +8 -4
- package/bin/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/bin/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/bin/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/bin/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/bin/registry.json +22 -1
- package/bin/release-skill-cli.mjs +126 -1
- package/bin/release-skill-local-finish.mjs +4 -0
- package/bin/release-skill.bundle.mjs +7152 -4231
- package/bin/rules.json +1 -1
- package/package.json +4 -4
- package/platform-manifest.json +30 -4
- package/schemas/release-plan.schema.json +43 -5
- package/schemas/release-run.schema.json +35 -1
- package/skills/release-finish/SKILL.md +65 -0
- package/skills/release-verify/SKILL.md +6 -1
- package/skills-src/release-finish/SKILL.md +65 -0
- package/skills-src/release-verify/SKILL.md +6 -1
- package/src/commands/approve.mjs +14 -2
- package/src/commands/distribute.mjs +562 -321
- package/src/commands/post-release-local.mjs +611 -0
- package/src/commands/postverify.mjs +262 -155
- package/src/commands/prepare.mjs +83 -57
- package/src/commands/publish.mjs +11 -10
- package/src/commands/reconcile.mjs +11 -10
- package/src/commands/setup.mjs +26 -13
- package/src/commands/ship.mjs +45 -12
- package/src/commands/verify.mjs +81 -18
- package/src/core/adoption-assessment.mjs +0 -3
- package/src/core/approval.mjs +11 -8
- package/src/core/foundation-plugin-verification.mjs +247 -0
- package/src/core/hooks.mjs +0 -16
- package/src/core/plan.mjs +19 -11
- package/src/core/postpublish-approval.mjs +12 -2
- package/src/core/postpublish-bundle.mjs +51 -13
- package/src/core/postpublish.mjs +146 -6
- package/src/core/preset-executor.mjs +53 -0
- package/src/core/proposal-inbox.mjs +91 -27
- package/src/core/recovery.mjs +27 -12
- package/src/core/skill-resource-closure.mjs +8 -4
- package/src/producers/build-adapters.mjs +10 -1
- package/src/producers/foundation-resource-projection.mjs +3 -3
- package/src/schemas/filesystem-tree-observation.schema.json +55 -0
- package/src/schemas/host-verification-result.schema.json +2 -2
- package/src/schemas/plugin-verification-request.schema.json +374 -0
- package/src/schemas/plugin-verification-result.schema.json +799 -0
- package/src/schemas/watchdog-termination-envelope.schema.json +16 -0
package/src/commands/verify.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveContained } from 'skill-family-harness-node';
|
|
|
23
23
|
const execFile = promisify(execFileCb);
|
|
24
24
|
|
|
25
25
|
import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from '../core/plan.mjs';
|
|
26
|
+
import { normalizePostPublishView, postPublishActionId } from '../core/postpublish.mjs';
|
|
26
27
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
27
28
|
import { readRunRecovery } from '../core/recovery.mjs';
|
|
28
29
|
import {
|
|
@@ -62,6 +63,10 @@ import {
|
|
|
62
63
|
import { loadProjectConfig } from '../core/config.mjs';
|
|
63
64
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
64
65
|
import { clearFrozenMarker } from '../core/frozen-marker.mjs';
|
|
66
|
+
import {
|
|
67
|
+
isFoundationPluginVerificationEligible,
|
|
68
|
+
verifyFrozenPluginWithFoundation,
|
|
69
|
+
} from '../core/foundation-plugin-verification.mjs';
|
|
65
70
|
import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
|
|
66
71
|
import {
|
|
67
72
|
normalizeRegistry,
|
|
@@ -884,7 +889,7 @@ async function discoverDistributeRuns({ planPath, plan }) {
|
|
|
884
889
|
* governance rule that NEEDS_INPUT/BLOCKED cannot silently become VERIFIED.
|
|
885
890
|
*
|
|
886
891
|
* @param {Object} run - A distribute run record (checkpoints + status).
|
|
887
|
-
* @param {Object} plan - The frozen release plan (postPublish
|
|
892
|
+
* @param {Object} plan - The frozen release plan (normalized postPublish declaration source).
|
|
888
893
|
* @returns {{ pass: boolean, warned: boolean, exemptions: Object[] }}
|
|
889
894
|
*/
|
|
890
895
|
export function evaluateDistributeGateRun(run, plan) {
|
|
@@ -892,12 +897,19 @@ export function evaluateDistributeGateRun(run, plan) {
|
|
|
892
897
|
if (run.status === 'DISTRIBUTED') return { pass: true, warned: false, exemptions: [] };
|
|
893
898
|
if (run.status !== 'PARTIAL') return { pass: false, warned: false, exemptions: [] };
|
|
894
899
|
|
|
895
|
-
|
|
900
|
+
// §4.3 unified normalization: hooks resolve across all declarations and
|
|
901
|
+
// are keyed by the SAME action-id derivation the distribute run used, so
|
|
902
|
+
// v3 namespaced checkpoint ids (unitId/hookId) map back to their hook.
|
|
903
|
+
const hooksByActionId = new Map((plan ? normalizePostPublishView(plan) : [])
|
|
904
|
+
.flatMap((declaration) => (declaration.hooks ?? []).map((hook) => [
|
|
905
|
+
postPublishActionId({ planVersion: plan?.planVersion, unitId: declaration.unitId, localId: hook.id }),
|
|
906
|
+
hook,
|
|
907
|
+
])));
|
|
896
908
|
const exemptions = [];
|
|
897
909
|
for (const checkpoint of run.checkpoints ?? []) {
|
|
898
910
|
if (checkpoint.status === 'succeeded' || checkpoint.status === 'skipped') continue;
|
|
899
911
|
const hook = checkpoint.actionType === 'postpublish-hook'
|
|
900
|
-
?
|
|
912
|
+
? hooksByActionId.get(checkpoint.actionId)
|
|
901
913
|
: undefined;
|
|
902
914
|
if (hook && checkpoint.status === 'failed' && hook.blocksVerified === false) {
|
|
903
915
|
exemptions.push({ actionId: checkpoint.actionId, status: checkpoint.status });
|
|
@@ -925,7 +937,7 @@ export function evaluateDistributeGateRun(run, plan) {
|
|
|
925
937
|
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
926
938
|
* @param {Object} [options.previousVerifyRun] - 上一次验证成功的 verify run 记录,用于安装契约摘要免验。
|
|
927
939
|
*
|
|
928
|
-
* @returns {Promise<{ planPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
|
|
940
|
+
* @returns {Promise<{ planPath: string, runPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
|
|
929
941
|
*
|
|
930
942
|
* @throws {ReleaseError} GATE_FAILED on safety gate failures.
|
|
931
943
|
* @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED if any verification fails.
|
|
@@ -944,6 +956,7 @@ export async function verifyRelease(options) {
|
|
|
944
956
|
previousVerifyRun,
|
|
945
957
|
execFn,
|
|
946
958
|
configPath: configPathOpt,
|
|
959
|
+
runPluginVerificationFn,
|
|
947
960
|
} = options ?? {};
|
|
948
961
|
|
|
949
962
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -1063,10 +1076,12 @@ export async function verifyRelease(options) {
|
|
|
1063
1076
|
// PARTIAL distribute run passes only through the blocksVerified:false
|
|
1064
1077
|
// exemption path (evaluateDistributeGateRun) — warned, never silent.
|
|
1065
1078
|
// =======================================================================
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1079
|
+
// §4.3 unified normalization: v3 empty arrays mean no distribution
|
|
1080
|
+
// requirement; legacy absent postPublish resolves to the same empty view.
|
|
1081
|
+
const postPublishDeclarations = normalizePostPublishView(plan);
|
|
1082
|
+
const requiresDistribution = postPublishDeclarations.some((declaration) =>
|
|
1083
|
+
(declaration.targets?.length ?? 0) > 0 || (declaration.hooks?.length ?? 0) > 0);
|
|
1084
|
+
if (requiresDistribution) {
|
|
1070
1085
|
await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
|
|
1071
1086
|
|
|
1072
1087
|
const distributeCandidates = await discoverDistributeRuns({ planPath, plan }) ?? [];
|
|
@@ -1351,10 +1366,15 @@ export async function verifyRelease(options) {
|
|
|
1351
1366
|
}
|
|
1352
1367
|
}
|
|
1353
1368
|
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
const isHumanConsumerFollowUp =
|
|
1369
|
+
// Kimi/CodeBuddy remain non-blocking post-release manual tasks. New plans
|
|
1370
|
+
// additionally require a Foundation observation of the complete frozen
|
|
1371
|
+
// payload before the manual marketplace/invocation follow-up is emitted.
|
|
1372
|
+
const isHumanConsumerFollowUp = [
|
|
1373
|
+
'manualFollowUps',
|
|
1374
|
+
'foundationPayloadThenManualFollowUps',
|
|
1375
|
+
].includes(plan.humanConsumersStrategy);
|
|
1376
|
+
const requiresFoundationPayloadObservation = plan.humanConsumersStrategy
|
|
1377
|
+
=== 'foundationPayloadThenManualFollowUps';
|
|
1358
1378
|
|
|
1359
1379
|
const missingManualAttestations = isHumanConsumerFollowUp
|
|
1360
1380
|
? []
|
|
@@ -1382,6 +1402,7 @@ export async function verifyRelease(options) {
|
|
|
1382
1402
|
|
|
1383
1403
|
// Collect non-blocking manual follow-up tasks for human consumer platforms
|
|
1384
1404
|
const manualFollowUps = [];
|
|
1405
|
+
const foundationPluginVerificationReceipts = [];
|
|
1385
1406
|
|
|
1386
1407
|
// Every action is identity-bound and adapters receive read-only or
|
|
1387
1408
|
// per-action isolated consumer paths. Run independent checks concurrently;
|
|
@@ -1414,13 +1435,40 @@ export async function verifyRelease(options) {
|
|
|
1414
1435
|
}
|
|
1415
1436
|
|
|
1416
1437
|
if (isMarketplaceAction(action.type)) {
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1438
|
+
// Both human-consumer strategies keep marketplace installation and
|
|
1439
|
+
// invocation as non-blocking manual follow-ups. The new strategy first
|
|
1440
|
+
// requires Foundation to observe the identity-bound frozen payload.
|
|
1420
1441
|
const isHumanConsumerPlatform = action.type === 'kimi-marketplace-install'
|
|
1421
1442
|
|| action.type === 'codebuddy-marketplace-install';
|
|
1422
1443
|
if (isHumanConsumerFollowUp && isHumanConsumerPlatform) {
|
|
1423
1444
|
const platform = action.type === 'kimi-marketplace-install' ? 'kimi' : 'codebuddy';
|
|
1445
|
+
const observesFoundationPayload = requiresFoundationPayloadObservation
|
|
1446
|
+
&& isFoundationPluginVerificationEligible({ action, units: plan.units });
|
|
1447
|
+
if (observesFoundationPayload) {
|
|
1448
|
+
const actionRunDir = join(runDir, 'foundation-plugin-verification', action.id);
|
|
1449
|
+
await mkdir(join(actionRunDir, 'install'), { recursive: true, mode: 0o700 });
|
|
1450
|
+
await mkdir(join(actionRunDir, 'temporary'), { recursive: true, mode: 0o700 });
|
|
1451
|
+
await mkdir(join(actionRunDir, 'evidence'), { recursive: true, mode: 0o700 });
|
|
1452
|
+
const receipt = await verifyFrozenPluginWithFoundation({
|
|
1453
|
+
plan,
|
|
1454
|
+
action,
|
|
1455
|
+
root,
|
|
1456
|
+
runDir: actionRunDir,
|
|
1457
|
+
...(runPluginVerificationFn ? { runPluginVerificationFn } : {}),
|
|
1458
|
+
clock: clockFn,
|
|
1459
|
+
});
|
|
1460
|
+
foundationPluginVerificationReceipts.push(receipt);
|
|
1461
|
+
await evidence.append({
|
|
1462
|
+
phase: 'verify-marketplace',
|
|
1463
|
+
actionId: action.id,
|
|
1464
|
+
actionType: action.type,
|
|
1465
|
+
status: 'FOUNDATION_PAYLOAD_OBSERVED',
|
|
1466
|
+
requestDigest: receipt.requestDigest,
|
|
1467
|
+
hostId: receipt.hostId,
|
|
1468
|
+
payloadMatches: receipt.payloadMatches,
|
|
1469
|
+
observationDigest: receipt.observationDigest,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1424
1472
|
manualFollowUps.push({
|
|
1425
1473
|
actionId: action.id,
|
|
1426
1474
|
platform,
|
|
@@ -1428,20 +1476,26 @@ export async function verifyRelease(options) {
|
|
|
1428
1476
|
version: action.parameters?.version,
|
|
1429
1477
|
unitId: action.unitId,
|
|
1430
1478
|
verifiedBySystem: false,
|
|
1431
|
-
reason:
|
|
1479
|
+
reason: observesFoundationPayload
|
|
1480
|
+
? 'complete frozen payload observed by Foundation; marketplace installation and host invocation remain manual'
|
|
1481
|
+
: 'human consumer platform — installation is a manual post-release task; not verified by system',
|
|
1432
1482
|
});
|
|
1433
1483
|
adapterChecks.push({
|
|
1434
1484
|
actionId: action.id,
|
|
1435
1485
|
actionType: action.type,
|
|
1436
1486
|
status: 'SKIPPED',
|
|
1437
|
-
reason:
|
|
1487
|
+
reason: observesFoundationPayload
|
|
1488
|
+
? 'foundation-payload-observed-manual-follow-up'
|
|
1489
|
+
: 'manual-follow-up',
|
|
1438
1490
|
});
|
|
1439
1491
|
await evidence.append({
|
|
1440
1492
|
phase: 'verify-marketplace',
|
|
1441
1493
|
actionId: action.id,
|
|
1442
1494
|
actionType: action.type,
|
|
1443
1495
|
status: 'SKIPPED',
|
|
1444
|
-
reason:
|
|
1496
|
+
reason: observesFoundationPayload
|
|
1497
|
+
? 'foundation-payload-observed-manual-follow-up'
|
|
1498
|
+
: 'manual-follow-up',
|
|
1445
1499
|
});
|
|
1446
1500
|
return;
|
|
1447
1501
|
}
|
|
@@ -1727,6 +1781,8 @@ export async function verifyRelease(options) {
|
|
|
1727
1781
|
}));
|
|
1728
1782
|
adapterChecks.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1729
1783
|
consumerVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1784
|
+
foundationPluginVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1785
|
+
manualFollowUps.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1730
1786
|
consumerGateResults.sort((a, b) => a.id.localeCompare(b.id));
|
|
1731
1787
|
const rejectedAction = actionResults.find((result) => result.status === 'rejected');
|
|
1732
1788
|
if (rejectedAction) throw rejectedAction.reason;
|
|
@@ -2034,6 +2090,9 @@ export async function verifyRelease(options) {
|
|
|
2034
2090
|
gateResults: consumerGateResults,
|
|
2035
2091
|
consumerVerificationReceipts,
|
|
2036
2092
|
skillResourceClosureReceipts,
|
|
2093
|
+
...(foundationPluginVerificationReceipts.length > 0
|
|
2094
|
+
? { foundationPluginVerificationReceipts }
|
|
2095
|
+
: {}),
|
|
2037
2096
|
...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
|
|
2038
2097
|
startedAt: clockFn(),
|
|
2039
2098
|
finishedAt: clockFn(),
|
|
@@ -2127,11 +2186,15 @@ export async function verifyRelease(options) {
|
|
|
2127
2186
|
|
|
2128
2187
|
return {
|
|
2129
2188
|
planPath,
|
|
2189
|
+
runPath: verifyRunPath,
|
|
2130
2190
|
status: VERIFIED,
|
|
2131
2191
|
adapterChecks,
|
|
2132
2192
|
recoveryActionCode: null,
|
|
2133
2193
|
smokeTest,
|
|
2134
2194
|
gateResults: consumerGateResults,
|
|
2195
|
+
...(foundationPluginVerificationReceipts.length > 0
|
|
2196
|
+
? { foundationPluginVerificationReceipts }
|
|
2197
|
+
: {}),
|
|
2135
2198
|
...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
|
|
2136
2199
|
...(baselineAdvance ? { baselineAdvance } : {}),
|
|
2137
2200
|
};
|
|
@@ -40,9 +40,6 @@
|
|
|
40
40
|
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
41
41
|
import { resolveProducerVersion } from './evidence.mjs';
|
|
42
42
|
|
|
43
|
-
// Keep the assessment entry point compatible without duplicating the rule.
|
|
44
|
-
export { findPostPublishUnitConflict } from './hooks.mjs';
|
|
45
|
-
|
|
46
43
|
/** Four adoption statuses; never reuses ASSESSED (design §3.2). */
|
|
47
44
|
export const ASSESSMENT_STATUS = Object.freeze({
|
|
48
45
|
NOT_CONFIGURED: 'NOT_CONFIGURED',
|
package/src/core/approval.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
|
|
13
13
|
*
|
|
14
14
|
* The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
|
|
15
|
-
* centralized here: for planVersion 2 plans the
|
|
15
|
+
* centralized here: for planVersion 2/3 plans (v3 inherits the v2 approval
|
|
16
|
+
* boundary per 多发布单元 postPublish v3 §4.2) the baseline is record-layer
|
|
16
17
|
* data -- gitTreeHash/workspaceDigest equality and the production workspace
|
|
17
18
|
* digest algorithm double-check are NOT invalidation conditions (artifact
|
|
18
19
|
* integrity is sealed by the frozen-artifact re-verification at publish).
|
|
@@ -186,11 +187,13 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
186
187
|
);
|
|
187
188
|
}
|
|
188
189
|
|
|
189
|
-
// planVersion fork (centralized here; see module header):
|
|
190
|
-
// the baseline as optional record-layer data
|
|
191
|
-
|
|
190
|
+
// planVersion fork (centralized here; see module header): planVersion 2/3
|
|
191
|
+
// plans treat the baseline as optional record-layer data (v3 inherits the
|
|
192
|
+
// v2 approval boundary per 多发布单元 postPublish v3 §4.2). Versions beyond
|
|
193
|
+
// {2,3} stay on the legacy v1 path and are never silently admitted.
|
|
194
|
+
const recordLayerBaseline = plan?.planVersion === 2 || plan?.planVersion === 3;
|
|
192
195
|
|
|
193
|
-
if (
|
|
196
|
+
if (recordLayerBaseline) {
|
|
194
197
|
if (!approval.planDigest || !approval.expiresAt) {
|
|
195
198
|
throw new ReleaseError(
|
|
196
199
|
GATE_FAILED,
|
|
@@ -206,7 +209,7 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
206
209
|
);
|
|
207
210
|
}
|
|
208
211
|
|
|
209
|
-
if (!
|
|
212
|
+
if (!recordLayerBaseline) {
|
|
210
213
|
if (plan.production?.mode === 'github-npm-v1') {
|
|
211
214
|
if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
212
215
|
throw new ReleaseError(
|
|
@@ -249,9 +252,9 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
// --- baseline equality checks (v1 plans only) ---
|
|
252
|
-
// For planVersion 2 plans the baseline is record-layer data: it stays in
|
|
255
|
+
// For planVersion 2/3 plans the baseline is record-layer data: it stays in
|
|
253
256
|
// the plan/approval files for audit but is not an invalidation condition.
|
|
254
|
-
if (!
|
|
257
|
+
if (!recordLayerBaseline) {
|
|
255
258
|
// --- baseline.gitTreeHash match ---
|
|
256
259
|
if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
|
|
257
260
|
throw new ReleaseError(
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-domain thin adapter for Foundation 0.13 complete-plugin observation.
|
|
3
|
+
*
|
|
4
|
+
* The frozen snapshot remains the only payload authority. This module derives
|
|
5
|
+
* transient Foundation inputs from that tree, calls the released Kit API, and
|
|
6
|
+
* returns only the fields needed to bind the mechanism observation to a
|
|
7
|
+
* release action. It does not prove a marketplace install or host invocation.
|
|
8
|
+
*
|
|
9
|
+
* @module core/foundation-plugin-verification
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { digestDocument } from 'skill-family-contracts';
|
|
15
|
+
import {
|
|
16
|
+
bundledHostProfilesRoot,
|
|
17
|
+
describeHost,
|
|
18
|
+
runPluginVerification,
|
|
19
|
+
} from 'skill-family-engineering-kit';
|
|
20
|
+
import {
|
|
21
|
+
createFilesystemRootBinding,
|
|
22
|
+
readFileBound,
|
|
23
|
+
} from 'skill-family-harness-node';
|
|
24
|
+
|
|
25
|
+
import { getPlatform, normalizeHostId } from '../platforms/registry.mjs';
|
|
26
|
+
import { verifyFrozenSnapshot } from '../snapshot/frozen.mjs';
|
|
27
|
+
import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
|
|
28
|
+
|
|
29
|
+
// Foundation's public plugin-verification request freezes the built-in driver
|
|
30
|
+
// contract at 1.0.0. The real API integration test intentionally exercises
|
|
31
|
+
// this value so an upstream contract change fails closed during adoption.
|
|
32
|
+
const FOUNDATION_PLUGIN_DRIVER_VERSION = '1.0.0';
|
|
33
|
+
|
|
34
|
+
function fail(message, details = {}) {
|
|
35
|
+
return new ReleaseError(POST_PUBLISH_VERIFY_FAILED, message, details);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function projectSourceMembers(entries) {
|
|
39
|
+
const directories = new Set();
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const parts = entry.path.split('/');
|
|
42
|
+
for (let count = 1; count < parts.length; count += 1) {
|
|
43
|
+
directories.add(parts.slice(0, count).join('/'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
...[...directories].map((path) => ({ path, type: 'directory' })),
|
|
48
|
+
...entries.map((entry) => ({
|
|
49
|
+
path: entry.path,
|
|
50
|
+
type: 'file',
|
|
51
|
+
sha256: entry.contentDigest,
|
|
52
|
+
bytes: entry.size,
|
|
53
|
+
executable: (entry.mode & 0o111) !== 0,
|
|
54
|
+
})),
|
|
55
|
+
].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return whether one human-consumer action has the complete frozen identity
|
|
60
|
+
* required by Foundation's local-staged plugin verification.
|
|
61
|
+
*
|
|
62
|
+
* This predicate is deliberately plan-derived. It does not persist a second
|
|
63
|
+
* action list, and the verifier repeats the same checks before every call.
|
|
64
|
+
*/
|
|
65
|
+
export function isFoundationPluginVerificationEligible({ action, units = [] } = {}) {
|
|
66
|
+
const platformId = action?.parameters?.consumer;
|
|
67
|
+
const expectedType = platformId === 'kimi'
|
|
68
|
+
? 'kimi-marketplace-install'
|
|
69
|
+
: platformId === 'codebuddy'
|
|
70
|
+
? 'codebuddy-marketplace-install'
|
|
71
|
+
: null;
|
|
72
|
+
if (!expectedType || action?.type !== expectedType) return false;
|
|
73
|
+
|
|
74
|
+
const unit = units.find((candidate) => candidate.id === action.unitId);
|
|
75
|
+
const snapshot = unit?.frozenSnapshot;
|
|
76
|
+
const sourceDescriptor = action.parameters?.sourceDescriptor;
|
|
77
|
+
if (
|
|
78
|
+
!snapshot?.path
|
|
79
|
+
|| !snapshot.commit
|
|
80
|
+
|| !snapshot.manifestDigest
|
|
81
|
+
|| sourceDescriptor?.form !== 'bundled-family'
|
|
82
|
+
|| sourceDescriptor.pluginSubpath !== '.'
|
|
83
|
+
|| sourceDescriptor.commit !== snapshot.commit
|
|
84
|
+
|| sourceDescriptor.payloadDigest !== snapshot.manifestDigest
|
|
85
|
+
|| action.parameters?.sourceCommit !== snapshot.commit
|
|
86
|
+
|| action.parameters?.snapshotPath !== snapshot.path
|
|
87
|
+
|| action.parameters?.manifestDigest !== snapshot.manifestDigest
|
|
88
|
+
) return false;
|
|
89
|
+
|
|
90
|
+
const platform = getPlatform(platformId);
|
|
91
|
+
return (unit.distributions ?? []).some((distribution) => (
|
|
92
|
+
distribution.type === platform.distributionType
|
|
93
|
+
&& typeof distribution.installationContract?.manifestRelativePath === 'string'
|
|
94
|
+
&& distribution.installationContract.manifestRelativePath.length > 0
|
|
95
|
+
));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertResultMatchesRequest(result, request) {
|
|
99
|
+
if (
|
|
100
|
+
result?.status !== 'observed'
|
|
101
|
+
|| result.requestDigest !== digestDocument(request)
|
|
102
|
+
|| result.goal !== request.goal
|
|
103
|
+
|| digestDocument(result.source) !== digestDocument(request.source)
|
|
104
|
+
|| digestDocument(result.host) !== digestDocument(request.host)
|
|
105
|
+
|| result.facts?.install?.payloadMatches !== true
|
|
106
|
+
|| result.facts.install.sourceMode !== 'local-staged'
|
|
107
|
+
|| result.facts.install.retained !== true
|
|
108
|
+
|| result.facts.invocation !== null
|
|
109
|
+
) {
|
|
110
|
+
throw fail('Foundation complete-plugin observation did not satisfy the frozen local payload contract', {
|
|
111
|
+
foundationStatus: result?.status ?? null,
|
|
112
|
+
foundationReason: result?.reason ?? null,
|
|
113
|
+
payloadMatches: result?.facts?.install?.payloadMatches ?? null,
|
|
114
|
+
requiredActions: result?.requiredActions ?? [],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Observe one frozen Kimi or CodeBuddy plugin payload through Foundation.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} input
|
|
123
|
+
* @param {object} input.plan
|
|
124
|
+
* @param {object} input.action
|
|
125
|
+
* @param {string} input.root
|
|
126
|
+
* @param {string} input.runDir - Fresh per-action directory containing empty
|
|
127
|
+
* install, temporary and evidence children.
|
|
128
|
+
* @param {Function} [input.runPluginVerificationFn]
|
|
129
|
+
* @param {Function} [input.clock]
|
|
130
|
+
* @returns {Promise<object>} local payload-observation receipt
|
|
131
|
+
*/
|
|
132
|
+
export async function verifyFrozenPluginWithFoundation({
|
|
133
|
+
plan,
|
|
134
|
+
action,
|
|
135
|
+
root,
|
|
136
|
+
runDir,
|
|
137
|
+
runPluginVerificationFn = runPluginVerification,
|
|
138
|
+
clock = () => new Date().toISOString(),
|
|
139
|
+
} = {}) {
|
|
140
|
+
const unit = (plan?.units ?? []).find((candidate) => candidate.id === action?.unitId);
|
|
141
|
+
if (!unit?.frozenSnapshot?.path || !unit.frozenSnapshot.commit) {
|
|
142
|
+
throw fail('Foundation plugin observation requires a frozen unit snapshot', {
|
|
143
|
+
actionId: action?.id,
|
|
144
|
+
unitId: action?.unitId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isFoundationPluginVerificationEligible({ action, units: plan.units })) {
|
|
149
|
+
throw fail('Foundation local plugin observation requires one identity-bound bundled-family payload', {
|
|
150
|
+
actionId: action?.id,
|
|
151
|
+
sourceForm: action?.parameters?.sourceDescriptor?.form ?? null,
|
|
152
|
+
pluginSubpath: action?.parameters?.sourceDescriptor?.pluginSubpath ?? null,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const platformId = action?.parameters?.consumer;
|
|
157
|
+
const platform = getPlatform(platformId);
|
|
158
|
+
if (!['kimi', 'codebuddy'].includes(platform.id)) {
|
|
159
|
+
throw fail('Foundation local plugin observation is not configured for this release platform', {
|
|
160
|
+
actionId: action?.id,
|
|
161
|
+
platform: platform.id,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const sourceDescriptor = action.parameters.sourceDescriptor;
|
|
165
|
+
const distribution = (unit.distributions ?? []).find(
|
|
166
|
+
(candidate) => candidate.type === platform.distributionType,
|
|
167
|
+
);
|
|
168
|
+
const manifestRelativePath = distribution?.installationContract?.manifestRelativePath;
|
|
169
|
+
if (!manifestRelativePath) {
|
|
170
|
+
throw fail('Foundation plugin observation requires the frozen installation manifest path', {
|
|
171
|
+
actionId: action.id,
|
|
172
|
+
distributionType: platform.distributionType,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const frozen = await verifyFrozenSnapshot({
|
|
177
|
+
root,
|
|
178
|
+
snapshotPath: unit.frozenSnapshot.path,
|
|
179
|
+
expectedDigest: unit.frozenSnapshot.manifestDigest,
|
|
180
|
+
});
|
|
181
|
+
const sourceRoot = frozen.snapshotDir;
|
|
182
|
+
const sourceMembers = projectSourceMembers(frozen.entries);
|
|
183
|
+
const sourceManifest = frozen.entries.find((entry) => entry.path === manifestRelativePath);
|
|
184
|
+
if (!sourceManifest) {
|
|
185
|
+
throw fail('Foundation plugin observation manifest is not part of the frozen payload', {
|
|
186
|
+
actionId: action.id,
|
|
187
|
+
manifestRelativePath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const hostsRoot = bundledHostProfilesRoot();
|
|
192
|
+
const hostId = await normalizeHostId(platform.buildAdapter.name);
|
|
193
|
+
const descriptor = await describeHost({ hostId, hostsRoot });
|
|
194
|
+
const hostsBinding = await createFilesystemRootBinding(hostsRoot);
|
|
195
|
+
const descriptorFile = await readFileBound(hostsRoot, `${hostId}/host-descriptor.json`, {
|
|
196
|
+
rootBinding: hostsBinding,
|
|
197
|
+
});
|
|
198
|
+
if (!descriptor.verification?.driverId) {
|
|
199
|
+
throw fail('Foundation host has no complete-plugin verification driver', { hostId });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const request = {
|
|
203
|
+
schemaVersion: 1,
|
|
204
|
+
kind: 'skill-family.plugin-verification-request',
|
|
205
|
+
operation: 'plugin-verification',
|
|
206
|
+
verificationSetId: `${plan.digest}:${action.id}`,
|
|
207
|
+
goal: 'install-only',
|
|
208
|
+
source: {
|
|
209
|
+
type: 'local-staged',
|
|
210
|
+
candidateRef: sourceDescriptor.commit,
|
|
211
|
+
sourceManifestSha256: sourceManifest.contentDigest,
|
|
212
|
+
membersDigest: digestDocument(sourceMembers),
|
|
213
|
+
},
|
|
214
|
+
host: {
|
|
215
|
+
hostId,
|
|
216
|
+
descriptorSha256: descriptorFile.sha256,
|
|
217
|
+
driverId: descriptor.verification.driverId,
|
|
218
|
+
driverVersion: FOUNDATION_PLUGIN_DRIVER_VERSION,
|
|
219
|
+
},
|
|
220
|
+
install: { mode: 'fresh-tree' },
|
|
221
|
+
};
|
|
222
|
+
const bindings = {
|
|
223
|
+
sourceRoot,
|
|
224
|
+
sourceManifestRelPath: manifestRelativePath,
|
|
225
|
+
sourceMembers,
|
|
226
|
+
installContainerRoot: join(runDir, 'install'),
|
|
227
|
+
temporaryRoot: join(runDir, 'temporary'),
|
|
228
|
+
privateEvidenceRoot: join(runDir, 'evidence'),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const result = await runPluginVerificationFn({ request, bindings, hostsRoot });
|
|
232
|
+
assertResultMatchesRequest(result, request);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
planDigest: plan.digest,
|
|
236
|
+
actionId: action.id,
|
|
237
|
+
unitId: unit.id,
|
|
238
|
+
requestDigest: result.requestDigest,
|
|
239
|
+
hostId,
|
|
240
|
+
goal: request.goal,
|
|
241
|
+
sourceType: request.source.type,
|
|
242
|
+
foundationStatus: result.status,
|
|
243
|
+
payloadMatches: result.facts.install.payloadMatches,
|
|
244
|
+
observationDigest: result.facts.install.observationDigest,
|
|
245
|
+
observedAt: clock(),
|
|
246
|
+
};
|
|
247
|
+
}
|
package/src/core/hooks.mjs
CHANGED
|
@@ -43,22 +43,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
|
43
43
|
// Validation
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* A release plan binds at most one postPublish declaration. Both prepare
|
|
48
|
-
* and adoption assessment consume this pure rule and retain their own
|
|
49
|
-
* error codes and presentation. Declaration contents are validated by
|
|
50
|
-
* the existing postPublish validator, not by this cross-unit check.
|
|
51
|
-
*
|
|
52
|
-
* @param {Array<Object>} units - config.releaseUnits.
|
|
53
|
-
* @returns {null|{unitIds: string[]}} Sorted conflicting unit IDs, or null.
|
|
54
|
-
*/
|
|
55
|
-
export function findPostPublishUnitConflict(units) {
|
|
56
|
-
const declaring = (units ?? [])
|
|
57
|
-
.filter((unit) => unit && typeof unit === 'object' && unit.postPublish !== undefined);
|
|
58
|
-
if (declaring.length <= 1) return null;
|
|
59
|
-
return { unitIds: declaring.map((unit) => unit.id).sort() };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
46
|
/**
|
|
63
47
|
* Validate the shape of a hook descriptor against the project contract.
|
|
64
48
|
*
|
package/src/core/plan.mjs
CHANGED
|
@@ -202,7 +202,8 @@ export function validatePlan(plan) {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* planVersion 2 digest scope (design: t1-2-digest-decoupling.md §4.1
|
|
205
|
+
* planVersion 2/3 digest scope (design: t1-2-digest-decoupling.md §4.1;
|
|
206
|
+
* v3 inherits the v2 scope per 多发布单元 postPublish v3 §4.2).
|
|
206
207
|
*
|
|
207
208
|
* The record layer -- top-level `digest`, lifecycle `status`, `createdAt`,
|
|
208
209
|
* the whole `baseline` object, and each action's runtime `status` -- is
|
|
@@ -210,8 +211,10 @@ export function validatePlan(plan) {
|
|
|
210
211
|
* but is excluded from the digest. Everything else (planVersion, units with
|
|
211
212
|
* every frozenSnapshot artifact identity field, externalActions identity,
|
|
212
213
|
* production, configDigest, verificationGates, snapshotDigest) binds.
|
|
214
|
+
* For v3 the frozen `postPublish` array and every item binding field stay in
|
|
215
|
+
* the binding layer: any order or binding change moves the digest.
|
|
213
216
|
*
|
|
214
|
-
* This is the ONLY place the v2 record layer is defined.
|
|
217
|
+
* This is the ONLY place the v2/v3 record layer is defined.
|
|
215
218
|
*
|
|
216
219
|
* @param {object} plan - A plan object.
|
|
217
220
|
* @returns {object} The binding-layer projection of the plan.
|
|
@@ -242,9 +245,12 @@ function stripRecordLayerV2(plan) {
|
|
|
242
245
|
*
|
|
243
246
|
* Digest scope forks on `plan.planVersion` (centralized here, never spread
|
|
244
247
|
* elsewhere):
|
|
245
|
-
* - planVersion 2: record-layer fields (status/createdAt/baseline/action
|
|
248
|
+
* - planVersion 2/3: record-layer fields (status/createdAt/baseline/action
|
|
246
249
|
* status/digest) are stripped before hashing, so re-preparing over the
|
|
247
|
-
* same sources yields the same digest. See `stripRecordLayerV2`.
|
|
250
|
+
* same sources yields the same digest. See `stripRecordLayerV2`. v3
|
|
251
|
+
* inherits the v2 scope exactly (multi-release-unit postPublish v3 §4.2);
|
|
252
|
+
* versions beyond {2,3} are NOT admitted and stay fail-closed on the
|
|
253
|
+
* legacy v1 path rather than being silently bound to v2 semantics.
|
|
248
254
|
* - planVersion 1 or absent (legacy): only the top-level `digest` field is
|
|
249
255
|
* stripped. Byte-for-byte legacy semantics -- this path must never change.
|
|
250
256
|
*
|
|
@@ -258,7 +264,7 @@ export function computePlanDigest(plan) {
|
|
|
258
264
|
// 而是经 digest.mjs 的宽松输入域包装(contracts 权威序列化 + 本地归一化)
|
|
259
265
|
// 后由 Foundation digestBytes 摘要 —— 对纯 JSON 计划与 digestDocument 字节
|
|
260
266
|
// 完全一致,对含 undefined 的计划保持迁移前字节语义。
|
|
261
|
-
if (plan.planVersion === 2) {
|
|
267
|
+
if (plan.planVersion === 2 || plan.planVersion === 3) {
|
|
262
268
|
return sha256Hex(canonicalJson(stripRecordLayerV2(plan)));
|
|
263
269
|
}
|
|
264
270
|
// Strip the digest field if present so the hash is self-consistent.
|
|
@@ -341,14 +347,15 @@ export async function writePlanImmutable(planPath, plan) {
|
|
|
341
347
|
// EEXIST 直接抛冲突,无幂等分支;wrapper 补齐,fail-closed 无双路径)。
|
|
342
348
|
const existing = await readFile(planPath, 'utf8').catch(() => null);
|
|
343
349
|
if (existing === json) return { planPath, planDigest };
|
|
344
|
-
// planVersion 2 (design: t1-2-digest-decoupling.md §4.1
|
|
345
|
-
//
|
|
350
|
+
// planVersion 2/3 (design: t1-2-digest-decoupling.md §4.1; v3 inherits
|
|
351
|
+
// the scope per 多发布单元 postPublish v3 §4.2): record-layer fields
|
|
352
|
+
// (status/createdAt/baseline) are excluded from the digest, so
|
|
346
353
|
// re-preparing over identical binding content yields the SAME digest
|
|
347
354
|
// with legitimately different bytes. Reuse the existing authority IFF
|
|
348
355
|
// it is genuinely self-consistent (its embedded digest and recomputed
|
|
349
356
|
// digest both equal this digest) -- the authority is never replaced.
|
|
350
357
|
// Any other divergence (tampering, malformed file) still fails closed.
|
|
351
|
-
if (plan.planVersion === 2 && existing !== null) {
|
|
358
|
+
if ((plan.planVersion === 2 || plan.planVersion === 3) && existing !== null) {
|
|
352
359
|
let existingPlan = null;
|
|
353
360
|
try {
|
|
354
361
|
existingPlan = JSON.parse(existing);
|
|
@@ -386,7 +393,7 @@ export async function writePlanImmutable(planPath, plan) {
|
|
|
386
393
|
/**
|
|
387
394
|
* Canonical normalized freeze timestamp shape (`YYYY-MM-DDTHH:MM:SS+00:00`).
|
|
388
395
|
* Mirrors the frozenSnapshot.commitTimestamp pattern in the release-plan
|
|
389
|
-
* schema; used by the planVersion 2 completeness gate, which requires a
|
|
396
|
+
* schema; used by the planVersion 2/3 completeness gate, which requires a
|
|
390
397
|
* canonical normalized timestamp without re-binding it to plan.createdAt.
|
|
391
398
|
*/
|
|
392
399
|
const CANONICAL_COMMIT_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/;
|
|
@@ -568,8 +575,9 @@ export function validatePlanActionCompleteness(plan, options = {}) {
|
|
|
568
575
|
}
|
|
569
576
|
if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
|
|
570
577
|
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
|
|
571
|
-
} else if (plan.planVersion === 2) {
|
|
572
|
-
// planVersion 2 (design: t1-2-digest-decoupling.md §4.2
|
|
578
|
+
} else if (plan.planVersion === 2 || plan.planVersion === 3) {
|
|
579
|
+
// planVersion 2/3 (design: t1-2-digest-decoupling.md §4.2; v3 inherits
|
|
580
|
+
// the semantics per 多发布单元 postPublish v3 §4.2): the freeze
|
|
573
581
|
// timestamp is derived from the headCommit committer date, so it no
|
|
574
582
|
// longer equals plan.createdAt (record-layer real clock). It must
|
|
575
583
|
// still be a canonical normalized timestamp -- the same pattern the
|
|
@@ -47,6 +47,7 @@ import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
|
47
47
|
import { computePlanDigest } from './plan.mjs';
|
|
48
48
|
import { computeApprovalDigest, validateApprovalTimeWindow } from './approval.mjs';
|
|
49
49
|
import { resolvePresetRequiresApproval } from './presets.mjs';
|
|
50
|
+
import { normalizePostPublishView, validatePostPublishHookIdUniqueness } from './postpublish.mjs';
|
|
50
51
|
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
51
52
|
|
|
52
53
|
const postpublishApprovalSchema = JSON.parse((await readTrustedPackageResource(
|
|
@@ -79,7 +80,8 @@ export function validatePostPublishApprovalRecordSchema(approval) {
|
|
|
79
80
|
* - record shape (postpublish-approval-record schema; additionalProperties
|
|
80
81
|
* is false, so plan-level fields like approvedActions are rejected);
|
|
81
82
|
* - planDigest equals the computed plan digest;
|
|
82
|
-
* - hookId names a hook declared in the frozen plan's postPublish
|
|
83
|
+
* - hookId names a hook declared in the frozen plan's postPublish
|
|
84
|
+
* declarations (unified normalized view, globally unique across units);
|
|
83
85
|
* - that hook actually declares requiresApproval: true;
|
|
84
86
|
* - the shared approval time window (24h max, 5-minute skew, unexpired).
|
|
85
87
|
*
|
|
@@ -103,7 +105,15 @@ export function validatePostPublishApproval(plan, approval, options = {}) {
|
|
|
103
105
|
);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
// §4.3 unified normalization (rework R-02): the frozen plan's postPublish
|
|
109
|
+
// may be the legacy single declaration (planVersion 1/2) or the v3
|
|
110
|
+
// declaration array; every consumer resolves declared hooks through the
|
|
111
|
+
// SAME normalized view, and the SAME array-level uniqueness authority
|
|
112
|
+
// re-asserts that hookId is globally unique across declarations — one
|
|
113
|
+
// (planDigest, hookId) approval must never authorize two units.
|
|
114
|
+
const declarations = normalizePostPublishView(plan);
|
|
115
|
+
validatePostPublishHookIdUniqueness(declarations);
|
|
116
|
+
const hooks = declarations.flatMap((declaration) => declaration.hooks ?? []);
|
|
107
117
|
const hook = hooks.find((entry) => entry.id === approval.hookId);
|
|
108
118
|
if (!hook) {
|
|
109
119
|
throw new ReleaseError(
|