@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,555 @@
1
+ /**
2
+ * Selects the release location and plans the worker for an already-created release tag.
3
+ * @module create-version/ci
4
+ */
5
+
6
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import {
9
+ CI_COMMIT_SHA_PATTERN, CI_RELEASE_ENVIRONMENT, CI_RUNTIME_DISABLED_VALUES, CI_RUNTIME_ENVIRONMENT, CI_SETUP_CHOICE,
10
+ CI_WORKFLOW_DIRECTORY, DEFAULT_CI_WORKFLOW, DISPATCH_CI_RELEASE_STEP, RELEASE_EXECUTION,
11
+ CI_MIGRATION_ENVIRONMENT_PATTERN, CI_NODE_VERSION_PIN_PATTERN, CI_RELEASE_METADATA_FILE, CI_VERCEL_CONFIG_FILE, RELEASE_EXECUTION_TRAILER, CI_VERCEL_DEPLOYMENT, CI_WORKFLOW_DRIFT_PREVIEW_LINES,
12
+ CI_GITHUB_PACKAGES_AUTHENTICATION_STEP, CI_GITHUB_PACKAGES_PERMISSION_LINE_PATTERN, CI_GITHUB_TOKEN_JOB_BINDING, CI_WORKFLOW_STEP_PREFIX,
13
+ } from "../constants/ci-release.js";
14
+ import { BROWSER_AUTHENTICATION, GITHUB_PACKAGES_REGISTRY_URL, GITHUB_REGISTRY_PROVIDER, GITHUB_REGISTRY_TOKEN_VARIABLE, JSR_REGISTRY_PROVIDER, NPM_OIDC_MINIMUM_NODE_VERSION, NPM_OIDC_MINIMUM_VERSION, NPM_REGISTRY_PROVIDER, OIDC_AUTHENTICATION, REGISTRY_TOKEN_VARIABLES, TOKEN_AUTHENTICATION } from "../constants/registry.js";
15
+ import { LOCKFILE_PACKAGE_MANAGERS, PACKAGE_MANAGER } from "../constants/package-manager.js";
16
+ import { CREATE_VERSION_FLAG, DEFAULT_NPM_REGISTRY_URL, MAIN_BRANCH, MIGRATION_STATUS, NPM_AUTH_STATUS, NPM_LOOKUP_STATUS, PACKAGE_MANIFEST_FILE, PINNED_NODE_VERSION_FILE, RELEASE_MODE, RELEASE_REMOTE, RELEASE_STEP, REMOTE_MAIN_REF } from "../constants/create-version.js";
17
+ import { RELEASE_TAG_PREFIX } from "../constants/versions.js";
18
+ import { compareReleaseVersions, findHighestStableVersion, isStableReleaseVersion, parseReleaseVersion, toReleaseTag } from "../versions.js";
19
+ import { print, select } from "../terminal-ui.js";
20
+ import { ReleaseStepError, describeError } from "./errors.js";
21
+ import { assertSafeCiPath, describeCiNodeVersion, extractPinnedPackageManagerVersion, prepareCiReleaseWorkflow, readRunningNodeMajor, renderCiReleaseWorkflow } from "./ci-setup.js";
22
+ import { listCommittedTreeEntries, readCommittedRegularFile } from "./committed-tree.js";
23
+ import { runCaptured } from "./process.js";
24
+ import { readReleaseExecutionTrailer } from "./release-execution.js";
25
+ import { ciWorkflowPath } from "./ci-config.js";
26
+ import { isRegistryProvider } from "./registry-config.js";
27
+ import { findNpmOidcRegistryProblem, resolveRegistry, selectProjectRegistry } from "./registry.js";
28
+
29
+ /**
30
+ * Detects a CI runtime from its conventional environment signals, accepting values such as `1`,
31
+ * `TRUE` or `yes` and treating only unset, empty or explicit false forms (`0`, `false`, `no`, `off`) as disabled.
32
+ * @returns {boolean} True when any CI signal is enabled.
33
+ */
34
+ function isCiRuntime() {
35
+ return CI_RUNTIME_ENVIRONMENT.some((name) => {
36
+ const value = process.env[name];
37
+ return value !== undefined && !CI_RUNTIME_DISABLED_VALUES.includes(value.trim().toLowerCase());
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Selects the execution location without ever dispatching again inside a CI runtime.
43
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Project configuration.
44
+ * @param {import("./plan.js").ReleaseOptions} options - CLI overrides.
45
+ * @returns {Promise<{ execution: "local" | "ci", setup: boolean } | null>} Selection, or null when canceled.
46
+ */
47
+ export async function chooseReleaseExecution(config, options) {
48
+ if (options.ciRelease || isCiRuntime()) {
49
+ if (options.execution === RELEASE_EXECUTION.ci || options.setupCi || options.retryCi) throw new ReleaseStepError("Un proceso de CI no puede disparar otro release en CI.", "Usá --ci-release con el tag recibido o --local.");
50
+ return { execution: RELEASE_EXECUTION.local, setup: false };
51
+ }
52
+ if (options.retryCi) return { execution: RELEASE_EXECUTION.ci, setup: false };
53
+ if (options.execution === RELEASE_EXECUTION.local) return { execution: RELEASE_EXECUTION.local, setup: false };
54
+ if (options.setupCi) return { execution: RELEASE_EXECUTION.ci, setup: true };
55
+ if (config.ci) {
56
+ const execution = !process.stdin.isTTY || options.execution || options.dryRun ? options.execution ?? RELEASE_EXECUTION.ci : await select({
57
+ message: "¿Dónde hacemos el release?",
58
+ options: [{ label: "CI", hint: `${config.ci.workflow} configurado; los checks corren en GitHub`, value: RELEASE_EXECUTION.ci }, { label: "Local", hint: "ejecuta el release completo en esta máquina", value: RELEASE_EXECUTION.local }],
59
+ });
60
+ return { execution: /** @type {"local" | "ci"} */ (execution), setup: false };
61
+ }
62
+ // An explicit --ci never falls back to a question: without a workflow there is nothing to dispatch.
63
+ if (options.execution === RELEASE_EXECUTION.ci) throw new ReleaseStepError("No hay un workflow de release configurado para --ci.", "Ejecutá --setup-ci o configurá ci.workflow en astra-rp.config.js; también podés usar --local.");
64
+ print("Para hacer releases en CI, astra-rp puede crear .github/workflows/release.yml y configurar ci.workflow. Usá --setup-ci para configurarlo o --local para continuar en esta máquina.");
65
+ if (options.dryRun || !process.stdin.isTTY) {
66
+ return { execution: RELEASE_EXECUTION.local, setup: false };
67
+ }
68
+ const choice = await select({
69
+ message: "Este repo todavía no tiene CI para releases",
70
+ options: [{ label: "Configurar CI automáticamente", value: CI_SETUP_CHOICE.configure }, { label: "Continuar con el release en local", value: CI_SETUP_CHOICE.local }, { label: "Cancelar", value: CI_SETUP_CHOICE.cancel }],
71
+ defaultIndex: null,
72
+ });
73
+ return choice === CI_SETUP_CHOICE.cancel ? null : { execution: choice === CI_SETUP_CHOICE.configure ? RELEASE_EXECUTION.ci : RELEASE_EXECUTION.local, setup: choice === CI_SETUP_CHOICE.configure };
74
+ }
75
+
76
+ /**
77
+ * Tells whether a pin installed by the worker (`npm install --global npm@<pin>` or setup-node's
78
+ * Node.js version) can resolve to at least the given stable version.
79
+ * A partial pin such as `11` resolves to the newest matching release, so it reaches any minimum
80
+ * that shares its leading components; a prerelease pin is below its own `X.Y.Z` release.
81
+ * @param {string} pinnedVersion - Version accepted by `CI_PACKAGE_MANAGER_VERSION_PATTERN` or `CI_NODE_VERSION_PIN_PATTERN`, such as `10.9.0`, `11` or `11.6.0-rc.1`.
82
+ * @param {string} minimumVersion - Stable `X.Y.Z` minimum.
83
+ * @returns {boolean} True when the pinned npm can satisfy the minimum.
84
+ */
85
+ function canPinnedVersionReach(pinnedVersion, minimumVersion) {
86
+ const prereleaseSeparatorIndex = pinnedVersion.indexOf("-");
87
+ const releasePart = prereleaseSeparatorIndex === -1 ? pinnedVersion : pinnedVersion.slice(0, prereleaseSeparatorIndex);
88
+ const pinnedComponents = releasePart.split(".").map(Number);
89
+ const minimumComponents = parseReleaseVersion(minimumVersion);
90
+ const differentIndex = pinnedComponents.findIndex((component, index) => component !== minimumComponents[index]);
91
+ if (differentIndex !== -1) return pinnedComponents[differentIndex] > minimumComponents[differentIndex];
92
+ return pinnedComponents.length < minimumComponents.length || prereleaseSeparatorIndex === -1;
93
+ }
94
+
95
+ /**
96
+ * Rejects the Node.js runtime the generated workflow selects when it is older than npm trusted
97
+ * publishing requires, whatever package manager installs dependencies. Aliases such as `lts/*` or
98
+ * `lts/iron` cannot be resolved locally and may select a runtime below the minimum (`lts/iron` is
99
+ * Node 20), which the worker would only reject after the tag was pushed, so they are rejected too.
100
+ * @param {string} repositoryRoot - Project root whose `.nvmrc` committed at HEAD, or the local Node.js major, selects the worker's runtime.
101
+ * @returns {Promise<void>}
102
+ * @throws {ReleaseStepError} When the selected Node.js is an unverifiable alias or cannot reach the npm OIDC minimum.
103
+ */
104
+ async function assertCiNodeSupportsNpmOidc(repositoryRoot) {
105
+ const nodeVersion = await describeCiNodeVersion(repositoryRoot);
106
+ if (!CI_NODE_VERSION_PIN_PATTERN.test(nodeVersion.version)) {
107
+ throw new ReleaseStepError(
108
+ `${nodeVersion.pinnedFile ?? PINNED_NODE_VERSION_FILE} fija el alias de Node "${nodeVersion.version}" en el worker de CI, que no se puede verificar antes del release, pero la publicación npm con publication.authentication "${OIDC_AUTHENTICATION}" requiere Node >= ${NPM_OIDC_MINIMUM_NODE_VERSION}; el worker la rechazaría después de subir el tag si el alias resuelve a una versión anterior.`,
109
+ `Fijá ${PINNED_NODE_VERSION_FILE} a una versión numérica ${NPM_OIDC_MINIMUM_NODE_VERSION} o superior (por ejemplo 24) en un commit propio (regenerá el workflow si ya existe), configurá publication.authentication "token" con su secret o usá --${CREATE_VERSION_FLAG.local}; no se creó, subió ni reenvió ningún release.`
110
+ );
111
+ }
112
+ if (canPinnedVersionReach(nodeVersion.version, NPM_OIDC_MINIMUM_NODE_VERSION)) return;
113
+ const runtimeSource = nodeVersion.pinnedFile ? `${nodeVersion.pinnedFile} fija Node ${nodeVersion.version}` : `Sin ${PINNED_NODE_VERSION_FILE}, el workflow usa Node ${nodeVersion.version} (el major de este Node.js)`;
114
+ throw new ReleaseStepError(
115
+ `${runtimeSource} en el worker de CI, pero la publicación npm con publication.authentication "${OIDC_AUTHENTICATION}" requiere Node >= ${NPM_OIDC_MINIMUM_NODE_VERSION}; el worker la rechazaría después de subir el tag.`,
116
+ `Subí ${PINNED_NODE_VERSION_FILE} a ${NPM_OIDC_MINIMUM_NODE_VERSION} o superior en un commit propio (regenerá el workflow si ya existe), configurá publication.authentication "token" con su secret o usá --${CREATE_VERSION_FLAG.local}; no se creó, subió ni reenvió ningún release.`
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Rejects registry authentication that a non-interactive GitHub Actions worker can never complete,
122
+ * before the immutable release commit and tag exist (or before a retry dispatches them again).
123
+ * OIDC stays allowed, since only the worker can prove it, except when the runtime the generated
124
+ * workflow sets up provably cannot perform npm trusted publishing: the worker would reject it only
125
+ * after the release was pushed. The Node.js runtime is checked for every package manager. With npm,
126
+ * the version pinned by `packageManager` is checked too and never raised silently, because that
127
+ * would change the client that runs `npm ci` against the committed lockfile; an unpinned npm
128
+ * project and other package managers get a capable npm installed by the workflow for publication
129
+ * (see `requiresCiNpmOidcClient`), after `npm ci` in the unpinned npm case. The effective npm
130
+ * destination is resolved as well, since trusted publishing only exists on the public npm registry.
131
+ * A token publication named `GITHUB_TOKEN` gets the built-in Actions token injected by the generated
132
+ * workflow, which only GitHub Packages accepts, so its effective destination is resolved too.
133
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Project configuration.
134
+ * @param {string} repositoryRoot - Project root whose `package.json`, `.nvmrc` and npm config pin the worker's runtime and destination.
135
+ * @returns {Promise<void>}
136
+ * @throws {ReleaseStepError} When the registry publication authorizes from a browser, uses npm OIDC with a Node.js or npm pin below the trusted publishing minimum or a registry other than the public npm one, or names `GITHUB_TOKEN` as the token of a destination other than GitHub Packages.
137
+ */
138
+ export async function assertCiCompatiblePublication(config, repositoryRoot) {
139
+ if (!isRegistryProvider(config.publish)) return;
140
+ if (config.publication?.authentication === BROWSER_AUTHENTICATION) {
141
+ throw new ReleaseStepError(
142
+ `${config.publish} con publication.authentication "${BROWSER_AUTHENTICATION}" no puede publicar desde GitHub Actions: el worker no es interactivo.`,
143
+ `Configurá publication.authentication "oidc" (con el paquete vinculado al repositorio) o "token" con su secret, o usá --${CREATE_VERSION_FLAG.local}; no se creó la versión ni el tag.`
144
+ );
145
+ }
146
+ const publishesWithNpmOidc = config.publish === NPM_REGISTRY_PROVIDER && config.publication?.authentication === OIDC_AUTHENTICATION;
147
+ const publishesWithBuiltInGithubToken = config.publication?.authentication === TOKEN_AUTHENTICATION && config.publication.tokenEnv === GITHUB_REGISTRY_TOKEN_VARIABLE;
148
+ if (!publishesWithNpmOidc && !publishesWithBuiltInGithubToken) return;
149
+ if (publishesWithNpmOidc) await assertCiNodeSupportsNpmOidc(repositoryRoot);
150
+ const manifest = readCiPublicationManifest(repositoryRoot);
151
+ if (publishesWithBuiltInGithubToken) {
152
+ // JSR reads its identity from its own manifest; npm-protocol destinations need package.json to resolve.
153
+ if (manifest === null && config.publish !== JSR_REGISTRY_PROVIDER) return;
154
+ await assertCiBuiltInGithubTokenRegistry(config, manifest ?? {}, repositoryRoot);
155
+ return;
156
+ }
157
+ if (manifest === null) return;
158
+ if (config.commands.packageManager === PACKAGE_MANAGER.npm) assertCiNpmPinSupportsOidc(manifest);
159
+ await assertCiNpmOidcRegistry(config, manifest, repositoryRoot);
160
+ }
161
+
162
+ /**
163
+ * Reads the project manifest that routes the worker's publication.
164
+ * @param {string} repositoryRoot - Project root.
165
+ * @returns {Record<string, unknown> | null} Parsed `package.json`, or `null` when the project has none.
166
+ * @throws {ReleaseStepError} When `package.json` exists but cannot be read or parsed.
167
+ */
168
+ function readCiPublicationManifest(repositoryRoot) {
169
+ const manifestPath = path.join(repositoryRoot, PACKAGE_MANIFEST_FILE);
170
+ if (!existsSync(manifestPath)) return null;
171
+ try {
172
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
173
+ } catch (error) {
174
+ throw new ReleaseStepError(`No se pudo leer ${PACKAGE_MANIFEST_FILE} para comprobar la publicación del worker de CI.`, `Corregí ${PACKAGE_MANIFEST_FILE}; no se creó, subió ni reenvió ningún release.`, { cause: error });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Rejects an npm `packageManager` pin below the trusted publishing minimum: the worker installs
180
+ * exactly that npm and runs `npm ci` with it, so it is never raised silently.
181
+ * @param {Record<string, unknown>} manifest - Parsed project `package.json`.
182
+ * @returns {void}
183
+ * @throws {ReleaseStepError} When the pinned npm cannot reach the npm OIDC minimum.
184
+ */
185
+ function assertCiNpmPinSupportsOidc(manifest) {
186
+ const pinnedNpmVersion = extractPinnedPackageManagerVersion(manifest);
187
+ if (pinnedNpmVersion === null || canPinnedVersionReach(pinnedNpmVersion, NPM_OIDC_MINIMUM_VERSION)) return;
188
+ throw new ReleaseStepError(
189
+ `packageManager fija npm@${pinnedNpmVersion}, que el worker de CI instala, pero la publicación npm con publication.authentication "${OIDC_AUTHENTICATION}" requiere npm >= ${NPM_OIDC_MINIMUM_VERSION}; el worker la rechazaría después de subir el tag.`,
190
+ `Subí packageManager a npm@${NPM_OIDC_MINIMUM_VERSION} o superior en un commit propio (regenerá el workflow si ya existe), configurá publication.authentication "token" con su secret o usá --${CREATE_VERSION_FLAG.local}; no se creó, subió ni reenvió ningún release.`
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Resolves the destination the worker publishes to (`publication.registryUrl`, `publishConfig` or
196
+ * npm's project config, as the worker does) before the immutable release commit and tag exist.
197
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Configuration publishing to a registry.
198
+ * @param {Record<string, unknown>} manifest - Parsed project `package.json`.
199
+ * @param {string} repositoryRoot - Project root whose npm configuration routes the publication.
200
+ * @param {string} publicationDescription - Publication being checked, used in the error message.
201
+ * @returns {Promise<import("./registry.js").SelectedRegistry>} Effective destination.
202
+ * @throws {ReleaseStepError} When the destination cannot be resolved.
203
+ */
204
+ async function resolveCiPublicationRegistry(config, manifest, repositoryRoot, publicationDescription) {
205
+ try {
206
+ return await resolveRegistry(selectProjectRegistry(config), manifest, repositoryRoot);
207
+ } catch (error) {
208
+ throw new ReleaseStepError(
209
+ `No se pudo resolver el registry de ${publicationDescription} para comprobarlo antes del release en CI: ${describeError(error)}.`,
210
+ `Corregí publication.registryUrl, publishConfig o la configuración de npm del proyecto; no se creó, subió ni reenvió ningún release.`,
211
+ { cause: error }
212
+ );
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Rejects an npm trusted publishing destination other than the public npm registry, before the
218
+ * immutable release commit and tag exist.
219
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Configuration publishing to npm through OIDC.
220
+ * @param {Record<string, unknown>} manifest - Parsed project `package.json`.
221
+ * @param {string} repositoryRoot - Project root whose npm configuration routes the publication.
222
+ * @returns {Promise<void>}
223
+ * @throws {ReleaseStepError} When the destination cannot be resolved or is not the public npm registry.
224
+ */
225
+ async function assertCiNpmOidcRegistry(config, manifest, repositoryRoot) {
226
+ const registry = await resolveCiPublicationRegistry(config, manifest, repositoryRoot, `la publicación npm con publication.authentication "${OIDC_AUTHENTICATION}"`);
227
+ const registryProblem = findNpmOidcRegistryProblem(registry);
228
+ if (registryProblem === null) return;
229
+ throw new ReleaseStepError(
230
+ `La publicación npm con publication.authentication "${OIDC_AUTHENTICATION}" apunta a ${registry.registryUrl} (${registry.label}): ${registryProblem}; el worker la rechazaría después de subir el tag.`,
231
+ `Publicá en ${DEFAULT_NPM_REGISTRY_URL} (quitá publication.registryUrl, publishConfig.registry o el registry del .npmrc del proyecto), configurá publication.authentication "token" con su secret o usá --${CREATE_VERSION_FLAG.local}; no se creó, subió ni reenvió ningún release.`
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Rejects `GITHUB_TOKEN` as the publication token of a destination other than GitHub Packages: the
237
+ * generated workflow binds that reserved name to the built-in Actions token (`github.token`) without
238
+ * requiring a stored secret, and any other registry would reject it only after the tag was pushed.
239
+ * Declaring `GITHUB_TOKEN` in `ci.secrets` to install private GitHub Packages dependencies is unaffected.
240
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Configuration publishing with token `GITHUB_TOKEN`.
241
+ * @param {Record<string, unknown>} manifest - Parsed project `package.json`.
242
+ * @param {string} repositoryRoot - Project root whose npm configuration routes the publication.
243
+ * @returns {Promise<void>}
244
+ * @throws {ReleaseStepError} When the destination cannot be resolved or is not GitHub Packages.
245
+ */
246
+ async function assertCiBuiltInGithubTokenRegistry(config, manifest, repositoryRoot) {
247
+ const registry = await resolveCiPublicationRegistry(config, manifest, repositoryRoot, `la publicación con publication.tokenEnv "${GITHUB_REGISTRY_TOKEN_VARIABLE}"`);
248
+ if (registry.provider === GITHUB_REGISTRY_PROVIDER) return;
249
+ throw new ReleaseStepError(
250
+ `La publicación con publication.tokenEnv "${GITHUB_REGISTRY_TOKEN_VARIABLE}" apunta a ${registry.registryUrl} (${registry.label}), pero el workflow de CI entrega en ${GITHUB_REGISTRY_TOKEN_VARIABLE} el token integrado de Actions, que solo acepta GitHub Packages (${GITHUB_PACKAGES_REGISTRY_URL}); el worker fallaría después de subir el tag.`,
251
+ `Guardá el token de ${registry.label} como secret con otro nombre y configuralo en publication.tokenEnv (por ejemplo "${REGISTRY_TOKEN_VARIABLES[registry.provider]}"), publicá en GitHub Packages o usá --${CREATE_VERSION_FLAG.local}; no se creó, subió ni reenvió ningún release.`
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Rejects resuming a pending release in a location different from the one it was prepared in: the
257
+ * Vercel build gate decides from the committed release metadata, and a CI preparation skipped the
258
+ * checks and migrations that a local resume (which only pushes and publishes) would never run.
259
+ * The Vercel metadata wins when it names this version; otherwise the release commit trailer decides.
260
+ * Release commits without either mark (created before they existed) are accepted as before.
261
+ * @param {import("./process.js").GitReader} reader - Git reader of the checkout whose HEAD is the release commit.
262
+ * @param {string} version - Version of the pending release.
263
+ * @param {"local" | "ci"} execution - Location selected for this run.
264
+ * @returns {Promise<void>}
265
+ * @throws {ReleaseStepError} When the committed mark belongs to this version and names another location, or is unreadable.
266
+ */
267
+ export async function assertResumeExecutionMatches(reader, version, execution) {
268
+ const committedMetadata = await reader.tryGit(["show", `HEAD:${CI_RELEASE_METADATA_FILE}`]);
269
+ let metadata = null;
270
+ if (committedMetadata !== null) {
271
+ try {
272
+ metadata = JSON.parse(committedMetadata);
273
+ } catch (error) {
274
+ throw new ReleaseStepError(`${CI_RELEASE_METADATA_FILE} del commit de release ${version} no es JSON válido.`, "Corregilo con un commit de release nuevo; no se subió ni publicó nada.", { cause: error });
275
+ }
276
+ }
277
+ const recordedInMetadata = metadata?.version === version;
278
+ const committedExecution = recordedInMetadata ? metadata.execution : await readReleaseExecutionTrailer(reader, version);
279
+ if (committedExecution === null || committedExecution === execution) return;
280
+ const resumeFlag = committedExecution === RELEASE_EXECUTION.local ? `--${CREATE_VERSION_FLAG.local}` : `--${CREATE_VERSION_FLAG.ci} (o --${CREATE_VERSION_FLAG.retryCi} ${toReleaseTag(version)} si el tag ya está en origin)`;
281
+ throw new ReleaseStepError(
282
+ `El release ${version} se creó para ejecutarse en ${committedExecution} (${recordedInMetadata ? CI_RELEASE_METADATA_FILE : `trailer ${RELEASE_EXECUTION_TRAILER} del commit`}), pero esta ejecución eligió ${execution}.`,
283
+ `Retomalo con ${resumeFlag}: los checks, las migraciones y el deploy dependen del modo con que se preparó el commit; no se subió ni publicó nada.`
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Checks that the configured workflow is a regular file inside the project.
289
+ * @param {string} repositoryRoot - Project root.
290
+ * @param {string} workflow - Validated workflow filename.
291
+ * @returns {void}
292
+ * @throws {ReleaseStepError} When the configured workflow is missing or a symlink.
293
+ */
294
+ function assertCiWorkflowFile(repositoryRoot, workflow) {
295
+ assertSafeCiPath(repositoryRoot, ciWorkflowPath(workflow));
296
+ const workflowPath = path.join(repositoryRoot, CI_WORKFLOW_DIRECTORY, workflow);
297
+ if (!existsSync(workflowPath) || !lstatSync(workflowPath).isFile()) throw new ReleaseStepError(`No existe un workflow regular en ${ciWorkflowPath(workflow)}.`, "Crealo y commitealo, o quitá ci.workflow y ejecutá --setup-ci; también podés elegir --local.");
298
+ }
299
+
300
+ /**
301
+ * Reads the configured workflow from HEAD after checking it is a regular file inside the project.
302
+ * `gh workflow run --ref <tag>` only finds workflows contained in the pushed release commit, so a
303
+ * local file that is untracked, ignored or set aside by `--ignore-local-changes` is not enough.
304
+ * @param {string} repositoryRoot - Project root.
305
+ * @param {string} workflow - Validated workflow filename.
306
+ * @param {string} [missingHint] - Recovery hint shown when HEAD does not contain the workflow.
307
+ * @returns {Promise<string>} Workflow content committed at HEAD.
308
+ * @throws {ReleaseStepError} When the workflow is missing, a symlink or not committed at HEAD.
309
+ */
310
+ export async function readCommittedCiWorkflow(repositoryRoot, workflow, missingHint = `Commitealo en un commit propio antes de crear el release, o elegí --${CREATE_VERSION_FLAG.local}. No se creó la versión ni el tag.`) {
311
+ assertCiWorkflowFile(repositoryRoot, workflow);
312
+ const workflowPath = ciWorkflowPath(workflow);
313
+ const committed = await runCaptured("git", ["show", `HEAD:${workflowPath}`], { cwd: repositoryRoot });
314
+ if (committed.status !== 0) throw new ReleaseStepError(`${workflowPath} existe pero no está commiteado en HEAD; el worker de CI no lo recibiría.`, missingHint);
315
+ return committed.stdout;
316
+ }
317
+
318
+ /**
319
+ * Rejects the generated workflow when HEAD has no lockfile of the project's package manager: it
320
+ * installs dependencies with a frozen lockfile (`npm ci`, `pnpm install --frozen-lockfile`,
321
+ * `yarn install --frozen-lockfile`/`--immutable`, `bun install --frozen-lockfile`), which fails
322
+ * without one, so the worker would stop only after the release commit and tag were pushed.
323
+ * A file present on disk is not enough: the worker checks out the pushed release commit.
324
+ * @param {string} repositoryRoot - Project root.
325
+ * @param {import("../package-manager.js").PackageManagerName} packageManager - Package manager the workflow installs with.
326
+ * @returns {Promise<void>}
327
+ * @throws {ReleaseStepError} When HEAD cannot be listed or contains none of the package manager's lockfiles.
328
+ */
329
+ export async function assertCiInstallLockfileCommitted(repositoryRoot, packageManager) {
330
+ const lockfiles = LOCKFILE_PACKAGE_MANAGERS.filter(([, lockfileManager]) => lockfileManager === packageManager).map(([fileName]) => fileName);
331
+ if ((await listCommittedTreeEntries(repositoryRoot, lockfiles)).length > 0) return;
332
+ throw new ReleaseStepError(
333
+ `El workflow generado instala dependencias con ${packageManager} y lockfile congelado, pero HEAD no contiene ${lockfiles.join(" ni ")}; el worker fallaría después de subir el tag.`,
334
+ `Generá el lockfile con ${packageManager} install y commitealo en un commit propio, o usá --${CREATE_VERSION_FLAG.local}; no se creó la versión ni el tag.`
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Rejects the generated workflow when the checkout has a `.nvmrc` that HEAD does not commit as a
340
+ * regular file (untracked, ignored, set aside by `--ignore-local-changes`, or committed as a
341
+ * symlink). The workflow only follows the committed pin, so it would silently run the local
342
+ * Node.js major instead of the pinned runtime, and the npm OIDC check would judge that other
343
+ * version; stopping before the bump lets the user commit the pin or drop it deliberately.
344
+ * @param {string} repositoryRoot - Project root.
345
+ * @returns {Promise<void>}
346
+ * @throws {ReleaseStepError} When `.nvmrc` exists locally but not as a regular file committed at HEAD, or HEAD cannot be read.
347
+ */
348
+ export async function assertCiNodeVersionFileCommitted(repositoryRoot) {
349
+ if (!lstatSync(path.join(repositoryRoot, PINNED_NODE_VERSION_FILE), { throwIfNoEntry: false })) return;
350
+ if ((await readCommittedRegularFile(repositoryRoot, PINNED_NODE_VERSION_FILE)) !== null) return;
351
+ throw new ReleaseStepError(
352
+ `${PINNED_NODE_VERSION_FILE} existe en el checkout pero HEAD no lo contiene como archivo regular commiteado; el workflow generado usaría Node ${readRunningNodeMajor()} (el major de este Node.js) en lugar de ese pin.`,
353
+ `Commiteá ${PINNED_NODE_VERSION_FILE} como archivo regular en un commit propio (sin ignorarlo), o borralo para usar el major de este Node.js, o usá --${CREATE_VERSION_FLAG.local}; no se creó la versión ni el tag.`
354
+ );
355
+ }
356
+
357
+ /**
358
+ * Splits workflow content into comparable lines regardless of checkout line endings.
359
+ * @param {string} content - Workflow YAML.
360
+ * @returns {string[]} Lines without the trailing newline.
361
+ */
362
+ function toWorkflowLines(content) {
363
+ return content.replace(/\r\n/gu, "\n").trimEnd().split("\n");
364
+ }
365
+
366
+ /**
367
+ * Tells whether two workflows have the same lines (see {@link toWorkflowLines}).
368
+ * @param {string[]} committedLines - Lines of the workflow committed at HEAD.
369
+ * @param {string[]} renderedLines - Lines of the workflow setup renders for the current configuration.
370
+ * @returns {boolean} True when every line matches in order.
371
+ */
372
+ function haveSameWorkflowLines(committedLines, renderedLines) {
373
+ return committedLines.length === renderedLines.length && committedLines.every((line, index) => line === renderedLines[index]);
374
+ }
375
+
376
+ /**
377
+ * Tells whether the configured workflow committed at HEAD is exactly the one setup renders for the
378
+ * current configuration, so an ordinary CI release knows it installs dependencies with a frozen
379
+ * lockfile and follows the committed `.nvmrc` like `--setup-ci` does. A missing, non-regular or
380
+ * customized workflow is not the generated one; its own checks stay with the release flow. The
381
+ * comparison renders without the generated workflow's GitHub Packages validations, so a customized
382
+ * workflow that authenticates on its own is never blocked by them; they run only once the committed
383
+ * workflow is known to be the generated one. A workflow that only lacks the GitHub Packages authentication
384
+ * lines (see {@link lacksOnlyGithubPackagesAuthentication}) was generated before that authentication existed:
385
+ * releasing with it would fail with 401 after the tag was pushed, so it is rejected instead of treated as customized.
386
+ * @param {string} repositoryRoot - Project root.
387
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Project configuration.
388
+ * @returns {Promise<boolean>} True when HEAD commits the generated workflow unchanged.
389
+ * @throws {ReleaseStepError} When Git cannot list or read HEAD, HEAD commits the generated workflow
390
+ * with a GitHub Packages setup it cannot authenticate, or a generated workflow without that authentication.
391
+ */
392
+ export async function isCommittedCiWorkflowGenerated(repositoryRoot, config) {
393
+ if (!config.ci) return false;
394
+ const workflowPath = ciWorkflowPath(config.ci.workflow);
395
+ const committedWorkflow = await readCommittedRegularFile(repositoryRoot, workflowPath);
396
+ if (committedWorkflow === null) return false;
397
+ const renderedWorkflow = await prepareCiReleaseWorkflow(repositoryRoot, config);
398
+ const committedLines = toWorkflowLines(committedWorkflow);
399
+ const renderedLines = toWorkflowLines(renderedWorkflow.content);
400
+ if (!haveSameWorkflowLines(committedLines, renderedLines)) {
401
+ if (!lacksOnlyGithubPackagesAuthentication(committedLines, renderedLines)) return false;
402
+ throw new ReleaseStepError(
403
+ `${workflowPath} se generó antes de que astra-rp autenticara GitHub Packages: le falta el paso "${CI_GITHUB_PACKAGES_AUTHENTICATION_STEP}" o su permiso packages, que el proyecto necesita para instalar dependencias; el worker fallaría con 401 después de subir el tag.`,
404
+ buildRegenerateCiWorkflowHint(),
405
+ );
406
+ }
407
+ await renderedWorkflow.assertGithubPackagesSetupSupported();
408
+ return true;
409
+ }
410
+
411
+ /**
412
+ * Builds the recovery hint for a committed workflow that no longer matches what setup generates.
413
+ * @returns {string} Spanish hint that offers regenerating, updating or keeping a deliberate customization.
414
+ */
415
+ function buildRegenerateCiWorkflowHint() {
416
+ return `Regeneralo con --${CREATE_VERSION_FLAG.setupCi} después de borrarlo en un commit propio, o actualizalo y commitealo a mano; si lo personalizaste a propósito, usá --${CREATE_VERSION_FLAG.ci}. No se creó la versión ni el tag.`;
417
+ }
418
+
419
+ /**
420
+ * Flags the rendered lines that only exist to authenticate GitHub Packages: the whole authentication step,
421
+ * the `packages` permission and the job-level Actions token binding.
422
+ * @param {string[]} renderedLines - Lines of the workflow setup renders.
423
+ * @returns {boolean[]} For each line, whether it belongs to the GitHub Packages authentication.
424
+ */
425
+ function flagGithubPackagesAuthenticationLines(renderedLines) {
426
+ const authenticationStepLine = `${CI_WORKFLOW_STEP_PREFIX}name: ${CI_GITHUB_PACKAGES_AUTHENTICATION_STEP}`;
427
+ let insideAuthenticationStep = false;
428
+ return renderedLines.map((line) => {
429
+ if (line.startsWith(CI_WORKFLOW_STEP_PREFIX)) insideAuthenticationStep = line === authenticationStepLine;
430
+ return insideAuthenticationStep || CI_GITHUB_PACKAGES_PERMISSION_LINE_PATTERN.test(line) || line === CI_GITHUB_TOKEN_JOB_BINDING;
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Tells whether a committed workflow is the rendered one minus some of its GitHub Packages authentication
436
+ * lines (see {@link flagGithubPackagesAuthenticationLines}), as a workflow generated before that
437
+ * authentication existed. Any other difference means the workflow was customized.
438
+ * @param {string[]} committedLines - Lines of the workflow committed at HEAD (see {@link toWorkflowLines}).
439
+ * @param {string[]} renderedLines - Lines of the workflow setup renders for the current configuration, with the authentication step.
440
+ * @returns {boolean} True when only authentication lines are missing from the committed workflow.
441
+ */
442
+ function lacksOnlyGithubPackagesAuthentication(committedLines, renderedLines) {
443
+ const authenticationLines = flagGithubPackagesAuthenticationLines(renderedLines);
444
+ if (!renderedLines.some((line, index) => authenticationLines[index] && line.startsWith(CI_WORKFLOW_STEP_PREFIX))) return false;
445
+ let committedIndex = 0;
446
+ for (const [renderedIndex, renderedLine] of renderedLines.entries()) {
447
+ if (committedLines[committedIndex] === renderedLine) committedIndex += 1;
448
+ else if (!authenticationLines[renderedIndex]) return false;
449
+ }
450
+ return committedIndex === committedLines.length;
451
+ }
452
+
453
+ /**
454
+ * Rejects `--setup-ci` over an existing workflow whose committed content no longer matches what
455
+ * setup would generate for the current configuration (for example a new publication token binding).
456
+ * Regeneration is never automatic: the committed file may hold intentional manual edits, so the
457
+ * user decides between regenerating it and releasing with the customized workflow through `--ci`.
458
+ * @param {string} repositoryRoot - Project root.
459
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Configuration with a validated `ci.workflow`.
460
+ * @returns {Promise<void>}
461
+ * @throws {ReleaseStepError} When the workflow is not committed at HEAD or differs from the rendered one.
462
+ */
463
+ export async function assertGeneratedCiWorkflowCurrent(repositoryRoot, config) {
464
+ if (!config.ci) return;
465
+ const workflowPath = ciWorkflowPath(config.ci.workflow);
466
+ const regenerateHint = buildRegenerateCiWorkflowHint();
467
+ const committedWorkflow = await readCommittedCiWorkflow(repositoryRoot, config.ci.workflow, regenerateHint);
468
+ const renderedWorkflow = await renderCiReleaseWorkflow(repositoryRoot, config);
469
+ const committedLines = toWorkflowLines(committedWorkflow);
470
+ const renderedLines = toWorkflowLines(renderedWorkflow);
471
+ if (haveSameWorkflowLines(committedLines, renderedLines)) return;
472
+ const missingLines = renderedLines.filter((line) => line.trim() !== "" && !committedLines.includes(line)).map((line) => line.trim());
473
+ const missingDetail = missingLines.length > 0 ? ` Líneas esperadas ausentes: ${missingLines.slice(0, CI_WORKFLOW_DRIFT_PREVIEW_LINES).join(" | ")}.` : "";
474
+ throw new ReleaseStepError(`${workflowPath} no coincide con el workflow que --${CREATE_VERSION_FLAG.setupCi} genera para la configuración actual; el worker podría fallar después de subir el tag.${missingDetail}`, regenerateHint);
475
+ }
476
+
477
+ /**
478
+ * Reads an immutable release identity and proves it already exists on origin/main.
479
+ * @param {import("./process.js").GitReader} reader - Git reader.
480
+ * @param {string} tag - Stable release tag, vX.Y.Z.
481
+ * @param {boolean} [requireHead] - Require the checkout to be exactly this release.
482
+ * @returns {Promise<import("./github-workflow.js").CiReleaseIdentity>} Verified remote identity.
483
+ * @throws {ReleaseStepError} When the tag, commit, version or checkout differs.
484
+ */
485
+ export async function readCiReleaseIdentity(reader, tag, requireHead = false) {
486
+ const version = tag.startsWith(RELEASE_TAG_PREFIX) ? tag.slice(RELEASE_TAG_PREFIX.length) : "";
487
+ if (!isStableReleaseVersion(version) || toReleaseTag(version) !== tag) throw new ReleaseStepError(`Tag de release inválido: ${tag}.`, "Usá un tag estable vX.Y.Z creado por create-version.");
488
+ const sha = await reader.tryGit(["rev-parse", "--verify", `refs/tags/${tag}^{commit}`]);
489
+ if (!sha || !CI_COMMIT_SHA_PATTERN.test(sha)) throw new ReleaseStepError(`No se pudo resolver ${tag}.`, "Traé el tag de origin antes de retomar el release.");
490
+ const remote = await reader.git(["ls-remote", "--tags", RELEASE_REMOTE, `refs/tags/${tag}`, `refs/tags/${tag}^{}`]);
491
+ const remoteSha = remote.split("\n").find((line) => line.endsWith(`refs/tags/${tag}^{}`))?.split(/\s+/u)[0] ?? remote.split(/\s+/u)[0];
492
+ if (remoteSha !== sha || (await reader.tryGit(["merge-base", "--is-ancestor", sha, REMOTE_MAIN_REF])) === null) throw new ReleaseStepError(`${tag} no coincide con origin o su commit no está en ${MAIN_BRANCH}.`, "No se publicó ni se reenviará nada; verificá el tag y origin/main.");
493
+ const manifest = JSON.parse(await reader.git(["show", `${tag}:${PACKAGE_MANIFEST_FILE}`]));
494
+ const currentMain = JSON.parse(await reader.git(["show", `${REMOTE_MAIN_REF}:${PACKAGE_MANIFEST_FILE}`]));
495
+ if (currentMain.version !== version) throw new ReleaseStepError(`${tag} ya no es la versión de origin/main.`, "No se ejecutará un release anterior sobre uno más nuevo; retomá el tag vigente.");
496
+ if (manifest.version !== version || (await reader.git(["log", "-1", "--format=%s", sha])) !== version) throw new ReleaseStepError(`${tag} no corresponde a un commit de versión ${version}.`, "Usá el tag creado por create-version; no se hizo otro bump.");
497
+ if (requireHead) {
498
+ if (await reader.git(["rev-parse", "HEAD"]) !== sha || (process.env[CI_RELEASE_ENVIRONMENT.version] && process.env[CI_RELEASE_ENVIRONMENT.version] !== version) || (process.env[CI_RELEASE_ENVIRONMENT.sha] && process.env[CI_RELEASE_ENVIRONMENT.sha] !== sha)) throw new ReleaseStepError("El checkout de CI no coincide con la versión y el commit enviados.", `Hacé checkout de ${tag} y conservá los inputs originales del workflow.`);
499
+ }
500
+ return { version, tag, sha };
501
+ }
502
+
503
+ /**
504
+ * Appends dispatch to the minimal local preparation plan.
505
+ * @param {import("./plan.js").ReleasePlan} plan - Existing bump/push plan.
506
+ * @returns {import("./plan.js").ReleasePlan} Plan whose last step only submits the worker.
507
+ */
508
+ export function appendCiDispatch(plan) {
509
+ if (plan.blockers.length > 0 || !plan.steps.some((step) => step.id === RELEASE_STEP.pushRelease)) return plan;
510
+ return { ...plan, steps: [...plan.steps, { id: DISPATCH_CI_RELEASE_STEP, title: "Enviar el release a GitHub Actions", detail: "Los checks, la preparación y la publicación continúan en CI sobre el tag exacto." }] };
511
+ }
512
+
513
+ /**
514
+ * Plans checks and unfinished publication for a pinned worker; never bumps or pushes.
515
+ * @param {import("./state.js").ReleaseSnapshot} state - Diagnosed worker checkout.
516
+ * @param {import("./plan.js").ReleaseCapabilities} capabilities - Normal project hooks.
517
+ * A retry after the registry publication succeeded skips migrations and publication, but still
518
+ * re-runs the preparation when the workflow deploys afterwards: the retry starts from a fresh
519
+ * checkout, so files or setup produced by `prepare` would otherwise be missing from the deployment.
520
+ * @param {string} version - Existing release version.
521
+ * A custom publisher leaves no registry record, so its completion comes from the worker's Actions
522
+ * history (see `findCompletedCiPublication`) and is treated exactly like a confirmed registry publication.
523
+ * @param {boolean} [deploymentFollows] - Whether the workflow deploys (for example to Vercel) after this worker finishes.
524
+ * @param {boolean} [customPublicationCompleted] - Whether an earlier run or attempt already finished the custom publisher.
525
+ * @returns {import("./plan.js").ReleasePlan} Worker-only execution plan.
526
+ */
527
+ export function buildCiWorkerPlan(state, capabilities, version, deploymentFollows = false, customPublicationCompleted = false) {
528
+ const blockers = [];
529
+ if (state.workingTreeChanges.length > 0) blockers.push({ title: "CI necesita un checkout limpio del tag", details: state.workingTreeChanges });
530
+ if (capabilities.checksMissing) blockers.push({ title: "Faltan checks para ejecutar el release en CI", details: ["Configurá checks o un script ci antes de publicar."] });
531
+ if (state.npm?.status !== undefined && state.npm.status !== NPM_LOOKUP_STATUS.ok) blockers.push({ title: "No se pudo consultar el registry del release", details: [state.npm.reason ?? "Verificá las credenciales y la conexión del worker."] });
532
+ if (state.npmAuth && state.npmAuth.status !== NPM_AUTH_STATUS.ok && state.npmAuth.status !== NPM_AUTH_STATUS.unknown) blockers.push({ title: "Las credenciales del worker no permiten publicar", details: [state.npmAuth.reason ?? "Configurá la credencial de publicación en Actions."] });
533
+ const alreadyPublished = customPublicationCompleted || (state.npm?.publishedVersions.includes(version) ?? false);
534
+ if (!alreadyPublished && state.migrations?.status === MIGRATION_STATUS.unknown) blockers.push({ title: "No se pudieron verificar las migraciones del release", details: [state.migrations.reason ?? "Configurá el entorno y la conexión de las migraciones en Actions."] });
535
+ const highestPublished = findHighestStableVersion(state.npm?.publishedVersions ?? []);
536
+ if (!alreadyPublished && highestPublished && compareReleaseVersions(version, highestPublished) < 0) blockers.push({ title: "El registry ya tiene una versión más nueva", details: [`${version} no se publicará sobre ${highestPublished}.`] });
537
+ const steps = [
538
+ ...(capabilities.checks ? [{ id: RELEASE_STEP.runChecks, title: "Validar el release en CI" }] : []),
539
+ ...(!alreadyPublished && state.migrations?.status === MIGRATION_STATUS.pending ? [{ id: RELEASE_STEP.applyMigrations, title: "Aplicar migraciones del release" }] : []),
540
+ ...((!alreadyPublished || deploymentFollows) && capabilities.prepare ? [{ id: RELEASE_STEP.prepareRelease, title: "Preparar el release" }] : []),
541
+ ...(!alreadyPublished && capabilities.publish ? [{ id: RELEASE_STEP.publishRelease, title: capabilities.publishTitle }] : []),
542
+ ];
543
+ if (alreadyPublished) print(`${toReleaseTag(version)} ya está publicado; CI verifica los checks${deploymentFollows && capabilities.prepare ? " y vuelve a preparar el release para el despliegue" : ""} sin repetir la publicación.`);
544
+ return { mode: blockers.length > 0 ? RELEASE_MODE.blocked : RELEASE_MODE.resume, pendingVersion: version, blockers, warnings: [], steps: blockers.length > 0 ? [] : steps };
545
+ }
546
+
547
+ /**
548
+ * Derives setup defaults from the application's deployment and migration configuration.
549
+ * @param {string} repositoryRoot - Checkout offered automatic setup.
550
+ * @param {import("./config.js").ResolvedCreateVersionConfig} config - Existing project hooks.
551
+ * @returns {import("./ci-config.js").ResolvedCiReleaseConfig} In-memory settings, committed only with a successful bump.
552
+ */
553
+ export function defaultCiReleaseConfig(repositoryRoot, config) {
554
+ return { workflow: DEFAULT_CI_WORKFLOW, secrets: [...new Set(config.migrations?.targetHint?.match(CI_MIGRATION_ENVIRONMENT_PATTERN) ?? [])], variables: [], deployment: config.publish === null && existsSync(path.join(repositoryRoot, CI_VERCEL_CONFIG_FILE)) ? CI_VERCEL_DEPLOYMENT : null };
555
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Reads files from the commit at HEAD: the content a CI worker checks out from the pushed release
3
+ * commit, unlike the working tree, which may hold untracked, ignored or set-aside files.
4
+ * @module create-version/committed-tree
5
+ */
6
+
7
+ import { GIT_REGULAR_FILE_MODES } from "../constants/ci-release.js";
8
+ import { ReleaseStepError } from "./errors.js";
9
+ import { runCaptured } from "./process.js";
10
+
11
+ /**
12
+ * Entry of the HEAD tree as listed by `git ls-tree`.
13
+ * @typedef {object} CommittedTreeEntry
14
+ * @property {string} mode - Git tree mode, such as `100644` or `120000`.
15
+ * @property {string} type - Git object type, such as `blob` or `tree`.
16
+ * @property {string} objectId - Object id of the entry.
17
+ * @property {string} filePath - Path relative to the repository root.
18
+ */
19
+
20
+ /** Hint shown when Git cannot list or read HEAD while preparing a CI release. */
21
+ const UNREADABLE_HEAD_HINT = "Revisá el repositorio Git antes de configurar CI; no se creó la versión ni el tag.";
22
+
23
+ /**
24
+ * Lists which of the given root-relative paths HEAD contains.
25
+ * @param {string} repositoryRoot - Project root.
26
+ * @param {string[]} filePaths - Root-relative paths to look up.
27
+ * @returns {Promise<CommittedTreeEntry[]>} Entries found at HEAD; absent paths are omitted.
28
+ * @throws {ReleaseStepError} When `git ls-tree` fails, for example without a commit at HEAD.
29
+ */
30
+ export async function listCommittedTreeEntries(repositoryRoot, filePaths) {
31
+ const listed = await runCaptured("git", ["ls-tree", "-z", "HEAD", "--", ...filePaths], { cwd: repositoryRoot });
32
+ if (listed.status !== 0) throw new ReleaseStepError(`No se pudo comprobar si HEAD contiene ${filePaths.join(" o ")} para el workflow de CI (git ls-tree terminó con status ${listed.status}).`, UNREADABLE_HEAD_HINT);
33
+ return listed.stdout.split("\0").filter(Boolean).map((record) => {
34
+ const separatorIndex = record.indexOf("\t");
35
+ const [mode, type, objectId] = record.slice(0, separatorIndex).split(" ");
36
+ return { mode, type, objectId, filePath: record.slice(separatorIndex + 1) };
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Tells whether a HEAD tree entry is a regular file, which the worker checks out as is; a symlink,
42
+ * submodule or directory is not.
43
+ * @param {CommittedTreeEntry} entry - Entry listed by {@link listCommittedTreeEntries}.
44
+ * @returns {boolean} True for a blob with one of {@link GIT_REGULAR_FILE_MODES}.
45
+ */
46
+ export function isCommittedRegularFile(entry) {
47
+ return entry.type === "blob" && GIT_REGULAR_FILE_MODES.includes(entry.mode);
48
+ }
49
+
50
+ /**
51
+ * Reads the content of a regular file entry of HEAD.
52
+ * @param {string} repositoryRoot - Project root.
53
+ * @param {CommittedTreeEntry} entry - Regular file entry (see {@link isCommittedRegularFile}).
54
+ * @returns {Promise<string>} Committed content.
55
+ * @throws {ReleaseStepError} When Git cannot read the committed object.
56
+ */
57
+ export async function readCommittedFileEntry(repositoryRoot, entry) {
58
+ const shown = await runCaptured("git", ["cat-file", "blob", entry.objectId], { cwd: repositoryRoot });
59
+ if (shown.status !== 0) throw new ReleaseStepError(`No se pudo leer ${entry.filePath} desde HEAD para el workflow de CI (git cat-file terminó con status ${shown.status}).`, UNREADABLE_HEAD_HINT);
60
+ return shown.stdout;
61
+ }
62
+
63
+ /**
64
+ * Reads a file committed at HEAD only when it is a regular file, which the worker checks out as is;
65
+ * a symlink, submodule or directory at that path counts as absent.
66
+ * @param {string} repositoryRoot - Project root.
67
+ * @param {string} filePath - Root-relative path.
68
+ * @returns {Promise<string | null>} Committed content, or `null` when HEAD has no regular file there.
69
+ * @throws {ReleaseStepError} When Git cannot list HEAD or read the committed object.
70
+ */
71
+ export async function readCommittedRegularFile(repositoryRoot, filePath) {
72
+ const [entry] = await listCommittedTreeEntries(repositoryRoot, [filePath]);
73
+ if (!entry || !isCommittedRegularFile(entry)) return null;
74
+ return readCommittedFileEntry(repositoryRoot, entry);
75
+ }