Skip to content

Commit 888d8ef

Browse files
authored
Add release artifact verification gate (#77)
Merge criteria satisfied: full GitHub check list green (unit-tests, e2e-tests, verify-artifacts, claude-review, Cursor Bugbot, CodeRabbit; Claude workflow skipped), mergeStateStatus CLEAN / mergeable MERGEABLE, reviewDecision APPROVED, and all review threads resolved or explicitly triaged. Local evidence included bash -n scripts/verify-release.sh, yarn verify:artifacts, yarn build, actionlint .github/workflows/artifact-checks.yml, and git diff --check. Workflow Change Audit evidence was posted in the PR. No npm publish, tag, release, or registry mutation was run.
1 parent 36e2536 commit 888d8ef

5 files changed

Lines changed: 607 additions & 10 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Verify package artifacts
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
verify-artifacts:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 15
16+
steps:
17+
# Pinned immutable actions/checkout release SHA verified via git ls-remote.
18+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
19+
- name: Use Node.js
20+
# Pinned immutable actions/setup-node release SHA verified via git ls-remote.
21+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
22+
with:
23+
node-version: '20.x'
24+
cache: yarn
25+
- name: Install packages using yarn
26+
run: yarn --frozen-lockfile
27+
- name: Verify package artifacts
28+
run: yarn verify:artifacts

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*\\.rsc\\.test\\..*\"",
8080
"test:e2e": "bash scripts/e2e/run.sh",
8181
"build": "rm -rf dist tsconfig.tsbuildinfo && yarn run tsc",
82+
"verify:artifacts": "bash scripts/verify-release.sh",
8283
"prepublishOnly": "yarn run build",
8384
"release": "bash scripts/release.sh",
8485
"release:dry-run": "bash scripts/release.sh --dry-run",
@@ -87,6 +88,7 @@
8788
"prepare": "yarn run build-if-needed"
8889
},
8990
"devDependencies": {
91+
"@arethetypeswrong/cli": "0.18.3",
9092
"@rspack/core": "^1.0.0",
9193
"@tsconfig/node14": "^14.1.2",
9294
"@types/jest": "^30.0.0",
@@ -95,6 +97,7 @@
9597
"css-loader": "^7.1.4",
9698
"jest": "^29.7.0",
9799
"mini-css-extract-plugin": "^2.10.2",
100+
"publint": "0.3.21",
98101
"react": "^19.2.0",
99102
"react-dom": "^19.2.0",
100103
"release-it": "20.2.0",

scripts/release.sh

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,23 +290,22 @@ preflight_checks() {
290290

291291
run_tests() {
292292
if [[ "$SKIP_TESTS" == true ]]; then
293-
log_warn "Skipping tests, build, and npm pack checks (--skip-tests)."
293+
log_warn "Skipping tests and artifact verification (--skip-tests)."
294294
return
295295
fi
296296

297297
echo ""
298-
log_info "Running tests, build, and npm pack dry run..."
298+
log_info "Running tests and artifact verification..."
299299
yarn test
300-
yarn run build
301-
npm pack --dry-run
300+
yarn run verify:artifacts
302301

303302
if ! git diff --quiet || ! git diff --cached --quiet; then
304303
log_error "Tests/build changed tracked files. Commit or revert those changes before releasing."
305304
git status --short
306305
exit 1
307306
fi
308307

309-
log_info "Tests, build, and npm pack dry run passed."
308+
log_info "Tests and artifact verification passed."
310309
}
311310

312311
show_summary_and_confirm() {

scripts/verify-release.sh

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5+
cd "$ROOT_DIR"
6+
7+
export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-${npm_config_cache:-${TMPDIR:-/tmp}/react-on-rails-rsc-npm-cache}}"
8+
9+
TMP_DIR=""
10+
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ror-rsc-artifacts.XXXXXX")"
11+
cleanup() {
12+
if [[ -n "${TMP_DIR:-}" ]]; then
13+
rm -rf "$TMP_DIR"
14+
fi
15+
}
16+
trap cleanup EXIT
17+
18+
log() {
19+
printf '\n[verify-release] %s\n' "$*"
20+
}
21+
22+
NODE_MAJOR="$(node -p "Number(process.versions.node.split('.')[0])")"
23+
if (( NODE_MAJOR < 20 )); then
24+
printf 'verify-release requires Node.js 20 or newer for import.meta.resolve export checks. Found %s.\n' "$(node -p "process.versions.node")" >&2
25+
exit 1
26+
fi
27+
28+
log "Building distributable files"
29+
yarn run build
30+
31+
log "Packing npm artifact"
32+
PACK_JSON="$TMP_DIR/npm-pack.json"
33+
npm pack --json --pack-destination "$TMP_DIR" > "$PACK_JSON"
34+
TARBALL="$(
35+
node - "$PACK_JSON" "$TMP_DIR" <<'NODE'
36+
const fs = require('fs');
37+
const path = require('path');
38+
39+
const [packJsonPath, packDestination] = process.argv.slice(2);
40+
const rawPackOutput = fs.readFileSync(packJsonPath, 'utf8');
41+
const packOutputLines = rawPackOutput.split(/\r?\n/);
42+
let packOutput;
43+
44+
for (const [lineIndex, line] of packOutputLines.entries()) {
45+
if (!line.trimStart().startsWith('[')) {
46+
continue;
47+
}
48+
49+
try {
50+
packOutput = JSON.parse(packOutputLines.slice(lineIndex).join('\n'));
51+
break;
52+
} catch {
53+
// npm can print lifecycle output before --json output; keep searching.
54+
}
55+
}
56+
57+
if (!packOutput) {
58+
throw new Error(`Unable to parse npm pack JSON output: ${rawPackOutput}`);
59+
}
60+
61+
if (!Array.isArray(packOutput) || packOutput.length !== 1 || !packOutput[0].filename) {
62+
throw new Error(`Unexpected npm pack output: ${JSON.stringify(packOutput)}`);
63+
}
64+
65+
console.log(path.resolve(packDestination, packOutput[0].filename));
66+
NODE
67+
)"
68+
test -f "$TARBALL"
69+
echo " - Tarball: $TARBALL"
70+
71+
log "Installing packed artifact into a temporary project"
72+
CONSUMER_DIR="$TMP_DIR/consumer"
73+
mkdir -p "$CONSUMER_DIR"
74+
(
75+
cd "$CONSUMER_DIR"
76+
yarn init -y >/dev/null
77+
yarn add --ignore-scripts --silent "$TARBALL"
78+
)
79+
80+
PACKAGE_DIR="$CONSUMER_DIR/node_modules/react-on-rails-rsc"
81+
82+
cat > "$TMP_DIR/verify-package-targets.cjs" <<'NODE'
83+
const fs = require('fs');
84+
const path = require('path');
85+
86+
const packageDir = process.argv[2];
87+
const packageJsonPath = path.join(packageDir, 'package.json');
88+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
89+
const packageRoot = fs.realpathSync(packageDir);
90+
91+
if (!pkg.exports || typeof pkg.exports !== 'object') {
92+
throw new Error('package.json must define an exports map');
93+
}
94+
95+
const targets = [];
96+
97+
function collectTargets(value, exportPath, conditionPath = []) {
98+
if (typeof value === 'string') {
99+
targets.push({ exportPath, conditionPath, target: value });
100+
return;
101+
}
102+
103+
if (value === null) {
104+
return;
105+
}
106+
107+
if (Array.isArray(value)) {
108+
for (const item of value) {
109+
collectTargets(item, exportPath, conditionPath);
110+
}
111+
return;
112+
}
113+
114+
if (typeof value === 'object') {
115+
for (const [condition, nestedValue] of Object.entries(value)) {
116+
collectTargets(nestedValue, exportPath, conditionPath.concat(condition));
117+
}
118+
return;
119+
}
120+
121+
throw new Error(`Unsupported exports value for ${exportPath}: ${JSON.stringify(value)}`);
122+
}
123+
124+
for (const [exportPath, value] of Object.entries(pkg.exports)) {
125+
collectTargets(value, exportPath);
126+
}
127+
128+
for (const { exportPath, conditionPath, target } of targets) {
129+
if (!target.startsWith('./')) {
130+
continue;
131+
}
132+
133+
if (target.includes('*')) {
134+
console.warn(` - Skipping wildcard export target: ${exportPath} -> ${target}`);
135+
continue;
136+
}
137+
138+
const resolvedTarget = path.resolve(packageDir, target);
139+
if (!fs.existsSync(resolvedTarget)) {
140+
const conditionLabel = conditionPath.length ? conditionPath.join('.') : '<root>';
141+
throw new Error(`Missing export target: ${exportPath} (${conditionLabel}) -> ${target}`);
142+
}
143+
144+
const realTarget = fs.realpathSync(resolvedTarget);
145+
if (!realTarget.startsWith(packageRoot + path.sep) && realTarget !== fs.realpathSync(packageJsonPath)) {
146+
throw new Error(`Export target escapes package root: ${exportPath} -> ${target}`);
147+
}
148+
}
149+
150+
const expectedRuntimeEntrypoints = [
151+
'.',
152+
'./client',
153+
'./client.browser',
154+
'./client.node',
155+
'./RSCReferenceDiscoveryPlugin',
156+
'./RspackLoader',
157+
'./RspackPlugin',
158+
'./server',
159+
'./server.node',
160+
'./WebpackLoader',
161+
'./WebpackPlugin',
162+
].sort();
163+
const runtimeEntrypoints = Object.keys(pkg.exports)
164+
.filter((exportPath) => exportPath !== './package.json')
165+
.sort();
166+
167+
if (JSON.stringify(runtimeEntrypoints) !== JSON.stringify(expectedRuntimeEntrypoints)) {
168+
throw new Error(
169+
[
170+
'Runtime export paths changed.',
171+
`Expected: ${expectedRuntimeEntrypoints.join(', ')}`,
172+
`Actual: ${runtimeEntrypoints.join(', ')}`,
173+
'Update the artifact verifier snapshot when the package export surface intentionally changes.',
174+
].join('\n')
175+
);
176+
}
177+
178+
console.log(` - Verified ${targets.length} export target files across ${runtimeEntrypoints.length} runtime paths plus package.json`);
179+
NODE
180+
181+
cat > "$TMP_DIR/resolve-exports.cjs" <<'NODE'
182+
const fs = require('fs');
183+
const path = require('path');
184+
185+
const packageDir = process.argv[2];
186+
const conditionLabel = process.argv[3] || 'default';
187+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
188+
const packageName = pkg.name;
189+
190+
for (const exportPath of Object.keys(pkg.exports)) {
191+
const specifier = exportPath === '.' ? packageName : `${packageName}/${exportPath.slice(2)}`;
192+
const resolved = require.resolve(specifier, { paths: [path.dirname(packageDir)] });
193+
console.log(` - ${conditionLabel} require.resolve ${specifier} -> ${path.relative(packageDir, resolved)}`);
194+
}
195+
NODE
196+
197+
IMPORT_RESOLVE_PROBE="$CONSUMER_DIR/import-resolve-exports.mjs"
198+
cat > "$IMPORT_RESOLVE_PROBE" <<'NODE'
199+
import fs from 'node:fs';
200+
import path from 'node:path';
201+
202+
const packageDir = process.argv[2];
203+
const conditionLabel = process.argv[3] || 'default';
204+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
205+
const packageName = pkg.name;
206+
207+
for (const exportPath of Object.keys(pkg.exports)) {
208+
const specifier = exportPath === '.' ? packageName : `${packageName}/${exportPath.slice(2)}`;
209+
const resolved = import.meta.resolve(specifier);
210+
console.log(` - ${conditionLabel} import.meta.resolve ${specifier} -> ${path.relative(packageDir, new URL(resolved).pathname)}`);
211+
}
212+
NODE
213+
214+
log "Verifying package exports and resolver conditions"
215+
node "$TMP_DIR/verify-package-targets.cjs" "$PACKAGE_DIR"
216+
node "$TMP_DIR/resolve-exports.cjs" "$PACKAGE_DIR" default
217+
node --conditions=react-server "$TMP_DIR/resolve-exports.cjs" "$PACKAGE_DIR" react-server
218+
node "$IMPORT_RESOLVE_PROBE" "$PACKAGE_DIR" default
219+
node --conditions=react-server "$IMPORT_RESOLVE_PROBE" "$PACKAGE_DIR" react-server
220+
221+
cat > "$TMP_DIR/verify-runtime-version.cjs" <<'NODE'
222+
const fs = require('fs');
223+
const path = require('path');
224+
225+
const packageDir = process.argv[2];
226+
const runtimePackagePath = path.join(packageDir, 'dist/react-server-dom-webpack/package.json');
227+
228+
function readFileSafe(filePath, context) {
229+
try {
230+
return fs.readFileSync(filePath, 'utf8');
231+
} catch (error) {
232+
throw new Error(
233+
`verify-runtime-version: cannot read ${filePath} (${context}); has the vendored runtime layout changed?\n` +
234+
` ${error.message}`
235+
);
236+
}
237+
}
238+
239+
const rootPackage = JSON.parse(readFileSafe(path.join(packageDir, 'package.json'), 'root package.json'));
240+
const runtimePackage = JSON.parse(readFileSafe(runtimePackagePath, 'runtime package.json'));
241+
const runtimeVersion = runtimePackage.version;
242+
const expectedPeerRange = `^${runtimeVersion}`;
243+
244+
function assertEqual(actual, expected, label) {
245+
if (actual !== expected) {
246+
throw new Error(`${label} expected ${expected}, got ${actual}`);
247+
}
248+
}
249+
250+
assertEqual(rootPackage.peerDependencies?.react, expectedPeerRange, 'root peerDependencies.react');
251+
assertEqual(rootPackage.peerDependencies?.['react-dom'], expectedPeerRange, 'root peerDependencies.react-dom');
252+
assertEqual(runtimePackage.peerDependencies?.react, expectedPeerRange, 'runtime peerDependencies.react');
253+
assertEqual(runtimePackage.peerDependencies?.['react-dom'], expectedPeerRange, 'runtime peerDependencies.react-dom');
254+
255+
const runtimeDevelopmentBundle = readFileSafe(
256+
path.join(
257+
packageDir,
258+
'dist/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js'
259+
),
260+
'development bundle'
261+
);
262+
263+
for (const marker of [`version: "${runtimeVersion}"`, `reconcilerVersion: "${runtimeVersion}"`]) {
264+
if (!runtimeDevelopmentBundle.includes(marker)) {
265+
throw new Error(`Packaged runtime bundle does not contain ${marker}`);
266+
}
267+
}
268+
269+
console.log(` - Runtime version ${runtimeVersion} matches peer policy ${expectedPeerRange}`);
270+
NODE
271+
272+
log "Verifying embedded React runtime version policy"
273+
node "$TMP_DIR/verify-runtime-version.cjs" "$PACKAGE_DIR"
274+
275+
log "Running publint"
276+
yarn run publint "$TARBALL"
277+
278+
log "Running Are The Types Wrong"
279+
# Keep the Node 16 profile to catch legacy Node resolver regressions; this
280+
# package still supports consumers on older bundler/runtime stacks.
281+
yarn run attw "$TARBALL" \
282+
--profile node16 \
283+
--ignore-rules cjs-only-exports-default \
284+
--format table \
285+
--no-emoji \
286+
--no-color
287+
288+
log "Artifact verification passed"

0 commit comments

Comments
 (0)