@wpelevator/wp-release 0.10.0 → 0.11.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.
@@ -1,1016 +1,418 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { execFile } from 'node:child_process';
3
- import { access, mkdir, readdir, readFile, rm, writeFile, } from 'node:fs/promises';
4
1
  import path from 'node:path';
5
- import { promisify } from 'node:util';
2
+ import { getVersionedReleases, prependChangelog } from "./changelog.js";
6
3
  import { loadChangesetConfig } from "./changeset-config.js";
7
- import { findMainPluginFile } from "./package-meta.js";
8
- import { findConstantValues } from "./version-check.js";
9
- import { formatChangelogList, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
4
+ import { applyDependents, assertDependentsSourcesReleased, collectPinDriftWarnings, expandDependentsPlan, findBundledPinChanges, hasChangeset, selectReleasePlan, stripDependentsPlan, updateDependencyDeclaration, } from "./changeset-dependents.js";
5
+ import { consumeChangeset, readChangesets, readChangesetsSince, writeChangeset, } from "./changeset-files.js";
6
+ import { assertKnownPackages, discoverPackages, getChangedPackages, toChangesetPackage, } from "./changeset-packages.js";
7
+ import { getChangesetBump, getNextVersion } from "./changesets.js";
10
8
  import { ValidationError } from "./errors.js";
11
- import semverSatisfies from 'semver/functions/satisfies.js';
12
- import validRange from 'semver/ranges/valid.js';
13
- const execFileAsync = promisify(execFile);
14
- /** npm dependency sections whose declared dependency re-releases the dependent. */
15
- const DEFAULT_SECTIONS = ['dependencies', 'optionalDependencies'];
16
- /**
17
- * Discover configured packages, pending changesets, and their proposed versions.
18
- *
19
- * @param input Repository root and optional explicit config file.
20
- * @return A release plan; `undefined` when no config is present.
21
- */
22
- export async function getChangesetReleasePlan(input) {
23
- const context = await getReleaseContext(input);
24
- return context?.plan;
25
- }
26
- /**
27
- * Discover the packages of the repository configuration.
28
- *
29
- * @param input Repository root and optional explicit config file.
30
- * @return Packages in the configured order with their current versions.
31
- * @throws {ValidationError} Without a config, or for an invalid one.
32
- */
33
- export async function getChangesetPackages(input) {
34
- const loaded = await requireConfig(input);
35
- const packages = await discoverPackages(input.cwd, loaded.config);
36
- return packages.map(toChangesetPackage);
37
- }
38
- async function getReleaseContext(input) {
39
- const loaded = await loadChangesetConfig(input);
40
- if (!loaded) {
41
- return undefined;
9
+ import { FileJournal } from "./file-journal.js";
10
+ import { getPreviousTag, getReleaseNotes, getTagVersion, selectTagPackages, } from "./release-tags.js";
11
+ import { ReleaseRepository } from "./release-repository.js";
12
+ import { formatTag } from "./tag-format.js";
13
+ import { verifyVersionTargets, writeVersion } from "./version-files.js";
14
+ import { isPrerelease } from "./versions.js";
15
+ const MISSING_CONFIG = 'No wp-release config found. Add wp-release.config.js or pass --config <path>.';
16
+ /**
17
+ * Changeset releases of a repository: recording changesets, versioning the
18
+ * packages they name and tagging the releases.
19
+ *
20
+ * Each operation loads the configuration, discovers the packages and reads the
21
+ * pending changesets again, so that one instance can version and then tag.
22
+ * Git runs through the injected runner.
23
+ */
24
+ export class ChangesetRelease {
25
+ cwd;
26
+ configFile;
27
+ repository;
28
+ /**
29
+ * @param options Repository root, config file and git runner.
30
+ */
31
+ constructor(options) {
32
+ this.cwd = options.cwd;
33
+ this.configFile = options.configFile;
34
+ this.repository = new ReleaseRepository({
35
+ cwd: options.cwd,
36
+ runner: options.git,
37
+ });
42
38
  }
43
- const config = loaded.config;
44
- const packages = await discoverPackages(input.cwd, config);
45
- const changesets = await readChangesets(input.cwd, config);
46
- const releases = [];
47
- packages.forEach((pkg) => {
48
- const bump = getChangesetBump(changesets, pkg.name);
49
- if (bump) {
50
- const packageChangesets = changesets.filter((changeset) => Object.hasOwn(changeset.packages, pkg.name));
51
- const nextVersion = config.nextVersion
52
- ? config.nextVersion({
53
- package: pkg,
54
- bump,
55
- currentVersion: pkg.currentVersion,
56
- changesets: packageChangesets,
57
- })
58
- : getNextVersion(pkg.currentVersion, bump);
59
- if ('string' !== typeof nextVersion || !nextVersion.trim()) {
60
- throw new ValidationError(`The nextVersion strategy returned an empty version for package "${pkg.name}".`);
39
+ /**
40
+ * Discover configured packages, pending changesets, and their proposed
41
+ * versions.
42
+ *
43
+ * @return A release plan; `undefined` when no config is present.
44
+ */
45
+ async getPlan() {
46
+ const context = await this.loadContext();
47
+ return context?.plan;
48
+ }
49
+ /**
50
+ * Discover the packages of the repository configuration.
51
+ *
52
+ * @return Packages in the configured order with their current versions.
53
+ * @throws {ValidationError} Without a config, or for an invalid one.
54
+ */
55
+ async getPackages() {
56
+ const config = await this.requireConfig();
57
+ const packages = await discoverPackages(this.repository, config);
58
+ return packages.map(toChangesetPackage);
59
+ }
60
+ /**
61
+ * Create a Changesets-compatible Markdown file.
62
+ *
63
+ * @param options Packages, summary and file name.
64
+ * @return Created file path.
65
+ * @throws {ValidationError} For unknown packages or an existing file.
66
+ */
67
+ async add(options) {
68
+ const config = await this.requireConfig();
69
+ const packages = await discoverPackages(this.repository, config);
70
+ assertKnownPackages(packages, Object.keys(options.packages));
71
+ return writeChangeset(this.getChangesetDirectory(config), options.packages, options.summary, options.name);
72
+ }
73
+ /**
74
+ * Apply pending package versions and changelogs. A failed run restores
75
+ * every file it changed.
76
+ *
77
+ * @param options Versioning options.
78
+ * @return Planned releases and whether files were changed.
79
+ * @throws {ValidationError} Without a config, or for an invalid plan.
80
+ */
81
+ async version(options = {}) {
82
+ const context = await this.requireContext();
83
+ const { config, packages } = context;
84
+ const withDependents = options.dependents ?? true;
85
+ let releasePlan = context.plan;
86
+ if (options.packages) {
87
+ releasePlan = expandDependentsPlan(await selectReleasePlan(context.plan, packages, options.packages, config, withDependents));
88
+ }
89
+ else if (!withDependents) {
90
+ releasePlan = stripDependentsPlan(context.plan);
91
+ }
92
+ // A dependent must not release while one of the packages it depends on has
93
+ // pending changesets that aren't released in the same run, or it would
94
+ // bundle unreleased code under a changelog that cannot credit it. The
95
+ // check only applies to repositories that adopted the dependents policy.
96
+ if (withDependents && config.dependents) {
97
+ await assertDependentsSourcesReleased(releasePlan, context.plan, packages, config);
98
+ }
99
+ const date = options.date ?? new Date().toISOString().slice(0, 10);
100
+ const releases = await getVersionedReleases(this.repository, config, releasePlan.releases, this.getChangesetDirectory(config));
101
+ if (!options.dryRun) {
102
+ await this.writeReleases(context, releasePlan, releases, date, options.packages);
103
+ }
104
+ return {
105
+ changesets: releasePlan.changesets,
106
+ releases,
107
+ date,
108
+ changed: !options.dryRun &&
109
+ (releasePlan.changesets.length > 0 ||
110
+ releasePlan.releases.length > 0),
111
+ };
112
+ }
113
+ /**
114
+ * Create missing release tags, optionally pushing them to the origin
115
+ * remote. Each tag comes with the title and notes of its release, for a
116
+ * workflow step to create the GitHub Release.
117
+ *
118
+ * @param options Tag options.
119
+ * @return Per-package tags and release notes.
120
+ * @throws {ValidationError} For conflicting options, unknown packages or version mismatches.
121
+ */
122
+ async tag(options = {}) {
123
+ if (options.untagged && options.push) {
124
+ throw new ValidationError('Listing untagged releases cannot be combined with pushing tags.');
125
+ }
126
+ const context = await this.requireContext();
127
+ const selected = selectTagPackages(context.packages, options.packages ?? [], options.version);
128
+ const packages = options.since
129
+ ? (await getChangedPackages(this.repository, selected, options.since)).packages
130
+ : selected;
131
+ const results = [];
132
+ const dryRun = options.dryRun || options.untagged;
133
+ const tagFormat = context.config.tagFormat ?? '{path}/{version}';
134
+ for (const pkg of packages) {
135
+ const version = await getTagVersion(pkg, options.version);
136
+ if (undefined !== options.version &&
137
+ options.version !== version) {
138
+ throw new ValidationError(`Package "${pkg.name}" is at version ${version}, not ${options.version}.`);
61
139
  }
62
- releases.push({
63
- package: pkg,
64
- bump,
65
- currentVersion: pkg.currentVersion,
66
- nextVersion,
67
- changesets: packageChangesets,
68
- });
69
- }
70
- });
71
- changesets.forEach((changeset) => {
72
- Object.keys(changeset.packages).forEach((name) => {
73
- if (!packages.some((pkg) => pkg.name === name)) {
74
- throw new ValidationError(`Changeset ${changeset.fileName} refers to unknown package "${name}".`);
140
+ const tag = formatTag(tagFormat, pkg, version);
141
+ const exists = await this.repository.tagExists(tag);
142
+ if (!exists) {
143
+ await verifyVersionTargets(this.repository, pkg, version);
75
144
  }
76
- });
77
- });
78
- await applyDependents(releases, packages, config);
79
- return { config, packages, plan: { changesets, releases } };
80
- }
81
- /**
82
- * Add releases for dependents of packages that release, applying the
83
- * configured dependents policy until the plan settles.
84
- *
85
- * @param releases Planned releases, mutated in place.
86
- * @param packages Discovered packages.
87
- * @param config Repository release configuration.
88
- */
89
- async function applyDependents(releases, packages, config) {
90
- const rule = config.dependents;
91
- if (!rule) {
92
- return;
93
- }
94
- // Dependents may themselves be dependents of other sources, so expand until stable.
95
- let grew = true;
96
- while (grew) {
97
- grew = false;
98
- for (const source of [...releases]) {
99
- for (const dependent of packages) {
100
- if (await deriveDependentRelease(source, dependent, releases, rule)) {
101
- grew = true;
102
- }
145
+ const previousTag = await getPreviousTag(this.repository, formatTag(tagFormat, pkg, ''), version);
146
+ const notes = await getReleaseNotes(this.repository, context.config, pkg, { version, tag, previousTag }, exists ? tag : 'HEAD');
147
+ if (!exists && !dryRun) {
148
+ await this.repository.createTag(tag);
103
149
  }
104
- }
105
- }
106
- }
107
- /**
108
- * Plan the release of a package that bundles a source package, or add the
109
- * source to the bundle list of its own pending release.
110
- *
111
- * @param source Package release of the source.
112
- * @param dependent Package that would bundle the source.
113
- * @param releases Planned releases, mutated in place.
114
- * @param rule Dependents policy.
115
- * @return Whether a new dependent release was planned.
116
- */
117
- async function deriveDependentRelease(source, dependent, releases, rule) {
118
- if (dependent.name === source.package.name ||
119
- isExcludedDependent(dependent, rule)) {
120
- return false;
121
- }
122
- const specs = await findDependencySpecs(dependent, source.package, rule);
123
- if (!specs.length || !dependentNeedsRelease(specs, source, rule)) {
124
- return false;
125
- }
126
- const release = releases.find((item) => item.package.name === dependent.name);
127
- if (release) {
128
- const sources = release.sources ?? [];
129
- if (!sources.includes(source.package.name)) {
130
- // A dependent that releases for its own reasons still notes the bundle.
131
- release.sources = [...sources, source.package.name];
132
- }
133
- return false;
134
- }
135
- const bump = rule.bump ?? 'patch';
136
- releases.push({
137
- package: dependent,
138
- bump,
139
- currentVersion: dependent.currentVersion,
140
- nextVersion: getNextVersion(dependent.currentVersion, bump),
141
- changesets: [],
142
- sources: [source.package.name],
143
- });
144
- return true;
145
- }
146
- /**
147
- * Find the specs through which a package declares a dependency on another.
148
- *
149
- * A spec is a dependency of the source that the current version resolves,
150
- * like a range that covers it or a workspace spec, or one that the
151
- * configured hook reports. A pin like an older version of the source
152
- * selects a registry release instead, which keeps the local code out of
153
- * the dependent's build.
154
- *
155
- * @param dependent Package potentially declaring the dependency.
156
- * @param source Released source package.
157
- * @param rule Dependents policy, with its dependency hook and sections.
158
- * @return The declared specs, with their manifest files and package names.
159
- */
160
- async function findDependencySpecs(dependent, source, rule) {
161
- const sourceManifest = await readJsonSafe(path.join(source.directory, 'package.json'));
162
- if ('string' !== typeof sourceManifest?.name) {
163
- return rule?.dependency?.find
164
- ? findDependencyHookSpecs(dependent, source, rule)
165
- : [];
166
- }
167
- const identifier = sourceManifest.name;
168
- const specs = [];
169
- const parsed = await readJsonSafe(path.join(dependent.directory, 'package.json'));
170
- for (const section of dependentsSections(rule)) {
171
- const sectionSpecs = parsed?.[section];
172
- const declared = 'object' === typeof sectionSpecs &&
173
- null !== sectionSpecs &&
174
- Object.hasOwn(sectionSpecs, identifier)
175
- ? String(sectionSpecs[identifier])
176
- : undefined;
177
- if (undefined !== declared &&
178
- isDependencyOfSource(declared, source.currentVersion)) {
179
- specs.push({
180
- file: path.join(dependent.directory, 'package.json'),
181
- name: identifier,
182
- spec: declared,
150
+ results.push({
151
+ package: pkg.name,
152
+ version,
153
+ tag,
154
+ title: `${pkg.name}@${version}`,
155
+ ...(previousTag ? { previousTag } : {}),
156
+ prerelease: isPrerelease(version),
157
+ exists,
158
+ created: !exists && !dryRun,
159
+ notes,
183
160
  });
184
161
  }
185
- }
186
- if (rule?.dependency?.find) {
187
- specs.push(...(await findDependencyHookSpecs(dependent, source, rule)));
188
- }
189
- return specs;
190
- }
191
- /**
192
- * Find the specs that the configured dependency hook reports.
193
- *
194
- * @param dependent Package potentially declaring the dependency.
195
- * @param source Released source package.
196
- * @param rule Dependents policy.
197
- * @return The hook's specs, with absolute manifest paths and package names.
198
- */
199
- async function findDependencyHookSpecs(dependent, source, rule) {
200
- const found = await rule.dependency?.find?.({
201
- package: dependent,
202
- source,
203
- });
204
- return (found ?? []).map((entry) => ({
205
- file: path.resolve(dependent.directory, entry.file),
206
- name: entry.name,
207
- spec: entry.spec,
208
- }));
209
- }
210
- /**
211
- * Check whether a spec points at the workspace copy of a package, by
212
- * resolving the current version through its range or its workspace protocol.
213
- *
214
- * @param spec Declared spec of the source.
215
- * @param currentVersion Current version of the source package.
216
- * @return Whether the spec is a dependency of the source.
217
- */
218
- function isDependencyOfSource(spec, currentVersion) {
219
- if (spec.startsWith('workspace:')) {
220
- return true;
221
- }
222
- const range = effectiveRange(spec, currentVersion);
223
- return (validRange(range) !== null && semverSatisfies(currentVersion, range));
224
- }
225
- /**
226
- * The dependency sections that a dependents policy scans.
227
- *
228
- * @param rule Dependents policy.
229
- * @return Dependency section names.
230
- */
231
- function dependentsSections(rule) {
232
- return (rule?.sections ?? DEFAULT_SECTIONS);
233
- }
234
- /**
235
- * Check whether a dependent has to release when its declared source releases.
236
- *
237
- * @param specs Declared specs of the source in the dependent.
238
- * @param source Released source package.
239
- * @param rule Dependents policy.
240
- * @return Whether the dependent needs a release.
241
- */
242
- function dependentNeedsRelease(specs, source, rule) {
243
- if ('always' === rule.when) {
244
- return true;
245
- }
246
- // Without a range that accepts the next version, like an exact pin, the
247
- // dependent has to release and rewrite its specs.
248
- return specs.some(({ spec }) => isOutOfRange(source.nextVersion, spec, source.currentVersion));
249
- }
250
- /**
251
- * Check whether a version falls outside a declared range.
252
- *
253
- * @param nextVersion Next version of the source package.
254
- * @param spec Declared spec of the source).
255
- * @param currentVersion Current version of the source package.
256
- * @return Whether the next version falls outside the range.
257
- */
258
- function isOutOfRange(nextVersion, spec, currentVersion) {
259
- const range = effectiveRange(spec, currentVersion);
260
- if (validRange(range) === null) {
261
- // Unrecognized ranges of dependencies like git URLs are left to the consumer.
262
- return false;
263
- }
264
- return !semverSatisfies(nextVersion, range);
265
- }
266
- /**
267
- * Resolve a dependency spec to the range it selects for an internal package.
268
- *
269
- * @param spec Declared spec.
270
- * @param currentVersion Current version of the source package.
271
- * @return The effective semver range.
272
- */
273
- function effectiveRange(spec, currentVersion) {
274
- if (!spec.startsWith('workspace:')) {
275
- return spec;
276
- }
277
- // Workspace specs resolve to the exact published version, and to a
278
- // prefixed one with their operator.
279
- const bare = spec.slice('workspace:'.length);
280
- if ('*' === bare) {
281
- return currentVersion;
282
- }
283
- if ('^' === bare || '~' === bare) {
284
- return `${bare}${currentVersion}`;
285
- }
286
- return bare;
287
- }
288
- /**
289
- * Check whether the dependents policy excludes a package.
290
- *
291
- * @param dependent Candidate dependent package.
292
- * @param rule Dependents policy.
293
- * @return Whether the package is excluded from re-releases.
294
- */
295
- function isExcludedDependent(dependent, rule) {
296
- return (rule.exclude ?? []).some((pattern) => pattern === dependent.name ||
297
- globRegExp(pattern).test(dependent.path));
298
- }
299
- async function readJsonSafe(file) {
300
- try {
301
- return JSON.parse(await readFile(file, 'utf8'));
302
- }
303
- catch (error) {
304
- if ('ENOENT' !== error.code) {
305
- throw error;
306
- }
307
- return undefined;
308
- }
309
- }
310
- /**
311
- * Create a Changesets-compatible Markdown file.
312
- *
313
- * @param input Repository, packages and summary.
314
- * @return Created file path.
315
- */
316
- export async function addChangeset(input) {
317
- const loaded = await requireConfig(input);
318
- const packages = await discoverPackages(input.cwd, loaded.config);
319
- Object.keys(input.packages).forEach((name) => {
320
- if (!packages.some((pkg) => pkg.name === name)) {
321
- throw new ValidationError(`Unknown package "${name}".`);
322
- }
323
- });
324
- const directory = path.resolve(input.cwd, loaded.config.changesets ?? '.changeset');
325
- await mkdir(directory, { recursive: true });
326
- const fileName = `${input.name ?? randomUUID().slice(0, 8)}.md`;
327
- const file = path.join(directory, fileName);
328
- let exists = false;
329
- try {
330
- await access(file);
331
- exists = true;
332
- }
333
- catch (error) {
334
- if ('ENOENT' !== error.code) {
335
- throw error;
162
+ // Push one tag at a time because GitHub doesn't create push events for
163
+ // tags when more than three are pushed at once.
164
+ if (options.push && !dryRun) {
165
+ for (const item of results.filter((result) => result.created)) {
166
+ await this.repository.pushTag(item.tag);
167
+ }
336
168
  }
337
- }
338
- if (exists) {
339
- throw new ValidationError(`Changeset ${fileName} already exists.`);
340
- }
341
- const entries = Object.entries(input.packages)
342
- .sort((a, b) => a[0].localeCompare(b[0]))
343
- .map(([name, bump]) => `${JSON.stringify(name)}: ${bump}`)
344
- .join('\n');
345
- await writeFile(file, `---\n${entries}\n---\n\n${input.summary.trim()}\n`);
346
- return file;
347
- }
348
- /**
349
- * Apply pending package versions and changelogs.
350
- *
351
- * @param input Repository and versioning options.
352
- * @return Planned releases and whether files were changed.
353
- */
354
- export async function versionChangesets(input) {
355
- const context = await getReleaseContext(input);
356
- if (!context) {
357
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
358
- }
359
- const { config, packages } = context;
360
- const withDependents = input.dependents ?? true;
361
- let releasePlan = context.plan;
362
- if (input.packages) {
363
- releasePlan = expandDependentsPlan(await selectReleasePlan(context.plan, packages, input.packages, config, withDependents));
364
- }
365
- else if (!withDependents) {
366
- releasePlan = stripDependentsPlan(context.plan);
367
- }
368
- // A dependent must not release while one of the packages it depends on has
369
- // pending changesets that aren't released in the same run, or it would
370
- // bundle unreleased code under a changelog that cannot credit it. The
371
- // check only applies to repositories that adopted the dependents policy.
372
- if (withDependents && config.dependents) {
373
- await assertDependentsSourcesReleased(releasePlan, context.plan, packages, config);
374
- }
375
- const date = input.date ?? new Date().toISOString().slice(0, 10);
376
- const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
377
- const releases = await getVersionedReleases(input.cwd, config, releasePlan.releases, changesetDirectory);
378
- if (!input.dryRun) {
379
- const journal = new Map();
169
+ return {
170
+ tags: options.untagged
171
+ ? results.filter((result) => !result.exists)
172
+ : results,
173
+ };
174
+ }
175
+ /**
176
+ * Check whether changed files belong to packages without a changeset.
177
+ *
178
+ * @param options Base git ref.
179
+ * @return Changed packages and missing changeset identifiers.
180
+ * @throws {ValidationError} Without a config.
181
+ */
182
+ async getStatus(options = {}) {
183
+ const { config, packages, plan } = await this.requireContext();
184
+ const { changesets, releases } = plan;
185
+ const since = options.since ??
186
+ (config.baseBranch ? `origin/${config.baseBranch}` : undefined);
187
+ const changed = since
188
+ ? await getChangedPackages(this.repository, packages, since)
189
+ : { packages: [], filesByPackage: {} };
190
+ const changedPackages = changed.packages.map((pkg) => pkg.name);
191
+ const changesetsInDiff = since
192
+ ? await readChangesetsSince(this.repository, this.getChangesetDirectory(config), changesets, since)
193
+ : [];
194
+ const hasEmptyChangeset = changesetsInDiff.some((changeset) => 0 === Object.keys(changeset.packages).length);
195
+ const bundled = await findBundledPinChanges(changed, changesetsInDiff, packages, config);
196
+ const missingChangesets = hasEmptyChangeset
197
+ ? []
198
+ : changedPackages.filter((name) => !hasChangeset(changesetsInDiff, name) &&
199
+ !bundled.has(name));
200
+ // A pin that doesn't resolve the local package selects a registry release
201
+ // instead, which the dependents policy won't re-release automatically.
202
+ const warnings = config.dependents
203
+ ? await collectPinDriftWarnings(config, packages)
204
+ : [];
205
+ return {
206
+ changesets,
207
+ releases,
208
+ changedPackages,
209
+ changedPackageFiles: changed.filesByPackage,
210
+ missingChangesets,
211
+ warnings,
212
+ };
213
+ }
214
+ /**
215
+ * Write the versions, dependency specs and changelogs of the releases and
216
+ * consume the applied changesets, restoring every changed file when a
217
+ * step fails.
218
+ *
219
+ * @param context Release context.
220
+ * @param plan Selected release plan.
221
+ * @param releases Releases with their changelog entries.
222
+ * @param date Release date, as YYYY-MM-DD.
223
+ * @param applied Versioned packages, or undefined when all were versioned.
224
+ */
225
+ async writeReleases(context, plan, releases, date, applied) {
226
+ const journal = new FileJournal();
227
+ const changesetDirectory = this.getChangesetDirectory(context.config);
380
228
  try {
381
229
  for (const release of releases) {
382
- const configured = packages.find((pkg) => pkg.name === release.package.name);
383
- if (!configured) {
384
- throw new ValidationError(`Unknown package "${release.package.name}".`);
385
- }
386
- for (const target of configured.versionConfig.to) {
387
- await writeVersion(configured.directory, target, release.nextVersion, journal);
388
- }
389
- for (const sourceName of release.sources ?? []) {
390
- const source = releasePlan.releases.find((item) => item.package.name === sourceName);
391
- if (!source) {
392
- throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
393
- }
394
- await updateDependencyDeclaration({
395
- dependent: release.package,
396
- source: source.package,
397
- version: source.nextVersion,
398
- rule: config.dependents,
399
- journal,
400
- });
401
- }
402
- await verifyVersionTargets(input.cwd, configured, release.nextVersion);
403
- await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
230
+ await this.writeRelease(context, plan, release, date, journal);
404
231
  }
405
- for (const changeset of releasePlan.changesets) {
406
- await consumeChangeset(path.join(changesetDirectory, changeset.fileName), changeset, input.packages, journal);
232
+ for (const changeset of plan.changesets) {
233
+ await consumeChangeset(path.join(changesetDirectory, changeset.fileName), changeset, applied, journal);
407
234
  }
408
235
  }
409
236
  catch (error) {
410
- const rollbackErrors = await restoreFileJournal(journal);
237
+ const rollbackErrors = await journal.restore();
411
238
  if (rollbackErrors.length) {
412
239
  throw new AggregateError([error, ...rollbackErrors], 'Changeset versioning failed and one or more file changes could not be rolled back.');
413
240
  }
414
241
  throw error;
415
242
  }
416
243
  }
417
- return {
418
- changesets: releasePlan.changesets,
419
- releases,
420
- date,
421
- changed: !input.dryRun &&
422
- (releasePlan.changesets.length > 0 ||
423
- releasePlan.releases.length > 0),
424
- };
425
- }
426
- /**
427
- * Drop bundle sources that aren't released by the selected plan.
428
- *
429
- * @param plan Selected release plan.
430
- * @return Release plan with dependents bundling only its released sources.
431
- */
432
- function expandDependentsPlan(plan) {
433
- const released = new Set(plan.releases.map((release) => release.package.name));
434
- plan.releases.forEach((release) => {
435
- if (release.sources) {
436
- release.sources = release.sources.filter((name) => released.has(name));
437
- if (!release.sources.length) {
438
- delete release.sources;
439
- }
440
- }
441
- });
442
- return plan;
443
- }
444
- /**
445
- * Remove the derived dependent releases of a plan, keeping only the releases
446
- * of packages that have their own changesets.
447
- *
448
- * @param plan Release plan of all pending changesets.
449
- * @return Release plan without derived dependent releases.
450
- */
451
- function stripDependentsPlan(plan) {
452
- return {
453
- ...plan,
454
- releases: plan.releases
455
- .filter((release) => release.changesets.length > 0)
456
- .map(({ sources, ...release }) => release),
457
- };
458
- }
459
- /**
460
- * Fail when a planned release bundles a source package that has pending
461
- * changesets released by a later run, so that no dependent ships unreleased
462
- * code under a changelog that cannot credit it.
463
- *
464
- * @param plan Planned releases.
465
- * @param fullPlan Release plan of all pending changesets.
466
- * @param packages Discovered packages.
467
- * @param config Repository release configuration.
468
- * @throws {ValidationError} If a source of a planned release stays pending.
469
- */
470
- async function assertDependentsSourcesReleased(plan, fullPlan, packages, config) {
471
- const releasing = new Set(plan.releases.map((release) => release.package.name));
472
- const pending = new Set(fullPlan.releases.map((release) => release.package.name));
473
- for (const release of plan.releases) {
474
- // Dependencies built into the dependent, like bundled or vendored
475
- // directories, stay out of the check, which only guards released
476
- // packages that the dependent declares.
477
- const names = await collectDependencyNames(release.package, packages, config);
478
- for (const name of names) {
479
- const blocked = pending.has(name) &&
480
- !releasing.has(name) &&
481
- name !== release.package.name;
482
- if (blocked) {
483
- throw new ValidationError(`Cannot release package "${release.package.name}" while its source "${name}" has pending changesets; release them together or pass --no-dependents.`);
484
- }
485
- }
486
- }
487
- }
488
- /**
489
- * Collect the names of the configured sources that a package declares, by the
490
- * dependency sections of the policy, without resolving the specs against the
491
- * versions. The changeset check uses this to see through a pin that the
492
- * versioning will rewrite after the source releases.
493
- *
494
- * @param pkg Planned package.
495
- * @param packages Discovered packages.
496
- * @param config Repository release configuration.
497
- * @return Release package names the package declares.
498
- */
499
- async function collectDeclarationNames(pkg, packages, config) {
500
- const names = [];
501
- for (const source of packages) {
502
- if (pkg.name !== source.name &&
503
- (await findNpmSpecs(pkg, source, dependentsSections(config.dependents))).length > 0) {
504
- names.push(source.name);
505
- }
506
- }
507
- return names;
508
- }
509
- /**
510
- * Collect the names of the configured sources that a package builds against,
511
- * through specs that resolve the current version or point at the workspace.
512
- *
513
- * @param pkg Planned package.
514
- * @param packages Discovered packages.
515
- * @param config Repository release configuration.
516
- * @return Release package names the package declares.
517
- */
518
- async function collectDependencyNames(pkg, packages, config) {
519
- const names = [];
520
- for (const source of packages) {
521
- if (pkg.name !== source.name &&
522
- (await findDependencySpecs(pkg, source, config.dependents))
523
- .length > 0) {
524
- names.push(source.name);
525
- }
526
- }
527
- return names;
528
- }
529
- /**
530
- * Update how a dependent declares its dependency on a released source, with
531
- * the configured hook or the built-in spec rewrite.
532
- *
533
- * @param input Dependent, source, released version, policy and journal.
534
- * @param input.dependent The released dependent package.
535
- * @param input.source Released source package that the dependent bundles.
536
- * @param input.version Released version of the source.
537
- * @param input.rule Dependents policy with its sections and dependency hooks.
538
- * @param input.journal File journal for rolling back.
539
- */
540
- async function updateDependencyDeclaration(input) {
541
- const { dependent, source, version, rule, journal } = input;
542
- const specs = await findDependencySpecs(dependent, source, rule);
543
- const update = rule?.dependency?.update;
544
- if (update) {
545
- await update({
546
- package: dependent,
547
- source,
548
- version,
549
- specs,
550
- remember: (file) => rememberFile(journal, file),
551
- });
552
- return;
553
- }
554
- for (const { file, name, spec } of specs) {
555
- const nextSpec = updatedSpec(spec, version);
556
- if (null === nextSpec &&
557
- isOutOfRange(version, spec, source.currentVersion)) {
558
- throw new ValidationError(`Cannot rewrite the dependency spec "${spec}" of "${name}" in ${dependent.name}'s ${file}; declare a range or pin that versioning rewrites, like "^${version}".`);
559
- }
560
- const changed = undefined !== nextSpec && nextSpec !== spec;
561
- if (changed) {
562
- const contents = await readFile(file, 'utf8');
563
- const declared = new RegExp(`"${escapeRegExp(name)}"\\s*:\\s*"${escapeRegExp(spec)}"`);
564
- if (!declared.test(contents)) {
565
- throw new ValidationError(`The dependency spec of "${name}" in ${dependent.name}'s ${file} doesn't match "${spec}"; rewrite it to "${nextSpec}".`);
566
- }
567
- await rememberFile(journal, file);
568
- await writeFile(file, contents.replace(declared, `"${name}": "${nextSpec}"`));
244
+ /**
245
+ * Write the version, the dependency specs of bundled sources and the
246
+ * changelog of a release.
247
+ *
248
+ * @param context Release context.
249
+ * @param plan Selected release plan.
250
+ * @param release Release with its changelog entries.
251
+ * @param date Release date, as YYYY-MM-DD.
252
+ * @param journal File journal for rolling back.
253
+ */
254
+ async writeRelease(context, plan, release, date, journal) {
255
+ const { config } = context;
256
+ const configured = context.packages.find((pkg) => pkg.name === release.package.name);
257
+ if (!configured) {
258
+ throw new ValidationError(`Unknown package "${release.package.name}".`);
569
259
  }
570
- }
571
- }
572
- /**
573
- * Update a dependency spec to the released version, keeping its shape, like
574
- * the caret of ^0.9.0 or the x of 0.9.x.
575
- *
576
- * @param spec Declared spec of the source.
577
- * @param version Released version of the source.
578
- * @return The updated spec, undefined when nothing has to change, like a wildcard or a workspace spec, or null when the spec can't be rewritten.
579
- */
580
- function updatedSpec(spec, version) {
581
- if (spec.startsWith('workspace:')) {
582
- // Workspace specs resolve to the local package, so the manifest
583
- // keeps them instead of a published version.
584
- return undefined;
585
- }
586
- const prefix = /^[=\^~]/.exec(spec)?.[0] ?? '';
587
- const bare = spec.slice(prefix.length).trim();
588
- const partial = /^(v?\d+(?:\.\d+)*)(\.x)?$/.exec(bare);
589
- if (partial?.[1]) {
590
- const depth = partial[1].split('.').length;
591
- const stem = version.split('.').slice(0, depth).join('.');
592
- // npm partials like 1.x pin the major, and 0.9 pins the major.minor.
593
- return `${prefix}${stem}${partial[2] ?? ''}`;
594
- }
595
- // Wildcards and keyword specs like * or latest already accept the release.
596
- return null;
597
- }
598
- /**
599
- * Format the changelog entries of each release, with the configured
600
- * formatter and the commit that added each changeset.
601
- *
602
- * @param cwd Repository root.
603
- * @param config Repository release configuration.
604
- * @param releases Package releases.
605
- * @param changesetDirectory Directory of the changeset files.
606
- * @return Releases with their changelog entries.
607
- */
608
- async function getVersionedReleases(cwd, config, releases, changesetDirectory) {
609
- const commits = new Map();
610
- const getCommit = (changeset) => {
611
- const cached = commits.get(changeset.fileName) ??
612
- getChangesetCommit(cwd, path.join(changesetDirectory, changeset.fileName));
613
- commits.set(changeset.fileName, cached);
614
- return cached;
615
- };
616
- const releasesByName = new Map(releases.map((release) => [release.package.name, release]));
617
- return Promise.all(releases.map(async (release) => {
618
- const entries = [];
619
- // Format one at a time, so that formatters calling an API aren't rate limited.
620
- for (const changeset of release.changesets) {
621
- entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
260
+ for (const target of configured.versionConfig.to) {
261
+ await writeVersion(configured.directory, target, release.nextVersion, journal);
622
262
  }
623
263
  for (const sourceName of release.sources ?? []) {
624
- const source = releasesByName.get(sourceName);
264
+ const source = plan.releases.find((item) => item.package.name === sourceName);
625
265
  if (!source) {
626
266
  throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
627
267
  }
628
- entries.push(await getDependentsChangelogEntry(config, release.package, source));
268
+ await updateDependencyDeclaration({
269
+ dependent: release.package,
270
+ source: source.package,
271
+ version: source.nextVersion,
272
+ rule: config.dependents,
273
+ journal,
274
+ });
629
275
  }
630
- return { ...release, changelog: formatChangelogList(entries) };
631
- }));
632
- }
633
- /**
634
- * Format the bundle changelog entry of a dependent release.
635
- *
636
- * @param config Repository release configuration.
637
- * @param pkg Dependent package being re-released.
638
- * @param source Released source package bundled by the dependent.
639
- * @return Changelog entry.
640
- */
641
- async function getDependentsChangelogEntry(config, pkg, source) {
642
- if (!config.dependents?.changelog) {
643
- return `Bundle ${source.package.name.split('/').pop()} ${source.nextVersion}.`;
644
- }
645
- const entry = await config.dependents.changelog({
646
- package: pkg,
647
- source: {
648
- package: source.package,
649
- version: source.nextVersion,
650
- },
651
- });
652
- if ('string' !== typeof entry) {
653
- throw new ValidationError(`The dependents changelog formatter didn't return a string for package "${pkg.name}".`);
654
- }
655
- return entry.trim();
656
- }
657
- async function getChangelogEntry(config, pkg, changeset, getCommit) {
658
- const summary = changeset.summary.trim();
659
- if (!summary || !config.changelogEntry) {
660
- return summary;
661
- }
662
- const commit = await getCommit(changeset);
663
- const entry = await config.changelogEntry({
664
- package: pkg,
665
- changeset,
666
- ...(commit ? { commit } : {}),
667
- });
668
- if ('string' !== typeof entry) {
669
- throw new ValidationError(`The changelogEntry formatter didn't return a string for changeset ${changeset.fileName}.`);
670
- }
671
- return entry.trim();
672
- }
673
- /**
674
- * Find the commit that added a changeset file.
675
- *
676
- * @param cwd Repository root.
677
- * @param file Changeset file.
678
- * @return Commit, or undefined when the file isn't committed.
679
- */
680
- async function getChangesetCommit(cwd, file) {
681
- const output = await git(cwd, [
682
- 'log',
683
- '--diff-filter=A',
684
- '--format=%H%x1f%h%x1f%s',
685
- '-1',
686
- '--',
687
- path.relative(cwd, file).split(path.sep).join('/'),
688
- ]);
689
- const [hash, shortHash = '', subject = ''] = output
690
- .trim()
691
- .split('\x1f');
692
- return hash ? { hash, shortHash, subject } : undefined;
693
- }
694
- /**
695
- * Add a package to the selected release plan when it bundles a selected source.
696
- *
697
- * @param source Selected package release.
698
- * @param dependent Package that would bundle the source.
699
- * @param packages Discovered packages.
700
- * @param rule Dependents policy.
701
- * @param selected Selected package names, mutated in place.
702
- * @return Whether the package was selected.
703
- */
704
- async function selectDependentRelease(source, dependent, packages, rule, selected) {
705
- const skip = selected.has(dependent.name) ||
706
- dependent.name === source.package.name ||
707
- isExcludedDependent(dependent, rule);
708
- if (skip) {
709
- return false;
710
- }
711
- const specs = await findDependencySpecs(dependent, source.package, rule);
712
- if (!specs.length || !dependentNeedsRelease(specs, source, rule)) {
713
- return false;
714
- }
715
- selected.add(dependent.name);
716
- return true;
717
- }
718
- /**
719
- * Limit a release plan to some packages and the changesets that name them.
720
- *
721
- * @param plan Release plan of all pending changesets.
722
- * @param packages Configured packages.
723
- * @param names Packages to version.
724
- * @param config Repository release configuration.
725
- * @param withDependents Whether the selection also releases the dependents of the selected sources.
726
- * @return Release plan of the selected packages and their dependents.
727
- * @throws {ValidationError} For unknown packages.
728
- */
729
- async function selectReleasePlan(plan, packages, names, config, withDependents = true) {
730
- names.forEach((name) => {
731
- if (!packages.some((pkg) => pkg.name === name)) {
732
- throw new ValidationError(`Unknown package "${name}".`);
276
+ await verifyVersionTargets(this.repository, configured, release.nextVersion);
277
+ await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
278
+ }
279
+ /**
280
+ * Load the configuration, discover the packages and plan the releases of
281
+ * the pending changesets, including the releases of their dependents.
282
+ *
283
+ * @return Release context, undefined without a config.
284
+ * @throws {ValidationError} For an invalid config, plan or changeset.
285
+ */
286
+ async loadContext() {
287
+ const loaded = await loadChangesetConfig({
288
+ cwd: this.cwd,
289
+ ...(this.configFile ? { configFile: this.configFile } : {}),
290
+ });
291
+ if (!loaded) {
292
+ return undefined;
733
293
  }
734
- });
735
- // Dependents that need a release close over the selected sources.
736
- const selected = new Set(names);
737
- let grew = true;
738
- // Dependents may depend on other selected dependents, so expand until stable.
739
- while (grew && withDependents && config.dependents) {
740
- grew = false;
741
- const selectedSources = plan.releases.filter((release) => selected.has(release.package.name));
742
- for (const source of selectedSources) {
743
- for (const dependent of packages) {
744
- if (await selectDependentRelease(source, dependent, packages, config.dependents, selected)) {
745
- grew = true;
294
+ const config = loaded.config;
295
+ const packages = await discoverPackages(this.repository, config);
296
+ const changesets = await readChangesets(this.getChangesetDirectory(config));
297
+ const releases = [];
298
+ packages.forEach((pkg) => {
299
+ const bump = getChangesetBump(changesets, pkg.name);
300
+ if (bump) {
301
+ const packageChangesets = changesets.filter((changeset) => Object.hasOwn(changeset.packages, pkg.name));
302
+ const nextVersion = config.nextVersion
303
+ ? config.nextVersion({
304
+ package: pkg,
305
+ bump,
306
+ currentVersion: pkg.currentVersion,
307
+ changesets: packageChangesets,
308
+ })
309
+ : getNextVersion(pkg.currentVersion, bump);
310
+ if ('string' !== typeof nextVersion || !nextVersion.trim()) {
311
+ throw new ValidationError(`The nextVersion strategy returned an empty version for package "${pkg.name}".`);
746
312
  }
313
+ releases.push({
314
+ package: pkg,
315
+ bump,
316
+ currentVersion: pkg.currentVersion,
317
+ nextVersion,
318
+ changesets: packageChangesets,
319
+ });
747
320
  }
748
- }
749
- }
750
- const selectedNames = [...selected];
751
- return {
752
- releases: plan.releases.filter((release) => {
753
- if (!selected.has(release.package.name)) {
754
- return false;
755
- }
756
- // Explicitly selected dependents without their own changesets only
757
- // release when their source releases in the same selection.
758
- return (release.changesets.length > 0 ||
759
- (release.sources ?? []).some((name) => selected.has(name)));
760
- }),
761
- changesets: plan.changesets.filter((changeset) => selectedNames.some((name) => Object.hasOwn(changeset.packages, name))),
762
- };
763
- }
764
- /**
765
- * Remove an applied changeset, or only the applied packages from a changeset
766
- * that names other packages too, so that their releases keep its summary.
767
- *
768
- * @param file Changeset file.
769
- * @param changeset Parsed changeset.
770
- * @param applied Versioned packages, or undefined when all were versioned.
771
- * @param journal File journal for rolling back.
772
- */
773
- async function consumeChangeset(file, changeset, applied, journal) {
774
- const remaining = Object.entries(changeset.packages).filter(([name]) => undefined !== applied && !applied.includes(name));
775
- await rememberFile(journal, file);
776
- if (remaining.length) {
777
- await writeFile(file, formatChangeset({
778
- packages: Object.fromEntries(remaining),
779
- summary: changeset.summary,
780
- }));
781
- }
782
- else {
783
- await rm(file);
784
- }
785
- }
786
- /**
787
- * Create missing release tags, optionally pushing them to the configured remote.
788
- *
789
- * @param input Repository and tag options.
790
- * @return Per-package tags and release notes.
791
- */
792
- export async function tagChangesets(input) {
793
- if (input.githubRelease && !input.push) {
794
- throw new ValidationError('GitHub Releases require pushing the tags.');
795
- }
796
- if (input.untagged && input.push) {
797
- throw new ValidationError('Listing untagged releases cannot be combined with pushing tags.');
798
- }
799
- const context = await getReleaseContext(input);
800
- if (!context) {
801
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
802
- }
803
- const selected = selectPackages(context.packages, input);
804
- const packages = input.since
805
- ? (await changedPackagesSince(input.cwd, selected, input.since))
806
- .packages
807
- : selected;
808
- const results = [];
809
- const dryRun = input.dryRun || input.untagged;
810
- const tagFormat = context.config.tagFormat ?? '{path}/{version}';
811
- for (const pkg of packages) {
812
- const version = await getTagVersion(pkg, input.version);
813
- if (undefined !== input.version && input.version !== version) {
814
- throw new ValidationError(`Package "${pkg.name}" is at version ${version}, not ${input.version}.`);
815
- }
816
- const tag = formatTag(tagFormat, pkg, version);
817
- const exists = await gitTagExists(input.cwd, tag);
818
- if (!exists) {
819
- await verifyVersionTargets(input.cwd, pkg, version);
820
- }
821
- const previousTag = await getPreviousTag(input.cwd, formatTag(tagFormat, pkg, ''), version);
822
- const notes = await getReleaseNotes(input.cwd, context.config, pkg, { version, tag, previousTag }, exists ? tag : 'HEAD');
823
- if (!exists && !dryRun) {
824
- await git(input.cwd, ['tag', tag]);
825
- }
826
- results.push({
827
- package: pkg.name,
828
- version,
829
- tag,
830
- ...(previousTag ? { previousTag } : {}),
831
- prerelease: isPrerelease(version),
832
- exists,
833
- created: !exists && !dryRun,
834
- notes,
835
321
  });
836
- }
837
- // Push one tag at a time because GitHub doesn't create push events for
838
- // tags when more than three are pushed at once.
839
- if (input.push && !dryRun) {
840
- for (const item of results.filter((result) => result.created)) {
841
- await git(input.cwd, ['push', 'origin', item.tag]);
842
- }
843
- }
844
- if (input.githubRelease && !dryRun) {
845
- for (const release of results) {
846
- await createGitHubRelease(input.cwd, release);
847
- }
848
- }
849
- return {
850
- tags: input.untagged
851
- ? results.filter((result) => !result.exists)
852
- : results,
853
- };
854
- }
855
- async function createGitHubRelease(cwd, release) {
856
- try {
857
- await execFileAsync('gh', ['release', 'view', release.tag], {
858
- cwd,
322
+ changesets.forEach((changeset) => {
323
+ Object.keys(changeset.packages).forEach((name) => {
324
+ if (!packages.some((pkg) => pkg.name === name)) {
325
+ throw new ValidationError(`Changeset ${changeset.fileName} refers to unknown package "${name}".`);
326
+ }
327
+ });
859
328
  });
860
- }
861
- catch {
862
- try {
863
- await execFileAsync('gh', [
864
- 'release',
865
- 'create',
866
- release.tag,
867
- '--title',
868
- `${release.package}@${release.version}`,
869
- '--notes',
870
- release.notes,
871
- ...(release.prerelease ? ['--prerelease'] : []),
872
- ], { cwd });
873
- }
874
- catch (error) {
875
- const message = error instanceof Error ? error.message : String(error);
876
- throw new ValidationError(`Could not create GitHub Release for ${release.tag}: ${message}`);
329
+ await applyDependents(releases, packages, config);
330
+ return { config, packages, plan: { changesets, releases } };
331
+ }
332
+ /**
333
+ * Load the release context of a repository that has to be configured.
334
+ *
335
+ * @return Release context.
336
+ * @throws {ValidationError} Without a config.
337
+ */
338
+ async requireContext() {
339
+ const context = await this.loadContext();
340
+ if (!context) {
341
+ throw new ValidationError(MISSING_CONFIG);
342
+ }
343
+ return context;
344
+ }
345
+ /**
346
+ * Load the configuration of a repository that has to be configured.
347
+ *
348
+ * @return Repository release configuration.
349
+ * @throws {ValidationError} Without a config.
350
+ */
351
+ async requireConfig() {
352
+ const loaded = await loadChangesetConfig({
353
+ cwd: this.cwd,
354
+ ...(this.configFile ? { configFile: this.configFile } : {}),
355
+ });
356
+ if (!loaded) {
357
+ throw new ValidationError(MISSING_CONFIG);
877
358
  }
359
+ return loaded.config;
878
360
  }
879
- }
880
- /**
881
- * Get the version to tag. Packages versioned by their git tags have no
882
- * version file, so the expected version is the version to tag, or else the
883
- * latest CHANGELOG.md version.
884
- *
885
- * @param pkg Configured package.
886
- * @param expected Expected version from --version, if any.
887
- * @return Version to tag.
888
- */
889
- async function getTagVersion(pkg, expected) {
890
- if ('git-tag' !== pkg.versionConfig.from.kind) {
891
- return pkg.currentVersion;
361
+ /**
362
+ * Get the directory of the changeset files.
363
+ *
364
+ * @param config Repository release configuration.
365
+ * @return Absolute directory path.
366
+ */
367
+ getChangesetDirectory(config) {
368
+ return path.resolve(this.cwd, config.changesets ?? '.changeset');
892
369
  }
893
- return (expected ??
894
- (await getLatestChangelogVersion(pkg.directory, pkg.currentVersion)));
895
370
  }
896
371
  /**
897
- * Pick the packages passed with --package, or all packages without it.
372
+ * Discover configured packages, pending changesets, and their proposed versions.
898
373
  *
899
- * @param packages Configured packages.
900
- * @param input Tag options.
901
- * @return Selected packages in the configured order.
902
- * @throws {ValidationError} For unknown packages, or --version without a single package.
374
+ * @param input Repository root and optional explicit config file.
375
+ * @return A release plan; `undefined` when no config is present.
903
376
  */
904
- function selectPackages(packages, input) {
905
- const names = input.packages ?? [];
906
- if (undefined !== input.version && 1 !== names.length) {
907
- throw new ValidationError('An expected version requires exactly one package.');
908
- }
909
- names.forEach((name) => {
910
- if (!packages.some((pkg) => pkg.name === name)) {
911
- throw new ValidationError(`Unknown package "${name}".`);
912
- }
913
- });
914
- return names.length
915
- ? packages.filter((pkg) => names.includes(pkg.name))
916
- : packages;
377
+ export async function getChangesetReleasePlan(input) {
378
+ return new ChangesetRelease(input).getPlan();
917
379
  }
918
380
  /**
919
- * Find the tag of the newest release before a version.
381
+ * Discover the packages of the repository configuration.
920
382
  *
921
- * @param cwd Repository root.
922
- * @param prefix Tag prefix of the package, like `plugins/alpha/`.
923
- * @param version Version being released.
924
- * @return Previous tag, or undefined for the first release.
383
+ * @param input Repository root and optional explicit config file.
384
+ * @return Packages in the configured order with their current versions.
385
+ * @throws {ValidationError} Without a config, or for an invalid one.
925
386
  */
926
- async function getPreviousTag(cwd, prefix, version) {
927
- const output = await git(cwd, ['tag', '--list', `${prefix}*`]);
928
- return output
929
- .trim()
930
- .split(/\r?\n/)
931
- .filter((tag) => tag.startsWith(prefix))
932
- .map((tag) => ({ tag, version: tag.slice(prefix.length) }))
933
- .filter((item) => isVersion(item.version) &&
934
- compareVersions(item.version, version) < 0)
935
- .sort((a, b) => compareVersions(b.version, a.version))[0]?.tag;
387
+ export async function getChangesetPackages(input) {
388
+ return new ChangesetRelease(input).getPackages();
936
389
  }
937
390
  /**
938
- * Build the release notes of a tag with the configured formatter.
391
+ * Create a Changesets-compatible Markdown file.
939
392
  *
940
- * @param cwd Repository root.
941
- * @param config Repository release configuration.
942
- * @param pkg Released package.
943
- * @param release Version, tag and previous tag of the release.
944
- * @param release.version Released version.
945
- * @param release.tag Release tag.
946
- * @param release.previousTag Tag of the previous release, if any.
947
- * @param end Commit of the release: the tag, or HEAD before tagging.
948
- * @return Release notes.
393
+ * @param input Repository, packages and summary.
394
+ * @return Created file path.
949
395
  */
950
- async function getReleaseNotes(cwd, config, pkg, release, end) {
951
- const changelog = await readReleaseNotes(pkg.directory, release.version);
952
- const commits = await getReleaseCommits(cwd, pkg, end, release.previousTag);
953
- const notes = changelog ||
954
- commits
955
- .map((commit) => `- ${commit.subject} (${commit.shortHash})`)
956
- .join('\n');
957
- if (!config.releaseNotes) {
958
- return notes;
959
- }
960
- const formatted = await config.releaseNotes({
961
- package: toChangesetPackage(pkg),
962
- ...release,
963
- changelog,
964
- commits,
965
- notes,
966
- });
967
- if ('string' !== typeof formatted) {
968
- throw new ValidationError(`The releaseNotes formatter didn't return a string for package "${pkg.name}".`);
969
- }
970
- return formatted.trim();
396
+ export async function addChangeset(input) {
397
+ return new ChangesetRelease(input).add(input);
971
398
  }
972
399
  /**
973
- * List the commits that changed a package since its previous release.
400
+ * Apply pending package versions and changelogs.
974
401
  *
975
- * @param cwd Repository root.
976
- * @param pkg Released package.
977
- * @param end Commit of the release.
978
- * @param previousTag Tag of the previous release, if any.
979
- * @return Commits, newest first.
402
+ * @param input Repository and versioning options.
403
+ * @return Planned releases and whether files were changed.
980
404
  */
981
- async function getReleaseCommits(cwd, pkg, end, previousTag) {
982
- const packageFromRepo = path.relative(cwd, pkg.directory).split(path.sep).join('/') ||
983
- '.';
984
- const output = await git(cwd, [
985
- 'log',
986
- '--format=%H%x1f%h%x1f%s',
987
- previousTag ? `${previousTag}..${end}` : end,
988
- '--',
989
- packageFromRepo,
990
- ]);
991
- return output
992
- .trim()
993
- .split(/\r?\n/)
994
- .filter(Boolean)
995
- .map((line) => {
996
- const [hash = '', shortHash = '', subject = ''] = line.split('\x1f');
997
- return { hash, shortHash, subject };
998
- });
405
+ export async function versionChangesets(input) {
406
+ return new ChangesetRelease(input).version(input);
999
407
  }
1000
408
  /**
1001
- * Remove the release configuration from a package passed to config callbacks.
409
+ * Create missing release tags, optionally pushing them to the configured remote.
1002
410
  *
1003
- * @param pkg Configured package.
1004
- * @return Package without its group configuration.
411
+ * @param input Repository and tag options.
412
+ * @return Per-package tags and release notes.
1005
413
  */
1006
- function toChangesetPackage(pkg) {
1007
- return {
1008
- name: pkg.name,
1009
- directory: pkg.directory,
1010
- path: pkg.path,
1011
- type: pkg.type,
1012
- currentVersion: pkg.currentVersion,
1013
- };
414
+ export async function tagChangesets(input) {
415
+ return new ChangesetRelease(input).tag(input);
1014
416
  }
1015
417
  /**
1016
418
  * Check whether changed files belong to packages without a changeset.
@@ -1019,733 +421,5 @@ function toChangesetPackage(pkg) {
1019
421
  * @return Changed packages and missing changeset identifiers.
1020
422
  */
1021
423
  export async function getChangesetStatus(input) {
1022
- const context = await getReleaseContext(input);
1023
- if (!context) {
1024
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
1025
- }
1026
- const { plan } = context;
1027
- const { changesets, releases } = plan;
1028
- const config = context.config;
1029
- const { packages } = context;
1030
- const since = input.since ??
1031
- (context.config.baseBranch
1032
- ? `origin/${context.config.baseBranch}`
1033
- : undefined);
1034
- const changed = since
1035
- ? await changedPackagesSince(input.cwd, context.packages, since)
1036
- : { packages: [], filesByPackage: {} };
1037
- const changedPackages = changed.packages.map((pkg) => pkg.name);
1038
- const changesetsInDiff = since
1039
- ? await readChangesetsSince(input.cwd, context.config, changesets, since)
1040
- : [];
1041
- const hasEmptyChangeset = changesetsInDiff.some((changeset) => 0 === Object.keys(changeset.packages).length);
1042
- // A dependent releases with its sources, which the release pull request
1043
- // already announces when this pull request's changesets release one of
1044
- // them. Exempting its package manifest pin change keeps the check from
1045
- // demanding an entry for what versioning rewrites anyway; documentation
1046
- // and code changes still need their own changesets.
1047
- const bundled = new Set((await Promise.all(changedPackages
1048
- .filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)))
1049
- .map(async (name) => {
1050
- if (!config.dependents ||
1051
- !(changed.filesByPackage[name] ?? []).every((file) => path.basename(file) === 'package.json')) {
1052
- return undefined;
1053
- }
1054
- const configured = packages.find((pkg) => pkg.name === name);
1055
- if (!configured) {
1056
- return undefined;
1057
- }
1058
- const sources = await collectDeclarationNames(configured, packages, config);
1059
- return changesetsInDiff.some((item) => sources.some((source) => Object.hasOwn(item.packages, source)))
1060
- ? name
1061
- : undefined;
1062
- }))).filter((name) => undefined !== name));
1063
- const missingChangesets = hasEmptyChangeset
1064
- ? []
1065
- : changedPackages.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)) && !bundled.has(name));
1066
- // A pin that doesn't resolve the local package selects a registry release
1067
- // instead, which the dependents policy won't re-release automatically.
1068
- const warnings = config.dependents
1069
- ? await collectPinDriftWarnings(context.config, context.packages)
1070
- : [];
1071
- return {
1072
- changesets,
1073
- releases,
1074
- changedPackages,
1075
- changedPackageFiles: changed.filesByPackage,
1076
- missingChangesets,
1077
- warnings,
1078
- };
1079
- }
1080
- /**
1081
- * Warn about declared pins of configured packages that don't resolve the
1082
- * current version of the package they name.
1083
- *
1084
- * @param config Repository release configuration.
1085
- * @param packages Discovered packages.
1086
- * @return Warnings, one per package pair.
1087
- */
1088
- async function collectPinDriftWarnings(config, packages) {
1089
- const warnings = [];
1090
- for (const dependent of packages) {
1091
- const others = packages.filter((source) => source.name !== dependent.name);
1092
- for (const source of others) {
1093
- const driftedPin = (await findNpmSpecs(dependent, source, config.dependents?.sections ?? DEFAULT_SECTIONS))
1094
- .filter(({ spec }) => !spec.startsWith('workspace:') &&
1095
- isOutOfRange(source.currentVersion, spec, source.currentVersion))
1096
- .at(0);
1097
- if (driftedPin) {
1098
- warnings.push(`"${dependent.name}" pins "${driftedPin.name}@${driftedPin.spec}", but the package is at ${source.currentVersion}; the dependents policy won't re-release it until the pin resolves the package.`);
1099
- }
1100
- }
1101
- }
1102
- return warnings;
1103
- }
1104
- /**
1105
- * Find the npm specs that a package declares for a source, without the
1106
- * dependency filtering of dependents links, for drift warnings.
1107
- *
1108
- * @param dependent Package potentially declaring the dependency.
1109
- * @param source Package the dependency is declared of.
1110
- * @param sections Dependency sections to scan.
1111
- * @return The declared specs.
1112
- */
1113
- async function findNpmSpecs(dependent, source, sections) {
1114
- const sourceManifest = await readJsonSafe(path.join(source.directory, 'package.json'));
1115
- if ('string' !== typeof sourceManifest?.name) {
1116
- return [];
1117
- }
1118
- const identifier = sourceManifest.name;
1119
- const parsed = await readJsonSafe(path.join(dependent.directory, 'package.json'));
1120
- const specs = [];
1121
- for (const section of sections) {
1122
- const sectionSpecs = parsed?.[section];
1123
- const declared = 'object' === typeof sectionSpecs &&
1124
- null !== sectionSpecs &&
1125
- Object.hasOwn(sectionSpecs, identifier)
1126
- ? String(sectionSpecs[identifier])
1127
- : undefined;
1128
- if (undefined !== declared) {
1129
- specs.push({
1130
- file: path.join(dependent.directory, 'package.json'),
1131
- name: identifier,
1132
- spec: declared,
1133
- });
1134
- }
1135
- }
1136
- return specs;
1137
- }
1138
- async function requireConfig(input) {
1139
- const loaded = await loadChangesetConfig(input);
1140
- if (!loaded) {
1141
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
1142
- }
1143
- return loaded;
1144
- }
1145
- async function readChangesets(cwd, config) {
1146
- const directory = path.resolve(cwd, config.changesets ?? '.changeset');
1147
- let entries;
1148
- try {
1149
- entries = await readdir(directory);
1150
- }
1151
- catch (error) {
1152
- if ('ENOENT' === error.code) {
1153
- return [];
1154
- }
1155
- throw error;
1156
- }
1157
- return Promise.all(entries
1158
- .filter((name) => name.endsWith('.md') && !name.startsWith('README'))
1159
- .sort()
1160
- .map(async (name) => parseChangeset(await readFile(path.join(directory, name), 'utf8'), name)));
1161
- }
1162
- async function readChangesetsSince(cwd, config, changesets, since) {
1163
- const directory = path.resolve(cwd, config.changesets ?? '.changeset');
1164
- const relativeDirectory = path
1165
- .relative(cwd, directory)
1166
- .split(path.sep)
1167
- .join('/');
1168
- const output = await git(cwd, [
1169
- 'diff',
1170
- '--name-only',
1171
- '--diff-filter=ACMR',
1172
- `${since}...HEAD`,
1173
- '--',
1174
- relativeDirectory,
1175
- ]);
1176
- const changedFiles = new Set(output.trim().split(/\r?\n/).filter(Boolean));
1177
- return changesets.filter((changeset) => changedFiles.has(path.posix.join(relativeDirectory, changeset.fileName)));
1178
- }
1179
- async function discoverPackages(cwd, config) {
1180
- const root = path.resolve(cwd, config.root ?? '.');
1181
- const packages = [];
1182
- const hasRecursiveGlob = config.packages.some((item) => item.glob.split('/').includes('**'));
1183
- const directories = await findDirectories(root, '', hasRecursiveGlob
1184
- ? Infinity
1185
- : Math.max(1, ...config.packages.map((item) => item.glob.split('/').length + 2)));
1186
- for (const group of config.packages) {
1187
- const matcher = globRegExp(group.glob);
1188
- const candidates = matcher.test('.')
1189
- ? ['.', ...directories]
1190
- : directories;
1191
- const matches = candidates
1192
- .filter((item) => matcher.test(item) &&
1193
- !(group.exclude ?? []).some((excluded) => globRegExp(excluded).test(item)))
1194
- .sort();
1195
- if (!matches.length) {
1196
- throw new ValidationError(`Package glob "${group.glob}" did not match any directories under ${root}.`);
1197
- }
1198
- for (const relativePath of matches) {
1199
- const directory = path.join(root, relativePath);
1200
- const name = await packageName(directory, relativePath, group.idFrom ?? 'path');
1201
- const currentVersion = await readVersion(cwd, directory, group.version.from, relativePath, group.tagType);
1202
- if (packages.some((pkg) => pkg.name === name)) {
1203
- throw new ValidationError(`Package identifier "${name}" is configured more than once.`);
1204
- }
1205
- packages.push({
1206
- name,
1207
- directory,
1208
- path: relativePath,
1209
- type: packageType(group),
1210
- currentVersion,
1211
- group,
1212
- versionConfig: group.version,
1213
- });
1214
- }
1215
- }
1216
- return packages;
1217
- }
1218
- async function findDirectories(root, prefix = '', maxDepth = 5) {
1219
- let entries;
1220
- try {
1221
- entries = await readdir(path.join(root, prefix), {
1222
- withFileTypes: true,
1223
- });
1224
- }
1225
- catch (error) {
1226
- if ('ENOENT' === error.code) {
1227
- return [];
1228
- }
1229
- throw error;
1230
- }
1231
- const directories = entries.filter((item) => item.isDirectory() &&
1232
- !['.git', 'node_modules', 'vendor', 'dist'].includes(item.name));
1233
- if (prefix.split(path.sep).filter(Boolean).length >= maxDepth) {
1234
- return directories.map((item) => path.join(prefix, item.name));
1235
- }
1236
- const children = await Promise.all(directories.map((item) => findDirectories(root, path.join(prefix, item.name), maxDepth)));
1237
- return [
1238
- ...directories.map((item) => path.join(prefix, item.name)),
1239
- ...children.flat(),
1240
- ];
1241
- }
1242
- function globRegExp(glob) {
1243
- const escaped = glob
1244
- .split('/')
1245
- .map((part) => {
1246
- if ('**' === part) {
1247
- return '.*';
1248
- }
1249
- return part
1250
- .replaceAll(/[.+^${}()|[\]\\]/g, '\\$&')
1251
- .replaceAll('*', '[^/]*')
1252
- .replaceAll('?', '[^/]');
1253
- })
1254
- .join('/');
1255
- return new RegExp(`^${escaped}$`);
1256
- }
1257
- async function packageName(directory, relativePath, idFrom) {
1258
- if ('path' === idFrom) {
1259
- if ('.' === relativePath) {
1260
- return path.basename(directory);
1261
- }
1262
- return relativePath.split(path.sep).join('/');
1263
- }
1264
- const [file, field] = idFrom.split('#');
1265
- if (!file || !field) {
1266
- throw new ValidationError(`Invalid package identifier source "${idFrom}".`);
1267
- }
1268
- const manifest = await readJson(path.join(directory, file));
1269
- const name = manifest[field];
1270
- if ('string' !== typeof name || !name.trim()) {
1271
- throw new ValidationError(`Expected ${file}#${field} in ${directory}.`);
1272
- }
1273
- return name;
1274
- }
1275
- async function readVersion(cwd, directory, descriptor, relativePath, tagType) {
1276
- if ('package-json' === descriptor.kind ||
1277
- 'composer-json' === descriptor.kind) {
1278
- const manifest = await readJson(path.join(directory, descriptor.file));
1279
- const version = manifest[descriptor.field];
1280
- if ('string' === typeof version && version.trim()) {
1281
- return version;
1282
- }
1283
- throw new ValidationError(`Missing ${descriptor.file}#${descriptor.field} in ${directory}.`);
1284
- }
1285
- if ('git-tag' === descriptor.kind) {
1286
- const pattern = descriptor.pattern ?? '{path}/{version}';
1287
- const tagsOutput = await git(cwd, [
1288
- 'tag',
1289
- '--list',
1290
- `${formatTemplatePrefix(pattern, relativePath, tagType)}*`,
1291
- ]);
1292
- const tags = tagsOutput.trim().split(/\r?\n/).filter(Boolean);
1293
- const versions = tags
1294
- .map((tag) => tag.match(/(?:^|\/|v)(\d+\.\d+\.\d+)$/)?.[1])
1295
- .filter((item) => Boolean(item));
1296
- versions.sort((a, b) => compareVersions(b, a));
1297
- if (versions[0]) {
1298
- return versions[0];
1299
- }
1300
- throw new ValidationError(`No git tag matches ${pattern} for ${relativePath}.`);
1301
- }
1302
- if ('php-constant' === descriptor.kind) {
1303
- const file = path.join(directory, descriptor.file);
1304
- const contents = await readFile(file, 'utf8');
1305
- const versions = findConstantValues(contents, descriptor.constant);
1306
- if (versions[0]) {
1307
- return versions[0];
1308
- }
1309
- throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
1310
- }
1311
- const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
1312
- const contents = await readFile(file, 'utf8');
1313
- const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
1314
- const match = new RegExp(`^\\s*\\*?\\s*${label}:\\s*([^\\r\\n]+)`, 'im').exec(contents);
1315
- if (!match) {
1316
- throw new ValidationError(`Missing ${label} header in ${file}.`);
1317
- }
1318
- return match[1]?.trim() ?? '';
1319
- }
1320
- async function writeVersion(directory, descriptor, version, journal) {
1321
- if ('package-json' === descriptor.kind ||
1322
- 'composer-json' === descriptor.kind) {
1323
- const file = path.join(directory, descriptor.file);
1324
- const contents = await readFile(file, 'utf8');
1325
- const manifest = JSON.parse(contents);
1326
- if ('composer-json' === descriptor.kind &&
1327
- descriptor.ifPresent &&
1328
- !Object.hasOwn(manifest, descriptor.field)) {
1329
- return;
1330
- }
1331
- manifest[descriptor.field] = version;
1332
- await rememberFile(journal, file);
1333
- await writeFile(file, `${JSON.stringify(manifest, null, '\t')}\n`);
1334
- return;
1335
- }
1336
- if ('php-constant' === descriptor.kind) {
1337
- const file = path.join(directory, descriptor.file);
1338
- const contents = await readFile(file, 'utf8');
1339
- const constant = escapeRegExp(descriptor.constant);
1340
- const definePattern = new RegExp(`(\\bdefine\\s*\\(\\s*(['"])${constant}\\2\\s*,\\s*)(['"])(.*?)\\3`, 'g');
1341
- const declarationPattern = new RegExp(`(\\bconst\\s+${constant}\\s*=\\s*)(['"])(.*?)\\2`, 'g');
1342
- const updated = contents
1343
- .replace(definePattern, (_match, prefix, _nameQuote, quote) => `${prefix}${quote}${version}${quote}`)
1344
- .replace(declarationPattern, (_match, prefix, quote) => `${prefix}${quote}${version}${quote}`);
1345
- if (updated === contents) {
1346
- throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
1347
- }
1348
- await rememberFile(journal, file);
1349
- await writeFile(file, updated);
1350
- return;
1351
- }
1352
- if ('wordpress-stable-tag' === descriptor.kind &&
1353
- descriptor.ifPresent &&
1354
- !(await versionFileExists(directory, descriptor.kind, descriptor.file))) {
1355
- return;
1356
- }
1357
- const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
1358
- const contents = await readFile(file, 'utf8');
1359
- const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
1360
- const expression = new RegExp(`(^\\s*\\*?\\s*${label}:\\s*)[^\\r\\n]+`, 'im');
1361
- if (!expression.test(contents)) {
1362
- throw new ValidationError(`Missing ${label} header in ${file}.`);
1363
- }
1364
- await rememberFile(journal, file);
1365
- await writeFile(file, contents.replace(expression, `$1${version}`));
1366
- }
1367
- async function getVersionFile(directory, kind, configuredFile) {
1368
- if (configuredFile) {
1369
- return configuredFile;
1370
- }
1371
- if ('wordpress-plugin-header' === kind) {
1372
- const file = await findMainPluginFile({ dir: directory });
1373
- if (file) {
1374
- return file;
1375
- }
1376
- throw new ValidationError(`Could not find a plugin header file in ${directory}.`);
1377
- }
1378
- if ('wordpress-stable-tag' === kind) {
1379
- for (const file of ['readme.txt', 'readme.md']) {
1380
- try {
1381
- await access(path.join(directory, file));
1382
- return file;
1383
- }
1384
- catch {
1385
- // Try the next WordPress readme filename.
1386
- }
1387
- }
1388
- throw new ValidationError(`Could not find readme.txt or readme.md in ${directory}.`);
1389
- }
1390
- return 'style.css';
1391
- }
1392
- async function versionFileExists(directory, kind, configuredFile) {
1393
- if (configuredFile) {
1394
- try {
1395
- await access(path.join(directory, configuredFile));
1396
- return true;
1397
- }
1398
- catch {
1399
- return false;
1400
- }
1401
- }
1402
- if ('wordpress-stable-tag' === kind) {
1403
- const files = await Promise.all(['readme.txt', 'readme.md'].map(async (file) => {
1404
- try {
1405
- await access(path.join(directory, file));
1406
- return true;
1407
- }
1408
- catch {
1409
- return false;
1410
- }
1411
- }));
1412
- return files.some(Boolean);
1413
- }
1414
- return true;
1415
- }
1416
- async function prependChangelog(directory, release, date, readmeChangelog, changelogInsertAfter, journal) {
1417
- const summaries = release.changesets
1418
- .map((item) => item.summary.trim())
1419
- .filter(Boolean);
1420
- const section = `## ${release.nextVersion} (${date})\n\n${release.changelog}\n\n`;
1421
- const changelog = path.join(directory, 'CHANGELOG.md');
1422
- let previous = '';
1423
- try {
1424
- previous = await readFile(changelog, 'utf8');
1425
- }
1426
- catch (error) {
1427
- if ('ENOENT' !== error.code) {
1428
- throw error;
1429
- }
1430
- }
1431
- const releaseHeading = new RegExp(`^## ${escapeRegExp(release.nextVersion)} \\(`, 'm');
1432
- if (!releaseHeading.test(previous)) {
1433
- await rememberFile(journal, changelog);
1434
- if (undefined !== changelogInsertAfter) {
1435
- await writeFile(changelog, insertAfterMarker(previous, changelogInsertAfter, section, changelog));
1436
- }
1437
- else {
1438
- const versionHeading = /^##[ \t]+v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:[ \t]+.*)?$/m.exec(previous);
1439
- const heading = /^#[ \t]+[^\r\n]+[ \t]*$/m.exec(previous);
1440
- const insertion = versionHeading ?? heading;
1441
- if (insertion) {
1442
- const before = previous
1443
- .slice(0, insertion.index)
1444
- .replace(/(?:\r?\n)*$/, '');
1445
- const after = previous.slice(insertion.index);
1446
- const prefix = before ? `${before}\n\n` : '';
1447
- await writeFile(changelog, `${prefix}${section}${after}`);
1448
- }
1449
- else {
1450
- await writeFile(changelog, `${section}${previous}`);
1451
- }
1452
- }
1453
- }
1454
- if (readmeChangelog) {
1455
- const readme = ['readme.txt', 'readme.md'].map((name) => path.join(directory, name));
1456
- let readmeUpdated = false;
1457
- let readmeFound = false;
1458
- let readmeMarkerFound = false;
1459
- let readmeAlreadyVersioned = false;
1460
- for (const file of readme) {
1461
- try {
1462
- const contents = await readFile(file, 'utf8');
1463
- readmeFound = true;
1464
- const defaultHeading = undefined === changelogInsertAfter
1465
- ? /^==\s*Changelog\s*==\s*$/im.exec(contents)?.[0]
1466
- : undefined;
1467
- const insertionMarker = changelogInsertAfter ?? defaultHeading;
1468
- readmeMarkerFound ||=
1469
- undefined !== insertionMarker &&
1470
- contents.includes(insertionMarker);
1471
- const alreadyVersioned = new RegExp(`^=\\s*${escapeRegExp(release.nextVersion)}\\s*=$`, 'm').test(contents);
1472
- readmeAlreadyVersioned ||= alreadyVersioned;
1473
- if (insertionMarker &&
1474
- !alreadyVersioned &&
1475
- !readmeUpdated &&
1476
- contents.includes(insertionMarker)) {
1477
- const readmeSection = `\n\n= ${release.nextVersion} =\n\n${summaries
1478
- .map((item) => `* ${item.replaceAll(/\r?\n/g, '\n ')}`)
1479
- .join('\n')}\n\n`;
1480
- await rememberFile(journal, file);
1481
- await writeFile(file, insertAfterMarker(contents, insertionMarker, readmeSection.trimStart(), file));
1482
- readmeUpdated = true;
1483
- }
1484
- }
1485
- catch (error) {
1486
- if ('ENOENT' !== error.code) {
1487
- throw error;
1488
- }
1489
- }
1490
- }
1491
- if (changelogInsertAfter &&
1492
- readmeFound &&
1493
- !readmeMarkerFound &&
1494
- !readmeAlreadyVersioned) {
1495
- throw new ValidationError(`Changelog insertion marker "${changelogInsertAfter}" was not found in a plugin readme in ${directory}.`);
1496
- }
1497
- }
1498
- }
1499
- function insertAfterMarker(contents, marker, section, file) {
1500
- const index = contents.indexOf(marker);
1501
- if (-1 === index) {
1502
- throw new ValidationError(`Changelog insertion marker "${marker}" was not found in ${file}.`);
1503
- }
1504
- const markerEnd = index + marker.length;
1505
- const after = contents
1506
- .slice(markerEnd)
1507
- .replace(/^(?:[ \t]*\r?\n)+/, '');
1508
- const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n';
1509
- return `${contents.slice(0, markerEnd)}${lineEnding}${lineEnding}${section.replaceAll('\n', lineEnding)}${after}`;
1510
- }
1511
- async function changedPackagesSince(cwd, packages, since) {
1512
- const output = await git(cwd, [
1513
- 'diff',
1514
- '--name-only',
1515
- `${since}...HEAD`,
1516
- ]);
1517
- const changedFiles = output.trim().split(/\r?\n/).filter(Boolean);
1518
- const filesByPackage = {};
1519
- const changed = packages.filter((pkg) => {
1520
- const packageFromRepo = path
1521
- .relative(cwd, pkg.directory)
1522
- .split(path.sep)
1523
- .join('/');
1524
- const matchingFiles = '.' === packageFromRepo
1525
- ? changedFiles
1526
- : changedFiles.filter((file) => file === packageFromRepo ||
1527
- file.startsWith(`${packageFromRepo}/`));
1528
- if (matchingFiles.length) {
1529
- filesByPackage[pkg.name] = matchingFiles;
1530
- }
1531
- return matchingFiles.length > 0;
1532
- });
1533
- return { packages: changed, filesByPackage };
1534
- }
1535
- function formatTag(format, pkg, version) {
1536
- const type = pkg.group.tagType ?? defaultTagType(pkg.path, pkg.type);
1537
- return format
1538
- .replaceAll('{name}', pkg.name)
1539
- .replaceAll('{slug}', '.' === pkg.path
1540
- ? path.basename(pkg.directory)
1541
- : path.basename(pkg.path))
1542
- .replaceAll('{path}', pkg.path)
1543
- .replaceAll('{type}', type)
1544
- .replaceAll('{version}', version);
1545
- }
1546
- function defaultTagType(packagePath, type) {
1547
- if ('.' === packagePath && type) {
1548
- return type;
1549
- }
1550
- const first = packagePath.split('/')[0] ?? 'package';
1551
- const irregular = {
1552
- actions: 'action',
1553
- plugins: 'plugin',
1554
- themes: 'theme',
1555
- php: 'php',
1556
- js: 'js',
1557
- };
1558
- return irregular[first] ?? first.replace(/s$/, '');
1559
- }
1560
- function formatTemplatePrefix(pattern, packagePath, tagType) {
1561
- return pattern
1562
- .replaceAll('{path}', packagePath)
1563
- .replaceAll('{type}', tagType ?? defaultTagType(packagePath))
1564
- .replaceAll('{name}', packagePath.split('/').at(-1) ?? packagePath)
1565
- .replaceAll('{slug}', packagePath.split('/').at(-1) ?? packagePath)
1566
- .replaceAll('{version}', '');
1567
- }
1568
- async function verifyVersionTargets(cwd, pkg, expected) {
1569
- for (const target of pkg.versionConfig.to) {
1570
- let actual;
1571
- if ('composer-json' === target.kind &&
1572
- target.ifPresent &&
1573
- !Object.hasOwn(await readJson(path.join(pkg.directory, target.file)), target.field)) {
1574
- actual = undefined;
1575
- }
1576
- else if ('wordpress-stable-tag' === target.kind &&
1577
- target.ifPresent &&
1578
- !(await versionFileExists(pkg.directory, target.kind, target.file))) {
1579
- actual = undefined;
1580
- }
1581
- else {
1582
- actual = await readVersion(cwd, pkg.directory, target, pkg.path, pkg.group.tagType);
1583
- }
1584
- if (undefined !== actual && actual !== expected) {
1585
- throw new ValidationError(`Version mismatch for ${pkg.name}: expected ${expected}, found ${actual}.`);
1586
- }
1587
- }
1588
- }
1589
- async function gitTagExists(cwd, tag) {
1590
- const result = await git(cwd, ['tag', '--list', tag]);
1591
- return result.trim() === tag;
1592
- }
1593
- async function git(cwd, args) {
1594
- const directory = path.resolve(cwd);
1595
- try {
1596
- const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${directory}`, ...args], { cwd: directory });
1597
- return stdout;
1598
- }
1599
- catch (error) {
1600
- const message = error instanceof Error ? error.message : String(error);
1601
- throw new ValidationError(`Git command failed (${args.join(' ')}): ${message}`);
1602
- }
1603
- }
1604
- async function readReleaseNotes(directory, version) {
1605
- let contents = '';
1606
- try {
1607
- contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
1608
- }
1609
- catch (error) {
1610
- if ('ENOENT' !== error.code) {
1611
- throw error;
1612
- }
1613
- }
1614
- const lines = contents.split(/\r?\n/);
1615
- const headingIndex = lines.findIndex((line) => new RegExp(`^##\\s+${escapeRegExp(version)}(?:\\s|$)`).test(line));
1616
- if (-1 === headingIndex) {
1617
- return '';
1618
- }
1619
- const followingHeading = lines.findIndex((line, index) => index > headingIndex && /^##\s/.test(line));
1620
- const end = -1 === followingHeading ? lines.length : followingHeading;
1621
- return lines
1622
- .slice(headingIndex + 1, end)
1623
- .join('\n')
1624
- .trim();
1625
- }
1626
- async function getLatestChangelogVersion(directory, fallback) {
1627
- try {
1628
- const contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
1629
- const match = /^##\s+(\d+\.\d+\.\d+)(?:\s|$)/m.exec(contents);
1630
- return match?.[1] ?? fallback;
1631
- }
1632
- catch (error) {
1633
- if ('ENOENT' === error.code) {
1634
- return fallback;
1635
- }
1636
- throw error;
1637
- }
1638
- }
1639
- function escapeRegExp(value) {
1640
- return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
1641
- }
1642
- async function rememberFile(journal, file) {
1643
- if (journal.has(file)) {
1644
- return;
1645
- }
1646
- try {
1647
- journal.set(file, await readFile(file, 'utf8'));
1648
- }
1649
- catch (error) {
1650
- if ('ENOENT' !== error.code) {
1651
- throw error;
1652
- }
1653
- journal.set(file, undefined);
1654
- }
1655
- }
1656
- async function restoreFileJournal(journal) {
1657
- const errors = [];
1658
- for (const [file, contents] of [...journal.entries()].reverse()) {
1659
- try {
1660
- if (undefined === contents) {
1661
- await rm(file, { force: true });
1662
- }
1663
- else {
1664
- await writeFile(file, contents);
1665
- }
1666
- }
1667
- catch (error) {
1668
- errors.push(error instanceof Error ? error : new Error(String(error)));
1669
- }
1670
- }
1671
- return errors;
1672
- }
1673
- async function readJson(file) {
1674
- try {
1675
- return JSON.parse(await readFile(file, 'utf8'));
1676
- }
1677
- catch (error) {
1678
- const message = error instanceof Error ? error.message : String(error);
1679
- throw new ValidationError(`Could not read JSON manifest ${file}: ${message}`);
1680
- }
1681
- }
1682
- function packageType(group) {
1683
- const kind = group.version.from.kind;
1684
- if ('wordpress-plugin-header' === kind) {
1685
- return 'plugin';
1686
- }
1687
- if ('wordpress-theme-header' === kind) {
1688
- return 'theme';
1689
- }
1690
- if ('package-json' === kind) {
1691
- return 'npm';
1692
- }
1693
- return 'other';
1694
- }
1695
- const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/;
1696
- function isVersion(version) {
1697
- return VERSION_PATTERN.test(version);
1698
- }
1699
- function isPrerelease(version) {
1700
- return Boolean(VERSION_PATTERN.exec(version)?.[4]);
1701
- }
1702
- /**
1703
- * Compare two versions with SemVer precedence, where a pre-release like
1704
- * 1.0.0-rc.1 comes before 1.0.0.
1705
- *
1706
- * @param left Version.
1707
- * @param right Version to compare with.
1708
- * @return Negative when left is lower, positive when higher, otherwise 0.
1709
- */
1710
- function compareVersions(left, right) {
1711
- const [leftCore = '', leftPre] = splitPrerelease(left);
1712
- const [rightCore = '', rightPre] = splitPrerelease(right);
1713
- const a = leftCore.split('.').map(Number);
1714
- const b = rightCore.split('.').map(Number);
1715
- const core = (a[0] ?? 0) - (b[0] ?? 0) ||
1716
- (a[1] ?? 0) - (b[1] ?? 0) ||
1717
- (a[2] ?? 0) - (b[2] ?? 0);
1718
- if (core || leftPre === rightPre) {
1719
- return core;
1720
- }
1721
- if (undefined === leftPre || undefined === rightPre) {
1722
- return undefined === leftPre ? 1 : -1;
1723
- }
1724
- return comparePrerelease(leftPre.split('.'), rightPre.split('.'));
1725
- }
1726
- function splitPrerelease(version) {
1727
- const index = version.indexOf('-');
1728
- return -1 === index
1729
- ? [version]
1730
- : [version.slice(0, index), version.slice(index + 1)];
1731
- }
1732
- function comparePrerelease(left, right) {
1733
- const [a, ...leftRest] = left;
1734
- const [b, ...rightRest] = right;
1735
- if (undefined === a || undefined === b) {
1736
- return (undefined === a ? 0 : 1) - (undefined === b ? 0 : 1);
1737
- }
1738
- const order = compareIdentifier(a, b);
1739
- return order || comparePrerelease(leftRest, rightRest);
1740
- }
1741
- function compareIdentifier(a, b) {
1742
- const aNumeric = /^\d+$/.test(a);
1743
- const bNumeric = /^\d+$/.test(b);
1744
- if (aNumeric && bNumeric) {
1745
- return Number(a) - Number(b);
1746
- }
1747
- if (aNumeric || bNumeric) {
1748
- return aNumeric ? -1 : 1;
1749
- }
1750
- return Number(a > b) - Number(a < b);
424
+ return new ChangesetRelease(input).getStatus(input);
1751
425
  }