Skip to content

Commit e09e9c1

Browse files
authored
fix(release): wait for the npm tarball before bumping Homebrew
## Related Issue Follow-up to the `@pymodel/pythinker-code@2.0.0` Release run: https://github.com/PyModel/pythinker-code/actions/runs/35034616438 (Homebrew tap 404). Drift issue: #312 ## Problem A future CLI release can go red, or leave users on an old binary, in three ways this PR closes: 1. `changeset publish` can succeed several minutes before the public npm tarball URL returns HTTP 200. The Homebrew job fetched once, got HTTP 404, and left the tap on the previous version. 2. Nightly `changeset version --snapshot` calls `@changesets/changelog-github`, which requires `GITHUB_TOKEN`. The publish job did not set it, so Nightly failed and opened a drift issue even when every live lane matched. 3. `pnpm release:status` did not read the Homebrew formula, so a missed tap bump stayed invisible until someone installed. ## What changed - `update-brew-formula.mjs` polls the npm tarball (10 minute budget, 15 second interval) until a non-empty 200 body arrives, then hashes it and pushes the formula. Fetch, sleep, and clock are injected so the poll is unit-tested without a network. - The brew job timeout is 20 minutes so the poll can finish before GitHub kills the job. - Nightly snapshot publish sets `GITHUB_TOKEN: ${{ github.token }}` and requests `pull-requests: read`. - `release-status` adds a Homebrew row against `Formula/pythinker-code.rb` on the tap, so the next nightly fails closed if the formula lags npm. Native CLI auto-update on 1.11.3 is a separate shipped-client issue (`canAutoInstall('native')` was false until 1.12.1). This PR does not change that path. 1.11.3 can still stage a newer build with `pythinker __update_download <version>` or `curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash`. ## Checklist - [x] I have read the CONTRIBUTING document. - [x] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Release Improvements** - Homebrew formula updates now wait for the npm package to become available, reducing incomplete or failed releases. - Release status now verifies that the Homebrew formula matches the published CLI version. - Release workflows have improved timing and permissions for more reliable publishing and nightly reconciliation. - **Bug Fixes** - Releases now retry temporary package availability issues before failing. - Status reporting clearly identifies when the Homebrew version is missing, invalid, or outdated. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 3c291d6 commit e09e9c1

8 files changed

Lines changed: 240 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Wait for the npm tarball to become downloadable before updating the Homebrew formula.

‎.github/workflows/nightly.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ concurrency:
1212
permissions:
1313
contents: read
1414
id-token: write
15+
pull-requests: read
1516

1617
jobs:
1718
publish:
@@ -52,6 +53,8 @@ jobs:
5253
run: pnpm build
5354

5455
- name: Publish dev snapshot
56+
env:
57+
GITHUB_TOKEN: ${{ github.token }}
5558
run: |
5659
pnpm changeset version --snapshot dev
5760
VERSION=$(node -p "require('./apps/pythinker-code/package.json').version")

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ jobs:
486486
run: node scripts/release/verify-release-consistency.mjs
487487

488488
update-brew-tap:
489-
timeout-minutes: 15
489+
timeout-minutes: 20
490490
# Checkout only; the tap push uses a minted app token, not this one.
491491
permissions:
492492
contents: read

‎scripts/release/release-status.mjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ async function fetchJson(fetchImpl, url, label, init = {}) {
6969
}
7070
}
7171

72+
async function fetchText(fetchImpl, url, label, init = {}) {
73+
const response = await fetchImpl(url, {
74+
...init,
75+
headers: {
76+
'user-agent': 'pythinker-release-status',
77+
...init.headers,
78+
},
79+
signal: AbortSignal.timeout(20_000),
80+
});
81+
if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`);
82+
return await response.text();
83+
}
84+
85+
function brewFormulaVersion(formula) {
86+
const match = typeof formula === 'string' ? /pythinker-code-(\d+\.\d+\.\d+)\.tgz/u.exec(formula) : null;
87+
return match === null ? undefined : validVersion(match[1]);
88+
}
89+
7290
function validVersion(value) {
7391
return typeof value === 'string' && semver.exec(value)?.[0] === value ? value : undefined;
7492
}
@@ -162,6 +180,7 @@ export async function collectReleaseStatus({
162180
npmResult,
163181
cdnResult,
164182
cliReleaseResult,
183+
brewResult,
165184
desktopStableResult,
166185
desktopReleasesResult,
167186
marketplaceResult,
@@ -184,6 +203,11 @@ export async function collectReleaseStatus({
184203
'CLI GitHub release',
185204
{ headers: github },
186205
),
206+
fetchText(
207+
fetchImpl,
208+
'https://raw.githubusercontent.com/PyModel/homebrew-tap/main/Formula/pythinker-code.rb',
209+
'Homebrew formula',
210+
),
187211
fetchJson(
188212
fetchImpl,
189213
'https://api.github.com/repos/PyModel/pythinker-desktop-releases/releases/latest',
@@ -260,6 +284,11 @@ export async function collectReleaseStatus({
260284
coverage: targetCoverage(Object.keys(value.downloads ?? {})),
261285
}));
262286

287+
const brew = settledValue(brewResult, (value) => {
288+
const version = brewFormulaVersion(value);
289+
if (version === undefined) throw new Error('Homebrew formula has no pythinker-code tarball version.');
290+
return { version };
291+
});
263292
const cliMissing = cliRelease.missing ?? expectedCliAssets;
264293
const cliOk = npm.version === cliVersion
265294
&& cliRelease.tag === cliTag
@@ -283,6 +312,13 @@ export async function collectReleaseStatus({
283312
details: cdn.error
284313
?? `platforms ${cdn.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(cdn.coverage?.missing ?? nativeTargets)}`,
285314
},
315+
{
316+
lane: 'Homebrew',
317+
expected: cliVersion,
318+
observed: brew.version ?? 'unavailable',
319+
ok: brew.version === cliVersion && brew.error === undefined,
320+
details: brew.error ?? `Formula/pythinker-code.rb ${brew.version}`,
321+
},
286322
{
287323
lane: 'Desktop Stable',
288324
expected: desktopVersion,

‎scripts/release/release-status.test.mjs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,17 @@ function json(body, status = 200) {
5050
function fixtureFetch({
5151
cliAssets = expectedCliAssets,
5252
nightlyAssets = desktopAssets(desktopNightlyVersion, 'nightly'),
53+
brewVersion = '1.3.0',
5354
} = {}) {
5455
return async (input) => {
5556
const url = new URL(String(input));
5657
if (url.hostname === 'registry.npmjs.org') return json({ latest: '1.3.0' });
58+
if (url.hostname === 'raw.githubusercontent.com') {
59+
return new Response(
60+
`url "https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${brewVersion}.tgz"\n`,
61+
{ status: 200, headers: { 'content-type': 'text/plain' } },
62+
);
63+
}
5764
if (url.hostname === 'code.pythinker.com') {
5865
return json({
5966
version: '1.3.0',
@@ -126,6 +133,7 @@ void test('reports all live release lanes aligned', async (t) => {
126133
assert.equal(result.ok, true);
127134
assert.equal(result.rows.every((row) => row.ok), true);
128135
assert.match(renderReleaseStatus(result), /\| npm CLI \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u);
136+
assert.match(renderReleaseStatus(result), /\| Homebrew \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u);
129137
assert.match(renderReleaseStatus(result), /\| Desktop Nightly \| 0\.2\.2-nightly\.4102 \| 0\.2\.2-nightly\.4102 \| PASS \|/u);
130138
});
131139

@@ -143,6 +151,20 @@ void test('fails when a published CLI release is missing one required asset', as
143151
assert.match(cli?.details ?? '', /missing manifest\.json/u);
144152
});
145153

154+
void test('fails when the Homebrew formula lags the published CLI version', async (t) => {
155+
const rootDir = await fixtureRoot(t);
156+
const result = await collectReleaseStatus({
157+
rootDir,
158+
desktopCommitCount,
159+
fetchImpl: fixtureFetch({ brewVersion: '1.2.0' }),
160+
});
161+
162+
assert.equal(result.ok, false);
163+
const brew = result.rows.find((row) => row.lane === 'Homebrew');
164+
assert.equal(brew?.ok, false);
165+
assert.equal(brew?.observed, '1.2.0');
166+
});
167+
146168
void test('fails when the current desktop Nightly release is incomplete', async (t) => {
147169
const rootDir = await fixtureRoot(t);
148170
const result = await collectReleaseStatus({

‎scripts/release/release-workflows.test.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ void test('release workflow uses full push-boundary lane signals and isolated jo
3030
assert.match(workflow, /pythinker_release_tag: \$\{\{ steps\.pythinker-release\.outputs\.tag \|\|/u);
3131
assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u);
3232
assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u);
33+
assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu);
3334
});
3435

3536
void test('VS Code release supports isolated recovery and attests verified VSIX files', () => {
@@ -56,13 +57,16 @@ void test('native releases fail without requested signing and attest each zip',
5657

5758
void test('nightly reconciliation maintains one release drift issue', () => {
5859
const workflow = read('.github/workflows/nightly.yml');
60+
const publishJob = workflow.slice(workflow.indexOf('\n publish:'), workflow.indexOf('\n desktop-nightly:'));
5961
assert.match(workflow, /uses: \.\/\.github\/workflows\/desktop-release\.yml/u);
6062
assert.match(workflow, /^ desktop-nightly:/mu);
6163
assert.match(workflow, /needs: \[publish, desktop-nightly\]/u);
6264
assert.doesNotMatch(workflow, /cron: '0 /u);
6365
assert.match(workflow, /scripts\/release\/release-status\.mjs/u);
6466
assert.match(workflow, /Release lane drift detected/u);
6567
assert.match(workflow, /issues: write/u);
68+
assert.match(workflow, /pull-requests: read/u);
69+
assert.equal(publishJob.includes('GITHUB_TOKEN: ${{ github.token }}'), true);
6670
assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node)@v\d+/u);
6771
assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 2);
6872
});

‎scripts/release/update-brew-formula.mjs‎

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,75 @@
1+
/**
2+
* Bump Formula/pythinker-code.rb in PyModel/homebrew-tap to the published npm
3+
* tarball. `changeset publish` can succeed several minutes before the public
4+
* GET of `/-/pythinker-code-<version>.tgz` returns 200, so the download polls
5+
* until the tarball is fetchable (fetch, sleep, and clock are injected so the
6+
* poll is unit-testable without a network or a real wait).
7+
*/
18
import { createHash } from 'node:crypto';
29
import { execFileSync, spawnSync } from 'node:child_process';
310
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
411
import { tmpdir } from 'node:os';
512
import { join } from 'node:path';
613

14+
export const NPM_TARBALL_POLL_BUDGET_MS = 600_000;
15+
export const NPM_TARBALL_POLL_INTERVAL_MS = 15_000;
16+
717
function redactGitOutput(value, token) {
818
const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@');
919
return token.length >= 8 ? redacted.replaceAll(token, '***') : redacted;
1020
}
1121

22+
function errorMessage(error) {
23+
return error instanceof Error ? error.message : String(error);
24+
}
25+
26+
export async function downloadNpmTarball(options) {
27+
const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options;
28+
const deadline = now() + budgetMs;
29+
let attempts = 0;
30+
let lastError;
31+
32+
for (;;) {
33+
attempts += 1;
34+
try {
35+
const response = await fetchImpl(url);
36+
if (response.status === 200) {
37+
const tarball = Buffer.from(await response.arrayBuffer());
38+
if (tarball.length > 0) return { tarball, attempts };
39+
lastError = new Error('Failed to download npm tarball: empty body');
40+
} else {
41+
lastError = new Error(`Failed to download npm tarball: HTTP ${response.status}`);
42+
}
43+
} catch (error) {
44+
lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error });
45+
}
46+
47+
const message = lastError?.message ?? 'Failed to download npm tarball';
48+
const remainingMs = deadline - now();
49+
if (remainingMs <= 0) {
50+
throw new Error(`${message} after ${attempts} attempt(s)`);
51+
}
52+
const waitMs = remainingMs < intervalMs ? remainingMs : intervalMs;
53+
log(`${message} (attempt ${attempts}); retrying in ${waitMs / 1000}s`);
54+
await sleep(waitMs);
55+
}
56+
}
57+
1258
async function main() {
1359
const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8'));
1460
const version = packageJson.version;
1561
const tarballUrl = `https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${version}.tgz`;
16-
const response = await fetch(tarballUrl);
17-
if (response.status !== 200) throw new Error(`Failed to download npm tarball: HTTP ${response.status}`);
18-
19-
const tarball = Buffer.from(await response.arrayBuffer());
20-
if (tarball.length === 0) throw new Error('Failed to download npm tarball: empty body');
62+
const { tarball } = await downloadNpmTarball({
63+
url: tarballUrl,
64+
fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }),
65+
sleep: (ms) =>
66+
new Promise((resolve) => {
67+
setTimeout(resolve, ms);
68+
}),
69+
now: () => Date.now(),
70+
budgetMs: NPM_TARBALL_POLL_BUDGET_MS,
71+
intervalMs: NPM_TARBALL_POLL_INTERVAL_MS,
72+
});
2173
const sha256 = createHash('sha256').update(tarball).digest('hex');
2274

2375
const token = process.env.TAP_GITHUB_TOKEN;
@@ -82,7 +134,9 @@ async function main() {
82134
}
83135
}
84136

85-
main().catch((error) => {
86-
console.error(error.message);
87-
process.exitCode = 1;
88-
});
137+
if (process.argv[1] === import.meta.filename) {
138+
main().catch((error) => {
139+
console.error(error.message);
140+
process.exitCode = 1;
141+
});
142+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { downloadNpmTarball } from './update-brew-formula.mjs';
5+
6+
const TARBALL_URL = 'https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-2.0.0.tgz';
7+
const BODY = Buffer.from('pythinker-tarball');
8+
9+
function response(status, body = BODY) {
10+
return new Response(body, { status });
11+
}
12+
13+
function pollClock() {
14+
let now = 0;
15+
const sleeps = [];
16+
const sleep = async (ms) => {
17+
sleeps.push(ms);
18+
now += ms;
19+
};
20+
return {
21+
now: () => now,
22+
sleeps,
23+
sleep,
24+
};
25+
}
26+
27+
function pollOptions(clock, fetchImpl) {
28+
return {
29+
url: TARBALL_URL,
30+
fetchImpl,
31+
sleep: (ms) => clock.sleep(ms),
32+
now: () => clock.now(),
33+
log: () => {},
34+
budgetMs: 45_000,
35+
intervalMs: 15_000,
36+
};
37+
}
38+
39+
void test('returns the tarball when the first fetch is HTTP 200', async () => {
40+
const clock = pollClock();
41+
let fetches = 0;
42+
const result = await downloadNpmTarball(
43+
pollOptions(clock, async () => {
44+
fetches += 1;
45+
return response(200);
46+
}),
47+
);
48+
49+
assert.equal(fetches, 1);
50+
assert.equal(result.attempts, 1);
51+
assert.deepEqual(result.tarball, BODY);
52+
assert.deepEqual(clock.sleeps, []);
53+
});
54+
55+
void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => {
56+
const clock = pollClock();
57+
const statuses = [404, 404, 200];
58+
const result = await downloadNpmTarball(
59+
pollOptions(clock, async () => response(statuses.shift() ?? 500)),
60+
);
61+
62+
assert.equal(result.attempts, 3);
63+
assert.deepEqual(result.tarball, BODY);
64+
assert.deepEqual(clock.sleeps, [15_000, 15_000]);
65+
});
66+
67+
void test('retries an empty 200 body until a non-empty tarball arrives', async () => {
68+
const clock = pollClock();
69+
const bodies = [Buffer.alloc(0), BODY];
70+
const result = await downloadNpmTarball(
71+
pollOptions(clock, async () => response(200, bodies.shift() ?? BODY)),
72+
);
73+
74+
assert.equal(result.attempts, 2);
75+
assert.deepEqual(result.tarball, BODY);
76+
assert.deepEqual(clock.sleeps, [15_000]);
77+
});
78+
79+
void test('throws after the budget when every fetch is HTTP 404', async () => {
80+
const clock = pollClock();
81+
let fetches = 0;
82+
await assert.rejects(
83+
() =>
84+
downloadNpmTarball(
85+
pollOptions(clock, async () => {
86+
fetches += 1;
87+
return response(404);
88+
}),
89+
),
90+
{ message: 'Failed to download npm tarball: HTTP 404 after 4 attempt(s)' },
91+
);
92+
assert.equal(fetches, 4);
93+
assert.deepEqual(clock.sleeps, [15_000, 15_000, 15_000]);
94+
});
95+
96+
void test('sleeps the leftover budget then fetches again before giving up', async () => {
97+
const clock = pollClock();
98+
const statuses = [404, 404, 404, 200];
99+
const result = await downloadNpmTarball({
100+
...pollOptions(clock, async () => response(statuses.shift() ?? 500)),
101+
budgetMs: 40_000,
102+
});
103+
assert.equal(result.attempts, 4);
104+
assert.deepEqual(result.tarball, BODY);
105+
assert.deepEqual(clock.sleeps, [15_000, 15_000, 10_000]);
106+
});

0 commit comments

Comments
 (0)