Skip to content

Commit df1972d

Browse files
committed
fix(release): verify no-build package releases
1 parent 381800e commit df1972d

6 files changed

Lines changed: 186 additions & 6 deletions

File tree

‎.async-pipeline/tasks.lock.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,5 @@
119119
}
120120
],
121121
"hash": "sha256:7d099417f0ce8e0120da48fc2282a8f7fdc1e29b39c17731385badb56bba35b2",
122-
"generatedAt": "2026-06-17T06:08:08.520Z"
122+
"generatedAt": "2026-06-17T08:18:18.886Z"
123123
}

‎.github/async-pipeline.lock.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"config": "pipeline.ts",
55
"workflow": ".github/workflows/async-pipeline.yml",
66
"hash": "sha256:0c6d60bde2ebaf956f3a0add90c04e7522b71e407e64b9f5779b0df58115e663",
7-
"generatedAt": "2026-06-17T06:08:08.378Z",
7+
"generatedAt": "2026-06-17T08:18:18.746Z",
88
"triggers": {
99
"pull_request": {},
1010
"push": {

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 0.2.1 - 2026-06-17
4+
5+
- Fixed the generated release pipeline for the no-build package by verifying
6+
npm and GitHub Release parity without requiring a GitHub Packages mirror.
7+
38
## 0.2.0 - 2026-06-17
49

510
- Added `csr` router mode for client-side first route rendering plus local SPA

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@async/framework",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "No-build AsyncLoader app runtime with signals, command events, server calls, route partials, cache split, SSR activation, and streaming boundaries.",
55
"type": "module",
66
"packageManager": "pnpm@11.1.0",

‎pipeline.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export default definePipeline({
4141
"README.md",
4242
"CHANGELOG.md",
4343
"package.json",
44-
"pipeline.ts"
44+
"pipeline.ts",
45+
"scripts/**/*.js"
4546
]
4647
},
4748

@@ -91,7 +92,7 @@ export default definePipeline({
9192
cache: false,
9293
run: [
9394
sh`pnpm async-pipeline publish npm --package ${packagePath}`,
94-
sh`pnpm async-pipeline release doctor --package ${packagePath}`
95+
sh`node scripts/release-doctor.js`
9596
]
9697
}),
9798

@@ -100,7 +101,7 @@ export default definePipeline({
100101
dependsOn: ["pack"],
101102
inputs: ["source"],
102103
cache: false,
103-
run: sh`pnpm async-pipeline release doctor --package ${packagePath}`
104+
run: sh`node scripts/release-doctor.js`
104105
})
105106
},
106107

‎scripts/release-doctor.js‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from "node:child_process";
3+
import { readFile } from "node:fs/promises";
4+
5+
const NPM_REGISTRY = "https://registry.npmjs.org";
6+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
7+
8+
main().catch((error) => {
9+
console.error(`::error::${error instanceof Error ? error.message : String(error)}`);
10+
process.exitCode = 1;
11+
});
12+
13+
async function main() {
14+
const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
15+
const changelog = await readFile(new URL("../CHANGELOG.md", import.meta.url), "utf8");
16+
const version = manifest.version;
17+
const tagName = `v${version}`;
18+
const expectedSha = expectedGitSha();
19+
const releaseBody = readReleaseBody(changelog, version);
20+
const repository = process.env.GITHUB_REPOSITORY ?? packageRepositoryName(manifest);
21+
22+
if (!repository) {
23+
throw new Error("Set GITHUB_REPOSITORY or package.json repository so release doctor can resolve GitHub state.");
24+
}
25+
26+
const npmInfo = await waitForNpmPackage(manifest.name, version);
27+
if (npmInfo.gitHead && npmInfo.gitHead !== expectedSha) {
28+
throw new Error(`npm gitHead for ${manifest.name}@${version} is ${npmInfo.gitHead}; expected ${expectedSha}.`);
29+
}
30+
console.log(`OK npm: ${manifest.name}@${version}`);
31+
32+
const token = process.env.GITHUB_TOKEN;
33+
if (!token) {
34+
throw new Error("Set GITHUB_TOKEN so release doctor can verify the GitHub Release.");
35+
}
36+
37+
const release = await githubApi(repository, `/releases/tags/${encodeURIComponent(tagName)}`, token);
38+
const tagSha = await githubTagSha(repository, tagName, token);
39+
if (tagSha !== expectedSha) {
40+
throw new Error(`Git tag ${tagName} points to ${tagSha}; expected ${expectedSha}.`);
41+
}
42+
console.log(`OK Git tag: ${repository}@${tagName}`);
43+
44+
if (normalizeReleaseBody(release.body ?? "") !== normalizeReleaseBody(releaseBody)) {
45+
throw new Error(`GitHub Release ${tagName} description does not match CHANGELOG.md.`);
46+
}
47+
console.log(`OK GitHub Release: ${repository}@${tagName}`);
48+
console.log(`Release doctor passed for ${manifest.name}@${version}.`);
49+
}
50+
51+
async function waitForNpmPackage(name, version) {
52+
const spec = `${name}@${version}`;
53+
const attempts = positiveInt(process.env.ASYNC_PIPELINE_RELEASE_DOCTOR_REGISTRY_ATTEMPTS, 12);
54+
const delayMs = positiveInt(process.env.ASYNC_PIPELINE_RELEASE_DOCTOR_REGISTRY_RETRY_DELAY_MS, 5000);
55+
let last;
56+
57+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
58+
const view = spawnSync("npm", ["view", spec, "version", "gitHead", "--json", "--registry", NPM_REGISTRY], {
59+
encoding: "utf8",
60+
stdio: ["ignore", "pipe", "pipe"],
61+
env: {
62+
...process.env,
63+
NPM_CONFIG_CACHE: process.env.NPM_CONFIG_CACHE ?? ".async/npm-cache"
64+
}
65+
});
66+
last = view;
67+
if (view.status === 0) {
68+
const info = JSON.parse(view.stdout);
69+
if (info.version !== version) {
70+
throw new Error(`npm returned ${info.version} for ${spec}.`);
71+
}
72+
return info;
73+
}
74+
if (!isMissingVersion(view) || attempt === attempts) {
75+
throw new Error(`Release doctor could not find ${spec} on npm: ${npmOutput(view).slice(0, 500)}`);
76+
}
77+
console.log(`Waiting for npm to expose ${spec} (${attempt}/${attempts}).`);
78+
await sleep(delayMs);
79+
}
80+
81+
throw new Error(`Release doctor could not find ${spec} on npm: ${npmOutput(last).slice(0, 500)}`);
82+
}
83+
84+
async function githubTagSha(repository, tagName, token) {
85+
const ref = await githubApi(repository, `/git/ref/tags/${encodeURIComponent(tagName)}`, token);
86+
const object = ref.object ?? {};
87+
if (object.type === "commit" && typeof object.sha === "string") {
88+
return object.sha;
89+
}
90+
if (object.type === "tag" && typeof object.sha === "string") {
91+
const tag = await githubApi(repository, `/git/tags/${object.sha}`, token);
92+
if (tag.object?.type === "commit" && typeof tag.object.sha === "string") {
93+
return tag.object.sha;
94+
}
95+
}
96+
throw new Error(`Git tag ${tagName} points to an unsupported Git object.`);
97+
}
98+
99+
async function githubApi(repository, path, token) {
100+
const apiBase = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
101+
const response = await fetch(`${apiBase}/repos/${repository}${path}`, {
102+
headers: {
103+
authorization: `Bearer ${token}`,
104+
accept: "application/vnd.github+json",
105+
"x-github-api-version": "2022-11-28"
106+
}
107+
});
108+
if (!response.ok) {
109+
const text = await response.text();
110+
throw new Error(`GitHub API GET /repos/${repository}${path} failed with ${response.status}: ${text.slice(0, 500)}`);
111+
}
112+
return response.json();
113+
}
114+
115+
function expectedGitSha() {
116+
if (SHA_PATTERN.test(process.env.GITHUB_SHA ?? "")) {
117+
return process.env.GITHUB_SHA;
118+
}
119+
const result = spawnSync("git", ["rev-parse", "HEAD"], {
120+
encoding: "utf8",
121+
stdio: ["ignore", "pipe", "pipe"]
122+
});
123+
if (result.status !== 0 || !SHA_PATTERN.test(result.stdout.trim())) {
124+
throw new Error("Could not resolve the expected release commit SHA.");
125+
}
126+
return result.stdout.trim();
127+
}
128+
129+
function readReleaseBody(changelog, version) {
130+
const start = changelog.indexOf(`## ${version} - `);
131+
if (start === -1) {
132+
throw new Error(`CHANGELOG.md has no "## ${version} - <date>" entry.`);
133+
}
134+
const bodyStart = changelog.indexOf("\n", start) + 1;
135+
const next = changelog.indexOf("\n## ", bodyStart);
136+
const body = changelog.slice(bodyStart, next === -1 ? undefined : next).trim();
137+
if (!body) {
138+
throw new Error(`CHANGELOG.md entry for ${version} is empty.`);
139+
}
140+
return body;
141+
}
142+
143+
function packageRepositoryName(manifest) {
144+
const repository = manifest.repository;
145+
const url = typeof repository === "string"
146+
? repository
147+
: repository && typeof repository.url === "string"
148+
? repository.url
149+
: undefined;
150+
const match = url?.match(/github\.com[:/]([^/\s]+)\/([^/\s.]+)(?:\.git)?/i);
151+
return match ? `${match[1]}/${match[2]}` : undefined;
152+
}
153+
154+
function normalizeReleaseBody(value) {
155+
return String(value).replace(/\r\n/g, "\n").trim();
156+
}
157+
158+
function isMissingVersion(result) {
159+
const output = npmOutput(result);
160+
return /E404|404 Not Found|is not in this registry|No match found/.test(output);
161+
}
162+
163+
function npmOutput(result) {
164+
return [result.stdout, result.stderr].filter(Boolean).join("\n");
165+
}
166+
167+
function positiveInt(value, fallback) {
168+
const parsed = Number.parseInt(value ?? "", 10);
169+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
170+
}
171+
172+
function sleep(ms) {
173+
return new Promise((resolve) => setTimeout(resolve, ms));
174+
}

0 commit comments

Comments
 (0)