@williamthorsen/release-kit 16.0.0 → 16.1.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/.meta/changelog.json +27 -0
- package/.readyup/kits/default.js +2 -2
- package/.readyup/manifest.json +4 -4
- package/CHANGELOG.md +8 -0
- package/README.md +7 -4
- package/dist/esm/assertTarballsComplete.d.ts +9 -0
- package/dist/esm/assertTarballsComplete.js +52 -0
- package/dist/esm/buildChangelogEntries.js +2 -2
- package/dist/esm/configSchema.d.ts +1 -0
- package/dist/esm/configSchema.js +1 -0
- package/dist/esm/detectPackageManager.d.ts +2 -0
- package/dist/esm/detectPackageManager.js +3 -0
- package/dist/esm/enumerateReleaseWindows.js +1 -1
- package/dist/esm/findMissingManifestPaths.d.ts +14 -0
- package/dist/esm/findMissingManifestPaths.js +90 -0
- package/dist/esm/packFromCleanCheckout.d.ts +11 -0
- package/dist/esm/packFromCleanCheckout.js +81 -0
- package/dist/esm/prepareCommand.js +4 -1
- package/dist/esm/publish.d.ts +1 -1
- package/dist/esm/publish.js +1 -6
- package/dist/esm/publishCommand.d.ts +3 -3
- package/dist/esm/publishCommand.js +8 -0
- package/docs/configuration.md +16 -15
- package/docs/publishing.md +4 -0
- package/docs/releasing.md +6 -0
- package/package.json +2 -2
- package/presets/labels/common.yaml +3 -0
package/.meta/changelog.json
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "16.1.0",
|
|
4
|
+
"date": "2026-11-03",
|
|
5
|
+
"sections": [
|
|
6
|
+
{
|
|
7
|
+
"title": "🎉 Features",
|
|
8
|
+
"audience": "all",
|
|
9
|
+
"items": [
|
|
10
|
+
{
|
|
11
|
+
"description": "Makes `prepare` and `publish` refuse a release, dry runs included, when a tarball packed from a clean checkout of HEAD lacks a file named by the package's `main`, `types`, `bin`, or `exports`, which keeps a package with missing build output from reaching the registry. (#1147)",
|
|
12
|
+
"hash": "c730654140e89d52ac7430987497d8b954fa7f00",
|
|
13
|
+
"entry": 1
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"description": "Adds the `devExportConditions` config key, which lists the export conditions that resolve only inside the repo and whose targets the tarball check does not require, in addition to `source`. (#1147)",
|
|
17
|
+
"hash": "c730654140e89d52ac7430987497d8b954fa7f00",
|
|
18
|
+
"entry": 2
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"description": "Adds a `content` label to the `common` label preset, for consumer-facing material such as copy, images, or displayed data rather than functionality. (#1150)",
|
|
22
|
+
"hash": "8c7b990041e5744c26547fe0ce8c9305f0f7c6f4",
|
|
23
|
+
"entry": 1
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
2
29
|
{
|
|
3
30
|
"version": "16.0.0",
|
|
4
31
|
"date": "2026-10-28",
|
package/.readyup/kits/default.js
CHANGED
|
@@ -223,7 +223,7 @@ function stripYamlQuotes(value) {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
// .readyup/lib/workflow-hashes.ts
|
|
226
|
-
var COMMON_PRESET_HASH = "
|
|
226
|
+
var COMMON_PRESET_HASH = "5c6d7efcc8744d9d30720d24e345b04e03f2b28677ad0c6782020d91731b8aa4";
|
|
227
227
|
var SYNC_LABELS_WORKFLOW_HASH = "0a88829d154c0de7cdd4246fede99e9311a89ab4199034dbe601663d641ab1e4";
|
|
228
228
|
var RELEASE_WORKFLOW_HASH_MONOREPO = "f63f4cabb57f94ee6550f17a9874c7a346d46554595ddbcd46fdd3208d613437";
|
|
229
229
|
var RELEASE_WORKFLOW_HASH_SINGLE = "d26dd4ca186632b388ea46bd885721124c03d19918e511baa40821d2d760da69";
|
|
@@ -234,7 +234,7 @@ var CREATE_GITHUB_RELEASE_WORKFLOW_HASH_SINGLE = "d6de961de913c67b08dfa7e07f7349
|
|
|
234
234
|
|
|
235
235
|
// .readyup/kits/default.ts
|
|
236
236
|
function getMinVersion() {
|
|
237
|
-
const picked = { "version": "16.
|
|
237
|
+
const picked = { "version": "16.1.0" };
|
|
238
238
|
if (typeof picked["version"] !== "string") {
|
|
239
239
|
throw new TypeError("release-kit/package.json: 'version' must be a string");
|
|
240
240
|
}
|
package/.readyup/manifest.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"checklist": "release-kit",
|
|
8
8
|
"checks": [
|
|
9
9
|
"@williamthorsen/release-kit in devDependencies",
|
|
10
|
-
"@williamthorsen/release-kit >= 16.
|
|
10
|
+
"@williamthorsen/release-kit >= 16.1.0",
|
|
11
11
|
"release.yaml workflow exists",
|
|
12
12
|
"release.yaml matches template",
|
|
13
13
|
"release.yaml does not reference deprecated tag ref",
|
|
@@ -58,12 +58,12 @@
|
|
|
58
58
|
"path": "lib/config-checks.ts"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
|
-
"hash": "
|
|
61
|
+
"hash": "f652dae3",
|
|
62
62
|
"kind": "module",
|
|
63
63
|
"path": "lib/workflow-hashes.ts"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
|
-
"hash": "
|
|
66
|
+
"hash": "b44f7148",
|
|
67
67
|
"kind": "inline",
|
|
68
68
|
"path": "../package.json",
|
|
69
69
|
"paths": [
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"readyupVersion": "0.44.0",
|
|
102
102
|
"source": "kits/default.ts",
|
|
103
103
|
"sourceHash": "56566d0b",
|
|
104
|
-
"targetHash": "
|
|
104
|
+
"targetHash": "5f810bb7"
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
"checklistChecks": [
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 16.1.0 — 2026-11-03
|
|
6
|
+
|
|
7
|
+
### 🎉 Features
|
|
8
|
+
|
|
9
|
+
- Makes `prepare` and `publish` refuse a release, dry runs included, when a tarball packed from a clean checkout of HEAD lacks a file named by the package's `main`, `types`, `bin`, or `exports`, which keeps a package with missing build output from reaching the registry. (#1147)
|
|
10
|
+
- Adds the `devExportConditions` config key, which lists the export conditions that resolve only inside the repo and whose targets the tarball check does not require, in addition to `source`. (#1147)
|
|
11
|
+
- Adds a `content` label to the `common` label preset, for consumer-facing material such as copy, images, or displayed data rather than functionality. (#1150)
|
|
12
|
+
|
|
5
13
|
## 16.0.0 — 2026-10-28
|
|
6
14
|
|
|
7
15
|
### 🎉 Features
|
package/README.md
CHANGED
|
@@ -7,11 +7,13 @@ Version-bumping and changelog-generation toolkit for release workflows.
|
|
|
7
7
|
Provides a self-contained CLI that auto-discovers workspaces from `pnpm-workspace.yaml`, parses conventional commits, determines version bumps, updates `package.json` files, and generates changelogs from the same commit history that decides each bump (with optional [editorial overrides](docs/editorial-overrides.md)).
|
|
8
8
|
|
|
9
9
|
<!-- section:release-notes -->
|
|
10
|
-
## Release notes — v16.
|
|
10
|
+
## Release notes — v16.1.0 (2026-11-03)
|
|
11
11
|
|
|
12
12
|
### 🎉 Features
|
|
13
13
|
|
|
14
|
-
-
|
|
14
|
+
- Makes `prepare` and `publish` refuse a release, dry runs included, when a tarball packed from a clean checkout of HEAD lacks a file named by the package's `main`, `types`, `bin`, or `exports`, which keeps a package with missing build output from reaching the registry. (#1147)
|
|
15
|
+
- Adds the `devExportConditions` config key, which lists the export conditions that resolve only inside the repo and whose targets the tarball check does not require, in addition to `source`. (#1147)
|
|
16
|
+
- Adds a `content` label to the `common` label preset, for consumer-facing material such as copy, images, or displayed data rather than functionality. (#1150)
|
|
15
17
|
<!-- /section:release-notes -->
|
|
16
18
|
|
|
17
19
|
## Installation
|
|
@@ -71,8 +73,9 @@ Every command other than `--version` and `--help` first locates the repo root an
|
|
|
71
73
|
1. **Workspace discovery**: Reads `pnpm-workspace.yaml` and resolves its `packages` patterns to find workspace directories, applying pnpm's semantics: A `!`-prefixed entry excludes what it matches, wherever it appears in the list. Each directory containing a `package.json` becomes a workspace. The repo is treated as a single-package project when it doesn't contain a workspace file, and when the workspace file doesn't declare a `packages` list: a file kept for `catalog:` or `overrides:` alone, which pnpm likewise resolves to the root package. A workspace file that declares patterns that don't resolve to any package is an error naming the condition that emptied it.
|
|
72
74
|
2. **Config loading**: Loads `.config/release-kit.config.ts` (if present) from the repo root, or the file named by `--config` relative to the invocation directory, and merges it with discovered defaults.
|
|
73
75
|
3. **Commit analysis**: For each workspace, reads the history once, builds the changelog items of the commits since the last version tag, and takes the bump as the highest level that those items call for.
|
|
74
|
-
4. **
|
|
75
|
-
5. **
|
|
76
|
+
4. **Tarball check**: Packs each publishable package that the release includes from a clean checkout of HEAD, and refuses the release when a tarball lacks a file that the package's manifest names; see [Packages whose tarball would lack a manifest file](docs/releasing.md#packages-whose-tarball-would-lack-a-manifest-file).
|
|
77
|
+
5. **Version bump + changelog**: Bumps `package.json` versions, builds structured `ChangelogEntry[]` from the commits between each pair of release tags, applies any [editorial overrides](docs/editorial-overrides.md) from per-scope `.meta/changelog-overrides.json` files, and renders both `CHANGELOG.md` and `.meta/changelog.json` from that single source, so the two always agree.
|
|
78
|
+
6. **Release tags file**: Writes computed tags to `tmp/.release-tags` for the release workflow to read when tagging and pushing.
|
|
76
79
|
|
|
77
80
|
[Changelogs](docs/changelogs.md) covers what a published tarball includes, the `changelog.json` item schema, and which commits appear in a changelog.
|
|
78
81
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { packFromCleanCheckout } from './packFromCleanCheckout.js';
|
|
2
|
+
/**
|
|
3
|
+
* Throws when the tarball of a publishable package among the given workspaces would lack a file that its manifest
|
|
4
|
+
* names, or when the packages cannot be packed from a clean checkout of HEAD.
|
|
5
|
+
*
|
|
6
|
+
* Each workspace path is repo-relative and is read from the current directory. A private package is not packed, and
|
|
7
|
+
* an empty list doesn't clone anything.
|
|
8
|
+
*/
|
|
9
|
+
export declare function assertTarballsComplete(workspacePaths: readonly string[], devExportConditions: readonly string[], pack?: typeof packFromCleanCheckout): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
4
|
+
import { detectPackageManager } from "./detectPackageManager.js";
|
|
5
|
+
import { findMissingManifestPaths } from "./findMissingManifestPaths.js";
|
|
6
|
+
import { isPublishableManifest } from "./isPublishableManifest.js";
|
|
7
|
+
import { packFromCleanCheckout } from "./packFromCleanCheckout.js";
|
|
8
|
+
import { isRecord } from "./typeGuards.js";
|
|
9
|
+
export function assertTarballsComplete(workspacePaths, devExportConditions, pack = packFromCleanCheckout) {
|
|
10
|
+
const candidates = workspacePaths
|
|
11
|
+
.map((workspacePath) => ({ workspacePath, manifest: readManifest(workspacePath) }))
|
|
12
|
+
.filter(({ manifest }) => isPublishableManifest(manifest));
|
|
13
|
+
if (candidates.length === 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
process.stderr.write(`Checking the tarballs of ${candidates.length} ${candidates.length === 1 ? 'package' : 'packages'} from a clean checkout of HEAD...\n`);
|
|
17
|
+
let filesByWorkspace;
|
|
18
|
+
try {
|
|
19
|
+
filesByWorkspace = pack(candidates.map(({ workspacePath }) => workspacePath), detectPackageManager());
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`Cannot release: The packages could not be packed from a clean checkout of HEAD.\n${describeError(error)}`, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
const incompletePackages = candidates
|
|
25
|
+
.map(({ workspacePath, manifest }) => ({
|
|
26
|
+
workspacePath,
|
|
27
|
+
manifest,
|
|
28
|
+
missingPaths: findMissingManifestPaths(manifest, filesByWorkspace.get(workspacePath) ?? [], devExportConditions),
|
|
29
|
+
}))
|
|
30
|
+
.filter(({ missingPaths }) => missingPaths.length > 0);
|
|
31
|
+
if (incompletePackages.length === 0) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(describeRefusal(incompletePackages));
|
|
35
|
+
}
|
|
36
|
+
function describeRefusal(incompletePackages) {
|
|
37
|
+
const sections = ['Cannot release: The tarball of every package must contain each file that its manifest names.'];
|
|
38
|
+
for (const { workspacePath, manifest, missingPaths } of incompletePackages) {
|
|
39
|
+
const name = typeof manifest['name'] === 'string' ? manifest['name'] : workspacePath;
|
|
40
|
+
sections.push([
|
|
41
|
+
`${name} (${workspacePath}) lacks:`,
|
|
42
|
+
...missingPaths.map(({ location, path }) => ` - ${path} (${location})`),
|
|
43
|
+
].join('\n'));
|
|
44
|
+
}
|
|
45
|
+
sections.push('Build these files in the package\'s publish lifecycle, such as in a "prepack" script. ' +
|
|
46
|
+
'If an export condition is for development only, list it in the "devExportConditions" config key.');
|
|
47
|
+
return sections.join('\n\n');
|
|
48
|
+
}
|
|
49
|
+
function readManifest(workspacePath) {
|
|
50
|
+
const manifest = JSON.parse(readFileSync(join(workspacePath, 'package.json'), 'utf8'));
|
|
51
|
+
return isRecord(manifest) ? manifest : {};
|
|
52
|
+
}
|
|
@@ -75,7 +75,7 @@ function buildSections(sectionMap, devOnlySections) {
|
|
|
75
75
|
return sections.toSorted((a, b) => getCanonicalSectionPriority(a.title) - getCanonicalSectionPriority(b.title));
|
|
76
76
|
}
|
|
77
77
|
function formatDate(timestampSec) {
|
|
78
|
-
return new Date(timestampSec *
|
|
78
|
+
return new Date(timestampSec * 1000).toISOString().slice(0, 10);
|
|
79
79
|
}
|
|
80
80
|
function getCanonicalSectionPriority(title) {
|
|
81
81
|
const index = CANONICAL_SECTION_ORDER.get(stripGroupDecorations(title));
|
|
@@ -170,7 +170,7 @@ function transformReleases(windows, context) {
|
|
|
170
170
|
unreleasedReading: {
|
|
171
171
|
bump: determineBumpType(signals, context.workTypes, context.versionPatterns),
|
|
172
172
|
commits: unreleasedCommits.filter((commit) => !isReleaseSubject(commit.subject)).toReversed(),
|
|
173
|
-
date: formatDate(unreleasedWindow?.timestampSec ?? Math.floor(Date.now() /
|
|
173
|
+
date: formatDate(unreleasedWindow?.timestampSec ?? Math.floor(Date.now() / 1000)),
|
|
174
174
|
diagnostics,
|
|
175
175
|
parsedCommitCount,
|
|
176
176
|
sections: buildSections(sectionMap, context.devOnlySections),
|
|
@@ -166,6 +166,7 @@ export declare const releaseKitConfigSchema: z.ZodObject<{
|
|
|
166
166
|
changelogMarkdown: z.ZodOptional<z.ZodObject<{
|
|
167
167
|
keptVersionCount: z.ZodOptional<z.ZodNumber>;
|
|
168
168
|
}, z.core.$strict>>;
|
|
169
|
+
devExportConditions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
169
170
|
formatCommand: z.ZodOptional<z.ZodString>;
|
|
170
171
|
project: z.ZodOptional<z.ZodObject<{
|
|
171
172
|
paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/dist/esm/configSchema.js
CHANGED
|
@@ -89,6 +89,7 @@ export const releaseKitConfigSchema = z
|
|
|
89
89
|
breakingPolicies: z.record(z.string(), breakingPolicyValueSchema).optional(),
|
|
90
90
|
changelogJson: changelogJsonInputSchema.optional(),
|
|
91
91
|
changelogMarkdown: changelogMarkdownInputSchema.optional(),
|
|
92
|
+
devExportConditions: z.array(z.string().min(1)).optional(),
|
|
92
93
|
formatCommand: z.string().min(1).optional(),
|
|
93
94
|
project: projectConfigSchema.optional(),
|
|
94
95
|
releaseNotes: releaseNotesInputSchema.optional(),
|
|
@@ -4,3 +4,5 @@ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'yarn-berry';
|
|
|
4
4
|
* and otherwise returns `npm`. Yarn v2+ is `'yarn-berry'`, to distinguish it from Yarn Classic.
|
|
5
5
|
*/
|
|
6
6
|
export declare function detectPackageManager(): PackageManager;
|
|
7
|
+
/** Maps a `PackageManager` value to its CLI executable name. */
|
|
8
|
+
export declare function resolveExecutable(packageManager: PackageManager): string;
|
|
@@ -20,6 +20,9 @@ export function detectPackageManager() {
|
|
|
20
20
|
}
|
|
21
21
|
return detectFromLockfile();
|
|
22
22
|
}
|
|
23
|
+
export function resolveExecutable(packageManager) {
|
|
24
|
+
return packageManager === 'yarn-berry' ? 'yarn' : packageManager;
|
|
25
|
+
}
|
|
23
26
|
function isYarnBerry(version) {
|
|
24
27
|
if (version === undefined) {
|
|
25
28
|
return false;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A path that a manifest names but the tarball lacks, with the manifest location that names it. */
|
|
2
|
+
export interface MissingManifestPath {
|
|
3
|
+
location: string;
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Lists each path named by the manifest's `main`, `types`, `bin`, and `exports` that `packedFiles` lacks.
|
|
8
|
+
*
|
|
9
|
+
* `packedFiles` holds tarball entries relative to the package root. `main` resolves as Node resolves it: the path
|
|
10
|
+
* itself, then with `.js` or `.json` appended, then as a directory containing `index.js`. An `exports` target
|
|
11
|
+
* containing `*` passes when any entry matches it, with `*` spanning `/` as in Node's subpath patterns. A target under
|
|
12
|
+
* `source` or under one of `devExportConditions` is exempt, at any depth.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findMissingManifestPaths(manifest: Record<string, unknown>, packedFiles: readonly string[], devExportConditions: readonly string[]): MissingManifestPath[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isRecord } from "./typeGuards.js";
|
|
2
|
+
const BUILT_IN_DEV_CONDITION = 'source';
|
|
3
|
+
export function findMissingManifestPaths(manifest, packedFiles, devExportConditions) {
|
|
4
|
+
const packedSet = new Set(packedFiles);
|
|
5
|
+
const exemptConditions = new Set([BUILT_IN_DEV_CONDITION, ...devExportConditions]);
|
|
6
|
+
const missingPaths = [];
|
|
7
|
+
const main = manifest['main'];
|
|
8
|
+
if (typeof main === 'string' && listMainCandidates(main).every((candidate) => !packedSet.has(candidate))) {
|
|
9
|
+
missingPaths.push({ location: 'main', path: main });
|
|
10
|
+
}
|
|
11
|
+
const types = manifest['types'];
|
|
12
|
+
if (typeof types === 'string' && !packedSet.has(normalizePath(types))) {
|
|
13
|
+
missingPaths.push({ location: 'types', path: types });
|
|
14
|
+
}
|
|
15
|
+
const binTargets = listBinTargets(manifest['bin']);
|
|
16
|
+
for (const { location, path } of binTargets) {
|
|
17
|
+
if (!packedSet.has(normalizePath(path))) {
|
|
18
|
+
missingPaths.push({ location, path });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const exportTargets = [];
|
|
22
|
+
collectExportTargets(manifest['exports'], 'exports', exemptConditions, exportTargets);
|
|
23
|
+
for (const target of exportTargets) {
|
|
24
|
+
if (!isTargetPacked(target.path, packedFiles, packedSet)) {
|
|
25
|
+
missingPaths.push(target);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return missingPaths;
|
|
29
|
+
}
|
|
30
|
+
function collectExportTargets(value, location, exemptConditions, targets) {
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
targets.push({ location, path: value });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
for (const [index, element] of value.entries()) {
|
|
37
|
+
collectExportTargets(element, `${location}[${index}]`, exemptConditions, targets);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!isRecord(value))
|
|
42
|
+
return;
|
|
43
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
44
|
+
if (key.startsWith('.')) {
|
|
45
|
+
collectExportTargets(nestedValue, `${location}[${JSON.stringify(key)}]`, exemptConditions, targets);
|
|
46
|
+
}
|
|
47
|
+
else if (!exemptConditions.has(key)) {
|
|
48
|
+
collectExportTargets(nestedValue, `${location}.${key}`, exemptConditions, targets);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isTargetPacked(target, packedFiles, packedSet) {
|
|
53
|
+
const normalizedTarget = normalizePath(target);
|
|
54
|
+
if (!normalizedTarget.includes('*')) {
|
|
55
|
+
return packedSet.has(normalizedTarget);
|
|
56
|
+
}
|
|
57
|
+
const pattern = new RegExp(`^${normalizedTarget
|
|
58
|
+
.split('*')
|
|
59
|
+
.map((part) => RegExp.escape(part))
|
|
60
|
+
.join('.*')}$`);
|
|
61
|
+
return packedFiles.some((file) => pattern.test(file));
|
|
62
|
+
}
|
|
63
|
+
function listBinTargets(bin) {
|
|
64
|
+
if (typeof bin === 'string') {
|
|
65
|
+
return [{ location: 'bin', path: bin }];
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(bin))
|
|
68
|
+
return [];
|
|
69
|
+
const targets = [];
|
|
70
|
+
for (const [command, path] of Object.entries(bin)) {
|
|
71
|
+
if (typeof path === 'string') {
|
|
72
|
+
targets.push({ location: `bin.${command}`, path });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return targets;
|
|
76
|
+
}
|
|
77
|
+
function listMainCandidates(main) {
|
|
78
|
+
const normalizedMain = normalizePath(main);
|
|
79
|
+
const directoryPrefix = normalizedMain === '' ? '' : `${normalizedMain}/`;
|
|
80
|
+
const indexCandidates = [`${directoryPrefix}index.js`, `${directoryPrefix}index.json`];
|
|
81
|
+
if (normalizedMain === '')
|
|
82
|
+
return indexCandidates;
|
|
83
|
+
return [normalizedMain, `${normalizedMain}.js`, `${normalizedMain}.json`, ...indexCandidates];
|
|
84
|
+
}
|
|
85
|
+
function normalizePath(path) {
|
|
86
|
+
return path
|
|
87
|
+
.replace(/^\.?\/+/, '')
|
|
88
|
+
.replace(/^\.$/, '')
|
|
89
|
+
.replace(/\/+$/, '');
|
|
90
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PackageManager } from './detectPackageManager.js';
|
|
2
|
+
/**
|
|
3
|
+
* Packs each workspace as the publish environment would and returns each one's tarball entries, keyed by workspace
|
|
4
|
+
* path and relative to the package root.
|
|
5
|
+
*
|
|
6
|
+
* Clones HEAD of the repo at the current directory into a temporary directory, runs a frozen install there, then for
|
|
7
|
+
* each workspace runs its `prepublishOnly` when it declares one and packs it. Workspace paths are relative to the repo
|
|
8
|
+
* root. Throws an error that names the failing step and contains its output. Leaves the repo and its working tree
|
|
9
|
+
* untouched, and removes the temporary directory.
|
|
10
|
+
*/
|
|
11
|
+
export declare function packFromCleanCheckout(workspacePaths: readonly string[], packageManager: PackageManager): Map<string, string[]>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { resolveExecutable } from "./detectPackageManager.js";
|
|
6
|
+
import { isRecord } from "./typeGuards.js";
|
|
7
|
+
export function packFromCleanCheckout(workspacePaths, packageManager) {
|
|
8
|
+
const repoRoot = runStep('find the repository root', 'git', ['rev-parse', '--show-toplevel'], process.cwd()).trim();
|
|
9
|
+
const headSha = runStep('resolve HEAD', 'git', ['rev-parse', 'HEAD'], repoRoot).trim();
|
|
10
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'release-kit-pack-'));
|
|
11
|
+
try {
|
|
12
|
+
const cloneDir = join(scratchDir, 'checkout');
|
|
13
|
+
runStep('clone HEAD', 'git', ['clone', '--quiet', '--shared', '--no-checkout', repoRoot, cloneDir], scratchDir);
|
|
14
|
+
runStep('check out HEAD', 'git', ['checkout', '--quiet', '--detach', headSha], cloneDir);
|
|
15
|
+
const executable = resolveExecutable(packageManager);
|
|
16
|
+
const commands = PACKAGE_MANAGER_COMMANDS[packageManager];
|
|
17
|
+
runStep('install dependencies', executable, commands.installArgs, cloneDir);
|
|
18
|
+
const filesByWorkspace = new Map();
|
|
19
|
+
for (const [index, workspacePath] of workspacePaths.entries()) {
|
|
20
|
+
const packageDir = join(cloneDir, workspacePath);
|
|
21
|
+
if (declaresPrepublishOnly(packageDir)) {
|
|
22
|
+
runStep(`run prepublishOnly in ${workspacePath}`, executable, ['run', 'prepublishOnly'], packageDir);
|
|
23
|
+
}
|
|
24
|
+
const packDir = join(scratchDir, 'packs', String(index));
|
|
25
|
+
mkdirSync(packDir, { recursive: true });
|
|
26
|
+
runStep(`pack ${workspacePath}`, executable, commands.buildPackArgs(packDir), packageDir);
|
|
27
|
+
filesByWorkspace.set(workspacePath, listTarballEntries(findTarball(packDir, workspacePath)));
|
|
28
|
+
}
|
|
29
|
+
return filesByWorkspace;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
rmSync(scratchDir, { force: true, recursive: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const PACKAGE_MANAGER_COMMANDS = {
|
|
36
|
+
npm: {
|
|
37
|
+
installArgs: ['ci', '--prefer-offline'],
|
|
38
|
+
buildPackArgs: (packDir) => ['pack', '--pack-destination', packDir],
|
|
39
|
+
},
|
|
40
|
+
pnpm: {
|
|
41
|
+
installArgs: ['install', '--frozen-lockfile', '--prefer-offline'],
|
|
42
|
+
buildPackArgs: (packDir) => ['pack', '--pack-destination', packDir],
|
|
43
|
+
},
|
|
44
|
+
yarn: {
|
|
45
|
+
installArgs: ['install', '--frozen-lockfile'],
|
|
46
|
+
buildPackArgs: (packDir) => ['pack', '--filename', join(packDir, 'package.tgz')],
|
|
47
|
+
},
|
|
48
|
+
'yarn-berry': {
|
|
49
|
+
installArgs: ['install', '--immutable'],
|
|
50
|
+
buildPackArgs: (packDir) => ['pack', '--out', join(packDir, 'package.tgz')],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
function declaresPrepublishOnly(packageDir) {
|
|
54
|
+
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
55
|
+
const scripts = isRecord(manifest) ? manifest['scripts'] : undefined;
|
|
56
|
+
return isRecord(scripts) && typeof scripts['prepublishOnly'] === 'string';
|
|
57
|
+
}
|
|
58
|
+
function findTarball(packDir, workspacePath) {
|
|
59
|
+
const tarballs = readdirSync(packDir).filter((name) => name.endsWith('.tgz'));
|
|
60
|
+
const [tarball] = tarballs;
|
|
61
|
+
if (tarballs.length !== 1 || tarball === undefined) {
|
|
62
|
+
throw new Error(`Could not pack ${workspacePath}: expected one tarball, found ${tarballs.length}.`);
|
|
63
|
+
}
|
|
64
|
+
return join(packDir, tarball);
|
|
65
|
+
}
|
|
66
|
+
function listTarballEntries(tarballPath) {
|
|
67
|
+
const listing = runStep('list the tarball', 'tar', ['--list', '--gzip', '--file', tarballPath], process.cwd());
|
|
68
|
+
return listing
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter((entry) => entry !== '' && !entry.endsWith('/'))
|
|
71
|
+
.map((entry) => entry.replace(/^package\//, ''));
|
|
72
|
+
}
|
|
73
|
+
function runStep(step, command, args, cwd) {
|
|
74
|
+
const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 2 ** 26 });
|
|
75
|
+
if (result.error === undefined && result.status === 0) {
|
|
76
|
+
return result.stdout;
|
|
77
|
+
}
|
|
78
|
+
const outcome = result.error === undefined ? `exited with status ${result.status}` : result.error.message;
|
|
79
|
+
const output = [result.stdout, result.stderr].filter((text) => text !== '').join('\n');
|
|
80
|
+
throw new Error(`Could not ${step}: \`${[command, ...args].join(' ')}\` ${outcome}.${output === '' ? '' : `\n${output}`}`);
|
|
81
|
+
}
|
|
@@ -3,6 +3,7 @@ import { formatGlyphLine, reportError } from '@williamthorsen/nmr-core';
|
|
|
3
3
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
4
4
|
import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
|
|
5
5
|
import { assertReleasesOnNpm } from "./assertReleasesOnNpm.js";
|
|
6
|
+
import { assertTarballsComplete } from "./assertTarballsComplete.js";
|
|
6
7
|
import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
|
|
7
8
|
import { dim } from "./format.js";
|
|
8
9
|
import { RELEASE_GLYPHS } from "./glyphs.js";
|
|
@@ -63,7 +64,9 @@ export async function prepareCommand(argv, styles, invocationDir) {
|
|
|
63
64
|
};
|
|
64
65
|
runAndReport(() => {
|
|
65
66
|
const plan = prepareReleaseTargets(config, targetOptions);
|
|
66
|
-
|
|
67
|
+
const releasedPackagePaths = findReleasedPackagePaths(plan, config.targets);
|
|
68
|
+
assertReleasesOnNpm(releasedPackagePaths);
|
|
69
|
+
assertTarballsComplete(releasedPackagePaths, userConfig?.devExportConditions ?? []);
|
|
67
70
|
return plan;
|
|
68
71
|
}, isDryRun, styles.stdout);
|
|
69
72
|
}
|
package/dist/esm/publish.d.ts
CHANGED
package/dist/esm/publish.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { resolveExecutable } from "./detectPackageManager.js";
|
|
2
3
|
export function publishPackage(resolvedTag, packageManager, options) {
|
|
3
4
|
const { isDryRun, shouldGenerateProvenance } = options;
|
|
4
5
|
const executable = resolveExecutable(packageManager);
|
|
@@ -6,12 +7,6 @@ export function publishPackage(resolvedTag, packageManager, options) {
|
|
|
6
7
|
console.info(`\n${isDryRun ? '[dry-run] ' : ''}Running: ${executable} ${args.join(' ')} (cwd: ${resolvedTag.workspacePath})`);
|
|
7
8
|
execFileSync(executable, args, { cwd: resolvedTag.workspacePath, stdio: 'inherit' });
|
|
8
9
|
}
|
|
9
|
-
function resolveExecutable(packageManager) {
|
|
10
|
-
if (packageManager === 'yarn-berry') {
|
|
11
|
-
return 'yarn';
|
|
12
|
-
}
|
|
13
|
-
return packageManager;
|
|
14
|
-
}
|
|
15
10
|
function buildPublishArgs(packageManager, options) {
|
|
16
11
|
const args = packageManager === 'yarn-berry' ? ['npm', 'publish'] : ['publish'];
|
|
17
12
|
if (options.isDryRun) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type StreamStyles } from '@williamthorsen/nmr-core';
|
|
2
2
|
/**
|
|
3
|
-
* Orchestrates the CLI `publish` command: checks for a clean working tree, resolves the tags on HEAD,
|
|
4
|
-
*
|
|
5
|
-
* A relative `--config` resolves against `invocationDir`.
|
|
3
|
+
* Orchestrates the CLI `publish` command: checks for a clean working tree, resolves the tags on HEAD, checks the
|
|
4
|
+
* tarballs of the publishable ones from a clean checkout, and publishes each one, injecting release notes into its
|
|
5
|
+
* README for the duration of the publish. A relative `--config` resolves against `invocationDir`.
|
|
6
6
|
*/
|
|
7
7
|
export declare function publishCommand(argv: string[], styles: StreamStyles, invocationDir: string): Promise<void>;
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
|
|
4
4
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
5
5
|
import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
|
|
6
|
+
import { assertTarballsComplete } from "./assertTarballsComplete.js";
|
|
6
7
|
import { configFlagSchema } from "./configFlagSchema.js";
|
|
7
8
|
import { deriveReleaseNotesConfig } from "./deriveReleaseNotesConfig.js";
|
|
8
9
|
import { detectPackageManager } from "./detectPackageManager.js";
|
|
@@ -46,6 +47,13 @@ export async function publishCommand(argv, styles, invocationDir) {
|
|
|
46
47
|
console.info('Nothing to publish.');
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
50
|
+
try {
|
|
51
|
+
assertTarballsComplete(publishableTags.map(({ workspacePath }) => workspacePath), userConfig?.devExportConditions ?? []);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
reportError(describeError(error));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
49
57
|
const packageManager = detectPackageManager();
|
|
50
58
|
const { releaseNotes, changelogJsonOutputPath, sectionOrder } = deriveReleaseNotesConfig(userConfig);
|
|
51
59
|
const shouldInject = releaseNotes.shouldInjectIntoReadme;
|
package/docs/configuration.md
CHANGED
|
@@ -16,21 +16,22 @@ The two paths differ in what an absent file means. An absent default path means
|
|
|
16
16
|
|
|
17
17
|
## `ReleaseKitConfig` reference
|
|
18
18
|
|
|
19
|
-
| Field
|
|
20
|
-
|
|
|
21
|
-
| `releases`
|
|
22
|
-
| `workspaces`
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
19
|
+
| Field | Type | Description |
|
|
20
|
+
| --------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `releases` | `Record<string, ReleaseEntry>` | Names, excludes, or composes release targets; see [`releases`](#releases). Can't be combined with `workspaces` or `project` |
|
|
22
|
+
| `workspaces` | `WorkspaceOverride[]` | Override or exclude discovered workspaces (matched by `dir`) |
|
|
23
|
+
| `devExportConditions` | `string[]` | Export conditions that resolve only inside the repo, such as one that points at unpublished source; the [tarball check](releasing.md#packages-whose-tarball-would-lack-a-manifest-file) does not require their targets in the tarball. `source` is exempt without being listed |
|
|
24
|
+
| `formatCommand` | `string` | Shell command to run after changelog generation; modified file paths are appended as arguments |
|
|
25
|
+
| `versionPatterns` | `VersionPatterns` | Rules for which commit types trigger major/minor bumps |
|
|
26
|
+
| `scopeAliases` | `Record<string, string>` | Maps shorthand scope names to canonical names in commit titles and in change-record entry `scopes`, which [route items to workspaces](changelogs.md#routing-to-workspaces). A renamed workspace directory or [release target](#releases) needs an entry from its old name to its new one |
|
|
27
|
+
| `workTypes` | `Record<string, WorkTypeConfig>` | Work type definitions, merged with defaults by key |
|
|
28
|
+
| `breakingPolicies` | `Record<string, 'forbidden' \| 'optional'>` | Per-type `!`-policy lookup. Defaults to `DEFAULT_BREAKING_POLICIES`. Replaces the default entirely when provided. Set to `{}` to disable enforcement |
|
|
29
|
+
| `retiredPackages` | `RetiredPackage[]` | Packages that once lived in this repo but have been extracted or removed; suppresses undeclared-tag-prefix warnings |
|
|
30
|
+
| `project` | `ProjectConfig` | Opt-in project-level release block. Declaring `project: {}` (even empty) enables a project-release stage in `prepare` |
|
|
31
|
+
| `repoLabels` | `RepoLabelsConfig` | Declares the repository's label registry for `sync-labels`; see [Label configuration](labels.md#label-configuration) |
|
|
32
|
+
| `changelogJson` | `ChangelogJsonConfig` | Controls `.meta/changelog.json`: `enabled` (default `true`), `outputPath` (default `'.meta/changelog.json'`), and `devOnlySections`; see [Custom work types](work-types.md#custom-work-types) |
|
|
33
|
+
| `changelogMarkdown` | `ChangelogMarkdownConfig` | `keptVersionCount` (an integer, at least 1) bounds `CHANGELOG.md` to that many of the newest versions and moves the rest into per-major archive files; omitted, `CHANGELOG.md` renders every version. See [Archived versions](changelogs.md#archived-versions) |
|
|
34
|
+
| `releaseNotes` | `ReleaseNotesConfig` | `shouldInjectIntoReadme` (default `false`) injects release notes into each package's README while it publishes; see [Release-notes injection](changelogs.md#release-notes-injection) |
|
|
34
35
|
|
|
35
36
|
All fields are optional.
|
|
36
37
|
|
package/docs/publishing.md
CHANGED
|
@@ -23,6 +23,10 @@ Example warning when an explicit tag is unpublishable:
|
|
|
23
23
|
Skipping basic-v1.0.0 (packages/basic): package.json#private is true.
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
### Tarball check
|
|
27
|
+
|
|
28
|
+
Before it publishes any package, and in a dry run as well, `publish` packs every publishable package from a clean checkout of HEAD and refuses when a tarball lacks a file that its manifest names. It is the same check that `prepare` runs; see [Packages whose tarball would lack a manifest file](releasing.md#packages-whose-tarball-would-lack-a-manifest-file).
|
|
29
|
+
|
|
26
30
|
## `release-kit create-github-release`
|
|
27
31
|
|
|
28
32
|
Creates GitHub Releases from `changelog.json` for tags on HEAD. Independent of `npm publish`: Invoking this command creates Releases for publishable packages whether or not they were published to a registry. Private workspaces are skipped, and a project tag gets a Release (see [Publishability filter](#publishability-filter)).
|
package/docs/releasing.md
CHANGED
|
@@ -63,6 +63,12 @@ release-kit prepare --bump=minor
|
|
|
63
63
|
|
|
64
64
|
Before it writes any file, and in a dry run as well, `prepare` asks npm about each publishable package that the release includes, querying the registry that the package's `publishConfig.registry` names when it sets one. OIDC trusted publishing can't publish a package's first version, so `prepare` refuses to release a package that npm doesn't list, and names each one with its fix: Run `npm publish --access public` from the package's directory once, then register the package as a trusted publisher. The `npm-auto-publish` [readiness kit](readiness-checks.md) prints the `npm trust` command. `prepare` also refuses when it can't look a package up, such as when the registry is unreachable, and reports npm's error for that package.
|
|
65
65
|
|
|
66
|
+
### Packages whose tarball would lack a manifest file
|
|
67
|
+
|
|
68
|
+
Before it writes any file, and in a dry run as well, `prepare` packs each publishable package that the release includes as the publish workflow would: It clones HEAD into a temporary directory, runs the package manager's frozen install there, runs the package's `prepublishOnly` script when it declares one, and packs the package. Build output in the working tree therefore does not count, and the check adds the time of one install and one build to every release. `prepare` refuses the release when a tarball lacks a file named by the package's `main`, `types`, `bin`, or `exports`, and names each package with each missing path and the manifest field that names it. The usual fix is to build the files in the package's publish lifecycle, such as in a `prepack` script. A failed install or build in the clean checkout also refuses the release, with the failing step's output.
|
|
69
|
+
|
|
70
|
+
A target under the `source` export condition is exempt, and so is a target under a condition listed in [`devExportConditions`](configuration.md#releasekitconfig-reference), which declares the conditions that resolve only inside the repo. [`publish`](publishing.md#tarball-check) runs the same check before it publishes any package.
|
|
71
|
+
|
|
66
72
|
### Previewing release notes with `--with-release-notes`
|
|
67
73
|
|
|
68
74
|
`--with-release-notes` writes two versioned files per workspace after each workspace's `changelog.json` is produced:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/release-kit",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.1.0",
|
|
4
4
|
"description": "Version-bumping and changelog-generation toolkit for release workflows",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"changelog",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@types/semver": "7.8.0",
|
|
64
64
|
"@williamthorsen/toolbelt.filesystem": "0.10.0",
|
|
65
65
|
"@williamthorsen/toolbelt.testing": "0.8.0",
|
|
66
|
-
"@williamthorsen/toolbelt.vitest": "0.13.
|
|
66
|
+
"@williamthorsen/toolbelt.vitest": "0.13.1",
|
|
67
67
|
"actionlint": "2.0.6",
|
|
68
68
|
"esbuild": "0.28.2",
|
|
69
69
|
"readyup": "0.44.0",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
- name: bug
|
|
10
10
|
color: d73a4a
|
|
11
11
|
description: "Something isn't working"
|
|
12
|
+
- name: content
|
|
13
|
+
color: 0e8a16
|
|
14
|
+
description: Consumer-facing material, such as copy, images, or displayed data, rather than functionality
|
|
12
15
|
- name: operational
|
|
13
16
|
color: '000000'
|
|
14
17
|
description: Actions outside the codebase
|