Skip to content

Commit e5dd300

Browse files
committed
test(release): add npm publish state checker
1 parent 54f88a1 commit e5dd300

5 files changed

Lines changed: 279 additions & 0 deletions

File tree

docs/release/beta-checklist.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Use `npm run release:qa:record -- --matrix runtime --list` to inspect native QA
5454

5555
Use `npm run release:native-workflow:record -- --list` to inspect native release workflow evidence. After a green workflow run, record the run URL, commit, and both platform artifact links with `--run-url`, `--commit`, `--ios-artifact`, and `--android-artifact`.
5656

57+
Use `npm run release:publish:status` to compare the Developer Preview package manifest against actual npm registry state. It should report the scoped free packages and root compatibility package under `next`, while `@chart-kit/pro` and `@chart-kit/skia-renderer` remain unpublished for Developer Preview. Add `-- --strict` when a complete publish is expected.
58+
5759
Use `npm run release:owner:record -- --list` to inspect H4/H5/H6 owner gates. Owner approval should be recorded through the same command with `--gate`, `--approved-by`, and one `--decision` value for each pending decision.
5860

5961
## Manual Review

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"release:qa:checklists:check": "node scripts/generate-native-qa-checklists.mjs --check",
8888
"release:qa:record": "node scripts/record-native-qa-evidence.mjs",
8989
"release:owner:record": "node scripts/record-owner-gate-decision.mjs",
90+
"release:publish:status": "node scripts/check-npm-publish-state.mjs",
9091
"release:gate": "node scripts/check-release-gates.mjs --strict",
9192
"release:gate:report": "node scripts/check-release-gates.mjs",
9293
"docs:build": "node scripts/docs-build.mjs && node scripts/typecheck-doc-examples.mjs",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { spawnSync } from "node:child_process";
2+
import { readFile } from "node:fs/promises";
3+
import path from "node:path";
4+
import process from "node:process";
5+
6+
const defaultManifestPath = "docs/release/evidence/package-manifest.json";
7+
8+
export const readJson = async (filePath) =>
9+
JSON.parse(await readFile(filePath, "utf8"));
10+
11+
export const npmViewPackage = ({ name, version }) => {
12+
const result = spawnSync(
13+
"npm",
14+
["view", `${name}@${version}`, "version", "dist-tags", "--json"],
15+
{
16+
encoding: "utf8"
17+
}
18+
);
19+
20+
if (result.status !== 0) {
21+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
22+
return {
23+
error: output.trim(),
24+
exists: false
25+
};
26+
}
27+
28+
return {
29+
exists: true,
30+
value: JSON.parse(result.stdout)
31+
};
32+
};
33+
34+
const normalizeNpmView = (viewResult) => {
35+
if (!viewResult.exists) {
36+
return {
37+
distTags: {},
38+
published: false,
39+
version: undefined
40+
};
41+
}
42+
43+
return {
44+
distTags: viewResult.value?.["dist-tags"] ?? {},
45+
published: true,
46+
version: viewResult.value?.version
47+
};
48+
};
49+
50+
export const buildNpmPublishState = async ({
51+
distTag,
52+
manifest,
53+
npmView = npmViewPackage,
54+
version
55+
}) => {
56+
const packages = manifest.packages ?? [];
57+
58+
if (!Array.isArray(packages) || packages.length === 0) {
59+
throw new Error("Package manifest must define at least one package.");
60+
}
61+
62+
const entries = [];
63+
64+
for (const packageInfo of packages) {
65+
if (!packageInfo.name) {
66+
throw new Error("Package manifest entries must include name.");
67+
}
68+
69+
const viewResult = normalizeNpmView(
70+
await npmView({ name: packageInfo.name, version })
71+
);
72+
const taggedVersion = viewResult.distTags[distTag];
73+
74+
let status = "pass";
75+
const expected = packageInfo.publishInBeta
76+
? `published under ${distTag}`
77+
: "unpublished for Developer Preview";
78+
79+
if (packageInfo.publishInBeta && !viewResult.published) {
80+
status = "missing";
81+
} else if (packageInfo.publishInBeta && taggedVersion !== version) {
82+
status = "wrong-dist-tag";
83+
} else if (!packageInfo.publishInBeta && viewResult.published) {
84+
status = "unexpected-published";
85+
}
86+
87+
entries.push({
88+
distTag,
89+
expected,
90+
name: packageInfo.name,
91+
publishInBeta: packageInfo.publishInBeta === true,
92+
published: viewResult.published,
93+
status,
94+
taggedVersion,
95+
version,
96+
visibleVersion: viewResult.version
97+
});
98+
}
99+
100+
const failures = entries.filter((entry) => entry.status !== "pass");
101+
const missingPublishable = failures.filter((entry) =>
102+
["missing", "wrong-dist-tag"].includes(entry.status)
103+
);
104+
const unexpectedPublished = failures.filter(
105+
(entry) => entry.status === "unexpected-published"
106+
);
107+
108+
return {
109+
distTag,
110+
entries,
111+
status:
112+
failures.length === 0
113+
? "complete"
114+
: unexpectedPublished.length > 0
115+
? "failed"
116+
: missingPublishable.length > 0
117+
? "partial"
118+
: "failed",
119+
version
120+
};
121+
};
122+
123+
const formatState = (state) => {
124+
const rows = state.entries.map((entry) => {
125+
const tagText = entry.taggedVersion
126+
? `${entry.distTag}=${entry.taggedVersion}`
127+
: `${entry.distTag}=<missing>`;
128+
const publishedText = entry.published ? "published" : "not published";
129+
130+
return `- ${entry.name}@${entry.version}: ${entry.status} (${publishedText}, ${tagText}; expected ${entry.expected})`;
131+
});
132+
133+
return [
134+
`NPM publish state: ${state.status}`,
135+
`Version: ${state.version}`,
136+
`Dist-tag: ${state.distTag}`,
137+
...rows
138+
].join("\n");
139+
};
140+
141+
export const main = async () => {
142+
const args = new Set(process.argv.slice(2));
143+
const repoRoot = process.cwd();
144+
const packageJson = await readJson(path.join(repoRoot, "package.json"));
145+
const manifest = await readJson(path.join(repoRoot, defaultManifestPath));
146+
const state = await buildNpmPublishState({
147+
distTag: manifest.distTag ?? "next",
148+
manifest,
149+
version: packageJson.version
150+
});
151+
152+
if (args.has("--json")) {
153+
console.log(JSON.stringify(state, null, 2));
154+
} else {
155+
console.log(formatState(state));
156+
}
157+
158+
if (args.has("--strict") && state.status !== "complete") {
159+
process.exit(1);
160+
}
161+
};
162+
163+
if (import.meta.url === `file://${process.argv[1]}`) {
164+
main().catch((error) => {
165+
console.error(error instanceof Error ? error.message : String(error));
166+
process.exit(1);
167+
});
168+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { buildNpmPublishState } from "./check-npm-publish-state.mjs";
4+
5+
const manifest = {
6+
distTag: "next",
7+
packages: [
8+
{
9+
name: "@chart-kit/core",
10+
publishInBeta: true
11+
},
12+
{
13+
name: "@chart-kit/react-native",
14+
publishInBeta: true
15+
},
16+
{
17+
name: "react-native-chart-kit",
18+
publishInBeta: true
19+
},
20+
{
21+
name: "@chart-kit/pro",
22+
publishInBeta: false
23+
}
24+
]
25+
};
26+
27+
const createNpmView = (publishedPackages) => async ({ name, version }) => {
28+
const value = publishedPackages[name];
29+
30+
if (!value) {
31+
return {
32+
exists: false
33+
};
34+
}
35+
36+
return {
37+
exists: true,
38+
value: {
39+
"dist-tags": value.distTags ?? { next: version },
40+
version: value.version ?? version
41+
}
42+
};
43+
};
44+
45+
describe("npm publish state checker", () => {
46+
it("marks publish state complete when free packages are published and preview packages are not", async () => {
47+
const state = await buildNpmPublishState({
48+
distTag: "next",
49+
manifest,
50+
npmView: createNpmView({
51+
"@chart-kit/core": {},
52+
"@chart-kit/react-native": {},
53+
"react-native-chart-kit": {}
54+
}),
55+
version: "7.0.0-next.0"
56+
});
57+
58+
expect(state.status).toBe("complete");
59+
expect(state.entries.map((entry) => [entry.name, entry.status])).toEqual([
60+
["@chart-kit/core", "pass"],
61+
["@chart-kit/react-native", "pass"],
62+
["react-native-chart-kit", "pass"],
63+
["@chart-kit/pro", "pass"]
64+
]);
65+
});
66+
67+
it("marks publish state partial when a publishable package is missing", async () => {
68+
const state = await buildNpmPublishState({
69+
distTag: "next",
70+
manifest,
71+
npmView: createNpmView({
72+
"react-native-chart-kit": {}
73+
}),
74+
version: "7.0.0-next.0"
75+
});
76+
77+
expect(state.status).toBe("partial");
78+
expect(state.entries.map((entry) => [entry.name, entry.status])).toEqual([
79+
["@chart-kit/core", "missing"],
80+
["@chart-kit/react-native", "missing"],
81+
["react-native-chart-kit", "pass"],
82+
["@chart-kit/pro", "pass"]
83+
]);
84+
});
85+
86+
it("fails when a package has the wrong dist-tag or a preview package is published", async () => {
87+
const state = await buildNpmPublishState({
88+
distTag: "next",
89+
manifest,
90+
npmView: createNpmView({
91+
"@chart-kit/core": { distTags: { beta: "7.0.0-next.0" } },
92+
"@chart-kit/pro": {},
93+
"@chart-kit/react-native": {},
94+
"react-native-chart-kit": {}
95+
}),
96+
version: "7.0.0-next.0"
97+
});
98+
99+
expect(state.status).toBe("failed");
100+
expect(state.entries.map((entry) => [entry.name, entry.status])).toEqual([
101+
["@chart-kit/core", "wrong-dist-tag"],
102+
["@chart-kit/react-native", "pass"],
103+
["react-native-chart-kit", "pass"],
104+
["@chart-kit/pro", "unexpected-published"]
105+
]);
106+
});
107+
});

scripts/check-release-gates.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const requiredScripts = [
7979
"release:qa:checklists:check",
8080
"release:qa:record",
8181
"release:owner:record",
82+
"release:publish:status",
8283
"release:gate",
8384
"release:gate:report"
8485
];

0 commit comments

Comments
 (0)