beez-rp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Locates and verifies the package archive prepared for a release, so
3
+ * `publish: "npm"` publishes exactly the artifact the project verified
4
+ * instead of repacking the working tree.
5
+ *
6
+ * Patterns are relative to the repository root, use `/` separators and
7
+ * replace `{version}` and `{name}` (the tarball base name `npm pack` uses, so
8
+ * `@scope/pkg` becomes `scope-pkg`). Inside a single path segment, `*` matches
9
+ * anything and `{sha256}` matches a SHA-256 digest that must equal the
10
+ * archive checksum (for example `releases/{version}-{sha256}/{name}-{version}.tgz`).
11
+ * `{sha256}` may repeat, in one segment or several: every occurrence must
12
+ * declare the same digest.
13
+ *
14
+ * Projects pack with `npm pack --ignore-scripts`, which is reproducible: the same
15
+ * checkout always yields the same bytes. So the archive is verified by comparing
16
+ * its SHA-512 integrity with the one `npm pack --dry-run` reports for the release
17
+ * checkout, and the manifest must not rely on rewrites only pnpm applies when packing.
18
+ * The archive is moved out of the package root during that dry run, so npm never
19
+ * counts it as part of the package it describes.
20
+ *
21
+ * @module create-version/artifact
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import path from "node:path";
28
+
29
+ import {
30
+ ARTIFACT_HOLDING_DIRECTORY_PREFIX,
31
+ ARTIFACT_NAME_PLACEHOLDER,
32
+ ARTIFACT_SEGMENT_WILDCARD,
33
+ ARTIFACT_SHA256_PLACEHOLDER,
34
+ ARTIFACT_VERSION_PLACEHOLDER,
35
+ CROSS_DEVICE_RENAME_ERROR_CODE,
36
+ NPM_INTEGRITY_ALGORITHM,
37
+ PACKAGE_SCOPE_PATTERN,
38
+ PACKED_SCOPE_REPLACEMENT,
39
+ PNPM_HOISTED_PUBLISH_CONFIG_KEYS,
40
+ PNPM_PACK_REWRITTEN_SPECIFIER_PATTERN,
41
+ PUBLISH_CONFIG_FIELD,
42
+ PUBLISHED_DEPENDENCY_FIELDS,
43
+ SAFE_ARTIFACT_PATH_PATTERN,
44
+ SHA256_HEX_PATTERN_SOURCE,
45
+ } from "../constants/create-version.js";
46
+
47
+ /**
48
+ * @typedef {{ path: string, expectedSha256: string | null }} PreparedArtifact
49
+ * @typedef {Record<string, unknown>} PackageManifest
50
+ */
51
+
52
+ /** Characters with regular-expression meaning, escaped in literal segment text. */
53
+ const REGEXP_SPECIAL_CHARACTERS_PATTERN = /[.+?^${}()|[\]\\]/gu;
54
+
55
+ /** Expression source of any run of characters inside one path segment. */
56
+ const SEGMENT_CHARACTERS_SOURCE = "[^/]*";
57
+
58
+ /** Expression source of the first `{sha256}` of a segment: captures the declared digest. */
59
+ const SHA256_CAPTURE_SOURCE = `(?<sha256>${SHA256_HEX_PATTERN_SOURCE})`;
60
+
61
+ /** Expression source of a repeated `{sha256}` in the same segment: must equal the captured digest. */
62
+ const SHA256_BACKREFERENCE_SOURCE = "\\k<sha256>";
63
+
64
+ /**
65
+ * Converts an npm package name into the tarball base name `npm pack` and `pnpm pack` use.
66
+ *
67
+ * @param {string} packageName - npm package name, optionally scoped.
68
+ * @returns {string} Name without `@` and with the scope joined by `-` (`@scope/pkg` → `scope-pkg`); unscoped names are unchanged.
69
+ */
70
+ function toPackedName(packageName) {
71
+ return packageName.replace(PACKAGE_SCOPE_PATTERN, PACKED_SCOPE_REPLACEMENT);
72
+ }
73
+
74
+ /**
75
+ * Replaces the `{version}` and `{name}` placeholders of an artifact pattern; `{sha256}` stays.
76
+ * `{name}` becomes the tarball base name, so scoped packages never add a path separator.
77
+ *
78
+ * @param {string} pattern - Configured pattern.
79
+ * @param {{ version: string, packageName: string }} release - Version and npm package name.
80
+ * @returns {string} Pattern with placeholders replaced.
81
+ */
82
+ export function expandArtifactPattern(pattern, { version, packageName }) {
83
+ return pattern.replaceAll(ARTIFACT_VERSION_PLACEHOLDER, version).replaceAll(ARTIFACT_NAME_PLACEHOLDER, toPackedName(packageName));
84
+ }
85
+
86
+ /**
87
+ * Compiles one path segment: `*` matches anything but `/`, `{sha256}` captures a digest.
88
+ * A repeated `{sha256}` becomes a backreference, so every occurrence must be the same digest.
89
+ *
90
+ * @param {string} segment - Segment of an expanded pattern.
91
+ * @returns {RegExp} Anchored expression; the digest, when present, is the `sha256` group.
92
+ */
93
+ function compileSegment(segment) {
94
+ const [firstPart, ...partsAfterDigests] = segment
95
+ .split(ARTIFACT_SHA256_PLACEHOLDER)
96
+ .map((part) =>
97
+ part
98
+ .split(ARTIFACT_SEGMENT_WILDCARD)
99
+ .map((literal) => literal.replace(REGEXP_SPECIAL_CHARACTERS_PATTERN, "\\$&"))
100
+ .join(SEGMENT_CHARACTERS_SOURCE)
101
+ );
102
+ const source = partsAfterDigests.reduce(
103
+ (compiled, part, index) => `${compiled}${index === 0 ? SHA256_CAPTURE_SOURCE : SHA256_BACKREFERENCE_SOURCE}${part}`,
104
+ firstPart
105
+ );
106
+ return new RegExp(`^${source}$`, "u");
107
+ }
108
+
109
+ /**
110
+ * Finds the newest file that matches an artifact pattern.
111
+ *
112
+ * @param {string} repositoryRoot - Repository root.
113
+ * @param {string} pattern - Configured pattern with `{version}` and optional `{name}`, `*` and `{sha256}`.
114
+ * @param {{ version: string, packageName: string }} release - Version and npm package name.
115
+ * @returns {PreparedArtifact | null} Path relative to the root with `/` separators and the digest its path declares, or `null`.
116
+ * Paths whose segments declare different digests never match.
117
+ */
118
+ export function findPreparedArtifact(repositoryRoot, pattern, release) {
119
+ const segments = expandArtifactPattern(pattern, release).split("/").filter(Boolean);
120
+ /** @type {PreparedArtifact[]} */
121
+ let candidates = [{ path: "", expectedSha256: null }];
122
+
123
+ for (const [index, segment] of segments.entries()) {
124
+ const isLast = index === segments.length - 1;
125
+ const matcher = compileSegment(segment);
126
+ /** @type {PreparedArtifact[]} */
127
+ const next = [];
128
+
129
+ for (const candidate of candidates) {
130
+ const absoluteDirectory = path.join(repositoryRoot, candidate.path);
131
+ if (!existsSync(absoluteDirectory) || !statSync(absoluteDirectory).isDirectory()) continue;
132
+
133
+ for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
134
+ const match = matcher.exec(entry.name);
135
+ if (!match || !(isLast ? entry.isFile() : entry.isDirectory())) continue;
136
+
137
+ const declaredSha256 = match.groups?.sha256 ?? null;
138
+ if (declaredSha256 && candidate.expectedSha256 && declaredSha256 !== candidate.expectedSha256) continue;
139
+
140
+ next.push({
141
+ path: candidate.path ? `${candidate.path}/${entry.name}` : entry.name,
142
+ expectedSha256: declaredSha256 ?? candidate.expectedSha256,
143
+ });
144
+ }
145
+ }
146
+
147
+ candidates = next;
148
+ }
149
+
150
+ const newest = candidates
151
+ .map((candidate) => ({ candidate, modifiedAt: statSync(path.join(repositoryRoot, candidate.path)).mtimeMs }))
152
+ .sort((left, right) => right.modifiedAt - left.modifiedAt)[0];
153
+
154
+ return newest?.candidate ?? null;
155
+ }
156
+
157
+ /**
158
+ * Checks that an artifact path can be passed to `npm publish` on a shell command line.
159
+ *
160
+ * @param {string} artifactPath - Relative path found by {@link findPreparedArtifact}.
161
+ * @returns {boolean} Whether the path only uses safe characters.
162
+ */
163
+ export function isSafeArtifactPath(artifactPath) {
164
+ return SAFE_ARTIFACT_PATH_PATTERN.test(artifactPath) && !artifactPath.split("/").includes("..");
165
+ }
166
+
167
+ /**
168
+ * Computes the SHA-256 of a file.
169
+ *
170
+ * @param {string} filePath - Absolute path.
171
+ * @returns {string} Lowercase hexadecimal digest.
172
+ */
173
+ export function computeSha256(filePath) {
174
+ return createHash("sha256").update(readFileSync(filePath)).digest("hex");
175
+ }
176
+
177
+
178
+ /**
179
+ * Computes the npm integrity of a file: the same `sha512-<base64>` string that
180
+ * `npm pack --json` reports for the archive it writes.
181
+ *
182
+ * @param {string} filePath - Absolute path.
183
+ * @returns {string} Subresource-integrity string.
184
+ */
185
+ export function computeNpmIntegrity(filePath) {
186
+ return `${NPM_INTEGRITY_ALGORITHM}-${createHash(NPM_INTEGRITY_ALGORITHM).update(readFileSync(filePath)).digest("base64")}`;
187
+ }
188
+
189
+ /**
190
+ * Tells whether a value is a plain JSON object.
191
+ *
192
+ * @param {unknown} value - Any value.
193
+ * @returns {value is Record<string, unknown>} Whether it is a non-array object.
194
+ */
195
+ function isRecord(value) {
196
+ return value !== null && typeof value === "object" && !Array.isArray(value);
197
+ }
198
+
199
+ /**
200
+ * Lists what a manifest needs from `pnpm pack` and npm would publish as it is: `workspace:`,
201
+ * `catalog:` or `jsr:` specifiers in published dependency maps, and `publishConfig` keys pnpm hoists onto the
202
+ * manifest (such as `exports`, `main` or `bin`; npm only reads `publishConfig` as configuration).
203
+ * Every other `publishConfig` key is npm configuration and is accepted.
204
+ *
205
+ * @param {PackageManifest} manifest - `package.json` of the release checkout.
206
+ * @returns {string[]} Problems in Spanish; empty when npm packs the package as pnpm would.
207
+ */
208
+ export function findPnpmPackRewrites(manifest) {
209
+ /** @type {string[]} */
210
+ const problems = [];
211
+
212
+ for (const field of PUBLISHED_DEPENDENCY_FIELDS) {
213
+ const dependencies = manifest[field];
214
+ if (!isRecord(dependencies)) continue;
215
+ for (const [dependencyName, specifier] of Object.entries(dependencies)) {
216
+ if (typeof specifier === "string" && PNPM_PACK_REWRITTEN_SPECIFIER_PATTERN.test(specifier)) {
217
+ problems.push(`${field}.${dependencyName} usa "${specifier}", que solo pnpm reescribe al empaquetar`);
218
+ }
219
+ }
220
+ }
221
+
222
+ const publishConfig = manifest[PUBLISH_CONFIG_FIELD];
223
+ if (isRecord(publishConfig)) {
224
+ for (const key of Object.keys(publishConfig)) {
225
+ if (PNPM_HOISTED_PUBLISH_CONFIG_KEYS.includes(key)) {
226
+ problems.push(`${PUBLISH_CONFIG_FIELD}.${key} no es configuración de npm (solo pnpm lo aplica al empaquetar)`);
227
+ }
228
+ }
229
+ }
230
+
231
+ return problems;
232
+ }
233
+
234
+ /**
235
+ * Moves a file, falling back to copy and delete when the destination is on another file system
236
+ * (`rename` fails with `EXDEV`, for example when the OS temp directory is another drive).
237
+ *
238
+ * @param {string} sourcePath - Absolute path of the file to move.
239
+ * @param {string} destinationPath - Absolute destination path.
240
+ * @returns {void}
241
+ */
242
+ function moveFile(sourcePath, destinationPath) {
243
+ try {
244
+ renameSync(sourcePath, destinationPath);
245
+ } catch (error) {
246
+ if (!(error instanceof Error) || /** @type {NodeJS.ErrnoException} */ (error).code !== CROSS_DEVICE_RENAME_ERROR_CODE) {
247
+ throw error;
248
+ }
249
+ copyFileSync(sourcePath, destinationPath);
250
+ unlinkSync(sourcePath);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Runs an operation with the prepared archive moved out of the package root, and always puts it
256
+ * back afterwards, also when the operation fails.
257
+ *
258
+ * `npm pack --dry-run` describes every file npm would pack now. When the package has no `files`
259
+ * allowlist and neither `.npmignore` nor `.gitignore` excludes the archive directory, the archive
260
+ * written by `prepare` (for example `releases/pkg-1.0.0.tgz`) would be packed into the package it
261
+ * is compared with, changing the integrity and rejecting a valid artifact. Hiding it reproduces the
262
+ * package root `prepare` packed: a directory left empty is ignored by npm, as it was then.
263
+ *
264
+ * @template T
265
+ * @param {string} repositoryRoot - Package root.
266
+ * @param {string} artifactPath - Archive relative to the root, found by {@link findPreparedArtifact}.
267
+ * @param {() => Promise<T>} operation - Runs while the archive is outside the package root.
268
+ * @param {string} [parentDirectory] - Where the holding directory is created; defaults to the OS temp directory.
269
+ * @returns {Promise<T>} The operation result.
270
+ */
271
+ export async function withArtifactOutsidePackageRoot(repositoryRoot, artifactPath, operation, parentDirectory = tmpdir()) {
272
+ const archivePath = path.join(repositoryRoot, artifactPath);
273
+ const holdingDirectory = mkdtempSync(path.join(parentDirectory, ARTIFACT_HOLDING_DIRECTORY_PREFIX));
274
+ const heldArchivePath = path.join(holdingDirectory, path.basename(archivePath));
275
+
276
+ try {
277
+ moveFile(archivePath, heldArchivePath);
278
+ } catch (error) {
279
+ rmSync(holdingDirectory, { recursive: true, force: true });
280
+ throw new Error(`beez-rp create-version: no se pudo apartar ${artifactPath} para verificarlo`, { cause: error });
281
+ }
282
+
283
+ try {
284
+ return await operation();
285
+ } finally {
286
+ try {
287
+ moveFile(heldArchivePath, archivePath);
288
+ } catch (error) {
289
+ // The holding directory is kept so the archive is never lost.
290
+ throw new Error(`beez-rp create-version: no se pudo devolver ${artifactPath} a su lugar; quedó en ${heldArchivePath}`, { cause: error });
291
+ }
292
+ rmSync(holdingDirectory, { recursive: true, force: true });
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Verifies a prepared archive before publishing it: the SHA-256 its path declares (when the
298
+ * pattern uses `{sha256}`) and the SHA-512 integrity `npm pack --dry-run` reports for the release
299
+ * checkout, so the archive is byte for byte what npm packs from that commit.
300
+ *
301
+ * @param {string} repositoryRoot - Repository root.
302
+ * @param {PreparedArtifact} artifact - Archive found by {@link findPreparedArtifact}.
303
+ * @param {string} expectedIntegrity - `integrity` of `npm pack --dry-run --json --ignore-scripts`.
304
+ * @returns {string[]} Problems in Spanish; empty when the archive can be published.
305
+ */
306
+ export function verifyPreparedArtifact(repositoryRoot, artifact, expectedIntegrity) {
307
+ const archivePath = path.join(repositoryRoot, artifact.path);
308
+
309
+ if (artifact.expectedSha256) {
310
+ const actualSha256 = computeSha256(archivePath);
311
+ if (actualSha256 !== artifact.expectedSha256) {
312
+ return [`el SHA-256 del tarball (${actualSha256}) no coincide con el de su ruta (${artifact.expectedSha256})`];
313
+ }
314
+ }
315
+
316
+ const actualIntegrity = computeNpmIntegrity(archivePath);
317
+ if (actualIntegrity !== expectedIntegrity) {
318
+ return [
319
+ `el tarball no es lo que npm empaquetaría de este commit (integrity ${actualIntegrity}; npm pack --dry-run informa ${expectedIntegrity})`,
320
+ ];
321
+ }
322
+
323
+ return [];
324
+ }
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
17
17
  import {
18
+ ARTIFACT_VERSION_PLACEHOLDER,
18
19
  CHANGELOG_LANGUAGE,
19
20
  CREATE_VERSION_CONFIG_FILE,
20
21
  CREATE_VERSION_CONFIG_FILES,
@@ -51,6 +52,7 @@ import { RELEASE_TYPE_ORDER } from "../constants/versions.js";
51
52
  * migrations?: MigrationsAdapter | null,
52
53
  * prepare?: string[] | ReleaseHook | null,
53
54
  * publish?: "npm" | ReleaseHook | null,
55
+ * artifact?: string | null,
54
56
  * summary?: string[],
55
57
  * }} CreateVersionConfig
56
58
  * `summary` lines replace `{version}` with the released version.
@@ -64,6 +66,7 @@ import { RELEASE_TYPE_ORDER } from "../constants/versions.js";
64
66
  * migrations: MigrationsAdapter | null,
65
67
  * prepare: string[] | ReleaseHook | null,
66
68
  * publish: "npm" | ReleaseHook | null,
69
+ * artifact: string | null,
67
70
  * summary: string[],
68
71
  * }} ResolvedCreateVersionConfig
69
72
  */
@@ -175,6 +178,16 @@ export function resolveCreateVersionConfig(rawConfig) {
175
178
  throw invalidField("migrations.targetHint", "a string");
176
179
  }
177
180
 
181
+ const artifact = config.artifact ?? null;
182
+ if (artifact !== null) {
183
+ if (typeof artifact !== "string" || !artifact.includes(ARTIFACT_VERSION_PLACEHOLDER)) {
184
+ throw invalidField("artifact", `a path pattern containing ${ARTIFACT_VERSION_PLACEHOLDER}, such as releases/{version}-*/{name}-{version}.tgz`);
185
+ }
186
+ if (publish !== NPM_PUBLISHER) {
187
+ throw invalidField("artifact", `used only with publish: "${NPM_PUBLISHER}"`);
188
+ }
189
+ }
190
+
178
191
  const summary = config.summary ?? [];
179
192
  if (!isStringList(summary)) {
180
193
  throw invalidField("summary", "a list of lines");
@@ -194,12 +207,15 @@ export function resolveCreateVersionConfig(rawConfig) {
194
207
  migrations,
195
208
  prepare: /** @type {string[] | ReleaseHook | null} */ (prepare),
196
209
  publish: /** @type {"npm" | ReleaseHook | null} */ (publish),
210
+ artifact: /** @type {string | null} */ (artifact),
197
211
  summary,
198
212
  };
199
213
  }
200
214
 
201
215
  /**
202
216
  * Imports `beez-rp.config.mjs` or `beez-rp.config.js` from the repository root and validates it.
217
+ * It is imported once per process: after syncing `main` the command stops and asks to run it
218
+ * again, so a new process imports the updated file and everything it imports.
203
219
  *
204
220
  * @param {string} repositoryRoot - Repository root.
205
221
  * @returns {Promise<ResolvedCreateVersionConfig>} Resolved configuration.
@@ -9,10 +9,31 @@
9
9
  * @module create-version
10
10
  */
11
11
 
12
+ export {
13
+ computeNpmIntegrity,
14
+ computeSha256,
15
+ expandArtifactPattern,
16
+ findPnpmPackRewrites,
17
+ findPreparedArtifact,
18
+ isSafeArtifactPath,
19
+ verifyPreparedArtifact,
20
+ withArtifactOutsidePackageRoot,
21
+ } from "./artifact.js";
12
22
  export { defineCreateVersionConfig, loadCreateVersionConfig, resolveCreateVersionConfig } from "./config.js";
13
23
  export { ReleaseStepError } from "./errors.js";
14
- export { lookupPublishedVersions, publishToNpm } from "./npm.js";
24
+ export {
25
+ buildNpmAuthConfigLine,
26
+ buildNpmPublishArguments,
27
+ buildNpmPublishEnvironment,
28
+ buildNpmViewArguments,
29
+ lookupPublishedVersions,
30
+ parseNpmPackDryRunOutput,
31
+ publishToNpm,
32
+ readNpmPackIntegrity,
33
+ resolvePublishRegistry,
34
+ withNpmAuthConfig,
35
+ } from "./npm.js";
15
36
  export { DEFAULT_CAPABILITIES, RELEASE_USAGE, buildReleasePlan, describeFeatureBranchGaps, parseReleaseArguments } from "./plan.js";
16
- export { createGitReader, listCommits, parseCommitLog, readPackageVersionAt, runCaptured, runCommandLine, runInherited } from "./process.js";
37
+ export { createGitReader, listCommits, parseCommitLog, readPackageManifestAt, readPackageVersionAt, runCaptured, runCommandLine, runInherited } from "./process.js";
17
38
  export { createHookContext, runCreateVersion } from "./run.js";
18
39
  export { collectReleaseState, findLastRelease, readChangelogState } from "./state.js";