@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +352 -2
  4. package/bin/astra-rp.js +51 -0
  5. package/package.json +93 -4
  6. package/src/build-gate.js +157 -0
  7. package/src/changelog-blocks.js +53 -0
  8. package/src/changelog.js +108 -0
  9. package/src/constants/build-gate.js +30 -0
  10. package/src/constants/changelog-draft.js +180 -0
  11. package/src/constants/changelog.js +74 -0
  12. package/src/constants/ci-release.js +346 -0
  13. package/src/constants/cli.js +12 -0
  14. package/src/constants/create-version.js +523 -0
  15. package/src/constants/guard-publish.js +29 -0
  16. package/src/constants/index.js +18 -0
  17. package/src/constants/monorepo.js +57 -0
  18. package/src/constants/package-manager.js +56 -0
  19. package/src/constants/registry.js +72 -0
  20. package/src/constants/terminal-ui.js +93 -0
  21. package/src/constants/version-files.js +63 -0
  22. package/src/constants/versions.js +60 -0
  23. package/src/create-version/artifact.js +343 -0
  24. package/src/create-version/changelog-draft-workspace.js +124 -0
  25. package/src/create-version/changelog-draft.js +380 -0
  26. package/src/create-version/changelog.js +264 -0
  27. package/src/create-version/ci-config.js +54 -0
  28. package/src/create-version/ci-publication-history.js +113 -0
  29. package/src/create-version/ci-release-identity-step.js +58 -0
  30. package/src/create-version/ci-setup.js +794 -0
  31. package/src/create-version/ci.js +555 -0
  32. package/src/create-version/committed-tree.js +75 -0
  33. package/src/create-version/config.js +489 -0
  34. package/src/create-version/errors.js +46 -0
  35. package/src/create-version/github-workflow.js +199 -0
  36. package/src/create-version/index.js +54 -0
  37. package/src/create-version/json.js +32 -0
  38. package/src/create-version/jsonc.js +76 -0
  39. package/src/create-version/jsr.js +96 -0
  40. package/src/create-version/local-changes.js +178 -0
  41. package/src/create-version/npm-auth.js +142 -0
  42. package/src/create-version/npm.js +812 -0
  43. package/src/create-version/plan.js +1200 -0
  44. package/src/create-version/process.js +334 -0
  45. package/src/create-version/registry-config.js +121 -0
  46. package/src/create-version/registry-http.js +62 -0
  47. package/src/create-version/registry.js +220 -0
  48. package/src/create-version/release-execution.js +45 -0
  49. package/src/create-version/release-tag-config.js +76 -0
  50. package/src/create-version/run.js +2087 -0
  51. package/src/create-version/state.js +461 -0
  52. package/src/create-version/strict-utf8.js +11 -0
  53. package/src/create-version/templates/release.yml +52 -0
  54. package/src/create-version/templates/vercel-ignore-build.mjs +66 -0
  55. package/src/guard-publish.js +77 -0
  56. package/src/index.js +42 -0
  57. package/src/monorepo/plan.js +475 -0
  58. package/src/monorepo/release-commit.js +46 -0
  59. package/src/monorepo/run.js +927 -0
  60. package/src/monorepo/state.js +206 -0
  61. package/src/monorepo/workspaces.js +327 -0
  62. package/src/package-manager.js +107 -0
  63. package/src/terminal-ui.js +548 -0
  64. package/src/testing.js +70 -0
  65. package/src/version-files.js +142 -0
  66. package/src/versions.js +303 -0
  67. package/types/build-gate.d.ts +43 -0
  68. package/types/changelog-blocks.d.ts +33 -0
  69. package/types/changelog.d.ts +61 -0
  70. package/types/constants/build-gate.d.ts +23 -0
  71. package/types/constants/changelog-draft.d.ts +138 -0
  72. package/types/constants/changelog.d.ts +56 -0
  73. package/types/constants/ci-release.d.ts +284 -0
  74. package/types/constants/cli.d.ts +11 -0
  75. package/types/constants/create-version.d.ts +397 -0
  76. package/types/constants/guard-publish.d.ts +25 -0
  77. package/types/constants/index.d.ts +17 -0
  78. package/types/constants/monorepo.d.ts +45 -0
  79. package/types/constants/package-manager.d.ts +40 -0
  80. package/types/constants/registry.d.ts +79 -0
  81. package/types/constants/terminal-ui.d.ts +73 -0
  82. package/types/constants/version-files.d.ts +51 -0
  83. package/types/constants/versions.d.ts +47 -0
  84. package/types/create-version/artifact.d.ts +113 -0
  85. package/types/create-version/changelog-draft-workspace.d.ts +33 -0
  86. package/types/create-version/changelog-draft.d.ts +127 -0
  87. package/types/create-version/changelog.d.ts +141 -0
  88. package/types/create-version/ci-config.d.ts +33 -0
  89. package/types/create-version/ci-publication-history.d.ts +51 -0
  90. package/types/create-version/ci-release-identity-step.d.ts +12 -0
  91. package/types/create-version/ci-setup.d.ts +148 -0
  92. package/types/create-version/ci.d.ts +145 -0
  93. package/types/create-version/committed-tree.d.ts +55 -0
  94. package/types/create-version/config.d.ts +128 -0
  95. package/types/create-version/errors.d.ts +39 -0
  96. package/types/create-version/github-workflow.d.ts +41 -0
  97. package/types/create-version/index.d.ts +28 -0
  98. package/types/create-version/json.d.ts +21 -0
  99. package/types/create-version/jsonc.d.ts +21 -0
  100. package/types/create-version/jsr.d.ts +52 -0
  101. package/types/create-version/local-changes.d.ts +52 -0
  102. package/types/create-version/npm-auth.d.ts +60 -0
  103. package/types/create-version/npm.d.ts +331 -0
  104. package/types/create-version/plan.d.ts +369 -0
  105. package/types/create-version/process.d.ts +151 -0
  106. package/types/create-version/registry-config.d.ts +56 -0
  107. package/types/create-version/registry-http.d.ts +22 -0
  108. package/types/create-version/registry.d.ts +86 -0
  109. package/types/create-version/release-execution.d.ts +22 -0
  110. package/types/create-version/release-tag-config.d.ts +18 -0
  111. package/types/create-version/run.d.ts +407 -0
  112. package/types/create-version/state.d.ts +204 -0
  113. package/types/create-version/strict-utf8.d.ts +10 -0
  114. package/types/guard-publish.d.ts +49 -0
  115. package/types/index.d.ts +23 -0
  116. package/types/monorepo/plan.d.ts +68 -0
  117. package/types/monorepo/release-commit.d.ts +29 -0
  118. package/types/monorepo/run.d.ts +68 -0
  119. package/types/monorepo/state.d.ts +96 -0
  120. package/types/monorepo/workspaces.d.ts +86 -0
  121. package/types/package-manager.d.ts +58 -0
  122. package/types/terminal-ui.d.ts +196 -0
  123. package/types/testing.d.ts +31 -0
  124. package/types/version-files.d.ts +59 -0
  125. package/types/versions.d.ts +158 -0
@@ -0,0 +1,1200 @@
1
+ /**
2
+ * Pure release planning of `astra-rp create-version`.
3
+ *
4
+ * Receives the snapshot gathered by `state.js` and the project capabilities,
5
+ * and decides without touching Git, npm or databases what is still missing
6
+ * to ship a release from `main`:
7
+ *
8
+ * - a new release: sync `main`, verify the manual changelog and its `[Unreleased]` entries, apply migrations, run the
9
+ * checks, bump (commit `X.Y.Z` + tag `vX.Y.Z`), prepare, push and publish;
10
+ * - the resume of a release commit that never reached `origin` or the registry,
11
+ * including the publication of a tagged release from a detached `HEAD`;
12
+ * - nothing, when every commit is already released;
13
+ * - blockers that explain what to fix first (feature branch, uncommitted
14
+ * files, foreign commits on `main`, unreachable registry, invalid changelog,
15
+ * npm credentials that cannot publish, a last release missing from npm).
16
+ *
17
+ * @module create-version/plan
18
+ */
19
+
20
+ import { parseArgs } from "node:util";
21
+ import { RELEASE_EXECUTION } from "../constants/ci-release.js";
22
+
23
+ import { CHANGELOG_FILE, CHANGELOG_UPDATE_REQUIRED_CODE } from "../constants/changelog.js";
24
+ import {
25
+ CREATE_VERSION_CONFIG_FILES,
26
+ CREATE_VERSION_FLAG,
27
+ DEFAULT_CHECKS_SCRIPT,
28
+ MAIN_BRANCH,
29
+ MAX_LISTED_ITEMS,
30
+ MIGRATION_STATUS,
31
+ NPM_AUTH_STATUS,
32
+ NPM_DIST_TAG,
33
+ NPM_LOOKUP_STATUS,
34
+ PORCELAIN_RENAME_SEPARATOR,
35
+ PORCELAIN_STATUS_WIDTH,
36
+ PROJECT_NPM_CONFIG_FILE,
37
+ PULL_REQUEST_STATE,
38
+ RELEASE_MODE,
39
+ RELEASE_STEP,
40
+ CREATE_VERSION_SHORT_FLAG,
41
+ RELEASE_TYPE_SHORT_NAME,
42
+ SHORT_FLAG_TOKEN_PATTERN,
43
+ UNSETTABLE_ASIDE_EXTENSIONS,
44
+ VERSION_PREFIX_PATTERN,
45
+ } from "../constants/create-version.js";
46
+ import { RELEASE_TYPE, RELEASE_TYPE_ORDER } from "../constants/versions.js";
47
+ import { compareReleaseVersions, findHighestStableVersion, isReleaseCommitSubject, isStableReleaseVersion, isStableVersionAbove, toReleaseTag } from "../versions.js";
48
+ import { DEFAULT_PROJECT_COMMANDS, describeCreateVersionWithOptions } from "../package-manager.js";
49
+ import { describeMissingUnreleasedEntries, describeUnreleasedEntriesHint } from "./changelog.js";
50
+ import { describeError } from "./errors.js";
51
+ import { describeNpmAuthProblem, describeNpmFirstPublicationWarning } from "./npm-auth.js";
52
+
53
+ /**
54
+ * @typedef {{ sha?: string, subject: string, body?: string }} ReleaseCommit
55
+ * @typedef {{ name: string, headSha: string, hasUpstream: boolean, unpushedCount: number, aheadOfMainCount: number }} FeatureBranchSnapshot
56
+ * @typedef {{ number: number, url: string, title?: string, state: string, isDraft: boolean, headRefOid: string }} PullRequestSnapshot
57
+ * @typedef {import("./npm.js").NpmLookup} NpmLookup
58
+ * @typedef {import("./npm.js").NpmAuthCheck} NpmAuthCheck
59
+ * @typedef {import("./config.js").MigrationCheck} MigrationCheck
60
+ * @typedef {{ sha: string, version: string | null, subject?: string | null, tagged?: boolean }} LastReleaseSnapshot
61
+ * Newest commit of `origin/main` that changed `version`; `tagged` when `vX.Y.Z` points at it.
62
+ * @typedef {{
63
+ * currentBranch: string | null,
64
+ * workingTreeChanges: string[],
65
+ * branch?: FeatureBranchSnapshot | null,
66
+ * pullRequest?: PullRequestSnapshot | null,
67
+ * githubError?: string | null,
68
+ * main: { aheadCommits: ReleaseCommit[], behindCount: number },
69
+ * headVersion: string | null,
70
+ * headSubject: string | null,
71
+ * headSha?: string | null,
72
+ * headReleaseTag?: string | null,
73
+ * remoteReleaseTagSha?: string | null,
74
+ * headOnRemoteMain?: boolean,
75
+ * releasedVersion?: string | null,
76
+ * lastRelease?: LastReleaseSnapshot | null,
77
+ * unreleasedCommits: ReleaseCommit[],
78
+ * npm: NpmLookup | null,
79
+ * npmAuth?: NpmAuthCheck | null,
80
+ * migrations: MigrationCheck | null,
81
+ * changelog: { exists: boolean, entryCount: number, unknownSections: string[], updated: boolean, reason: string | null },
82
+ * }} ReleaseState
83
+ * @typedef {import("../package-manager.js").ProjectCommands} ProjectCommands
84
+ * @typedef {{ checks: boolean, checksMissing?: boolean, prepare: boolean, publish: boolean, publishTitle: string, commands?: ProjectCommands }} ReleaseCapabilities
85
+ * `checksMissing` means the project configures no checks, has no `ci` script and did not skip
86
+ * them with `checks: false`: a new release is blocked. `commands` are the project's package
87
+ * manager commands quoted by every hint (pnpm when omitted).
88
+ * @typedef {{ id: string, title: string, detail?: string }} ReleasePlanStep
89
+ * @typedef {{ code?: "changelog-update-required", title: string, details: string[] }} ReleaseBlocker
90
+ * @typedef {{ mode: string, steps: ReleasePlanStep[], blockers: ReleaseBlocker[], warnings: string[], pendingVersion: string | null }} ReleasePlan
91
+ * @typedef {{ bump: "patch" | "minor" | "major" | null, setVersion: string | null, dryRun: boolean, skipUnpublished: boolean, ignoreLocalChanges: boolean, acceptSuggested: boolean, help: boolean, execution?: "local" | "ci" | null, setupCi?: boolean, ciRelease?: string | null, retryCi?: string | null }} ReleaseOptions
92
+ * `acceptSuggested` takes the release type the commits suggest instead of asking.
93
+ * @typedef {{ version: string, latestPublished: string | null, resumable: boolean, registryLabel?: string }} UnpublishedRelease
94
+ * @typedef {{ skipUnpublished?: boolean, ignoreLocalChanges?: boolean, ciWorkerFiles?: readonly string[] }} PlanOptions
95
+ * `skipUnpublished` plans a new release even when the last release is missing from npm.
96
+ * `ignoreLocalChanges` plans the release despite uncommitted changes, which the run sets aside.
97
+ * `ciWorkerFiles` lists, only for CI preparation, the root-relative files the worker reads from the
98
+ * release tag; `ignoreLocalChanges` cannot set changes to them aside.
99
+ */
100
+
101
+ /** Capabilities of a project without checks, preparation or publication. */
102
+ export const DEFAULT_CAPABILITIES = Object.freeze({
103
+ checks: false,
104
+ checksMissing: false,
105
+ prepare: false,
106
+ publish: false,
107
+ publishTitle: "Publicar el release",
108
+ commands: DEFAULT_PROJECT_COMMANDS,
109
+ });
110
+
111
+ /** Usage printed by `create-version --help` in a pnpm project. */
112
+ export const RELEASE_USAGE = [
113
+ "Uso: pnpm create-version [opciones]",
114
+ "",
115
+ " -b, --bump patch|minor|major|p|m|M",
116
+ " Elige el tipo de versión sin preguntar (m es minor y M major).",
117
+ " -p, --patch Igual que --bump patch.",
118
+ " -m, --minor Igual que --bump minor.",
119
+ " -M, --major Igual que --bump major (M en mayúscula).",
120
+ " -sv, --set-version X.Y.Z Fija la versión exacta (solo el siguiente patch, minor o major).",
121
+ " --dry-run Diagnostica y muestra el plan sin cambiar nada.",
122
+ " --skip-unpublished Crea un release nuevo aunque el último release no esté en npm (lo saltea).",
123
+ " --ignore-local-changes Publica aunque haya cambios sin commitear: se apartan (git stash) y se restauran al final.",
124
+ " --accept-suggested Toma la versión sugerida por los commits sin preguntar (en un monorepo, la de cada paquete).",
125
+ " -l, --local Ejecuta todo el release en esta máquina, sin preguntar.",
126
+ " --ci Hace el bump y push local; ejecuta los checks y publica en CI, sin preguntar (requiere ci.workflow).",
127
+ " --setup-ci Crea release.yml y configura CI dentro del commit del bump.",
128
+ " --ci-release vX.Y.Z Ejecuta en CI el release ya creado, sin otro bump ni push.",
129
+ " --retry-ci vX.Y.Z Reenvía a CI un tag existente, sin crear otra versión.",
130
+ " -h, --help Muestra esta ayuda.",
131
+ "",
132
+ "Los flags cortos van de a uno (-l -m, no -lm); los que llevan valor aceptan -b m o -b=m.",
133
+ ].join("\n");
134
+
135
+ /**
136
+ * Builds the usage printed by `create-version --help` for the project's package manager.
137
+ *
138
+ * @param {ProjectCommands} [commands] - Project commands (pnpm when omitted).
139
+ * @returns {string} Usage.
140
+ */
141
+ export function buildReleaseUsage(commands = DEFAULT_PROJECT_COMMANDS) {
142
+ const [, ...options] = RELEASE_USAGE.split("\n");
143
+ return [`Uso: ${describeCreateVersionWithOptions(commands)} [opciones]`, ...options].join("\n");
144
+ }
145
+
146
+ /** Release type of each short name accepted by `--bump`/`-b`, such as `m` → `minor`. */
147
+ const RELEASE_TYPE_BY_SHORT_NAME = Object.freeze(Object.fromEntries(Object.entries(RELEASE_TYPE_SHORT_NAME).map(([releaseType, shortName]) => [shortName, releaseType])));
148
+
149
+ /**
150
+ * `parseArgs` options of `create-version`, all long: short flags are expanded before parsing. Flags
151
+ * with a value keep every occurrence (`multiple`) so a repeated flag with another value is rejected
152
+ * instead of silently keeping the last one.
153
+ */
154
+ const RELEASE_ARGUMENT_OPTIONS = Object.freeze({
155
+ [CREATE_VERSION_FLAG.bump]: { type: /** @type {const} */ ("string"), multiple: /** @type {const} */ (true) },
156
+ [CREATE_VERSION_FLAG.patch]: { type: /** @type {const} */ ("boolean"), default: false },
157
+ [CREATE_VERSION_FLAG.minor]: { type: /** @type {const} */ ("boolean"), default: false },
158
+ [CREATE_VERSION_FLAG.major]: { type: /** @type {const} */ ("boolean"), default: false },
159
+ [CREATE_VERSION_FLAG.setVersion]: { type: /** @type {const} */ ("string"), multiple: /** @type {const} */ (true) },
160
+ [CREATE_VERSION_FLAG.dryRun]: { type: /** @type {const} */ ("boolean"), default: false },
161
+ [CREATE_VERSION_FLAG.skipUnpublished]: { type: /** @type {const} */ ("boolean"), default: false },
162
+ [CREATE_VERSION_FLAG.ignoreLocalChanges]: { type: /** @type {const} */ ("boolean"), default: false },
163
+ [CREATE_VERSION_FLAG.acceptSuggested]: { type: /** @type {const} */ ("boolean"), default: false },
164
+ [CREATE_VERSION_FLAG.local]: { type: /** @type {const} */ ("boolean"), default: false },
165
+ [CREATE_VERSION_FLAG.ci]: { type: /** @type {const} */ ("boolean"), default: false },
166
+ [CREATE_VERSION_FLAG.setupCi]: { type: /** @type {const} */ ("boolean"), default: false },
167
+ [CREATE_VERSION_FLAG.ciRelease]: { type: /** @type {const} */ ("string"), multiple: /** @type {const} */ (true) },
168
+ [CREATE_VERSION_FLAG.retryCi]: { type: /** @type {const} */ ("string"), multiple: /** @type {const} */ (true) },
169
+ [CREATE_VERSION_FLAG.help]: { type: /** @type {const} */ ("boolean"), default: false },
170
+ });
171
+
172
+ /** Long flags whose value may come in the next token, such as `--bump minor`. */
173
+ const VALUE_FLAG_TOKENS = new Set(Object.entries(RELEASE_ARGUMENT_OPTIONS).filter(([, option]) => option.type === "string").map(([flag]) => `--${flag}`));
174
+
175
+ /**
176
+ * Rewrites every short flag as its long form (`-l` → `--local`, `-sv=1.3.0` → `--set-version=1.3.0`)
177
+ * so `parseArgs` only sees long flags. Short flags are whole tokens: `-sv` has two letters, so a
178
+ * grouped token such as `-lm` would be ambiguous and is rejected like an unknown one. The token after
179
+ * a flag that takes a value is that value and is kept as is.
180
+ *
181
+ * @param {string[]} argv - Arguments after the command name, without `--`.
182
+ * @returns {string[]} The same arguments with long flags only.
183
+ * @throws {Error} With a Spanish message when a short flag is unknown or grouped.
184
+ */
185
+ function expandShortFlags(argv) {
186
+ /** @type {string[]} */
187
+ const expanded = [];
188
+ let nextIsValue = false;
189
+
190
+ for (const argument of argv) {
191
+ const shortFlag = nextIsValue ? undefined : SHORT_FLAG_TOKEN_PATTERN.exec(argument)?.groups;
192
+ if (!shortFlag) {
193
+ expanded.push(argument);
194
+ nextIsValue = !nextIsValue && VALUE_FLAG_TOKENS.has(argument);
195
+ continue;
196
+ }
197
+
198
+ const longFlag = Object.hasOwn(CREATE_VERSION_SHORT_FLAG, shortFlag.name) ? CREATE_VERSION_SHORT_FLAG[/** @type {keyof typeof CREATE_VERSION_SHORT_FLAG} */ (shortFlag.name)] : undefined;
199
+ if (longFlag === undefined) {
200
+ throw new Error(`Opción corta desconocida: "${argument}". Los flags cortos van de a uno (por ejemplo -l -m, no -lm); usá --help para ver las opciones.`);
201
+ }
202
+ const longToken = `--${longFlag}`;
203
+ expanded.push(shortFlag.value === undefined ? longToken : `${longToken}=${shortFlag.value}`);
204
+ nextIsValue = shortFlag.value === undefined && VALUE_FLAG_TOKENS.has(longToken);
205
+ }
206
+
207
+ return expanded;
208
+ }
209
+
210
+ /**
211
+ * Resolves the release type of `--bump` (`-b`) and of the `--patch`, `--minor` and `--major`
212
+ * shortcuts (`-p`, `-m`, `-M`): `--bump` accepts the full name or its short name (`m`, `M`), a
213
+ * single shortcut stands for `--bump <type>`, and repeating the same type is accepted (`-b M --bump major`).
214
+ *
215
+ * @param {string[]} bumpValues - Every value of `--bump`, in command-line order (empty when it was not passed).
216
+ * @param {string[]} shortcutTypes - Release types whose shortcut flag was passed, in patch-minor-major order.
217
+ * @returns {string | undefined} The requested release type, or `undefined` when none was requested.
218
+ * @throws {Error} With a Spanish message when `--bump` is not a release type or two different types are requested.
219
+ */
220
+ function resolveRequestedReleaseType(bumpValues, shortcutTypes) {
221
+ const releaseTypes = /** @type {string[]} */ (Object.values(RELEASE_TYPE));
222
+ const bumpTypes = bumpValues.map((bumpValue) => {
223
+ const releaseType = RELEASE_TYPE_BY_SHORT_NAME[bumpValue] ?? bumpValue;
224
+ if (!releaseTypes.includes(releaseType)) {
225
+ throw new Error(`--bump espera ${releaseTypes.join("|")} (o ${Object.values(RELEASE_TYPE_SHORT_NAME).join("|")}) y recibió "${bumpValue}".`);
226
+ }
227
+ return releaseType;
228
+ });
229
+
230
+ const conflictingBumpIndex = bumpTypes.findIndex((releaseType) => releaseType !== bumpTypes[0]);
231
+ if (conflictingBumpIndex !== -1) {
232
+ throw new Error(`--bump ${bumpValues[0]} y --bump ${bumpValues[conflictingBumpIndex]} piden tipos de versión distintos: elegí un solo tipo de versión.`);
233
+ }
234
+ const [bumpType] = bumpTypes;
235
+ const [bumpValue] = bumpValues;
236
+
237
+ const shortcutFlags = shortcutTypes.map((releaseType) => `--${releaseType}`);
238
+ if (shortcutFlags.length > 1) {
239
+ throw new Error(`${shortcutFlags.slice(0, -1).join(", ")} y ${shortcutFlags.at(-1)} no se combinan: elegí un solo tipo de versión.`);
240
+ }
241
+
242
+ const [shortcutType] = shortcutTypes;
243
+ if (shortcutType !== undefined && bumpType !== undefined && bumpType !== shortcutType) {
244
+ throw new Error(`--${shortcutType} no coincide con --bump ${bumpValue}: elegí un solo tipo de versión.`);
245
+ }
246
+ return shortcutType ?? bumpType;
247
+ }
248
+
249
+ /**
250
+ * Reads the single value of a flag that may be repeated: repeating it with the same value (after
251
+ * `normalize`, such as `1.3.0` and `v1.3.0`) is accepted, while another value is a conflict that
252
+ * `parseArgs` would otherwise settle by keeping the last one.
253
+ *
254
+ * @param {string} flag - Long flag name, without dashes.
255
+ * @param {string[] | undefined} flagValues - Every value of the flag, in command-line order.
256
+ * @param {(value: string) => string} [normalize] - Maps a value to the form compared between occurrences.
257
+ * @returns {string | undefined} The first value, or `undefined` when the flag was not passed.
258
+ * @throws {Error} With a Spanish message when the flag received different values.
259
+ */
260
+ function readSingleFlagValue(flag, flagValues, normalize = (value) => value) {
261
+ const [firstValue] = flagValues ?? [];
262
+ const conflictingValue = flagValues?.find((value) => normalize(value) !== normalize(firstValue));
263
+ if (conflictingValue !== undefined) {
264
+ throw new Error(`--${flag} recibió valores distintos (${firstValue} y ${conflictingValue}): pasalo una sola vez.`);
265
+ }
266
+ return firstValue;
267
+ }
268
+
269
+ /**
270
+ * Removes the `v` a typed version may carry (`v1.2.0` means `1.2.0`); the version rules validate the rest.
271
+ *
272
+ * @param {string} version - Version as typed.
273
+ * @returns {string} Version without its prefix.
274
+ */
275
+ function stripVersionPrefix(version) {
276
+ return version.replace(VERSION_PREFIX_PATTERN, "");
277
+ }
278
+
279
+ /**
280
+ * Parses the command-line arguments of `create-version`.
281
+ *
282
+ * @param {string[]} argv - Arguments after the command name.
283
+ * @returns {ReleaseOptions} Options.
284
+ * @throws {Error} With a Spanish message when an argument is unknown or invalid.
285
+ */
286
+ export function parseReleaseArguments(argv) {
287
+ const expandedArguments = expandShortFlags(argv.filter((argument) => argument !== CREATE_VERSION_FLAG.endOfOptions));
288
+ /** @type {Record<string, string[] | boolean | undefined>} */
289
+ let values;
290
+
291
+ try {
292
+ ({ values } = parseArgs({ args: expandedArguments, options: RELEASE_ARGUMENT_OPTIONS }));
293
+ } catch (error) {
294
+ throw new Error(`Opción inválida: ${describeError(error)}. Usá --help para ver las opciones.`, { cause: error });
295
+ }
296
+
297
+ const bump = resolveRequestedReleaseType(/** @type {string[] | undefined} */ (values[CREATE_VERSION_FLAG.bump]) ?? [], RELEASE_TYPE_ORDER.filter((releaseType) => values[releaseType]));
298
+ const setVersion = readSingleFlagValue(CREATE_VERSION_FLAG.setVersion, /** @type {string[] | undefined} */ (values[CREATE_VERSION_FLAG.setVersion]), stripVersionPrefix);
299
+
300
+ if (bump !== undefined && setVersion !== undefined) {
301
+ throw new Error("Usá --bump (o --patch, --minor, --major) o --set-version, no los dos a la vez.");
302
+ }
303
+
304
+ if (values[CREATE_VERSION_FLAG.acceptSuggested] && (bump !== undefined || setVersion !== undefined)) {
305
+ throw new Error("--accept-suggested elige la versión sugerida: no se combina con --bump ni con --set-version.");
306
+ }
307
+ if (values[CREATE_VERSION_FLAG.local] && (values[CREATE_VERSION_FLAG.ci] || values[CREATE_VERSION_FLAG.setupCi] || values[CREATE_VERSION_FLAG.retryCi])) {
308
+ throw new Error("--local no se combina con --ci, --setup-ci ni --retry-ci.");
309
+ }
310
+ const ciRelease = readSingleFlagValue(CREATE_VERSION_FLAG.ciRelease, /** @type {string[] | undefined} */ (values[CREATE_VERSION_FLAG.ciRelease]));
311
+ const retryCi = readSingleFlagValue(CREATE_VERSION_FLAG.retryCi, /** @type {string[] | undefined} */ (values[CREATE_VERSION_FLAG.retryCi]));
312
+ for (const [flag, value] of [[CREATE_VERSION_FLAG.ciRelease, ciRelease], [CREATE_VERSION_FLAG.retryCi, retryCi]]) {
313
+ if (value !== undefined) {
314
+ const version = stripVersionPrefix(value);
315
+ if (value !== value.trim() || !isStableReleaseVersion(version) || toReleaseTag(version) !== value) throw new Error(`--${flag} espera un tag estable vX.Y.Z, no un valor vacío ni una versión sin prefijo.`);
316
+ }
317
+ }
318
+ if ((ciRelease !== undefined || retryCi !== undefined) && (bump !== undefined || setVersion !== undefined || values[CREATE_VERSION_FLAG.acceptSuggested] || values[CREATE_VERSION_FLAG.setupCi] || values[CREATE_VERSION_FLAG.ignoreLocalChanges] || values[CREATE_VERSION_FLAG.skipUnpublished] || (ciRelease !== undefined && retryCi !== undefined) || (ciRelease !== undefined && values[CREATE_VERSION_FLAG.ci]))) {
319
+ throw new Error("--ci-release y --retry-ci trabajan sobre un tag existente: no se combinan con un bump, configuración de CI ni cambios locales.");
320
+ }
321
+
322
+ return {
323
+ bump: /** @type {ReleaseOptions["bump"]} */ (bump ?? null),
324
+ setVersion: setVersion === undefined ? null : stripVersionPrefix(setVersion),
325
+ dryRun: Boolean(values[CREATE_VERSION_FLAG.dryRun]),
326
+ skipUnpublished: Boolean(values[CREATE_VERSION_FLAG.skipUnpublished]),
327
+ ignoreLocalChanges: Boolean(values[CREATE_VERSION_FLAG.ignoreLocalChanges]),
328
+ acceptSuggested: Boolean(values[CREATE_VERSION_FLAG.acceptSuggested]),
329
+ help: Boolean(values[CREATE_VERSION_FLAG.help]),
330
+ execution: values[CREATE_VERSION_FLAG.local] ? RELEASE_EXECUTION.local : values[CREATE_VERSION_FLAG.ci] || values[CREATE_VERSION_FLAG.setupCi] ? RELEASE_EXECUTION.ci : null,
331
+ setupCi: Boolean(values[CREATE_VERSION_FLAG.setupCi]),
332
+ ciRelease: ciRelease ?? null,
333
+ retryCi: retryCi ?? null,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Describes what is still missing for a feature branch to reach `main`, so
339
+ * the blocker tells the user the next concrete action.
340
+ *
341
+ * @param {FeatureBranchSnapshot} branch - Branch snapshot.
342
+ * @param {PullRequestSnapshot | null} pullRequest - Pull request of the branch.
343
+ * @param {string | null} githubError - Why the pull request could not be read.
344
+ * @param {ProjectCommands} [commands] - Project commands quoted by the hints (pnpm when omitted).
345
+ * @returns {string[]} Spanish lines, most urgent first.
346
+ */
347
+ export function describeFeatureBranchGaps(branch, pullRequest, githubError, commands = DEFAULT_PROJECT_COMMANDS) {
348
+ if (pullRequest?.state === PULL_REQUEST_STATE.merged && branch.headSha === pullRequest.headRefOid) {
349
+ return [`El PR #${pullRequest.number} ya está mergeado: hacé git switch ${MAIN_BRANCH}.`];
350
+ }
351
+
352
+ if (branch.aheadOfMainCount === 0) {
353
+ return [`La rama no tiene commits nuevos respecto de ${MAIN_BRANCH}: hacé git switch ${MAIN_BRANCH}.`];
354
+ }
355
+
356
+ const gaps = [];
357
+
358
+ if (!branch.hasUpstream) {
359
+ gaps.push(`La rama nunca se subió: git push -u origin ${branch.name}.`);
360
+ } else if (branch.unpushedCount > 0) {
361
+ gaps.push(`${branch.unpushedCount} commit(s) sin subir: git push.`);
362
+ }
363
+
364
+ if (githubError) {
365
+ gaps.push(`No se pudo consultar el PR (${githubError}).`);
366
+ } else if (pullRequest?.state === PULL_REQUEST_STATE.merged) {
367
+ gaps.push(`El PR #${pullRequest.number} ya se mergeó, pero la rama tiene commits posteriores: abrí un PR nuevo.`);
368
+ } else if (pullRequest?.state === PULL_REQUEST_STATE.open) {
369
+ const draftNote = pullRequest.isDraft ? " (está en borrador)" : "";
370
+ gaps.push(`Falta mergear el PR #${pullRequest.number}${draftNote}: ${pullRequest.url}`);
371
+ } else {
372
+ gaps.push(`Falta abrir el PR contra ${MAIN_BRANCH}: gh pr create --fill.`);
373
+ }
374
+
375
+ gaps.push(`Después hacé git switch ${MAIN_BRANCH} y corré ${commands.createVersion}.`);
376
+
377
+ return gaps;
378
+ }
379
+
380
+ /**
381
+ * Builds a plan that cannot run until its blockers are fixed.
382
+ *
383
+ * @param {ReleaseBlocker[]} blockers - What must be fixed first.
384
+ * @param {string[]} [warnings] - Warnings gathered before blocking.
385
+ * @returns {ReleasePlan} Blocked plan without steps.
386
+ */
387
+ function blockedPlan(blockers, warnings = []) {
388
+ return { mode: RELEASE_MODE.blocked, steps: [], blockers, warnings, pendingVersion: null };
389
+ }
390
+
391
+ /**
392
+ * Tells whether the npm lookup of the diagnosis ran and failed, so the plan is blocked before it
393
+ * could show a publication step; shared with the monorepo mode and the run.
394
+ *
395
+ * @param {{ npm?: NpmLookup | null }} snapshot - Repository or package snapshot.
396
+ * @returns {boolean} `true` when npm was queried and did not answer with the published versions.
397
+ */
398
+ export function hasFailedNpmLookup(snapshot) {
399
+ return Boolean(snapshot.npm) && snapshot.npm?.status !== NPM_LOOKUP_STATUS.ok;
400
+ }
401
+
402
+ /**
403
+ * Explains a failed npm lookup; shared with the monorepo mode. When the registry answered it and
404
+ * rejected the credential (an invalid or expired token, or a user that cannot publish), an
405
+ * authenticated `npm view` fails with E401/E403, so the credential blocker explains it: replacing
406
+ * the token is the fix, not the connection. A missing token never reached the registry, so it does
407
+ * not explain the lookup.
408
+ *
409
+ * @param {NpmLookup} npm - Failed lookup.
410
+ * @param {NpmAuthCheck | null | undefined} npmAuth - Credential check, when it ran.
411
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
412
+ * @param {{ subject?: string, defaultReason: string }} description - Text after the registry name in the
413
+ * title (such as ` para <paquete>`) and the reason shown when the lookup has none.
414
+ * @returns {ReleaseBlocker} Credential blocker, or the generic lookup blocker.
415
+ */
416
+ export function failedNpmLookupBlocker(npm, npmAuth, commands, { subject = "", defaultReason }) {
417
+ const rejectedCredential = npmAuth && npmAuth.status !== NPM_AUTH_STATUS.missingToken ? describeNpmAuthProblem(npmAuth, commands) : null;
418
+ return rejectedCredential ?? {
419
+ title: `No se pudo consultar ${npm.registryLabel ?? "npm"}${subject}`,
420
+ details: [`${npm.reason ?? defaultReason}.`, `Revisá la conexión y volvé a correr ${commands.createVersion}.`],
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Explains that the releases only leave from `main`; shared with the monorepo mode.
426
+ *
427
+ * @param {Pick<ReleaseState, "branch" | "pullRequest" | "githubError"> & { currentBranch: string }} state - Snapshot on a feature branch.
428
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
429
+ * @returns {ReleaseBlocker} Blocker with what is still missing for the branch to reach `main`.
430
+ */
431
+ export function featureBranchBlocker(state, commands) {
432
+ return {
433
+ title: `Estás en ${state.currentBranch}: los releases salen solo desde ${MAIN_BRANCH}`,
434
+ details: state.branch
435
+ ? describeFeatureBranchGaps(state.branch, state.pullRequest ?? null, state.githubError ?? null, commands)
436
+ : [`Hacé git switch ${MAIN_BRANCH} y volvé a correr ${commands.createVersion}.`],
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Lists the uncommitted files that block the release without `--ignore-local-changes`; shared with the monorepo mode.
442
+ *
443
+ * @param {string[]} blockingChanges - `git status --porcelain` lines that block.
444
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
445
+ * @returns {ReleaseBlocker} Blocker.
446
+ */
447
+ export function uncommittedFilesBlocker(blockingChanges, commands) {
448
+ return {
449
+ title: `Hay ${blockingChanges.length} archivo(s) sin commitear`,
450
+ details: [
451
+ ...blockingChanges.slice(0, MAX_LISTED_ITEMS),
452
+ `Commitealos en una rama (o git stash) y volvé a correr ${commands.createVersion}, o corré ${describeCreateVersionWithOptions(commands)} --${CREATE_VERSION_FLAG.ignoreLocalChanges} para apartarlos durante el release.`,
453
+ ],
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Stops a new release whose manual changelog was not updated since the last release; shared with the monorepo mode.
459
+ *
460
+ * @param {string | null} reason - Why the changelog check failed, when it says so.
461
+ * @param {string} changelogPath - Changelog path relative to the root.
462
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
463
+ * @returns {ReleaseBlocker} Blocker with {@link CHANGELOG_UPDATE_REQUIRED_CODE}.
464
+ */
465
+ export function changelogUpdateRequiredBlocker(reason, changelogPath, commands) {
466
+ return {
467
+ code: CHANGELOG_UPDATE_REQUIRED_CODE,
468
+ title: reason ?? `${changelogPath} no fue actualizado desde el último release`,
469
+ details: [`Actualizá ${changelogPath} manualmente y volvé a correr ${commands.createVersion}; no se tocó la versión.`],
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Stops a new release whose changelog has no `## [Unreleased]` block or no entry in it: the release
475
+ * commit closes that block as the new version. Shared with the monorepo mode.
476
+ *
477
+ * @param {{ exists: boolean, entryCount: number }} changelog - `[Unreleased]` block of the changelog.
478
+ * @param {string} changelogPath - Changelog path relative to the root.
479
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
480
+ * @returns {ReleaseBlocker | null} Blocker with {@link CHANGELOG_UPDATE_REQUIRED_CODE}, or `null` when `[Unreleased]` has entries.
481
+ */
482
+ export function unreleasedEntriesRequiredBlocker(changelog, changelogPath, commands) {
483
+ const reason = describeMissingUnreleasedEntries(changelog, changelogPath);
484
+ return reason ? { code: CHANGELOG_UPDATE_REQUIRED_CODE, title: reason, details: [describeUnreleasedEntriesHint(changelogPath, commands.createVersion)] } : null;
485
+ }
486
+
487
+ /**
488
+ * Builds the step that fast-forwards `main` before a new release; shared with the monorepo mode.
489
+ *
490
+ * @param {number} behindCount - Commits `origin/main` has and `main` lacks.
491
+ * @param {ProjectCommands} commands - Project commands quoted by the detail.
492
+ * @returns {ReleasePlanStep} Sync step.
493
+ */
494
+ export function syncMainPlanStep(behindCount, commands) {
495
+ return {
496
+ id: RELEASE_STEP.syncMain,
497
+ title: `Actualizar ${MAIN_BRANCH} desde origin`,
498
+ detail: `${behindCount} commit(s) nuevos. Después hay que volver a correr ${commands.createVersion}, que diagnostica con el código nuevo.`,
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Adds the migrations step when migrations are pending, or a warning when they could not be checked;
504
+ * shared with the monorepo mode.
505
+ *
506
+ * @param {MigrationCheck | null | undefined} migrations - Migration state of the diagnosis.
507
+ * @param {ReleasePlanStep[]} steps - Steps of the plan, extended in place.
508
+ * @param {string[]} warnings - Warnings of the plan, extended in place.
509
+ * @returns {void}
510
+ */
511
+ export function addMigrationsPlanStep(migrations, steps, warnings) {
512
+ if (migrations?.status === MIGRATION_STATUS.pending) {
513
+ steps.push({
514
+ id: RELEASE_STEP.applyMigrations,
515
+ title: `Aplicar ${migrations.pending.length} migración(es) en la base de datos`,
516
+ detail: `Destino: ${migrations.target ?? "desconocido"} · se pide confirmación antes.`,
517
+ });
518
+ } else if (migrations?.status === MIGRATION_STATUS.unknown) {
519
+ warnings.push(`No se pudo verificar si hay migraciones pendientes: ${migrations.reason ?? "motivo desconocido"}.`);
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Classifies the npm credential check of a package the plan would publish; shared with the monorepo mode.
525
+ *
526
+ * @param {NpmAuthCheck} npmAuth - Credential check.
527
+ * @param {ProjectCommands} commands - Project commands quoted by the fix.
528
+ * @param {string} [subject] - Text after the registry name in the unverified warning (such as ` de <paquete>`).
529
+ * @returns {{ problem: ReleaseBlocker | null, warning: string | null }} Blocker when the credentials cannot publish,
530
+ * otherwise a warning when they could not be verified or the first publication cannot be confirmed.
531
+ */
532
+ export function classifyNpmAuth(npmAuth, commands, subject = "") {
533
+ const problem = describeNpmAuthProblem(npmAuth, commands);
534
+ if (problem) {
535
+ return { problem, warning: null };
536
+ }
537
+ if (npmAuth.status === NPM_AUTH_STATUS.unknown) {
538
+ return { problem: null, warning: `No se pudieron verificar las credenciales de ${npmAuth.registryLabel ?? "npm"}${subject}: ${npmAuth.reason ?? "motivo desconocido"}. Se intenta publicar igual.` };
539
+ }
540
+ return { problem: null, warning: describeNpmFirstPublicationWarning(npmAuth) };
541
+ }
542
+
543
+ /**
544
+ * Lists the blockers that must be fixed before any release step runs.
545
+ *
546
+ * @param {ReleaseState} state - Snapshot.
547
+ * @param {boolean} ignoreLocalChanges - Whether uncommitted changes are set aside instead of blocking.
548
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
549
+ * @returns {ReleaseBlocker[]} Blockers, most urgent first.
550
+ */
551
+ function findBlockers(state, ignoreLocalChanges, commands) {
552
+ if (!state.currentBranch && !findDetachedReleaseVersion(state)) {
553
+ return [
554
+ {
555
+ title: "HEAD está desacoplado (detached)",
556
+ details: [
557
+ `Hacé git switch ${MAIN_BRANCH} y volvé a correr ${commands.createVersion}.`,
558
+ `Desacoplado solo se puede publicar un release que falta en ${state.npm?.registryLabel ?? "npm"}: HEAD tiene que ser el commit X.Y.Z de su tag vX.Y.Z.`,
559
+ ],
560
+ },
561
+ ];
562
+ }
563
+
564
+ /** @type {ReleaseBlocker[]} */
565
+ const blockers = [];
566
+
567
+ if (state.currentBranch && state.currentBranch !== MAIN_BRANCH) {
568
+ blockers.push(featureBranchBlocker({ ...state, currentBranch: state.currentBranch }, commands));
569
+ }
570
+
571
+ // CHANGELOG.md may be uncommitted only for a new release, whose bump commits it: resuming an
572
+ // existing release commit re-checks it with requireCleanChangelog.
573
+ const blockingChanges = state.workingTreeChanges.filter((line) => !isChangelogChange(line));
574
+
575
+ if (blockingChanges.length > 0 && !ignoreLocalChanges) {
576
+ blockers.push(uncommittedFilesBlocker(blockingChanges, commands));
577
+ }
578
+
579
+ if (state.npm && hasFailedNpmLookup(state)) {
580
+ blockers.push(failedNpmLookupBlocker(state.npm, state.npmAuth, commands, { defaultReason: "El registry no respondió" }));
581
+ }
582
+
583
+ return blockers;
584
+ }
585
+
586
+ /**
587
+ * Tells whether a `git status --porcelain` line is a change of `CHANGELOG.md`.
588
+ *
589
+ * @param {string} line - Porcelain line.
590
+ * @returns {boolean} `true` when the line reports `CHANGELOG.md`.
591
+ */
592
+ function isChangelogChange(line) {
593
+ return line.slice(PORCELAIN_STATUS_WIDTH) === CHANGELOG_FILE;
594
+ }
595
+
596
+ /**
597
+ * Lists the uncommitted changes a run with `--ignore-local-changes` sets aside: every change,
598
+ * except `CHANGELOG.md` in a new release, whose bump commits it.
599
+ *
600
+ * @param {ReleaseState} state - Snapshot.
601
+ * @param {string} mode - Planned {@link RELEASE_MODE}.
602
+ * @returns {string[]} `git status --porcelain` lines to set aside.
603
+ */
604
+ export function listLocalChangesToSetAside(state, mode) {
605
+ return mode === RELEASE_MODE.newRelease ? state.workingTreeChanges.filter((line) => !isChangelogChange(line)) : state.workingTreeChanges;
606
+ }
607
+
608
+ /**
609
+ * Lists the paths of a `git status --porcelain` line: the path, or both the original and the new
610
+ * path of a rename or copy (`R old.js -> new.js`), without the quotes Git adds to unusual names.
611
+ *
612
+ * @param {string} line - Porcelain line.
613
+ * @returns {string[]} Paths the line reports.
614
+ */
615
+ export function listPorcelainPaths(line) {
616
+ return line
617
+ .slice(PORCELAIN_STATUS_WIDTH)
618
+ .split(PORCELAIN_RENAME_SEPARATOR)
619
+ .map((changedPath) => changedPath.replace(/^"|"$/gu, ""));
620
+ }
621
+
622
+ /**
623
+ * Tells whether a `git status --porcelain` line touches code or data the loaded configuration may
624
+ * import (see {@link UNSETTABLE_ASIDE_EXTENSIONS}), or a `.npmrc` the diagnosis already read to
625
+ * resolve the registry and credentials.
626
+ *
627
+ * @param {string} line - Porcelain line.
628
+ * @returns {boolean} `true` when any of its paths has one of those extensions or is a `.npmrc`.
629
+ */
630
+ export function isCodeChange(line) {
631
+ return listPorcelainPaths(line).some(
632
+ (changedPath) => changedPath.split("/").at(-1) === PROJECT_NPM_CONFIG_FILE || UNSETTABLE_ASIDE_EXTENSIONS.some((extension) => changedPath.endsWith(extension))
633
+ );
634
+ }
635
+
636
+ /**
637
+ * Blocks a runnable `--ignore-local-changes` plan whose changes to set aside include code or data
638
+ * (`.js`, `.mjs`, `.cjs`, `.ts`, `.json`, `.npmrc`): the configuration was loaded from the working tree before
639
+ * setting them aside and may depend on them, so the release would not run the committed code.
640
+ *
641
+ * @param {ReleasePlan} plan - Plan.
642
+ * @param {ReleaseState} state - Snapshot.
643
+ * @param {boolean} ignoreLocalChanges - Whether `--ignore-local-changes` was chosen.
644
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
645
+ * @returns {ReleasePlan} The same plan, or a blocked plan when a code change would be set aside.
646
+ */
647
+ function refuseToSetAsideCodeChanges(plan, state, ignoreLocalChanges, commands) {
648
+ const codeChanges = ignoreLocalChanges && plan.steps.length > 0 ? listLocalChangesToSetAside(state, plan.mode).filter(isCodeChange) : [];
649
+
650
+ if (codeChanges.length === 0) {
651
+ return plan;
652
+ }
653
+
654
+ return blockedPlan([codeChangesToSetAsideBlocker(codeChanges, commands)]);
655
+ }
656
+
657
+ /**
658
+ * Explains why `--ignore-local-changes` cannot set aside code or data changes (see
659
+ * {@link UNSETTABLE_ASIDE_EXTENSIONS}); shared with the monorepo mode.
660
+ *
661
+ * @param {string[]} codeChanges - `git status --porcelain` lines of the code or data changes.
662
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
663
+ * @returns {ReleaseBlocker} Blocker listing them.
664
+ */
665
+ export function codeChangesToSetAsideBlocker(codeChanges, commands) {
666
+ return {
667
+ title: `--${CREATE_VERSION_FLAG.ignoreLocalChanges} no aparta cambios de código ni de datos (${[...UNSETTABLE_ASIDE_EXTENSIONS, PROJECT_NPM_CONFIG_FILE].join(", ")})`,
668
+ details: [
669
+ ...codeChanges.slice(0, MAX_LISTED_ITEMS),
670
+ `La configuración ya se cargó con esos cambios y puede depender de ellos: commitealos en una rama o guardalos con git stash, y volvé a correr ${commands.createVersion}.`,
671
+ ],
672
+ };
673
+ }
674
+
675
+ /**
676
+ * Blocks a runnable CI preparation whose `--ignore-local-changes` would set aside changes to files
677
+ * that shape the worker (configuration, manifest, npm config, Node.js pin, lockfiles or workflow):
678
+ * the preparation and its preflight read them from the working tree, while the worker checks out
679
+ * the committed versions from the release tag, so the pushed tag could fail only in CI.
680
+ *
681
+ * @param {ReleasePlan} plan - Plan.
682
+ * @param {ReleaseState} state - Snapshot.
683
+ * @param {boolean} ignoreLocalChanges - Whether `--ignore-local-changes` was chosen.
684
+ * @param {readonly string[]} ciWorkerFiles - Root-relative files the worker reads from the tag; empty outside CI preparation.
685
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
686
+ * @returns {ReleasePlan} The same plan, or a blocked plan when a worker file would be set aside.
687
+ */
688
+ function refuseToSetAsideCiWorkerChanges(plan, state, ignoreLocalChanges, ciWorkerFiles, commands) {
689
+ const workerChanges = ignoreLocalChanges && ciWorkerFiles.length > 0 && plan.steps.length > 0
690
+ ? listLocalChangesToSetAside(state, plan.mode).filter((line) => listPorcelainPaths(line).some((changedPath) => ciWorkerFiles.includes(changedPath)))
691
+ : [];
692
+
693
+ if (workerChanges.length === 0) {
694
+ return plan;
695
+ }
696
+
697
+ return blockedPlan([
698
+ {
699
+ title: `--${CREATE_VERSION_FLAG.ignoreLocalChanges} no aparta cambios en archivos que definen el worker de CI`,
700
+ details: [
701
+ ...workerChanges.slice(0, MAX_LISTED_ITEMS),
702
+ `La preparación los leería del working tree y el worker usaría los commiteados en el tag: commitealos en un commit propio o guardalos con git stash, y volvé a correr ${commands.createVersion}. No se creó la versión ni el tag.`,
703
+ ],
704
+ },
705
+ ]);
706
+ }
707
+
708
+ /**
709
+ * Warns that a runnable plan sets uncommitted changes aside, so the user knows where they go.
710
+ *
711
+ * @param {ReleasePlan} plan - Plan.
712
+ * @param {ReleaseState} state - Snapshot.
713
+ * @param {boolean} ignoreLocalChanges - Whether `--ignore-local-changes` was chosen.
714
+ * @returns {ReleasePlan} The same plan, with a warning when changes will be set aside.
715
+ */
716
+ function warnAboutSetAsideChanges(plan, state, ignoreLocalChanges) {
717
+ const setAside = listLocalChangesToSetAside(state, plan.mode);
718
+
719
+ if (!ignoreLocalChanges || plan.steps.length === 0 || setAside.length === 0) {
720
+ return plan;
721
+ }
722
+
723
+ return {
724
+ ...plan,
725
+ warnings: [
726
+ ...plan.warnings,
727
+ `Se ignoran ${setAside.length} cambio(s) sin commitear: se apartan con git stash durante el release y se restauran al final (si el proceso se corta, recuperalos con git stash pop).`,
728
+ ],
729
+ };
730
+ }
731
+
732
+ /**
733
+ * Blocks a resume plan while `CHANGELOG.md` has uncommitted changes: only a new release commits
734
+ * that file (in its bump), so resuming would prepare or publish content that is not in the
735
+ * release commit.
736
+ *
737
+ * @param {ReleasePlan} plan - Resume plan (from `main` or from a detached release tag).
738
+ * @param {ReleaseState} state - Snapshot.
739
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
740
+ * @returns {ReleasePlan} The same plan, or a blocked plan when `CHANGELOG.md` is dirty.
741
+ */
742
+ function requireCleanChangelog(plan, state, commands) {
743
+ return requireCommittedResumeFiles(
744
+ plan,
745
+ state.workingTreeChanges.filter(isChangelogChange),
746
+ `${CHANGELOG_FILE} tiene cambios sin commitear y el release ${plan.pendingVersion} ya está commiteado`,
747
+ `Retomar un release usa el ${CHANGELOG_FILE} de su commit: descartá los cambios (git restore ${CHANGELOG_FILE}) o guardalos (git stash) y volvé a correr ${commands.createVersion}.`
748
+ );
749
+ }
750
+
751
+ /**
752
+ * Blocks a resume plan while `astra-rp.config.js` or `astra-rp.config.mjs` has uncommitted changes:
753
+ * the run imports the configuration from the working tree, so resuming would check `versionFiles`
754
+ * (and run the hooks) with a configuration other than the one of the release commit.
755
+ *
756
+ * @param {ReleasePlan} plan - Resume plan (from `main` or from a detached release tag).
757
+ * @param {ReleaseState} state - Snapshot.
758
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
759
+ * @returns {ReleasePlan} The same plan, or a blocked plan when the configuration file is dirty.
760
+ */
761
+ function requireCommittedConfig(plan, state, commands) {
762
+ return requireCommittedResumeFiles(
763
+ plan,
764
+ state.workingTreeChanges.filter((line) => listPorcelainPaths(line).some((changedPath) => CREATE_VERSION_CONFIG_FILES.includes(changedPath))),
765
+ `La configuración tiene cambios sin commitear y el release ${plan.pendingVersion} ya está commiteado`,
766
+ `Retomar un release usa la configuración de su commit (versionFiles incluido): descartá los cambios (git restore) o guardalos (git stash) y volvé a correr ${commands.createVersion}.`
767
+ );
768
+ }
769
+
770
+ /**
771
+ * Blocks a resume plan while files it reads from the release commit have uncommitted changes.
772
+ *
773
+ * @param {ReleasePlan} plan - Resume plan (from `main` or from a detached release tag).
774
+ * @param {string[]} changes - `git status --porcelain` lines of those files.
775
+ * @param {string} title - Spanish blocker title.
776
+ * @param {string} fix - Spanish instruction listed after the changes.
777
+ * @returns {ReleasePlan} The same plan, or a blocked plan when any of those files is dirty.
778
+ */
779
+ function requireCommittedResumeFiles(plan, changes, title, fix) {
780
+ if (plan.mode !== RELEASE_MODE.resume || changes.length === 0) {
781
+ return plan;
782
+ }
783
+
784
+ return blockedPlan([{ title, details: [...changes, fix] }]);
785
+ }
786
+
787
+ /**
788
+ * Builds the blocker for local `main` commits that are not release commits.
789
+ *
790
+ * @param {ReleaseCommit[]} commits - Foreign commits.
791
+ * @returns {ReleaseBlocker} Blocker.
792
+ */
793
+ export function foreignCommitsBlocker(commits) {
794
+ return {
795
+ title: `${MAIN_BRANCH} local tiene ${commits.length} commit(s) que no están en origin`,
796
+ details: [
797
+ ...commits.slice(0, MAX_LISTED_ITEMS).map((commit) => `· ${commit.subject}`),
798
+ "Movelos a una rama (git switch -c <rama>) y llevalos por un PR.",
799
+ ],
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Plans the steps still missing for a release commit already created at `HEAD`.
805
+ *
806
+ * @param {ReleaseState} state - Snapshot.
807
+ * @param {ReleaseCapabilities} capabilities - Project capabilities.
808
+ * @returns {ReleasePlan | null} Resume plan, or `null` when `HEAD` is not a pending release.
809
+ */
810
+ function planResume(state, capabilities) {
811
+ const { headVersion, headSubject, main, npm } = state;
812
+
813
+ if (!isStableReleaseVersion(headVersion) || headSubject?.trim() !== headVersion) {
814
+ return null;
815
+ }
816
+
817
+ const version = /** @type {string} */ (headVersion);
818
+ const pendingPush = main.aheadCommits.length > 0;
819
+ const pendingPublish = npm !== null && !npm.publishedVersions.includes(version);
820
+
821
+ if (!pendingPush && !pendingPublish) {
822
+ return null;
823
+ }
824
+
825
+ const foreignCommits = main.aheadCommits.filter((commit) => !isReleaseCommitSubject(commit.subject));
826
+
827
+ if (foreignCommits.length > 0) {
828
+ return blockedPlan([foreignCommitsBlocker(foreignCommits)]);
829
+ }
830
+
831
+ const needsPublish = capabilities.publish && (npm !== null ? pendingPublish : pendingPush);
832
+ /** @type {ReleasePlanStep[]} */
833
+ const steps = [];
834
+
835
+ if (capabilities.prepare && (pendingPush || needsPublish)) {
836
+ steps.push({ id: RELEASE_STEP.prepareRelease, title: `Preparar el release ${version}`, detail: "El commit de versión ya existe: se vuelve a preparar lo necesario." });
837
+ }
838
+
839
+ if (pendingPush) {
840
+ steps.push({ id: RELEASE_STEP.pushRelease, title: `Subir ${MAIN_BRANCH} y ${toReleaseTag(version)} a origin`, detail: "El release quedó creado solo en local." });
841
+ }
842
+
843
+ if (needsPublish) {
844
+ steps.push({ id: RELEASE_STEP.publishRelease, title: `${capabilities.publishTitle} (${version})`, detail: "Solo falta lo que no se completó." });
845
+ }
846
+
847
+ return steps.length > 0 ? { mode: RELEASE_MODE.resume, steps, blockers: [], warnings: [], pendingVersion: version } : null;
848
+ }
849
+
850
+ /**
851
+ * Returns the version a detached `HEAD` may publish: `HEAD` must be exactly the commit its tag
852
+ * `vX.Y.Z` points at, with subject `X.Y.Z`, and npm must report that the version is missing.
853
+ *
854
+ * @param {ReleaseState} state - Snapshot.
855
+ * @returns {string | null} Version to publish, or `null` when `HEAD` is not detached on such a release.
856
+ */
857
+ function findDetachedReleaseVersion(state) {
858
+ const { currentBranch, headVersion, headSubject, headReleaseTag, npm } = state;
859
+
860
+ if (currentBranch || !isStableReleaseVersion(headVersion) || headSubject?.trim() !== headVersion) {
861
+ return null;
862
+ }
863
+
864
+ const version = /** @type {string} */ (headVersion);
865
+ const tagged = headReleaseTag === toReleaseTag(version);
866
+ const missingFromNpm = npm?.status === NPM_LOOKUP_STATUS.ok && !npm.publishedVersions.includes(version);
867
+ return tagged && missingFromNpm ? version : null;
868
+ }
869
+
870
+ /**
871
+ * Lists why a detached release tag cannot be published: its commit must already be on `origin`
872
+ * (a detached publication never pushes `main`; it only pushes a tag missing from `origin` whose
873
+ * commit `origin/main` already has), and its version must be higher than every stable version on
874
+ * npm and than the version the `latest` dist-tag points at (the publication moves `latest`).
875
+ *
876
+ * @param {string} version - Version found by {@link findDetachedReleaseVersion}.
877
+ * @param {ReleaseState} state - Snapshot.
878
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
879
+ * @returns {ReleaseBlocker[]} Blockers, empty when the tag can be published.
880
+ */
881
+ function findDetachedReleaseBlockers(version, state, commands) {
882
+ const tag = toReleaseTag(version);
883
+ const returnToMain = `Volvé con git switch ${MAIN_BRANCH} y corré ${commands.createVersion}, que retoma el push de ${MAIN_BRANCH} y ${tag} antes de publicar.`;
884
+ /** @type {ReleaseBlocker[]} */
885
+ const blockers = [];
886
+
887
+ if (!state.remoteReleaseTagSha && !state.headOnRemoteMain) {
888
+ blockers.push({
889
+ title: `${tag} no está en origin (o no se pudo consultar origin)`,
890
+ details: [`Desacoplado solo se publica un release cuyo commit ya está en origin: publicarlo dejaría en ${state.npm?.registryLabel ?? "npm"} una versión sin su commit en origin.`, returnToMain],
891
+ });
892
+ } else if (state.remoteReleaseTagSha && state.remoteReleaseTagSha !== state.headSha) {
893
+ blockers.push({
894
+ title: `${tag} de origin apunta a otro commit que el ${tag} local`,
895
+ details: [`origin: ${state.remoteReleaseTagSha} · HEAD: ${state.headSha ?? "desconocido"}.`, `Revisá cuál es el release correcto antes de publicar. ${returnToMain}`],
896
+ });
897
+ }
898
+
899
+ const highestStable = findHighestStableVersion(state.npm?.publishedVersions ?? []);
900
+ const latestDistTag = state.npm?.latestVersion ?? null;
901
+ const higherPublished = [
902
+ { publishedVersion: highestStable, description: `la versión más alta publicada en ${state.npm?.registryLabel ?? "npm"}` },
903
+ { publishedVersion: latestDistTag, description: `la versión del dist-tag ${state.npm?.tag ?? NPM_DIST_TAG} en ${state.npm?.registryLabel ?? "npm"}` },
904
+ ].find(({ publishedVersion }) => publishedVersion && !isStableVersionAbove(version, publishedVersion));
905
+
906
+ if (higherPublished) {
907
+ blockers.push({
908
+ title: `${version} no es mayor que ${higherPublished.publishedVersion}, ${higherPublished.description}`,
909
+ details: [
910
+ `Publicarla con --tag ${NPM_DIST_TAG} movería ${NPM_DIST_TAG} hacia atrás: si hace falta, publicala a mano con otro dist-tag.`,
911
+ `Hacé git switch ${MAIN_BRANCH} para volver.`,
912
+ ],
913
+ });
914
+ }
915
+
916
+ return blockers;
917
+ }
918
+
919
+ /**
920
+ * Plans the publication of a tagged release from a detached `HEAD`: preparation and publication
921
+ * only, without syncing nor pushing `main`.
922
+ *
923
+ * @param {string} version - Version found by {@link findDetachedReleaseVersion}.
924
+ * @param {ReleaseCapabilities} capabilities - Project capabilities.
925
+ * @param {ReleaseState} state - Snapshot.
926
+ * @returns {ReleasePlan} Resume plan, or a blocker when the project has no publication step, the
927
+ * tag is not on `origin` or the version is not above the latest one on npm.
928
+ */
929
+ function planDetachedResume(version, capabilities, state) {
930
+ const tag = toReleaseTag(version);
931
+
932
+ if (!capabilities.publish) {
933
+ return blockedPlan([{ title: `${tag} no está en ${state.npm?.registryLabel ?? "npm"}, pero este proyecto no configura publish`, details: ["Agregá publish en astra-rp.config.js o publicalo a mano."] }]);
934
+ }
935
+
936
+ const blockers = findDetachedReleaseBlockers(version, state, capabilities.commands ?? DEFAULT_PROJECT_COMMANDS);
937
+
938
+ if (blockers.length > 0) {
939
+ return blockedPlan(blockers);
940
+ }
941
+
942
+ /** @type {ReleasePlanStep[]} */
943
+ const steps = [];
944
+
945
+ if (!state.remoteReleaseTagSha) {
946
+ steps.push({ id: RELEASE_STEP.pushReleaseTag, title: `Subir ${tag} a origin`, detail: `Su commit ya está en ${MAIN_BRANCH} de origin: solo falta el tag.` });
947
+ }
948
+
949
+ if (capabilities.prepare) {
950
+ steps.push({ id: RELEASE_STEP.prepareRelease, title: `Preparar el release ${version}`, detail: `Desde el tag ${tag} (HEAD desacoplado): no se toca ${MAIN_BRANCH}.` });
951
+ }
952
+
953
+ steps.push({ id: RELEASE_STEP.publishRelease, title: `${capabilities.publishTitle} (${version})`, detail: `Desde el tag ${tag}: solo falta publicarlo, sin sync ni push de ${MAIN_BRANCH}.` });
954
+ return { mode: RELEASE_MODE.resume, steps, blockers: [], warnings: [], pendingVersion: version };
955
+ }
956
+
957
+ /**
958
+ * Finds a last release that npm never received and that a new release would skip: a stable
959
+ * version (from `origin/main`) missing from the published versions and higher than every
960
+ * published one (or, with nothing published, tagged as `vX.Y.Z`).
961
+ *
962
+ * @param {ReleaseState} state - Snapshot.
963
+ * @returns {UnpublishedRelease | null} Unpublished release, or `null` when npm is not tracked or
964
+ * has it. `resumable` when its commit is `X.Y.Z` with tag `vX.Y.Z`.
965
+ */
966
+ function findUnpublishedLastRelease(state) {
967
+ const { npm, lastRelease } = state;
968
+ const version = state.releasedVersion ?? lastRelease?.version ?? null;
969
+
970
+ if (npm?.status !== NPM_LOOKUP_STATUS.ok || !isStableReleaseVersion(version) || npm.publishedVersions.includes(/** @type {string} */ (version))) {
971
+ return null;
972
+ }
973
+
974
+ const releaseVersion = /** @type {string} */ (version);
975
+ const latestPublished = findHighestStableVersion(npm.publishedVersions);
976
+
977
+ if (latestPublished && compareReleaseVersions(releaseVersion, latestPublished) <= 0) {
978
+ return null;
979
+ }
980
+
981
+ // With nothing published, an untagged version is the initial one of a package that was never
982
+ // released (not a skipped release), so only a tagged release blocks.
983
+ if (!latestPublished && !lastRelease?.tagged) {
984
+ return null;
985
+ }
986
+
987
+ const resumable = Boolean(lastRelease?.tagged) && lastRelease?.version === releaseVersion && lastRelease?.subject?.trim() === releaseVersion;
988
+ return { version: releaseVersion, latestPublished, resumable, registryLabel: npm.registryLabel };
989
+ }
990
+
991
+ /**
992
+ * Explains why no new release is planned while the last one is missing from npm, and how to publish it.
993
+ *
994
+ * @param {UnpublishedRelease} unpublished - Unpublished release.
995
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
996
+ * @returns {ReleaseBlocker} Blocker.
997
+ */
998
+ function unpublishedReleaseBlocker({ version, latestPublished, resumable, registryLabel = "npm" }, commands) {
999
+ const tag = toReleaseTag(version);
1000
+ const published = latestPublished ? `${registryLabel} llega hasta ${latestPublished}` : `${registryLabel} no tiene ninguna versión publicada`;
1001
+ const howToPublish = resumable
1002
+ ? [
1003
+ `Para publicarla: git switch --detach ${tag} y ${commands.createVersion}, que retoma solo la preparación y la publicación desde el tag (sin tocar ${MAIN_BRANCH}).`,
1004
+ `Después volvé con git switch ${MAIN_BRANCH} y corré ${commands.createVersion} para el release nuevo.`,
1005
+ ]
1006
+ : [`Su commit no es un commit ${version} con el tag ${tag}, así que create-version no puede retomarlo: publicala a mano desde ese commit.`];
1007
+
1008
+ return {
1009
+ title: `La versión ${version} (último release, tag ${tag}) no está en ${registryLabel}`,
1010
+ details: [`${published}; un release nuevo la saltearía.`, ...howToPublish, `Para saltearla a propósito: ${describeCreateVersionWithOptions(commands)} --${CREATE_VERSION_FLAG.skipUnpublished}.`],
1011
+ };
1012
+ }
1013
+
1014
+ /**
1015
+ * Warns that `--skip-unpublished` leaves the last release out of npm on purpose.
1016
+ *
1017
+ * @param {UnpublishedRelease} unpublished - Skipped release.
1018
+ * @returns {string} Warning.
1019
+ */
1020
+ function skippedReleaseWarning({ version, registryLabel = "npm" }) {
1021
+ return `Se saltea ${version} (tag ${toReleaseTag(version)}), que no está en ${registryLabel}: el release nuevo sale sin publicarla (--skip-unpublished).`;
1022
+ }
1023
+
1024
+ /**
1025
+ * Checks the last release of `origin/main` missing from npm before resuming a different local
1026
+ * release: pushing and publishing that one would skip it. `--skip-unpublished` skips it on purpose.
1027
+ *
1028
+ * @param {ReleasePlan} resume - Plan built by {@link planResume}.
1029
+ * @param {ReleaseState} state - Snapshot.
1030
+ * @param {boolean} skipUnpublished - Whether `--skip-unpublished` was chosen.
1031
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
1032
+ * @returns {ReleasePlan} The same plan, blocked, or with a warning when the release is skipped.
1033
+ */
1034
+ function checkUnpublishedBeforeResume(resume, state, skipUnpublished, commands) {
1035
+ const unpublished = resume.mode === RELEASE_MODE.resume ? findUnpublishedLastRelease(state) : null;
1036
+
1037
+ if (!unpublished || unpublished.version === resume.pendingVersion) {
1038
+ return resume;
1039
+ }
1040
+
1041
+ return skipUnpublished
1042
+ ? { ...resume, warnings: [...resume.warnings, skippedReleaseWarning(unpublished)] }
1043
+ : blockedPlan([unpublishedReleaseBlocker(unpublished, commands)]);
1044
+ }
1045
+
1046
+ /**
1047
+ * Stops a plan that would publish when the npm credential check found a problem, or warns when
1048
+ * the check could not decide or the first publication cannot be told apart from a hidden private package.
1049
+ *
1050
+ * @param {ReleasePlan} plan - Plan built from the rest of the snapshot.
1051
+ * @param {NpmAuthCheck | null | undefined} npmAuth - Credential check, when the project publishes to npm.
1052
+ * @param {ProjectCommands} commands - Project commands quoted by the fix.
1053
+ * @returns {ReleasePlan} The same plan, blocked or with a warning when needed.
1054
+ */
1055
+ function applyNpmAuth(plan, npmAuth, commands) {
1056
+ if (!npmAuth || !plan.steps.some((planStep) => planStep.id === RELEASE_STEP.publishRelease)) {
1057
+ return plan;
1058
+ }
1059
+
1060
+ const { problem, warning } = classifyNpmAuth(npmAuth, commands);
1061
+
1062
+ if (problem) {
1063
+ return blockedPlan([problem]);
1064
+ }
1065
+
1066
+ return warning ? { ...plan, warnings: [...plan.warnings, warning] } : plan;
1067
+ }
1068
+
1069
+ /**
1070
+ * Stops a new release that nothing would validate before the version is touched.
1071
+ *
1072
+ * @param {ProjectCommands} commands - Project commands quoted by the hints.
1073
+ * @returns {ReleaseBlocker} Blocker.
1074
+ */
1075
+ export function missingChecksBlocker(commands) {
1076
+ return {
1077
+ title: "El proyecto no valida nada antes de publicar",
1078
+ details: [
1079
+ `Agregá un script ${DEFAULT_CHECKS_SCRIPT} en package.json (se corre ${commands.runScript(DEFAULT_CHECKS_SCRIPT)}) o checks en astra-rp.config.(m)js.`,
1080
+ "Para saltear la validación a propósito: checks: false.",
1081
+ ],
1082
+ };
1083
+ }
1084
+
1085
+ /**
1086
+ * Decides what is still missing to publish a release from `main` (or, from a detached `HEAD` on
1087
+ * a release tag, to publish that release).
1088
+ *
1089
+ * @param {ReleaseState} state - Snapshot gathered by `state.js`.
1090
+ * @param {ReleaseCapabilities} [capabilities] - Steps the project configured.
1091
+ * @param {PlanOptions} [planOptions] - Options chosen on the command line.
1092
+ * @returns {ReleasePlan} Ordered plan.
1093
+ */
1094
+ export function buildReleasePlan(state, capabilities = DEFAULT_CAPABILITIES, planOptions = {}) {
1095
+ const commands = capabilities.commands ?? DEFAULT_PROJECT_COMMANDS;
1096
+ const ignoreLocalChanges = planOptions.ignoreLocalChanges ?? false;
1097
+ const codeSafePlan = refuseToSetAsideCodeChanges(planRelease(state, capabilities, planOptions), state, ignoreLocalChanges, commands);
1098
+ const plan = refuseToSetAsideCiWorkerChanges(codeSafePlan, state, ignoreLocalChanges, planOptions.ciWorkerFiles ?? [], commands);
1099
+ return warnAboutSetAsideChanges(applyNpmAuth(plan, state.npmAuth, commands), state, ignoreLocalChanges);
1100
+ }
1101
+
1102
+ /**
1103
+ * Plans the release from the snapshot, before the npm credential check is applied.
1104
+ *
1105
+ * @param {ReleaseState} state - Snapshot gathered by `state.js`.
1106
+ * @param {ReleaseCapabilities} capabilities - Steps the project configured.
1107
+ * @param {PlanOptions} planOptions - Options chosen on the command line.
1108
+ * @returns {ReleasePlan} Ordered plan.
1109
+ */
1110
+ function planRelease(state, capabilities, { skipUnpublished = false, ignoreLocalChanges = false }) {
1111
+ const commands = capabilities.commands ?? DEFAULT_PROJECT_COMMANDS;
1112
+ const blockers = findBlockers(state, ignoreLocalChanges, commands);
1113
+
1114
+ if (blockers.length > 0) {
1115
+ return blockedPlan(blockers);
1116
+ }
1117
+
1118
+ const detachedVersion = findDetachedReleaseVersion(state);
1119
+
1120
+ if (detachedVersion) {
1121
+ const detachedPlan = requireCommittedConfig(planDetachedResume(detachedVersion, capabilities, state), state, commands);
1122
+ return requireCleanChangelog(detachedPlan, state, commands);
1123
+ }
1124
+
1125
+ const resume = planResume(state, capabilities);
1126
+
1127
+ if (resume) {
1128
+ const resumePlan = requireCommittedConfig(checkUnpublishedBeforeResume(resume, state, skipUnpublished, commands), state, commands);
1129
+ return requireCleanChangelog(resumePlan, state, commands);
1130
+ }
1131
+
1132
+ if (state.main.aheadCommits.length > 0) {
1133
+ return blockedPlan([foreignCommitsBlocker(state.main.aheadCommits)]);
1134
+ }
1135
+
1136
+ const unpublished = findUnpublishedLastRelease(state);
1137
+
1138
+ if (unpublished && !skipUnpublished) {
1139
+ return blockedPlan([unpublishedReleaseBlocker(unpublished, commands)]);
1140
+ }
1141
+
1142
+ if (state.unreleasedCommits.length === 0) {
1143
+ return { mode: RELEASE_MODE.upToDate, steps: [], blockers: [], warnings: [], pendingVersion: null };
1144
+ }
1145
+
1146
+ /** @type {ReleasePlanStep[]} */
1147
+ const steps = [];
1148
+ /** @type {string[]} */
1149
+ const warnings = unpublished ? [skippedReleaseWarning(unpublished)] : [];
1150
+
1151
+ // The rest of the plan depends on the code and configuration of the updated main, so the run
1152
+ // stops after syncing and the next one diagnoses again.
1153
+ if (state.main.behindCount > 0) {
1154
+ steps.push(syncMainPlanStep(state.main.behindCount, commands));
1155
+ return { mode: RELEASE_MODE.newRelease, steps, blockers: [], warnings, pendingVersion: null };
1156
+ }
1157
+
1158
+ if (capabilities.checksMissing) {
1159
+ return blockedPlan([missingChecksBlocker(commands)]);
1160
+ }
1161
+
1162
+ if (state.changelog.updated !== true) {
1163
+ return blockedPlan([changelogUpdateRequiredBlocker(state.changelog.reason, CHANGELOG_FILE, commands)], warnings);
1164
+ }
1165
+
1166
+ const missingEntries = unreleasedEntriesRequiredBlocker(state.changelog, CHANGELOG_FILE, commands);
1167
+ if (missingEntries) {
1168
+ return blockedPlan([missingEntries], warnings);
1169
+ }
1170
+
1171
+ steps.push({
1172
+ id: RELEASE_STEP.verifyChangelog,
1173
+ title: "Verificar el CHANGELOG y sus entradas en [Unreleased]",
1174
+ detail: "Debe haber cambiado desde el último release y tener entradas en [Unreleased]; el commit de versión cierra esa sección.",
1175
+ });
1176
+
1177
+ addMigrationsPlanStep(state.migrations, steps, warnings);
1178
+
1179
+ if (capabilities.checks) {
1180
+ steps.push({ id: RELEASE_STEP.runChecks, title: "Validar el proyecto", detail: "Corre los checks configurados antes de tocar la versión." });
1181
+ }
1182
+
1183
+ steps.push({
1184
+ id: RELEASE_STEP.bumpVersion,
1185
+ title: "Elegir la nueva versión y crear commit + tag",
1186
+ detail: "Actualiza package.json y cierra [Unreleased] del CHANGELOG como ## [X.Y.Z] - AAAA-MM-DD, con un [Unreleased] vacío arriba.",
1187
+ });
1188
+
1189
+ if (capabilities.prepare) {
1190
+ steps.push({ id: RELEASE_STEP.prepareRelease, title: "Preparar el release", detail: "Corre la preparación configurada sobre el commit de versión." });
1191
+ }
1192
+
1193
+ steps.push({ id: RELEASE_STEP.pushRelease, title: `Subir ${MAIN_BRANCH} y el tag a origin` });
1194
+
1195
+ if (capabilities.publish) {
1196
+ steps.push({ id: RELEASE_STEP.publishRelease, title: capabilities.publishTitle });
1197
+ }
1198
+
1199
+ return { mode: RELEASE_MODE.newRelease, steps, blockers: [], warnings, pendingVersion: null };
1200
+ }