@astra-kit/rp 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +196 -0
- package/LICENSE.md +21 -0
- package/README.md +352 -2
- package/bin/astra-rp.js +51 -0
- package/package.json +93 -4
- package/src/build-gate.js +157 -0
- package/src/changelog-blocks.js +53 -0
- package/src/changelog.js +108 -0
- package/src/constants/build-gate.js +30 -0
- package/src/constants/changelog-draft.js +180 -0
- package/src/constants/changelog.js +74 -0
- package/src/constants/ci-release.js +346 -0
- package/src/constants/cli.js +12 -0
- package/src/constants/create-version.js +523 -0
- package/src/constants/guard-publish.js +29 -0
- package/src/constants/index.js +18 -0
- package/src/constants/monorepo.js +57 -0
- package/src/constants/package-manager.js +56 -0
- package/src/constants/registry.js +72 -0
- package/src/constants/terminal-ui.js +93 -0
- package/src/constants/version-files.js +63 -0
- package/src/constants/versions.js +60 -0
- package/src/create-version/artifact.js +343 -0
- package/src/create-version/changelog-draft-workspace.js +124 -0
- package/src/create-version/changelog-draft.js +380 -0
- package/src/create-version/changelog.js +264 -0
- package/src/create-version/ci-config.js +54 -0
- package/src/create-version/ci-publication-history.js +113 -0
- package/src/create-version/ci-release-identity-step.js +58 -0
- package/src/create-version/ci-setup.js +794 -0
- package/src/create-version/ci.js +555 -0
- package/src/create-version/committed-tree.js +75 -0
- package/src/create-version/config.js +489 -0
- package/src/create-version/errors.js +46 -0
- package/src/create-version/github-workflow.js +199 -0
- package/src/create-version/index.js +54 -0
- package/src/create-version/json.js +32 -0
- package/src/create-version/jsonc.js +76 -0
- package/src/create-version/jsr.js +96 -0
- package/src/create-version/local-changes.js +178 -0
- package/src/create-version/npm-auth.js +142 -0
- package/src/create-version/npm.js +812 -0
- package/src/create-version/plan.js +1200 -0
- package/src/create-version/process.js +334 -0
- package/src/create-version/registry-config.js +121 -0
- package/src/create-version/registry-http.js +62 -0
- package/src/create-version/registry.js +220 -0
- package/src/create-version/release-execution.js +45 -0
- package/src/create-version/release-tag-config.js +76 -0
- package/src/create-version/run.js +2087 -0
- package/src/create-version/state.js +461 -0
- package/src/create-version/strict-utf8.js +11 -0
- package/src/create-version/templates/release.yml +52 -0
- package/src/create-version/templates/vercel-ignore-build.mjs +66 -0
- package/src/guard-publish.js +77 -0
- package/src/index.js +42 -0
- package/src/monorepo/plan.js +475 -0
- package/src/monorepo/release-commit.js +46 -0
- package/src/monorepo/run.js +927 -0
- package/src/monorepo/state.js +206 -0
- package/src/monorepo/workspaces.js +327 -0
- package/src/package-manager.js +107 -0
- package/src/terminal-ui.js +548 -0
- package/src/testing.js +70 -0
- package/src/version-files.js +142 -0
- package/src/versions.js +303 -0
- package/types/build-gate.d.ts +43 -0
- package/types/changelog-blocks.d.ts +33 -0
- package/types/changelog.d.ts +61 -0
- package/types/constants/build-gate.d.ts +23 -0
- package/types/constants/changelog-draft.d.ts +138 -0
- package/types/constants/changelog.d.ts +56 -0
- package/types/constants/ci-release.d.ts +284 -0
- package/types/constants/cli.d.ts +11 -0
- package/types/constants/create-version.d.ts +397 -0
- package/types/constants/guard-publish.d.ts +25 -0
- package/types/constants/index.d.ts +17 -0
- package/types/constants/monorepo.d.ts +45 -0
- package/types/constants/package-manager.d.ts +40 -0
- package/types/constants/registry.d.ts +79 -0
- package/types/constants/terminal-ui.d.ts +73 -0
- package/types/constants/version-files.d.ts +51 -0
- package/types/constants/versions.d.ts +47 -0
- package/types/create-version/artifact.d.ts +113 -0
- package/types/create-version/changelog-draft-workspace.d.ts +33 -0
- package/types/create-version/changelog-draft.d.ts +127 -0
- package/types/create-version/changelog.d.ts +141 -0
- package/types/create-version/ci-config.d.ts +33 -0
- package/types/create-version/ci-publication-history.d.ts +51 -0
- package/types/create-version/ci-release-identity-step.d.ts +12 -0
- package/types/create-version/ci-setup.d.ts +148 -0
- package/types/create-version/ci.d.ts +145 -0
- package/types/create-version/committed-tree.d.ts +55 -0
- package/types/create-version/config.d.ts +128 -0
- package/types/create-version/errors.d.ts +39 -0
- package/types/create-version/github-workflow.d.ts +41 -0
- package/types/create-version/index.d.ts +28 -0
- package/types/create-version/json.d.ts +21 -0
- package/types/create-version/jsonc.d.ts +21 -0
- package/types/create-version/jsr.d.ts +52 -0
- package/types/create-version/local-changes.d.ts +52 -0
- package/types/create-version/npm-auth.d.ts +60 -0
- package/types/create-version/npm.d.ts +331 -0
- package/types/create-version/plan.d.ts +369 -0
- package/types/create-version/process.d.ts +151 -0
- package/types/create-version/registry-config.d.ts +56 -0
- package/types/create-version/registry-http.d.ts +22 -0
- package/types/create-version/registry.d.ts +86 -0
- package/types/create-version/release-execution.d.ts +22 -0
- package/types/create-version/release-tag-config.d.ts +18 -0
- package/types/create-version/run.d.ts +407 -0
- package/types/create-version/state.d.ts +204 -0
- package/types/create-version/strict-utf8.d.ts +10 -0
- package/types/guard-publish.d.ts +49 -0
- package/types/index.d.ts +23 -0
- package/types/monorepo/plan.d.ts +68 -0
- package/types/monorepo/release-commit.d.ts +29 -0
- package/types/monorepo/run.d.ts +68 -0
- package/types/monorepo/state.d.ts +96 -0
- package/types/monorepo/workspaces.d.ts +86 -0
- package/types/package-manager.d.ts +58 -0
- package/types/terminal-ui.d.ts +196 -0
- package/types/testing.d.ts +31 -0
- package/types/version-files.d.ts +59 -0
- package/types/versions.d.ts +158 -0
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
import { isRecord } from "./json.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {{ path: string, expectedSha256: string | null }} PreparedArtifact
|
|
50
|
+
* @typedef {Record<string, unknown>} PackageManifest
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** Characters with regular-expression meaning, escaped in literal segment text. */
|
|
54
|
+
const REGEXP_SPECIAL_CHARACTERS_PATTERN = /[.+?^${}()|[\]\\]/gu;
|
|
55
|
+
|
|
56
|
+
/** Expression source of any run of characters inside one path segment. */
|
|
57
|
+
const SEGMENT_CHARACTERS_SOURCE = "[^/]*";
|
|
58
|
+
|
|
59
|
+
/** Expression source of the first `{sha256}` of a segment: captures the declared digest. */
|
|
60
|
+
const SHA256_CAPTURE_SOURCE = `(?<sha256>${SHA256_HEX_PATTERN_SOURCE})`;
|
|
61
|
+
|
|
62
|
+
/** Expression source of a repeated `{sha256}` in the same segment: must equal the captured digest. */
|
|
63
|
+
const SHA256_BACKREFERENCE_SOURCE = "\\k<sha256>";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Converts an npm package name into the tarball base name `npm pack` and `pnpm pack` use.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} packageName - npm package name, optionally scoped.
|
|
69
|
+
* @returns {string} Name without `@` and with the scope joined by `-` (`@scope/pkg` → `scope-pkg`); unscoped names are unchanged.
|
|
70
|
+
*/
|
|
71
|
+
function toPackedName(packageName) {
|
|
72
|
+
return packageName.replace(PACKAGE_SCOPE_PATTERN, PACKED_SCOPE_REPLACEMENT);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Replaces the `{version}` and `{name}` placeholders of an artifact pattern; `{sha256}` stays.
|
|
77
|
+
* `{name}` becomes the tarball base name, so scoped packages never add a path separator.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} pattern - Configured pattern.
|
|
80
|
+
* @param {{ version: string, packageName: string }} release - Version and npm package name.
|
|
81
|
+
* @returns {string} Pattern with placeholders replaced.
|
|
82
|
+
*/
|
|
83
|
+
export function expandArtifactPattern(pattern, { version, packageName }) {
|
|
84
|
+
return pattern.replaceAll(ARTIFACT_VERSION_PLACEHOLDER, version).replaceAll(ARTIFACT_NAME_PLACEHOLDER, toPackedName(packageName));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compiles one path segment: `*` matches anything but `/`, `{sha256}` captures a digest.
|
|
89
|
+
* A repeated `{sha256}` becomes a backreference, so every occurrence must be the same digest.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} segment - Segment of an expanded pattern.
|
|
92
|
+
* @returns {RegExp} Anchored expression; the digest, when present, is the `sha256` group.
|
|
93
|
+
*/
|
|
94
|
+
function compileSegment(segment) {
|
|
95
|
+
const [firstPart, ...partsAfterDigests] = segment
|
|
96
|
+
.split(ARTIFACT_SHA256_PLACEHOLDER)
|
|
97
|
+
.map((part) =>
|
|
98
|
+
part
|
|
99
|
+
.split(ARTIFACT_SEGMENT_WILDCARD)
|
|
100
|
+
.map((literal) => literal.replace(REGEXP_SPECIAL_CHARACTERS_PATTERN, "\\$&"))
|
|
101
|
+
.join(SEGMENT_CHARACTERS_SOURCE)
|
|
102
|
+
);
|
|
103
|
+
const source = partsAfterDigests.reduce(
|
|
104
|
+
(compiled, part, index) => `${compiled}${index === 0 ? SHA256_CAPTURE_SOURCE : SHA256_BACKREFERENCE_SOURCE}${part}`,
|
|
105
|
+
firstPart
|
|
106
|
+
);
|
|
107
|
+
return new RegExp(`^${source}$`, "u");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Finds the newest file that matches an artifact pattern.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} repositoryRoot - Repository root.
|
|
114
|
+
* @param {string} pattern - Configured pattern with `{version}` and optional `{name}`, `*` and `{sha256}`.
|
|
115
|
+
* @param {{ version: string, packageName: string }} release - Version and npm package name.
|
|
116
|
+
* @returns {PreparedArtifact | null} Path relative to the root with `/` separators and the digest its path declares, or `null`.
|
|
117
|
+
* Paths whose segments declare different digests never match.
|
|
118
|
+
*/
|
|
119
|
+
export function findPreparedArtifact(repositoryRoot, pattern, release) {
|
|
120
|
+
const segments = expandArtifactPattern(pattern, release).split("/").filter(Boolean);
|
|
121
|
+
/** @type {PreparedArtifact[]} */
|
|
122
|
+
let candidates = [{ path: "", expectedSha256: null }];
|
|
123
|
+
|
|
124
|
+
for (const [index, segment] of segments.entries()) {
|
|
125
|
+
const isLast = index === segments.length - 1;
|
|
126
|
+
const matcher = compileSegment(segment);
|
|
127
|
+
/** @type {PreparedArtifact[]} */
|
|
128
|
+
const next = [];
|
|
129
|
+
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
const absoluteDirectory = path.join(repositoryRoot, candidate.path);
|
|
132
|
+
if (!existsSync(absoluteDirectory) || !statSync(absoluteDirectory).isDirectory()) continue;
|
|
133
|
+
|
|
134
|
+
for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) {
|
|
135
|
+
const match = matcher.exec(entry.name);
|
|
136
|
+
if (!match || !(isLast ? entry.isFile() : entry.isDirectory())) continue;
|
|
137
|
+
|
|
138
|
+
const declaredSha256 = match.groups?.sha256 ?? null;
|
|
139
|
+
if (declaredSha256 && candidate.expectedSha256 && declaredSha256 !== candidate.expectedSha256) continue;
|
|
140
|
+
|
|
141
|
+
next.push({
|
|
142
|
+
path: candidate.path ? `${candidate.path}/${entry.name}` : entry.name,
|
|
143
|
+
expectedSha256: declaredSha256 ?? candidate.expectedSha256,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
candidates = next;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const newest = candidates
|
|
152
|
+
.map((candidate) => ({ candidate, modifiedAt: statSync(path.join(repositoryRoot, candidate.path)).mtimeMs }))
|
|
153
|
+
.toSorted((left, right) => right.modifiedAt - left.modifiedAt)[0];
|
|
154
|
+
|
|
155
|
+
return newest?.candidate ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Checks that an artifact path can be passed to `npm publish` on a shell command line.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} artifactPath - Relative path found by {@link findPreparedArtifact}.
|
|
162
|
+
* @returns {boolean} Whether the path only uses safe characters.
|
|
163
|
+
*/
|
|
164
|
+
export function isSafeArtifactPath(artifactPath) {
|
|
165
|
+
return SAFE_ARTIFACT_PATH_PATTERN.test(artifactPath) && !artifactPath.split("/").includes("..");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Computes the SHA-256 of file bytes.
|
|
170
|
+
*
|
|
171
|
+
* @param {Buffer} bytes - File content.
|
|
172
|
+
* @returns {string} Lowercase hexadecimal digest.
|
|
173
|
+
*/
|
|
174
|
+
function computeBytesSha256(bytes) {
|
|
175
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Computes the npm integrity of file bytes (see {@link computeNpmIntegrity}).
|
|
180
|
+
*
|
|
181
|
+
* @param {Buffer} bytes - File content.
|
|
182
|
+
* @returns {string} Subresource-integrity string.
|
|
183
|
+
*/
|
|
184
|
+
function computeBytesNpmIntegrity(bytes) {
|
|
185
|
+
return `${NPM_INTEGRITY_ALGORITHM}-${createHash(NPM_INTEGRITY_ALGORITHM).update(bytes).digest("base64")}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Computes the SHA-256 of a file.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} filePath - Absolute path.
|
|
192
|
+
* @returns {string} Lowercase hexadecimal digest.
|
|
193
|
+
*/
|
|
194
|
+
export function computeSha256(filePath) {
|
|
195
|
+
return computeBytesSha256(readFileSync(filePath));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Computes the npm integrity of a file: the same `sha512-<base64>` string that
|
|
201
|
+
* `npm pack --json` reports for the archive it writes.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} filePath - Absolute path.
|
|
204
|
+
* @returns {string} Subresource-integrity string.
|
|
205
|
+
*/
|
|
206
|
+
export function computeNpmIntegrity(filePath) {
|
|
207
|
+
return computeBytesNpmIntegrity(readFileSync(filePath));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lists what a manifest needs from `pnpm pack` and npm would publish as it is: `workspace:`,
|
|
212
|
+
* `catalog:` or `jsr:` specifiers in published dependency maps, and `publishConfig` keys pnpm hoists onto the
|
|
213
|
+
* manifest (such as `exports`, `main` or `bin`; npm only reads `publishConfig` as configuration).
|
|
214
|
+
* Every other `publishConfig` key is npm configuration and is accepted.
|
|
215
|
+
*
|
|
216
|
+
* @param {PackageManifest} manifest - `package.json` of the release checkout.
|
|
217
|
+
* @returns {string[]} Problems in Spanish; empty when npm packs the package as pnpm would.
|
|
218
|
+
*/
|
|
219
|
+
export function findPnpmPackRewrites(manifest) {
|
|
220
|
+
/** @type {string[]} */
|
|
221
|
+
const problems = [];
|
|
222
|
+
|
|
223
|
+
for (const field of PUBLISHED_DEPENDENCY_FIELDS) {
|
|
224
|
+
const dependencies = manifest[field];
|
|
225
|
+
if (!isRecord(dependencies)) continue;
|
|
226
|
+
for (const [dependencyName, specifier] of Object.entries(dependencies)) {
|
|
227
|
+
if (typeof specifier === "string" && PNPM_PACK_REWRITTEN_SPECIFIER_PATTERN.test(specifier)) {
|
|
228
|
+
problems.push(`${field}.${dependencyName} usa "${specifier}", que solo pnpm reescribe al empaquetar`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const publishConfig = manifest[PUBLISH_CONFIG_FIELD];
|
|
234
|
+
if (isRecord(publishConfig)) {
|
|
235
|
+
for (const key of Object.keys(publishConfig)) {
|
|
236
|
+
if (PNPM_HOISTED_PUBLISH_CONFIG_KEYS.includes(key)) {
|
|
237
|
+
problems.push(`${PUBLISH_CONFIG_FIELD}.${key} no es configuración de npm (solo pnpm lo aplica al empaquetar)`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return problems;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Moves a file, falling back to copy and delete when the destination is on another file system
|
|
247
|
+
* (`rename` fails with `EXDEV`, for example when the OS temp directory is another drive).
|
|
248
|
+
*
|
|
249
|
+
* @param {string} sourcePath - Absolute path of the file to move.
|
|
250
|
+
* @param {string} destinationPath - Absolute destination path.
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function moveFile(sourcePath, destinationPath) {
|
|
254
|
+
try {
|
|
255
|
+
renameSync(sourcePath, destinationPath);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!(error instanceof Error) || /** @type {NodeJS.ErrnoException} */ (error).code !== CROSS_DEVICE_RENAME_ERROR_CODE) {
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
copyFileSync(sourcePath, destinationPath);
|
|
261
|
+
unlinkSync(sourcePath);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Runs an operation with the prepared archive moved out of the package root, and always puts it
|
|
267
|
+
* back afterwards, also when the operation fails.
|
|
268
|
+
*
|
|
269
|
+
* `npm pack --dry-run` describes every file npm would pack now. When the package has no `files`
|
|
270
|
+
* allowlist and neither `.npmignore` nor `.gitignore` excludes the archive directory, the archive
|
|
271
|
+
* written by `prepare` (for example `releases/pkg-1.0.0.tgz`) would be packed into the package it
|
|
272
|
+
* is compared with, changing the integrity and rejecting a valid artifact. Hiding it reproduces the
|
|
273
|
+
* package root `prepare` packed: a directory left empty is ignored by npm, as it was then.
|
|
274
|
+
*
|
|
275
|
+
* @template T
|
|
276
|
+
* @param {string} repositoryRoot - Package root.
|
|
277
|
+
* @param {string} artifactPath - Archive relative to the root, found by {@link findPreparedArtifact}.
|
|
278
|
+
* @param {() => Promise<T>} operation - Runs while the archive is outside the package root.
|
|
279
|
+
* @param {string} [parentDirectory] - Where the holding directory is created; defaults to the OS temp directory.
|
|
280
|
+
* @returns {Promise<T>} The operation result.
|
|
281
|
+
*/
|
|
282
|
+
export async function withArtifactOutsidePackageRoot(repositoryRoot, artifactPath, operation, parentDirectory = tmpdir()) {
|
|
283
|
+
const archivePath = path.join(repositoryRoot, artifactPath);
|
|
284
|
+
const holdingDirectory = mkdtempSync(path.join(parentDirectory, ARTIFACT_HOLDING_DIRECTORY_PREFIX));
|
|
285
|
+
const heldArchivePath = path.join(holdingDirectory, path.basename(archivePath));
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
moveFile(archivePath, heldArchivePath);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
rmSync(holdingDirectory, { recursive: true, force: true });
|
|
291
|
+
throw new Error(`astra-rp create-version: no se pudo apartar ${artifactPath} para verificarlo`, { cause: error });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @type {{ value: T } | { error: unknown }} */
|
|
295
|
+
let outcome;
|
|
296
|
+
try {
|
|
297
|
+
outcome = { value: await operation() };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
outcome = { error };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Putting the archive back takes precedence over the operation's own result or failure.
|
|
303
|
+
try {
|
|
304
|
+
moveFile(heldArchivePath, archivePath);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// The holding directory is kept so the archive is never lost.
|
|
307
|
+
throw new Error(`astra-rp create-version: no se pudo devolver ${artifactPath} a su lugar; quedó en ${heldArchivePath}`, { cause: error });
|
|
308
|
+
}
|
|
309
|
+
rmSync(holdingDirectory, { recursive: true, force: true });
|
|
310
|
+
|
|
311
|
+
if ("error" in outcome) throw outcome.error;
|
|
312
|
+
return outcome.value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Verifies a prepared archive before publishing it: the SHA-256 its path declares (when the
|
|
317
|
+
* pattern uses `{sha256}`) and the SHA-512 integrity `npm pack --dry-run` reports for the release
|
|
318
|
+
* checkout, so the archive is byte for byte what npm packs from that commit.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} repositoryRoot - Repository root.
|
|
321
|
+
* @param {PreparedArtifact} artifact - Archive found by {@link findPreparedArtifact}.
|
|
322
|
+
* @param {string} expectedIntegrity - `integrity` of `npm pack --dry-run --json --ignore-scripts`.
|
|
323
|
+
* @returns {string[]} Problems in Spanish; empty when the archive can be published.
|
|
324
|
+
*/
|
|
325
|
+
export function verifyPreparedArtifact(repositoryRoot, artifact, expectedIntegrity) {
|
|
326
|
+
const archiveBytes = readFileSync(path.join(repositoryRoot, artifact.path));
|
|
327
|
+
|
|
328
|
+
if (artifact.expectedSha256) {
|
|
329
|
+
const actualSha256 = computeBytesSha256(archiveBytes);
|
|
330
|
+
if (actualSha256 !== artifact.expectedSha256) {
|
|
331
|
+
return [`el SHA-256 del tarball (${actualSha256}) no coincide con el de su ruta (${artifact.expectedSha256})`];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const actualIntegrity = computeBytesNpmIntegrity(archiveBytes);
|
|
336
|
+
if (actualIntegrity !== expectedIntegrity) {
|
|
337
|
+
return [
|
|
338
|
+
`el tarball no es lo que npm empaquetaría de este commit (integrity ${actualIntegrity}; npm pack --dry-run informa ${expectedIntegrity})`,
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated workspace where the changelog drafting agents run: a temporary directory outside the
|
|
3
|
+
* repository with a snapshot of the files Git tracks at `HEAD` and the release context (diff and
|
|
4
|
+
* log of the release range, plus the working tree changelog) that astra-rp reads for them. The
|
|
5
|
+
* agents never see the checkout, its `.git` directory nor its untracked files, and need no Git
|
|
6
|
+
* command of their own.
|
|
7
|
+
*
|
|
8
|
+
* @module create-version/changelog-draft-workspace
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
CHANGELOG_DRAFT_CONTEXT_DIRECTORY,
|
|
17
|
+
CHANGELOG_DRAFT_CURRENT_CHANGELOG_FILE,
|
|
18
|
+
CHANGELOG_DRAFT_DIFF_FILE,
|
|
19
|
+
CHANGELOG_DRAFT_DIRECTORY_PREFIX,
|
|
20
|
+
CHANGELOG_DRAFT_INDEX_FILE,
|
|
21
|
+
CHANGELOG_DRAFT_LOG_FILE,
|
|
22
|
+
CHANGELOG_DRAFT_SNAPSHOT_DIRECTORY,
|
|
23
|
+
WORKSPACE_REMOVAL_RETRIES,
|
|
24
|
+
} from "../constants/changelog-draft.js";
|
|
25
|
+
import { createGitReader, runCaptured } from "./process.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{ repositoryRoot: string, lastReleaseSha: string | null, changelogPath: string }} ChangelogDraftWorkspaceRequest
|
|
29
|
+
* @typedef {{ directory: string, remove: () => void }} ChangelogDraftWorkspace
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates the workspace: `repository/` with the tracked files at `HEAD`, and `release-context/`
|
|
34
|
+
* with the diff of the release range (without the changelog the agents are drafting), its
|
|
35
|
+
* `git log --stat` and the working tree changelog they will update, when it is a regular file.
|
|
36
|
+
* Without a previous release, the range is the whole history.
|
|
37
|
+
*
|
|
38
|
+
* @param {ChangelogDraftWorkspaceRequest} request - Repository, last release and changelog path.
|
|
39
|
+
* @returns {Promise<ChangelogDraftWorkspace>} Workspace directory and how to remove it.
|
|
40
|
+
* @throws {Error} When Git fails or the changelog cannot be copied; the partial workspace is removed and the original error is the `cause`.
|
|
41
|
+
*/
|
|
42
|
+
export async function createChangelogDraftWorkspace({ repositoryRoot, lastReleaseSha, changelogPath }) {
|
|
43
|
+
const directory = mkdtempSync(path.join(os.tmpdir(), CHANGELOG_DRAFT_DIRECTORY_PREFIX));
|
|
44
|
+
// Retries cover Windows releasing the handles of an agent stopped for taking too long.
|
|
45
|
+
const remove = () => rmSync(directory, { recursive: true, force: true, ...WORKSPACE_REMOVAL_RETRIES });
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await extractTrackedSnapshot(repositoryRoot, directory);
|
|
49
|
+
await writeReleaseContext(repositoryRoot, directory, lastReleaseSha, changelogPath);
|
|
50
|
+
return { directory, remove };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
remove();
|
|
53
|
+
throw new Error(`changelog-draft-workspace:create failed for ${repositoryRoot} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Checks out the tree of `HEAD` into the snapshot directory through a temporary index
|
|
59
|
+
* (`GIT_INDEX_FILE`), so the repository index stays untouched. Unlike `git archive`, it ignores
|
|
60
|
+
* the `export-ignore` and `export-subst` attributes: files such as `AGENTS.md` reach the agents as
|
|
61
|
+
* they are committed.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} repositoryRoot - Repository root.
|
|
64
|
+
* @param {string} directory - Workspace directory.
|
|
65
|
+
* @returns {Promise<void>} Resolves once the snapshot is checked out.
|
|
66
|
+
* @throws {Error} When `git read-tree` or `git checkout-index` fail.
|
|
67
|
+
*/
|
|
68
|
+
async function extractTrackedSnapshot(repositoryRoot, directory) {
|
|
69
|
+
const indexPath = path.join(directory, CHANGELOG_DRAFT_INDEX_FILE);
|
|
70
|
+
const snapshotDirectory = path.join(directory, CHANGELOG_DRAFT_SNAPSHOT_DIRECTORY);
|
|
71
|
+
mkdirSync(snapshotDirectory);
|
|
72
|
+
const environment = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
73
|
+
|
|
74
|
+
for (const gitArguments of [["read-tree", "HEAD"], ["checkout-index", "--all", `--prefix=${snapshotDirectory}${path.sep}`]]) {
|
|
75
|
+
const result = await runCaptured("git", gitArguments, { cwd: repositoryRoot, env: environment });
|
|
76
|
+
if (result.status !== 0) {
|
|
77
|
+
throw new Error(`git ${gitArguments.join(" ")} failed in ${repositoryRoot} with code ${result.status}: ${result.stderr}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
rmSync(indexPath, { force: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Writes the diff and the log of the release range, plus the working tree changelog. The diff
|
|
85
|
+
* excludes the changelog, whose empty `[Unreleased]` block is what the agents are drafting.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} repositoryRoot - Repository root.
|
|
88
|
+
* @param {string} directory - Workspace directory.
|
|
89
|
+
* @param {string | null} lastReleaseSha - Commit of the last release, or `null` before the first one.
|
|
90
|
+
* @param {string} changelogPath - Changelog path relative to the repository root.
|
|
91
|
+
* @returns {Promise<void>} Resolves once the context files are written.
|
|
92
|
+
* @throws {Error} When a Git command fails or the changelog cannot be copied.
|
|
93
|
+
*/
|
|
94
|
+
async function writeReleaseContext(repositoryRoot, directory, lastReleaseSha, changelogPath) {
|
|
95
|
+
const reader = createGitReader(repositoryRoot);
|
|
96
|
+
// Before the first release the diff starts at the empty tree; `hash-object` reads it from an empty stdin.
|
|
97
|
+
const diffBase = lastReleaseSha ?? (await reader.git(["hash-object", "-t", "tree", "--stdin"])).trim();
|
|
98
|
+
const logRange = lastReleaseSha ? `${lastReleaseSha}..HEAD` : "HEAD";
|
|
99
|
+
const diff = await reader.git(["diff", "--no-color", "--no-ext-diff", diffBase, "HEAD", "--", ".", `:(exclude)${changelogPath}`]);
|
|
100
|
+
const log = await reader.git(["log", "--no-color", "--stat", logRange]);
|
|
101
|
+
|
|
102
|
+
const contextDirectory = path.join(directory, CHANGELOG_DRAFT_CONTEXT_DIRECTORY);
|
|
103
|
+
mkdirSync(contextDirectory);
|
|
104
|
+
writeFileSync(path.join(contextDirectory, CHANGELOG_DRAFT_DIFF_FILE), `${diff}\n`);
|
|
105
|
+
writeFileSync(path.join(contextDirectory, CHANGELOG_DRAFT_LOG_FILE), `${log}\n`);
|
|
106
|
+
copyWorkingChangelog(repositoryRoot, contextDirectory, changelogPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Copies the working tree changelog to the release context, so the agents infer its language and
|
|
111
|
+
* conventions even when it is untracked or only changed locally. It is inspected without following
|
|
112
|
+
* symlinks: a missing changelog or one that is not a regular file is left out.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} repositoryRoot - Repository root.
|
|
115
|
+
* @param {string} contextDirectory - Release context directory.
|
|
116
|
+
* @param {string} changelogPath - Changelog path relative to the repository root.
|
|
117
|
+
* @returns {void}
|
|
118
|
+
* @throws {Error} When the changelog exists but cannot be inspected or copied.
|
|
119
|
+
*/
|
|
120
|
+
function copyWorkingChangelog(repositoryRoot, contextDirectory, changelogPath) {
|
|
121
|
+
const sourcePath = path.join(repositoryRoot, changelogPath);
|
|
122
|
+
if (!lstatSync(sourcePath, { throwIfNoEntry: false })?.isFile()) return;
|
|
123
|
+
copyFileSync(sourcePath, path.join(contextDirectory, CHANGELOG_DRAFT_CURRENT_CHANGELOG_FILE));
|
|
124
|
+
}
|