release-skill 0.1.4 → 0.1.6
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/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +104 -0
- package/INSTALL.md +81 -1
- package/INSTALL.zh-CN.md +69 -1
- package/README.md +233 -8
- package/README.zh-CN.md +188 -8
- 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 +14164 -9912
- package/adapters/claude/bin/release-skill.mjs +24 -4
- package/adapters/claude/native/safe-write/binding.gyp +2 -1
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -10
- package/adapters/claude/schemas/release-project.schema.json +141 -0
- package/adapters/claude/skills/release-help/SKILL.md +21 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
- package/adapters/claude/skills/release-publish/SKILL.md +3 -1
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
- package/adapters/codex/bin/release-skill.mjs +24 -4
- package/adapters/codex/native/safe-write/binding.gyp +2 -1
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -10
- package/adapters/codex/schemas/release-project.schema.json +141 -0
- package/adapters/codex/skills/release-help/SKILL.md +21 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
- package/adapters/codex/skills/release-publish/SKILL.md +3 -1
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/bin/release-skill-cli.mjs +163 -4
- package/bin/release-skill.bundle.mjs +14164 -9912
- package/bin/release-skill.mjs +24 -4
- package/native/safe-write/binding.gyp +2 -1
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +1 -1
- package/package.json +2 -2
- package/references/.render-manifest.json +4 -4
- package/references/02-project-config.md +24 -0
- package/references/05-evidence-and-errors.md +5 -0
- package/schemas/.render-manifest.json +10 -10
- package/schemas/release-project.schema.json +141 -0
- package/scripts/build-bundle.mjs +15 -2
- package/skills/release-help/SKILL.md +21 -0
- package/skills/release-prepare/SKILL.md +17 -6
- package/skills/release-publish/SKILL.md +3 -1
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-help/SKILL.md +21 -0
- package/skills-src/release-prepare/SKILL.md +17 -6
- package/skills-src/release-publish/SKILL.md +3 -1
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/src/adapters/plugin-marketplace.mjs +70 -3
- package/src/artifacts/transaction-journal.mjs +1126 -105
- package/src/artifacts/transaction.mjs +313 -130
- package/src/commands/docs.mjs +332 -0
- package/src/commands/prepare.mjs +324 -17
- package/src/commands/reconcile.mjs +4 -1
- package/src/commands/verify.mjs +4 -1
- package/src/core/errors.mjs +64 -2
- package/src/core/plan.mjs +59 -1
- package/src/core/redact.mjs +206 -0
- package/src/docs/changelog-renderer.mjs +853 -0
- package/src/docs/config.mjs +337 -0
- package/src/docs/notes-loader.mjs +432 -0
- package/src/docs/notes.mjs +553 -0
- package/src/docs/readme-renderer.mjs +647 -0
- package/src/docs/refresh-planner.mjs +542 -0
- package/src/docs/refresh-service.mjs +675 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe loader for the structured release-notes source
|
|
3
|
+
* (2026-07-21-release-docs-refresh-protocol §2/§3).
|
|
4
|
+
*
|
|
5
|
+
* `loadReleaseNotesSource({ unitRoot, config, version, maxBytes, seam, backendFactory })`:
|
|
6
|
+
*
|
|
7
|
+
* 1. normalizes `config` through `normalizeReleaseDocumentsConfig` (closed
|
|
8
|
+
* semantic validation first);
|
|
9
|
+
* 2. substitutes every `{version}` placeholder in `notesSource` and
|
|
10
|
+
* re-canonicalizes the result through the shared `canonicalArtifactPath`
|
|
11
|
+
* helper; the version itself must be a safe single path segment;
|
|
12
|
+
* 3. reads the target exclusively through directory handles provided by the
|
|
13
|
+
* safe-filesystem backend (`artifacts/safe-fs.mjs`): `openRoot` on the
|
|
14
|
+
* caller-supplied unit root AS-IS (the loader never realpath's it; the
|
|
15
|
+
* backend's per-segment O_NOFOLLOW walk rejects a symlinked root or
|
|
16
|
+
* ancestor), then `openDir` per ancestor segment (openat, no-follow),
|
|
17
|
+
* then `readEntry` + `readFile` on the leaf relative to the parent
|
|
18
|
+
* handle. No absolute path is ever opened after the root handle is
|
|
19
|
+
* established, so a parent directory replaced with a symlink between
|
|
20
|
+
* the walk and the leaf read cannot redirect the read (openat stays
|
|
21
|
+
* bound to the original directory inode). All handles are closed in
|
|
22
|
+
* reverse order. Every identity consumed from `readEntry` (pre-read)
|
|
23
|
+
* and `readFile` (post-read) is strictly validated first — `size`,
|
|
24
|
+
* `dev`, `ino`, `nlink` must be non-negative safe integers and `bytes`
|
|
25
|
+
* must be a `Buffer`; anything else fails closed before parsing. The
|
|
26
|
+
* identity is then verified: regular-file type, `nlink === 1`, size
|
|
27
|
+
* within `maxBytes`, and agreement between reported size, returned
|
|
28
|
+
* bytes length, and the pre-read `readEntry` metadata (dev/ino/size) —
|
|
29
|
+
* any mismatch fails closed;
|
|
30
|
+
* 4. delegates to `parseReleaseNotes` with the format chosen by suffix
|
|
31
|
+
* (yaml/yml/json);
|
|
32
|
+
* 5. returns a deeply frozen `{ relativePath, bytesDigest, notes }` where
|
|
33
|
+
* `bytesDigest` is `sha256:` + lowercase hex over the raw bytes.
|
|
34
|
+
*
|
|
35
|
+
* If the safe backend is unavailable the loader fails closed with the
|
|
36
|
+
* backend's stable error (e.g. SAFE_WRITE_UNAVAILABLE); it never degrades
|
|
37
|
+
* to absolute-path opens.
|
|
38
|
+
*
|
|
39
|
+
* The handle walk itself is exported as `readSafeFileThroughHandles` so the
|
|
40
|
+
* refresh service reads every release-document target through the identical
|
|
41
|
+
* safe-read primitive (same O_NOFOLLOW walk, same identity checks, plus the
|
|
42
|
+
* permission mode captured by the same stable readFile).
|
|
43
|
+
*
|
|
44
|
+
* Injection points (tests only):
|
|
45
|
+
* - `backendFactory: async () => backend` — supply a backend built from a
|
|
46
|
+
* fake addon via `createBackend` to cover race/identity branches
|
|
47
|
+
* deterministically;
|
|
48
|
+
* - `seam.beforeLeafRead({ parentDir })` — awaited after every ancestor
|
|
49
|
+
* handle is established and before the leaf read. It receives ONLY the
|
|
50
|
+
* relative parent directory (never an absolute path, never unitRoot).
|
|
51
|
+
*
|
|
52
|
+
* Path/race problems throw the existing stable code `PATH_UNSAFE`; semantic
|
|
53
|
+
* problems throw `RELEASE_DOCS_INVALID`; parse problems propagate their own
|
|
54
|
+
* codes (including `RELEASE_DOCS_TRANSLATION_MISSING`). Error messages and
|
|
55
|
+
* details never carry absolute paths or note body text.
|
|
56
|
+
*
|
|
57
|
+
* @module src/docs/notes-loader
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { loadSafeFs } from '../artifacts/safe-fs.mjs';
|
|
61
|
+
import { canonicalArtifactPath } from '../artifacts/path-key.mjs';
|
|
62
|
+
import { sha256Hex } from '../core/digest.mjs';
|
|
63
|
+
import {
|
|
64
|
+
ReleaseError,
|
|
65
|
+
PATH_UNSAFE,
|
|
66
|
+
RELEASE_DOCS_INVALID,
|
|
67
|
+
} from '../core/errors.mjs';
|
|
68
|
+
import { normalizeReleaseDocumentsConfig } from './config.mjs';
|
|
69
|
+
import { parseReleaseNotes, DEFAULT_MAX_NOTES_BYTES } from './notes.mjs';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Error helpers (never include absolute paths or body text)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function unsafe(reason, message, details = {}) {
|
|
76
|
+
throw new ReleaseError(PATH_UNSAFE, message, { reason, ...details });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function invalid(reason, message, details = {}) {
|
|
80
|
+
throw new ReleaseError(RELEASE_DOCS_INVALID, message, { reason, ...details });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Wrap a backend error without forwarding its (already sanitized) message. */
|
|
84
|
+
function unsafeCause(reason, message, err, details = {}) {
|
|
85
|
+
throw new ReleaseError(PATH_UNSAFE, message, {
|
|
86
|
+
reason,
|
|
87
|
+
cause: err?.code ?? 'BACKEND_ERROR',
|
|
88
|
+
...details,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Placeholder substitution and format selection
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const FORMAT_BY_SUFFIX = Object.freeze({ yaml: 'yaml', yml: 'yml', json: 'json' });
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Substitute every `{version}` placeholder and re-canonicalize the path.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} notesSource Canonical notesSource from config validation.
|
|
102
|
+
* @param {string} version Release version; must be a safe single segment.
|
|
103
|
+
* @returns {{ relativePath: string, format: string }}
|
|
104
|
+
*/
|
|
105
|
+
function resolveNotesPath(notesSource, version) {
|
|
106
|
+
// The version must itself be a safe single path segment before it may be
|
|
107
|
+
// embedded into a filesystem path. canonicalArtifactPath covers traversal,
|
|
108
|
+
// separators (backslash), colon, NUL and reserved names; the single-segment
|
|
109
|
+
// requirement is enforced explicitly here.
|
|
110
|
+
if (version.includes('/')) {
|
|
111
|
+
invalid(
|
|
112
|
+
'UNSAFE_VERSION_SEGMENT',
|
|
113
|
+
'version is not a safe file-name fragment for notesSource substitution',
|
|
114
|
+
{ version, cause: 'PATH_UNSAFE' },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
canonicalArtifactPath(version);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
invalid(
|
|
121
|
+
'UNSAFE_VERSION_SEGMENT',
|
|
122
|
+
'version is not a safe file-name fragment for notesSource substitution',
|
|
123
|
+
{ version, cause: err.code ?? 'PATH_UNSAFE' },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const substituted = notesSource.split('{version}').join(version);
|
|
128
|
+
if (substituted.includes('{') || substituted.includes('}')) {
|
|
129
|
+
invalid(
|
|
130
|
+
'RESIDUAL_PLACEHOLDER',
|
|
131
|
+
'notesSource contains a placeholder other than {version}',
|
|
132
|
+
{ notesSource },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let relativePath;
|
|
137
|
+
try {
|
|
138
|
+
relativePath = canonicalArtifactPath(substituted).path;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
invalid(
|
|
141
|
+
'UNSAFE_NOTES_PATH',
|
|
142
|
+
`notesSource is unsafe after version substitution: ${err.message}`,
|
|
143
|
+
{ notesSource, cause: err.code ?? 'PATH_UNSAFE' },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dot = relativePath.lastIndexOf('.');
|
|
148
|
+
const suffix = dot >= 0 ? relativePath.slice(dot + 1) : '';
|
|
149
|
+
const format = FORMAT_BY_SUFFIX[suffix];
|
|
150
|
+
if (!format) {
|
|
151
|
+
invalid(
|
|
152
|
+
'UNSUPPORTED_SUFFIX',
|
|
153
|
+
'notesSource must end with .yaml, .yml, or .json after version substitution',
|
|
154
|
+
{ notesSource, suffix },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { relativePath, format };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Handle-based safe read (openat / no-follow; no absolute-path opens)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const IDENTITY_FIELDS = Object.freeze(['size', 'dev', 'ino', 'nlink']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Strictly validate identity metadata reported by the backend before any of
|
|
169
|
+
* it is trusted. `size`, `dev`, `ino`, `nlink` must each be a non-negative
|
|
170
|
+
* safe integer (rejecting NaN, Infinity, negatives, fractions, strings,
|
|
171
|
+
* null/undefined, and values beyond the safe-integer range); when `withBytes`
|
|
172
|
+
* is set, `bytes` must be a Buffer. Any violation fails closed with
|
|
173
|
+
* PATH_UNSAFE before the parser is ever reached.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} entry readEntry or readFile result from the backend.
|
|
176
|
+
* @param {string} relativePath Canonical relative path (error details only).
|
|
177
|
+
* @param {{ withBytes?: boolean }} [options]
|
|
178
|
+
* @param {string} [subject] Error-message subject (default 'notes source').
|
|
179
|
+
*/
|
|
180
|
+
function validateIdentity(entry, relativePath, { withBytes = false } = {}, subject = 'notes source') {
|
|
181
|
+
for (const field of IDENTITY_FIELDS) {
|
|
182
|
+
const value = entry[field];
|
|
183
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
184
|
+
unsafe('UNSAFE_IDENTITY', `${subject} metadata is not a trustworthy identity`, {
|
|
185
|
+
relativePath,
|
|
186
|
+
field,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (withBytes && !Buffer.isBuffer(entry.bytes)) {
|
|
191
|
+
unsafe('UNSAFE_IDENTITY', `${subject} read did not return bytes`, {
|
|
192
|
+
relativePath,
|
|
193
|
+
field: 'bytes',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read one file through directory handles only (openat / no-follow per
|
|
200
|
+
* segment; never an absolute-path open after the root handle is open).
|
|
201
|
+
*
|
|
202
|
+
* Shared safe-read primitive: the notes source itself and every
|
|
203
|
+
* release-document target are read through this exact walk (regular file,
|
|
204
|
+
* nlink === 1, size limit, pre/post identity agreement). There is no
|
|
205
|
+
* absolute-path `fs.readFile` fallback; when the backend is unavailable the
|
|
206
|
+
* read fails closed.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} backend Safe-fs backend (loadSafeFs or injected fake).
|
|
209
|
+
* @param {string} unitRoot Caller-supplied unit root, handed verbatim to
|
|
210
|
+
* backend.openRoot — never realpath'd; openRoot's per-segment O_NOFOLLOW
|
|
211
|
+
* walk rejects a symlinked root or ancestor.
|
|
212
|
+
* @param {string} relativePath Canonical relative path (POSIX segments).
|
|
213
|
+
* @param {number} limit Size limit in bytes.
|
|
214
|
+
* @param {{ beforeLeafRead?: (info: { parentDir: string }) => Promise<void> }} [seam]
|
|
215
|
+
* @param {string} [subject] Error-message subject (default 'notes source';
|
|
216
|
+
* release-document target reads pass 'release document target').
|
|
217
|
+
* @returns {Promise<Readonly<{ bytes: Buffer, mode: number }>>} fresh byte
|
|
218
|
+
* copy plus the permission mode captured by the same stable readFile.
|
|
219
|
+
*/
|
|
220
|
+
export async function readSafeFileThroughHandles(backend, unitRoot, relativePath, limit, seam, subject = 'notes source') {
|
|
221
|
+
const segments = relativePath.split('/');
|
|
222
|
+
const handleStack = [];
|
|
223
|
+
let readResult = null;
|
|
224
|
+
let primaryError = null;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
let rootHandle;
|
|
228
|
+
try {
|
|
229
|
+
rootHandle = await backend.openRoot(unitRoot);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
unsafeCause('ROOT_OPEN_FAILED', 'release unit root cannot be opened safely', err, { relativePath });
|
|
232
|
+
}
|
|
233
|
+
handleStack.push(rootHandle);
|
|
234
|
+
|
|
235
|
+
// Walk ancestors relative to the parent handle (openat, no-follow).
|
|
236
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
237
|
+
const segment = segments[i];
|
|
238
|
+
const parent = handleStack[handleStack.length - 1];
|
|
239
|
+
let entry;
|
|
240
|
+
try {
|
|
241
|
+
entry = await parent.readEntry(segment);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
unsafeCause('ANCESTOR_READ_FAILED', `${subject} path component cannot be inspected`, err, { relativePath });
|
|
244
|
+
}
|
|
245
|
+
if (entry === null || entry === undefined) {
|
|
246
|
+
unsafe('MISSING', `${subject} path component does not exist`, { relativePath });
|
|
247
|
+
}
|
|
248
|
+
if (entry.type === 'symlink') {
|
|
249
|
+
unsafe('ANCESTOR_SYMLINK', `${subject} path contains a symlinked directory`, { relativePath });
|
|
250
|
+
}
|
|
251
|
+
if (entry.type !== 'directory') {
|
|
252
|
+
unsafe('ANCESTOR_NOT_DIRECTORY', `${subject} path component is not a directory`, { relativePath });
|
|
253
|
+
}
|
|
254
|
+
let child;
|
|
255
|
+
try {
|
|
256
|
+
child = await parent.openDir(segment);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
unsafeCause('ANCESTOR_OPEN_FAILED', `${subject} directory cannot be opened safely`, err, { relativePath });
|
|
259
|
+
}
|
|
260
|
+
handleStack.push(child);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Test-only seam: parent handles are established; the leaf is not read
|
|
264
|
+
// yet. Only the relative parent directory is exposed — never an
|
|
265
|
+
// absolute path.
|
|
266
|
+
if (seam && typeof seam.beforeLeafRead === 'function') {
|
|
267
|
+
const parentDir = segments.length > 1 ? segments.slice(0, -1).join('/') : '.';
|
|
268
|
+
await seam.beforeLeafRead({ parentDir });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Leaf identity check before reading anything.
|
|
272
|
+
const leaf = segments[segments.length - 1];
|
|
273
|
+
const parentHandle = handleStack[handleStack.length - 1];
|
|
274
|
+
let leafEntry;
|
|
275
|
+
try {
|
|
276
|
+
leafEntry = await parentHandle.readEntry(leaf);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
unsafeCause('LEAF_READ_FAILED', `${subject} file cannot be inspected`, err, { relativePath });
|
|
279
|
+
}
|
|
280
|
+
if (leafEntry === null || leafEntry === undefined) {
|
|
281
|
+
unsafe('MISSING', `${subject} file does not exist`, { relativePath });
|
|
282
|
+
}
|
|
283
|
+
if (leafEntry.type === 'symlink') {
|
|
284
|
+
unsafe('TARGET_SYMLINK', `${subject} target must not be a symlink`, { relativePath });
|
|
285
|
+
}
|
|
286
|
+
if (leafEntry.type !== 'file') {
|
|
287
|
+
unsafe('NOT_REGULAR_FILE', `${subject} target must be a regular file`, { relativePath });
|
|
288
|
+
}
|
|
289
|
+
validateIdentity(leafEntry, relativePath, {}, subject);
|
|
290
|
+
if (leafEntry.nlink !== 1) {
|
|
291
|
+
unsafe('HARDLINK', `${subject} target must not be hardlinked`, { relativePath, nlink: leafEntry.nlink });
|
|
292
|
+
}
|
|
293
|
+
if (leafEntry.size > limit) {
|
|
294
|
+
invalid('INPUT_TOO_LARGE', `${subject} exceeds the size limit`, {
|
|
295
|
+
size: leafEntry.size,
|
|
296
|
+
maxBytes: limit,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Read through the parent handle (openat, no-follow).
|
|
301
|
+
try {
|
|
302
|
+
readResult = await parentHandle.readFile(leaf);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
unsafeCause('LEAF_READ_FAILED', `${subject} file cannot be read safely`, err, { relativePath });
|
|
305
|
+
}
|
|
306
|
+
if (readResult === null || readResult === undefined) {
|
|
307
|
+
unsafe('MISSING', `${subject} file disappeared during read`, { relativePath });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Strictly validate the identity returned by readFile before trusting it.
|
|
311
|
+
validateIdentity(readResult, relativePath, { withBytes: true }, subject);
|
|
312
|
+
if (readResult.nlink !== 1) {
|
|
313
|
+
unsafe('HARDLINK', `${subject} target must not be hardlinked`, { relativePath, nlink: readResult.nlink });
|
|
314
|
+
}
|
|
315
|
+
if (readResult.size > limit) {
|
|
316
|
+
invalid('INPUT_TOO_LARGE', `${subject} exceeds the size limit`, {
|
|
317
|
+
size: readResult.size,
|
|
318
|
+
maxBytes: limit,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (readResult.bytes.length !== readResult.size) {
|
|
322
|
+
unsafe('CHANGED_DURING_READ', `${subject} bytes disagree with reported size`, { relativePath });
|
|
323
|
+
}
|
|
324
|
+
if (readResult.size !== leafEntry.size
|
|
325
|
+
|| readResult.dev !== leafEntry.dev
|
|
326
|
+
|| readResult.ino !== leafEntry.ino) {
|
|
327
|
+
unsafe('CHANGED_DURING_READ', `${subject} identity changed between inspection and read`, { relativePath });
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
primaryError = err instanceof ReleaseError
|
|
331
|
+
? err
|
|
332
|
+
: new ReleaseError(PATH_UNSAFE, `${subject} read failed`, { reason: 'READ_FAILED' });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Close every handle in reverse order; a close failure after a successful
|
|
336
|
+
// read still fails closed.
|
|
337
|
+
const closeFailures = [];
|
|
338
|
+
for (let i = handleStack.length - 1; i >= 0; i -= 1) {
|
|
339
|
+
try {
|
|
340
|
+
await handleStack[i].close();
|
|
341
|
+
} catch (closeErr) {
|
|
342
|
+
closeFailures.push(closeErr?.code ?? 'CLOSE_FAILED');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (primaryError) {
|
|
347
|
+
throw primaryError;
|
|
348
|
+
}
|
|
349
|
+
if (closeFailures.length > 0) {
|
|
350
|
+
unsafe('CLOSE_FAILED', `${subject} handle close failed after read`, {
|
|
351
|
+
relativePath,
|
|
352
|
+
closeFailures,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return Object.freeze({ bytes: Buffer.from(readResult.bytes), mode: readResult.mode });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Public API
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Safely load and parse the structured release-notes source for one release
|
|
365
|
+
* unit.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} options
|
|
368
|
+
* @param {string} options.unitRoot Absolute release-unit root directory.
|
|
369
|
+
* @param {object} options.config Raw releaseDocuments config block.
|
|
370
|
+
* @param {string} options.version Exact expected release version.
|
|
371
|
+
* @param {number} [options.maxBytes] Size limit (default 1 MiB).
|
|
372
|
+
* @param {{ beforeLeafRead?: (info: { parentDir: string }) => Promise<void> }} [options.seam]
|
|
373
|
+
* Test-only injection point; receives only the relative parent directory.
|
|
374
|
+
* @param {() => Promise<object>} [options.backendFactory]
|
|
375
|
+
* Test-only safe-fs backend factory (default: loadSafeFs). Failures fail
|
|
376
|
+
* closed; there is no fallback to absolute-path opens.
|
|
377
|
+
* @returns {Promise<Readonly<{
|
|
378
|
+
* relativePath: string,
|
|
379
|
+
* bytesDigest: string,
|
|
380
|
+
* notes: object,
|
|
381
|
+
* }>>} deeply frozen result; `relativePath` is workspace-relative, never absolute
|
|
382
|
+
* @throws {ReleaseError} PATH_UNSAFE on path/race problems; RELEASE_DOCS_INVALID
|
|
383
|
+
* on semantic problems; backend/parse-layer codes propagate (including
|
|
384
|
+
* SAFE_WRITE_UNAVAILABLE and RELEASE_DOCS_TRANSLATION_MISSING).
|
|
385
|
+
*/
|
|
386
|
+
export async function loadReleaseNotesSource({
|
|
387
|
+
unitRoot,
|
|
388
|
+
config,
|
|
389
|
+
version,
|
|
390
|
+
maxBytes,
|
|
391
|
+
seam,
|
|
392
|
+
backendFactory,
|
|
393
|
+
} = {}) {
|
|
394
|
+
if (typeof unitRoot !== 'string' || unitRoot.length === 0) {
|
|
395
|
+
invalid('INVALID_OPTIONS', 'unitRoot must be a non-empty string', {});
|
|
396
|
+
}
|
|
397
|
+
if (typeof version !== 'string' || version.length === 0) {
|
|
398
|
+
invalid('INVALID_OPTIONS', 'version must be a non-empty string', {});
|
|
399
|
+
}
|
|
400
|
+
const limit = maxBytes ?? DEFAULT_MAX_NOTES_BYTES;
|
|
401
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
402
|
+
invalid('INVALID_OPTIONS', 'maxBytes must be a positive integer', {});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 1. Closed semantic validation of the config block first.
|
|
406
|
+
const normalized = normalizeReleaseDocumentsConfig(config);
|
|
407
|
+
|
|
408
|
+
// 2. Substitute {version} and re-canonicalize; choose format by suffix.
|
|
409
|
+
const { relativePath, format } = resolveNotesPath(normalized.notesSource, version);
|
|
410
|
+
|
|
411
|
+
// 3. Load the safe backend; fail closed when unavailable (no fallback).
|
|
412
|
+
const factory = backendFactory ?? loadSafeFs;
|
|
413
|
+
const backend = await factory();
|
|
414
|
+
|
|
415
|
+
// 4. Handle-based safe read inside the physical unit root.
|
|
416
|
+
const { bytes } = await readSafeFileThroughHandles(backend, unitRoot, relativePath, limit, seam);
|
|
417
|
+
|
|
418
|
+
// 5. Strict closed-model parse by format.
|
|
419
|
+
const notes = parseReleaseNotes(bytes, {
|
|
420
|
+
format,
|
|
421
|
+
expectedVersion: version,
|
|
422
|
+
locales: [...normalized.locales],
|
|
423
|
+
maxBytes: limit,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// 6. Frozen canonical result; no absolute paths, deterministic digest.
|
|
427
|
+
return Object.freeze({
|
|
428
|
+
relativePath,
|
|
429
|
+
bytesDigest: `sha256:${sha256Hex(bytes)}`,
|
|
430
|
+
notes,
|
|
431
|
+
});
|
|
432
|
+
}
|