@wpelevator/wp-release 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -1
- package/dist/changeset-config.d.ts +189 -0
- package/dist/changeset-config.js +273 -0
- package/dist/changeset-release.d.ts +76 -0
- package/dist/changeset-release.js +835 -0
- package/dist/changesets.d.ts +72 -0
- package/dist/changesets.js +103 -0
- package/dist/cli.d.ts +12 -5
- package/dist/cli.js +52 -7
- package/dist/commands/changeset-add.d.ts +37 -0
- package/dist/commands/changeset-add.js +35 -0
- package/dist/commands/changeset-args.d.ts +118 -0
- package/dist/commands/changeset-args.js +144 -0
- package/dist/commands/changeset-status.d.ts +43 -0
- package/dist/commands/changeset-status.js +65 -0
- package/dist/commands/changeset-tag.d.ts +28 -0
- package/dist/commands/changeset-tag.js +30 -0
- package/dist/commands/changeset-version.d.ts +38 -0
- package/dist/commands/changeset-version.js +39 -0
- package/dist/commands/command.d.ts +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,835 @@
|
|
|
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
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { loadChangesetConfig } from "./changeset-config.js";
|
|
7
|
+
import { findMainPluginFile } from "./package-meta.js";
|
|
8
|
+
import { findConstantValues } from "./version-check.js";
|
|
9
|
+
import { getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
|
|
10
|
+
import { ValidationError } from "./errors.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
/**
|
|
13
|
+
* Discover configured packages, pending changesets, and their proposed versions.
|
|
14
|
+
*
|
|
15
|
+
* @param input Repository root and optional explicit config file.
|
|
16
|
+
* @return A release plan; `undefined` when no config is present.
|
|
17
|
+
*/
|
|
18
|
+
export async function getChangesetReleasePlan(input) {
|
|
19
|
+
const context = await getReleaseContext(input);
|
|
20
|
+
return context?.plan;
|
|
21
|
+
}
|
|
22
|
+
async function getReleaseContext(input) {
|
|
23
|
+
const loaded = await loadChangesetConfig(input);
|
|
24
|
+
if (!loaded) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const config = loaded.config;
|
|
28
|
+
const packages = await discoverPackages(input.cwd, config);
|
|
29
|
+
const changesets = await readChangesets(input.cwd, config);
|
|
30
|
+
const releases = [];
|
|
31
|
+
packages.forEach((pkg) => {
|
|
32
|
+
const bump = getChangesetBump(changesets, pkg.name);
|
|
33
|
+
if (bump) {
|
|
34
|
+
const packageChangesets = changesets.filter((changeset) => Object.hasOwn(changeset.packages, pkg.name));
|
|
35
|
+
const nextVersion = config.nextVersion
|
|
36
|
+
? config.nextVersion({
|
|
37
|
+
package: pkg,
|
|
38
|
+
bump,
|
|
39
|
+
currentVersion: pkg.currentVersion,
|
|
40
|
+
changesets: packageChangesets,
|
|
41
|
+
})
|
|
42
|
+
: getNextVersion(pkg.currentVersion, bump);
|
|
43
|
+
if ('string' !== typeof nextVersion || !nextVersion.trim()) {
|
|
44
|
+
throw new ValidationError(`The nextVersion strategy returned an empty version for package "${pkg.name}".`);
|
|
45
|
+
}
|
|
46
|
+
releases.push({
|
|
47
|
+
package: pkg,
|
|
48
|
+
bump,
|
|
49
|
+
currentVersion: pkg.currentVersion,
|
|
50
|
+
nextVersion,
|
|
51
|
+
changesets: packageChangesets,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
changesets.forEach((changeset) => {
|
|
56
|
+
Object.keys(changeset.packages).forEach((name) => {
|
|
57
|
+
if (!packages.some((pkg) => pkg.name === name)) {
|
|
58
|
+
throw new ValidationError(`Changeset ${changeset.fileName} refers to unknown package "${name}".`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return { config, packages, plan: { changesets, releases } };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create a Changesets-compatible Markdown file.
|
|
66
|
+
*
|
|
67
|
+
* @param input Repository, packages and summary.
|
|
68
|
+
* @return Created file path.
|
|
69
|
+
*/
|
|
70
|
+
export async function addChangeset(input) {
|
|
71
|
+
const loaded = await requireConfig(input);
|
|
72
|
+
const packages = await discoverPackages(input.cwd, loaded.config);
|
|
73
|
+
Object.keys(input.packages).forEach((name) => {
|
|
74
|
+
if (!packages.some((pkg) => pkg.name === name)) {
|
|
75
|
+
throw new ValidationError(`Unknown package "${name}".`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const directory = path.resolve(input.cwd, loaded.config.changesets ?? '.changeset');
|
|
79
|
+
await mkdir(directory, { recursive: true });
|
|
80
|
+
const fileName = `${input.name ?? randomUUID().slice(0, 8)}.md`;
|
|
81
|
+
const file = path.join(directory, fileName);
|
|
82
|
+
let exists = false;
|
|
83
|
+
try {
|
|
84
|
+
await access(file);
|
|
85
|
+
exists = true;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if ('ENOENT' !== error.code) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (exists) {
|
|
93
|
+
throw new ValidationError(`Changeset ${fileName} already exists.`);
|
|
94
|
+
}
|
|
95
|
+
const entries = Object.entries(input.packages)
|
|
96
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
97
|
+
.map(([name, bump]) => `${JSON.stringify(name)}: ${bump}`)
|
|
98
|
+
.join('\n');
|
|
99
|
+
await writeFile(file, `---\n${entries}\n---\n\n${input.summary.trim()}\n`);
|
|
100
|
+
return file;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Apply pending package versions and changelogs.
|
|
104
|
+
*
|
|
105
|
+
* @param input Repository and versioning options.
|
|
106
|
+
* @return Planned releases and whether files were changed.
|
|
107
|
+
*/
|
|
108
|
+
export async function versionChangesets(input) {
|
|
109
|
+
const context = await getReleaseContext(input);
|
|
110
|
+
if (!context) {
|
|
111
|
+
throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
|
|
112
|
+
}
|
|
113
|
+
const { config, packages, plan: releasePlan } = context;
|
|
114
|
+
const date = input.date ?? new Date().toISOString().slice(0, 10);
|
|
115
|
+
if (!input.dryRun) {
|
|
116
|
+
const journal = new Map();
|
|
117
|
+
try {
|
|
118
|
+
for (const release of releasePlan.releases) {
|
|
119
|
+
const configured = packages.find((pkg) => pkg.name === release.package.name);
|
|
120
|
+
if (!configured) {
|
|
121
|
+
throw new ValidationError(`Unknown package "${release.package.name}".`);
|
|
122
|
+
}
|
|
123
|
+
for (const target of configured.versionConfig.to) {
|
|
124
|
+
await writeVersion(configured.directory, target, release.nextVersion, journal);
|
|
125
|
+
}
|
|
126
|
+
await verifyVersionTargets(input.cwd, configured, release.nextVersion);
|
|
127
|
+
await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
|
|
128
|
+
}
|
|
129
|
+
const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
|
|
130
|
+
for (const changeset of releasePlan.changesets) {
|
|
131
|
+
const file = path.join(changesetDirectory, changeset.fileName);
|
|
132
|
+
await rememberFile(journal, file);
|
|
133
|
+
await rm(file);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const rollbackErrors = await restoreFileJournal(journal);
|
|
138
|
+
if (rollbackErrors.length) {
|
|
139
|
+
throw new AggregateError([error, ...rollbackErrors], 'Changeset versioning failed and one or more file changes could not be rolled back.');
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...releasePlan,
|
|
146
|
+
date,
|
|
147
|
+
changed: !input.dryRun && releasePlan.changesets.length > 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Create missing release tags, optionally pushing them to the configured remote.
|
|
152
|
+
*
|
|
153
|
+
* @param input Repository and tag options.
|
|
154
|
+
* @return Per-package tags and release notes.
|
|
155
|
+
*/
|
|
156
|
+
export async function tagChangesets(input) {
|
|
157
|
+
if (input.githubRelease && !input.push) {
|
|
158
|
+
throw new ValidationError('GitHub Releases require pushing the tags.');
|
|
159
|
+
}
|
|
160
|
+
if (input.untagged && input.push) {
|
|
161
|
+
throw new ValidationError('Listing untagged releases cannot be combined with pushing tags.');
|
|
162
|
+
}
|
|
163
|
+
const context = await getReleaseContext(input);
|
|
164
|
+
if (!context) {
|
|
165
|
+
throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
|
|
166
|
+
}
|
|
167
|
+
const packages = input.since
|
|
168
|
+
? (await changedPackagesSince(input.cwd, context.packages, input.since)).packages
|
|
169
|
+
: context.packages;
|
|
170
|
+
const results = [];
|
|
171
|
+
const dryRun = input.dryRun || input.untagged;
|
|
172
|
+
const tagFormat = context.config.tagFormat ?? '{path}/{version}';
|
|
173
|
+
for (const pkg of packages) {
|
|
174
|
+
const version = 'git-tag' === pkg.versionConfig.from.kind
|
|
175
|
+
? await getLatestChangelogVersion(pkg.directory, pkg.currentVersion)
|
|
176
|
+
: pkg.currentVersion;
|
|
177
|
+
const tag = formatTag(tagFormat, pkg, version);
|
|
178
|
+
const exists = await gitTagExists(input.cwd, tag);
|
|
179
|
+
const notes = await readReleaseNotes(pkg.directory, version);
|
|
180
|
+
if (!exists && !dryRun) {
|
|
181
|
+
await git(input.cwd, ['tag', tag]);
|
|
182
|
+
}
|
|
183
|
+
results.push({
|
|
184
|
+
package: pkg.name,
|
|
185
|
+
version,
|
|
186
|
+
tag,
|
|
187
|
+
exists,
|
|
188
|
+
created: !exists && !dryRun,
|
|
189
|
+
notes,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Push one tag at a time because GitHub doesn't create push events for
|
|
193
|
+
// tags when more than three are pushed at once.
|
|
194
|
+
if (input.push && !dryRun) {
|
|
195
|
+
for (const item of results.filter((result) => result.created)) {
|
|
196
|
+
await git(input.cwd, ['push', 'origin', item.tag]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (input.githubRelease && !dryRun) {
|
|
200
|
+
for (const release of results) {
|
|
201
|
+
await createGitHubRelease(input.cwd, release);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
tags: input.untagged
|
|
206
|
+
? results.filter((result) => !result.exists)
|
|
207
|
+
: results,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function createGitHubRelease(cwd, release) {
|
|
211
|
+
try {
|
|
212
|
+
await execFileAsync('gh', ['release', 'view', release.tag], {
|
|
213
|
+
cwd,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
try {
|
|
218
|
+
await execFileAsync('gh', [
|
|
219
|
+
'release',
|
|
220
|
+
'create',
|
|
221
|
+
release.tag,
|
|
222
|
+
'--title',
|
|
223
|
+
`${release.package}@${release.version}`,
|
|
224
|
+
'--notes',
|
|
225
|
+
release.notes,
|
|
226
|
+
], { cwd });
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
throw new ValidationError(`Could not create GitHub Release for ${release.tag}: ${message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Check whether changed files belong to packages without a changeset.
|
|
236
|
+
*
|
|
237
|
+
* @param input Repository and base git ref.
|
|
238
|
+
* @return Changed packages and missing changeset identifiers.
|
|
239
|
+
*/
|
|
240
|
+
export async function getChangesetStatus(input) {
|
|
241
|
+
const context = await getReleaseContext(input);
|
|
242
|
+
if (!context) {
|
|
243
|
+
throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
|
|
244
|
+
}
|
|
245
|
+
const { plan } = context;
|
|
246
|
+
const { changesets, releases } = plan;
|
|
247
|
+
const since = input.since ??
|
|
248
|
+
(context.config.baseBranch
|
|
249
|
+
? `origin/${context.config.baseBranch}`
|
|
250
|
+
: undefined);
|
|
251
|
+
const changed = since
|
|
252
|
+
? await changedPackagesSince(input.cwd, context.packages, since)
|
|
253
|
+
: { packages: [], filesByPackage: {} };
|
|
254
|
+
const changedPackages = changed.packages.map((pkg) => pkg.name);
|
|
255
|
+
const changesetsInDiff = since
|
|
256
|
+
? await readChangesetsSince(input.cwd, context.config, changesets, since)
|
|
257
|
+
: [];
|
|
258
|
+
const hasEmptyChangeset = changesetsInDiff.some((changeset) => 0 === Object.keys(changeset.packages).length);
|
|
259
|
+
const missingChangesets = hasEmptyChangeset
|
|
260
|
+
? []
|
|
261
|
+
: changedPackages.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)));
|
|
262
|
+
return {
|
|
263
|
+
changesets,
|
|
264
|
+
releases,
|
|
265
|
+
changedPackages,
|
|
266
|
+
changedPackageFiles: changed.filesByPackage,
|
|
267
|
+
missingChangesets,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function requireConfig(input) {
|
|
271
|
+
const loaded = await loadChangesetConfig(input);
|
|
272
|
+
if (!loaded) {
|
|
273
|
+
throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
|
|
274
|
+
}
|
|
275
|
+
return loaded;
|
|
276
|
+
}
|
|
277
|
+
async function readChangesets(cwd, config) {
|
|
278
|
+
const directory = path.resolve(cwd, config.changesets ?? '.changeset');
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = await readdir(directory);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if ('ENOENT' === error.code) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
return Promise.all(entries
|
|
290
|
+
.filter((name) => name.endsWith('.md') && !name.startsWith('README'))
|
|
291
|
+
.sort()
|
|
292
|
+
.map(async (name) => parseChangeset(await readFile(path.join(directory, name), 'utf8'), name)));
|
|
293
|
+
}
|
|
294
|
+
async function readChangesetsSince(cwd, config, changesets, since) {
|
|
295
|
+
const directory = path.resolve(cwd, config.changesets ?? '.changeset');
|
|
296
|
+
const relativeDirectory = path
|
|
297
|
+
.relative(cwd, directory)
|
|
298
|
+
.split(path.sep)
|
|
299
|
+
.join('/');
|
|
300
|
+
const output = await git(cwd, [
|
|
301
|
+
'diff',
|
|
302
|
+
'--name-only',
|
|
303
|
+
'--diff-filter=ACMR',
|
|
304
|
+
`${since}...HEAD`,
|
|
305
|
+
'--',
|
|
306
|
+
relativeDirectory,
|
|
307
|
+
]);
|
|
308
|
+
const changedFiles = new Set(output.trim().split(/\r?\n/).filter(Boolean));
|
|
309
|
+
return changesets.filter((changeset) => changedFiles.has(path.posix.join(relativeDirectory, changeset.fileName)));
|
|
310
|
+
}
|
|
311
|
+
async function discoverPackages(cwd, config) {
|
|
312
|
+
const root = path.resolve(cwd, config.root ?? '.');
|
|
313
|
+
const packages = [];
|
|
314
|
+
const hasRecursiveGlob = config.packages.some((item) => item.glob.split('/').includes('**'));
|
|
315
|
+
const directories = await findDirectories(root, '', hasRecursiveGlob
|
|
316
|
+
? Infinity
|
|
317
|
+
: Math.max(1, ...config.packages.map((item) => item.glob.split('/').length + 2)));
|
|
318
|
+
for (const group of config.packages) {
|
|
319
|
+
const matcher = globRegExp(group.glob);
|
|
320
|
+
const candidates = matcher.test('.')
|
|
321
|
+
? ['.', ...directories]
|
|
322
|
+
: directories;
|
|
323
|
+
const matches = candidates
|
|
324
|
+
.filter((item) => matcher.test(item) &&
|
|
325
|
+
!(group.exclude ?? []).some((excluded) => globRegExp(excluded).test(item)))
|
|
326
|
+
.sort();
|
|
327
|
+
if (!matches.length) {
|
|
328
|
+
throw new ValidationError(`Package glob "${group.glob}" did not match any directories under ${root}.`);
|
|
329
|
+
}
|
|
330
|
+
for (const relativePath of matches) {
|
|
331
|
+
const directory = path.join(root, relativePath);
|
|
332
|
+
const name = await packageName(directory, relativePath, group.idFrom ?? 'path');
|
|
333
|
+
const currentVersion = await readVersion(cwd, directory, group.version.from, relativePath, group.tagType);
|
|
334
|
+
if (packages.some((pkg) => pkg.name === name)) {
|
|
335
|
+
throw new ValidationError(`Package identifier "${name}" is configured more than once.`);
|
|
336
|
+
}
|
|
337
|
+
packages.push({
|
|
338
|
+
name,
|
|
339
|
+
directory,
|
|
340
|
+
path: relativePath,
|
|
341
|
+
type: packageType(group),
|
|
342
|
+
currentVersion,
|
|
343
|
+
group,
|
|
344
|
+
versionConfig: group.version,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return packages;
|
|
349
|
+
}
|
|
350
|
+
async function findDirectories(root, prefix = '', maxDepth = 5) {
|
|
351
|
+
let entries;
|
|
352
|
+
try {
|
|
353
|
+
entries = await readdir(path.join(root, prefix), {
|
|
354
|
+
withFileTypes: true,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if ('ENOENT' === error.code) {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
const directories = entries.filter((item) => item.isDirectory() &&
|
|
364
|
+
!['.git', 'node_modules', 'vendor', 'dist'].includes(item.name));
|
|
365
|
+
if (prefix.split(path.sep).filter(Boolean).length >= maxDepth) {
|
|
366
|
+
return directories.map((item) => path.join(prefix, item.name));
|
|
367
|
+
}
|
|
368
|
+
const children = await Promise.all(directories.map((item) => findDirectories(root, path.join(prefix, item.name), maxDepth)));
|
|
369
|
+
return [
|
|
370
|
+
...directories.map((item) => path.join(prefix, item.name)),
|
|
371
|
+
...children.flat(),
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
function globRegExp(glob) {
|
|
375
|
+
const escaped = glob
|
|
376
|
+
.split('/')
|
|
377
|
+
.map((part) => {
|
|
378
|
+
if ('**' === part) {
|
|
379
|
+
return '.*';
|
|
380
|
+
}
|
|
381
|
+
return part
|
|
382
|
+
.replaceAll(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
383
|
+
.replaceAll('*', '[^/]*')
|
|
384
|
+
.replaceAll('?', '[^/]');
|
|
385
|
+
})
|
|
386
|
+
.join('/');
|
|
387
|
+
return new RegExp(`^${escaped}$`);
|
|
388
|
+
}
|
|
389
|
+
async function packageName(directory, relativePath, idFrom) {
|
|
390
|
+
if ('path' === idFrom) {
|
|
391
|
+
if ('.' === relativePath) {
|
|
392
|
+
return path.basename(directory);
|
|
393
|
+
}
|
|
394
|
+
return relativePath.split(path.sep).join('/');
|
|
395
|
+
}
|
|
396
|
+
const [file, field] = idFrom.split('#');
|
|
397
|
+
if (!file || !field) {
|
|
398
|
+
throw new ValidationError(`Invalid package identifier source "${idFrom}".`);
|
|
399
|
+
}
|
|
400
|
+
const manifest = await readJson(path.join(directory, file));
|
|
401
|
+
const name = manifest[field];
|
|
402
|
+
if ('string' !== typeof name || !name.trim()) {
|
|
403
|
+
throw new ValidationError(`Expected ${file}#${field} in ${directory}.`);
|
|
404
|
+
}
|
|
405
|
+
return name;
|
|
406
|
+
}
|
|
407
|
+
async function readVersion(cwd, directory, descriptor, relativePath, tagType) {
|
|
408
|
+
if ('package-json' === descriptor.kind ||
|
|
409
|
+
'composer-json' === descriptor.kind) {
|
|
410
|
+
const manifest = await readJson(path.join(directory, descriptor.file));
|
|
411
|
+
const version = manifest[descriptor.field];
|
|
412
|
+
if ('string' === typeof version && version.trim()) {
|
|
413
|
+
return version;
|
|
414
|
+
}
|
|
415
|
+
throw new ValidationError(`Missing ${descriptor.file}#${descriptor.field} in ${directory}.`);
|
|
416
|
+
}
|
|
417
|
+
if ('git-tag' === descriptor.kind) {
|
|
418
|
+
const pattern = descriptor.pattern ?? '{path}/{version}';
|
|
419
|
+
const tagsOutput = await git(cwd, [
|
|
420
|
+
'tag',
|
|
421
|
+
'--list',
|
|
422
|
+
`${formatTemplatePrefix(pattern, relativePath, tagType)}*`,
|
|
423
|
+
]);
|
|
424
|
+
const tags = tagsOutput.trim().split(/\r?\n/).filter(Boolean);
|
|
425
|
+
const versions = tags
|
|
426
|
+
.map((tag) => tag.match(/(?:^|\/|v)(\d+\.\d+\.\d+)$/)?.[1])
|
|
427
|
+
.filter((item) => Boolean(item));
|
|
428
|
+
versions.sort((a, b) => compareVersions(b, a));
|
|
429
|
+
if (versions[0]) {
|
|
430
|
+
return versions[0];
|
|
431
|
+
}
|
|
432
|
+
throw new ValidationError(`No git tag matches ${pattern} for ${relativePath}.`);
|
|
433
|
+
}
|
|
434
|
+
if ('php-constant' === descriptor.kind) {
|
|
435
|
+
const file = path.join(directory, descriptor.file);
|
|
436
|
+
const contents = await readFile(file, 'utf8');
|
|
437
|
+
const versions = findConstantValues(contents, descriptor.constant);
|
|
438
|
+
if (versions[0]) {
|
|
439
|
+
return versions[0];
|
|
440
|
+
}
|
|
441
|
+
throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
|
|
442
|
+
}
|
|
443
|
+
const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
|
|
444
|
+
const contents = await readFile(file, 'utf8');
|
|
445
|
+
const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
|
|
446
|
+
const match = new RegExp(`^\\s*\\*?\\s*${label}:\\s*([^\\r\\n]+)`, 'im').exec(contents);
|
|
447
|
+
if (!match) {
|
|
448
|
+
throw new ValidationError(`Missing ${label} header in ${file}.`);
|
|
449
|
+
}
|
|
450
|
+
return match[1]?.trim() ?? '';
|
|
451
|
+
}
|
|
452
|
+
async function writeVersion(directory, descriptor, version, journal) {
|
|
453
|
+
if ('package-json' === descriptor.kind ||
|
|
454
|
+
'composer-json' === descriptor.kind) {
|
|
455
|
+
const file = path.join(directory, descriptor.file);
|
|
456
|
+
const contents = await readFile(file, 'utf8');
|
|
457
|
+
const manifest = JSON.parse(contents);
|
|
458
|
+
if ('composer-json' === descriptor.kind &&
|
|
459
|
+
descriptor.ifPresent &&
|
|
460
|
+
!Object.hasOwn(manifest, descriptor.field)) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
manifest[descriptor.field] = version;
|
|
464
|
+
await rememberFile(journal, file);
|
|
465
|
+
await writeFile(file, `${JSON.stringify(manifest, null, '\t')}\n`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if ('php-constant' === descriptor.kind) {
|
|
469
|
+
const file = path.join(directory, descriptor.file);
|
|
470
|
+
const contents = await readFile(file, 'utf8');
|
|
471
|
+
const constant = escapeRegExp(descriptor.constant);
|
|
472
|
+
const definePattern = new RegExp(`(\\bdefine\\s*\\(\\s*(['"])${constant}\\2\\s*,\\s*)(['"])(.*?)\\3`, 'g');
|
|
473
|
+
const declarationPattern = new RegExp(`(\\bconst\\s+${constant}\\s*=\\s*)(['"])(.*?)\\2`, 'g');
|
|
474
|
+
const updated = contents
|
|
475
|
+
.replace(definePattern, (_match, prefix, _nameQuote, quote) => `${prefix}${quote}${version}${quote}`)
|
|
476
|
+
.replace(declarationPattern, (_match, prefix, quote) => `${prefix}${quote}${version}${quote}`);
|
|
477
|
+
if (updated === contents) {
|
|
478
|
+
throw new ValidationError(`Missing PHP constant ${descriptor.constant} in ${file}.`);
|
|
479
|
+
}
|
|
480
|
+
await rememberFile(journal, file);
|
|
481
|
+
await writeFile(file, updated);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if ('wordpress-stable-tag' === descriptor.kind &&
|
|
485
|
+
descriptor.ifPresent &&
|
|
486
|
+
!(await versionFileExists(directory, descriptor.kind, descriptor.file))) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const file = path.join(directory, await getVersionFile(directory, descriptor.kind, descriptor.file));
|
|
490
|
+
const contents = await readFile(file, 'utf8');
|
|
491
|
+
const label = 'wordpress-stable-tag' === descriptor.kind ? 'Stable tag' : 'Version';
|
|
492
|
+
const expression = new RegExp(`(^\\s*\\*?\\s*${label}:\\s*)[^\\r\\n]+`, 'im');
|
|
493
|
+
if (!expression.test(contents)) {
|
|
494
|
+
throw new ValidationError(`Missing ${label} header in ${file}.`);
|
|
495
|
+
}
|
|
496
|
+
await rememberFile(journal, file);
|
|
497
|
+
await writeFile(file, contents.replace(expression, `$1${version}`));
|
|
498
|
+
}
|
|
499
|
+
async function getVersionFile(directory, kind, configuredFile) {
|
|
500
|
+
if (configuredFile) {
|
|
501
|
+
return configuredFile;
|
|
502
|
+
}
|
|
503
|
+
if ('wordpress-plugin-header' === kind) {
|
|
504
|
+
const file = await findMainPluginFile({ dir: directory });
|
|
505
|
+
if (file) {
|
|
506
|
+
return file;
|
|
507
|
+
}
|
|
508
|
+
throw new ValidationError(`Could not find a plugin header file in ${directory}.`);
|
|
509
|
+
}
|
|
510
|
+
if ('wordpress-stable-tag' === kind) {
|
|
511
|
+
for (const file of ['readme.txt', 'readme.md']) {
|
|
512
|
+
try {
|
|
513
|
+
await access(path.join(directory, file));
|
|
514
|
+
return file;
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// Try the next WordPress readme filename.
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
throw new ValidationError(`Could not find readme.txt or readme.md in ${directory}.`);
|
|
521
|
+
}
|
|
522
|
+
return 'style.css';
|
|
523
|
+
}
|
|
524
|
+
async function versionFileExists(directory, kind, configuredFile) {
|
|
525
|
+
if (configuredFile) {
|
|
526
|
+
try {
|
|
527
|
+
await access(path.join(directory, configuredFile));
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if ('wordpress-stable-tag' === kind) {
|
|
535
|
+
const files = await Promise.all(['readme.txt', 'readme.md'].map(async (file) => {
|
|
536
|
+
try {
|
|
537
|
+
await access(path.join(directory, file));
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}));
|
|
544
|
+
return files.some(Boolean);
|
|
545
|
+
}
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
async function prependChangelog(directory, release, date, readmeChangelog, changelogInsertAfter, journal) {
|
|
549
|
+
const summaries = release.changesets
|
|
550
|
+
.map((item) => item.summary.trim())
|
|
551
|
+
.filter(Boolean);
|
|
552
|
+
const section = `## ${release.nextVersion} (${date})\n\n${summaries
|
|
553
|
+
.map((item) => `- ${item.replaceAll(/\r?\n/g, '\n ')}`)
|
|
554
|
+
.join('\n')}\n\n`;
|
|
555
|
+
const changelog = path.join(directory, 'CHANGELOG.md');
|
|
556
|
+
let previous = '';
|
|
557
|
+
try {
|
|
558
|
+
previous = await readFile(changelog, 'utf8');
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if ('ENOENT' !== error.code) {
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const releaseHeading = new RegExp(`^## ${escapeRegExp(release.nextVersion)} \\(`, 'm');
|
|
566
|
+
if (!releaseHeading.test(previous)) {
|
|
567
|
+
await rememberFile(journal, changelog);
|
|
568
|
+
if (undefined !== changelogInsertAfter) {
|
|
569
|
+
await writeFile(changelog, insertAfterMarker(previous, changelogInsertAfter, section, changelog));
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
const versionHeading = /^##[ \t]+v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:[ \t]+.*)?$/m.exec(previous);
|
|
573
|
+
const heading = /^#[ \t]+[^\r\n]+[ \t]*$/m.exec(previous);
|
|
574
|
+
const insertion = versionHeading ?? heading;
|
|
575
|
+
if (insertion) {
|
|
576
|
+
const before = previous
|
|
577
|
+
.slice(0, insertion.index)
|
|
578
|
+
.replace(/(?:\r?\n)*$/, '');
|
|
579
|
+
const after = previous.slice(insertion.index);
|
|
580
|
+
const prefix = before ? `${before}\n\n` : '';
|
|
581
|
+
await writeFile(changelog, `${prefix}${section}${after}`);
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
await writeFile(changelog, `${section}${previous}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (readmeChangelog) {
|
|
589
|
+
const readme = ['readme.txt', 'readme.md'].map((name) => path.join(directory, name));
|
|
590
|
+
let readmeUpdated = false;
|
|
591
|
+
let readmeFound = false;
|
|
592
|
+
let readmeMarkerFound = false;
|
|
593
|
+
let readmeAlreadyVersioned = false;
|
|
594
|
+
for (const file of readme) {
|
|
595
|
+
try {
|
|
596
|
+
const contents = await readFile(file, 'utf8');
|
|
597
|
+
readmeFound = true;
|
|
598
|
+
const defaultHeading = undefined === changelogInsertAfter
|
|
599
|
+
? /^==\s*Changelog\s*==\s*$/im.exec(contents)?.[0]
|
|
600
|
+
: undefined;
|
|
601
|
+
const insertionMarker = changelogInsertAfter ?? defaultHeading;
|
|
602
|
+
readmeMarkerFound ||=
|
|
603
|
+
undefined !== insertionMarker &&
|
|
604
|
+
contents.includes(insertionMarker);
|
|
605
|
+
const alreadyVersioned = new RegExp(`^=\\s*${escapeRegExp(release.nextVersion)}\\s*=$`, 'm').test(contents);
|
|
606
|
+
readmeAlreadyVersioned ||= alreadyVersioned;
|
|
607
|
+
if (insertionMarker &&
|
|
608
|
+
!alreadyVersioned &&
|
|
609
|
+
!readmeUpdated &&
|
|
610
|
+
contents.includes(insertionMarker)) {
|
|
611
|
+
const readmeSection = `\n\n= ${release.nextVersion} =\n\n${summaries
|
|
612
|
+
.map((item) => `* ${item.replaceAll(/\r?\n/g, '\n ')}`)
|
|
613
|
+
.join('\n')}\n\n`;
|
|
614
|
+
await rememberFile(journal, file);
|
|
615
|
+
await writeFile(file, insertAfterMarker(contents, insertionMarker, readmeSection.trimStart(), file));
|
|
616
|
+
readmeUpdated = true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
if ('ENOENT' !== error.code) {
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (changelogInsertAfter &&
|
|
626
|
+
readmeFound &&
|
|
627
|
+
!readmeMarkerFound &&
|
|
628
|
+
!readmeAlreadyVersioned) {
|
|
629
|
+
throw new ValidationError(`Changelog insertion marker "${changelogInsertAfter}" was not found in a plugin readme in ${directory}.`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function insertAfterMarker(contents, marker, section, file) {
|
|
634
|
+
const index = contents.indexOf(marker);
|
|
635
|
+
if (-1 === index) {
|
|
636
|
+
throw new ValidationError(`Changelog insertion marker "${marker}" was not found in ${file}.`);
|
|
637
|
+
}
|
|
638
|
+
const markerEnd = index + marker.length;
|
|
639
|
+
const after = contents
|
|
640
|
+
.slice(markerEnd)
|
|
641
|
+
.replace(/^(?:[ \t]*\r?\n)+/, '');
|
|
642
|
+
const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n';
|
|
643
|
+
return `${contents.slice(0, markerEnd)}${lineEnding}${lineEnding}${section.replaceAll('\n', lineEnding)}${after}`;
|
|
644
|
+
}
|
|
645
|
+
async function changedPackagesSince(cwd, packages, since) {
|
|
646
|
+
const output = await git(cwd, [
|
|
647
|
+
'diff',
|
|
648
|
+
'--name-only',
|
|
649
|
+
`${since}...HEAD`,
|
|
650
|
+
]);
|
|
651
|
+
const changedFiles = output.trim().split(/\r?\n/).filter(Boolean);
|
|
652
|
+
const filesByPackage = {};
|
|
653
|
+
const changed = packages.filter((pkg) => {
|
|
654
|
+
const packageFromRepo = path
|
|
655
|
+
.relative(cwd, pkg.directory)
|
|
656
|
+
.split(path.sep)
|
|
657
|
+
.join('/');
|
|
658
|
+
const matchingFiles = '.' === packageFromRepo
|
|
659
|
+
? changedFiles
|
|
660
|
+
: changedFiles.filter((file) => file === packageFromRepo ||
|
|
661
|
+
file.startsWith(`${packageFromRepo}/`));
|
|
662
|
+
if (matchingFiles.length) {
|
|
663
|
+
filesByPackage[pkg.name] = matchingFiles;
|
|
664
|
+
}
|
|
665
|
+
return matchingFiles.length > 0;
|
|
666
|
+
});
|
|
667
|
+
return { packages: changed, filesByPackage };
|
|
668
|
+
}
|
|
669
|
+
function formatTag(format, pkg, version) {
|
|
670
|
+
const type = pkg.group.tagType ?? defaultTagType(pkg.path, pkg.type);
|
|
671
|
+
return format
|
|
672
|
+
.replaceAll('{name}', pkg.name)
|
|
673
|
+
.replaceAll('{slug}', '.' === pkg.path
|
|
674
|
+
? path.basename(pkg.directory)
|
|
675
|
+
: path.basename(pkg.path))
|
|
676
|
+
.replaceAll('{path}', pkg.path)
|
|
677
|
+
.replaceAll('{type}', type)
|
|
678
|
+
.replaceAll('{version}', version);
|
|
679
|
+
}
|
|
680
|
+
function defaultTagType(packagePath, type) {
|
|
681
|
+
if ('.' === packagePath && type) {
|
|
682
|
+
return type;
|
|
683
|
+
}
|
|
684
|
+
const first = packagePath.split('/')[0] ?? 'package';
|
|
685
|
+
const irregular = {
|
|
686
|
+
actions: 'action',
|
|
687
|
+
plugins: 'plugin',
|
|
688
|
+
themes: 'theme',
|
|
689
|
+
php: 'php',
|
|
690
|
+
js: 'js',
|
|
691
|
+
};
|
|
692
|
+
return irregular[first] ?? first.replace(/s$/, '');
|
|
693
|
+
}
|
|
694
|
+
function formatTemplatePrefix(pattern, packagePath, tagType) {
|
|
695
|
+
return pattern
|
|
696
|
+
.replaceAll('{path}', packagePath)
|
|
697
|
+
.replaceAll('{type}', tagType ?? defaultTagType(packagePath))
|
|
698
|
+
.replaceAll('{name}', packagePath.split('/').at(-1) ?? packagePath)
|
|
699
|
+
.replaceAll('{slug}', packagePath.split('/').at(-1) ?? packagePath)
|
|
700
|
+
.replaceAll('{version}', '');
|
|
701
|
+
}
|
|
702
|
+
async function verifyVersionTargets(cwd, pkg, expected) {
|
|
703
|
+
for (const target of pkg.versionConfig.to) {
|
|
704
|
+
let actual;
|
|
705
|
+
if ('composer-json' === target.kind &&
|
|
706
|
+
target.ifPresent &&
|
|
707
|
+
!Object.hasOwn(await readJson(path.join(pkg.directory, target.file)), target.field)) {
|
|
708
|
+
actual = undefined;
|
|
709
|
+
}
|
|
710
|
+
else if ('wordpress-stable-tag' === target.kind &&
|
|
711
|
+
target.ifPresent &&
|
|
712
|
+
!(await versionFileExists(pkg.directory, target.kind, target.file))) {
|
|
713
|
+
actual = undefined;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
actual = await readVersion(cwd, pkg.directory, target, pkg.path, pkg.group.tagType);
|
|
717
|
+
}
|
|
718
|
+
if (undefined !== actual && actual !== expected) {
|
|
719
|
+
throw new ValidationError(`Version mismatch for ${pkg.name}: expected ${expected}, found ${actual}.`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function gitTagExists(cwd, tag) {
|
|
724
|
+
const result = await git(cwd, ['tag', '--list', tag]);
|
|
725
|
+
return result.trim() === tag;
|
|
726
|
+
}
|
|
727
|
+
async function git(cwd, args) {
|
|
728
|
+
const directory = path.resolve(cwd);
|
|
729
|
+
try {
|
|
730
|
+
const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${directory}`, ...args], { cwd: directory });
|
|
731
|
+
return stdout;
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
735
|
+
throw new ValidationError(`Git command failed (${args.join(' ')}): ${message}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
async function readReleaseNotes(directory, version) {
|
|
739
|
+
let contents = '';
|
|
740
|
+
try {
|
|
741
|
+
contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
if ('ENOENT' !== error.code) {
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const lines = contents.split(/\r?\n/);
|
|
749
|
+
const headingIndex = lines.findIndex((line) => new RegExp(`^##\\s+${escapeRegExp(version)}(?:\\s|$)`).test(line));
|
|
750
|
+
if (-1 === headingIndex) {
|
|
751
|
+
return '';
|
|
752
|
+
}
|
|
753
|
+
const followingHeading = lines.findIndex((line, index) => index > headingIndex && /^##\s/.test(line));
|
|
754
|
+
const end = -1 === followingHeading ? lines.length : followingHeading;
|
|
755
|
+
return lines
|
|
756
|
+
.slice(headingIndex + 1, end)
|
|
757
|
+
.join('\n')
|
|
758
|
+
.trim();
|
|
759
|
+
}
|
|
760
|
+
async function getLatestChangelogVersion(directory, fallback) {
|
|
761
|
+
try {
|
|
762
|
+
const contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
|
|
763
|
+
const match = /^##\s+(\d+\.\d+\.\d+)(?:\s|$)/m.exec(contents);
|
|
764
|
+
return match?.[1] ?? fallback;
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
if ('ENOENT' === error.code) {
|
|
768
|
+
return fallback;
|
|
769
|
+
}
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function escapeRegExp(value) {
|
|
774
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
775
|
+
}
|
|
776
|
+
async function rememberFile(journal, file) {
|
|
777
|
+
if (journal.has(file)) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
journal.set(file, await readFile(file, 'utf8'));
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
if ('ENOENT' !== error.code) {
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
journal.set(file, undefined);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
async function restoreFileJournal(journal) {
|
|
791
|
+
const errors = [];
|
|
792
|
+
for (const [file, contents] of [...journal.entries()].reverse()) {
|
|
793
|
+
try {
|
|
794
|
+
if (undefined === contents) {
|
|
795
|
+
await rm(file, { force: true });
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
await writeFile(file, contents);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return errors;
|
|
806
|
+
}
|
|
807
|
+
async function readJson(file) {
|
|
808
|
+
try {
|
|
809
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
813
|
+
throw new ValidationError(`Could not read JSON manifest ${file}: ${message}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function packageType(group) {
|
|
817
|
+
const kind = group.version.from.kind;
|
|
818
|
+
if ('wordpress-plugin-header' === kind) {
|
|
819
|
+
return 'plugin';
|
|
820
|
+
}
|
|
821
|
+
if ('wordpress-theme-header' === kind) {
|
|
822
|
+
return 'theme';
|
|
823
|
+
}
|
|
824
|
+
if ('package-json' === kind) {
|
|
825
|
+
return 'npm';
|
|
826
|
+
}
|
|
827
|
+
return 'other';
|
|
828
|
+
}
|
|
829
|
+
function compareVersions(left, right) {
|
|
830
|
+
const a = left.split('.').map(Number);
|
|
831
|
+
const b = right.split('.').map(Number);
|
|
832
|
+
return ((a[0] ?? 0) - (b[0] ?? 0) ||
|
|
833
|
+
(a[1] ?? 0) - (b[1] ?? 0) ||
|
|
834
|
+
(a[2] ?? 0) - (b[2] ?? 0));
|
|
835
|
+
}
|