Skip to content

Commit f8e5c6b

Browse files
authored
ci: dry-run publishing on the release PR (#2239)
1 parent a825452 commit f8e5c6b

3 files changed

Lines changed: 241 additions & 5 deletions

File tree

.github/workflows/create-release-pr.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ jobs:
6464
pull-requests: read
6565
env:
6666
RELEASE_BRANCH: changeset-release/main
67-
# Workflows that must leave the approval queue before this job is done. Both
68-
# trigger on `pull_request` to main with no path filters, so both always run
69-
# on the release PR; keep this list in step with them. Other pending runs are
70-
# still approved, just not waited for.
71-
EXPECTED_WORKFLOWS: ci npm-package-existence
67+
# Workflows that must leave the approval queue before this job is done. They
68+
# all trigger on `pull_request` to main with no path filters, so they always
69+
# run on the release PR; keep this list in step with them. Other pending runs
70+
# are still approved, just not waited for.
71+
EXPECTED_WORKFLOWS: ci npm-package-existence release-dry-run
7272
steps:
7373
- name: Approve workflow runs awaiting approval
7474
env:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: release-dry-run
2+
3+
on:
4+
# Run everything release-npm-packages.yml does except the publish itself, for
5+
# verification: a packaging problem then fails a check on the release PR instead
6+
# of surfacing part-way through a release. Changesets has no built-in `publish
7+
# --dry-run` (changesets/changesets#614), so `changeset publish-plan` and
8+
# `changeset pack` stand in for it. Provenance and trusted publishing are not
9+
# covered, since both need an actual upload from a push to main.
10+
#
11+
# Keep this workflow listed in EXPECTED_WORKFLOWS in create-release-pr.yml,
12+
# which is what approves the runs awaiting approval on the release PR.
13+
pull_request:
14+
branches:
15+
- main
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
dry-run-release:
22+
name: Dry-run the release
23+
runs-on: ubuntu-latest
24+
if: github.head_ref == 'changeset-release/main'
25+
steps:
26+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27+
with:
28+
ref: ${{ github.event.pull_request.head.sha }}
29+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
30+
with:
31+
node-version: 24
32+
cache: "yarn"
33+
- name: Install dependencies
34+
run: yarn install
35+
# Written under $RUNNER_TEMP rather than the workspace, so nothing packed here
36+
# can be mistaken for a change to the tree under test. Set in a step because
37+
# the runner context is not available in the job's `env`.
38+
- name: Set up dry-run paths
39+
run: |
40+
set -euo pipefail
41+
dir="$RUNNER_TEMP/release-dry-run"
42+
echo "PUBLISH_PLAN=$dir/publish-plan.json" >> "$GITHUB_ENV"
43+
echo "PACK_DIR=$dir/pack" >> "$GITHUB_ENV"
44+
# Before the build, since it needs no artifacts: an unresolvable plan then
45+
# fails in seconds rather than after a full build. Written out so the pack
46+
# below releases exactly this plan.
47+
- name: Resolve the publish plan
48+
run: yarn changeset publish-plan --output "$PUBLISH_PLAN"
49+
- name: Build Release Artifacts
50+
run: yarn build:release
51+
# Runs the same per-package pack that `changeset publish` would, which is
52+
# where a package.json that publishes only in theory gives way: the
53+
# publishConfig.directory incompatibility that broke a release in #2235 fails
54+
# this step.
55+
- name: Pack every package the release would publish
56+
run: yarn changeset pack --from-publish-plan "$PUBLISH_PLAN" --out-dir "$PACK_DIR"
57+
# Packing an unbuilt tree matches no files and still succeeds, so the step
58+
# above passing is not by itself evidence that the tarballs are usable.
59+
- name: Verify the packed tarballs
60+
run: node ./scripts/npm-packages/verify-packed-tarballs.mts "$PACK_DIR"
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Checks that the tarballs `changeset pack` produced contain the files their
5+
* entry points target, and declare no `workspace:` dependency ranges.
6+
*
7+
* Packing a tree that was never built matches none of the dist directories that
8+
* `files` selects, which yarn reports as success, so a passing `changeset pack`
9+
* is not by itself evidence that a release would ship anything importable.
10+
* Everything is read back out of the tarball, so what is checked is what a
11+
* consumer would install.
12+
*
13+
* The `browser`, `react-native` and `exports` maps are left alone: they are
14+
* condition tables over the same directories main, module and types cover, and
15+
* are already validated at build time by scripts/validation (submodules-linter,
16+
* esm-compat).
17+
*
18+
* Runs directly via Node type stripping (Node >= 24, no build step).
19+
*
20+
* Usage:
21+
* node verify-packed-tarballs.mts <packDir>
22+
*
23+
* where <packDir> is the --out-dir given to `changeset pack`.
24+
*/
25+
26+
import { execFileSync } from "node:child_process";
27+
import fs from "node:fs";
28+
import path from "node:path";
29+
30+
import { fail } from "./shared.mts";
31+
32+
/** The shape `changeset pack` writes; only the fields used here are described. */
33+
interface PackedRelease {
34+
kind: "publish" | "tag-only";
35+
name: string;
36+
version: string;
37+
/** Present on publish releases once packed, relative to the pack directory. */
38+
tarball?: { path: string; integrity: string };
39+
}
40+
41+
interface PackedPlan {
42+
version: number;
43+
plan: PackedRelease[][];
44+
}
45+
46+
/**
47+
* Manifest fields pointing at a single built file, paired with the directory each
48+
* one proves made it into the tarball. Every publishable package declares all
49+
* three, so a manifest missing one is reported rather than skipped.
50+
*/
51+
const REQUIRED_ENTRY_POINTS = [
52+
{ field: "main", proves: "dist-cjs" },
53+
{ field: "module", proves: "dist-es" },
54+
{ field: "types", proves: "dist-types" },
55+
] as const;
56+
57+
/** Manifest fields whose ranges an installing consumer has to be able to resolve. */
58+
const DEPENDENCY_FIELDS = ["dependencies", "peerDependencies", "optionalDependencies"] as const;
59+
60+
const packDirArg = process.argv[2];
61+
if (!packDirArg || process.argv.length > 3) {
62+
fail("Usage: node verify-packed-tarballs.mts <packDir>");
63+
}
64+
65+
const packDir = path.resolve(packDirArg);
66+
const planPath = path.join(packDir, "publish-plan.json");
67+
if (!fs.existsSync(planPath)) {
68+
fail(`No publish plan at ${planPath}. Run \`changeset pack --out-dir ${packDirArg}\` first.`);
69+
}
70+
71+
const { plan } = JSON.parse(fs.readFileSync(planPath, "utf-8")) as PackedPlan;
72+
const releases = plan.flat().filter((release) => release.kind === "publish");
73+
74+
if (releases.length === 0) {
75+
// A release PR always has packages to publish, so an empty plan means the pack
76+
// ran against the wrong tree.
77+
fail(`${planPath} covers no packages to publish.`);
78+
}
79+
80+
/** Paths inside a tarball, with npm's leading "package/" component removed. */
81+
function listTarballContents(tarballPath: string): Set<string> {
82+
const stdout = execFileSync("tar", ["-tzf", tarballPath], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
83+
return new Set(
84+
stdout
85+
.split("\n")
86+
.filter(Boolean)
87+
.map((entry) => entry.replace(/^\.?\/?package\//, "").replace(/\/$/, ""))
88+
);
89+
}
90+
91+
/** The packed manifest, which is what an installing consumer actually reads. */
92+
function readPackedManifest(tarballPath: string): Record<string, unknown> {
93+
const stdout = execFileSync("tar", ["-xzOf", tarballPath, "package/package.json"], {
94+
encoding: "utf-8",
95+
maxBuffer: 8 * 1024 * 1024,
96+
});
97+
return JSON.parse(stdout) as Record<string, unknown>;
98+
}
99+
100+
const errors: string[] = [];
101+
const verified: string[] = [];
102+
103+
for (const release of releases) {
104+
const label = `${release.name}@${release.version}`;
105+
const errorsBefore = errors.length;
106+
107+
if (!release.tarball) {
108+
errors.push(`${label}: the publish plan has no tarball for it, so \`changeset pack\` did not pack it.`);
109+
continue;
110+
}
111+
112+
const tarballPath = path.join(packDir, release.tarball.path);
113+
if (!fs.existsSync(tarballPath)) {
114+
errors.push(`${label}: the publish plan points at ${release.tarball.path}, which does not exist.`);
115+
continue;
116+
}
117+
118+
let contents: Set<string>;
119+
let manifest: Record<string, unknown>;
120+
try {
121+
contents = listTarballContents(tarballPath);
122+
manifest = readPackedManifest(tarballPath);
123+
} catch (error) {
124+
errors.push(`${label}: could not read ${release.tarball.path}: ${(error as Error).message}`);
125+
continue;
126+
}
127+
128+
// A mismatch means the plan and the tarball disagree about what is released.
129+
if (manifest.version !== release.version) {
130+
errors.push(`${label}: the packed manifest declares version ${String(manifest.version)}.`);
131+
}
132+
133+
// Internal dependencies are declared as `workspace:^`, which only the pack tool
134+
// resolves to a real range. One left unresolved is unresolvable to anyone
135+
// installing the package.
136+
for (const field of DEPENDENCY_FIELDS) {
137+
const deps = manifest[field];
138+
if (typeof deps !== "object" || deps === null) {
139+
continue;
140+
}
141+
for (const [dependency, range] of Object.entries(deps as Record<string, unknown>)) {
142+
if (typeof range === "string" && range.startsWith("workspace:")) {
143+
errors.push(`${label}: "${field}"."${dependency}" is still ${range} in the packed manifest.`);
144+
}
145+
}
146+
}
147+
148+
for (const { field, proves } of REQUIRED_ENTRY_POINTS) {
149+
const declared = manifest[field];
150+
if (typeof declared !== "string") {
151+
errors.push(`${label}: the packed manifest declares no "${field}", so its ${proves} output is unverifiable.`);
152+
continue;
153+
}
154+
const entry = declared.replace(/^\.\//, "");
155+
if (!contents.has(entry)) {
156+
errors.push(`${label}: "${field}" is ${declared} but ${entry} is not in ${release.tarball.path}.`);
157+
}
158+
}
159+
160+
if (errors.length === errorsBefore) {
161+
verified.push(label);
162+
}
163+
}
164+
165+
if (errors.length) {
166+
fail(
167+
`${errors.length} problem(s) with the packed tarballs in ${packDir}. A release from this commit would publish ` +
168+
`packages that consumers cannot use:\n ${errors.join("\n ")}`
169+
);
170+
}
171+
172+
console.log(
173+
`✅ All ${verified.length} packed tarball(s) carry the entry points their manifests declare, at the version the ` +
174+
`plan releases, with every dependency range resolved:\n ` +
175+
verified.join("\n ")
176+
);

0 commit comments

Comments
 (0)