release-skill 0.1.10 → 0.2.1
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 +10 -0
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/INSTALL.md +24 -4
- package/INSTALL.zh-CN.md +22 -4
- package/README.md +19 -32
- package/README.zh-CN.md +14 -25
- 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 +2721 -1843
- package/adapters/claude/schemas/.render-manifest.json +8 -8
- package/adapters/claude/schemas/approval-record.schema.json +1 -1
- package/adapters/claude/schemas/release-plan.schema.json +6 -2
- package/adapters/claude/schemas/release-project.schema.json +14 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/codex/schemas/.render-manifest.json +8 -8
- package/adapters/codex/schemas/approval-record.schema.json +1 -1
- package/adapters/codex/schemas/release-plan.schema.json +6 -2
- package/adapters/codex/schemas/release-project.schema.json +14 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
- package/adapters/kimi/schemas/.render-manifest.json +8 -8
- package/adapters/kimi/schemas/approval-record.schema.json +1 -1
- package/adapters/kimi/schemas/release-plan.schema.json +6 -2
- package/adapters/kimi/schemas/release-project.schema.json +14 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +10 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +85363 -0
- package/adapters/workbuddy/bin/release-skill.mjs +54 -0
- package/adapters/workbuddy/native/safe-write/binding.gyp +41 -0
- package/adapters/workbuddy/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/workbuddy/native/safe-write/prebuilds.json +24 -0
- package/adapters/workbuddy/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/workbuddy/schemas/.render-manifest.json +37 -0
- package/adapters/workbuddy/schemas/approval-record.schema.json +115 -0
- package/adapters/workbuddy/schemas/artifact-lock.schema.json +111 -0
- package/adapters/workbuddy/schemas/artifact-plan.schema.json +52 -0
- package/adapters/workbuddy/schemas/artifact-policy.schema.json +76 -0
- package/adapters/workbuddy/schemas/evidence-event.schema.json +89 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +882 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +909 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +343 -0
- package/adapters/workbuddy/skills/release-assess/SKILL.md +51 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +77 -0
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +92 -0
- package/adapters/workbuddy/skills/release-publish/SKILL.md +57 -0
- package/adapters/workbuddy/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/workbuddy/skills/release-setup/SKILL.md +95 -0
- package/adapters/workbuddy/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill-cli.mjs +3 -0
- package/bin/release-skill.bundle.mjs +2721 -1843
- package/package.json +9 -2
- package/references/.render-manifest.json +8 -8
- package/references/01-state-machine.md +5 -5
- package/references/02-project-config.md +1 -1
- package/references/05-evidence-and-errors.md +1 -1
- package/references/06-adapter-contract.md +41 -1
- package/schemas/.render-manifest.json +8 -8
- package/schemas/approval-record.schema.json +1 -1
- package/schemas/release-plan.schema.json +6 -2
- package/schemas/release-project.schema.json +14 -0
- package/scripts/sync-public-files.mjs +481 -0
- package/src/adapters/contract.mjs +60 -0
- package/src/adapters/plugin-marketplace.mjs +289 -736
- package/src/commands/prepare.mjs +195 -182
- package/src/commands/publish.mjs +438 -122
- package/src/commands/reconcile.mjs +369 -191
- package/src/commands/verify.mjs +13 -2
- package/src/core/approval.mjs +72 -45
- package/src/core/baseline.mjs +5 -0
- package/src/core/checkpoints.mjs +143 -0
- package/src/core/evidence.mjs +30 -3
- package/src/core/hook-cache.mjs +254 -0
- package/src/core/hooks.mjs +37 -1
- package/src/core/observe-retry.mjs +223 -0
- package/src/core/plan.mjs +162 -253
- package/src/platforms/kimi.mjs +514 -0
- package/src/platforms/registry.mjs +393 -0
- package/src/producers/build-adapters.mjs +49 -23
package/src/commands/verify.mjs
CHANGED
|
@@ -120,7 +120,10 @@ function matchesSubset(actual, expected) {
|
|
|
120
120
|
* - When smokeBin is configured: the specified bin is resolved, validated
|
|
121
121
|
* against path-escape/symlink/non-regular-file guards, and executed with
|
|
122
122
|
* smokeArgs; output is validated against smokeExpectedJson (recursive
|
|
123
|
-
* subset match) when present.
|
|
123
|
+
* subset match) when present. The expected `version` field is injected at
|
|
124
|
+
* runtime from the unit's resolved targetVersion and overrides any
|
|
125
|
+
* config-declared value, so the version check never depends on a
|
|
126
|
+
* hand-written config version (T2.1 §4.3).
|
|
124
127
|
* - When smokeBin is not configured: install + name/version check passes
|
|
125
128
|
* immediately; runBin is never called; result records
|
|
126
129
|
* cliSmoke: "not-configured".
|
|
@@ -162,7 +165,15 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
162
165
|
unitId: unit.id,
|
|
163
166
|
smokeBin: dist.smokeBin,
|
|
164
167
|
smokeArgs: dist.smokeArgs ?? [],
|
|
165
|
-
|
|
168
|
+
// The expected `version` is always the unit's resolved
|
|
169
|
+
// targetVersion (whose source is version.source → package.json),
|
|
170
|
+
// injected at runtime. A config-declared smokeExpectedJson.version
|
|
171
|
+
// is redundant and overridden. This keeps the version check strong
|
|
172
|
+
// without hand-writing the version into project config, so a
|
|
173
|
+
// version bump never churns configDigest (T2.1 §4.3).
|
|
174
|
+
smokeExpectedJson: dist.smokeExpectedJson
|
|
175
|
+
? { ...dist.smokeExpectedJson, version: unit.targetVersion }
|
|
176
|
+
: dist.smokeExpectedJson,
|
|
166
177
|
});
|
|
167
178
|
}
|
|
168
179
|
}
|
package/src/core/approval.mjs
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
* Centralises the safety gates that an approval record must pass before
|
|
5
5
|
* any external write actions can proceed:
|
|
6
6
|
* - planDigest matches the computed plan digest
|
|
7
|
-
* - baseline.gitTreeHash matches the plan baseline
|
|
7
|
+
* - baseline.gitTreeHash matches the plan baseline (v1 plans only)
|
|
8
8
|
* - targetVersion matches the plan's first unit target version
|
|
9
9
|
* - approvedActions exactly equals plan external action ids (no superset, no subset)
|
|
10
10
|
* - approval has not expired
|
|
11
11
|
* - approval duration does not exceed 24 hours
|
|
12
12
|
* - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
|
|
13
13
|
*
|
|
14
|
+
* The planVersion fork (design: t1-2-digest-decoupling.md §4.3) is
|
|
15
|
+
* centralized here: for planVersion 2 plans the baseline is record-layer
|
|
16
|
+
* data -- gitTreeHash/workspaceDigest equality and the production workspace
|
|
17
|
+
* digest algorithm double-check are NOT invalidation conditions (artifact
|
|
18
|
+
* integrity is sealed by the frozen-artifact re-verification at publish).
|
|
19
|
+
* Version, action-list, planDigest, and time-window bindings are preserved
|
|
20
|
+
* for every plan version. v1 plans keep the full legacy path untouched.
|
|
21
|
+
*
|
|
14
22
|
* @module core/approval
|
|
15
23
|
*/
|
|
16
24
|
|
|
@@ -89,7 +97,19 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
|
|
100
|
+
// planVersion fork (centralized here; see module header): v2 plans treat
|
|
101
|
+
// the baseline as optional record-layer data.
|
|
102
|
+
const planV2 = plan?.planVersion === 2;
|
|
103
|
+
|
|
104
|
+
if (planV2) {
|
|
105
|
+
if (!approval.planDigest || !approval.expiresAt) {
|
|
106
|
+
throw new ReleaseError(
|
|
107
|
+
GATE_FAILED,
|
|
108
|
+
'approval record missing required fields: planDigest or expiresAt',
|
|
109
|
+
{ approval },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else if (!approval.planDigest || !approval.baseline?.gitTreeHash || !approval.expiresAt) {
|
|
93
113
|
throw new ReleaseError(
|
|
94
114
|
GATE_FAILED,
|
|
95
115
|
'approval record missing required fields: planDigest, baseline.gitTreeHash, or expiresAt',
|
|
@@ -97,35 +117,37 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
97
117
|
);
|
|
98
118
|
}
|
|
99
119
|
|
|
100
|
-
if (
|
|
101
|
-
if (plan.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
if (!planV2) {
|
|
121
|
+
if (plan.production?.mode === 'github-npm-v1') {
|
|
122
|
+
if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
123
|
+
throw new ReleaseError(
|
|
124
|
+
GATE_FAILED,
|
|
125
|
+
`production plan workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
126
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (approval.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
130
|
+
throw new ReleaseError(
|
|
131
|
+
GATE_FAILED,
|
|
132
|
+
`production approval workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
133
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: approval.baseline?.workspaceDigestAlgorithm ?? null },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
|
-
if (
|
|
137
|
+
if (
|
|
138
|
+
plan.baseline?.workspaceDigestAlgorithm &&
|
|
139
|
+
approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
|
|
140
|
+
) {
|
|
109
141
|
throw new ReleaseError(
|
|
110
142
|
GATE_FAILED,
|
|
111
|
-
|
|
112
|
-
{
|
|
143
|
+
'approval workspace digest algorithm does not match the frozen plan',
|
|
144
|
+
{
|
|
145
|
+
planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
|
|
146
|
+
approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
|
|
147
|
+
},
|
|
113
148
|
);
|
|
114
149
|
}
|
|
115
150
|
}
|
|
116
|
-
if (
|
|
117
|
-
plan.baseline?.workspaceDigestAlgorithm &&
|
|
118
|
-
approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
|
|
119
|
-
) {
|
|
120
|
-
throw new ReleaseError(
|
|
121
|
-
GATE_FAILED,
|
|
122
|
-
'approval workspace digest algorithm does not match the frozen plan',
|
|
123
|
-
{
|
|
124
|
-
planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
|
|
125
|
-
approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
|
|
126
|
-
},
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
151
|
|
|
130
152
|
// --- planDigest match ---
|
|
131
153
|
const actualDigest = computePlanDigest(plan);
|
|
@@ -137,30 +159,35 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
137
159
|
);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
|
-
// --- baseline
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// --- baseline.workspaceDigest match ---
|
|
150
|
-
if (plan.baseline?.workspaceDigest) {
|
|
151
|
-
if (!approval.baseline?.workspaceDigest) {
|
|
162
|
+
// --- baseline equality checks (v1 plans only) ---
|
|
163
|
+
// For planVersion 2 plans the baseline is record-layer data: it stays in
|
|
164
|
+
// the plan/approval files for audit but is not an invalidation condition.
|
|
165
|
+
if (!planV2) {
|
|
166
|
+
// --- baseline.gitTreeHash match ---
|
|
167
|
+
if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
|
|
152
168
|
throw new ReleaseError(
|
|
153
169
|
GATE_FAILED,
|
|
154
|
-
|
|
155
|
-
{
|
|
170
|
+
`approval baseline mismatch: approval says ${approval.baseline.gitTreeHash}, plan says ${plan.baseline?.gitTreeHash}`,
|
|
171
|
+
{ approvalTreeHash: approval.baseline.gitTreeHash, planTreeHash: plan.baseline?.gitTreeHash },
|
|
156
172
|
);
|
|
157
173
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
|
|
175
|
+
// --- baseline.workspaceDigest match ---
|
|
176
|
+
if (plan.baseline?.workspaceDigest) {
|
|
177
|
+
if (!approval.baseline?.workspaceDigest) {
|
|
178
|
+
throw new ReleaseError(
|
|
179
|
+
GATE_FAILED,
|
|
180
|
+
'approval record missing baseline.workspaceDigest (plan has workspaceDigest)',
|
|
181
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (approval.baseline.workspaceDigest !== plan.baseline.workspaceDigest) {
|
|
185
|
+
throw new ReleaseError(
|
|
186
|
+
GATE_FAILED,
|
|
187
|
+
`approval workspaceDigest mismatch: approval says ${approval.baseline.workspaceDigest}, plan says ${plan.baseline.workspaceDigest}`,
|
|
188
|
+
{ approvalWorkspaceDigest: approval.baseline.workspaceDigest, planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
164
191
|
}
|
|
165
192
|
}
|
|
166
193
|
|
package/src/core/baseline.mjs
CHANGED
|
@@ -38,6 +38,11 @@ const CONTROL_PLANE_PREFIXES = [
|
|
|
38
38
|
'.release-skill/runs',
|
|
39
39
|
'.release-skill/transactions',
|
|
40
40
|
'.release-skill/kimi-attestations',
|
|
41
|
+
// T3.2 incremental hook cache: a pure local optimisation written by prepare.
|
|
42
|
+
// Excluding it keeps cache records from destabilising workspaceDigest on
|
|
43
|
+
// every prepare (and hook-cache.mjs also skips this prefix when fingerprinting
|
|
44
|
+
// inputs, so records never hash themselves).
|
|
45
|
+
'.release-skill/cache',
|
|
41
46
|
];
|
|
42
47
|
const RESERVED_CONTROL_PREFIXES = [
|
|
43
48
|
...CONTROL_PLANE_PREFIXES,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared checkpoint ordering and dependency-tier constants for the publish
|
|
3
|
+
* and reconcile sagas.
|
|
4
|
+
*
|
|
5
|
+
* These were previously duplicated byte-for-byte in `commands/publish.mjs`
|
|
6
|
+
* and `commands/reconcile.mjs` (the reconcile copy carried a `Must match
|
|
7
|
+
* publish.mjs` comment). They live here as the single source of truth so the
|
|
8
|
+
* two commands cannot drift apart (T3.1 §4.7).
|
|
9
|
+
*
|
|
10
|
+
* The tier table is a HARD-CODED dependency layering. It is never derived at
|
|
11
|
+
* runtime (no topological sort, no dynamic inference): every dependency it
|
|
12
|
+
* encodes is backed by concrete code evidence (see t3-1-parallel-checkpoints.md
|
|
13
|
+
* §3). Action types absent from every tier fail closed; they are never
|
|
14
|
+
* silently appended to the last tier.
|
|
15
|
+
*
|
|
16
|
+
* @module core/checkpoints
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checkpoint order for the publish/reconcile sagas.
|
|
21
|
+
*
|
|
22
|
+
* Used to sort a plan's external actions into a deterministic execution
|
|
23
|
+
* order. Action types not present here sort to the end (index 999), matching
|
|
24
|
+
* the legacy inline comparator in publish.mjs.
|
|
25
|
+
*/
|
|
26
|
+
export const CHECKPOINT_ORDER = [
|
|
27
|
+
'push-commit',
|
|
28
|
+
'push-snapshot',
|
|
29
|
+
'set-default-branch',
|
|
30
|
+
'create-tag',
|
|
31
|
+
'npm-publish',
|
|
32
|
+
'github-release',
|
|
33
|
+
'claude-marketplace-install',
|
|
34
|
+
'codex-marketplace-install',
|
|
35
|
+
'kimi-marketplace-install',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map plan action type to adapter ActionType.
|
|
40
|
+
*
|
|
41
|
+
* Plan uses `push-commit`, `push-snapshot`, `create-tag`, `npm-publish`,
|
|
42
|
+
* `github-release`. The adapter contract uses `git-push`, `git-tag`,
|
|
43
|
+
* `npm-publish`, `github-release`.
|
|
44
|
+
*/
|
|
45
|
+
export const ADAPTER_ACTION_TYPE_MAP = {
|
|
46
|
+
'push-commit': 'git-push',
|
|
47
|
+
'push-snapshot': 'push-snapshot',
|
|
48
|
+
'set-default-branch': 'set-default-branch',
|
|
49
|
+
'create-tag': 'git-tag',
|
|
50
|
+
'npm-publish': 'npm-publish',
|
|
51
|
+
'github-release': 'github-release',
|
|
52
|
+
'claude-marketplace-install': 'claude-marketplace-install',
|
|
53
|
+
'codex-marketplace-install': 'codex-marketplace-install',
|
|
54
|
+
'kimi-marketplace-install': 'kimi-marketplace-install',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hard-coded dependency tiers for parallel checkpoint execution (T3.1 §4.1).
|
|
59
|
+
*
|
|
60
|
+
* Tiers execute strictly serially (a whole tier completes before the next
|
|
61
|
+
* begins); the actions within a tier are independent and run concurrently.
|
|
62
|
+
* Each entry's dependency is backed by code evidence:
|
|
63
|
+
* - Tier 1 `set-default-branch` / `create-tag` depend on Tier 0
|
|
64
|
+
* `push-snapshot` (the frozen commit must exist on the remote before a tag
|
|
65
|
+
* or branch tip can point at it). `npm-publish` has no git dependency and is
|
|
66
|
+
* placed in Tier 1 only for conservative scheduling.
|
|
67
|
+
* - Tier 2 `github-release` and the claude/codex marketplace installs depend
|
|
68
|
+
* on Tier 1 `create-tag` (release `--verify-tag`; install ref is the tag).
|
|
69
|
+
* - Tier 3 `kimi-marketplace-install` depends on Tier 2 `github-release`
|
|
70
|
+
* (its install URL points at the Release page).
|
|
71
|
+
*
|
|
72
|
+
* Action types not listed in any tier are unknown to the scheduler and fail
|
|
73
|
+
* closed (see groupActionsByTier); they are never silently scheduled.
|
|
74
|
+
*/
|
|
75
|
+
export const TIER_TABLE = [
|
|
76
|
+
['push-commit', 'push-snapshot'], // Tier 0
|
|
77
|
+
['set-default-branch', 'create-tag', 'npm-publish'], // Tier 1
|
|
78
|
+
['github-release', 'claude-marketplace-install', 'codex-marketplace-install'], // Tier 2
|
|
79
|
+
['kimi-marketplace-install'], // Tier 3
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Fast reverse lookup: action type -> tier index (-1 when unknown). */
|
|
83
|
+
const TIER_OF = new Map();
|
|
84
|
+
TIER_TABLE.forEach((tierTypes, tierIndex) => {
|
|
85
|
+
for (const type of tierTypes) {
|
|
86
|
+
TIER_OF.set(type, tierIndex);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the tier index for an action type, or -1 if the type is not present
|
|
92
|
+
* in any tier (i.e. unknown to the scheduler and must fail closed).
|
|
93
|
+
*
|
|
94
|
+
* @param {string} actionType - The plan action type.
|
|
95
|
+
* @returns {number} Tier index (0-based) or -1.
|
|
96
|
+
*/
|
|
97
|
+
export function tierOfActionType(actionType) {
|
|
98
|
+
return TIER_OF.has(actionType) ? TIER_OF.get(actionType) : -1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Sort external actions by CHECKPOINT_ORDER.
|
|
103
|
+
*
|
|
104
|
+
* Action types not in CHECKPOINT_ORDER sort to the end (index 999), matching
|
|
105
|
+
* the legacy inline comparator. Returns a new array; the input is not mutated.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object[]} actions - External actions (each has a `type`).
|
|
108
|
+
* @returns {Object[]} A new sorted array.
|
|
109
|
+
*/
|
|
110
|
+
export function sortActionsByCheckpointOrder(actions) {
|
|
111
|
+
return (actions ?? []).slice().sort((a, b) => {
|
|
112
|
+
const ai = CHECKPOINT_ORDER.indexOf(a.type);
|
|
113
|
+
const bi = CHECKPOINT_ORDER.indexOf(b.type);
|
|
114
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Group an ordered list of external actions into dependency tiers.
|
|
120
|
+
*
|
|
121
|
+
* Actions are bucketed by TIER_TABLE; within a tier the input order is
|
|
122
|
+
* preserved (callers pass CHECKPOINT_ORDER-sorted actions). Actions whose type
|
|
123
|
+
* is absent from every tier are collected in `unknown` so the caller can fail
|
|
124
|
+
* closed instead of silently scheduling an unrecognized external write.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object[]} orderedActions - CHECKPOINT_ORDER-sorted external actions.
|
|
127
|
+
* @returns {{ tiers: Object[][], unknown: Object[] }}
|
|
128
|
+
* `tiers[i]` is the array of actions in tier i (possibly empty);
|
|
129
|
+
* `unknown` holds actions whose type is not in any tier.
|
|
130
|
+
*/
|
|
131
|
+
export function groupActionsByTier(orderedActions) {
|
|
132
|
+
const tiers = TIER_TABLE.map(() => []);
|
|
133
|
+
const unknown = [];
|
|
134
|
+
for (const action of orderedActions ?? []) {
|
|
135
|
+
const tierIndex = tierOfActionType(action.type);
|
|
136
|
+
if (tierIndex === -1) {
|
|
137
|
+
unknown.push(action);
|
|
138
|
+
} else {
|
|
139
|
+
tiers[tierIndex].push(action);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { tiers, unknown };
|
|
143
|
+
}
|
package/src/core/evidence.mjs
CHANGED
|
@@ -95,12 +95,22 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
95
95
|
const evidencePath = `${runDir}/evidence.jsonl`;
|
|
96
96
|
const summaryPath = `${runDir}/summary.json`;
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// Sequences start at 1, matching schemas/evidence-event.schema.json
|
|
99
|
+
// (`sequence.minimum: 1`). The historical implementation started at 0; that
|
|
100
|
+
// was an implementation/schema drift, corrected here (T3.1 §4.4).
|
|
101
|
+
let sequence = 1;
|
|
99
102
|
let handle = null;
|
|
103
|
+
// Mutex chain serializing every append. T3.1 runs same-tier checkpoints
|
|
104
|
+
// concurrently, so multiple `append` calls can be in flight at once; even
|
|
105
|
+
// under JS single-threading their awaits would interleave and could tear a
|
|
106
|
+
// line. Chaining each append behind the previous one guarantees a complete
|
|
107
|
+
// line is written before the next event starts.
|
|
108
|
+
let appendChain = Promise.resolve();
|
|
100
109
|
|
|
101
110
|
/**
|
|
102
111
|
* Lazily open the evidence file for appending.
|
|
103
112
|
* Creates the run directory if it does not exist.
|
|
113
|
+
* Must only be called from inside the serialized append chain.
|
|
104
114
|
*/
|
|
105
115
|
async function ensureHandle() {
|
|
106
116
|
if (handle === null) {
|
|
@@ -115,16 +125,23 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
115
125
|
* The event is enriched with automatic metadata:
|
|
116
126
|
* - `schemaVersion`: always 1
|
|
117
127
|
* - `runId`: extracted from the run directory name
|
|
118
|
-
* - `sequence`: auto-incrementing integer starting at
|
|
128
|
+
* - `sequence`: auto-incrementing integer starting at 1
|
|
119
129
|
* - `timestamp`: ISO-8601 string from the clock
|
|
120
130
|
* - `command`: the command passed at creation time
|
|
121
131
|
*
|
|
122
132
|
* The entire event object is redacted before writing.
|
|
123
133
|
*
|
|
134
|
+
* Ordering semantics (T3.1 §4.4): `sequence` is guaranteed monotonic but is
|
|
135
|
+
* NOT guaranteed to match real-world completion order. Same-tier checkpoints
|
|
136
|
+
* run concurrently, so their events are serialized in whichever order reaches
|
|
137
|
+
* the mutex first. Callers that need layer context attach it as `details.tier`
|
|
138
|
+
* (no top-level field is added; the evidence schema is `additionalProperties:
|
|
139
|
+
* false` at the top level).
|
|
140
|
+
*
|
|
124
141
|
* @param {Object} event - The event data. Must include `phase` and `status`;
|
|
125
142
|
* may include `error` and any other fields.
|
|
126
143
|
*/
|
|
127
|
-
async function
|
|
144
|
+
async function appendOnce(event) {
|
|
128
145
|
await ensureHandle();
|
|
129
146
|
|
|
130
147
|
const enriched = {
|
|
@@ -144,6 +161,14 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
144
161
|
await handle.write(`${line}\n`, null, 'utf8');
|
|
145
162
|
}
|
|
146
163
|
|
|
164
|
+
function append(event) {
|
|
165
|
+
const result = appendChain.then(() => appendOnce(event));
|
|
166
|
+
// Keep the chain alive even if one append rejects; the caller still
|
|
167
|
+
// receives that rejection through `result`.
|
|
168
|
+
appendChain = result.then(() => undefined, () => undefined);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
/**
|
|
148
173
|
* Write the final summary file and close the evidence stream.
|
|
149
174
|
*
|
|
@@ -152,6 +177,8 @@ export function createEvidenceWriter({ runDir, command, clock }) {
|
|
|
152
177
|
* @param {Object} summary - The run summary object.
|
|
153
178
|
*/
|
|
154
179
|
async function finish(summary) {
|
|
180
|
+
// Drain any in-flight appends before closing the handle.
|
|
181
|
+
await appendChain;
|
|
155
182
|
await ensureHandle();
|
|
156
183
|
|
|
157
184
|
const redacted = redact(summary);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental hook result cache (T3.2).
|
|
3
|
+
*
|
|
4
|
+
* A hook that opts in with `cacheable: true` and a `cacheInputs` glob list is
|
|
5
|
+
* keyed by the fingerprint of its full configuration plus the content of every
|
|
6
|
+
* file its inputs match. When the key is unchanged and the last run succeeded,
|
|
7
|
+
* prepare replays the cached outcome instead of re-executing the hook.
|
|
8
|
+
*
|
|
9
|
+
* Safety contract (see t3-2-incremental-hooks.md §4.8):
|
|
10
|
+
* - Failures are never cached. Only an `exitCode === 0` result is written; a
|
|
11
|
+
* non-zero exit or HOOK_TIMEOUT leaves no record, so the next run re-executes.
|
|
12
|
+
* - The cache only ever skips execution. It runs AFTER the hook authorization
|
|
13
|
+
* gate and never bypasses any GATE; hook order and failure semantics are
|
|
14
|
+
* untouched.
|
|
15
|
+
* - Fail-closed inputs: if any declared `cacheInputs` glob matches no file, the
|
|
16
|
+
* input set is considered a declaration error and caching aborts with
|
|
17
|
+
* GATE_FAILED before the hook runs (no execution, no cache).
|
|
18
|
+
* - Default zero change: a hook without `cacheable: true` never touches the
|
|
19
|
+
* cache directory at all.
|
|
20
|
+
*
|
|
21
|
+
* The cache is a pure local optimisation under `.release-skill/cache` (a
|
|
22
|
+
* registered control-plane prefix, excluded from workspaceDigest and
|
|
23
|
+
* .gitignore). Deleting it is equivalent to a cold miss for every hook.
|
|
24
|
+
*
|
|
25
|
+
* @module hook-cache
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
31
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
32
|
+
|
|
33
|
+
/** Control-plane location of hook cache records: `.release-skill/cache/hooks`. */
|
|
34
|
+
const CACHE_BASE = ['.release-skill', 'cache', 'hooks'];
|
|
35
|
+
|
|
36
|
+
/** Bounded tail length stored per stream (matches prepare's evidence tails). */
|
|
37
|
+
const TAIL_LENGTH = 4000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Directory names never walked when enumerating hook inputs. `.git` and
|
|
41
|
+
* `node_modules` are VCS/dependency internals; `.release-skill` is the control
|
|
42
|
+
* plane and MUST stay excluded so cache records never fingerprint themselves
|
|
43
|
+
* (which would destabilise every subsequent key).
|
|
44
|
+
*/
|
|
45
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules', '.release-skill']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Translate a `cacheInputs` glob into an anchored RegExp.
|
|
49
|
+
*
|
|
50
|
+
* Supported syntax (sufficient for input declarations):
|
|
51
|
+
* - `**` matches any run of characters, including `/` (crosses directories)
|
|
52
|
+
* - `*` matches any run of characters except `/`
|
|
53
|
+
* - `?` matches a single character except `/`
|
|
54
|
+
* - every other character matches literally (regex metacharacters escaped)
|
|
55
|
+
*
|
|
56
|
+
* @param {string} glob
|
|
57
|
+
* @returns {RegExp}
|
|
58
|
+
*/
|
|
59
|
+
function globToRegExp(glob) {
|
|
60
|
+
let source = '';
|
|
61
|
+
let i = 0;
|
|
62
|
+
while (i < glob.length) {
|
|
63
|
+
const c = glob[i];
|
|
64
|
+
if (c === '*') {
|
|
65
|
+
if (glob[i + 1] === '*') {
|
|
66
|
+
source += '.*';
|
|
67
|
+
i += 2;
|
|
68
|
+
} else {
|
|
69
|
+
source += '[^/]*';
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
} else if (c === '?') {
|
|
73
|
+
source += '[^/]';
|
|
74
|
+
i += 1;
|
|
75
|
+
} else {
|
|
76
|
+
source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
i += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return new RegExp(`^${source}$`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Recursively list every regular file under `root` as a `/`-separated relative
|
|
85
|
+
* path, skipping VCS/dependency/control-plane directories and symlinks (inputs
|
|
86
|
+
* are real files; symlink handling stays deterministic by not following them).
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root - Absolute project root.
|
|
89
|
+
* @returns {Promise<string[]>} Sorted relative paths.
|
|
90
|
+
*/
|
|
91
|
+
async function listInputFiles(root) {
|
|
92
|
+
const out = [];
|
|
93
|
+
|
|
94
|
+
async function walk(dirAbs, dirRel) {
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return; // Unreadable directory: treat as no inputs there.
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
104
|
+
const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
105
|
+
await walk(join(dirAbs, entry.name), rel);
|
|
106
|
+
} else if (entry.isFile()) {
|
|
107
|
+
out.push(dirRel ? `${dirRel}/${entry.name}` : entry.name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await walk(root, '');
|
|
113
|
+
out.sort();
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute the cache key for a cacheable hook.
|
|
119
|
+
*
|
|
120
|
+
* `cacheKey = sha256( canonicalJSON(hook config) + canonicalJSON(sorted matched
|
|
121
|
+
* files [{ path, sha256(content) }]) )`
|
|
122
|
+
*
|
|
123
|
+
* The full hook configuration (command/cwd/timeoutMs/envAllowlist/cacheInputs/
|
|
124
|
+
* cacheable) is part of the key, so any config change switches the key. Matched
|
|
125
|
+
* files are sorted by path before hashing for determinism.
|
|
126
|
+
*
|
|
127
|
+
* @param {Object} hook - A hook descriptor with a non-empty `cacheInputs`.
|
|
128
|
+
* @param {string} root - Absolute project root.
|
|
129
|
+
* @returns {Promise<{ cacheKey: string, matchedFiles: string[] }>}
|
|
130
|
+
* @throws {ReleaseError} GATE_FAILED when any declared glob matches no file.
|
|
131
|
+
*/
|
|
132
|
+
export async function computeHookCacheKey(hook, root) {
|
|
133
|
+
const globs = Array.isArray(hook.cacheInputs) ? hook.cacheInputs : [];
|
|
134
|
+
const matchers = globs.map((glob) => ({ glob, re: globToRegExp(glob) }));
|
|
135
|
+
|
|
136
|
+
const allFiles = await listInputFiles(root);
|
|
137
|
+
const matched = [];
|
|
138
|
+
const hitPerGlob = matchers.map(() => false);
|
|
139
|
+
for (const relPath of allFiles) {
|
|
140
|
+
let hit = false;
|
|
141
|
+
for (let i = 0; i < matchers.length; i += 1) {
|
|
142
|
+
if (matchers[i].re.test(relPath)) {
|
|
143
|
+
hitPerGlob[i] = true;
|
|
144
|
+
hit = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (hit) matched.push(relPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fail-closed: a glob that matches nothing is a declaration error (a typo or
|
|
151
|
+
// a missing input). Refuse to cache rather than risk a false hit.
|
|
152
|
+
for (let i = 0; i < matchers.length; i += 1) {
|
|
153
|
+
if (!hitPerGlob[i]) {
|
|
154
|
+
throw new ReleaseError(
|
|
155
|
+
GATE_FAILED,
|
|
156
|
+
`hook cacheInputs glob "${matchers[i].glob}" matched no files; refusing to cache`,
|
|
157
|
+
{ glob: matchers[i].glob },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
matched.sort();
|
|
163
|
+
const fileEntries = [];
|
|
164
|
+
for (const relPath of matched) {
|
|
165
|
+
const content = await readFile(join(root, relPath));
|
|
166
|
+
fileEntries.push({ path: relPath, sha256: sha256Hex(content) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const cacheKey = sha256Hex(canonicalJson(hook) + canonicalJson(fileEntries));
|
|
170
|
+
return { cacheKey, matchedFiles: matched };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the cache directory for a named hook.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} root
|
|
177
|
+
* @param {string} hookName
|
|
178
|
+
* @returns {string} Absolute path to `.release-skill/cache/hooks/<hookName>`.
|
|
179
|
+
*/
|
|
180
|
+
export function hookCacheDir(root, hookName) {
|
|
181
|
+
return join(root, ...CACHE_BASE, hookName);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the cache record path for a hook + key.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} root
|
|
188
|
+
* @param {string} hookName
|
|
189
|
+
* @param {string} cacheKey
|
|
190
|
+
* @returns {string} Absolute path to the `<cacheKey>.json` record.
|
|
191
|
+
*/
|
|
192
|
+
export function hookCachePath(root, hookName, cacheKey) {
|
|
193
|
+
return join(hookCacheDir(root, hookName), `${cacheKey}.json`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read a cached hook result. Returns the record only when it exists, its key
|
|
198
|
+
* matches, and it recorded a successful (`exitCode === 0`) run; anything else
|
|
199
|
+
* (missing, corrupt, or non-zero) is a miss.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} root
|
|
202
|
+
* @param {string} hookName
|
|
203
|
+
* @param {string} cacheKey
|
|
204
|
+
* @returns {Promise<Object | null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function readHookCache(root, hookName, cacheKey) {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(hookCachePath(root, hookName, cacheKey), 'utf8');
|
|
209
|
+
const record = JSON.parse(raw);
|
|
210
|
+
if (record && record.cacheKey === cacheKey && record.exitCode === 0) {
|
|
211
|
+
return record;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Write a successful hook result to the cache. Never throws: a write failure
|
|
221
|
+
* returns `{ ok: false, error }` so the caller can record a warning without
|
|
222
|
+
* aborting prepare (the cache is an optimisation, not a gate).
|
|
223
|
+
*
|
|
224
|
+
* @param {string} root
|
|
225
|
+
* @param {string} hookName
|
|
226
|
+
* @param {string} cacheKey
|
|
227
|
+
* @param {Object} result
|
|
228
|
+
* @param {number} result.exitCode - Must be 0; non-zero results are not cached.
|
|
229
|
+
* @param {string} [result.stdoutTail] - Already truncated to TAIL_LENGTH.
|
|
230
|
+
* @param {string} [result.stderrTail] - Already truncated to TAIL_LENGTH.
|
|
231
|
+
* @param {string} [result.createdAt] - ISO timestamp (defaults to now).
|
|
232
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
233
|
+
*/
|
|
234
|
+
export async function writeHookCache(root, hookName, cacheKey, result) {
|
|
235
|
+
// Defence in depth: a failure must never be persisted, even if a caller
|
|
236
|
+
// mistakenly passes a non-zero exit code.
|
|
237
|
+
if (!result || result.exitCode !== 0) {
|
|
238
|
+
return { ok: false, error: 'refusing to cache a non-zero exit result' };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await mkdir(hookCacheDir(root, hookName), { recursive: true });
|
|
242
|
+
const record = {
|
|
243
|
+
cacheKey,
|
|
244
|
+
exitCode: 0,
|
|
245
|
+
stdoutTail: String(result.stdoutTail ?? '').slice(-TAIL_LENGTH),
|
|
246
|
+
stderrTail: String(result.stderrTail ?? '').slice(-TAIL_LENGTH),
|
|
247
|
+
createdAt: result.createdAt ?? new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
await writeFile(hookCachePath(root, hookName, cacheKey), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
250
|
+
return { ok: true };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { ok: false, error: err.message };
|
|
253
|
+
}
|
|
254
|
+
}
|