@wpelevator/wp-release 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/changeset-config.d.ts +75 -0
- package/dist/changeset-config.js +48 -0
- package/dist/changeset-release.d.ts +3 -0
- package/dist/changeset-release.js +614 -12
- package/dist/changesets.d.ts +2 -0
- package/dist/commands/changeset-args.d.ts +5 -0
- package/dist/commands/changeset-args.js +2 -0
- package/dist/commands/changeset-status.d.ts +2 -0
- package/dist/commands/changeset-status.js +6 -2
- package/dist/commands/changeset-version.d.ts +3 -0
- package/dist/commands/changeset-version.js +2 -1
- package/dist/index.d.ts +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -142,6 +142,46 @@ changelogEntry: async ( { changeset, commit } ) => {
|
|
|
142
142
|
},
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
Configure `dependents` to re-release packages that bundle a package when it releases. The dependents come from what the packages declare: a package is a dependent of a source when the spec that it declares in its npm `dependencies` or `optionalDependencies` (or the sections that the policy adds) resolves the current version of the source or points at the workspace, and it re-releases when the next version of the source falls outside the declared range, or always with `when: 'always'`. A pin to an older version selects a registry release instead and `changeset-status` warns about the drift until the pin resolves the package again. Versioning a dependent rewrites its declared specs of the source to the released version, keeping their range prefixes, so an exact pin matches the released workspace again and the built bundle matches the changelog. Wildcards and unpinned ranges accept the release without one, like the `devDependencies` of a package. The derived dependents appear in `changeset-status`, `changeset-version` and `changeset-tag` like any other release, and a dependent that releases for its own reasons keeps its own bump and appends the bundle note:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
dependents: {
|
|
149
|
+
bump: 'patch', // Default
|
|
150
|
+
exclude: [ 'actions/wp-release-sign' ], // Package paths under root, or globs over them
|
|
151
|
+
},
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Excluded packages still release with their own changesets and stay out of the automatic re-releases.
|
|
155
|
+
|
|
156
|
+
The bundle changelog entry defaults to `Bundle <source> <version>.` and is formatted with `dependents.changelog( { package, source } )`:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
dependents: {
|
|
160
|
+
changelog: ( { source } ) => `Bundle ${ source.package.name } ${ source.version }.`,
|
|
161
|
+
},
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`dependents.sections` lists the npm dependency sections that declare a source, defaulting to `dependencies` and `optionalDependencies`; add `devDependencies` for dependencies that end up in the dependent's build, or `peerDependencies` to scan them.
|
|
165
|
+
|
|
166
|
+
The `dependents.dependency` hooks extend the built-in npm scan instead of replacing it, so that both work behind the same range rule and rewrite:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
dependents: {
|
|
170
|
+
dependency: {
|
|
171
|
+
// Specs that declare the source outside package.json, like a Composer
|
|
172
|
+
// require in a vendor-isolated directory.
|
|
173
|
+
find: async ( { package: dependent, source } ) => {
|
|
174
|
+
return [ { file: 'vendor-isolated/composer.json', name: '…', spec: '^0.3' } ];
|
|
175
|
+
},
|
|
176
|
+
// Update the declaration to the released version. Call `remember( file )`
|
|
177
|
+
// before writing a file, so failed versioning rolls it back.
|
|
178
|
+
update: async ( { package: dependent, source, specs, version, remember } ) => { /* … */ },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
After versioning, run `npm install --package-lock-only` so that the root lockfile resolves the rewritten pins to the workspace links again; the `Prepare Release` workflow (`prepare-release.yml` in this monorepo) does it with its release pull request.
|
|
184
|
+
|
|
145
185
|
To release packages separately, for example with a release pull request for each, pass `--package <name>`, repeatable, to version only those packages. A changeset that also names other packages keeps only their entries and its summary, so it still applies to their releases with the same bump. Changesets without packages are left for a run without `--package`.
|
|
146
186
|
|
|
147
187
|
The `changeset-version` result lists each release with its `package`, `bump`, `currentVersion`, `nextVersion` and `changelog` entries, a Markdown list like the one added to `CHANGELOG.md`. Use it to name and describe the release pull request in a repository script, or call `versionChangesets()` from the library and format the entries of each release with `formatChangelogEntries( release.changesets )`.
|
|
@@ -69,6 +69,79 @@ export interface ChangesetPackageGroup {
|
|
|
69
69
|
/** Version source and files to update for every discovered package. */
|
|
70
70
|
version: PackageVersionConfig;
|
|
71
71
|
}
|
|
72
|
+
/** Inputs supplied to a dependents changelog formatter. */
|
|
73
|
+
export interface DependentsChangelogInput {
|
|
74
|
+
/** The dependent package being re-released. */
|
|
75
|
+
package: ChangesetPackage;
|
|
76
|
+
/** The released source package that the dependent bundles. */
|
|
77
|
+
source: {
|
|
78
|
+
package: ChangesetPackage;
|
|
79
|
+
/** Version of the source release. */
|
|
80
|
+
version: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Inputs supplied to a dependents dependency finder. */
|
|
84
|
+
export interface DependentsFindInput {
|
|
85
|
+
/** The dependent package. */
|
|
86
|
+
package: ChangesetPackage;
|
|
87
|
+
/** The source package. */
|
|
88
|
+
source: ChangesetPackage;
|
|
89
|
+
}
|
|
90
|
+
/** A dependency of a source that a package declares outside package.json. */
|
|
91
|
+
export interface DependentsSpec {
|
|
92
|
+
/** Manifest file that declares the dependency, relative to the dependent package. */
|
|
93
|
+
file: string;
|
|
94
|
+
/** Identifier that holds the spec, like a Composer package name. */
|
|
95
|
+
name: string;
|
|
96
|
+
/** Declared spec of the source. */
|
|
97
|
+
spec: string;
|
|
98
|
+
}
|
|
99
|
+
/** Inputs supplied to a dependents dependency update. */
|
|
100
|
+
export interface DependentsUpdateInput {
|
|
101
|
+
/** The dependent package whose declaration gets updated. */
|
|
102
|
+
package: ChangesetPackage;
|
|
103
|
+
/** The released source package. */
|
|
104
|
+
source: ChangesetPackage;
|
|
105
|
+
/** Released version of the source. */
|
|
106
|
+
version: string;
|
|
107
|
+
/** The specs to update, with their manifest files and identifiers. */
|
|
108
|
+
specs: DependentsSpec[];
|
|
109
|
+
/** Snapshot a file before writing it, so that failed versioning rolls it back. */
|
|
110
|
+
remember: (file: string) => Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/** How packages find and update their dependency of a source. */
|
|
113
|
+
export interface DependentsDependencyOptions {
|
|
114
|
+
/** Specs of the source that a package declares outside package.json, added to the built-in npm scan, so that the range rule and the rewrite apply to them the same way. */
|
|
115
|
+
find?: (input: DependentsFindInput) => Promise<DependentsSpec[]>;
|
|
116
|
+
/** Update a package's declared dependency of a released source to the released version; defaults to rewriting the found specs, keeping their range prefixes. Call `remember()` with every file path before writing it. */
|
|
117
|
+
update?: (input: DependentsUpdateInput) => Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
/** npm dependency section that the dependents of a policy scan. */
|
|
120
|
+
export type DependentsSection = 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'devDependencies';
|
|
121
|
+
/**
|
|
122
|
+
* Packages that bundle another configured package, re-released whenever that
|
|
123
|
+
* source releases, so that their shipped build includes the new version. The
|
|
124
|
+
* dependents come from the dependencies that each package declares, like the
|
|
125
|
+
* npm packages depending on another through package.json: a package is a
|
|
126
|
+
* dependent of a source when its declared spec resolves the source's current
|
|
127
|
+
* version or points at the workspace, and it releases when the next version
|
|
128
|
+
* of the source falls outside its declared range, or always with
|
|
129
|
+
* `when: 'always'`.
|
|
130
|
+
*/
|
|
131
|
+
export interface DependentsConfig {
|
|
132
|
+
/** Bump applied to dependents that release without their own changeset; defaults to patch. */
|
|
133
|
+
bump?: 'patch' | 'minor' | 'major';
|
|
134
|
+
/** Package paths, identifiers or globs never re-released as dependents; they still release with their own changesets. */
|
|
135
|
+
exclude?: string[];
|
|
136
|
+
/** Whether dependents release only when the next source version falls outside their declared ranges, or always; defaults to out-of-range. */
|
|
137
|
+
when?: 'out-of-range' | 'always';
|
|
138
|
+
/** npm dependency sections scanned for sources; defaults to dependencies and optionalDependencies, with devDependencies for dependencies that get bundled into the dependent's build. */
|
|
139
|
+
sections?: DependentsSection[];
|
|
140
|
+
/** Overrides of the built-in npm lookup and spec rewrite, for dependencies that other manifests declare. */
|
|
141
|
+
dependency?: DependentsDependencyOptions;
|
|
142
|
+
/** Changelog entry added to the dependent, for example a bundle note; defaults to "Bundle <source> <version>." */
|
|
143
|
+
changelog?: (input: DependentsChangelogInput) => string | Promise<string>;
|
|
144
|
+
}
|
|
72
145
|
/** JavaScript configuration for changeset-aware releases. */
|
|
73
146
|
export interface ChangesetConfig {
|
|
74
147
|
/** Branch receiving ordinary changes. */
|
|
@@ -91,6 +164,8 @@ export interface ChangesetConfig {
|
|
|
91
164
|
releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
|
|
92
165
|
/** Format the CHANGELOG.md entry of a changeset, for example to credit its pull request. */
|
|
93
166
|
changelogEntry?: (input: ChangelogEntryInput) => string | Promise<string>;
|
|
167
|
+
/** Groups of packages re-released whenever one of their source packages releases. */
|
|
168
|
+
dependents?: DependentsConfig;
|
|
94
169
|
}
|
|
95
170
|
/** Inputs supplied to a custom next-version strategy. */
|
|
96
171
|
export interface NextVersionInput {
|
package/dist/changeset-config.js
CHANGED
|
@@ -198,6 +198,54 @@ function validateChangesetConfig(config, configFile) {
|
|
|
198
198
|
'function' !== typeof candidate.changelogEntry) {
|
|
199
199
|
throw new ValidationError(`wp-release config ${configFile}.changelogEntry must be a function.`);
|
|
200
200
|
}
|
|
201
|
+
if (undefined !== candidate.dependents &&
|
|
202
|
+
('object' !== typeof candidate.dependents ||
|
|
203
|
+
Array.isArray(candidate.dependents) ||
|
|
204
|
+
null === candidate.dependents)) {
|
|
205
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents must be an object.`);
|
|
206
|
+
}
|
|
207
|
+
const dependentsOptions = candidate.dependents;
|
|
208
|
+
if (undefined !== dependentsOptions?.bump &&
|
|
209
|
+
!['patch', 'minor', 'major'].includes(dependentsOptions.bump)) {
|
|
210
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.bump must be patch, minor or major.`);
|
|
211
|
+
}
|
|
212
|
+
if (undefined !== dependentsOptions?.exclude &&
|
|
213
|
+
(!Array.isArray(dependentsOptions.exclude) ||
|
|
214
|
+
!dependentsOptions.exclude.length ||
|
|
215
|
+
dependentsOptions.exclude.some((item) => 'string' !== typeof item || !item.trim()))) {
|
|
216
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.exclude must be an array of non-empty strings.`);
|
|
217
|
+
}
|
|
218
|
+
if (undefined !== dependentsOptions?.when &&
|
|
219
|
+
!['out-of-range', 'always'].includes(dependentsOptions.when)) {
|
|
220
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.when must be out-of-range or always.`);
|
|
221
|
+
}
|
|
222
|
+
if (undefined !== dependentsOptions?.sections &&
|
|
223
|
+
(!Array.isArray(dependentsOptions.sections) ||
|
|
224
|
+
!dependentsOptions.sections.length ||
|
|
225
|
+
dependentsOptions.sections.some((section) => ![
|
|
226
|
+
'dependencies',
|
|
227
|
+
'optionalDependencies',
|
|
228
|
+
'peerDependencies',
|
|
229
|
+
'devDependencies',
|
|
230
|
+
].includes(section)))) {
|
|
231
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.sections must be a list of dependencies, optionalDependencies, peerDependencies or devDependencies.`);
|
|
232
|
+
}
|
|
233
|
+
if (undefined !== dependentsOptions?.changelog &&
|
|
234
|
+
'function' !== typeof dependentsOptions.changelog) {
|
|
235
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.changelog must be a function.`);
|
|
236
|
+
}
|
|
237
|
+
if (undefined !== dependentsOptions?.dependency) {
|
|
238
|
+
if ('object' !== typeof dependentsOptions.dependency ||
|
|
239
|
+
null === dependentsOptions.dependency) {
|
|
240
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.dependency must be an object.`);
|
|
241
|
+
}
|
|
242
|
+
['find', 'update'].forEach((key) => {
|
|
243
|
+
if (undefined !== dependentsOptions.dependency[key] &&
|
|
244
|
+
'function' !== typeof dependentsOptions.dependency[key]) {
|
|
245
|
+
throw new ValidationError(`wp-release config ${configFile}.dependents.dependency.${key} must be a function.`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
201
249
|
if (undefined !== candidate.releaseNotes &&
|
|
202
250
|
'function' !== typeof candidate.releaseNotes) {
|
|
203
251
|
throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);
|
|
@@ -8,6 +8,8 @@ export interface ChangesetVersionInput extends ChangesetRepository {
|
|
|
8
8
|
date?: string;
|
|
9
9
|
/** Version only these packages, leaving the other changesets pending. */
|
|
10
10
|
packages?: string[];
|
|
11
|
+
/** Whether the selected sources release their configured dependents; defaults to true. */
|
|
12
|
+
dependents?: boolean;
|
|
11
13
|
}
|
|
12
14
|
/** Versioned releases, the applied changesets and whether files were changed. */
|
|
13
15
|
export interface VersionChangesetsResult {
|
|
@@ -93,4 +95,5 @@ export declare function getChangesetStatus(input: ChangesetRepository & {
|
|
|
93
95
|
changedPackages: string[];
|
|
94
96
|
changedPackageFiles: Record<string, string[]>;
|
|
95
97
|
missingChangesets: string[];
|
|
98
|
+
warnings: string[];
|
|
96
99
|
}>;
|
|
@@ -8,7 +8,11 @@ import { findMainPluginFile } from "./package-meta.js";
|
|
|
8
8
|
import { findConstantValues } from "./version-check.js";
|
|
9
9
|
import { formatChangelogList, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
|
|
10
10
|
import { ValidationError } from "./errors.js";
|
|
11
|
+
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
12
|
+
import validRange from 'semver/ranges/valid.js';
|
|
11
13
|
const execFileAsync = promisify(execFile);
|
|
14
|
+
/** npm dependency sections whose declared dependency re-releases the dependent. */
|
|
15
|
+
const DEFAULT_SECTIONS = ['dependencies', 'optionalDependencies'];
|
|
12
16
|
/**
|
|
13
17
|
* Discover configured packages, pending changesets, and their proposed versions.
|
|
14
18
|
*
|
|
@@ -71,8 +75,238 @@ async function getReleaseContext(input) {
|
|
|
71
75
|
}
|
|
72
76
|
});
|
|
73
77
|
});
|
|
78
|
+
await applyDependents(releases, packages, config);
|
|
74
79
|
return { config, packages, plan: { changesets, releases } };
|
|
75
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Add releases for dependents of packages that release, applying the
|
|
83
|
+
* configured dependents policy until the plan settles.
|
|
84
|
+
*
|
|
85
|
+
* @param releases Planned releases, mutated in place.
|
|
86
|
+
* @param packages Discovered packages.
|
|
87
|
+
* @param config Repository release configuration.
|
|
88
|
+
*/
|
|
89
|
+
async function applyDependents(releases, packages, config) {
|
|
90
|
+
const rule = config.dependents;
|
|
91
|
+
if (!rule) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Dependents may themselves be dependents of other sources, so expand until stable.
|
|
95
|
+
let grew = true;
|
|
96
|
+
while (grew) {
|
|
97
|
+
grew = false;
|
|
98
|
+
for (const source of [...releases]) {
|
|
99
|
+
for (const dependent of packages) {
|
|
100
|
+
if (await deriveDependentRelease(source, dependent, releases, rule)) {
|
|
101
|
+
grew = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Plan the release of a package that bundles a source package, or add the
|
|
109
|
+
* source to the bundle list of its own pending release.
|
|
110
|
+
*
|
|
111
|
+
* @param source Package release of the source.
|
|
112
|
+
* @param dependent Package that would bundle the source.
|
|
113
|
+
* @param releases Planned releases, mutated in place.
|
|
114
|
+
* @param rule Dependents policy.
|
|
115
|
+
* @return Whether a new dependent release was planned.
|
|
116
|
+
*/
|
|
117
|
+
async function deriveDependentRelease(source, dependent, releases, rule) {
|
|
118
|
+
if (dependent.name === source.package.name ||
|
|
119
|
+
isExcludedDependent(dependent, rule)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const specs = await findDependencySpecs(dependent, source.package, rule);
|
|
123
|
+
if (!specs.length || !dependentNeedsRelease(specs, source, rule)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const release = releases.find((item) => item.package.name === dependent.name);
|
|
127
|
+
if (release) {
|
|
128
|
+
const sources = release.sources ?? [];
|
|
129
|
+
if (!sources.includes(source.package.name)) {
|
|
130
|
+
// A dependent that releases for its own reasons still notes the bundle.
|
|
131
|
+
release.sources = [...sources, source.package.name];
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const bump = rule.bump ?? 'patch';
|
|
136
|
+
releases.push({
|
|
137
|
+
package: dependent,
|
|
138
|
+
bump,
|
|
139
|
+
currentVersion: dependent.currentVersion,
|
|
140
|
+
nextVersion: getNextVersion(dependent.currentVersion, bump),
|
|
141
|
+
changesets: [],
|
|
142
|
+
sources: [source.package.name],
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Find the specs through which a package declares a dependency on another.
|
|
148
|
+
*
|
|
149
|
+
* A spec is a dependency of the source that the current version resolves,
|
|
150
|
+
* like a range that covers it or a workspace spec, or one that the
|
|
151
|
+
* configured hook reports. A pin like an older version of the source
|
|
152
|
+
* selects a registry release instead, which keeps the local code out of
|
|
153
|
+
* the dependent's build.
|
|
154
|
+
*
|
|
155
|
+
* @param dependent Package potentially declaring the dependency.
|
|
156
|
+
* @param source Released source package.
|
|
157
|
+
* @param rule Dependents policy, with its dependency hook and sections.
|
|
158
|
+
* @return The declared specs, with their manifest files and package names.
|
|
159
|
+
*/
|
|
160
|
+
async function findDependencySpecs(dependent, source, rule) {
|
|
161
|
+
const sourceManifest = await readJsonSafe(path.join(source.directory, 'package.json'));
|
|
162
|
+
if ('string' !== typeof sourceManifest?.name) {
|
|
163
|
+
return rule?.dependency?.find
|
|
164
|
+
? findDependencyHookSpecs(dependent, source, rule)
|
|
165
|
+
: [];
|
|
166
|
+
}
|
|
167
|
+
const identifier = sourceManifest.name;
|
|
168
|
+
const specs = [];
|
|
169
|
+
const parsed = await readJsonSafe(path.join(dependent.directory, 'package.json'));
|
|
170
|
+
for (const section of dependentsSections(rule)) {
|
|
171
|
+
const sectionSpecs = parsed?.[section];
|
|
172
|
+
const declared = 'object' === typeof sectionSpecs &&
|
|
173
|
+
null !== sectionSpecs &&
|
|
174
|
+
Object.hasOwn(sectionSpecs, identifier)
|
|
175
|
+
? String(sectionSpecs[identifier])
|
|
176
|
+
: undefined;
|
|
177
|
+
if (undefined !== declared &&
|
|
178
|
+
isDependencyOfSource(declared, source.currentVersion)) {
|
|
179
|
+
specs.push({
|
|
180
|
+
file: path.join(dependent.directory, 'package.json'),
|
|
181
|
+
name: identifier,
|
|
182
|
+
spec: declared,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (rule?.dependency?.find) {
|
|
187
|
+
specs.push(...(await findDependencyHookSpecs(dependent, source, rule)));
|
|
188
|
+
}
|
|
189
|
+
return specs;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Find the specs that the configured dependency hook reports.
|
|
193
|
+
*
|
|
194
|
+
* @param dependent Package potentially declaring the dependency.
|
|
195
|
+
* @param source Released source package.
|
|
196
|
+
* @param rule Dependents policy.
|
|
197
|
+
* @return The hook's specs, with absolute manifest paths and package names.
|
|
198
|
+
*/
|
|
199
|
+
async function findDependencyHookSpecs(dependent, source, rule) {
|
|
200
|
+
const found = await rule.dependency?.find?.({
|
|
201
|
+
package: dependent,
|
|
202
|
+
source,
|
|
203
|
+
});
|
|
204
|
+
return (found ?? []).map((entry) => ({
|
|
205
|
+
file: path.resolve(dependent.directory, entry.file),
|
|
206
|
+
name: entry.name,
|
|
207
|
+
spec: entry.spec,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Check whether a spec points at the workspace copy of a package, by
|
|
212
|
+
* resolving the current version through its range or its workspace protocol.
|
|
213
|
+
*
|
|
214
|
+
* @param spec Declared spec of the source.
|
|
215
|
+
* @param currentVersion Current version of the source package.
|
|
216
|
+
* @return Whether the spec is a dependency of the source.
|
|
217
|
+
*/
|
|
218
|
+
function isDependencyOfSource(spec, currentVersion) {
|
|
219
|
+
if (spec.startsWith('workspace:')) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const range = effectiveRange(spec, currentVersion);
|
|
223
|
+
return (validRange(range) !== null && semverSatisfies(currentVersion, range));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The dependency sections that a dependents policy scans.
|
|
227
|
+
*
|
|
228
|
+
* @param rule Dependents policy.
|
|
229
|
+
* @return Dependency section names.
|
|
230
|
+
*/
|
|
231
|
+
function dependentsSections(rule) {
|
|
232
|
+
return (rule?.sections ?? DEFAULT_SECTIONS);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Check whether a dependent has to release when its declared source releases.
|
|
236
|
+
*
|
|
237
|
+
* @param specs Declared specs of the source in the dependent.
|
|
238
|
+
* @param source Released source package.
|
|
239
|
+
* @param rule Dependents policy.
|
|
240
|
+
* @return Whether the dependent needs a release.
|
|
241
|
+
*/
|
|
242
|
+
function dependentNeedsRelease(specs, source, rule) {
|
|
243
|
+
if ('always' === rule.when) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
// Without a range that accepts the next version, like an exact pin, the
|
|
247
|
+
// dependent has to release and rewrite its specs.
|
|
248
|
+
return specs.some(({ spec }) => isOutOfRange(source.nextVersion, spec, source.currentVersion));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Check whether a version falls outside a declared range.
|
|
252
|
+
*
|
|
253
|
+
* @param nextVersion Next version of the source package.
|
|
254
|
+
* @param spec Declared spec of the source).
|
|
255
|
+
* @param currentVersion Current version of the source package.
|
|
256
|
+
* @return Whether the next version falls outside the range.
|
|
257
|
+
*/
|
|
258
|
+
function isOutOfRange(nextVersion, spec, currentVersion) {
|
|
259
|
+
const range = effectiveRange(spec, currentVersion);
|
|
260
|
+
if (validRange(range) === null) {
|
|
261
|
+
// Unrecognized ranges of dependencies like git URLs are left to the consumer.
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
return !semverSatisfies(nextVersion, range);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Resolve a dependency spec to the range it selects for an internal package.
|
|
268
|
+
*
|
|
269
|
+
* @param spec Declared spec.
|
|
270
|
+
* @param currentVersion Current version of the source package.
|
|
271
|
+
* @return The effective semver range.
|
|
272
|
+
*/
|
|
273
|
+
function effectiveRange(spec, currentVersion) {
|
|
274
|
+
if (!spec.startsWith('workspace:')) {
|
|
275
|
+
return spec;
|
|
276
|
+
}
|
|
277
|
+
// Workspace specs resolve to the exact published version, and to a
|
|
278
|
+
// prefixed one with their operator.
|
|
279
|
+
const bare = spec.slice('workspace:'.length);
|
|
280
|
+
if ('*' === bare) {
|
|
281
|
+
return currentVersion;
|
|
282
|
+
}
|
|
283
|
+
if ('^' === bare || '~' === bare) {
|
|
284
|
+
return `${bare}${currentVersion}`;
|
|
285
|
+
}
|
|
286
|
+
return bare;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Check whether the dependents policy excludes a package.
|
|
290
|
+
*
|
|
291
|
+
* @param dependent Candidate dependent package.
|
|
292
|
+
* @param rule Dependents policy.
|
|
293
|
+
* @return Whether the package is excluded from re-releases.
|
|
294
|
+
*/
|
|
295
|
+
function isExcludedDependent(dependent, rule) {
|
|
296
|
+
return (rule.exclude ?? []).some((pattern) => pattern === dependent.name ||
|
|
297
|
+
globRegExp(pattern).test(dependent.path));
|
|
298
|
+
}
|
|
299
|
+
async function readJsonSafe(file) {
|
|
300
|
+
try {
|
|
301
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if ('ENOENT' !== error.code) {
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
76
310
|
/**
|
|
77
311
|
* Create a Changesets-compatible Markdown file.
|
|
78
312
|
*
|
|
@@ -123,9 +357,21 @@ export async function versionChangesets(input) {
|
|
|
123
357
|
throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
|
|
124
358
|
}
|
|
125
359
|
const { config, packages } = context;
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
360
|
+
const withDependents = input.dependents ?? true;
|
|
361
|
+
let releasePlan = context.plan;
|
|
362
|
+
if (input.packages) {
|
|
363
|
+
releasePlan = expandDependentsPlan(await selectReleasePlan(context.plan, packages, input.packages, config, withDependents));
|
|
364
|
+
}
|
|
365
|
+
else if (!withDependents) {
|
|
366
|
+
releasePlan = stripDependentsPlan(context.plan);
|
|
367
|
+
}
|
|
368
|
+
// A dependent must not release while one of the packages it depends on has
|
|
369
|
+
// pending changesets that aren't released in the same run, or it would
|
|
370
|
+
// bundle unreleased code under a changelog that cannot credit it. The
|
|
371
|
+
// check only applies to repositories that adopted the dependents policy.
|
|
372
|
+
if (withDependents && config.dependents) {
|
|
373
|
+
await assertDependentsSourcesReleased(releasePlan, context.plan, packages, config);
|
|
374
|
+
}
|
|
129
375
|
const date = input.date ?? new Date().toISOString().slice(0, 10);
|
|
130
376
|
const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
|
|
131
377
|
const releases = await getVersionedReleases(input.cwd, config, releasePlan.releases, changesetDirectory);
|
|
@@ -140,6 +386,19 @@ export async function versionChangesets(input) {
|
|
|
140
386
|
for (const target of configured.versionConfig.to) {
|
|
141
387
|
await writeVersion(configured.directory, target, release.nextVersion, journal);
|
|
142
388
|
}
|
|
389
|
+
for (const sourceName of release.sources ?? []) {
|
|
390
|
+
const source = releasePlan.releases.find((item) => item.package.name === sourceName);
|
|
391
|
+
if (!source) {
|
|
392
|
+
throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
|
|
393
|
+
}
|
|
394
|
+
await updateDependencyDeclaration({
|
|
395
|
+
dependent: release.package,
|
|
396
|
+
source: source.package,
|
|
397
|
+
version: source.nextVersion,
|
|
398
|
+
rule: config.dependents,
|
|
399
|
+
journal,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
143
402
|
await verifyVersionTargets(input.cwd, configured, release.nextVersion);
|
|
144
403
|
await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
|
|
145
404
|
}
|
|
@@ -159,9 +418,183 @@ export async function versionChangesets(input) {
|
|
|
159
418
|
changesets: releasePlan.changesets,
|
|
160
419
|
releases,
|
|
161
420
|
date,
|
|
162
|
-
changed: !input.dryRun &&
|
|
421
|
+
changed: !input.dryRun &&
|
|
422
|
+
(releasePlan.changesets.length > 0 ||
|
|
423
|
+
releasePlan.releases.length > 0),
|
|
163
424
|
};
|
|
164
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Drop bundle sources that aren't released by the selected plan.
|
|
428
|
+
*
|
|
429
|
+
* @param plan Selected release plan.
|
|
430
|
+
* @return Release plan with dependents bundling only its released sources.
|
|
431
|
+
*/
|
|
432
|
+
function expandDependentsPlan(plan) {
|
|
433
|
+
const released = new Set(plan.releases.map((release) => release.package.name));
|
|
434
|
+
plan.releases.forEach((release) => {
|
|
435
|
+
if (release.sources) {
|
|
436
|
+
release.sources = release.sources.filter((name) => released.has(name));
|
|
437
|
+
if (!release.sources.length) {
|
|
438
|
+
delete release.sources;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return plan;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Remove the derived dependent releases of a plan, keeping only the releases
|
|
446
|
+
* of packages that have their own changesets.
|
|
447
|
+
*
|
|
448
|
+
* @param plan Release plan of all pending changesets.
|
|
449
|
+
* @return Release plan without derived dependent releases.
|
|
450
|
+
*/
|
|
451
|
+
function stripDependentsPlan(plan) {
|
|
452
|
+
return {
|
|
453
|
+
...plan,
|
|
454
|
+
releases: plan.releases
|
|
455
|
+
.filter((release) => release.changesets.length > 0)
|
|
456
|
+
.map(({ sources, ...release }) => release),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Fail when a planned release bundles a source package that has pending
|
|
461
|
+
* changesets released by a later run, so that no dependent ships unreleased
|
|
462
|
+
* code under a changelog that cannot credit it.
|
|
463
|
+
*
|
|
464
|
+
* @param plan Planned releases.
|
|
465
|
+
* @param fullPlan Release plan of all pending changesets.
|
|
466
|
+
* @param packages Discovered packages.
|
|
467
|
+
* @param config Repository release configuration.
|
|
468
|
+
* @throws {ValidationError} If a source of a planned release stays pending.
|
|
469
|
+
*/
|
|
470
|
+
async function assertDependentsSourcesReleased(plan, fullPlan, packages, config) {
|
|
471
|
+
const releasing = new Set(plan.releases.map((release) => release.package.name));
|
|
472
|
+
const pending = new Set(fullPlan.releases.map((release) => release.package.name));
|
|
473
|
+
for (const release of plan.releases) {
|
|
474
|
+
// Dependencies built into the dependent, like bundled or vendored
|
|
475
|
+
// directories, stay out of the check, which only guards released
|
|
476
|
+
// packages that the dependent declares.
|
|
477
|
+
const names = await collectDependencyNames(release.package, packages, config);
|
|
478
|
+
for (const name of names) {
|
|
479
|
+
const blocked = pending.has(name) &&
|
|
480
|
+
!releasing.has(name) &&
|
|
481
|
+
name !== release.package.name;
|
|
482
|
+
if (blocked) {
|
|
483
|
+
throw new ValidationError(`Cannot release package "${release.package.name}" while its source "${name}" has pending changesets; release them together or pass --no-dependents.`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Collect the names of the configured sources that a package declares, by the
|
|
490
|
+
* dependency sections of the policy, without resolving the specs against the
|
|
491
|
+
* versions. The changeset check uses this to see through a pin that the
|
|
492
|
+
* versioning will rewrite after the source releases.
|
|
493
|
+
*
|
|
494
|
+
* @param pkg Planned package.
|
|
495
|
+
* @param packages Discovered packages.
|
|
496
|
+
* @param config Repository release configuration.
|
|
497
|
+
* @return Release package names the package declares.
|
|
498
|
+
*/
|
|
499
|
+
async function collectDeclarationNames(pkg, packages, config) {
|
|
500
|
+
const names = [];
|
|
501
|
+
for (const source of packages) {
|
|
502
|
+
if (pkg.name !== source.name &&
|
|
503
|
+
(await findNpmSpecs(pkg, source, dependentsSections(config.dependents))).length > 0) {
|
|
504
|
+
names.push(source.name);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return names;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Collect the names of the configured sources that a package builds against,
|
|
511
|
+
* through specs that resolve the current version or point at the workspace.
|
|
512
|
+
*
|
|
513
|
+
* @param pkg Planned package.
|
|
514
|
+
* @param packages Discovered packages.
|
|
515
|
+
* @param config Repository release configuration.
|
|
516
|
+
* @return Release package names the package declares.
|
|
517
|
+
*/
|
|
518
|
+
async function collectDependencyNames(pkg, packages, config) {
|
|
519
|
+
const names = [];
|
|
520
|
+
for (const source of packages) {
|
|
521
|
+
if (pkg.name !== source.name &&
|
|
522
|
+
(await findDependencySpecs(pkg, source, config.dependents))
|
|
523
|
+
.length > 0) {
|
|
524
|
+
names.push(source.name);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return names;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Update how a dependent declares its dependency on a released source, with
|
|
531
|
+
* the configured hook or the built-in spec rewrite.
|
|
532
|
+
*
|
|
533
|
+
* @param input Dependent, source, released version, policy and journal.
|
|
534
|
+
* @param input.dependent The released dependent package.
|
|
535
|
+
* @param input.source Released source package that the dependent bundles.
|
|
536
|
+
* @param input.version Released version of the source.
|
|
537
|
+
* @param input.rule Dependents policy with its sections and dependency hooks.
|
|
538
|
+
* @param input.journal File journal for rolling back.
|
|
539
|
+
*/
|
|
540
|
+
async function updateDependencyDeclaration(input) {
|
|
541
|
+
const { dependent, source, version, rule, journal } = input;
|
|
542
|
+
const specs = await findDependencySpecs(dependent, source, rule);
|
|
543
|
+
const update = rule?.dependency?.update;
|
|
544
|
+
if (update) {
|
|
545
|
+
await update({
|
|
546
|
+
package: dependent,
|
|
547
|
+
source,
|
|
548
|
+
version,
|
|
549
|
+
specs,
|
|
550
|
+
remember: (file) => rememberFile(journal, file),
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
for (const { file, name, spec } of specs) {
|
|
555
|
+
const nextSpec = updatedSpec(spec, version);
|
|
556
|
+
if (null === nextSpec &&
|
|
557
|
+
isOutOfRange(version, spec, source.currentVersion)) {
|
|
558
|
+
throw new ValidationError(`Cannot rewrite the dependency spec "${spec}" of "${name}" in ${dependent.name}'s ${file}; declare a range or pin that versioning rewrites, like "^${version}".`);
|
|
559
|
+
}
|
|
560
|
+
const changed = undefined !== nextSpec && nextSpec !== spec;
|
|
561
|
+
if (changed) {
|
|
562
|
+
const contents = await readFile(file, 'utf8');
|
|
563
|
+
const declared = new RegExp(`"${escapeRegExp(name)}"\\s*:\\s*"${escapeRegExp(spec)}"`);
|
|
564
|
+
if (!declared.test(contents)) {
|
|
565
|
+
throw new ValidationError(`The dependency spec of "${name}" in ${dependent.name}'s ${file} doesn't match "${spec}"; rewrite it to "${nextSpec}".`);
|
|
566
|
+
}
|
|
567
|
+
await rememberFile(journal, file);
|
|
568
|
+
await writeFile(file, contents.replace(declared, `"${name}": "${nextSpec}"`));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Update a dependency spec to the released version, keeping its shape, like
|
|
574
|
+
* the caret of ^0.9.0 or the x of 0.9.x.
|
|
575
|
+
*
|
|
576
|
+
* @param spec Declared spec of the source.
|
|
577
|
+
* @param version Released version of the source.
|
|
578
|
+
* @return The updated spec, undefined when nothing has to change, like a wildcard or a workspace spec, or null when the spec can't be rewritten.
|
|
579
|
+
*/
|
|
580
|
+
function updatedSpec(spec, version) {
|
|
581
|
+
if (spec.startsWith('workspace:')) {
|
|
582
|
+
// Workspace specs resolve to the local package, so the manifest
|
|
583
|
+
// keeps them instead of a published version.
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
const prefix = /^[=\^~]/.exec(spec)?.[0] ?? '';
|
|
587
|
+
const bare = spec.slice(prefix.length).trim();
|
|
588
|
+
const partial = /^(v?\d+(?:\.\d+)*)(\.x)?$/.exec(bare);
|
|
589
|
+
if (partial?.[1]) {
|
|
590
|
+
const depth = partial[1].split('.').length;
|
|
591
|
+
const stem = version.split('.').slice(0, depth).join('.');
|
|
592
|
+
// npm partials like 1.x pin the major, and 0.9 pins the major.minor.
|
|
593
|
+
return `${prefix}${stem}${partial[2] ?? ''}`;
|
|
594
|
+
}
|
|
595
|
+
// Wildcards and keyword specs like * or latest already accept the release.
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
165
598
|
/**
|
|
166
599
|
* Format the changelog entries of each release, with the configured
|
|
167
600
|
* formatter and the commit that added each changeset.
|
|
@@ -180,15 +613,47 @@ async function getVersionedReleases(cwd, config, releases, changesetDirectory) {
|
|
|
180
613
|
commits.set(changeset.fileName, cached);
|
|
181
614
|
return cached;
|
|
182
615
|
};
|
|
616
|
+
const releasesByName = new Map(releases.map((release) => [release.package.name, release]));
|
|
183
617
|
return Promise.all(releases.map(async (release) => {
|
|
184
618
|
const entries = [];
|
|
185
619
|
// Format one at a time, so that formatters calling an API aren't rate limited.
|
|
186
620
|
for (const changeset of release.changesets) {
|
|
187
621
|
entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
|
|
188
622
|
}
|
|
623
|
+
for (const sourceName of release.sources ?? []) {
|
|
624
|
+
const source = releasesByName.get(sourceName);
|
|
625
|
+
if (!source) {
|
|
626
|
+
throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
|
|
627
|
+
}
|
|
628
|
+
entries.push(await getDependentsChangelogEntry(config, release.package, source));
|
|
629
|
+
}
|
|
189
630
|
return { ...release, changelog: formatChangelogList(entries) };
|
|
190
631
|
}));
|
|
191
632
|
}
|
|
633
|
+
/**
|
|
634
|
+
* Format the bundle changelog entry of a dependent release.
|
|
635
|
+
*
|
|
636
|
+
* @param config Repository release configuration.
|
|
637
|
+
* @param pkg Dependent package being re-released.
|
|
638
|
+
* @param source Released source package bundled by the dependent.
|
|
639
|
+
* @return Changelog entry.
|
|
640
|
+
*/
|
|
641
|
+
async function getDependentsChangelogEntry(config, pkg, source) {
|
|
642
|
+
if (!config.dependents?.changelog) {
|
|
643
|
+
return `Bundle ${source.package.name.split('/').pop()} ${source.nextVersion}.`;
|
|
644
|
+
}
|
|
645
|
+
const entry = await config.dependents.changelog({
|
|
646
|
+
package: pkg,
|
|
647
|
+
source: {
|
|
648
|
+
package: source.package,
|
|
649
|
+
version: source.nextVersion,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
if ('string' !== typeof entry) {
|
|
653
|
+
throw new ValidationError(`The dependents changelog formatter didn't return a string for package "${pkg.name}".`);
|
|
654
|
+
}
|
|
655
|
+
return entry.trim();
|
|
656
|
+
}
|
|
192
657
|
async function getChangelogEntry(config, pkg, changeset, getCommit) {
|
|
193
658
|
const summary = changeset.summary.trim();
|
|
194
659
|
if (!summary || !config.changelogEntry) {
|
|
@@ -226,24 +691,74 @@ async function getChangesetCommit(cwd, file) {
|
|
|
226
691
|
.split('\x1f');
|
|
227
692
|
return hash ? { hash, shortHash, subject } : undefined;
|
|
228
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* Add a package to the selected release plan when it bundles a selected source.
|
|
696
|
+
*
|
|
697
|
+
* @param source Selected package release.
|
|
698
|
+
* @param dependent Package that would bundle the source.
|
|
699
|
+
* @param packages Discovered packages.
|
|
700
|
+
* @param rule Dependents policy.
|
|
701
|
+
* @param selected Selected package names, mutated in place.
|
|
702
|
+
* @return Whether the package was selected.
|
|
703
|
+
*/
|
|
704
|
+
async function selectDependentRelease(source, dependent, packages, rule, selected) {
|
|
705
|
+
const skip = selected.has(dependent.name) ||
|
|
706
|
+
dependent.name === source.package.name ||
|
|
707
|
+
isExcludedDependent(dependent, rule);
|
|
708
|
+
if (skip) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const specs = await findDependencySpecs(dependent, source.package, rule);
|
|
712
|
+
if (!specs.length || !dependentNeedsRelease(specs, source, rule)) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
selected.add(dependent.name);
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
229
718
|
/**
|
|
230
719
|
* Limit a release plan to some packages and the changesets that name them.
|
|
231
720
|
*
|
|
232
|
-
* @param plan
|
|
233
|
-
* @param packages
|
|
234
|
-
* @param names
|
|
235
|
-
* @
|
|
721
|
+
* @param plan Release plan of all pending changesets.
|
|
722
|
+
* @param packages Configured packages.
|
|
723
|
+
* @param names Packages to version.
|
|
724
|
+
* @param config Repository release configuration.
|
|
725
|
+
* @param withDependents Whether the selection also releases the dependents of the selected sources.
|
|
726
|
+
* @return Release plan of the selected packages and their dependents.
|
|
236
727
|
* @throws {ValidationError} For unknown packages.
|
|
237
728
|
*/
|
|
238
|
-
function selectReleasePlan(plan, packages, names) {
|
|
729
|
+
async function selectReleasePlan(plan, packages, names, config, withDependents = true) {
|
|
239
730
|
names.forEach((name) => {
|
|
240
731
|
if (!packages.some((pkg) => pkg.name === name)) {
|
|
241
732
|
throw new ValidationError(`Unknown package "${name}".`);
|
|
242
733
|
}
|
|
243
734
|
});
|
|
735
|
+
// Dependents that need a release close over the selected sources.
|
|
736
|
+
const selected = new Set(names);
|
|
737
|
+
let grew = true;
|
|
738
|
+
// Dependents may depend on other selected dependents, so expand until stable.
|
|
739
|
+
while (grew && withDependents && config.dependents) {
|
|
740
|
+
grew = false;
|
|
741
|
+
const selectedSources = plan.releases.filter((release) => selected.has(release.package.name));
|
|
742
|
+
for (const source of selectedSources) {
|
|
743
|
+
for (const dependent of packages) {
|
|
744
|
+
if (await selectDependentRelease(source, dependent, packages, config.dependents, selected)) {
|
|
745
|
+
grew = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const selectedNames = [...selected];
|
|
244
751
|
return {
|
|
245
|
-
releases: plan.releases.filter((release) =>
|
|
246
|
-
|
|
752
|
+
releases: plan.releases.filter((release) => {
|
|
753
|
+
if (!selected.has(release.package.name)) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
// Explicitly selected dependents without their own changesets only
|
|
757
|
+
// release when their source releases in the same selection.
|
|
758
|
+
return (release.changesets.length > 0 ||
|
|
759
|
+
(release.sources ?? []).some((name) => selected.has(name)));
|
|
760
|
+
}),
|
|
761
|
+
changesets: plan.changesets.filter((changeset) => selectedNames.some((name) => Object.hasOwn(changeset.packages, name))),
|
|
247
762
|
};
|
|
248
763
|
}
|
|
249
764
|
/**
|
|
@@ -510,6 +1025,8 @@ export async function getChangesetStatus(input) {
|
|
|
510
1025
|
}
|
|
511
1026
|
const { plan } = context;
|
|
512
1027
|
const { changesets, releases } = plan;
|
|
1028
|
+
const config = context.config;
|
|
1029
|
+
const { packages } = context;
|
|
513
1030
|
const since = input.since ??
|
|
514
1031
|
(context.config.baseBranch
|
|
515
1032
|
? `origin/${context.config.baseBranch}`
|
|
@@ -522,17 +1039,102 @@ export async function getChangesetStatus(input) {
|
|
|
522
1039
|
? await readChangesetsSince(input.cwd, context.config, changesets, since)
|
|
523
1040
|
: [];
|
|
524
1041
|
const hasEmptyChangeset = changesetsInDiff.some((changeset) => 0 === Object.keys(changeset.packages).length);
|
|
1042
|
+
// A dependent releases with its sources, which the release pull request
|
|
1043
|
+
// already announces when this pull request's changesets release one of
|
|
1044
|
+
// them. Exempting its package manifest pin change keeps the check from
|
|
1045
|
+
// demanding an entry for what versioning rewrites anyway; documentation
|
|
1046
|
+
// and code changes still need their own changesets.
|
|
1047
|
+
const bundled = new Set((await Promise.all(changedPackages
|
|
1048
|
+
.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)))
|
|
1049
|
+
.map(async (name) => {
|
|
1050
|
+
if (!config.dependents ||
|
|
1051
|
+
!(changed.filesByPackage[name] ?? []).every((file) => path.basename(file) === 'package.json')) {
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}
|
|
1054
|
+
const configured = packages.find((pkg) => pkg.name === name);
|
|
1055
|
+
if (!configured) {
|
|
1056
|
+
return undefined;
|
|
1057
|
+
}
|
|
1058
|
+
const sources = await collectDeclarationNames(configured, packages, config);
|
|
1059
|
+
return changesetsInDiff.some((item) => sources.some((source) => Object.hasOwn(item.packages, source)))
|
|
1060
|
+
? name
|
|
1061
|
+
: undefined;
|
|
1062
|
+
}))).filter((name) => undefined !== name));
|
|
525
1063
|
const missingChangesets = hasEmptyChangeset
|
|
526
1064
|
? []
|
|
527
|
-
: changedPackages.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)));
|
|
1065
|
+
: changedPackages.filter((name) => !changesetsInDiff.some((item) => Object.hasOwn(item.packages, name)) && !bundled.has(name));
|
|
1066
|
+
// A pin that doesn't resolve the local package selects a registry release
|
|
1067
|
+
// instead, which the dependents policy won't re-release automatically.
|
|
1068
|
+
const warnings = config.dependents
|
|
1069
|
+
? await collectPinDriftWarnings(context.config, context.packages)
|
|
1070
|
+
: [];
|
|
528
1071
|
return {
|
|
529
1072
|
changesets,
|
|
530
1073
|
releases,
|
|
531
1074
|
changedPackages,
|
|
532
1075
|
changedPackageFiles: changed.filesByPackage,
|
|
533
1076
|
missingChangesets,
|
|
1077
|
+
warnings,
|
|
534
1078
|
};
|
|
535
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Warn about declared pins of configured packages that don't resolve the
|
|
1082
|
+
* current version of the package they name.
|
|
1083
|
+
*
|
|
1084
|
+
* @param config Repository release configuration.
|
|
1085
|
+
* @param packages Discovered packages.
|
|
1086
|
+
* @return Warnings, one per package pair.
|
|
1087
|
+
*/
|
|
1088
|
+
async function collectPinDriftWarnings(config, packages) {
|
|
1089
|
+
const warnings = [];
|
|
1090
|
+
for (const dependent of packages) {
|
|
1091
|
+
const others = packages.filter((source) => source.name !== dependent.name);
|
|
1092
|
+
for (const source of others) {
|
|
1093
|
+
const driftedPin = (await findNpmSpecs(dependent, source, config.dependents?.sections ?? DEFAULT_SECTIONS))
|
|
1094
|
+
.filter(({ spec }) => !spec.startsWith('workspace:') &&
|
|
1095
|
+
isOutOfRange(source.currentVersion, spec, source.currentVersion))
|
|
1096
|
+
.at(0);
|
|
1097
|
+
if (driftedPin) {
|
|
1098
|
+
warnings.push(`"${dependent.name}" pins "${driftedPin.name}@${driftedPin.spec}", but the package is at ${source.currentVersion}; the dependents policy won't re-release it until the pin resolves the package.`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return warnings;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Find the npm specs that a package declares for a source, without the
|
|
1106
|
+
* dependency filtering of dependents links, for drift warnings.
|
|
1107
|
+
*
|
|
1108
|
+
* @param dependent Package potentially declaring the dependency.
|
|
1109
|
+
* @param source Package the dependency is declared of.
|
|
1110
|
+
* @param sections Dependency sections to scan.
|
|
1111
|
+
* @return The declared specs.
|
|
1112
|
+
*/
|
|
1113
|
+
async function findNpmSpecs(dependent, source, sections) {
|
|
1114
|
+
const sourceManifest = await readJsonSafe(path.join(source.directory, 'package.json'));
|
|
1115
|
+
if ('string' !== typeof sourceManifest?.name) {
|
|
1116
|
+
return [];
|
|
1117
|
+
}
|
|
1118
|
+
const identifier = sourceManifest.name;
|
|
1119
|
+
const parsed = await readJsonSafe(path.join(dependent.directory, 'package.json'));
|
|
1120
|
+
const specs = [];
|
|
1121
|
+
for (const section of sections) {
|
|
1122
|
+
const sectionSpecs = parsed?.[section];
|
|
1123
|
+
const declared = 'object' === typeof sectionSpecs &&
|
|
1124
|
+
null !== sectionSpecs &&
|
|
1125
|
+
Object.hasOwn(sectionSpecs, identifier)
|
|
1126
|
+
? String(sectionSpecs[identifier])
|
|
1127
|
+
: undefined;
|
|
1128
|
+
if (undefined !== declared) {
|
|
1129
|
+
specs.push({
|
|
1130
|
+
file: path.join(dependent.directory, 'package.json'),
|
|
1131
|
+
name: identifier,
|
|
1132
|
+
spec: declared,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return specs;
|
|
1137
|
+
}
|
|
536
1138
|
async function requireConfig(input) {
|
|
537
1139
|
const loaded = await loadChangesetConfig(input);
|
|
538
1140
|
if (!loaded) {
|
package/dist/changesets.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface PackageRelease {
|
|
|
31
31
|
currentVersion: string;
|
|
32
32
|
nextVersion: string;
|
|
33
33
|
changesets: Changeset[];
|
|
34
|
+
/** Names of released source packages that this dependent release bundles; absent for changeset-driven releases. */
|
|
35
|
+
sources?: string[];
|
|
34
36
|
}
|
|
35
37
|
/** A package release with its formatted changelog entries, once versioned. */
|
|
36
38
|
export interface VersionedRelease extends PackageRelease {
|
|
@@ -65,12 +65,17 @@ export declare const changesetVersionArgs: {
|
|
|
65
65
|
readonly type: "string";
|
|
66
66
|
readonly multiple: true;
|
|
67
67
|
};
|
|
68
|
+
readonly 'no-dependents': {
|
|
69
|
+
readonly type: "boolean";
|
|
70
|
+
};
|
|
68
71
|
};
|
|
69
72
|
/** Parsed input for `changeset-version`. */
|
|
70
73
|
export interface ChangesetVersionArgs {
|
|
71
74
|
dryRun: boolean;
|
|
72
75
|
date?: string;
|
|
73
76
|
packages?: string[];
|
|
77
|
+
/** Whether the selected sources release their configured dependents; defaults to true. */
|
|
78
|
+
dependents: boolean;
|
|
74
79
|
}
|
|
75
80
|
/**
|
|
76
81
|
* Validate and normalize a release date option.
|
|
@@ -75,6 +75,7 @@ export const changesetVersionArgs = {
|
|
|
75
75
|
'dry-run': { type: 'boolean' },
|
|
76
76
|
date: { type: 'string' },
|
|
77
77
|
package: { type: 'string', multiple: true },
|
|
78
|
+
'no-dependents': { type: 'boolean' },
|
|
78
79
|
};
|
|
79
80
|
/**
|
|
80
81
|
* Validate and normalize a release date option.
|
|
@@ -111,6 +112,7 @@ export function parseChangesetVersionArgs(values) {
|
|
|
111
112
|
}
|
|
112
113
|
return {
|
|
113
114
|
dryRun: getBoolean(values, 'dry-run'),
|
|
115
|
+
dependents: !getBoolean(values, 'no-dependents'),
|
|
114
116
|
...(date ? { date } : {}),
|
|
115
117
|
...(packages.length ? { packages } : {}),
|
|
116
118
|
};
|
|
@@ -13,6 +13,7 @@ export interface ChangesetStatusResult {
|
|
|
13
13
|
changedPackages: string[];
|
|
14
14
|
changedPackageFiles: Record<string, string[]>;
|
|
15
15
|
missingChangesets: string[];
|
|
16
|
+
warnings: string[];
|
|
16
17
|
}
|
|
17
18
|
declare const _default: {
|
|
18
19
|
name: string;
|
|
@@ -37,6 +38,7 @@ declare const _default: {
|
|
|
37
38
|
changedPackages: string[];
|
|
38
39
|
changedPackageFiles: Record<string, string[]>;
|
|
39
40
|
missingChangesets: string[];
|
|
41
|
+
warnings: string[];
|
|
40
42
|
};
|
|
41
43
|
}>;
|
|
42
44
|
};
|
|
@@ -20,8 +20,11 @@ An empty changeset added since the comparison base counts as an explicit no-rele
|
|
|
20
20
|
logger.summary(() => {
|
|
21
21
|
const rows = result.changedPackages.map((name) => {
|
|
22
22
|
let status = 'no release requested';
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const release = result.releases.find((item) => item.package.name === name);
|
|
24
|
+
if (release) {
|
|
25
|
+
status = release.changesets.length
|
|
26
|
+
? 'changeset found'
|
|
27
|
+
: 'releases with its sources';
|
|
25
28
|
}
|
|
26
29
|
if (result.missingChangesets.includes(name)) {
|
|
27
30
|
status = 'missing changeset';
|
|
@@ -49,6 +52,7 @@ An empty changeset added since the comparison base counts as an explicit no-rele
|
|
|
49
52
|
else if (!result.releases.length) {
|
|
50
53
|
logger.info('No pending package releases.');
|
|
51
54
|
}
|
|
55
|
+
result.warnings.forEach((warning) => logger.warning(warning));
|
|
52
56
|
return {
|
|
53
57
|
exitCode: result.missingChangesets.length ? 2 : 0,
|
|
54
58
|
result: {
|
|
@@ -26,6 +26,9 @@ declare const _default: {
|
|
|
26
26
|
readonly type: "string";
|
|
27
27
|
readonly multiple: true;
|
|
28
28
|
};
|
|
29
|
+
readonly 'no-dependents': {
|
|
30
|
+
readonly type: "boolean";
|
|
31
|
+
};
|
|
29
32
|
};
|
|
30
33
|
run({ values, cwd, configFile, logger }: import("./command.ts").CommandContext): Promise<{
|
|
31
34
|
exitCode: number;
|
|
@@ -3,12 +3,13 @@ import { getChangesetVersionSummary } from "../summary.js";
|
|
|
3
3
|
import { parseChangesetVersionArgs, changesetVersionArgs, } from "./changeset-args.js";
|
|
4
4
|
export default {
|
|
5
5
|
name: 'changeset-version',
|
|
6
|
-
usage: 'changeset-version [--dry-run] [--date <YYYY-MM-DD>] [--package <name>]',
|
|
6
|
+
usage: 'changeset-version [--dry-run] [--date <YYYY-MM-DD>] [--package <name>] [--no-dependents]',
|
|
7
7
|
summary: 'Apply pending version bumps and changelog entries.',
|
|
8
8
|
help: `Options:
|
|
9
9
|
--dry-run Print the release plan without writing files
|
|
10
10
|
--date <YYYY-MM-DD> Changelog date (default: today)
|
|
11
11
|
--package <name> Version only this package (repeatable)
|
|
12
|
+
--no-dependents Leave the configured dependents of the selected sources out
|
|
12
13
|
|
|
13
14
|
Updates configured version files, prepends changelog entries and removes the
|
|
14
15
|
applied changeset files. With --package, a changeset that also names other
|
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type { ExtractZipInput, ZipEntryInfo, ZipEntryType } from './unzip.ts';
|
|
|
43
43
|
export type { FindVersionMismatchesInput, GitTagInput, VersionCheckResult, VersionMismatch, } from './version-check.ts';
|
|
44
44
|
export type { Changeset, ChangesetBump, ChangesetPackages, ChangesetConfig, ChangesetPackageType, ChangesetPackage, PackageRelease, VersionedRelease, ChangesetReleasePlan, } from './changesets.ts';
|
|
45
45
|
export type { ChangesetRepository, ChangesetVersionInput, VersionChangesetsResult, ChangesetTagInput, ChangesetTagResult, } from './changeset-release.ts';
|
|
46
|
-
export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, ChangelogEntryInput, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
|
|
46
|
+
export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, ChangelogEntryInput, DependentsChangelogInput, DependentsConfig, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
|
|
47
47
|
export type { SvnCheckoutInput, SvnCommandInput, SvnCommandOutput, SvnCommitInput, SvnCopyInput, SvnDepth, SvnOptions, SvnPathsInput, SvnPropsetInput, SvnRunner, SvnStatusEntry, SvnStatusType, SvnUpdateInput, } from './svn.ts';
|
|
48
48
|
export type { SyncDirectoryInput, SyncResult } from './sync.ts';
|
|
49
49
|
export type { SummaryContext } from './summary.ts';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wpelevator/wp-release",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Package, checksum, sign, verify and version-check WordPress plugins and themes, and deploy plugins to WordPress.org SVN",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,11 +41,13 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"ignore": "^7.0.12",
|
|
44
|
+
"semver": "^7.7.1",
|
|
44
45
|
"yauzl": "^3.4.0",
|
|
45
46
|
"yazl": "^3.3.1"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^22.20.5",
|
|
50
|
+
"@types/semver": "^7.8.0",
|
|
49
51
|
"@types/yauzl": "^3.4.0",
|
|
50
52
|
"@types/yazl": "^3.3.1",
|
|
51
53
|
"@wordpress/scripts": "^33.0",
|