@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,2087 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `astra-rp create-version`: one command that diagnoses a repository, shows
|
|
3
|
+
* what is still missing and ships the release from `main`.
|
|
4
|
+
*
|
|
5
|
+
* 1. Diagnosis: fetches `origin`, reads the branch, working tree, `main`
|
|
6
|
+
* versus `origin/main`, the last release, unreleased commits, the
|
|
7
|
+
* published versions, the npm credentials (when the plan would publish to
|
|
8
|
+
* npm) and pending migrations (`state.js`).
|
|
9
|
+
* 2. Plan: `plan.js` turns that snapshot into ordered steps, or into blockers
|
|
10
|
+
* that explain what to fix.
|
|
11
|
+
* 3. Execution: runs each step; project-specific work (checks, migrations,
|
|
12
|
+
* preparation, publication) comes from `astra-rp.config.js`.
|
|
13
|
+
*
|
|
14
|
+
* Every step is derived from the current state, so running the command again
|
|
15
|
+
* after a failure resumes from the first missing step.
|
|
16
|
+
*
|
|
17
|
+
* @module create-version/run
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { CHANGELOG_FILE, CHANGELOG_UPDATE_REQUIRED_CODE, LINE_BREAK_PATTERN } from "../constants/changelog.js";
|
|
24
|
+
import {
|
|
25
|
+
CREATE_VERSION_FLAG,
|
|
26
|
+
FAILURE_EXIT_CODE,
|
|
27
|
+
GITHUB_REPOSITORY_PATTERN,
|
|
28
|
+
MAIN_BRANCH,
|
|
29
|
+
MAX_LISTED_COMMITS,
|
|
30
|
+
MAX_LISTED_ITEMS,
|
|
31
|
+
MIGRATION_STATUS,
|
|
32
|
+
NPM_AUTH_STATUS,
|
|
33
|
+
NPM_LOOKUP_STATUS,
|
|
34
|
+
NPM_TOKEN_VARIABLE,
|
|
35
|
+
NPM_WRITE_ACCESS_UNVERIFIED_NOTE,
|
|
36
|
+
PACKAGE_MANIFEST_FILE,
|
|
37
|
+
PACKAGE_VERSION_FIELD_PATTERN,
|
|
38
|
+
PINNED_NODE_VERSION_FILE,
|
|
39
|
+
PROJECT_NPM_CONFIG_FILE,
|
|
40
|
+
RELEASE_MODE,
|
|
41
|
+
RELEASE_REGISTRY,
|
|
42
|
+
RELEASE_REMOTE,
|
|
43
|
+
RELEASE_STEP,
|
|
44
|
+
REMOTE_MAIN_REF,
|
|
45
|
+
SHORT_SHA_LENGTH,
|
|
46
|
+
SUMMARY_VERSION_PLACEHOLDER,
|
|
47
|
+
VERSION_PREFIX_PATTERN,
|
|
48
|
+
buildMainSyncedRestartMessage,
|
|
49
|
+
} from "../constants/create-version.js";
|
|
50
|
+
import {
|
|
51
|
+
BOX_TONE,
|
|
52
|
+
ICON,
|
|
53
|
+
formatDuration,
|
|
54
|
+
measureActiveMs,
|
|
55
|
+
paint,
|
|
56
|
+
print,
|
|
57
|
+
renderBanner,
|
|
58
|
+
renderBox,
|
|
59
|
+
renderRow,
|
|
60
|
+
renderStepHeader,
|
|
61
|
+
select,
|
|
62
|
+
startSpinner,
|
|
63
|
+
} from "../terminal-ui.js";
|
|
64
|
+
import { GIT_LITERAL_PATHSPEC_PREFIX, VERSION_BLOCK_END_MARKERS, VERSION_BLOCK_PROBLEM, VERSION_BLOCK_START_MARKERS } from "../constants/version-files.js";
|
|
65
|
+
import { updateVersionMarkers, VersionBlockError } from "../version-files.js";
|
|
66
|
+
import { listNextVersions, resolveRequestedVersion, suggestNextReleaseType, toReleaseTag } from "../versions.js";
|
|
67
|
+
import {
|
|
68
|
+
expandArtifactPattern,
|
|
69
|
+
findPnpmPackRewrites,
|
|
70
|
+
findPreparedArtifact,
|
|
71
|
+
isSafeArtifactPath,
|
|
72
|
+
verifyPreparedArtifact,
|
|
73
|
+
withArtifactOutsidePackageRoot,
|
|
74
|
+
} from "./artifact.js";
|
|
75
|
+
import { describeReleaseTypes, loadCreateVersionConfig } from "./config.js";
|
|
76
|
+
import { ciWorkflowPath } from "./ci-config.js";
|
|
77
|
+
import { describeChangelogDraftProvider, draftChangelogEntries } from "./changelog-draft.js";
|
|
78
|
+
import { readUnreleased } from "../changelog.js";
|
|
79
|
+
import {
|
|
80
|
+
assertPreservedFilesUnchanged,
|
|
81
|
+
assertUnreleasedEntries,
|
|
82
|
+
describeMissingUnreleasedEntries,
|
|
83
|
+
fillUnreleasedEntries,
|
|
84
|
+
formatReleaseDate,
|
|
85
|
+
hasOnlyEmptySections,
|
|
86
|
+
prepareChangelogClosing,
|
|
87
|
+
verifyChangelogUpdate,
|
|
88
|
+
} from "./changelog.js";
|
|
89
|
+
import { ReleaseStepError, describeError } from "./errors.js";
|
|
90
|
+
import { readJsonManifest } from "./json.js";
|
|
91
|
+
import {
|
|
92
|
+
describePublishedRelease,
|
|
93
|
+
readNpmPackIntegrity,
|
|
94
|
+
} from "./npm.js";
|
|
95
|
+
import { describeNpmPublishFailure, describeNpmTokenSource } from "./npm-auth.js";
|
|
96
|
+
import { restoreLocalChanges, setAsideLocalChanges } from "./local-changes.js";
|
|
97
|
+
import { describeCreateVersionWithOptions, describeProjectCommands, detectPackageManager } from "../package-manager.js";
|
|
98
|
+
import { buildReleasePlan, buildReleaseUsage, hasFailedNpmLookup, listLocalChangesToSetAside, parseReleaseArguments } from "./plan.js";
|
|
99
|
+
import { createGitReader, listCommits, runCommandLine, runInherited } from "./process.js";
|
|
100
|
+
import { collectReleaseState, findLastRelease } from "./state.js";
|
|
101
|
+
import { isRegistryProvider } from "./registry-config.js";
|
|
102
|
+
import { checkRegistryAccess, lookupRegistryVersions, publishRegistryRelease, resolveRegistry, selectProjectRegistry } from "./registry.js";
|
|
103
|
+
import { prepareJsrVersionUpdates, readJsrManifest } from "./jsr.js";
|
|
104
|
+
import { JSR_REGISTRY_PROVIDER, REGISTRY_LABELS } from "../constants/registry.js";
|
|
105
|
+
import { CI_DISPATCH_STATUS, CI_GIT_HOOKS_OPTION, CI_VERCEL_DEPLOYMENT, CI_WORKER_CONFIGURATION_FILES, DISPATCH_CI_RELEASE_STEP, RELEASE_EXECUTION } from "../constants/ci-release.js";
|
|
106
|
+
import { findCompletedCiPublication, requiresCiPublicationHistory } from "./ci-publication-history.js";
|
|
107
|
+
import { appendCiDispatch, assertCiCompatiblePublication, assertCiInstallLockfileCommitted, assertCiNodeVersionFileCommitted, assertGeneratedCiWorkflowCurrent, assertResumeExecutionMatches, buildCiWorkerPlan, chooseReleaseExecution, defaultCiReleaseConfig, isCommittedCiWorkflowGenerated, readCiReleaseIdentity, readCommittedCiWorkflow } from "./ci.js";
|
|
108
|
+
import { assertCiSetupFilesUnchanged, describeCiEnvironment, prepareCiReleaseMetadata, prepareCiSetupFiles } from "./ci-setup.js";
|
|
109
|
+
import { createGithubWorkflowClient } from "./github-workflow.js";
|
|
110
|
+
import { loadReleaseTagConfig } from "./release-tag-config.js";
|
|
111
|
+
import { formatReleaseExecutionTrailer } from "./release-execution.js";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {import("./config.js").ResolvedCreateVersionConfig} ResolvedCreateVersionConfig
|
|
115
|
+
* @typedef {import("./config.js").HookContext} HookContext
|
|
116
|
+
* @typedef {import("./state.js").ReleaseSnapshot} ReleaseSnapshot
|
|
117
|
+
* @typedef {import("./process.js").GitReader} GitReader
|
|
118
|
+
* @typedef {{
|
|
119
|
+
* repositoryRoot: string,
|
|
120
|
+
* config: ResolvedCreateVersionConfig,
|
|
121
|
+
* state: ReleaseSnapshot,
|
|
122
|
+
* options: import("./plan.js").ReleaseOptions,
|
|
123
|
+
* reader: GitReader,
|
|
124
|
+
* version: string | null,
|
|
125
|
+
* pushed: boolean,
|
|
126
|
+
* published: boolean,
|
|
127
|
+
* commitCount: number | null,
|
|
128
|
+
* packageName: string,
|
|
129
|
+
* registryUrl: string | null,
|
|
130
|
+
* registryLabel: string | null,
|
|
131
|
+
* commands: import("../package-manager.js").ProjectCommands,
|
|
132
|
+
* changelogFiles: import("./changelog.js").PreservedReleaseFile[],
|
|
133
|
+
* execution?: "local" | "ci",
|
|
134
|
+
* ciWorker?: boolean,
|
|
135
|
+
* ciSetupFiles?: ReleaseFileUpdate[],
|
|
136
|
+
* workflowClient?: import("./github-workflow.js").GithubWorkflowClient,
|
|
137
|
+
* }} ReleaseContext
|
|
138
|
+
* @typedef {{
|
|
139
|
+
* repositoryRoot: string,
|
|
140
|
+
* reader: GitReader,
|
|
141
|
+
* config: ResolvedCreateVersionConfig,
|
|
142
|
+
* commands: import("../package-manager.js").ProjectCommands,
|
|
143
|
+
* state: { migrations: import("./config.js").MigrationCheck | null },
|
|
144
|
+
* execution?: "local" | "ci",
|
|
145
|
+
* ciWorker?: boolean,
|
|
146
|
+
* version?: string | null,
|
|
147
|
+
* }} StepContext
|
|
148
|
+
* What the steps shared with the monorepo mode read: both release contexts satisfy it.
|
|
149
|
+
* @typedef {{ mode: string, steps: import("./plan.js").ReleasePlanStep[], blockers: import("./plan.js").ReleaseBlocker[], warnings: string[] }} RenderablePlan
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/** Raised when the user cancels on purpose; ends the run without an error box. */
|
|
153
|
+
export class ReleaseCancelledError extends Error {}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Raised after syncing `main` brought new commits: the diagnosis, the plan and the configuration
|
|
157
|
+
* (with every module it imports) belong to the previous `main`, so the run ends without an error
|
|
158
|
+
* and asks to run the command again in a new process.
|
|
159
|
+
*/
|
|
160
|
+
export class MainSyncedRestartError extends Error {}
|
|
161
|
+
|
|
162
|
+
/** Answers of the pending-migrations prompt. */
|
|
163
|
+
const MIGRATION_CHOICE = Object.freeze({ apply: "apply", skip: "skip", cancel: "cancel" });
|
|
164
|
+
|
|
165
|
+
/** Answers of the prompt that offers to ignore uncommitted changes. */
|
|
166
|
+
const LOCAL_CHANGES_CHOICE = Object.freeze({ ignore: "ignore", cancel: "cancel" });
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Prints a failure that ends the run before any step: its message and, for a {@link ReleaseStepError}, its hint.
|
|
170
|
+
*
|
|
171
|
+
* @param {unknown} error - Thrown value.
|
|
172
|
+
* @param {{ red?: boolean }} [display] - Whether the message and hint are painted red.
|
|
173
|
+
* @returns {void}
|
|
174
|
+
*/
|
|
175
|
+
export function printFailure(error, { red = false } = {}) {
|
|
176
|
+
const hint = error instanceof ReleaseStepError ? ` ${error.hint}` : "";
|
|
177
|
+
const message = `${describeError(error)}${hint}`;
|
|
178
|
+
print(`${ICON.failure} ${red ? paint("red", message) : message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Lists items as bullet lines up to a limit and summarizes the rest in a gray line.
|
|
183
|
+
*
|
|
184
|
+
* @param {readonly string[]} items - Items to list.
|
|
185
|
+
* @param {number} limit - Most items listed one by one.
|
|
186
|
+
* @returns {string[]} Box lines.
|
|
187
|
+
*/
|
|
188
|
+
function listWithOverflow(items, limit) {
|
|
189
|
+
const lines = items.slice(0, limit).map((item) => `${ICON.bullet} ${item}`);
|
|
190
|
+
if (items.length > limit) {
|
|
191
|
+
lines.push(paint("gray", `… y ${items.length - limit} más`));
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Lists the uncommitted changes and asks whether to set them aside for this release, as
|
|
198
|
+
* `--ignore-local-changes` does. Nothing is preselected, so a stray Enter never ignores them.
|
|
199
|
+
*
|
|
200
|
+
* @param {string[]} changes - `git status --porcelain` lines that would be set aside.
|
|
201
|
+
* @returns {Promise<boolean>} `true` to set them aside and go on.
|
|
202
|
+
*/
|
|
203
|
+
export async function askToIgnoreLocalChanges(changes) {
|
|
204
|
+
const lines = listWithOverflow(changes, MAX_LISTED_ITEMS);
|
|
205
|
+
lines.push("", paint("gray", "Si los ignorás, se apartan con git stash durante el release (no se publican) y se restauran al final."));
|
|
206
|
+
print(renderBox({ title: `Hay ${changes.length} cambio(s) sin commitear`, lines, tone: BOX_TONE.warning }));
|
|
207
|
+
|
|
208
|
+
const choice = await select({
|
|
209
|
+
message: "¿Ignorar los cambios locales y seguir con el release?",
|
|
210
|
+
options: [
|
|
211
|
+
{ label: "Ignorarlos y seguir", hint: `igual que --${CREATE_VERSION_FLAG.ignoreLocalChanges}`, value: LOCAL_CHANGES_CHOICE.ignore },
|
|
212
|
+
{ label: "Cancelar", hint: "commitealos o guardalos antes de publicar", value: LOCAL_CHANGES_CHOICE.cancel },
|
|
213
|
+
],
|
|
214
|
+
defaultIndex: null,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return choice === LOCAL_CHANGES_CHOICE.ignore;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Creates the context passed to project hooks.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} repositoryRoot - Repository root.
|
|
224
|
+
* @param {GitReader} reader - Git reader.
|
|
225
|
+
* @param {string | null} version - Version being released, when known.
|
|
226
|
+
* @returns {HookContext} Hook context.
|
|
227
|
+
*/
|
|
228
|
+
export function createHookContext(repositoryRoot, reader, version) {
|
|
229
|
+
return {
|
|
230
|
+
repositoryRoot,
|
|
231
|
+
version,
|
|
232
|
+
git: reader,
|
|
233
|
+
run: (commandLine) => runCommandLine(commandLine, repositoryRoot),
|
|
234
|
+
print,
|
|
235
|
+
fail: (message, hint) => {
|
|
236
|
+
throw new ReleaseStepError(message, hint);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Renders a list of commit subjects inside a box.
|
|
243
|
+
*
|
|
244
|
+
* @param {{ subject: string }[]} commits - Commits, newest first.
|
|
245
|
+
* @param {string} title - Box title.
|
|
246
|
+
* @returns {string} Box.
|
|
247
|
+
*/
|
|
248
|
+
export function renderCommitList(commits, title) {
|
|
249
|
+
const lines = listWithOverflow(commits.map((commit) => commit.subject), MAX_LISTED_COMMITS);
|
|
250
|
+
return renderBox({ title, lines, tone: BOX_TONE.accent });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Compares the running Node.js with `.nvmrc`, which may pin a full version or only a major.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} repositoryRoot - Repository root.
|
|
257
|
+
* @returns {{ matches: boolean, pinned: string } | null} Comparison, or `null` without `.nvmrc`.
|
|
258
|
+
*/
|
|
259
|
+
export function checkPinnedNodeVersion(repositoryRoot) {
|
|
260
|
+
const pinnedPath = path.join(repositoryRoot, PINNED_NODE_VERSION_FILE);
|
|
261
|
+
|
|
262
|
+
if (!existsSync(pinnedPath)) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const pinned = readFileSync(pinnedPath, "utf8").trim().replace(VERSION_PREFIX_PATTERN, "");
|
|
267
|
+
const running = process.versions.node;
|
|
268
|
+
return { matches: running === pinned || running.startsWith(`${pinned}.`), pinned };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Renders the repository rows both diagnoses start with: the branch, the working tree and `main`
|
|
273
|
+
* compared with `origin/main`.
|
|
274
|
+
*
|
|
275
|
+
* @param {{ currentBranch: string | null, workingTreeChanges: string[], main: { aheadCommits: unknown[], behindCount: number } }} state - Repository part of a snapshot.
|
|
276
|
+
* @param {string | null} [detachedTag] - Release tag a detached `HEAD` sits on, which is only a warning.
|
|
277
|
+
* @returns {string[]} Rows.
|
|
278
|
+
*/
|
|
279
|
+
export function renderRepositoryRows(state, detachedTag = null) {
|
|
280
|
+
const { aheadCommits, behindCount } = state.main;
|
|
281
|
+
const syncParts = [
|
|
282
|
+
...(behindCount > 0 ? [paint("yellow", `${behindCount} atrás`)] : []),
|
|
283
|
+
...(aheadCommits.length > 0 ? [paint("yellow", `${aheadCommits.length} adelante`)] : []),
|
|
284
|
+
];
|
|
285
|
+
const branchRow = state.currentBranch === MAIN_BRANCH
|
|
286
|
+
? renderRow(ICON.success, "Rama", MAIN_BRANCH)
|
|
287
|
+
: detachedTag
|
|
288
|
+
? renderRow(ICON.warning, "Rama", paint("yellow", `HEAD desacoplado en ${detachedTag}`))
|
|
289
|
+
: renderRow(ICON.failure, "Rama", paint("red", state.currentBranch ?? "HEAD desacoplado"));
|
|
290
|
+
return [
|
|
291
|
+
branchRow,
|
|
292
|
+
renderRow(
|
|
293
|
+
state.workingTreeChanges.length === 0 ? ICON.success : ICON.warning,
|
|
294
|
+
"Working tree",
|
|
295
|
+
state.workingTreeChanges.length === 0 ? "limpio" : paint("yellow", `${state.workingTreeChanges.length} cambio(s) sin commitear`)
|
|
296
|
+
),
|
|
297
|
+
renderRow(syncParts.length > 0 ? ICON.warning : ICON.success, `${MAIN_BRANCH} ↔ origin`, syncParts.join(", ") || "al día"),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Renders the Node.js row of the diagnosis when the project pins a version in `.nvmrc`.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} repositoryRoot - Repository root.
|
|
305
|
+
* @returns {string | null} Row, or `null` without `.nvmrc`.
|
|
306
|
+
*/
|
|
307
|
+
export function renderNodeVersionRow(repositoryRoot) {
|
|
308
|
+
const node = checkPinnedNodeVersion(repositoryRoot);
|
|
309
|
+
if (!node) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return renderRow(node.matches ? ICON.success : ICON.warning, "Node.js", node.matches ? process.version : paint("yellow", `${process.version} (.nvmrc pide v${node.pinned})`));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Renders the status panel of the diagnosis.
|
|
317
|
+
*
|
|
318
|
+
* @param {ReleaseSnapshot} state - Snapshot.
|
|
319
|
+
* @param {string} repositoryRoot - Repository root.
|
|
320
|
+
* @returns {string} Box.
|
|
321
|
+
*/
|
|
322
|
+
function renderDiagnosis(state, repositoryRoot) {
|
|
323
|
+
const detachedTag = !state.currentBranch && state.headReleaseTag ? state.headReleaseTag : null;
|
|
324
|
+
const rows = [
|
|
325
|
+
...renderRepositoryRows(state, detachedTag),
|
|
326
|
+
renderRow(
|
|
327
|
+
ICON.info,
|
|
328
|
+
"Último release",
|
|
329
|
+
state.lastRelease?.version
|
|
330
|
+
? `${paint("cyan", toReleaseTag(state.lastRelease.version))} ${paint("gray", `(${state.lastRelease.sha.slice(0, SHORT_SHA_LENGTH)})`)}`
|
|
331
|
+
: paint("gray", "ninguno todavía")
|
|
332
|
+
),
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
if (state.npm) {
|
|
336
|
+
const latestPublished = state.npm.latestVersion ?? state.npm.publishedVersions.at(-1);
|
|
337
|
+
rows.push(
|
|
338
|
+
renderRow(
|
|
339
|
+
state.npm.status === NPM_LOOKUP_STATUS.ok ? ICON.success : ICON.failure,
|
|
340
|
+
state.npm.registryLabel ?? "npm",
|
|
341
|
+
state.npm.status === NPM_LOOKUP_STATUS.ok ? `${state.npm.tag ?? "latest"} ${paint("cyan", latestPublished ?? "ninguna todavía")}` : paint("red", "no respondió")
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (state.npmAuth) {
|
|
347
|
+
rows.push(renderNpmAuthRow(state.npmAuth));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
rows.push(
|
|
351
|
+
renderRow(
|
|
352
|
+
state.unreleasedCommits.length > 0 ? ICON.warning : ICON.success,
|
|
353
|
+
"Sin publicar",
|
|
354
|
+
state.unreleasedCommits.length > 0 ? paint("yellow", `${state.unreleasedCommits.length} commit(s) en ${REMOTE_MAIN_REF}`) : "nada nuevo desde el último release"
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
if (state.migrations) {
|
|
359
|
+
const { migrations } = state;
|
|
360
|
+
const [icon, value] =
|
|
361
|
+
migrations.status === MIGRATION_STATUS.pending
|
|
362
|
+
? [ICON.warning, paint("yellow", `${migrations.pending.length} pendiente(s) en ${migrations.target ?? "desconocido"}`)]
|
|
363
|
+
: migrations.status === MIGRATION_STATUS.upToDate
|
|
364
|
+
? [ICON.success, `al día en ${migrations.target ?? "la base de datos"}`]
|
|
365
|
+
: [ICON.warning, paint("yellow", "no se pudo verificar")];
|
|
366
|
+
rows.push(renderRow(icon, "Migraciones", value));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (state.unreleasedCommits.length > 0) {
|
|
370
|
+
const missingEntries = state.changelog.updated ? describeMissingUnreleasedEntries(state.changelog, CHANGELOG_FILE) : null;
|
|
371
|
+
const changelogValue = !state.changelog.updated
|
|
372
|
+
? state.changelog.reason ?? "sin actualización manual desde el último release"
|
|
373
|
+
: missingEntries ?? "actualizado manualmente; el commit de versión cierra [Unreleased]";
|
|
374
|
+
rows.push(renderRow(state.changelog.updated && !missingEntries ? ICON.success : ICON.warning, "CHANGELOG", changelogValue));
|
|
375
|
+
} else {
|
|
376
|
+
rows.push(renderRow(ICON.info, "CHANGELOG", "sin actualización requerida"));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const nodeRow = renderNodeVersionRow(repositoryRoot);
|
|
380
|
+
if (nodeRow) {
|
|
381
|
+
rows.push(nodeRow);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return renderBox({ title: "Diagnóstico", lines: rows, tone: BOX_TONE.info });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Renders the npm credential row of the diagnosis: the user and where the token came from, or the problem.
|
|
389
|
+
* An owner passes without claiming that the token can write, which npm cannot check before publishing.
|
|
390
|
+
*
|
|
391
|
+
* @param {import("./npm.js").NpmAuthCheck} npmAuth - Credential check.
|
|
392
|
+
* @returns {string} Row.
|
|
393
|
+
*/
|
|
394
|
+
export function renderNpmAuthRow(npmAuth) {
|
|
395
|
+
const source = describeNpmTokenSource(npmAuth);
|
|
396
|
+
const label = `${npmAuth.registryLabel ?? "npm"} auth`;
|
|
397
|
+
|
|
398
|
+
switch (npmAuth.status) {
|
|
399
|
+
case NPM_AUTH_STATUS.ok:
|
|
400
|
+
return npmAuth.firstPublication
|
|
401
|
+
? renderRow(ICON.success, label, `${npmAuth.user} (${source})${paint("gray", " · primera publicación")}`)
|
|
402
|
+
: renderRow(ICON.success, label, `${npmAuth.user} (${source}), dueño de ${npmAuth.packageName}${paint("gray", `; ${NPM_WRITE_ACCESS_UNVERIFIED_NOTE}`)}`);
|
|
403
|
+
case NPM_AUTH_STATUS.missingToken:
|
|
404
|
+
return renderRow(ICON.failure, label, paint("red", `falta ${npmAuth.tokenVariable ?? NPM_TOKEN_VARIABLE}`));
|
|
405
|
+
case NPM_AUTH_STATUS.invalidToken:
|
|
406
|
+
return renderRow(ICON.failure, label, paint("red", `token inválido o vencido (${source})`));
|
|
407
|
+
case NPM_AUTH_STATUS.notOwner:
|
|
408
|
+
return renderRow(ICON.failure, label, paint("red", `${npmAuth.user} no puede publicar ${npmAuth.packageName} (${source})`));
|
|
409
|
+
case NPM_AUTH_STATUS.projectCredentials:
|
|
410
|
+
return renderRow(ICON.failure, label, paint("red", `el ${PROJECT_NPM_CONFIG_FILE} del proyecto define credenciales que pisan ${npmAuth.tokenVariable ?? NPM_TOKEN_VARIABLE}`));
|
|
411
|
+
case NPM_AUTH_STATUS.unsupportedAuth:
|
|
412
|
+
return renderRow(ICON.failure, label, paint("red", npmAuth.reason ?? "autenticación no disponible"));
|
|
413
|
+
default:
|
|
414
|
+
return renderRow(ICON.warning, label, paint("yellow", `no verificable (${source}): ${npmAuth.reason ?? "permiso de escritura no verificable"}`));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Renders the plan or its blockers.
|
|
420
|
+
*
|
|
421
|
+
* @param {RenderablePlan} plan - Plan.
|
|
422
|
+
* @returns {string} Box.
|
|
423
|
+
*/
|
|
424
|
+
export function renderPlan(plan) {
|
|
425
|
+
if (plan.blockers.length > 0) {
|
|
426
|
+
const lines = plan.blockers.flatMap((blocker, index) => [
|
|
427
|
+
...(index > 0 ? [""] : []),
|
|
428
|
+
`${ICON.failure} ${paint("bold", blocker.title)}`,
|
|
429
|
+
...blocker.details.map((detail) => ` ${paint("gray", "→")} ${detail}`),
|
|
430
|
+
]);
|
|
431
|
+
return renderBox({ title: "No se puede publicar todavía", lines, tone: BOX_TONE.danger });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const lines = plan.steps.flatMap((planStep, index) => [
|
|
435
|
+
`${paint(["bold", "magenta"], `${index + 1}.`)} ${paint("bold", planStep.title)}`,
|
|
436
|
+
...(planStep.detail ? [` ${paint("gray", planStep.detail)}`] : []),
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
for (const warning of plan.warnings) {
|
|
440
|
+
lines.push("", `${ICON.warning} ${paint("yellow", warning)}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const title = plan.mode === RELEASE_MODE.resume ? "Plan · retomar el release pendiente" : "Plan";
|
|
444
|
+
return renderBox({ title, lines, tone: BOX_TONE.accent });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Runs Git with visible output and fails the step on error.
|
|
449
|
+
*
|
|
450
|
+
* @param {StepContext} context - Release context.
|
|
451
|
+
* @param {string[]} gitArguments - Git arguments.
|
|
452
|
+
* @param {string} failureMessage - Spanish message when it fails.
|
|
453
|
+
* @param {string} hint - Spanish next action.
|
|
454
|
+
* @returns {Promise<void>}
|
|
455
|
+
*/
|
|
456
|
+
export async function runGitStep(context, gitArguments, failureMessage, hint) {
|
|
457
|
+
const argumentsWithHooks = context.execution === RELEASE_EXECUTION.ci ? ["-c", CI_GIT_HOOKS_OPTION, ...gitArguments] : gitArguments;
|
|
458
|
+
const exitCode = await runInherited("git", argumentsWithHooks, { cwd: context.repositoryRoot });
|
|
459
|
+
|
|
460
|
+
if (exitCode !== 0) {
|
|
461
|
+
throw new ReleaseStepError(`${failureMessage} (git ${gitArguments[0]} salió con código ${exitCode}).`, hint);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Creates an annotated release tag, failing the step when Git rejects it.
|
|
467
|
+
*
|
|
468
|
+
* @param {StepContext} context - Release context.
|
|
469
|
+
* @param {string} tag - Tag to create.
|
|
470
|
+
* @param {string} annotation - Tag message.
|
|
471
|
+
* @param {string} hint - Spanish next action when it fails.
|
|
472
|
+
* @param {string | null} [target] - Commit to tag; `HEAD` when `null`.
|
|
473
|
+
* @returns {Promise<void>}
|
|
474
|
+
*/
|
|
475
|
+
export async function createAnnotatedTag(context, tag, annotation, hint, target = null) {
|
|
476
|
+
await runGitStep(context, ["tag", "-a", tag, "-m", annotation, ...(target === null ? [] : [target])], `No se pudo crear el tag ${tag}`, hint);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Runs configured command lines in order, stopping at the first failure.
|
|
481
|
+
*
|
|
482
|
+
* @param {StepContext} context - Release context.
|
|
483
|
+
* @param {string[]} commandLines - Commands from `astra-rp.config.js`.
|
|
484
|
+
* @param {string} hint - Spanish next action when one fails.
|
|
485
|
+
* @returns {Promise<void>}
|
|
486
|
+
*/
|
|
487
|
+
export async function runConfiguredCommands(context, commandLines, hint) {
|
|
488
|
+
for (const commandLine of commandLines) {
|
|
489
|
+
print(paint("gray", `$ ${commandLine}`));
|
|
490
|
+
const exitCode = await runCommandLine(commandLine, context.repositoryRoot);
|
|
491
|
+
|
|
492
|
+
if (exitCode !== 0) {
|
|
493
|
+
throw new ReleaseStepError(`${commandLine} falló con código ${exitCode}.`, hint);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Returns the version being released: the one just bumped or the pending one.
|
|
500
|
+
*
|
|
501
|
+
* @param {ReleaseContext} context - Release context.
|
|
502
|
+
* @returns {string} Version.
|
|
503
|
+
*/
|
|
504
|
+
function requireReleaseVersion(context) {
|
|
505
|
+
const version = context.version ?? context.state.headVersion;
|
|
506
|
+
|
|
507
|
+
if (!version) {
|
|
508
|
+
throw new ReleaseStepError("No se pudo leer la versión a publicar.", `Revisá package.json y volvé a correr ${context.commands.createVersion}.`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return version;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Steps a configuration adds to the release plan.
|
|
516
|
+
*
|
|
517
|
+
* @param {ResolvedCreateVersionConfig} config - Resolved configuration.
|
|
518
|
+
* @param {string} [registryLabel] - Provider discovered from the resolved endpoint.
|
|
519
|
+
* @returns {import("./plan.js").ReleaseCapabilities} Capabilities for `buildReleasePlan`.
|
|
520
|
+
*/
|
|
521
|
+
export function describeReleaseCapabilities(config, registryLabel) {
|
|
522
|
+
return {
|
|
523
|
+
checks: (config.checks?.length ?? 0) > 0,
|
|
524
|
+
checksMissing: config.checks === null,
|
|
525
|
+
prepare: config.prepare !== null,
|
|
526
|
+
publish: config.publish !== null,
|
|
527
|
+
publishTitle: isRegistryProvider(config.publish) ? `Publicar en ${registryLabel ?? REGISTRY_LABELS[config.publish]}` : "Publicar el release",
|
|
528
|
+
commands: config.commands,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Fast-forwards local `main` to `origin/main`. When that brings new commits the release stops
|
|
534
|
+
* before touching the version: the diagnosis, the plan and `astra-rp.config.(m)js` (imported with
|
|
535
|
+
* its modules at startup) come from the previous `main`, so the command must run again in a new
|
|
536
|
+
* process to diagnose with the new code and configuration.
|
|
537
|
+
*
|
|
538
|
+
* @param {StepContext} context - Release context.
|
|
539
|
+
* @returns {Promise<void>}
|
|
540
|
+
* @throws {ReleaseStepError} When the fast-forward fails.
|
|
541
|
+
* @throws {MainSyncedRestartError} When `main` moved and the command has to run again.
|
|
542
|
+
*/
|
|
543
|
+
export async function syncMainStep(context) {
|
|
544
|
+
const headBeforeSync = await context.reader.git(["rev-parse", "HEAD"]);
|
|
545
|
+
await runGitStep(
|
|
546
|
+
context,
|
|
547
|
+
["merge", "--ff-only", "--quiet", REMOTE_MAIN_REF],
|
|
548
|
+
`No se pudo actualizar ${MAIN_BRANCH} en fast-forward`,
|
|
549
|
+
`Revisá git status y git log ${REMOTE_MAIN_REF}..${MAIN_BRANCH}.`
|
|
550
|
+
);
|
|
551
|
+
print(`${ICON.success} ${MAIN_BRANCH} quedó igual a ${REMOTE_MAIN_REF}.`);
|
|
552
|
+
|
|
553
|
+
if ((await context.reader.git(["rev-parse", "HEAD"])) !== headBeforeSync) {
|
|
554
|
+
throw new MainSyncedRestartError();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Shows the pending migrations, asks for confirmation and applies them through the project adapter.
|
|
560
|
+
*
|
|
561
|
+
* @param {StepContext} context - Release context.
|
|
562
|
+
* @returns {Promise<void>}
|
|
563
|
+
*/
|
|
564
|
+
export async function applyMigrationsStep(context) {
|
|
565
|
+
const adapter = context.config.migrations;
|
|
566
|
+
const migrations = context.state.migrations;
|
|
567
|
+
|
|
568
|
+
if (!adapter || !migrations) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const lines = listWithOverflow(migrations.pending, MAX_LISTED_ITEMS * 2);
|
|
573
|
+
const targetHint = adapter.targetHint ? ` (${adapter.targetHint})` : "";
|
|
574
|
+
lines.push("", `${ICON.warning} Destino: ${paint(["bold", "yellow"], migrations.target ?? "desconocido")}${targetHint}`);
|
|
575
|
+
print(renderBox({ title: "Migraciones pendientes", lines, tone: BOX_TONE.warning }));
|
|
576
|
+
|
|
577
|
+
const choice = await select({
|
|
578
|
+
message: "¿Aplicar estas migraciones ahora?",
|
|
579
|
+
options: [
|
|
580
|
+
{ label: "Aplicar ahora", hint: "corre la migración del proyecto", value: MIGRATION_CHOICE.apply },
|
|
581
|
+
{ label: "Saltear", hint: "seguir con el release sin migrar", value: MIGRATION_CHOICE.skip },
|
|
582
|
+
{ label: "Cancelar release", value: MIGRATION_CHOICE.cancel },
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (choice === MIGRATION_CHOICE.cancel) {
|
|
587
|
+
throw new ReleaseCancelledError();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (choice === MIGRATION_CHOICE.skip) {
|
|
591
|
+
print(`${ICON.warning} ${paint("yellow", "Migraciones salteadas: el deploy puede fallar si el código las necesita.")}`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const hookContext = createHookContext(context.repositoryRoot, context.reader, context.ciWorker ? context.version ?? null : null);
|
|
596
|
+
await adapter.apply(hookContext);
|
|
597
|
+
const recheck = await adapter.check(hookContext);
|
|
598
|
+
|
|
599
|
+
if (recheck.status === MIGRATION_STATUS.pending) {
|
|
600
|
+
throw new ReleaseStepError(
|
|
601
|
+
`Siguen pendientes ${recheck.pending.length} migración(es) después de migrar.`,
|
|
602
|
+
context.ciWorker ? "Revisá el journal y la base antes de reintentar este mismo tag; no se publicó el release." : "Revisá el journal de migraciones y la tabla de migraciones aplicadas; no se subió ninguna versión."
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (context.ciWorker && recheck.status !== MIGRATION_STATUS.upToDate) {
|
|
607
|
+
context.state.migrations = recheck;
|
|
608
|
+
throw new ReleaseStepError("Las migraciones se ejecutaron, pero no se pudo confirmar que la base esté al día.", "Verificá la base y las credenciales antes de reintentar el workflow de este mismo tag; no se publicó el release.");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
print(`${ICON.success} Base de datos al día.`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Tells whether a local run can draft the missing `[Unreleased]` entries: the project configured
|
|
616
|
+
* `changelogDraft` and the only thing stopping the new release is a changelog without entries. A
|
|
617
|
+
* changelog that already has entries but was not updated is left to the user.
|
|
618
|
+
*
|
|
619
|
+
* @param {RenderablePlan} plan - Plan built from the diagnosis.
|
|
620
|
+
* @param {ReleaseSnapshot} state - Diagnosis.
|
|
621
|
+
* @param {ResolvedCreateVersionConfig} config - Project configuration.
|
|
622
|
+
* @param {import("./plan.js").ReleaseOptions} options - Command line options.
|
|
623
|
+
* @returns {boolean} Whether to draft the entries.
|
|
624
|
+
*/
|
|
625
|
+
function canDraftChangelog(plan, state, config, options) {
|
|
626
|
+
return (
|
|
627
|
+
config.changelogDraft.length > 0 &&
|
|
628
|
+
!options.ciRelease &&
|
|
629
|
+
!options.retryCi &&
|
|
630
|
+
state.changelog.entryCount === 0 &&
|
|
631
|
+
plan.blockers.length > 0 &&
|
|
632
|
+
plan.blockers.every((blocker) => blocker.code === CHANGELOG_UPDATE_REQUIRED_CODE)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Reads the changelog the agent is about to draft, without following symlinks: a link (or any
|
|
638
|
+
* other entry that is not a regular file) could point outside the repository, so it is reported
|
|
639
|
+
* instead of read.
|
|
640
|
+
*
|
|
641
|
+
* @param {string} changelogPath - Absolute path of the changelog.
|
|
642
|
+
* @returns {{ kind: "missing" } | { kind: "file", bytes: Buffer } | { kind: "notRegularFile" }} What the path holds.
|
|
643
|
+
*/
|
|
644
|
+
function readChangelogForDraft(changelogPath) {
|
|
645
|
+
const entry = lstatSync(changelogPath, { throwIfNoEntry: false });
|
|
646
|
+
if (!entry) return { kind: "missing" };
|
|
647
|
+
if (!entry.isFile()) return { kind: "notRegularFile" };
|
|
648
|
+
return { kind: "file", bytes: readFileSync(changelogPath) };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Tells whether the changelog still holds what was read before drafting: same entry type, same
|
|
653
|
+
* presence and same bytes. Writing over a changelog someone edited while the agent ran would
|
|
654
|
+
* silently discard those edits.
|
|
655
|
+
*
|
|
656
|
+
* @param {string} changelogPath - Absolute path of the changelog.
|
|
657
|
+
* @param {Buffer | null} originalBytes - Bytes read before drafting, or `null` when it did not exist.
|
|
658
|
+
* @returns {boolean} Whether the changelog is unchanged; `false` when it cannot be read again.
|
|
659
|
+
*/
|
|
660
|
+
function isChangelogUnchangedSinceDraft(changelogPath, originalBytes) {
|
|
661
|
+
try {
|
|
662
|
+
const current = readChangelogForDraft(changelogPath);
|
|
663
|
+
if (originalBytes === null) return current.kind === "missing";
|
|
664
|
+
return current.kind === "file" && current.bytes.equals(originalBytes);
|
|
665
|
+
} catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Drafts the `[Unreleased]` entries with the configured agent CLIs, in order, and writes the first
|
|
672
|
+
* valid answer to the changelog (creating it when missing). The entries are not confirmed: the
|
|
673
|
+
* release commits them, so they are printed for the user to see. When every agent fails, the
|
|
674
|
+
* reasons are printed and the release keeps its changelog blocker. A changelog that is not a
|
|
675
|
+
* regular file (for example a symlink) is never drafted, nor one whose `[Unreleased]` holds text
|
|
676
|
+
* other than empty change-type headings, and one that changed while the agent ran is left untouched.
|
|
677
|
+
*
|
|
678
|
+
* @param {string} repositoryRoot - Repository root.
|
|
679
|
+
* @param {ResolvedCreateVersionConfig} config - Project configuration with `changelogDraft`.
|
|
680
|
+
* @param {ReleaseSnapshot} state - Diagnosis with the package, last release and unreleased commits.
|
|
681
|
+
* @returns {Promise<boolean>} Whether the changelog now holds drafted entries.
|
|
682
|
+
*/
|
|
683
|
+
async function draftMissingChangelog(repositoryRoot, config, state) {
|
|
684
|
+
const changelogPath = path.join(repositoryRoot, CHANGELOG_FILE);
|
|
685
|
+
let currentChangelog = "";
|
|
686
|
+
/** @type {Buffer | null} */
|
|
687
|
+
let originalBytes = null;
|
|
688
|
+
try {
|
|
689
|
+
const changelog = readChangelogForDraft(changelogPath);
|
|
690
|
+
if (changelog.kind === "notRegularFile") {
|
|
691
|
+
print(`${ICON.warning} ${paint("yellow", `No se redactó ${CHANGELOG_FILE}: no es un archivo regular (por ejemplo, un symlink) y escribirlo podría modificar un archivo fuera del repositorio.`)}`);
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
if (changelog.kind === "file") {
|
|
695
|
+
originalBytes = changelog.bytes;
|
|
696
|
+
currentChangelog = STRICT_UTF8_DECODER.decode(originalBytes);
|
|
697
|
+
}
|
|
698
|
+
} catch (error) {
|
|
699
|
+
print(`${ICON.warning} ${paint("yellow", `No se redactó ${CHANGELOG_FILE}: no se pudo leer como texto UTF-8 (${describeError(error)}).`)}`);
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (!hasOnlyEmptySections(readUnreleased(currentChangelog))) {
|
|
703
|
+
print(`${ICON.warning} ${paint("yellow", `No se redactó ${CHANGELOG_FILE}: [Unreleased] tiene texto que no es una entrada; agregá las entradas a mano.`)}`);
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
print(`${ICON.info} ${CHANGELOG_FILE} no tiene entradas en [Unreleased]: se redactan con un agente que lee el repositorio sin modificarlo.`);
|
|
708
|
+
const progress = { spinner: /** @type {ReturnType<typeof startSpinner> | null} */ (null) };
|
|
709
|
+
const draft = await draftChangelogEntries({
|
|
710
|
+
providers: config.changelogDraft,
|
|
711
|
+
repositoryRoot,
|
|
712
|
+
request: { packageName: state.packageName, changelogPath: CHANGELOG_FILE, lastReleaseSha: state.lastRelease?.sha ?? null, commits: state.unreleasedCommits },
|
|
713
|
+
onAttempt: (provider) => {
|
|
714
|
+
progress.spinner = startSpinner(`Redactando [Unreleased] con ${describeChangelogDraftProvider(provider)}`);
|
|
715
|
+
},
|
|
716
|
+
onFailure: ({ provider, reason }) => progress.spinner?.fail(`${describeChangelogDraftProvider(provider)} no pudo redactarlo: ${reason}`),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
if (draft.entries === null) {
|
|
720
|
+
const failures = draft.failures.map(({ provider, reason }) => `${ICON.failure} ${describeChangelogDraftProvider(provider)}: ${reason}`);
|
|
721
|
+
print(renderBox({ title: `${CHANGELOG_FILE} sin redactar`, lines: [...failures, `Agregá las entradas a mano bajo ## [Unreleased] y volvé a correr ${config.commands.createVersion}.`], tone: BOX_TONE.warning }));
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const providerLabel = describeChangelogDraftProvider(draft.provider);
|
|
726
|
+
progress.spinner?.succeed(`[Unreleased] redactado con ${providerLabel}`);
|
|
727
|
+
if (!isChangelogUnchangedSinceDraft(changelogPath, originalBytes)) {
|
|
728
|
+
print(`${ICON.failure} ${paint("red", `No se escribieron las entradas de ${providerLabel}: ${CHANGELOG_FILE} cambió mientras el agente redactaba. Revisá el archivo y volvé a correr ${config.commands.createVersion}.`)}`);
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
// `wx` fails if a changelog appeared after the last check, instead of overwriting it.
|
|
733
|
+
writeFileSync(changelogPath, fillUnreleasedEntries(currentChangelog, draft.entries), { flag: originalBytes === null ? "wx" : "w" });
|
|
734
|
+
} catch (error) {
|
|
735
|
+
print(`${ICON.failure} ${paint("red", `No se pudo escribir ${CHANGELOG_FILE} con las entradas de ${providerLabel} (${describeError(error)}).`)}`);
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
print(renderBox({ title: `${CHANGELOG_FILE} · [Unreleased] de ${providerLabel}`, lines: draft.entries.split(LINE_BREAK_PATTERN), tone: BOX_TONE.info }));
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Verifies the manual changelog before migrations or checks and captures its bytes. A new release
|
|
744
|
+
* also requires entries in `[Unreleased]`, which its bump closes; the CI worker only checks the
|
|
745
|
+
* changelog of the release commit it received, where `[Unreleased]` is already closed.
|
|
746
|
+
*
|
|
747
|
+
* @param {ReleaseContext} context - Release context.
|
|
748
|
+
* @returns {Promise<void>}
|
|
749
|
+
*/
|
|
750
|
+
async function verifyChangelogStep(context) {
|
|
751
|
+
const changelog = await verifyChangelogUpdate(context, CHANGELOG_FILE, context.state.lastRelease?.sha ?? null);
|
|
752
|
+
if (!context.ciWorker) assertUnreleasedEntries(context, changelog);
|
|
753
|
+
context.changelogFiles = [changelog];
|
|
754
|
+
if (context.config.publish === JSR_REGISTRY_PROVIDER && context.state.headVersion) {
|
|
755
|
+
const candidate = listNextVersions(context.state.headVersion)[0].version;
|
|
756
|
+
await prepareJsrVersionUpdates(context, context.repositoryRoot, context.config.publication, candidate);
|
|
757
|
+
}
|
|
758
|
+
print(context.ciWorker ? `${ICON.success} ${CHANGELOG_FILE} del release verificado.` : `${ICON.success} ${CHANGELOG_FILE} actualizado manualmente; el commit de versión cierra su sección [Unreleased].`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Runs the configured checks before touching the version.
|
|
763
|
+
*
|
|
764
|
+
* @param {StepContext} context - Release context.
|
|
765
|
+
* @returns {Promise<void>}
|
|
766
|
+
*/
|
|
767
|
+
export async function runChecksStep(context) {
|
|
768
|
+
await runConfiguredCommands(context, context.config.checks ?? [], "Corregí el error de arriba; todavía no se tocó la versión.");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* A file of the release commit: its bytes before the release, kept so a rollback writes them back
|
|
773
|
+
* exactly, and the content the release writes.
|
|
774
|
+
*
|
|
775
|
+
* @typedef {{ filePath: string, originalBytes: Buffer | null, content: string }} ReleaseFileUpdate
|
|
776
|
+
* `null` means the file was absent and must be removed on rollback.
|
|
777
|
+
*/
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* @typedef {Pick<StepContext, "repositoryRoot" | "config" | "reader" | "commands">} VersionFilesContext
|
|
781
|
+
*/
|
|
782
|
+
|
|
783
|
+
/** Strict UTF-8 decoder: invalid bytes throw instead of turning into U+FFFD, and a byte order mark is kept as text. */
|
|
784
|
+
const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Reads a file the release rewrites, as its exact bytes and as strict UTF-8 text: writing back the
|
|
788
|
+
* text of a file that is not UTF-8 (such as ISO-8859-1) would replace its other bytes.
|
|
789
|
+
*
|
|
790
|
+
* @param {VersionFilesContext} context - Release context.
|
|
791
|
+
* @param {string} filePath - File relative to the root, `/`-separated.
|
|
792
|
+
* @param {string} fileLabel - How the messages name the file, such as `src/cli.js (versionFiles)`.
|
|
793
|
+
* @returns {{ originalBytes: Buffer, text: string }} Bytes on disk and their text.
|
|
794
|
+
* @throws {ReleaseStepError} When the file is not valid UTF-8.
|
|
795
|
+
*/
|
|
796
|
+
export function readReleaseFile(context, filePath, fileLabel) {
|
|
797
|
+
const originalBytes = readFileSync(path.join(context.repositoryRoot, filePath));
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
return { originalBytes, text: STRICT_UTF8_DECODER.decode(originalBytes) };
|
|
801
|
+
} catch (error) {
|
|
802
|
+
throw new ReleaseStepError(
|
|
803
|
+
`${fileLabel} no es texto UTF-8 válido: reescribir su versión cambiaría también otros bytes.`,
|
|
804
|
+
`Convertilo a UTF-8 y commitealo, y volvé a correr ${context.commands.createVersion}; no se tocó la versión.`,
|
|
805
|
+
{ cause: error }
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Writes a new version into the top-level `version` of a manifest, keeping the rest of its bytes.
|
|
812
|
+
* The text replacement changes the first `"version"` field of the file, so the result is parsed to
|
|
813
|
+
* confirm it was the top-level one and not nested metadata.
|
|
814
|
+
*
|
|
815
|
+
* @param {VersionFilesContext} context - Release context.
|
|
816
|
+
* @param {string} filePath - Manifest path relative to the root, as the messages name it.
|
|
817
|
+
* @param {string} text - Manifest content.
|
|
818
|
+
* @param {string} version - Version to write.
|
|
819
|
+
* @returns {string} Manifest content with the new top-level `version`.
|
|
820
|
+
* @throws {ReleaseStepError} When the first `"version"` of the file is not the top-level one.
|
|
821
|
+
*/
|
|
822
|
+
export function rewriteManifestVersion(context, filePath, text, version) {
|
|
823
|
+
const content = text.replace(PACKAGE_VERSION_FIELD_PATTERN, `$1${version}$2`);
|
|
824
|
+
|
|
825
|
+
if (JSON.parse(content).version !== version) {
|
|
826
|
+
throw new ReleaseStepError(
|
|
827
|
+
`${filePath} tiene un campo "version" anidado antes del "version" de primer nivel: el release reescribiría ese otro campo.`,
|
|
828
|
+
`Mové el "version" de primer nivel antes de cualquier objeto con su propio "version" y volvé a correr ${context.commands.createVersion}; no se tocó la versión.`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return content;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Builds the pathspec that matches a release file literally, so a name such as `:version` or
|
|
837
|
+
* `v*.txt` is never read as pathspec magic nor a glob.
|
|
838
|
+
*
|
|
839
|
+
* @param {string} filePath - Path relative to the root, `/`-separated.
|
|
840
|
+
* @returns {string} Literal pathspec, to pass after `--`.
|
|
841
|
+
*/
|
|
842
|
+
function toLiteralPathspec(filePath) {
|
|
843
|
+
return `${GIT_LITERAL_PATHSPEC_PREFIX}${filePath}`;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Explains, in Spanish, which version block markers of a file are paired wrongly and how to fix them.
|
|
848
|
+
*
|
|
849
|
+
* @param {VersionBlockError} blockError - Pairing problem found in the file.
|
|
850
|
+
* @param {string} filePath - Configured path.
|
|
851
|
+
* @returns {{ message: string, fix: string }} What is wrong (file, line and marker) and what to change.
|
|
852
|
+
*/
|
|
853
|
+
function describeVersionBlockProblem(blockError, filePath) {
|
|
854
|
+
const { problem, lineNumber, marker, openingLineNumber, openingMarker } = blockError;
|
|
855
|
+
const expectedEndMarker = VERSION_BLOCK_END_MARKERS[VERSION_BLOCK_START_MARKERS.indexOf(openingMarker ?? "")];
|
|
856
|
+
|
|
857
|
+
switch (problem) {
|
|
858
|
+
case VERSION_BLOCK_PROBLEM.unterminated:
|
|
859
|
+
return {
|
|
860
|
+
message: `${filePath} (versionFiles) abre un bloque de versión con ${marker} en la línea ${lineNumber} y nunca lo cierra.`,
|
|
861
|
+
fix: `Cerralo con ${expectedEndMarker} al final de las líneas que llevan la versión`,
|
|
862
|
+
};
|
|
863
|
+
case VERSION_BLOCK_PROBLEM.nested:
|
|
864
|
+
return {
|
|
865
|
+
message: `${filePath} (versionFiles) abre un bloque de versión con ${marker} en la línea ${lineNumber} dentro del bloque que abrió ${openingMarker} en la línea ${openingLineNumber}.`,
|
|
866
|
+
fix: `Cerrá el bloque de la línea ${openingLineNumber} con ${expectedEndMarker} antes de abrir otro`,
|
|
867
|
+
};
|
|
868
|
+
case VERSION_BLOCK_PROBLEM.mismatched:
|
|
869
|
+
return {
|
|
870
|
+
message: `${filePath} (versionFiles) cierra con ${marker} en la línea ${lineNumber} el bloque que abrió ${openingMarker} en la línea ${openingLineNumber}.`,
|
|
871
|
+
fix: `Cambiá ${marker} por ${expectedEndMarker}`,
|
|
872
|
+
};
|
|
873
|
+
default:
|
|
874
|
+
return {
|
|
875
|
+
message: `${filePath} (versionFiles) tiene ${marker} en la línea ${lineNumber} sin ningún bloque de versión abierto.`,
|
|
876
|
+
fix: `Abrí el bloque con ${VERSION_BLOCK_START_MARKERS[VERSION_BLOCK_END_MARKERS.indexOf(marker)]} o borrá ese marcador`,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Rewrites the marked versions of a `versionFiles` entry, stopping the release when its block
|
|
883
|
+
* markers are paired wrongly or none of its lines carries a version.
|
|
884
|
+
*
|
|
885
|
+
* @param {VersionFilesContext} context - Release context.
|
|
886
|
+
* @param {string} filePath - Configured path.
|
|
887
|
+
* @param {string} content - Content of the file.
|
|
888
|
+
* @param {string} version - Version to write.
|
|
889
|
+
* @param {string} untouchedNote - What the stop leaves untouched, closing the hint.
|
|
890
|
+
* @returns {string} Content with the marked versions rewritten.
|
|
891
|
+
* @throws {ReleaseStepError} When the block markers are paired wrongly or no version is marked.
|
|
892
|
+
*/
|
|
893
|
+
function rewriteMarkedVersions(context, filePath, content, version, untouchedNote) {
|
|
894
|
+
let update;
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
update = updateVersionMarkers(content, version);
|
|
898
|
+
} catch (error) {
|
|
899
|
+
if (!(error instanceof VersionBlockError)) {
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
902
|
+
const { message, fix } = describeVersionBlockProblem(error, filePath);
|
|
903
|
+
throw new ReleaseStepError(message, `${fix} y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`, { cause: error });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (update.replacements === 0) {
|
|
907
|
+
throw new ReleaseStepError(
|
|
908
|
+
`${filePath} (versionFiles) no tiene ninguna versión marcada para actualizar.`,
|
|
909
|
+
`Marcá la línea con un comentario astra-rp-version (o x-release-please-version), o el bloque con astra-rp-start-version … astra-rp-end, y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return update.content;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Computes the new content of `versionFiles` entries before anything is written, so
|
|
918
|
+
* a missing, non-regular (symlink or directory), untracked, non-UTF-8 or unmarked file stops the release with the version untouched.
|
|
919
|
+
*
|
|
920
|
+
* @param {VersionFilesContext} context - Release context.
|
|
921
|
+
* @param {readonly string[]} versionFiles - Entries that get `version`: every configured one, or in a
|
|
922
|
+
* monorepo the ones inside the package being released.
|
|
923
|
+
* @param {string} version - Version being released.
|
|
924
|
+
* @returns {Promise<ReleaseFileUpdate[]>} Files to write, with their bytes before the release.
|
|
925
|
+
* @throws {ReleaseStepError} When a file is missing, is not a regular file, is not tracked by Git, is not valid UTF-8, has
|
|
926
|
+
* block markers paired wrongly or none of its lines is marked.
|
|
927
|
+
*/
|
|
928
|
+
export async function prepareVersionFileUpdates(context, versionFiles, version) {
|
|
929
|
+
const untouchedNote = "no se tocó la versión";
|
|
930
|
+
|
|
931
|
+
for (const filePath of versionFiles) {
|
|
932
|
+
if (!existsSync(path.join(context.repositoryRoot, filePath))) {
|
|
933
|
+
throw new ReleaseStepError(`${filePath} (versionFiles) no existe.`, `Corregí versionFiles en astra-rp.config.(m)js y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`);
|
|
934
|
+
}
|
|
935
|
+
// lstat does not follow links: writing through a symlink would change a file outside versionFiles.
|
|
936
|
+
if (!lstatSync(path.join(context.repositoryRoot, filePath)).isFile()) {
|
|
937
|
+
throw new ReleaseStepError(
|
|
938
|
+
`${filePath} (versionFiles) no es un archivo regular (es un symlink o un directorio).`,
|
|
939
|
+
`Apuntá versionFiles al archivo real en astra-rp.config.(m)js y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const trackedPaths = versionFiles.length === 0 ? [] : (await context.reader.git(["ls-files", "-z", "--", ...versionFiles.map(toLiteralPathspec)])).split("\0");
|
|
945
|
+
const untrackedPath = versionFiles.find((filePath) => !trackedPaths.includes(filePath));
|
|
946
|
+
|
|
947
|
+
if (untrackedPath !== undefined) {
|
|
948
|
+
throw new ReleaseStepError(
|
|
949
|
+
`${untrackedPath} (versionFiles) no está trackeado en Git (lo ignora .gitignore o nunca se commiteó): el commit de release no podría incluirlo.`,
|
|
950
|
+
`Commitealo (si está ignorado, con git add -f) o sacalo de versionFiles en astra-rp.config.(m)js, y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return versionFiles.map((filePath) => {
|
|
955
|
+
const { originalBytes, text } = readReleaseFile(context, filePath, `${filePath} (versionFiles)`);
|
|
956
|
+
return { filePath, originalBytes, content: rewriteMarkedVersions(context, filePath, text, version, untouchedNote) };
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Undoes a release whose files were written but not committed (a failed write, `git add`,
|
|
962
|
+
* `git write-tree` or `git commit`, or a commit undone because a hook changed it): the index entries
|
|
963
|
+
* of the release files go back to the tree the index had before staging, and every release file to
|
|
964
|
+
* its original bytes (recreating a file, and its folder, that a hook deleted).
|
|
965
|
+
*
|
|
966
|
+
* @param {VersionFilesContext} context - Release context.
|
|
967
|
+
* @param {ReleaseFileUpdate[]} fileUpdates - Files of the release commit.
|
|
968
|
+
* @param {string | null} indexTree - Tree of the index before staging, or `null` when Git could not write it.
|
|
969
|
+
* @param {ReleaseStepError} failure - What failed.
|
|
970
|
+
* @returns {Promise<ReleaseStepError>} Failure to throw, whose hint says what was restored.
|
|
971
|
+
*/
|
|
972
|
+
async function rollBackRelease(context, fileUpdates, indexTree, failure) {
|
|
973
|
+
const releasePathspecs = fileUpdates.map(({ filePath }) => toLiteralPathspec(filePath));
|
|
974
|
+
const indexRestored = indexTree !== null && (await context.reader.tryGit(["reset", "--quiet", indexTree, "--", ...releasePathspecs])) !== null;
|
|
975
|
+
const unrestoredPaths = fileUpdates
|
|
976
|
+
.filter(({ filePath, originalBytes }) => {
|
|
977
|
+
const absolutePath = path.join(context.repositoryRoot, filePath);
|
|
978
|
+
try {
|
|
979
|
+
if (originalBytes === null) {
|
|
980
|
+
rmSync(absolutePath, { force: true });
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
// Only files that changed: one that could not be written (read-only) already holds its bytes.
|
|
984
|
+
// A file a hook deleted is recreated, folder included, so uncommitted notes are not lost.
|
|
985
|
+
if (!existsSync(absolutePath)) {
|
|
986
|
+
mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
987
|
+
writeFileSync(absolutePath, originalBytes);
|
|
988
|
+
} else if (!readFileSync(absolutePath).equals(originalBytes)) {
|
|
989
|
+
writeFileSync(absolutePath, originalBytes);
|
|
990
|
+
}
|
|
991
|
+
return false;
|
|
992
|
+
} catch {
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
})
|
|
996
|
+
.map(({ filePath }) => filePath);
|
|
997
|
+
const restoreNote =
|
|
998
|
+
indexRestored && unrestoredPaths.length === 0
|
|
999
|
+
? "se restauraron package.json, versionFiles y el CHANGELOG (contenido y staging), así que no se tocó la versión"
|
|
1000
|
+
: `no se pudo restaurar todo: revisá git status y devolvé ${unrestoredPaths.length > 0 ? unrestoredPaths.join(", ") : "los archivos de versión y el staging del release"} a su estado anterior antes de reintentar`;
|
|
1001
|
+
|
|
1002
|
+
return new ReleaseStepError(failure.message, `${failure.hint} Después volvé a correr ${context.commands.createVersion}; ${restoreNote}.`, { cause: failure });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Checks, before a resume pushes or publishes a release commit that already exists, that every
|
|
1007
|
+
* `versionFiles` entry in `HEAD` carries the pending version in its marked lines: a resume never
|
|
1008
|
+
* rewrites them.
|
|
1009
|
+
*
|
|
1010
|
+
* @param {VersionFilesContext} context - Release context.
|
|
1011
|
+
* @param {string} version - Version of the pending release.
|
|
1012
|
+
* @returns {Promise<void>}
|
|
1013
|
+
* @throws {ReleaseStepError} When an entry is missing from `HEAD`, has no marked version, has block
|
|
1014
|
+
* markers paired wrongly or has a marked version other than `version`.
|
|
1015
|
+
*/
|
|
1016
|
+
async function verifyReleasedVersionFiles(context, version) {
|
|
1017
|
+
const untouchedNote = "no se subió ni publicó nada";
|
|
1018
|
+
|
|
1019
|
+
for (const filePath of context.config.versionFiles) {
|
|
1020
|
+
const content = await context.reader.tryGit(["show", `HEAD:${filePath}`]);
|
|
1021
|
+
|
|
1022
|
+
if (content === null) {
|
|
1023
|
+
throw new ReleaseStepError(
|
|
1024
|
+
`${filePath} (versionFiles) no existe en el commit de release ${version} (HEAD).`,
|
|
1025
|
+
`Agregalo al commit de release con sus líneas marcadas en ${version}, o corregí versionFiles en astra-rp.config.(m)js, y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (rewriteMarkedVersions(context, filePath, content, version, untouchedNote) !== content) {
|
|
1030
|
+
throw new ReleaseStepError(
|
|
1031
|
+
`${filePath} (versionFiles) tiene en el commit de release (HEAD) una versión marcada distinta de ${version}.`,
|
|
1032
|
+
`Actualizá sus líneas marcadas a ${version} dentro del commit de release (git commit --amend, y recreá el tag ${toReleaseTag(version)} si ya existe en local) y volvé a correr ${context.commands.createVersion}; ${untouchedNote}.`
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Stops the release when an earlier step (such as a check running a formatter with `--fix`) changed
|
|
1040
|
+
* a `package.json` or a `versionFiles` entry: the plan requires them clean, so any difference from
|
|
1041
|
+
* `HEAD` would be read as the original content and shipped in the release commit.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {VersionFilesContext} context - Release context.
|
|
1044
|
+
* @param {readonly string[]} filePaths - Manifests and `versionFiles` entries the release rewrites.
|
|
1045
|
+
* @returns {Promise<void>}
|
|
1046
|
+
* @throws {ReleaseStepError} When any of those files differs from `HEAD`.
|
|
1047
|
+
*/
|
|
1048
|
+
export async function assertReleaseFilesMatchHead(context, filePaths) {
|
|
1049
|
+
const releasePathspecs = filePaths.map(toLiteralPathspec);
|
|
1050
|
+
const changedPaths = (await context.reader.git(["diff", "--name-only", "-z", "HEAD", "--", ...releasePathspecs])).split("\0").filter(Boolean);
|
|
1051
|
+
|
|
1052
|
+
if (changedPaths.length > 0) {
|
|
1053
|
+
throw new ReleaseStepError(
|
|
1054
|
+
`Un paso anterior (por ejemplo, un check) modificó ${changedPaths.join(", ")}: el commit de release incluiría esos cambios.`,
|
|
1055
|
+
`Revisá el check o commiteá esos cambios, y volvé a correr ${context.commands.createVersion}; no se tocó la versión.`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Writes the release files (versions and closed changelogs), stages them literally and creates the
|
|
1062
|
+
* release commit, checking that it holds exactly the prepared tree. Any failure (or a hook that changes
|
|
1063
|
+
* the commit, or rewrites a closed changelog in the working tree) rolls the files and their staging
|
|
1064
|
+
* back and undoes the commit, so no tag is created.
|
|
1065
|
+
*
|
|
1066
|
+
* @param {StepContext} context - Release context.
|
|
1067
|
+
* @param {ReleaseFileUpdate[]} releaseFiles - Files of the release commit, with their bytes before the release.
|
|
1068
|
+
* @param {string} subject - Subject of the release commit.
|
|
1069
|
+
* @param {readonly ReleaseFileUpdate[]} [closedChangelogs] - Changelogs among `releaseFiles` whose working-tree bytes
|
|
1070
|
+
* must still hold their closed `[Unreleased]` after the commit.
|
|
1071
|
+
* @param {readonly string[]} [trailers] - Trailer lines (`Key: value`) appended as the last paragraph of the commit message.
|
|
1072
|
+
* @returns {Promise<void>}
|
|
1073
|
+
* @throws {ReleaseStepError} When writing, staging or committing fails, or the commit differs from the prepared tree.
|
|
1074
|
+
*/
|
|
1075
|
+
export async function commitReleaseFiles(context, releaseFiles, subject, closedChangelogs = [], trailers = []) {
|
|
1076
|
+
// The index before staging, so a rollback puts it back exactly (a CHANGELOG.md the user staged stays staged).
|
|
1077
|
+
const indexTree = await context.reader.tryGit(["write-tree"]);
|
|
1078
|
+
let preparedTree = "";
|
|
1079
|
+
|
|
1080
|
+
try {
|
|
1081
|
+
for (const { filePath, content } of releaseFiles) {
|
|
1082
|
+
try {
|
|
1083
|
+
mkdirSync(path.dirname(path.join(context.repositoryRoot, filePath)), { recursive: true });
|
|
1084
|
+
writeFileSync(path.join(context.repositoryRoot, filePath), content);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
throw new ReleaseStepError(`No se pudo escribir ${filePath} para el release (${describeError(error)}).`, "Revisá que se pueda escribir (permisos, solo lectura).", {
|
|
1087
|
+
cause: error,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
await runGitStep(context, ["add", "--", ...releaseFiles.map(({ filePath }) => toLiteralPathspec(filePath))], "No se pudo stagear package.json, CHANGELOG.md y versionFiles", "Revisá git status.");
|
|
1092
|
+
// What the commit must hold: a hook that changes or stages anything else makes it differ.
|
|
1093
|
+
preparedTree = await context.reader.git(["write-tree"]);
|
|
1094
|
+
const messageArguments = trailers.length > 0 ? ["-m", subject, "-m", trailers.join("\n")] : ["-m", subject];
|
|
1095
|
+
await runGitStep(context, ["commit", "--quiet", ...messageArguments], "El commit de versión falló", "Corregí el error (por ejemplo, un hook pre-commit que lo rechaza).");
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
const failure =
|
|
1098
|
+
error instanceof ReleaseStepError
|
|
1099
|
+
? error
|
|
1100
|
+
: new ReleaseStepError(`No se pudo preparar el commit de versión ${subject} (${describeError(error)}).`, "Revisá git status.", { cause: error });
|
|
1101
|
+
throw await rollBackRelease(context, releaseFiles, indexTree, failure);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** @type {ReleaseStepError | null} */
|
|
1105
|
+
let failure = null;
|
|
1106
|
+
if ((await context.reader.git(["rev-parse", "HEAD^{tree}"])) !== preparedTree) {
|
|
1107
|
+
const unpreparedPaths = await context.reader.git(["diff", "--name-only", preparedTree, "HEAD"]);
|
|
1108
|
+
failure = new ReleaseStepError(
|
|
1109
|
+
`El commit de versión ${subject} incluía cambios que astra-rp no preparó: ${unpreparedPaths.split("\n").slice(0, MAX_LISTED_ITEMS).join(", ")} (por ejemplo, de un hook). Se deshizo el commit y no se creó el tag.`,
|
|
1110
|
+
"Revisá el hook y esos cambios."
|
|
1111
|
+
);
|
|
1112
|
+
} else {
|
|
1113
|
+
try {
|
|
1114
|
+
assertPreservedFilesUnchanged(context, closedChangelogs.map(({ filePath, content }) => ({ filePath, originalBytes: Buffer.from(content) })));
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
if (!(error instanceof ReleaseStepError)) throw error;
|
|
1117
|
+
failure = error;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (failure) {
|
|
1121
|
+
if ((await context.reader.tryGit(["reset", "--soft", "--quiet", "HEAD^"])) === null) {
|
|
1122
|
+
throw new ReleaseStepError(failure.message, `No se pudo deshacer el commit: corré git reset --soft HEAD^, revisá git status y volvé a correr ${context.commands.createVersion}.`);
|
|
1123
|
+
}
|
|
1124
|
+
throw await rollBackRelease(context, releaseFiles, indexTree, failure);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Chooses the next version (flags or prompt), closes the `[Unreleased]` section of the CHANGELOG
|
|
1130
|
+
* and creates the release commit and annotated tag.
|
|
1131
|
+
*
|
|
1132
|
+
* @param {ReleaseContext} context - Release context.
|
|
1133
|
+
* @returns {Promise<void>}
|
|
1134
|
+
*/
|
|
1135
|
+
async function bumpVersionStep(context) {
|
|
1136
|
+
assertPreservedFilesUnchanged(context, context.changelogFiles);
|
|
1137
|
+
await assertReleaseFilesMatchHead(context, [PACKAGE_MANIFEST_FILE, ...context.config.versionFiles]);
|
|
1138
|
+
// Re-read the manifest: syncing main may have brought a newer version.
|
|
1139
|
+
const manifest = readReleaseFile(context, PACKAGE_MANIFEST_FILE, PACKAGE_MANIFEST_FILE);
|
|
1140
|
+
const currentVersion = JSON.parse(manifest.text).version;
|
|
1141
|
+
const lastReleaseSha = context.state.lastRelease?.sha;
|
|
1142
|
+
const commits = await listCommits(context.reader, lastReleaseSha ? `${lastReleaseSha}..HEAD` : "HEAD");
|
|
1143
|
+
print(renderCommitList(commits, `Qué se publica (${commits.length} commit(s))`));
|
|
1144
|
+
|
|
1145
|
+
let nextRelease = resolveRequestedVersion(currentVersion, context.options);
|
|
1146
|
+
const suggestion = suggestNextReleaseType(commits, currentVersion, { preMajorShift: context.config.preMajorShift });
|
|
1147
|
+
|
|
1148
|
+
if (!nextRelease && context.options.acceptSuggested) {
|
|
1149
|
+
nextRelease = resolveRequestedVersion(currentVersion, { bump: suggestion.releaseType, setVersion: null });
|
|
1150
|
+
print(`${ICON.info} Versión sugerida aceptada: ${paint(["bold", "cyan"], nextRelease?.version ?? "")} (${suggestion.releaseType}: ${suggestion.reason})`);
|
|
1151
|
+
} else if (nextRelease) {
|
|
1152
|
+
print(`${ICON.info} Versión elegida por flag: ${paint(["bold", "cyan"], nextRelease.version)} (${nextRelease.releaseType})`);
|
|
1153
|
+
} else {
|
|
1154
|
+
const nextVersions = listNextVersions(currentVersion);
|
|
1155
|
+
const releaseTypeDescriptions = describeReleaseTypes(context.config, currentVersion);
|
|
1156
|
+
const chosenVersion = await select({
|
|
1157
|
+
message: `¿Qué versión publicamos? (actual ${currentVersion})`,
|
|
1158
|
+
options: nextVersions.map((candidate) => ({
|
|
1159
|
+
label: `${candidate.releaseType.padEnd(5)} ${currentVersion} → ${candidate.version}`,
|
|
1160
|
+
hint: candidate.releaseType === suggestion.releaseType ? `${ICON.star} sugerida: ${suggestion.reason}` : undefined,
|
|
1161
|
+
description: releaseTypeDescriptions[candidate.releaseType],
|
|
1162
|
+
value: candidate.version,
|
|
1163
|
+
})),
|
|
1164
|
+
// Nothing is preselected so a stray Enter never ships a version: the suggestion is only a hint.
|
|
1165
|
+
defaultIndex: null,
|
|
1166
|
+
});
|
|
1167
|
+
nextRelease = nextVersions.find((candidate) => candidate.version === chosenVersion) ?? null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (!nextRelease) {
|
|
1171
|
+
throw new ReleaseStepError("No se eligió ninguna versión.", `Volvé a correr ${context.commands.createVersion}.`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
assertCiSetupFilesUnchanged(context.repositoryRoot, context.ciSetupFiles ?? []);
|
|
1175
|
+
|
|
1176
|
+
const execution = context.execution ?? RELEASE_EXECUTION.local;
|
|
1177
|
+
/** @type {ReleaseFileUpdate[]} */
|
|
1178
|
+
const releaseFiles = [
|
|
1179
|
+
{ filePath: PACKAGE_MANIFEST_FILE, originalBytes: manifest.originalBytes, content: rewriteManifestVersion(context, PACKAGE_MANIFEST_FILE, manifest.text, nextRelease.version) },
|
|
1180
|
+
...(await prepareVersionFileUpdates(context, context.config.versionFiles, nextRelease.version)),
|
|
1181
|
+
...(context.config.publish === JSR_REGISTRY_PROVIDER ? await prepareJsrVersionUpdates(context, context.repositoryRoot, context.config.publication, nextRelease.version) : []),
|
|
1182
|
+
...(context.ciSetupFiles ?? []),
|
|
1183
|
+
...(context.config.ci?.deployment === CI_VERCEL_DEPLOYMENT ? [prepareCiReleaseMetadata(context.repositoryRoot, nextRelease.version, execution)] : []),
|
|
1184
|
+
];
|
|
1185
|
+
// Every release of a CI-capable project records where it was prepared, so a resume after a failed push cannot switch modes.
|
|
1186
|
+
const executionTrailers = context.config.ci || execution === RELEASE_EXECUTION.ci ? [formatReleaseExecutionTrailer(execution)] : [];
|
|
1187
|
+
const releaseDate = formatReleaseDate(new Date(), context.config.releaseTimeZone);
|
|
1188
|
+
const closedChangelogs = context.changelogFiles.map((changelog) => prepareChangelogClosing(context, changelog, nextRelease.version, releaseDate));
|
|
1189
|
+
await commitReleaseFiles(context, [...releaseFiles, ...closedChangelogs], nextRelease.version, closedChangelogs, executionTrailers);
|
|
1190
|
+
|
|
1191
|
+
const tag = toReleaseTag(nextRelease.version);
|
|
1192
|
+
await createAnnotatedTag(context, tag, nextRelease.version, `Si ya existe, revisalo con git show ${tag}.`);
|
|
1193
|
+
|
|
1194
|
+
context.version = nextRelease.version;
|
|
1195
|
+
context.commitCount = commits.length;
|
|
1196
|
+
const releaseTimeZoneNote = context.config.releaseTimeZone === null ? "" : ` (zona horaria ${context.config.releaseTimeZone})`;
|
|
1197
|
+
print(`${ICON.success} ${CHANGELOG_FILE}: [Unreleased] cerrado como ## [${nextRelease.version}] - ${releaseDate}${releaseTimeZoneNote}.`);
|
|
1198
|
+
print(`${ICON.success} Commit ${paint("bold", nextRelease.version)} y tag ${paint(["bold", "cyan"], tag)} creados en local.`);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Runs the configured preparation (commands or hook) on the release commit.
|
|
1203
|
+
*
|
|
1204
|
+
* @param {ReleaseContext} context - Release context.
|
|
1205
|
+
* @returns {Promise<void>}
|
|
1206
|
+
*/
|
|
1207
|
+
async function prepareReleaseStep(context) {
|
|
1208
|
+
const { prepare } = context.config;
|
|
1209
|
+
const version = requireReleaseVersion(context);
|
|
1210
|
+
|
|
1211
|
+
if (Array.isArray(prepare)) {
|
|
1212
|
+
await runConfiguredCommands(context, prepare, `El release quedó en local: corregí el error y volvé a correr ${context.commands.createVersion}, que retoma desde acá.`);
|
|
1213
|
+
} else if (prepare) {
|
|
1214
|
+
await prepare(createHookContext(context.repositoryRoot, context.reader, version));
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Pushes `main` and the release tag atomically, creating the tag when a resumed release lacks it.
|
|
1220
|
+
*
|
|
1221
|
+
* @param {ReleaseContext} context - Release context.
|
|
1222
|
+
* @returns {Promise<void>}
|
|
1223
|
+
*/
|
|
1224
|
+
async function pushReleaseStep(context) {
|
|
1225
|
+
const version = requireReleaseVersion(context);
|
|
1226
|
+
const tag = toReleaseTag(version);
|
|
1227
|
+
|
|
1228
|
+
if ((await context.reader.tryGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`])) === null) {
|
|
1229
|
+
await createAnnotatedTag(context, tag, version, `Revisá git tag --list ${tag}.`);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Running `create-version` is the request to publish; `--dry-run` previews the plan.
|
|
1233
|
+
await runGitStep(
|
|
1234
|
+
context,
|
|
1235
|
+
["push", "--atomic", RELEASE_REMOTE, MAIN_BRANCH, `refs/tags/${tag}`],
|
|
1236
|
+
`El push de ${MAIN_BRANCH} + ${tag} falló`,
|
|
1237
|
+
`El release quedó en local: corregí el error y corré ${context.commands.createVersion}, que retoma el push de ${tag}.`
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
if (!(await context.reader.tryGit(["ls-remote", "--tags", RELEASE_REMOTE, tag]))) {
|
|
1241
|
+
throw new ReleaseStepError(`${MAIN_BRANCH} se subió pero ${tag} no aparece en ${RELEASE_REMOTE}.`, `Subilo con git push ${RELEASE_REMOTE} ${tag}.`);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
context.version = version;
|
|
1245
|
+
context.pushed = true;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Pushes only the release tag of a detached `HEAD` whose commit `origin/main` already has, so
|
|
1250
|
+
* the detached publication can go on without touching `main`. The push is not forced: a tag that
|
|
1251
|
+
* already exists on `origin` with another commit rejects it.
|
|
1252
|
+
*
|
|
1253
|
+
* @param {ReleaseContext} context - Release context.
|
|
1254
|
+
* @returns {Promise<void>}
|
|
1255
|
+
* @throws {ReleaseStepError} When the push fails or the tag does not show up on `origin`.
|
|
1256
|
+
*/
|
|
1257
|
+
async function pushReleaseTagStep(context) {
|
|
1258
|
+
const version = requireReleaseVersion(context);
|
|
1259
|
+
const tag = toReleaseTag(version);
|
|
1260
|
+
|
|
1261
|
+
await runGitStep(
|
|
1262
|
+
context,
|
|
1263
|
+
["push", RELEASE_REMOTE, `refs/tags/${tag}`],
|
|
1264
|
+
`El push de ${tag} falló`,
|
|
1265
|
+
`No se publicó nada: corregí el error y corré ${context.commands.createVersion} desde ${tag} (HEAD desacoplado).`
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
if (!(await context.reader.tryGit(["ls-remote", "--tags", RELEASE_REMOTE, `refs/tags/${tag}`]))) {
|
|
1269
|
+
throw new ReleaseStepError(`${tag} no aparece en ${RELEASE_REMOTE} después del push.`, `Subilo con git push ${RELEASE_REMOTE} ${tag} y volvé a correr ${context.commands.createVersion} desde el tag.`);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
context.version = version;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Checks that no tracked file differs from `HEAD` before publishing. `prepare` may create untracked
|
|
1277
|
+
* or ignored output (`dist/`, `releases/`), but a modified tracked file (such as `package.json`)
|
|
1278
|
+
* means the publication would no longer be the release commit.
|
|
1279
|
+
*
|
|
1280
|
+
* @param {{ reader: GitReader, commands: import("../package-manager.js").ProjectCommands }} context - Git reader of
|
|
1281
|
+
* the checkout being published and the project commands quoted by the hint.
|
|
1282
|
+
* @returns {Promise<void>}
|
|
1283
|
+
* @throws {ReleaseStepError} When Git cannot report the working tree state or a tracked file changed.
|
|
1284
|
+
*/
|
|
1285
|
+
export async function assertNoTrackedChanges(context) {
|
|
1286
|
+
const output = await context.reader.tryGit(["status", "--porcelain", "--untracked-files=no"]);
|
|
1287
|
+
|
|
1288
|
+
if (output === null) {
|
|
1289
|
+
throw new ReleaseStepError("No se pudo leer el estado del working tree antes de publicar.", `No se publicó nada. Revisá git status y volvé a correr ${context.commands.createVersion}.`);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const trackedChanges = output.split("\n").filter((line) => line.trim() !== "");
|
|
1293
|
+
|
|
1294
|
+
if (trackedChanges.length > 0) {
|
|
1295
|
+
throw new ReleaseStepError(
|
|
1296
|
+
`El paso de preparación modificó archivos versionados: ${trackedChanges.slice(0, MAX_LISTED_ITEMS).join("; ")}.`,
|
|
1297
|
+
`No se publicó nada. prepare puede generar archivos ignorados (dist/, releases/) pero no cambiar archivos versionados como package.json: revertí esos cambios y volvé a correr ${context.commands.createVersion}.`
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Finds and verifies the archive prepared for the release when the project configured `artifact`.
|
|
1304
|
+
* The working tree must still be the release commit (no tracked file modified by `prepare`), the
|
|
1305
|
+
* manifest must not rely on rewrites only `pnpm pack` applies, and the archive must have the same
|
|
1306
|
+
* SHA-512 integrity `npm pack --dry-run --ignore-scripts` reports for that commit: `npm pack` is
|
|
1307
|
+
* reproducible, so equal hashes mean the archive is byte for byte what npm packs.
|
|
1308
|
+
*
|
|
1309
|
+
* @param {ReleaseContext} context - Release context.
|
|
1310
|
+
* @param {string} version - Version being published.
|
|
1311
|
+
* @param {Record<string, unknown>} workingManifest - Working tree `package.json`, after `prepare`.
|
|
1312
|
+
* @returns {Promise<string | null>} Verified archive path relative to the root, or `null` to publish the working tree.
|
|
1313
|
+
*/
|
|
1314
|
+
async function resolvePublishedArtifact(context, version, workingManifest) {
|
|
1315
|
+
const { artifact } = context.config;
|
|
1316
|
+
|
|
1317
|
+
if (!artifact) {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
await assertNoTrackedChanges(context);
|
|
1322
|
+
|
|
1323
|
+
const pnpmRewrites = findPnpmPackRewrites(workingManifest);
|
|
1324
|
+
|
|
1325
|
+
if (pnpmRewrites.length > 0) {
|
|
1326
|
+
throw new ReleaseStepError(
|
|
1327
|
+
`Este paquete depende de reescrituras de pnpm al empaquetar y astra-rp publica con npm: ${pnpmRewrites.slice(0, MAX_LISTED_ITEMS).join("; ")}.`,
|
|
1328
|
+
"No se publicó nada. Reemplazá los especificadores workspace:/catalog:/jsr: por rangos de versión y sacá de publishConfig los campos del manifest que solo pnpm aplica al empaquetar (exports, main, bin, types...): declaralos en la raíz de package.json."
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const release = { version, packageName: String(workingManifest.name) };
|
|
1333
|
+
const prepared = findPreparedArtifact(context.repositoryRoot, artifact, release);
|
|
1334
|
+
|
|
1335
|
+
if (!prepared) {
|
|
1336
|
+
throw new ReleaseStepError(
|
|
1337
|
+
`No hay un artefacto preparado de ${version} que coincida con ${expandArtifactPattern(artifact, release)}.`,
|
|
1338
|
+
`Revisá la salida del paso de preparación y volvé a correr ${context.commands.createVersion}: retoma la preparación y la publicación.`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (!isSafeArtifactPath(prepared.path)) {
|
|
1343
|
+
throw new ReleaseStepError(`La ruta del artefacto ${prepared.path} tiene caracteres no permitidos.`, "Usá rutas con letras, números, ., -, _, @, +, ~ y /.");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Without a `files` allowlist or an ignore rule, npm would pack the archive into the package it
|
|
1347
|
+
// describes; it is moved out of the root during the dry run and published later from its path.
|
|
1348
|
+
const npmPack = await withArtifactOutsidePackageRoot(context.repositoryRoot, prepared.path, () => readNpmPackIntegrity(context.repositoryRoot));
|
|
1349
|
+
|
|
1350
|
+
if (!npmPack.pack) {
|
|
1351
|
+
throw new ReleaseStepError(
|
|
1352
|
+
`No se pudo verificar ${prepared.path}: ${npmPack.problem}.`,
|
|
1353
|
+
`No se publicó nada. Corré npm pack --dry-run --json --ignore-scripts en la raíz para ver el error y volvé a correr ${context.commands.createVersion}.`
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (npmPack.pack.version !== version) {
|
|
1358
|
+
throw new ReleaseStepError(
|
|
1359
|
+
`npm pack --dry-run describe ${npmPack.pack.name}@${npmPack.pack.version} y se está publicando ${version}.`,
|
|
1360
|
+
`No se publicó nada. Revisá que HEAD sea el commit de release y volvé a correr ${context.commands.createVersion}.`
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const problems = verifyPreparedArtifact(context.repositoryRoot, prepared, npmPack.pack.integrity);
|
|
1365
|
+
|
|
1366
|
+
if (problems.length > 0) {
|
|
1367
|
+
throw new ReleaseStepError(
|
|
1368
|
+
`El artefacto ${prepared.path} no se puede publicar: ${problems.join("; ")}.`,
|
|
1369
|
+
`No se publicó nada. Hacé que prepare empaquete con npm pack --ignore-scripts después de construir, borrá ese tarball y volvé a correr ${context.commands.createVersion}.`
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
print(`${ICON.success} ${prepared.path} verificado${prepared.expectedSha256 ? " (SHA-256 de la ruta e integrity de npm pack)" : " (integrity de npm pack)"}.`);
|
|
1374
|
+
return prepared.path;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Publishes the release with npm or the project hook.
|
|
1379
|
+
*
|
|
1380
|
+
* @param {ReleaseContext} context - Release context.
|
|
1381
|
+
* @returns {Promise<void>}
|
|
1382
|
+
*/
|
|
1383
|
+
async function publishReleaseStep(context) {
|
|
1384
|
+
const version = requireReleaseVersion(context);
|
|
1385
|
+
const { publish } = context.config;
|
|
1386
|
+
|
|
1387
|
+
if (isRegistryProvider(publish)) {
|
|
1388
|
+
// Syncing main may have renamed the package after the diagnosis: publish under the current name.
|
|
1389
|
+
/** @type {import("./artifact.js").PackageManifest} */
|
|
1390
|
+
const manifest = readJsonManifest(context.repositoryRoot);
|
|
1391
|
+
const packageName = String(manifest.name);
|
|
1392
|
+
context.packageName = packageName;
|
|
1393
|
+
const selection = selectProjectRegistry(context.config);
|
|
1394
|
+
let registry;
|
|
1395
|
+
try {
|
|
1396
|
+
registry = await resolveRegistry(selection, manifest, context.repositoryRoot);
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
throw new ReleaseStepError(`No se puede resolver el destino de publicación: ${error instanceof Error ? error.message : "configuración inválida"}.`, `Corregí publication.registryUrl, el proveedor o el manifest y volvé a correr ${context.commands.createVersion}; no se publicó nada.`, { cause: error });
|
|
1399
|
+
}
|
|
1400
|
+
const registryUrl = registry.registryUrl;
|
|
1401
|
+
context.registryUrl = registryUrl;
|
|
1402
|
+
context.registryLabel = registry.label;
|
|
1403
|
+
const artifactPath = registry.provider === JSR_REGISTRY_PROVIDER ? (await assertNoTrackedChanges(context), null) : await resolvePublishedArtifact(context, version, manifest);
|
|
1404
|
+
if (artifactPath) {
|
|
1405
|
+
print(paint("gray", `Publicando ${artifactPath}; npm puede pedir la confirmación 2FA en el navegador o un código.`));
|
|
1406
|
+
}
|
|
1407
|
+
const result = await publishRegistryRelease(selection, manifest, context.repositoryRoot, context.repositoryRoot, version, artifactPath);
|
|
1408
|
+
|
|
1409
|
+
if (result.missingToken) {
|
|
1410
|
+
throw new ReleaseStepError(
|
|
1411
|
+
`Falta ${registry.options.tokenEnv} para publicar ${version} en ${registry.label}.`,
|
|
1412
|
+
`Definilo en el entorno, .env del repo o ~/.config/astra-rp/.env y corré ${context.commands.createVersion}: retoma solo la publicación.`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!result.confirmed) {
|
|
1417
|
+
// npm inherited the terminal (2FA), so its output cannot be parsed: the credentials are checked again.
|
|
1418
|
+
const npmAuth = await checkRegistryAccess(selection, manifest, context.repositoryRoot);
|
|
1419
|
+
const failure = registry.provider === RELEASE_REGISTRY.npm ? describeNpmPublishFailure(npmAuth, { exitCode: result.exitCode, version }, context.commands) : {
|
|
1420
|
+
message: result.exitCode === 0
|
|
1421
|
+
? `El cliente de ${registry.label} terminó con código 0, pero no se confirmó ${registry.packageName}@${version} en la metadata.`
|
|
1422
|
+
: `La publicación en ${registry.label} terminó con código ${result.exitCode} y no se confirmó ${registry.packageName}@${version}.`,
|
|
1423
|
+
hint: `Consultá ${registry.registryUrl} antes de reintentar; ${context.commands.createVersion} retoma la publicación. ${npmAuth.reason ?? "Revisá las credenciales y el permiso de escritura."}`,
|
|
1424
|
+
};
|
|
1425
|
+
throw new ReleaseStepError(failure.message, failure.hint);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// npm view ignores publishConfig, so the registry the release went to is queried explicitly.
|
|
1429
|
+
const npm = await lookupRegistryVersions(selection, manifest, context.repositoryRoot);
|
|
1430
|
+
if (!npm.publishedVersions.includes(version)) {
|
|
1431
|
+
print(`${ICON.warning} ${paint("yellow", `${registry.label} todavía no muestra ${version}; puede tardar unos segundos en propagarse.`)}`);
|
|
1432
|
+
}
|
|
1433
|
+
context.packageName = registry.packageName;
|
|
1434
|
+
} else if (typeof publish === "function") {
|
|
1435
|
+
await publish(createHookContext(context.repositoryRoot, context.reader, version));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
context.version = version;
|
|
1439
|
+
context.published = true;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Executors of each plan step.
|
|
1444
|
+
*
|
|
1445
|
+
* @type {Record<string, (context: ReleaseContext) => Promise<void>>}
|
|
1446
|
+
*/
|
|
1447
|
+
const STEP_EXECUTORS = {
|
|
1448
|
+
[RELEASE_STEP.syncMain]: syncMainStep,
|
|
1449
|
+
[RELEASE_STEP.applyMigrations]: applyMigrationsStep,
|
|
1450
|
+
[RELEASE_STEP.verifyChangelog]: verifyChangelogStep,
|
|
1451
|
+
[RELEASE_STEP.runChecks]: runChecksStep,
|
|
1452
|
+
[RELEASE_STEP.bumpVersion]: bumpVersionStep,
|
|
1453
|
+
[RELEASE_STEP.prepareRelease]: prepareReleaseStep,
|
|
1454
|
+
[RELEASE_STEP.pushRelease]: pushReleaseStep,
|
|
1455
|
+
[RELEASE_STEP.pushReleaseTag]: pushReleaseTagStep,
|
|
1456
|
+
[RELEASE_STEP.publishRelease]: publishReleaseStep,
|
|
1457
|
+
[DISPATCH_CI_RELEASE_STEP]: dispatchCiReleaseStep,
|
|
1458
|
+
};
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Builds the command that dispatches an existing release tag to CI again, with the separator npm needs
|
|
1462
|
+
* to forward `--retry-ci` to the script.
|
|
1463
|
+
* @param {import("../package-manager.js").ProjectCommands} commands - Project commands.
|
|
1464
|
+
* @param {string} tag - Release tag, such as `v1.2.3`.
|
|
1465
|
+
* @returns {string} Retry command.
|
|
1466
|
+
*/
|
|
1467
|
+
function describeCiRetryCommand(commands, tag) {
|
|
1468
|
+
return `${describeCreateVersionWithOptions(commands)} --${CREATE_VERSION_FLAG.retryCi} ${tag}`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Dispatches the pushed tag without performing any project checks or publication locally.
|
|
1473
|
+
* @param {ReleaseContext} context - Prepared release and GitHub adapter.
|
|
1474
|
+
* @returns {Promise<void>} Resolves when GitHub accepted the workflow.
|
|
1475
|
+
* @throws {ReleaseStepError} When dispatch remains unconfirmed.
|
|
1476
|
+
*/
|
|
1477
|
+
async function dispatchCiReleaseStep(context) {
|
|
1478
|
+
const tag = toReleaseTag(requireReleaseVersion(context));
|
|
1479
|
+
const workflow = context.config.ci?.workflow;
|
|
1480
|
+
const retryCommand = describeCiRetryCommand(context.commands, tag);
|
|
1481
|
+
if (!workflow || !context.workflowClient) throw new ReleaseStepError("No hay un workflow disponible para enviar el release.", `Reintentá con ${retryCommand}.`);
|
|
1482
|
+
const release = await readCiReleaseIdentity(context.reader, tag);
|
|
1483
|
+
const result = await context.workflowClient.dispatch(workflow, release, retryCommand);
|
|
1484
|
+
print(`${ICON.success} ${tag} ${result.status === CI_DISPATCH_STATUS.existing ? "ya tiene una ejecución activa o exitosa" : "enviado a CI"}. Los checks y la publicación se ejecutan en GitHub.`);
|
|
1485
|
+
if (result.url) print(`${ICON.info} ${result.url}`);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Renders the closing summary of a pushed or published release.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {ReleaseContext} context - Release context.
|
|
1492
|
+
* @param {string} remoteUrl - `origin` URL.
|
|
1493
|
+
* @param {number} startedAt - Start timestamp.
|
|
1494
|
+
* @returns {string} Box.
|
|
1495
|
+
*/
|
|
1496
|
+
function renderReleaseSummary(context, remoteUrl, startedAt) {
|
|
1497
|
+
const version = /** @type {string} */ (context.version);
|
|
1498
|
+
const tag = toReleaseTag(version);
|
|
1499
|
+
const lines = [`${ICON.success} ${paint("bold", "Versión")} ${paint(["bold", "greenBright"], version)} ${paint("gray", `(${tag})`)}`];
|
|
1500
|
+
if (context.execution === RELEASE_EXECUTION.ci) return renderBox({ title: `${tag} enviado a CI`, lines: [...lines, "El release todavía no está confirmado: revisá el resultado del workflow en Actions."], tone: BOX_TONE.info });
|
|
1501
|
+
|
|
1502
|
+
if (context.commitCount !== null) {
|
|
1503
|
+
lines.push(`${ICON.success} ${paint("bold", "Commits")} ${context.commitCount}`);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (context.pushed) {
|
|
1507
|
+
lines.push(`${ICON.success} ${paint("bold", "Git")} ${MAIN_BRANCH} + ${tag} en ${RELEASE_REMOTE}`);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (context.published && context.registryUrl) {
|
|
1511
|
+
lines.push(`${ICON.success} ${paint("bold", context.registryLabel ?? "Registro")} ${describePublishedRelease({ registryUrl: context.registryUrl, packageName: context.packageName, version })}`);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const githubRepository = GITHUB_REPOSITORY_PATTERN.exec(remoteUrl)?.[1];
|
|
1515
|
+
const previousSha = context.state.lastRelease?.sha;
|
|
1516
|
+
if (githubRepository && previousSha) {
|
|
1517
|
+
lines.push(`${ICON.info} ${paint("bold", "Cambios")} https://github.com/${githubRepository}/compare/${previousSha.slice(0, SHORT_SHA_LENGTH)}...${tag}`);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
for (const line of context.config.summary) {
|
|
1521
|
+
lines.push(`${ICON.info} ${line.replaceAll(SUMMARY_VERSION_PLACEHOLDER, version)}`);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
lines.push("", paint("gray", `Tiempo total: ${formatDuration(measureActiveMs(startedAt))} (sin contar la espera de tus respuestas)`));
|
|
1525
|
+
|
|
1526
|
+
return renderBox({ title: `${ICON.rocket} ${tag} publicado`, lines, tone: BOX_TONE.success });
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Explains, after a failed step, that the release already reached `origin` and only the
|
|
1531
|
+
* publication is missing, so the user knows GitHub has it and npm does not.
|
|
1532
|
+
*
|
|
1533
|
+
* @param {ReleaseContext} context - Release context.
|
|
1534
|
+
* @param {string} remoteUrl - `origin` URL.
|
|
1535
|
+
* @returns {Promise<string | null>} Spanish summary, or `null` when the release tag is not on `origin` or it was published.
|
|
1536
|
+
*/
|
|
1537
|
+
async function describeReleaseOnOrigin(context, remoteUrl) {
|
|
1538
|
+
const version = context.version;
|
|
1539
|
+
|
|
1540
|
+
if (!version || context.published || !context.config.publish) {
|
|
1541
|
+
return null;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const tag = toReleaseTag(version);
|
|
1545
|
+
const tagOnOrigin = context.pushed || Boolean(await context.reader.tryGit(["ls-remote", "--tags", RELEASE_REMOTE, `refs/tags/${tag}`]));
|
|
1546
|
+
|
|
1547
|
+
if (!tagOnOrigin) {
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const host = GITHUB_REPOSITORY_PATTERN.test(remoteUrl) ? "GitHub" : RELEASE_REMOTE;
|
|
1552
|
+
const target = isRegistryProvider(context.config.publish) ? `publicar en ${context.registryLabel ?? context.state.npm?.registryLabel ?? REGISTRY_LABELS[context.config.publish]}` : "publicar el release";
|
|
1553
|
+
|
|
1554
|
+
// A detached release is published from its tag and never pushes `main`.
|
|
1555
|
+
if (!context.state.currentBranch) {
|
|
1556
|
+
return `${tag} ya está en ${host} (tag); falta ${target}. Corré ${context.commands.createVersion} desde ${tag} (HEAD desacoplado) para reintentar solo la publicación.`;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (context.pushed || (await isTagCommitOnRemoteMain(context.reader, tag))) {
|
|
1560
|
+
return `${tag} ya está en ${host} (${MAIN_BRANCH} + tag); falta ${target}. Corré ${context.commands.createVersion} para reintentar solo la publicación.`;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
return `${tag} ya está en ${host} solo como tag: ${MAIN_BRANCH} de ${RELEASE_REMOTE} todavía no tiene el commit del release; faltan subir ${MAIN_BRANCH} y ${target}. Corré ${context.commands.createVersion} para retomar desde el push.`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Tells whether `main` on `origin` already contains the commit a release tag points at. The remote
|
|
1568
|
+
* ref is read with `git ls-remote`, independently of the tag, because a prior or manual push may
|
|
1569
|
+
* have sent only the tag.
|
|
1570
|
+
*
|
|
1571
|
+
* @param {GitReader} reader - Git reader of the repository.
|
|
1572
|
+
* @param {string} tag - Release tag, such as `v1.2.0`.
|
|
1573
|
+
* @returns {Promise<boolean>} `true` when remote `main` includes the tag commit; `false` when it
|
|
1574
|
+
* does not, or when it cannot be decided (no remote `main`, or its commit is not fetched locally).
|
|
1575
|
+
*/
|
|
1576
|
+
async function isTagCommitOnRemoteMain(reader, tag) {
|
|
1577
|
+
const remoteMainLine = await reader.tryGit(["ls-remote", RELEASE_REMOTE, `refs/heads/${MAIN_BRANCH}`]);
|
|
1578
|
+
const remoteMainSha = remoteMainLine?.trim().split(/\s+/u)[0];
|
|
1579
|
+
|
|
1580
|
+
if (!remoteMainSha) {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return (await reader.tryGit(["merge-base", "--is-ancestor", `refs/tags/${tag}^{commit}`, remoteMainSha])) !== null;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Capabilities the plan runs with: a CI preparation leaves the checks, the preparation and the
|
|
1589
|
+
* publication to the worker.
|
|
1590
|
+
*
|
|
1591
|
+
* @param {ResolvedCreateVersionConfig} config - Resolved configuration.
|
|
1592
|
+
* @param {boolean} isCiPreparation - Whether this run only prepares the release for CI.
|
|
1593
|
+
* @param {string} [registryLabel] - Provider discovered from the resolved endpoint.
|
|
1594
|
+
* @returns {import("./plan.js").ReleaseCapabilities} Capabilities for `buildReleasePlan`.
|
|
1595
|
+
*/
|
|
1596
|
+
function describePlannedCapabilities(config, isCiPreparation, registryLabel) {
|
|
1597
|
+
const capabilities = describeReleaseCapabilities(config, registryLabel);
|
|
1598
|
+
return isCiPreparation ? { ...capabilities, checks: false, prepare: false, publish: false } : capabilities;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Rejects a CI preparation before diagnosing or bumping when the worker could never publish the
|
|
1603
|
+
* pushed tag, and checks what the generated workflow needs committed when it is the one in use.
|
|
1604
|
+
*
|
|
1605
|
+
* @param {string} repositoryRoot - Repository root.
|
|
1606
|
+
* @param {ResolvedCreateVersionConfig} config - Configuration with CI settings.
|
|
1607
|
+
* @param {boolean} setsUpGeneratedWorkflow - Whether this run writes the generated workflow.
|
|
1608
|
+
* @returns {Promise<void>}
|
|
1609
|
+
* @throws {ReleaseStepError} When the publication, lockfile or Node.js pin cannot work in the worker.
|
|
1610
|
+
*/
|
|
1611
|
+
async function preflightCiPreparation(repositoryRoot, config, setsUpGeneratedWorkflow) {
|
|
1612
|
+
await assertCiCompatiblePublication(config, repositoryRoot);
|
|
1613
|
+
// An ordinary CI release with the generated workflow committed installs the same way.
|
|
1614
|
+
const usesGeneratedWorkflow = setsUpGeneratedWorkflow || await isCommittedCiWorkflowGenerated(repositoryRoot, config);
|
|
1615
|
+
if (usesGeneratedWorkflow) {
|
|
1616
|
+
await assertCiInstallLockfileCommitted(repositoryRoot, config.commands.packageManager);
|
|
1617
|
+
await assertCiNodeVersionFileCommitted(repositoryRoot);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Dispatches an existing release tag to CI again (`--retry-ci`) without creating another version.
|
|
1623
|
+
*
|
|
1624
|
+
* @param {{
|
|
1625
|
+
* repositoryRoot: string,
|
|
1626
|
+
* reader: GitReader,
|
|
1627
|
+
* config: ResolvedCreateVersionConfig,
|
|
1628
|
+
* options: import("./plan.js").ReleaseOptions,
|
|
1629
|
+
* workflowClient: import("./github-workflow.js").GithubWorkflowClient | undefined,
|
|
1630
|
+
* }} retry - Repository, reader, configuration, options with `retryCi` and the GitHub adapter.
|
|
1631
|
+
* @returns {Promise<number>} Process exit code.
|
|
1632
|
+
*/
|
|
1633
|
+
async function runRetryCi({ repositoryRoot, reader, config, options, workflowClient }) {
|
|
1634
|
+
try {
|
|
1635
|
+
if (!workflowClient) throw new ReleaseStepError("No hay un workflow configurado para reenviar el release.", "Configurá ci.workflow y volvé a ejecutar --retry-ci con el mismo tag.");
|
|
1636
|
+
const release = await readCiReleaseIdentity(reader, /** @type {string} */ (options.retryCi));
|
|
1637
|
+
// The worker runs the workflow and configuration stored in the tag: later commits on main that keep
|
|
1638
|
+
// the version may rename the workflow or change its bindings, so both are resolved from the tag.
|
|
1639
|
+
const releaseConfig = await loadReleaseTagConfig(repositoryRoot, release.tag);
|
|
1640
|
+
if (!releaseConfig.ci) throw new ReleaseStepError(`${release.tag} no tiene ci.workflow en su configuración; ese release no se preparó para CI.`, `Publicá ${release.tag} con --local; no se envió ninguna ejecución.`);
|
|
1641
|
+
const releaseWorkflow = releaseConfig.ci.workflow;
|
|
1642
|
+
if (options.dryRun) { print(`--dry-run: se reenviaría ${release.tag} a ${releaseWorkflow}; no se cambió nada.`); return 0; }
|
|
1643
|
+
const environment = describeCiEnvironment(releaseConfig);
|
|
1644
|
+
await workflowClient.preflight(releaseWorkflow, environment.secrets, environment.variables);
|
|
1645
|
+
const submitted = await workflowClient.dispatch(releaseWorkflow, release, describeCiRetryCommand(config.commands, release.tag));
|
|
1646
|
+
print(`${release.tag} ${submitted.status === CI_DISPATCH_STATUS.existing ? "ya tiene una ejecución activa o exitosa" : "enviado a CI"}: ${submitted.url ?? "revisá Actions"}. No se creó otra versión.`);
|
|
1647
|
+
return 0;
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
printFailure(error);
|
|
1650
|
+
return FAILURE_EXIT_CODE;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* Checks, before a resume pushes or publishes a release commit that already exists, that it can go on
|
|
1656
|
+
* as committed: in the execution mode it was prepared for (local runs only), and with `versionFiles`
|
|
1657
|
+
* and the JSR manifest of `HEAD` already carrying the pending version, since a resume never rewrites them.
|
|
1658
|
+
*
|
|
1659
|
+
* @param {{ repositoryRoot: string, config: ResolvedCreateVersionConfig, reader: GitReader, options: import("./plan.js").ReleaseOptions }} resume - Release being resumed.
|
|
1660
|
+
* @param {string} pendingVersion - Version of the pending release.
|
|
1661
|
+
* @param {"local" | "ci"} execution - Execution location chosen for this run.
|
|
1662
|
+
* @returns {Promise<void>}
|
|
1663
|
+
* @throws {ReleaseStepError} When the mode differs or a versioned file does not carry the pending version.
|
|
1664
|
+
*/
|
|
1665
|
+
async function verifyResumeReady({ repositoryRoot, config, reader, options }, pendingVersion, execution) {
|
|
1666
|
+
// The worker always runs the release committed for CI; local runs must resume in the committed mode.
|
|
1667
|
+
if (!options.ciRelease) await assertResumeExecutionMatches(reader, pendingVersion, execution);
|
|
1668
|
+
await verifyReleasedVersionFiles({ repositoryRoot, config, reader, commands: config.commands }, pendingVersion);
|
|
1669
|
+
if (config.publish === JSR_REGISTRY_PROVIDER && readJsrManifest(repositoryRoot, config.publication.configFile).manifest.version !== pendingVersion) {
|
|
1670
|
+
throw new ReleaseStepError("El manifest JSR no coincide con la versión del release pendiente.", "Corregilo dentro del commit de release antes de subir o publicar; no se reescribió ningún archivo.");
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Runs `create-version` in a repository.
|
|
1676
|
+
*
|
|
1677
|
+
* @param {{ repositoryRoot: string, argv: string[] }} options - Repository root and arguments after the command name.
|
|
1678
|
+
* @returns {Promise<number>} Process exit code.
|
|
1679
|
+
*/
|
|
1680
|
+
export async function runCreateVersion({ repositoryRoot, argv }) {
|
|
1681
|
+
const startedAt = Date.now();
|
|
1682
|
+
// Usage and argument errors are printed before the configuration loads.
|
|
1683
|
+
const usage = buildReleaseUsage(describeProjectCommands(detectPackageManager(repositoryRoot)));
|
|
1684
|
+
/** @type {import("./plan.js").ReleaseOptions} */
|
|
1685
|
+
let options;
|
|
1686
|
+
|
|
1687
|
+
try {
|
|
1688
|
+
options = parseReleaseArguments(argv);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
print(`${ICON.failure} ${paint("red", describeError(error))}`);
|
|
1691
|
+
print(usage);
|
|
1692
|
+
return FAILURE_EXIT_CODE;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (options.help) {
|
|
1696
|
+
print(usage);
|
|
1697
|
+
return 0;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
let config;
|
|
1701
|
+
|
|
1702
|
+
try {
|
|
1703
|
+
config = await loadCreateVersionConfig(repositoryRoot);
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
print(`${ICON.failure} ${paint("red", describeError(error))}`);
|
|
1706
|
+
return FAILURE_EXIT_CODE;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (config.packages) {
|
|
1710
|
+
if ((config.ci && options.execution !== RELEASE_EXECUTION.local) || options.execution === RELEASE_EXECUTION.ci || options.setupCi || options.ciRelease || options.retryCi) {
|
|
1711
|
+
print(`${ICON.failure} CI por tag vX.Y.Z requiere un proyecto con una única versión; para monorepos usá --local sin ci configurado.`);
|
|
1712
|
+
return FAILURE_EXIT_CODE;
|
|
1713
|
+
}
|
|
1714
|
+
// Imported lazily: the monorepo mode imports this module for the steps it shares.
|
|
1715
|
+
const { runMonorepoCreateVersion } = await import("../monorepo/run.js");
|
|
1716
|
+
return runMonorepoCreateVersion({ repositoryRoot, config, options, startedAt });
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const ciWasConfigured = config.ci !== null;
|
|
1720
|
+
let selection;
|
|
1721
|
+
try {
|
|
1722
|
+
selection = await chooseReleaseExecution(config, options);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
printFailure(error);
|
|
1725
|
+
return FAILURE_EXIT_CODE;
|
|
1726
|
+
}
|
|
1727
|
+
if (!selection) return 0;
|
|
1728
|
+
if (selection.setup && !config.ci) config = { ...config, ci: defaultCiReleaseConfig(repositoryRoot, config) };
|
|
1729
|
+
// Setup either writes the generated workflow or keeps an existing one that still matches it; both install from the committed lockfile.
|
|
1730
|
+
const setsUpGeneratedWorkflow = selection.setup;
|
|
1731
|
+
if (selection.setup && ciWasConfigured && config.ci && existsSync(path.join(repositoryRoot, ciWorkflowPath(config.ci.workflow)))) {
|
|
1732
|
+
// An existing workflow turns setup into an ordinary CI release only while it still matches the current configuration.
|
|
1733
|
+
try {
|
|
1734
|
+
await assertGeneratedCiWorkflowCurrent(repositoryRoot, config);
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
printFailure(error);
|
|
1737
|
+
return FAILURE_EXIT_CODE;
|
|
1738
|
+
}
|
|
1739
|
+
selection = { ...selection, setup: false };
|
|
1740
|
+
}
|
|
1741
|
+
const isCiPreparation = selection.execution === RELEASE_EXECUTION.ci;
|
|
1742
|
+
// Rejected before diagnosing or bumping: a pushed tag could never be published by a non-interactive worker.
|
|
1743
|
+
// A retry skips these checks: the worker reruns the configuration, workflow and files stored in the
|
|
1744
|
+
// tag, which passed them when the tag was prepared, while the working tree may have changed since.
|
|
1745
|
+
if (isCiPreparation && !options.retryCi) {
|
|
1746
|
+
try {
|
|
1747
|
+
await preflightCiPreparation(repositoryRoot, config, setsUpGeneratedWorkflow);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
printFailure(error);
|
|
1750
|
+
return FAILURE_EXIT_CODE;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const workflowClient = isCiPreparation ? createGithubWorkflowClient(repositoryRoot) : undefined;
|
|
1754
|
+
|
|
1755
|
+
const reader = createGitReader(repositoryRoot);
|
|
1756
|
+
const remoteUrl = (await reader.tryGit(["remote", "get-url", RELEASE_REMOTE])) ?? "";
|
|
1757
|
+
const { migrations } = config;
|
|
1758
|
+
let capabilities = describePlannedCapabilities(config, isCiPreparation);
|
|
1759
|
+
// The worker reloads these files from the release tag: setting their changes aside would preflight another configuration.
|
|
1760
|
+
const ciWorkerFiles = isCiPreparation && config.ci ? [...CI_WORKER_CONFIGURATION_FILES, ciWorkflowPath(config.ci.workflow)] : [];
|
|
1761
|
+
const planOptions = { skipUnpublished: options.skipUnpublished, ignoreLocalChanges: options.ignoreLocalChanges, ciWorkerFiles };
|
|
1762
|
+
/**
|
|
1763
|
+
* Diagnoses the repository: once at the start and again after drafting the changelog, whose new
|
|
1764
|
+
* entries can unblock steps (such as the npm credential check) the first plan did not reach.
|
|
1765
|
+
*
|
|
1766
|
+
* @param {ReturnType<typeof startSpinner>} spinner - Spinner that shows the progress.
|
|
1767
|
+
* @returns {Promise<ReleaseSnapshot>} Snapshot.
|
|
1768
|
+
*/
|
|
1769
|
+
const diagnose = (spinner) =>
|
|
1770
|
+
collectReleaseState({
|
|
1771
|
+
repositoryRoot,
|
|
1772
|
+
// CI preparation still reads the registry: the plan must block (or require --skip-unpublished)
|
|
1773
|
+
// when the last release is missing, before an immutable tag is pushed for the worker.
|
|
1774
|
+
trackNpm: config.registry !== null,
|
|
1775
|
+
registrySelection: selectProjectRegistry(config),
|
|
1776
|
+
checkMigrations: !isCiPreparation && migrations ? () => migrations.check(createHookContext(repositoryRoot, reader, null)) : null,
|
|
1777
|
+
// The credentials are checked when the plan would publish to npm, and also when the npm lookup
|
|
1778
|
+
// failed: an authenticated `npm view` rejected with E401/E403 means the token, not the connection, is wrong.
|
|
1779
|
+
checkNpmAuth: (snapshot) =>
|
|
1780
|
+
!isCiPreparation && isRegistryProvider(config.publish) &&
|
|
1781
|
+
// Planned as if local changes were ignored: the run may still offer to ignore them, and
|
|
1782
|
+
// that plan must not publish with unchecked credentials.
|
|
1783
|
+
(Boolean(options.ciRelease && !snapshot.npm?.publishedVersions.includes(snapshot.headVersion ?? "")) || hasFailedNpmLookup(snapshot) ||
|
|
1784
|
+
buildReleasePlan(snapshot, capabilities, { ...planOptions, ignoreLocalChanges: true }).steps.some((planStep) => planStep.id === RELEASE_STEP.publishRelease)),
|
|
1785
|
+
onProgress: (label) => spinner.update(label),
|
|
1786
|
+
});
|
|
1787
|
+
let state;
|
|
1788
|
+
const spinner = startSpinner("Diagnosticando el repositorio");
|
|
1789
|
+
|
|
1790
|
+
try {
|
|
1791
|
+
state = await diagnose(spinner);
|
|
1792
|
+
spinner.succeed("Diagnóstico completo");
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
spinner.fail("No se pudo diagnosticar el repositorio");
|
|
1795
|
+
print(paint("red", describeError(error)));
|
|
1796
|
+
return FAILURE_EXIT_CODE;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const latestPublished = state.npm?.publishedVersions.at(-1);
|
|
1800
|
+
capabilities = describePlannedCapabilities(config, isCiPreparation, state.npm?.registryLabel);
|
|
1801
|
+
const publishedLabel = state.npm
|
|
1802
|
+
? latestPublished ? `v${latestPublished} en ${state.npm.registryLabel ?? "npm"}` : null
|
|
1803
|
+
: state.releasedVersion ? `v${state.releasedVersion} ${config.publishedLabel}` : null;
|
|
1804
|
+
print(renderBanner({ projectName: config.projectName ?? state.packageName, publishedLabel }));
|
|
1805
|
+
print(renderDiagnosis(state, repositoryRoot));
|
|
1806
|
+
|
|
1807
|
+
let plan = buildReleasePlan(state, capabilities, planOptions);
|
|
1808
|
+
if (options.ciRelease) {
|
|
1809
|
+
try {
|
|
1810
|
+
const release = await readCiReleaseIdentity(reader, options.ciRelease, true);
|
|
1811
|
+
state.lastRelease = await findLastRelease(reader, "HEAD^");
|
|
1812
|
+
// A retry of a failed deployment, or a duplicate dispatch queued behind the concurrency group, must not
|
|
1813
|
+
// repeat a custom publisher that already finished: the registry cannot prove it.
|
|
1814
|
+
const completedPublication = config.ci && requiresCiPublicationHistory(config) ? await findCompletedCiPublication(config.ci.workflow, release) : null;
|
|
1815
|
+
if (completedPublication) print(`${ICON.info} El publisher personalizado de ${release.tag} ya terminó en el intento ${completedPublication.attempt} de ${completedPublication.url}; no se repite.`);
|
|
1816
|
+
plan = buildCiWorkerPlan(state, capabilities, release.version, config.ci?.deployment === CI_VERCEL_DEPLOYMENT, completedPublication !== null);
|
|
1817
|
+
plan.steps.unshift({ id: RELEASE_STEP.verifyChangelog, title: "Verificar el CHANGELOG del release recibido" });
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
printFailure(error);
|
|
1820
|
+
return FAILURE_EXIT_CODE;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (options.retryCi) {
|
|
1825
|
+
return runRetryCi({ repositoryRoot, reader, config, options, workflowClient });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Uncommitted changes are the only blocker when ignoring them unblocks the plan: an interactive
|
|
1829
|
+
// run asks instead of stopping (a dry run, or one without terminal, keeps the blocker and its hint).
|
|
1830
|
+
if (!options.ciRelease && plan.blockers.length > 0 && !options.ignoreLocalChanges && !options.dryRun && process.stdin.isTTY) {
|
|
1831
|
+
const planIgnoringChanges = buildReleasePlan(state, capabilities, { ...planOptions, ignoreLocalChanges: true });
|
|
1832
|
+
|
|
1833
|
+
if (planIgnoringChanges.blockers.length === 0 && planIgnoringChanges.steps.length > 0) {
|
|
1834
|
+
if (!(await askToIgnoreLocalChanges(listLocalChangesToSetAside(state, planIgnoringChanges.mode)))) {
|
|
1835
|
+
print(`${ICON.info} Release cancelado: no se tocó nada. Commiteá o guardá los cambios y volvé a correr ${config.commands.createVersion}.`);
|
|
1836
|
+
return 0;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
options = { ...options, ignoreLocalChanges: true };
|
|
1840
|
+
plan = planIgnoringChanges;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (canDraftChangelog(plan, state, config, options)) {
|
|
1844
|
+
if (options.dryRun) {
|
|
1845
|
+
print(`${ICON.info} --dry-run: sin --dry-run, ${CHANGELOG_FILE} se redactaría con ${config.changelogDraft.map(describeChangelogDraftProvider).join(" o ")} antes de cortar.`);
|
|
1846
|
+
} else if (await draftMissingChangelog(repositoryRoot, config, state)) {
|
|
1847
|
+
const rediagnosisSpinner = startSpinner(`Diagnosticando de nuevo con el ${CHANGELOG_FILE} redactado`);
|
|
1848
|
+
try {
|
|
1849
|
+
state = await diagnose(rediagnosisSpinner);
|
|
1850
|
+
rediagnosisSpinner.succeed("Diagnóstico completo");
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
rediagnosisSpinner.fail("No se pudo diagnosticar el repositorio");
|
|
1853
|
+
print(paint("red", describeError(error)));
|
|
1854
|
+
return FAILURE_EXIT_CODE;
|
|
1855
|
+
}
|
|
1856
|
+
capabilities = describePlannedCapabilities(config, isCiPreparation, state.npm?.registryLabel);
|
|
1857
|
+
plan = buildReleasePlan(state, capabilities, { ...planOptions, ignoreLocalChanges: Boolean(options.ignoreLocalChanges) });
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (isCiPreparation) plan = appendCiDispatch(plan);
|
|
1861
|
+
|
|
1862
|
+
if (plan.mode === RELEASE_MODE.upToDate) {
|
|
1863
|
+
const since = state.lastRelease?.version ? toReleaseTag(state.lastRelease.version) : "el inicio";
|
|
1864
|
+
print(renderBox({ title: "Todo al día", lines: [`${ICON.success} No hay nada nuevo para publicar desde ${since}.`], tone: BOX_TONE.success }));
|
|
1865
|
+
return 0;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (plan.mode === RELEASE_MODE.newRelease) {
|
|
1869
|
+
print(renderCommitList(state.unreleasedCommits, "Commits sin publicar"));
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
print(renderPlan(plan));
|
|
1873
|
+
|
|
1874
|
+
// A missing manual changelog update must fail CI; other diagnostic blockers retain their exit behavior.
|
|
1875
|
+
if (plan.blockers.length > 0) {
|
|
1876
|
+
return isCiPreparation || options.ciRelease || plan.blockers.some((blocker) => blocker.code === CHANGELOG_UPDATE_REQUIRED_CODE) ? FAILURE_EXIT_CODE : 0;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
if (plan.mode === RELEASE_MODE.newRelease && state.headVersion) {
|
|
1880
|
+
try {
|
|
1881
|
+
resolveRequestedVersion(state.headVersion, options);
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
print(`${ICON.failure} ${paint("red", describeError(error))}`);
|
|
1884
|
+
return FAILURE_EXIT_CODE;
|
|
1885
|
+
}
|
|
1886
|
+
} else if (plan.mode === RELEASE_MODE.resume && (options.bump || options.setVersion)) {
|
|
1887
|
+
print(`${ICON.warning} ${paint("yellow", `Se ignoran --bump y --set-version: se retoma ${plan.pendingVersion}, que ya tiene versión y CHANGELOG.`)}`);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// A resume never rewrites versionFiles: before pushing or publishing, HEAD must already carry the pending version.
|
|
1891
|
+
if (plan.mode === RELEASE_MODE.resume && plan.pendingVersion) {
|
|
1892
|
+
try {
|
|
1893
|
+
await verifyResumeReady({ repositoryRoot, config, reader, options }, plan.pendingVersion, selection.execution);
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
printFailure(error, { red: true });
|
|
1896
|
+
return FAILURE_EXIT_CODE;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
if (options.dryRun) {
|
|
1901
|
+
print(`${ICON.info} ${paint("cyan", `--dry-run: no se cambió nada. Corré ${config.commands.createVersion} para ejecutar el plan.`)}`);
|
|
1902
|
+
return 0;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// The version prompt has no default, so it cannot answer itself without a terminal: fail before
|
|
1906
|
+
// any step runs instead of after the checks.
|
|
1907
|
+
if (plan.steps.some((planStep) => planStep.id === RELEASE_STEP.bumpVersion) && !options.bump && !options.setVersion && !options.acceptSuggested && !process.stdin.isTTY) {
|
|
1908
|
+
print(`${ICON.failure} ${paint("red", `Sin terminal interactiva no se puede elegir la versión: usá --${CREATE_VERSION_FLAG.bump} patch|minor|major, --${CREATE_VERSION_FLAG.setVersion} X.Y.Z o --${CREATE_VERSION_FLAG.acceptSuggested}.`)}`);
|
|
1909
|
+
return FAILURE_EXIT_CODE;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/** @type {ReleaseFileUpdate[]} */
|
|
1913
|
+
let ciSetupFiles = [];
|
|
1914
|
+
if (isCiPreparation && config.ci && workflowClient) {
|
|
1915
|
+
try {
|
|
1916
|
+
if (selection.setup && plan.mode !== RELEASE_MODE.newRelease) throw new ReleaseStepError("La configuración automática de CI requiere un release nuevo.", "Completá el release pendiente con --local y después ejecutá --setup-ci.");
|
|
1917
|
+
if (selection.setup) ciSetupFiles = await prepareCiSetupFiles(repositoryRoot, config, !ciWasConfigured);
|
|
1918
|
+
// The release commit is built from HEAD and dispatched with --ref <tag>: the workflow must already be committed.
|
|
1919
|
+
else await readCommittedCiWorkflow(repositoryRoot, config.ci.workflow);
|
|
1920
|
+
const environment = describeCiEnvironment(config);
|
|
1921
|
+
await workflowClient.preflight(config.ci.workflow, environment.secrets, environment.variables);
|
|
1922
|
+
if (config.migrations && environment.secrets.length === 0) print(`${ICON.warning} El worker necesita las variables de las migraciones: declaralas en ci.secrets o ci.variables y configurá Actions antes de publicar.`);
|
|
1923
|
+
print(`${ICON.info} CI ejecutará los checks y hooks; la preparación local omite los hooks de Git.`);
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
printFailure(error);
|
|
1926
|
+
return FAILURE_EXIT_CODE;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** @type {ReleaseContext} */
|
|
1931
|
+
const context = {
|
|
1932
|
+
repositoryRoot,
|
|
1933
|
+
config,
|
|
1934
|
+
state,
|
|
1935
|
+
options,
|
|
1936
|
+
reader,
|
|
1937
|
+
version: plan.pendingVersion,
|
|
1938
|
+
pushed: false,
|
|
1939
|
+
published: false,
|
|
1940
|
+
commitCount: null,
|
|
1941
|
+
packageName: state.packageName,
|
|
1942
|
+
registryUrl: null,
|
|
1943
|
+
registryLabel: state.npm?.registryLabel ?? null,
|
|
1944
|
+
commands: config.commands,
|
|
1945
|
+
changelogFiles: [],
|
|
1946
|
+
execution: selection.execution,
|
|
1947
|
+
ciWorker: Boolean(options.ciRelease),
|
|
1948
|
+
ciSetupFiles,
|
|
1949
|
+
workflowClient,
|
|
1950
|
+
};
|
|
1951
|
+
const changesToSetAside = options.ignoreLocalChanges ? listLocalChangesToSetAside(state, plan.mode) : [];
|
|
1952
|
+
|
|
1953
|
+
return withLocalChangesSetAside(
|
|
1954
|
+
{
|
|
1955
|
+
reader,
|
|
1956
|
+
repositoryRoot,
|
|
1957
|
+
changeCount: changesToSetAside.length,
|
|
1958
|
+
setAsideOptions: { keepChangelog: plan.mode === RELEASE_MODE.newRelease, createVersionCommand: config.commands.createVersion },
|
|
1959
|
+
messages: {
|
|
1960
|
+
setAside: "cambio(s) sin commitear apartados con git stash; al terminar vuelven igual: lo staged staged y el resto sin stagear.",
|
|
1961
|
+
restored: "Cambios sin commitear restaurados como estaban: lo staged sigue staged y el resto sin stagear.",
|
|
1962
|
+
recovery: "cuando lo resuelvas, recuperalos con git stash pop --index.",
|
|
1963
|
+
},
|
|
1964
|
+
},
|
|
1965
|
+
() => runPlanSteps(context, {
|
|
1966
|
+
steps: plan.steps,
|
|
1967
|
+
executors: STEP_EXECUTORS,
|
|
1968
|
+
untouchedNote: "No se tocó la versión ni los tags.",
|
|
1969
|
+
describeFailureFollowUp: (failedContext) => describeReleaseFailureFollowUp(failedContext, remoteUrl),
|
|
1970
|
+
renderSummary: (finishedContext) => (finishedContext.version && (finishedContext.pushed || finishedContext.published) ? renderReleaseSummary(finishedContext, remoteUrl, startedAt) : null),
|
|
1971
|
+
}, startedAt)
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Closes the failure box of a step: where the release stands and how to go on without another bump.
|
|
1977
|
+
*
|
|
1978
|
+
* @param {ReleaseContext} context - Release context.
|
|
1979
|
+
* @param {string} remoteUrl - URL of the release remote.
|
|
1980
|
+
* @returns {Promise<string[]>} Lines appended after the failure and its hint.
|
|
1981
|
+
*/
|
|
1982
|
+
async function describeReleaseFailureFollowUp(context, remoteUrl) {
|
|
1983
|
+
const releaseOnOrigin = await describeReleaseOnOrigin(context, remoteUrl);
|
|
1984
|
+
return ["", context.execution === RELEASE_EXECUTION.ci && context.version && context.pushed
|
|
1985
|
+
? `${ICON.warning} ${toReleaseTag(context.version)} ya está en origin. Revisá Actions y reenviá el mismo release con ${describeCiRetryCommand(context.commands, toReleaseTag(context.version))}; no hagas otro bump.`
|
|
1986
|
+
: context.ciWorker && context.version ? `${ICON.warning} ${toReleaseTag(context.version)} ya está en origin. Corregí el paso y reintentá el workflow de este mismo tag; no se hizo otro bump ni push.`
|
|
1987
|
+
: releaseOnOrigin ? `${ICON.warning} ${paint("bold", releaseOnOrigin)}` : paint("gray", `${context.commands.createVersion} retoma desde el primer paso que falte.`)];
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Sets the uncommitted changes aside while an operation runs and restores them afterwards, whatever
|
|
1992
|
+
* its outcome; shared with the monorepo mode.
|
|
1993
|
+
*
|
|
1994
|
+
* @param {{
|
|
1995
|
+
* reader: GitReader,
|
|
1996
|
+
* repositoryRoot: string,
|
|
1997
|
+
* changeCount: number,
|
|
1998
|
+
* setAsideOptions: Parameters<typeof setAsideLocalChanges>[1],
|
|
1999
|
+
* messages: { setAside: string, restored: string, recovery: string },
|
|
2000
|
+
* }} localChanges - Repository, how many changes to set aside (none skips it), the options of
|
|
2001
|
+
* {@link setAsideLocalChanges} and the Spanish messages printed after setting aside, after
|
|
2002
|
+
* restoring and when the restore fails.
|
|
2003
|
+
* @param {() => Promise<number>} operation - Work run with the changes set aside.
|
|
2004
|
+
* @returns {Promise<number>} Exit code of the operation, or a failure when the changes could not be set aside.
|
|
2005
|
+
*/
|
|
2006
|
+
export async function withLocalChangesSetAside({ reader, repositoryRoot, changeCount, setAsideOptions, messages }, operation) {
|
|
2007
|
+
let setAside = null;
|
|
2008
|
+
|
|
2009
|
+
if (changeCount > 0) {
|
|
2010
|
+
try {
|
|
2011
|
+
setAside = await setAsideLocalChanges(reader, setAsideOptions);
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
printFailure(error, { red: true });
|
|
2014
|
+
return FAILURE_EXIT_CODE;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
print(`${ICON.info} ${changeCount} ${messages.setAside}`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
try {
|
|
2021
|
+
return await operation();
|
|
2022
|
+
} finally {
|
|
2023
|
+
if (setAside) {
|
|
2024
|
+
const restore = await restoreLocalChanges(reader, repositoryRoot, setAside);
|
|
2025
|
+
print(
|
|
2026
|
+
restore.restored
|
|
2027
|
+
? `${ICON.success} ${messages.restored}`
|
|
2028
|
+
: `${ICON.warning} ${paint("yellow", `No se pudieron restaurar los cambios sin commitear (${restore.reason}): ${messages.recovery}`)}`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Runs the plan steps in order and prints the outcome; shared with the monorepo mode.
|
|
2036
|
+
*
|
|
2037
|
+
* @template {{ commands: import("../package-manager.js").ProjectCommands }} Context
|
|
2038
|
+
* @param {Context} context - Release context passed to every step.
|
|
2039
|
+
* @param {{
|
|
2040
|
+
* steps: import("./plan.js").ReleasePlanStep[],
|
|
2041
|
+
* executors: Record<string, (context: Context) => Promise<void>>,
|
|
2042
|
+
* untouchedNote: string,
|
|
2043
|
+
* describeFailureFollowUp: (context: Context) => Promise<string[]>,
|
|
2044
|
+
* renderSummary: (context: Context) => string | null,
|
|
2045
|
+
* }} planRun - Steps and their executors, what a restart after syncing `main` left untouched, the lines
|
|
2046
|
+
* that close a failure box, and the closing summary (`null` for the generic one).
|
|
2047
|
+
* @param {number} startedAt - Start time of the command, in milliseconds.
|
|
2048
|
+
* @returns {Promise<number>} Process exit code.
|
|
2049
|
+
*/
|
|
2050
|
+
export async function runPlanSteps(context, { steps, executors, untouchedNote, describeFailureFollowUp, renderSummary }, startedAt) {
|
|
2051
|
+
for (const [index, planStep] of steps.entries()) {
|
|
2052
|
+
print(renderStepHeader(index + 1, steps.length, planStep.title));
|
|
2053
|
+
|
|
2054
|
+
try {
|
|
2055
|
+
await executors[planStep.id](context);
|
|
2056
|
+
} catch (error) {
|
|
2057
|
+
if (error instanceof ReleaseCancelledError) {
|
|
2058
|
+
return 0;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Not a failure: the run ends on purpose so the next one diagnoses the updated main.
|
|
2062
|
+
if (error instanceof MainSyncedRestartError) {
|
|
2063
|
+
print(
|
|
2064
|
+
renderBox({
|
|
2065
|
+
title: `${MAIN_BRANCH} actualizado`,
|
|
2066
|
+
lines: [`${ICON.info} ${buildMainSyncedRestartMessage(context.commands.createVersion)}`, "", paint("gray", untouchedNote)],
|
|
2067
|
+
tone: BOX_TONE.info,
|
|
2068
|
+
})
|
|
2069
|
+
);
|
|
2070
|
+
return 0;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
const lines = [`${ICON.failure} ${describeError(error)}`];
|
|
2074
|
+
if (error instanceof ReleaseStepError) {
|
|
2075
|
+
lines.push("", `${paint("bold", "Qué hacer:")} ${error.hint}`);
|
|
2076
|
+
}
|
|
2077
|
+
lines.push(...(await describeFailureFollowUp(context)));
|
|
2078
|
+
print(renderBox({ title: `Falló el paso ${index + 1}: ${planStep.title}`, lines, tone: BOX_TONE.danger }));
|
|
2079
|
+
return FAILURE_EXIT_CODE;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
print("");
|
|
2084
|
+
print(renderSummary(context) ?? renderBox({ title: "Listo", lines: [`${ICON.success} Plan completado en ${formatDuration(measureActiveMs(startedAt))}.`], tone: BOX_TONE.success }));
|
|
2085
|
+
|
|
2086
|
+
return 0;
|
|
2087
|
+
}
|