@wpelevator/wp-release 0.9.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.
Files changed (43) hide show
  1. package/README.md +101 -4
  2. package/dist/changelog.d.ts +45 -0
  3. package/dist/changelog.js +246 -0
  4. package/dist/changeset-config.d.ts +75 -0
  5. package/dist/changeset-config.js +48 -0
  6. package/dist/changeset-dependents.d.ts +132 -0
  7. package/dist/changeset-dependents.js +540 -0
  8. package/dist/changeset-files.d.ts +41 -0
  9. package/dist/changeset-files.js +100 -0
  10. package/dist/changeset-packages.d.ts +59 -0
  11. package/dist/changeset-packages.js +186 -0
  12. package/dist/changeset-release.d.ts +155 -18
  13. package/dist/changeset-release.js +366 -1090
  14. package/dist/changesets.d.ts +2 -0
  15. package/dist/commands/changeset-args.d.ts +5 -4
  16. package/dist/commands/changeset-args.js +2 -5
  17. package/dist/commands/changeset-status.d.ts +2 -0
  18. package/dist/commands/changeset-status.js +6 -2
  19. package/dist/commands/changeset-tag.d.ts +0 -3
  20. package/dist/commands/changeset-tag.js +8 -10
  21. package/dist/commands/changeset-version.d.ts +3 -0
  22. package/dist/commands/changeset-version.js +2 -1
  23. package/dist/file-journal.d.ts +22 -0
  24. package/dist/file-journal.js +56 -0
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +1 -1
  27. package/dist/json-files.d.ts +15 -0
  28. package/dist/json-files.js +35 -0
  29. package/dist/package-meta.js +1 -3
  30. package/dist/regexp.d.ts +7 -0
  31. package/dist/regexp.js +9 -0
  32. package/dist/release-repository.d.ts +102 -0
  33. package/dist/release-repository.js +159 -0
  34. package/dist/release-tags.d.ts +56 -0
  35. package/dist/release-tags.js +89 -0
  36. package/dist/tag-format.d.ts +20 -0
  37. package/dist/tag-format.js +51 -0
  38. package/dist/version-check.js +1 -3
  39. package/dist/version-files.d.ts +36 -0
  40. package/dist/version-files.js +195 -0
  41. package/dist/versions.d.ts +25 -0
  42. package/dist/versions.js +74 -0
  43. package/package.json +3 -1
@@ -1,501 +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
- const execFileAsync = promisify(execFile);
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>.';
12
16
  /**
13
- * Discover configured packages, pending changesets, and their proposed versions.
17
+ * Changeset releases of a repository: recording changesets, versioning the
18
+ * packages they name and tagging the releases.
14
19
  *
15
- * @param input Repository root and optional explicit config file.
16
- * @return A release plan; `undefined` when no config is present.
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.
17
23
  */
18
- export async function getChangesetReleasePlan(input) {
19
- const context = await getReleaseContext(input);
20
- return context?.plan;
21
- }
22
- /**
23
- * Discover the packages of the repository configuration.
24
- *
25
- * @param input Repository root and optional explicit config file.
26
- * @return Packages in the configured order with their current versions.
27
- * @throws {ValidationError} Without a config, or for an invalid one.
28
- */
29
- export async function getChangesetPackages(input) {
30
- const loaded = await requireConfig(input);
31
- const packages = await discoverPackages(input.cwd, loaded.config);
32
- return packages.map(toChangesetPackage);
33
- }
34
- async function getReleaseContext(input) {
35
- const loaded = await loadChangesetConfig(input);
36
- if (!loaded) {
37
- return undefined;
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
+ });
38
38
  }
39
- const config = loaded.config;
40
- const packages = await discoverPackages(input.cwd, config);
41
- const changesets = await readChangesets(input.cwd, config);
42
- const releases = [];
43
- packages.forEach((pkg) => {
44
- const bump = getChangesetBump(changesets, pkg.name);
45
- if (bump) {
46
- const packageChangesets = changesets.filter((changeset) => Object.hasOwn(changeset.packages, pkg.name));
47
- const nextVersion = config.nextVersion
48
- ? config.nextVersion({
49
- package: pkg,
50
- bump,
51
- currentVersion: pkg.currentVersion,
52
- changesets: packageChangesets,
53
- })
54
- : getNextVersion(pkg.currentVersion, bump);
55
- if ('string' !== typeof nextVersion || !nextVersion.trim()) {
56
- 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}.`);
139
+ }
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);
57
144
  }
58
- releases.push({
59
- package: pkg,
60
- bump,
61
- currentVersion: pkg.currentVersion,
62
- nextVersion,
63
- changesets: packageChangesets,
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);
149
+ }
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,
64
160
  });
65
161
  }
66
- });
67
- changesets.forEach((changeset) => {
68
- Object.keys(changeset.packages).forEach((name) => {
69
- if (!packages.some((pkg) => pkg.name === name)) {
70
- throw new ValidationError(`Changeset ${changeset.fileName} refers to unknown package "${name}".`);
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);
71
167
  }
72
- });
73
- });
74
- return { config, packages, plan: { changesets, releases } };
75
- }
76
- /**
77
- * Create a Changesets-compatible Markdown file.
78
- *
79
- * @param input Repository, packages and summary.
80
- * @return Created file path.
81
- */
82
- export async function addChangeset(input) {
83
- const loaded = await requireConfig(input);
84
- const packages = await discoverPackages(input.cwd, loaded.config);
85
- Object.keys(input.packages).forEach((name) => {
86
- if (!packages.some((pkg) => pkg.name === name)) {
87
- throw new ValidationError(`Unknown package "${name}".`);
88
168
  }
89
- });
90
- const directory = path.resolve(input.cwd, loaded.config.changesets ?? '.changeset');
91
- await mkdir(directory, { recursive: true });
92
- const fileName = `${input.name ?? randomUUID().slice(0, 8)}.md`;
93
- const file = path.join(directory, fileName);
94
- let exists = false;
95
- try {
96
- await access(file);
97
- exists = true;
98
- }
99
- catch (error) {
100
- if ('ENOENT' !== error.code) {
101
- throw error;
102
- }
103
- }
104
- if (exists) {
105
- throw new ValidationError(`Changeset ${fileName} already exists.`);
106
- }
107
- const entries = Object.entries(input.packages)
108
- .sort((a, b) => a[0].localeCompare(b[0]))
109
- .map(([name, bump]) => `${JSON.stringify(name)}: ${bump}`)
110
- .join('\n');
111
- await writeFile(file, `---\n${entries}\n---\n\n${input.summary.trim()}\n`);
112
- return file;
113
- }
114
- /**
115
- * Apply pending package versions and changelogs.
116
- *
117
- * @param input Repository and versioning options.
118
- * @return Planned releases and whether files were changed.
119
- */
120
- export async function versionChangesets(input) {
121
- const context = await getReleaseContext(input);
122
- if (!context) {
123
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
124
- }
125
- const { config, packages } = context;
126
- const releasePlan = input.packages
127
- ? selectReleasePlan(context.plan, packages, input.packages)
128
- : context.plan;
129
- const date = input.date ?? new Date().toISOString().slice(0, 10);
130
- const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
131
- const releases = await getVersionedReleases(input.cwd, config, releasePlan.releases, changesetDirectory);
132
- if (!input.dryRun) {
133
- 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);
134
228
  try {
135
229
  for (const release of releases) {
136
- const configured = packages.find((pkg) => pkg.name === release.package.name);
137
- if (!configured) {
138
- throw new ValidationError(`Unknown package "${release.package.name}".`);
139
- }
140
- for (const target of configured.versionConfig.to) {
141
- await writeVersion(configured.directory, target, release.nextVersion, journal);
142
- }
143
- await verifyVersionTargets(input.cwd, configured, release.nextVersion);
144
- await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
230
+ await this.writeRelease(context, plan, release, date, journal);
145
231
  }
146
- for (const changeset of releasePlan.changesets) {
147
- 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);
148
234
  }
149
235
  }
150
236
  catch (error) {
151
- const rollbackErrors = await restoreFileJournal(journal);
237
+ const rollbackErrors = await journal.restore();
152
238
  if (rollbackErrors.length) {
153
239
  throw new AggregateError([error, ...rollbackErrors], 'Changeset versioning failed and one or more file changes could not be rolled back.');
154
240
  }
155
241
  throw error;
156
242
  }
157
243
  }
158
- return {
159
- changesets: releasePlan.changesets,
160
- releases,
161
- date,
162
- changed: !input.dryRun && releasePlan.changesets.length > 0,
163
- };
164
- }
165
- /**
166
- * Format the changelog entries of each release, with the configured
167
- * formatter and the commit that added each changeset.
168
- *
169
- * @param cwd Repository root.
170
- * @param config Repository release configuration.
171
- * @param releases Package releases.
172
- * @param changesetDirectory Directory of the changeset files.
173
- * @return Releases with their changelog entries.
174
- */
175
- async function getVersionedReleases(cwd, config, releases, changesetDirectory) {
176
- const commits = new Map();
177
- const getCommit = (changeset) => {
178
- const cached = commits.get(changeset.fileName) ??
179
- getChangesetCommit(cwd, path.join(changesetDirectory, changeset.fileName));
180
- commits.set(changeset.fileName, cached);
181
- return cached;
182
- };
183
- return Promise.all(releases.map(async (release) => {
184
- const entries = [];
185
- // Format one at a time, so that formatters calling an API aren't rate limited.
186
- for (const changeset of release.changesets) {
187
- entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
188
- }
189
- return { ...release, changelog: formatChangelogList(entries) };
190
- }));
191
- }
192
- async function getChangelogEntry(config, pkg, changeset, getCommit) {
193
- const summary = changeset.summary.trim();
194
- if (!summary || !config.changelogEntry) {
195
- return summary;
196
- }
197
- const commit = await getCommit(changeset);
198
- const entry = await config.changelogEntry({
199
- package: pkg,
200
- changeset,
201
- ...(commit ? { commit } : {}),
202
- });
203
- if ('string' !== typeof entry) {
204
- throw new ValidationError(`The changelogEntry formatter didn't return a string for changeset ${changeset.fileName}.`);
205
- }
206
- return entry.trim();
207
- }
208
- /**
209
- * Find the commit that added a changeset file.
210
- *
211
- * @param cwd Repository root.
212
- * @param file Changeset file.
213
- * @return Commit, or undefined when the file isn't committed.
214
- */
215
- async function getChangesetCommit(cwd, file) {
216
- const output = await git(cwd, [
217
- 'log',
218
- '--diff-filter=A',
219
- '--format=%H%x1f%h%x1f%s',
220
- '-1',
221
- '--',
222
- path.relative(cwd, file).split(path.sep).join('/'),
223
- ]);
224
- const [hash, shortHash = '', subject = ''] = output
225
- .trim()
226
- .split('\x1f');
227
- return hash ? { hash, shortHash, subject } : undefined;
228
- }
229
- /**
230
- * Limit a release plan to some packages and the changesets that name them.
231
- *
232
- * @param plan Release plan of all pending changesets.
233
- * @param packages Configured packages.
234
- * @param names Packages to version.
235
- * @return Release plan of the named packages.
236
- * @throws {ValidationError} For unknown packages.
237
- */
238
- function selectReleasePlan(plan, packages, names) {
239
- names.forEach((name) => {
240
- if (!packages.some((pkg) => pkg.name === name)) {
241
- throw new ValidationError(`Unknown package "${name}".`);
242
- }
243
- });
244
- return {
245
- releases: plan.releases.filter((release) => names.includes(release.package.name)),
246
- changesets: plan.changesets.filter((changeset) => names.some((name) => Object.hasOwn(changeset.packages, name))),
247
- };
248
- }
249
- /**
250
- * Remove an applied changeset, or only the applied packages from a changeset
251
- * that names other packages too, so that their releases keep its summary.
252
- *
253
- * @param file Changeset file.
254
- * @param changeset Parsed changeset.
255
- * @param applied Versioned packages, or undefined when all were versioned.
256
- * @param journal File journal for rolling back.
257
- */
258
- async function consumeChangeset(file, changeset, applied, journal) {
259
- const remaining = Object.entries(changeset.packages).filter(([name]) => undefined !== applied && !applied.includes(name));
260
- await rememberFile(journal, file);
261
- if (remaining.length) {
262
- await writeFile(file, formatChangeset({
263
- packages: Object.fromEntries(remaining),
264
- summary: changeset.summary,
265
- }));
266
- }
267
- else {
268
- await rm(file);
269
- }
270
- }
271
- /**
272
- * Create missing release tags, optionally pushing them to the configured remote.
273
- *
274
- * @param input Repository and tag options.
275
- * @return Per-package tags and release notes.
276
- */
277
- export async function tagChangesets(input) {
278
- if (input.githubRelease && !input.push) {
279
- throw new ValidationError('GitHub Releases require pushing the tags.');
280
- }
281
- if (input.untagged && input.push) {
282
- throw new ValidationError('Listing untagged releases cannot be combined with pushing tags.');
283
- }
284
- const context = await getReleaseContext(input);
285
- if (!context) {
286
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
287
- }
288
- const selected = selectPackages(context.packages, input);
289
- const packages = input.since
290
- ? (await changedPackagesSince(input.cwd, selected, input.since))
291
- .packages
292
- : selected;
293
- const results = [];
294
- const dryRun = input.dryRun || input.untagged;
295
- const tagFormat = context.config.tagFormat ?? '{path}/{version}';
296
- for (const pkg of packages) {
297
- const version = await getTagVersion(pkg, input.version);
298
- if (undefined !== input.version && input.version !== version) {
299
- throw new ValidationError(`Package "${pkg.name}" is at version ${version}, not ${input.version}.`);
300
- }
301
- const tag = formatTag(tagFormat, pkg, version);
302
- const exists = await gitTagExists(input.cwd, tag);
303
- if (!exists) {
304
- await verifyVersionTargets(input.cwd, pkg, version);
305
- }
306
- const previousTag = await getPreviousTag(input.cwd, formatTag(tagFormat, pkg, ''), version);
307
- const notes = await getReleaseNotes(input.cwd, context.config, pkg, { version, tag, previousTag }, exists ? tag : 'HEAD');
308
- if (!exists && !dryRun) {
309
- await git(input.cwd, ['tag', tag]);
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}".`);
259
+ }
260
+ for (const target of configured.versionConfig.to) {
261
+ await writeVersion(configured.directory, target, release.nextVersion, journal);
262
+ }
263
+ for (const sourceName of release.sources ?? []) {
264
+ const source = plan.releases.find((item) => item.package.name === sourceName);
265
+ if (!source) {
266
+ throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
267
+ }
268
+ await updateDependencyDeclaration({
269
+ dependent: release.package,
270
+ source: source.package,
271
+ version: source.nextVersion,
272
+ rule: config.dependents,
273
+ journal,
274
+ });
310
275
  }
311
- results.push({
312
- package: pkg.name,
313
- version,
314
- tag,
315
- ...(previousTag ? { previousTag } : {}),
316
- prerelease: isPrerelease(version),
317
- exists,
318
- created: !exists && !dryRun,
319
- notes,
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 } : {}),
320
290
  });
321
- }
322
- // Push one tag at a time because GitHub doesn't create push events for
323
- // tags when more than three are pushed at once.
324
- if (input.push && !dryRun) {
325
- for (const item of results.filter((result) => result.created)) {
326
- await git(input.cwd, ['push', 'origin', item.tag]);
327
- }
328
- }
329
- if (input.githubRelease && !dryRun) {
330
- for (const release of results) {
331
- await createGitHubRelease(input.cwd, release);
332
- }
333
- }
334
- return {
335
- tags: input.untagged
336
- ? results.filter((result) => !result.exists)
337
- : results,
338
- };
339
- }
340
- async function createGitHubRelease(cwd, release) {
341
- try {
342
- await execFileAsync('gh', ['release', 'view', release.tag], {
343
- cwd,
291
+ if (!loaded) {
292
+ return undefined;
293
+ }
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}".`);
312
+ }
313
+ releases.push({
314
+ package: pkg,
315
+ bump,
316
+ currentVersion: pkg.currentVersion,
317
+ nextVersion,
318
+ changesets: packageChangesets,
319
+ });
320
+ }
344
321
  });
345
- }
346
- catch {
347
- try {
348
- await execFileAsync('gh', [
349
- 'release',
350
- 'create',
351
- release.tag,
352
- '--title',
353
- `${release.package}@${release.version}`,
354
- '--notes',
355
- release.notes,
356
- ...(release.prerelease ? ['--prerelease'] : []),
357
- ], { cwd });
358
- }
359
- catch (error) {
360
- const message = error instanceof Error ? error.message : String(error);
361
- throw new ValidationError(`Could not create GitHub Release for ${release.tag}: ${message}`);
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
+ });
328
+ });
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);
362
358
  }
359
+ return loaded.config;
363
360
  }
364
- }
365
- /**
366
- * Get the version to tag. Packages versioned by their git tags have no
367
- * version file, so the expected version is the version to tag, or else the
368
- * latest CHANGELOG.md version.
369
- *
370
- * @param pkg Configured package.
371
- * @param expected Expected version from --version, if any.
372
- * @return Version to tag.
373
- */
374
- async function getTagVersion(pkg, expected) {
375
- if ('git-tag' !== pkg.versionConfig.from.kind) {
376
- 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');
377
369
  }
378
- return (expected ??
379
- (await getLatestChangelogVersion(pkg.directory, pkg.currentVersion)));
380
370
  }
381
371
  /**
382
- * Pick the packages passed with --package, or all packages without it.
372
+ * Discover configured packages, pending changesets, and their proposed versions.
383
373
  *
384
- * @param packages Configured packages.
385
- * @param input Tag options.
386
- * @return Selected packages in the configured order.
387
- * @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.
388
376
  */
389
- function selectPackages(packages, input) {
390
- const names = input.packages ?? [];
391
- if (undefined !== input.version && 1 !== names.length) {
392
- throw new ValidationError('An expected version requires exactly one package.');
393
- }
394
- names.forEach((name) => {
395
- if (!packages.some((pkg) => pkg.name === name)) {
396
- throw new ValidationError(`Unknown package "${name}".`);
397
- }
398
- });
399
- return names.length
400
- ? packages.filter((pkg) => names.includes(pkg.name))
401
- : packages;
377
+ export async function getChangesetReleasePlan(input) {
378
+ return new ChangesetRelease(input).getPlan();
402
379
  }
403
380
  /**
404
- * Find the tag of the newest release before a version.
381
+ * Discover the packages of the repository configuration.
405
382
  *
406
- * @param cwd Repository root.
407
- * @param prefix Tag prefix of the package, like `plugins/alpha/`.
408
- * @param version Version being released.
409
- * @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.
410
386
  */
411
- async function getPreviousTag(cwd, prefix, version) {
412
- const output = await git(cwd, ['tag', '--list', `${prefix}*`]);
413
- return output
414
- .trim()
415
- .split(/\r?\n/)
416
- .filter((tag) => tag.startsWith(prefix))
417
- .map((tag) => ({ tag, version: tag.slice(prefix.length) }))
418
- .filter((item) => isVersion(item.version) &&
419
- compareVersions(item.version, version) < 0)
420
- .sort((a, b) => compareVersions(b.version, a.version))[0]?.tag;
387
+ export async function getChangesetPackages(input) {
388
+ return new ChangesetRelease(input).getPackages();
421
389
  }
422
390
  /**
423
- * Build the release notes of a tag with the configured formatter.
391
+ * Create a Changesets-compatible Markdown file.
424
392
  *
425
- * @param cwd Repository root.
426
- * @param config Repository release configuration.
427
- * @param pkg Released package.
428
- * @param release Version, tag and previous tag of the release.
429
- * @param release.version Released version.
430
- * @param release.tag Release tag.
431
- * @param release.previousTag Tag of the previous release, if any.
432
- * @param end Commit of the release: the tag, or HEAD before tagging.
433
- * @return Release notes.
393
+ * @param input Repository, packages and summary.
394
+ * @return Created file path.
434
395
  */
435
- async function getReleaseNotes(cwd, config, pkg, release, end) {
436
- const changelog = await readReleaseNotes(pkg.directory, release.version);
437
- const commits = await getReleaseCommits(cwd, pkg, end, release.previousTag);
438
- const notes = changelog ||
439
- commits
440
- .map((commit) => `- ${commit.subject} (${commit.shortHash})`)
441
- .join('\n');
442
- if (!config.releaseNotes) {
443
- return notes;
444
- }
445
- const formatted = await config.releaseNotes({
446
- package: toChangesetPackage(pkg),
447
- ...release,
448
- changelog,
449
- commits,
450
- notes,
451
- });
452
- if ('string' !== typeof formatted) {
453
- throw new ValidationError(`The releaseNotes formatter didn't return a string for package "${pkg.name}".`);
454
- }
455
- return formatted.trim();
396
+ export async function addChangeset(input) {
397
+ return new ChangesetRelease(input).add(input);
456
398
  }
457
399
  /**
458
- * List the commits that changed a package since its previous release.
400
+ * Apply pending package versions and changelogs.
459
401
  *
460
- * @param cwd Repository root.
461
- * @param pkg Released package.
462
- * @param end Commit of the release.
463
- * @param previousTag Tag of the previous release, if any.
464
- * @return Commits, newest first.
402
+ * @param input Repository and versioning options.
403
+ * @return Planned releases and whether files were changed.
465
404
  */
466
- async function getReleaseCommits(cwd, pkg, end, previousTag) {
467
- const packageFromRepo = path.relative(cwd, pkg.directory).split(path.sep).join('/') ||
468
- '.';
469
- const output = await git(cwd, [
470
- 'log',
471
- '--format=%H%x1f%h%x1f%s',
472
- previousTag ? `${previousTag}..${end}` : end,
473
- '--',
474
- packageFromRepo,
475
- ]);
476
- return output
477
- .trim()
478
- .split(/\r?\n/)
479
- .filter(Boolean)
480
- .map((line) => {
481
- const [hash = '', shortHash = '', subject = ''] = line.split('\x1f');
482
- return { hash, shortHash, subject };
483
- });
405
+ export async function versionChangesets(input) {
406
+ return new ChangesetRelease(input).version(input);
484
407
  }
485
408
  /**
486
- * Remove the release configuration from a package passed to config callbacks.
409
+ * Create missing release tags, optionally pushing them to the configured remote.
487
410
  *
488
- * @param pkg Configured package.
489
- * @return Package without its group configuration.
411
+ * @param input Repository and tag options.
412
+ * @return Per-package tags and release notes.
490
413
  */
491
- function toChangesetPackage(pkg) {
492
- return {
493
- name: pkg.name,
494
- directory: pkg.directory,
495
- path: pkg.path,
496
- type: pkg.type,
497
- currentVersion: pkg.currentVersion,
498
- };
414
+ export async function tagChangesets(input) {
415
+ return new ChangesetRelease(input).tag(input);
499
416
  }
500
417
  /**
501
418
  * Check whether changed files belong to packages without a changeset.
@@ -504,646 +421,5 @@ function toChangesetPackage(pkg) {
504
421
  * @return Changed packages and missing changeset identifiers.
505
422
  */
506
423
  export async function getChangesetStatus(input) {
507
- const context = await getReleaseContext(input);
508
- if (!context) {
509
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
510
- }
511
- const { plan } = context;
512
- const { changesets, releases } = plan;
513
- const since = input.since ??
514
- (context.config.baseBranch
515
- ? `origin/${context.config.baseBranch}`
516
- : undefined);
517
- const changed = since
518
- ? await changedPackagesSince(input.cwd, context.packages, since)
519
- : { packages: [], filesByPackage: {} };
520
- const changedPackages = changed.packages.map((pkg) => pkg.name);
521
- const changesetsInDiff = since
522
- ? await readChangesetsSince(input.cwd, context.config, changesets, since)
523
- : [];
524
- const hasEmptyChangeset = changesetsInDiff.some((changeset) => 0 === Object.keys(changeset.packages).length);
525
- const missingChangesets = hasEmptyChangeset
526
- ? []
527
- : changedPackages.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)));
528
- return {
529
- changesets,
530
- releases,
531
- changedPackages,
532
- changedPackageFiles: changed.filesByPackage,
533
- missingChangesets,
534
- };
535
- }
536
- async function requireConfig(input) {
537
- const loaded = await loadChangesetConfig(input);
538
- if (!loaded) {
539
- throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
540
- }
541
- return loaded;
542
- }
543
- async function readChangesets(cwd, config) {
544
- const directory = path.resolve(cwd, config.changesets ?? '.changeset');
545
- let entries;
546
- try {
547
- entries = await readdir(directory);
548
- }
549
- catch (error) {
550
- if ('ENOENT' === error.code) {
551
- return [];
552
- }
553
- throw error;
554
- }
555
- return Promise.all(entries
556
- .filter((name) => name.endsWith('.md') && !name.startsWith('README'))
557
- .sort()
558
- .map(async (name) => parseChangeset(await readFile(path.join(directory, name), 'utf8'), name)));
559
- }
560
- async function readChangesetsSince(cwd, config, changesets, since) {
561
- const directory = path.resolve(cwd, config.changesets ?? '.changeset');
562
- const relativeDirectory = path
563
- .relative(cwd, directory)
564
- .split(path.sep)
565
- .join('/');
566
- const output = await git(cwd, [
567
- 'diff',
568
- '--name-only',
569
- '--diff-filter=ACMR',
570
- `${since}...HEAD`,
571
- '--',
572
- relativeDirectory,
573
- ]);
574
- const changedFiles = new Set(output.trim().split(/\r?\n/).filter(Boolean));
575
- return changesets.filter((changeset) => changedFiles.has(path.posix.join(relativeDirectory, changeset.fileName)));
576
- }
577
- async function discoverPackages(cwd, config) {
578
- const root = path.resolve(cwd, config.root ?? '.');
579
- const packages = [];
580
- const hasRecursiveGlob = config.packages.some((item) => item.glob.split('/').includes('**'));
581
- const directories = await findDirectories(root, '', hasRecursiveGlob
582
- ? Infinity
583
- : Math.max(1, ...config.packages.map((item) => item.glob.split('/').length + 2)));
584
- for (const group of config.packages) {
585
- const matcher = globRegExp(group.glob);
586
- const candidates = matcher.test('.')
587
- ? ['.', ...directories]
588
- : directories;
589
- const matches = candidates
590
- .filter((item) => matcher.test(item) &&
591
- !(group.exclude ?? []).some((excluded) => globRegExp(excluded).test(item)))
592
- .sort();
593
- if (!matches.length) {
594
- throw new ValidationError(`Package glob "${group.glob}" did not match any directories under ${root}.`);
595
- }
596
- for (const relativePath of matches) {
597
- const directory = path.join(root, relativePath);
598
- const name = await packageName(directory, relativePath, group.idFrom ?? 'path');
599
- const currentVersion = await readVersion(cwd, directory, group.version.from, relativePath, group.tagType);
600
- if (packages.some((pkg) => pkg.name === name)) {
601
- throw new ValidationError(`Package identifier "${name}" is configured more than once.`);
602
- }
603
- packages.push({
604
- name,
605
- directory,
606
- path: relativePath,
607
- type: packageType(group),
608
- currentVersion,
609
- group,
610
- versionConfig: group.version,
611
- });
612
- }
613
- }
614
- return packages;
615
- }
616
- async function findDirectories(root, prefix = '', maxDepth = 5) {
617
- let entries;
618
- try {
619
- entries = await readdir(path.join(root, prefix), {
620
- withFileTypes: true,
621
- });
622
- }
623
- catch (error) {
624
- if ('ENOENT' === error.code) {
625
- return [];
626
- }
627
- throw error;
628
- }
629
- const directories = entries.filter((item) => item.isDirectory() &&
630
- !['.git', 'node_modules', 'vendor', 'dist'].includes(item.name));
631
- if (prefix.split(path.sep).filter(Boolean).length >= maxDepth) {
632
- return directories.map((item) => path.join(prefix, item.name));
633
- }
634
- const children = await Promise.all(directories.map((item) => findDirectories(root, path.join(prefix, item.name), maxDepth)));
635
- return [
636
- ...directories.map((item) => path.join(prefix, item.name)),
637
- ...children.flat(),
638
- ];
639
- }
640
- function globRegExp(glob) {
641
- const escaped = glob
642
- .split('/')
643
- .map((part) => {
644
- if ('**' === part) {
645
- return '.*';
646
- }
647
- return part
648
- .replaceAll(/[.+^${}()|[\]\\]/g, '\\$&')
649
- .replaceAll('*', '[^/]*')
650
- .replaceAll('?', '[^/]');
651
- })
652
- .join('/');
653
- return new RegExp(`^${escaped}$`);
654
- }
655
- async function packageName(directory, relativePath, idFrom) {
656
- if ('path' === idFrom) {
657
- if ('.' === relativePath) {
658
- return path.basename(directory);
659
- }
660
- return relativePath.split(path.sep).join('/');
661
- }
662
- const [file, field] = idFrom.split('#');
663
- if (!file || !field) {
664
- throw new ValidationError(`Invalid package identifier source "${idFrom}".`);
665
- }
666
- const manifest = await readJson(path.join(directory, file));
667
- const name = manifest[field];
668
- if ('string' !== typeof name || !name.trim()) {
669
- throw new ValidationError(`Expected ${file}#${field} in ${directory}.`);
670
- }
671
- return name;
672
- }
673
- async function readVersion(cwd, directory, descriptor, relativePath, tagType) {
674
- if ('package-json' === descriptor.kind ||
675
- 'composer-json' === descriptor.kind) {
676
- const manifest = await readJson(path.join(directory, descriptor.file));
677
- const version = manifest[descriptor.field];
678
- if ('string' === typeof version && version.trim()) {
679
- return version;
680
- }
681
- throw new ValidationError(`Missing ${descriptor.file}#${descriptor.field} in ${directory}.`);
682
- }
683
- if ('git-tag' === descriptor.kind) {
684
- const pattern = descriptor.pattern ?? '{path}/{version}';
685
- const tagsOutput = await git(cwd, [
686
- 'tag',
687
- '--list',
688
- `${formatTemplatePrefix(pattern, relativePath, tagType)}*`,
689
- ]);
690
- const tags = tagsOutput.trim().split(/\r?\n/).filter(Boolean);
691
- const versions = tags
692
- .map((tag) => tag.match(/(?:^|\/|v)(\d+\.\d+\.\d+)$/)?.[1])
693
- .filter((item) => Boolean(item));
694
- versions.sort((a, b) => compareVersions(b, a));
695
- if (versions[0]) {
696
- return versions[0];
697
- }
698
- throw new ValidationError(`No git tag matches ${pattern} for ${relativePath}.`);
699
- }
700
- if ('php-constant' === descriptor.kind) {
701
- const file = path.join(directory, descriptor.file);
702
- const contents = await readFile(file, 'utf8');
703
- const versions = findConstantValues(contents, descriptor.constant);
704
- if (versions[0]) {
705
- return versions[0];
706
- }
707
- throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
708
- }
709
- const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
710
- const contents = await readFile(file, 'utf8');
711
- const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
712
- const match = new RegExp(`^\\s*\\*?\\s*${label}:\\s*([^\\r\\n]+)`, 'im').exec(contents);
713
- if (!match) {
714
- throw new ValidationError(`Missing ${label} header in ${file}.`);
715
- }
716
- return match[1]?.trim() ?? '';
717
- }
718
- async function writeVersion(directory, descriptor, version, journal) {
719
- if ('package-json' === descriptor.kind ||
720
- 'composer-json' === descriptor.kind) {
721
- const file = path.join(directory, descriptor.file);
722
- const contents = await readFile(file, 'utf8');
723
- const manifest = JSON.parse(contents);
724
- if ('composer-json' === descriptor.kind &&
725
- descriptor.ifPresent &&
726
- !Object.hasOwn(manifest, descriptor.field)) {
727
- return;
728
- }
729
- manifest[descriptor.field] = version;
730
- await rememberFile(journal, file);
731
- await writeFile(file, `${JSON.stringify(manifest, null, '\t')}\n`);
732
- return;
733
- }
734
- if ('php-constant' === descriptor.kind) {
735
- const file = path.join(directory, descriptor.file);
736
- const contents = await readFile(file, 'utf8');
737
- const constant = escapeRegExp(descriptor.constant);
738
- const definePattern = new RegExp(`(\\bdefine\\s*\\(\\s*(['"])${constant}\\2\\s*,\\s*)(['"])(.*?)\\3`, 'g');
739
- const declarationPattern = new RegExp(`(\\bconst\\s+${constant}\\s*=\\s*)(['"])(.*?)\\2`, 'g');
740
- const updated = contents
741
- .replace(definePattern, (_match, prefix, _nameQuote, quote) => `${prefix}${quote}${version}${quote}`)
742
- .replace(declarationPattern, (_match, prefix, quote) => `${prefix}${quote}${version}${quote}`);
743
- if (updated === contents) {
744
- throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
745
- }
746
- await rememberFile(journal, file);
747
- await writeFile(file, updated);
748
- return;
749
- }
750
- if ('wordpress-stable-tag' === descriptor.kind &&
751
- descriptor.ifPresent &&
752
- !(await versionFileExists(directory, descriptor.kind, descriptor.file))) {
753
- return;
754
- }
755
- const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
756
- const contents = await readFile(file, 'utf8');
757
- const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
758
- const expression = new RegExp(`(^\\s*\\*?\\s*${label}:\\s*)[^\\r\\n]+`, 'im');
759
- if (!expression.test(contents)) {
760
- throw new ValidationError(`Missing ${label} header in ${file}.`);
761
- }
762
- await rememberFile(journal, file);
763
- await writeFile(file, contents.replace(expression, `$1${version}`));
764
- }
765
- async function getVersionFile(directory, kind, configuredFile) {
766
- if (configuredFile) {
767
- return configuredFile;
768
- }
769
- if ('wordpress-plugin-header' === kind) {
770
- const file = await findMainPluginFile({ dir: directory });
771
- if (file) {
772
- return file;
773
- }
774
- throw new ValidationError(`Could not find a plugin header file in ${directory}.`);
775
- }
776
- if ('wordpress-stable-tag' === kind) {
777
- for (const file of ['readme.txt', 'readme.md']) {
778
- try {
779
- await access(path.join(directory, file));
780
- return file;
781
- }
782
- catch {
783
- // Try the next WordPress readme filename.
784
- }
785
- }
786
- throw new ValidationError(`Could not find readme.txt or readme.md in ${directory}.`);
787
- }
788
- return 'style.css';
789
- }
790
- async function versionFileExists(directory, kind, configuredFile) {
791
- if (configuredFile) {
792
- try {
793
- await access(path.join(directory, configuredFile));
794
- return true;
795
- }
796
- catch {
797
- return false;
798
- }
799
- }
800
- if ('wordpress-stable-tag' === kind) {
801
- const files = await Promise.all(['readme.txt', 'readme.md'].map(async (file) => {
802
- try {
803
- await access(path.join(directory, file));
804
- return true;
805
- }
806
- catch {
807
- return false;
808
- }
809
- }));
810
- return files.some(Boolean);
811
- }
812
- return true;
813
- }
814
- async function prependChangelog(directory, release, date, readmeChangelog, changelogInsertAfter, journal) {
815
- const summaries = release.changesets
816
- .map((item) => item.summary.trim())
817
- .filter(Boolean);
818
- const section = `## ${release.nextVersion} (${date})\n\n${release.changelog}\n\n`;
819
- const changelog = path.join(directory, 'CHANGELOG.md');
820
- let previous = '';
821
- try {
822
- previous = await readFile(changelog, 'utf8');
823
- }
824
- catch (error) {
825
- if ('ENOENT' !== error.code) {
826
- throw error;
827
- }
828
- }
829
- const releaseHeading = new RegExp(`^## ${escapeRegExp(release.nextVersion)} \\(`, 'm');
830
- if (!releaseHeading.test(previous)) {
831
- await rememberFile(journal, changelog);
832
- if (undefined !== changelogInsertAfter) {
833
- await writeFile(changelog, insertAfterMarker(previous, changelogInsertAfter, section, changelog));
834
- }
835
- else {
836
- const versionHeading = /^##[ \t]+v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:[ \t]+.*)?$/m.exec(previous);
837
- const heading = /^#[ \t]+[^\r\n]+[ \t]*$/m.exec(previous);
838
- const insertion = versionHeading ?? heading;
839
- if (insertion) {
840
- const before = previous
841
- .slice(0, insertion.index)
842
- .replace(/(?:\r?\n)*$/, '');
843
- const after = previous.slice(insertion.index);
844
- const prefix = before ? `${before}\n\n` : '';
845
- await writeFile(changelog, `${prefix}${section}${after}`);
846
- }
847
- else {
848
- await writeFile(changelog, `${section}${previous}`);
849
- }
850
- }
851
- }
852
- if (readmeChangelog) {
853
- const readme = ['readme.txt', 'readme.md'].map((name) => path.join(directory, name));
854
- let readmeUpdated = false;
855
- let readmeFound = false;
856
- let readmeMarkerFound = false;
857
- let readmeAlreadyVersioned = false;
858
- for (const file of readme) {
859
- try {
860
- const contents = await readFile(file, 'utf8');
861
- readmeFound = true;
862
- const defaultHeading = undefined === changelogInsertAfter
863
- ? /^==\s*Changelog\s*==\s*$/im.exec(contents)?.[0]
864
- : undefined;
865
- const insertionMarker = changelogInsertAfter ?? defaultHeading;
866
- readmeMarkerFound ||=
867
- undefined !== insertionMarker &&
868
- contents.includes(insertionMarker);
869
- const alreadyVersioned = new RegExp(`^=\\s*${escapeRegExp(release.nextVersion)}\\s*=$`, 'm').test(contents);
870
- readmeAlreadyVersioned ||= alreadyVersioned;
871
- if (insertionMarker &&
872
- !alreadyVersioned &&
873
- !readmeUpdated &&
874
- contents.includes(insertionMarker)) {
875
- const readmeSection = `\n\n= ${release.nextVersion} =\n\n${summaries
876
- .map((item) => `* ${item.replaceAll(/\r?\n/g, '\n ')}`)
877
- .join('\n')}\n\n`;
878
- await rememberFile(journal, file);
879
- await writeFile(file, insertAfterMarker(contents, insertionMarker, readmeSection.trimStart(), file));
880
- readmeUpdated = true;
881
- }
882
- }
883
- catch (error) {
884
- if ('ENOENT' !== error.code) {
885
- throw error;
886
- }
887
- }
888
- }
889
- if (changelogInsertAfter &&
890
- readmeFound &&
891
- !readmeMarkerFound &&
892
- !readmeAlreadyVersioned) {
893
- throw new ValidationError(`Changelog insertion marker "${changelogInsertAfter}" was not found in a plugin readme in ${directory}.`);
894
- }
895
- }
896
- }
897
- function insertAfterMarker(contents, marker, section, file) {
898
- const index = contents.indexOf(marker);
899
- if (-1 === index) {
900
- throw new ValidationError(`Changelog insertion marker "${marker}" was not found in ${file}.`);
901
- }
902
- const markerEnd = index + marker.length;
903
- const after = contents
904
- .slice(markerEnd)
905
- .replace(/^(?:[ \t]*\r?\n)+/, '');
906
- const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n';
907
- return `${contents.slice(0, markerEnd)}${lineEnding}${lineEnding}${section.replaceAll('\n', lineEnding)}${after}`;
908
- }
909
- async function changedPackagesSince(cwd, packages, since) {
910
- const output = await git(cwd, [
911
- 'diff',
912
- '--name-only',
913
- `${since}...HEAD`,
914
- ]);
915
- const changedFiles = output.trim().split(/\r?\n/).filter(Boolean);
916
- const filesByPackage = {};
917
- const changed = packages.filter((pkg) => {
918
- const packageFromRepo = path
919
- .relative(cwd, pkg.directory)
920
- .split(path.sep)
921
- .join('/');
922
- const matchingFiles = '.' === packageFromRepo
923
- ? changedFiles
924
- : changedFiles.filter((file) => file === packageFromRepo ||
925
- file.startsWith(`${packageFromRepo}/`));
926
- if (matchingFiles.length) {
927
- filesByPackage[pkg.name] = matchingFiles;
928
- }
929
- return matchingFiles.length > 0;
930
- });
931
- return { packages: changed, filesByPackage };
932
- }
933
- function formatTag(format, pkg, version) {
934
- const type = pkg.group.tagType ?? defaultTagType(pkg.path, pkg.type);
935
- return format
936
- .replaceAll('{name}', pkg.name)
937
- .replaceAll('{slug}', '.' === pkg.path
938
- ? path.basename(pkg.directory)
939
- : path.basename(pkg.path))
940
- .replaceAll('{path}', pkg.path)
941
- .replaceAll('{type}', type)
942
- .replaceAll('{version}', version);
943
- }
944
- function defaultTagType(packagePath, type) {
945
- if ('.' === packagePath && type) {
946
- return type;
947
- }
948
- const first = packagePath.split('/')[0] ?? 'package';
949
- const irregular = {
950
- actions: 'action',
951
- plugins: 'plugin',
952
- themes: 'theme',
953
- php: 'php',
954
- js: 'js',
955
- };
956
- return irregular[first] ?? first.replace(/s$/, '');
957
- }
958
- function formatTemplatePrefix(pattern, packagePath, tagType) {
959
- return pattern
960
- .replaceAll('{path}', packagePath)
961
- .replaceAll('{type}', tagType ?? defaultTagType(packagePath))
962
- .replaceAll('{name}', packagePath.split('/').at(-1) ?? packagePath)
963
- .replaceAll('{slug}', packagePath.split('/').at(-1) ?? packagePath)
964
- .replaceAll('{version}', '');
965
- }
966
- async function verifyVersionTargets(cwd, pkg, expected) {
967
- for (const target of pkg.versionConfig.to) {
968
- let actual;
969
- if ('composer-json' === target.kind &&
970
- target.ifPresent &&
971
- !Object.hasOwn(await readJson(path.join(pkg.directory, target.file)), target.field)) {
972
- actual = undefined;
973
- }
974
- else if ('wordpress-stable-tag' === target.kind &&
975
- target.ifPresent &&
976
- !(await versionFileExists(pkg.directory, target.kind, target.file))) {
977
- actual = undefined;
978
- }
979
- else {
980
- actual = await readVersion(cwd, pkg.directory, target, pkg.path, pkg.group.tagType);
981
- }
982
- if (undefined !== actual && actual !== expected) {
983
- throw new ValidationError(`Version mismatch for ${pkg.name}: expected ${expected}, found ${actual}.`);
984
- }
985
- }
986
- }
987
- async function gitTagExists(cwd, tag) {
988
- const result = await git(cwd, ['tag', '--list', tag]);
989
- return result.trim() === tag;
990
- }
991
- async function git(cwd, args) {
992
- const directory = path.resolve(cwd);
993
- try {
994
- const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${directory}`, ...args], { cwd: directory });
995
- return stdout;
996
- }
997
- catch (error) {
998
- const message = error instanceof Error ? error.message : String(error);
999
- throw new ValidationError(`Git command failed (${args.join(' ')}): ${message}`);
1000
- }
1001
- }
1002
- async function readReleaseNotes(directory, version) {
1003
- let contents = '';
1004
- try {
1005
- contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
1006
- }
1007
- catch (error) {
1008
- if ('ENOENT' !== error.code) {
1009
- throw error;
1010
- }
1011
- }
1012
- const lines = contents.split(/\r?\n/);
1013
- const headingIndex = lines.findIndex((line) => new RegExp(`^##\\s+${escapeRegExp(version)}(?:\\s|$)`).test(line));
1014
- if (-1 === headingIndex) {
1015
- return '';
1016
- }
1017
- const followingHeading = lines.findIndex((line, index) => index > headingIndex && /^##\s/.test(line));
1018
- const end = -1 === followingHeading ? lines.length : followingHeading;
1019
- return lines
1020
- .slice(headingIndex + 1, end)
1021
- .join('\n')
1022
- .trim();
1023
- }
1024
- async function getLatestChangelogVersion(directory, fallback) {
1025
- try {
1026
- const contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
1027
- const match = /^##\s+(\d+\.\d+\.\d+)(?:\s|$)/m.exec(contents);
1028
- return match?.[1] ?? fallback;
1029
- }
1030
- catch (error) {
1031
- if ('ENOENT' === error.code) {
1032
- return fallback;
1033
- }
1034
- throw error;
1035
- }
1036
- }
1037
- function escapeRegExp(value) {
1038
- return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
1039
- }
1040
- async function rememberFile(journal, file) {
1041
- if (journal.has(file)) {
1042
- return;
1043
- }
1044
- try {
1045
- journal.set(file, await readFile(file, 'utf8'));
1046
- }
1047
- catch (error) {
1048
- if ('ENOENT' !== error.code) {
1049
- throw error;
1050
- }
1051
- journal.set(file, undefined);
1052
- }
1053
- }
1054
- async function restoreFileJournal(journal) {
1055
- const errors = [];
1056
- for (const [file, contents] of [...journal.entries()].reverse()) {
1057
- try {
1058
- if (undefined === contents) {
1059
- await rm(file, { force: true });
1060
- }
1061
- else {
1062
- await writeFile(file, contents);
1063
- }
1064
- }
1065
- catch (error) {
1066
- errors.push(error instanceof Error ? error : new Error(String(error)));
1067
- }
1068
- }
1069
- return errors;
1070
- }
1071
- async function readJson(file) {
1072
- try {
1073
- return JSON.parse(await readFile(file, 'utf8'));
1074
- }
1075
- catch (error) {
1076
- const message = error instanceof Error ? error.message : String(error);
1077
- throw new ValidationError(`Could not read JSON manifest ${file}: ${message}`);
1078
- }
1079
- }
1080
- function packageType(group) {
1081
- const kind = group.version.from.kind;
1082
- if ('wordpress-plugin-header' === kind) {
1083
- return 'plugin';
1084
- }
1085
- if ('wordpress-theme-header' === kind) {
1086
- return 'theme';
1087
- }
1088
- if ('package-json' === kind) {
1089
- return 'npm';
1090
- }
1091
- return 'other';
1092
- }
1093
- const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/;
1094
- function isVersion(version) {
1095
- return VERSION_PATTERN.test(version);
1096
- }
1097
- function isPrerelease(version) {
1098
- return Boolean(VERSION_PATTERN.exec(version)?.[4]);
1099
- }
1100
- /**
1101
- * Compare two versions with SemVer precedence, where a pre-release like
1102
- * 1.0.0-rc.1 comes before 1.0.0.
1103
- *
1104
- * @param left Version.
1105
- * @param right Version to compare with.
1106
- * @return Negative when left is lower, positive when higher, otherwise 0.
1107
- */
1108
- function compareVersions(left, right) {
1109
- const [leftCore = '', leftPre] = splitPrerelease(left);
1110
- const [rightCore = '', rightPre] = splitPrerelease(right);
1111
- const a = leftCore.split('.').map(Number);
1112
- const b = rightCore.split('.').map(Number);
1113
- const core = (a[0] ?? 0) - (b[0] ?? 0) ||
1114
- (a[1] ?? 0) - (b[1] ?? 0) ||
1115
- (a[2] ?? 0) - (b[2] ?? 0);
1116
- if (core || leftPre === rightPre) {
1117
- return core;
1118
- }
1119
- if (undefined === leftPre || undefined === rightPre) {
1120
- return undefined === leftPre ? 1 : -1;
1121
- }
1122
- return comparePrerelease(leftPre.split('.'), rightPre.split('.'));
1123
- }
1124
- function splitPrerelease(version) {
1125
- const index = version.indexOf('-');
1126
- return -1 === index
1127
- ? [version]
1128
- : [version.slice(0, index), version.slice(index + 1)];
1129
- }
1130
- function comparePrerelease(left, right) {
1131
- const [a, ...leftRest] = left;
1132
- const [b, ...rightRest] = right;
1133
- if (undefined === a || undefined === b) {
1134
- return (undefined === a ? 0 : 1) - (undefined === b ? 0 : 1);
1135
- }
1136
- const order = compareIdentifier(a, b);
1137
- return order || comparePrerelease(leftRest, rightRest);
1138
- }
1139
- function compareIdentifier(a, b) {
1140
- const aNumeric = /^\d+$/.test(a);
1141
- const bNumeric = /^\d+$/.test(b);
1142
- if (aNumeric && bNumeric) {
1143
- return Number(a) - Number(b);
1144
- }
1145
- if (aNumeric || bNumeric) {
1146
- return aNumeric ? -1 : 1;
1147
- }
1148
- return Number(a > b) - Number(a < b);
424
+ return new ChangesetRelease(input).getStatus(input);
1149
425
  }