release-skill 0.1.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/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- package/src/snapshot/scan.mjs +419 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared approval validation for publish and reconcile commands.
|
|
3
|
+
*
|
|
4
|
+
* Centralises the safety gates that an approval record must pass before
|
|
5
|
+
* any external write actions can proceed:
|
|
6
|
+
* - planDigest matches the computed plan digest
|
|
7
|
+
* - baseline.gitTreeHash matches the plan baseline
|
|
8
|
+
* - targetVersion matches the plan's first unit target version
|
|
9
|
+
* - approvedActions exactly equals plan external action ids (no superset, no subset)
|
|
10
|
+
* - approval has not expired
|
|
11
|
+
* - approval duration does not exceed 24 hours
|
|
12
|
+
* - approvedAt is not in the future (beyond 5-minute clock skew tolerance)
|
|
13
|
+
*
|
|
14
|
+
* @module core/approval
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
18
|
+
import { computePlanDigest } from './plan.mjs';
|
|
19
|
+
import { sha256Hex } from './digest.mjs';
|
|
20
|
+
import { readFile } from 'node:fs/promises';
|
|
21
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import Ajv from 'ajv';
|
|
24
|
+
import addFormats from 'ajv-formats';
|
|
25
|
+
import { WORKSPACE_DIGEST_ALGORITHM } from './baseline.mjs';
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const approvalSchema = JSON.parse(await readFile(
|
|
29
|
+
join(__dirname, '..', '..', 'schemas', 'approval-record.schema.json'),
|
|
30
|
+
'utf8',
|
|
31
|
+
));
|
|
32
|
+
const approvalAjv = new Ajv({ allErrors: true, strict: false });
|
|
33
|
+
addFormats(approvalAjv);
|
|
34
|
+
const validateApprovalSchema = approvalAjv.compile(approvalSchema);
|
|
35
|
+
|
|
36
|
+
export function validateApprovalRecordSchema(approval) {
|
|
37
|
+
if (validateApprovalSchema(approval)) return;
|
|
38
|
+
const errors = validateApprovalSchema.errors ?? [];
|
|
39
|
+
throw new ReleaseError(
|
|
40
|
+
GATE_FAILED,
|
|
41
|
+
`approval record schema validation failed: ${errors.map((error) => `${error.instancePath || '/'}: ${error.message}`).join('; ')}`,
|
|
42
|
+
{ validationErrors: errors },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function computeApprovalDigest(rawApproval) {
|
|
47
|
+
return sha256Hex(typeof rawApproval === 'string' || Buffer.isBuffer(rawApproval)
|
|
48
|
+
? rawApproval
|
|
49
|
+
: JSON.stringify(rawApproval, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertImmutableApprovalAuthority(approvalPath, plan, rawApproval) {
|
|
53
|
+
if (!plan?.production) return;
|
|
54
|
+
const planDigest = computePlanDigest(plan);
|
|
55
|
+
const approvalDigest = computeApprovalDigest(rawApproval);
|
|
56
|
+
const absolute = resolve(approvalPath);
|
|
57
|
+
const planDirectory = dirname(absolute);
|
|
58
|
+
if (
|
|
59
|
+
basename(absolute) !== `${approvalDigest}.json` ||
|
|
60
|
+
basename(planDirectory) !== planDigest ||
|
|
61
|
+
basename(dirname(planDirectory)) !== 'approvals'
|
|
62
|
+
) {
|
|
63
|
+
throw new ReleaseError(
|
|
64
|
+
GATE_FAILED,
|
|
65
|
+
'production commands require approvals/<planDigest>/<approvalDigest>.json immutable authority',
|
|
66
|
+
{ approvalPath, planDigest, approvalDigest },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return approvalDigest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate an approval record against a loaded plan.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} plan The parsed release plan (must already pass schema validation).
|
|
76
|
+
* @param {Object} approval The parsed approval record.
|
|
77
|
+
* @param {Object} [options]
|
|
78
|
+
* @param {() => string} [options.clock] Clock function returning ISO-8601 strings.
|
|
79
|
+
*
|
|
80
|
+
* @throws {ReleaseError} GATE_FAILED if any validation check fails.
|
|
81
|
+
*/
|
|
82
|
+
export function validateApproval(plan, approval, options = {}) {
|
|
83
|
+
const clockFn = typeof options.clock === 'function' ? options.clock : () => new Date().toISOString();
|
|
84
|
+
|
|
85
|
+
// --- Required fields ---
|
|
86
|
+
if (!approval || typeof approval !== 'object') {
|
|
87
|
+
throw new ReleaseError(
|
|
88
|
+
GATE_FAILED,
|
|
89
|
+
'approval record is missing or not an object',
|
|
90
|
+
{},
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!approval.planDigest || !approval.baseline?.gitTreeHash || !approval.expiresAt) {
|
|
95
|
+
throw new ReleaseError(
|
|
96
|
+
GATE_FAILED,
|
|
97
|
+
'approval record missing required fields: planDigest, baseline.gitTreeHash, or expiresAt',
|
|
98
|
+
{ approval },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (plan.production?.mode === 'github-npm-v1') {
|
|
103
|
+
if (plan.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
104
|
+
throw new ReleaseError(
|
|
105
|
+
GATE_FAILED,
|
|
106
|
+
`production plan workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
107
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: plan.baseline?.workspaceDigestAlgorithm ?? null },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (approval.baseline?.workspaceDigestAlgorithm !== WORKSPACE_DIGEST_ALGORITHM) {
|
|
111
|
+
throw new ReleaseError(
|
|
112
|
+
GATE_FAILED,
|
|
113
|
+
`production approval workspace digest algorithm is missing or obsolete; expected ${WORKSPACE_DIGEST_ALGORITHM}`,
|
|
114
|
+
{ expected: WORKSPACE_DIGEST_ALGORITHM, actual: approval.baseline?.workspaceDigestAlgorithm ?? null },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
plan.baseline?.workspaceDigestAlgorithm &&
|
|
120
|
+
approval.baseline?.workspaceDigestAlgorithm !== plan.baseline.workspaceDigestAlgorithm
|
|
121
|
+
) {
|
|
122
|
+
throw new ReleaseError(
|
|
123
|
+
GATE_FAILED,
|
|
124
|
+
'approval workspace digest algorithm does not match the frozen plan',
|
|
125
|
+
{
|
|
126
|
+
planAlgorithm: plan.baseline.workspaceDigestAlgorithm,
|
|
127
|
+
approvalAlgorithm: approval.baseline?.workspaceDigestAlgorithm ?? null,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- planDigest match ---
|
|
133
|
+
const actualDigest = computePlanDigest(plan);
|
|
134
|
+
if (approval.planDigest !== actualDigest) {
|
|
135
|
+
throw new ReleaseError(
|
|
136
|
+
GATE_FAILED,
|
|
137
|
+
`approval planDigest mismatch: approval says ${String(approval.planDigest).slice(0, 16)}..., plan is ${actualDigest.slice(0, 16)}...`,
|
|
138
|
+
{ approvalDigest: approval.planDigest, planDigest: actualDigest },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- baseline.gitTreeHash match ---
|
|
143
|
+
if (approval.baseline.gitTreeHash !== plan.baseline?.gitTreeHash) {
|
|
144
|
+
throw new ReleaseError(
|
|
145
|
+
GATE_FAILED,
|
|
146
|
+
`approval baseline mismatch: approval says ${approval.baseline.gitTreeHash}, plan says ${plan.baseline?.gitTreeHash}`,
|
|
147
|
+
{ approvalTreeHash: approval.baseline.gitTreeHash, planTreeHash: plan.baseline?.gitTreeHash },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- baseline.workspaceDigest match ---
|
|
152
|
+
if (plan.baseline?.workspaceDigest) {
|
|
153
|
+
if (!approval.baseline?.workspaceDigest) {
|
|
154
|
+
throw new ReleaseError(
|
|
155
|
+
GATE_FAILED,
|
|
156
|
+
'approval record missing baseline.workspaceDigest (plan has workspaceDigest)',
|
|
157
|
+
{ planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (approval.baseline.workspaceDigest !== plan.baseline.workspaceDigest) {
|
|
161
|
+
throw new ReleaseError(
|
|
162
|
+
GATE_FAILED,
|
|
163
|
+
`approval workspaceDigest mismatch: approval says ${approval.baseline.workspaceDigest}, plan says ${plan.baseline.workspaceDigest}`,
|
|
164
|
+
{ approvalWorkspaceDigest: approval.baseline.workspaceDigest, planWorkspaceDigest: plan.baseline.workspaceDigest },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Bind approval to every unit id + targetVersion pair ---
|
|
170
|
+
const units = plan.units ?? [];
|
|
171
|
+
const expectedUnitVersions = {};
|
|
172
|
+
const versions = new Set();
|
|
173
|
+
for (const unit of units) {
|
|
174
|
+
if (!unit.targetVersion || typeof unit.targetVersion !== 'string' || unit.targetVersion.trim() === '') {
|
|
175
|
+
throw new ReleaseError(
|
|
176
|
+
GATE_FAILED,
|
|
177
|
+
`unit "${unit.id ?? '(unknown)'}" is missing targetVersion; all units must have a non-empty targetVersion`,
|
|
178
|
+
{ unitId: unit.id },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
expectedUnitVersions[unit.id] = unit.targetVersion;
|
|
182
|
+
versions.add(unit.targetVersion);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (approval.unitVersions) {
|
|
186
|
+
const expectedIds = Object.keys(expectedUnitVersions).sort();
|
|
187
|
+
const approvedIds = Object.keys(approval.unitVersions).sort();
|
|
188
|
+
if (JSON.stringify(expectedIds) !== JSON.stringify(approvedIds)) {
|
|
189
|
+
throw new ReleaseError(
|
|
190
|
+
GATE_FAILED,
|
|
191
|
+
'approval unitVersions keys do not exactly match plan units',
|
|
192
|
+
{ expectedIds, approvedIds },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
for (const unitId of expectedIds) {
|
|
196
|
+
if (approval.unitVersions[unitId] !== expectedUnitVersions[unitId]) {
|
|
197
|
+
throw new ReleaseError(
|
|
198
|
+
GATE_FAILED,
|
|
199
|
+
`target version mismatch for unit "${unitId}": plan says ${expectedUnitVersions[unitId]}, approval says ${approval.unitVersions[unitId]}`,
|
|
200
|
+
{ unitId, planVersion: expectedUnitVersions[unitId], approvalVersion: approval.unitVersions[unitId] },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (approval.targetVersion !== undefined) {
|
|
205
|
+
if (versions.size !== 1) {
|
|
206
|
+
throw new ReleaseError(
|
|
207
|
+
GATE_FAILED,
|
|
208
|
+
'approval targetVersion is ambiguous for heterogeneous unitVersions; omit targetVersion',
|
|
209
|
+
{ targetVersion: approval.targetVersion, expectedUnitVersions },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const [planVersion] = versions;
|
|
213
|
+
if (approval.targetVersion !== planVersion) {
|
|
214
|
+
throw new ReleaseError(
|
|
215
|
+
GATE_FAILED,
|
|
216
|
+
`approval targetVersion conflicts with unitVersions: expected ${planVersion}, got ${approval.targetVersion}`,
|
|
217
|
+
{ planVersion, approvalVersion: approval.targetVersion },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
if (versions.size !== 1) {
|
|
223
|
+
throw new ReleaseError(
|
|
224
|
+
GATE_FAILED,
|
|
225
|
+
'heterogeneous multi-unit plan requires approval.unitVersions',
|
|
226
|
+
{ expectedUnitVersions },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const planVersion = units[0]?.targetVersion;
|
|
230
|
+
if (!approval.targetVersion || planVersion !== approval.targetVersion) {
|
|
231
|
+
throw new ReleaseError(
|
|
232
|
+
GATE_FAILED,
|
|
233
|
+
`target version mismatch: plan says ${planVersion}, approval says ${approval.targetVersion ?? '(missing)'}`,
|
|
234
|
+
{ planVersion, approvalVersion: approval.targetVersion },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- approvedActions exact set equality (not just superset) ---
|
|
240
|
+
const approvedSet = new Set(approval.approvedActions ?? []);
|
|
241
|
+
const planActionIds = new Set((plan.externalActions ?? []).map((a) => a.id));
|
|
242
|
+
|
|
243
|
+
// Every plan action must be in the approved set
|
|
244
|
+
for (const action of plan.externalActions ?? []) {
|
|
245
|
+
if (!approvedSet.has(action.id)) {
|
|
246
|
+
throw new ReleaseError(
|
|
247
|
+
GATE_FAILED,
|
|
248
|
+
`action "${action.id}" is not in the approved actions list`,
|
|
249
|
+
{ actionId: action.id, approvedActions: [...approvedSet] },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// No extra actions in the approved set (exact match required)
|
|
255
|
+
for (const approvedId of approvedSet) {
|
|
256
|
+
if (!planActionIds.has(approvedId)) {
|
|
257
|
+
throw new ReleaseError(
|
|
258
|
+
GATE_FAILED,
|
|
259
|
+
`approved actions list contains "${approvedId}" which is not a plan action; exact set equality required`,
|
|
260
|
+
{ approvedId, planActionIds: [...planActionIds] },
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Time validation ---
|
|
266
|
+
const approvedAtDate = new Date(approval.approvedAt);
|
|
267
|
+
if (Number.isNaN(approvedAtDate.getTime())) {
|
|
268
|
+
throw new ReleaseError(
|
|
269
|
+
GATE_FAILED,
|
|
270
|
+
`invalid approvedAt: "${approval.approvedAt}"`,
|
|
271
|
+
{ approvedAt: approval.approvedAt },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const expiresAtDate = new Date(approval.expiresAt);
|
|
276
|
+
if (Number.isNaN(expiresAtDate.getTime())) {
|
|
277
|
+
throw new ReleaseError(
|
|
278
|
+
GATE_FAILED,
|
|
279
|
+
`invalid expiresAt: "${approval.expiresAt}"`,
|
|
280
|
+
{ expiresAt: approval.expiresAt },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (expiresAtDate.getTime() <= approvedAtDate.getTime()) {
|
|
285
|
+
throw new ReleaseError(
|
|
286
|
+
GATE_FAILED,
|
|
287
|
+
`expiresAt (${approval.expiresAt}) must be after approvedAt (${approval.approvedAt})`,
|
|
288
|
+
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Max 24h approval window ---
|
|
293
|
+
const MAX_APPROVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
294
|
+
const approvalDurationMs = expiresAtDate.getTime() - approvedAtDate.getTime();
|
|
295
|
+
if (approvalDurationMs > MAX_APPROVAL_MS) {
|
|
296
|
+
throw new ReleaseError(
|
|
297
|
+
GATE_FAILED,
|
|
298
|
+
`approval duration ${Math.round(approvalDurationMs / 3600000)}h exceeds maximum 24h`,
|
|
299
|
+
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt, durationHours: approvalDurationMs / 3600000 },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Reject future approvedAt (beyond 5-minute clock skew tolerance) ---
|
|
304
|
+
const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
|
|
305
|
+
const now = clockFn();
|
|
306
|
+
const nowDate = new Date(now);
|
|
307
|
+
if (Number.isNaN(nowDate.getTime())) {
|
|
308
|
+
throw new ReleaseError(
|
|
309
|
+
GATE_FAILED,
|
|
310
|
+
`invalid clock value: "${now}"`,
|
|
311
|
+
{ clock: now },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (approvedAtDate.getTime() > nowDate.getTime() + CLOCK_SKEW_TOLERANCE_MS) {
|
|
315
|
+
throw new ReleaseError(
|
|
316
|
+
GATE_FAILED,
|
|
317
|
+
`approvedAt (${approval.approvedAt}) is in the future (current time: ${now})`,
|
|
318
|
+
{ approvedAt: approval.approvedAt, now },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Expiry (publish/reconcile require current approval; verify may only
|
|
323
|
+
// revalidate the immutable approval identity after publication) ---
|
|
324
|
+
if (options.requireUnexpired !== false && nowDate > expiresAtDate) {
|
|
325
|
+
throw new ReleaseError(
|
|
326
|
+
GATE_FAILED,
|
|
327
|
+
`approval expired at ${approval.expiresAt}, current time is ${now}`,
|
|
328
|
+
{ expiresAt: approval.expiresAt, now },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { lstat, open, readlink } from 'node:fs/promises';
|
|
5
|
+
import { constants as fsConstants } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Narrow, centrally-defined list of release-skill control-plane paths that
|
|
12
|
+
* the tool itself creates or deletes during the release lifecycle. These
|
|
13
|
+
* paths must be excluded from workspaceDigest and statusEntries so that the
|
|
14
|
+
* prepare→approve→publish happy path does not trigger a false BASELINE_CHANGED.
|
|
15
|
+
*
|
|
16
|
+
* Only paths **within** `.release-skill/` are listed here. Runtime directories
|
|
17
|
+
* use reserved prefixes; immutable plans and approvals use exact digest-shaped
|
|
18
|
+
* paths so arbitrary files under similarly named directories remain visible.
|
|
19
|
+
*
|
|
20
|
+
* `project.yaml` is intentionally **not** listed — changes to project
|
|
21
|
+
* configuration must always cause a baseline drift.
|
|
22
|
+
*/
|
|
23
|
+
export const WORKSPACE_DIGEST_ALGORITHM = 'git-workspace-v2';
|
|
24
|
+
|
|
25
|
+
const CONTROL_PLANE_PREFIXES = [
|
|
26
|
+
'.release-skill/lock',
|
|
27
|
+
'.release-skill/lock-audit',
|
|
28
|
+
'.release-skill/runs',
|
|
29
|
+
'.release-skill/transactions',
|
|
30
|
+
];
|
|
31
|
+
const RESERVED_CONTROL_PREFIXES = [
|
|
32
|
+
...CONTROL_PLANE_PREFIXES,
|
|
33
|
+
'.release-skill/plans',
|
|
34
|
+
'.release-skill/approvals',
|
|
35
|
+
];
|
|
36
|
+
const CONTROL_PLANE_EXACT = new Set([
|
|
37
|
+
'.release-skill/release-plan.json',
|
|
38
|
+
'.release-skill/approval-record.json',
|
|
39
|
+
]);
|
|
40
|
+
const DIGEST = '[a-f0-9]{64}';
|
|
41
|
+
const IMMUTABLE_PLAN = new RegExp(`^\\.release-skill/plans/${DIGEST}\\.json$`);
|
|
42
|
+
const IMMUTABLE_APPROVAL = new RegExp(
|
|
43
|
+
`^\\.release-skill/approvals/${DIGEST}/${DIGEST}\\.json$`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check whether a path (relative to repo root, using `/` separators) is a
|
|
48
|
+
* release-skill control-plane file that should be excluded from the digest.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} p - Repo-relative path.
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isControlPlanePath(p) {
|
|
54
|
+
// Git's -z output preserves path bytes and uses `/` as the repository
|
|
55
|
+
// separator. Do not rewrite literal backslashes: on POSIX they are valid
|
|
56
|
+
// filename bytes, not separators.
|
|
57
|
+
const normalized = p;
|
|
58
|
+
if (CONTROL_PLANE_EXACT.has(normalized)) return true;
|
|
59
|
+
if (IMMUTABLE_PLAN.test(normalized) || IMMUTABLE_APPROVAL.test(normalized)) return true;
|
|
60
|
+
return CONTROL_PLANE_PREFIXES.some(
|
|
61
|
+
(cp) => normalized === cp || normalized.startsWith(`${cp}/`),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isReservedReleaseControlPath(p) {
|
|
66
|
+
return RESERVED_CONTROL_PREFIXES.some(
|
|
67
|
+
(prefix) => p === prefix || p.startsWith(`${prefix}/`),
|
|
68
|
+
) || CONTROL_PLANE_EXACT.has(p);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function splitNul(value) {
|
|
72
|
+
const records = value.split('\0');
|
|
73
|
+
if (records.at(-1) === '') records.pop();
|
|
74
|
+
return records;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseStatusPorcelainZ(statusOut) {
|
|
78
|
+
const records = splitNul(statusOut);
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
81
|
+
const record = records[index];
|
|
82
|
+
if (record.length < 3) throw new Error('Malformed git status --porcelain=v1 -z record');
|
|
83
|
+
const xy = record.slice(0, 2);
|
|
84
|
+
const destination = record.slice(3);
|
|
85
|
+
const renamed = xy.includes('R') || xy.includes('C');
|
|
86
|
+
const source = renamed ? records[++index] : null;
|
|
87
|
+
if (renamed && source === undefined) {
|
|
88
|
+
throw new Error('Malformed git status rename record: missing source path');
|
|
89
|
+
}
|
|
90
|
+
const paths = source === null ? [destination] : [source, destination];
|
|
91
|
+
// A rename crossing the control-plane boundary is user-visible. Exclude
|
|
92
|
+
// only when every path participating in the status record is control data.
|
|
93
|
+
if (paths.every(isControlPlanePath)) continue;
|
|
94
|
+
entries.push(source === null
|
|
95
|
+
? `${xy} ${destination}`
|
|
96
|
+
: `${xy} ${source} -> ${destination}`);
|
|
97
|
+
}
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compute a deterministic workspace digest covering staged, unstaged, and
|
|
103
|
+
* untracked (non-ignored) content.
|
|
104
|
+
*
|
|
105
|
+
* Sources:
|
|
106
|
+
* - `git ls-files -s` -- staged index entries (mode, object hash, path)
|
|
107
|
+
* - `git diff` -- unstaged modifications to tracked files (patch text)
|
|
108
|
+
* - `git ls-files -o --exclude-standard` -- untracked files respecting .gitignore
|
|
109
|
+
*
|
|
110
|
+
* All lines are sorted lexicographically to ensure determinism. The combined
|
|
111
|
+
* string is hashed with SHA-256.
|
|
112
|
+
*
|
|
113
|
+
* Control-plane paths (lock, runs, approval-record, etc.) are filtered out
|
|
114
|
+
* so that release-skill's own lifecycle files do not cause baseline drift.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} root - Absolute path to a valid git repository directory.
|
|
117
|
+
* @returns {Promise<string>} Hex-encoded SHA-256 digest.
|
|
118
|
+
*/
|
|
119
|
+
async function computeWorkspaceDigest(root) {
|
|
120
|
+
const opts = { cwd: root, shell: false, encoding: 'utf8' };
|
|
121
|
+
|
|
122
|
+
const [
|
|
123
|
+
{ stdout: stagedOut },
|
|
124
|
+
{ stdout: changedOut },
|
|
125
|
+
{ stdout: untrackedOut },
|
|
126
|
+
] = await Promise.all([
|
|
127
|
+
execFile('git', ['ls-files', '-s', '-z'], opts),
|
|
128
|
+
execFile('git', ['diff', '--name-only', '-z'], opts),
|
|
129
|
+
execFile('git', ['ls-files', '-o', '--exclude-standard', '-z'], opts),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
// Collect all meaningful lines
|
|
133
|
+
const parts = [];
|
|
134
|
+
|
|
135
|
+
// Staged entries: one line per file like "100644 <hash> 0\tpath"
|
|
136
|
+
// Filter out control-plane paths.
|
|
137
|
+
for (const record of splitNul(stagedOut)) {
|
|
138
|
+
const tabIdx = record.indexOf('\t');
|
|
139
|
+
const filePath = tabIdx >= 0 ? record.slice(tabIdx + 1) : null;
|
|
140
|
+
if (filePath && isControlPlanePath(filePath)) continue;
|
|
141
|
+
parts.push(record);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ask Git for unambiguous NUL-delimited names, then request the patch for
|
|
145
|
+
// each exact argv path. This avoids parsing C-quoted `diff --git` headers.
|
|
146
|
+
const changedPaths = splitNul(changedOut).filter((p) => !isControlPlanePath(p));
|
|
147
|
+
for (const changedPath of changedPaths) {
|
|
148
|
+
const { stdout: patch } = await execFile(
|
|
149
|
+
'git',
|
|
150
|
+
['diff', '--no-ext-diff', '--no-textconv', '--binary', '--no-color', '--', changedPath],
|
|
151
|
+
opts,
|
|
152
|
+
);
|
|
153
|
+
parts.push(`UNSTAGED:${changedPath}\0${patch}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Untracked (non-ignored) entries: filter control-plane paths, then produce a
|
|
157
|
+
// safe content digest binding path + Git-visible type + file bytes.
|
|
158
|
+
const untrackedPaths = splitNul(untrackedOut)
|
|
159
|
+
.filter((p) => p.length > 0 && !isControlPlanePath(p));
|
|
160
|
+
|
|
161
|
+
const untrackedParts = await Promise.all(
|
|
162
|
+
untrackedPaths.map(async (relPath) => {
|
|
163
|
+
const absPath = path.resolve(root, relPath);
|
|
164
|
+
|
|
165
|
+
// Defense-in-depth: resolved path must remain within the repo root.
|
|
166
|
+
// Normalise both sides to handle trailing-separator differences.
|
|
167
|
+
const normalisedRoot = path.resolve(root) + path.sep;
|
|
168
|
+
if (!absPath.startsWith(normalisedRoot) && absPath !== path.resolve(root)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Refusing to read path outside repository root: ${relPath} resolves to ${absPath}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stat = await lstat(absPath);
|
|
175
|
+
// Git-visible type marker (do NOT follow symlinks)
|
|
176
|
+
let type;
|
|
177
|
+
if (stat.isSymbolicLink()) {
|
|
178
|
+
type = 'symlink';
|
|
179
|
+
} else if (stat.isDirectory()) {
|
|
180
|
+
type = 'dir';
|
|
181
|
+
} else if (stat.isFile()) {
|
|
182
|
+
type = 'file';
|
|
183
|
+
} else {
|
|
184
|
+
type = 'other';
|
|
185
|
+
}
|
|
186
|
+
const typeMarker = `TYPE:${type}`;
|
|
187
|
+
|
|
188
|
+
let content;
|
|
189
|
+
if (stat.isFile()) {
|
|
190
|
+
// TOCTOU-safe read: open with O_NOFOLLOW so a symlink replacing the
|
|
191
|
+
// file between lstat and open causes ELOOP, then verify the inode
|
|
192
|
+
// still matches to catch same-type replacement races.
|
|
193
|
+
const handle = await open(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
194
|
+
try {
|
|
195
|
+
const fdStat = await handle.stat();
|
|
196
|
+
if (fdStat.ino !== stat.ino) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Race detected: inode changed for ${relPath} between lstat and read`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const { buffer, bytesRead } = await handle.read(
|
|
202
|
+
Buffer.alloc(fdStat.size || 4096),
|
|
203
|
+
0,
|
|
204
|
+
fdStat.size || 4096,
|
|
205
|
+
0,
|
|
206
|
+
);
|
|
207
|
+
content = buffer.subarray(0, bytesRead);
|
|
208
|
+
} finally {
|
|
209
|
+
await handle.close();
|
|
210
|
+
}
|
|
211
|
+
} else if (stat.isSymbolicLink()) {
|
|
212
|
+
// Bind the link target itself — do NOT follow.
|
|
213
|
+
content = Buffer.from(await readlink(absPath), 'utf8');
|
|
214
|
+
} else {
|
|
215
|
+
content = Buffer.alloc(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [
|
|
219
|
+
relPath,
|
|
220
|
+
typeMarker,
|
|
221
|
+
createHash('sha256').update(content).digest('hex'),
|
|
222
|
+
].join('\t');
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Sort for determinism
|
|
227
|
+
parts.sort();
|
|
228
|
+
untrackedParts.sort();
|
|
229
|
+
|
|
230
|
+
const combined = parts.join('\n')
|
|
231
|
+
+ (parts.length && untrackedParts.length ? '\n' : '')
|
|
232
|
+
+ untrackedParts.join('\n');
|
|
233
|
+
|
|
234
|
+
return createHash('sha256').update(combined).digest('hex');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Capture the Git baseline state for a repository.
|
|
239
|
+
*
|
|
240
|
+
* Returns a `workspaceDigest` that deterministically covers HEAD/tree AND
|
|
241
|
+
* staged, unstaged, and untracked (non-ignored) content. Changes to
|
|
242
|
+
* git-ignored paths (e.g. `.release-skill/`, `node_modules/`) do not affect
|
|
243
|
+
* the digest.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} root - Absolute path to a valid git repository directory.
|
|
246
|
+
* @returns {Promise<{ gitHead: string, gitTreeHash: string, workspaceDigest: string, statusEntries: string[], capturedAt: string }>}
|
|
247
|
+
*/
|
|
248
|
+
export async function captureBaseline(root) {
|
|
249
|
+
const opts = { cwd: root, shell: false, encoding: 'utf8' };
|
|
250
|
+
|
|
251
|
+
const [{ stdout: headOut }, { stdout: treeOut }, { stdout: statusOut }] =
|
|
252
|
+
await Promise.all([
|
|
253
|
+
execFile('git', ['rev-parse', 'HEAD'], opts),
|
|
254
|
+
execFile('git', ['rev-parse', 'HEAD^{tree}'], opts),
|
|
255
|
+
execFile('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], opts),
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const gitHead = headOut.trim();
|
|
259
|
+
const gitTreeHash = treeOut.trim();
|
|
260
|
+
const statusEntries = parseStatusPorcelainZ(statusOut);
|
|
261
|
+
|
|
262
|
+
const workspaceDigest = await computeWorkspaceDigest(root);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
gitHead,
|
|
266
|
+
gitTreeHash,
|
|
267
|
+
workspaceDigestAlgorithm: WORKSPACE_DIGEST_ALGORITHM,
|
|
268
|
+
workspaceDigest,
|
|
269
|
+
statusEntries,
|
|
270
|
+
capturedAt: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
}
|