Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brew-tap-wait-for-npm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Wait for the npm tarball to become downloadable before updating the Homebrew formula.
3 changes: 3 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ concurrency:
permissions:
contents: read
id-token: write
pull-requests: read

jobs:
publish:
Expand Down Expand Up @@ -52,6 +53,8 @@ jobs:
run: pnpm build

- name: Publish dev snapshot
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
pnpm changeset version --snapshot dev
VERSION=$(node -p "require('./apps/pythinker-code/package.json').version")
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ jobs:
run: node scripts/release/verify-release-consistency.mjs

update-brew-tap:
timeout-minutes: 15
timeout-minutes: 20
# Checkout only; the tap push uses a minted app token, not this one.
permissions:
contents: read
Expand Down
36 changes: 36 additions & 0 deletions scripts/release/release-status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ async function fetchJson(fetchImpl, url, label, init = {}) {
}
}

async function fetchText(fetchImpl, url, label, init = {}) {
const response = await fetchImpl(url, {
...init,
headers: {
'user-agent': 'pythinker-release-status',
...init.headers,
},
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`);
return await response.text();
}

function brewFormulaVersion(formula) {
const match = typeof formula === 'string' ? /pythinker-code-(\d+\.\d+\.\d+)\.tgz/u.exec(formula) : null;
return match === null ? undefined : validVersion(match[1]);
}

function validVersion(value) {
return typeof value === 'string' && semver.exec(value)?.[0] === value ? value : undefined;
}
Expand Down Expand Up @@ -162,6 +180,7 @@ export async function collectReleaseStatus({
npmResult,
cdnResult,
cliReleaseResult,
brewResult,
desktopStableResult,
desktopReleasesResult,
marketplaceResult,
Expand All @@ -184,6 +203,11 @@ export async function collectReleaseStatus({
'CLI GitHub release',
{ headers: github },
),
fetchText(
fetchImpl,
'https://raw.githubusercontent.com/PyModel/homebrew-tap/main/Formula/pythinker-code.rb',
'Homebrew formula',
),
fetchJson(
fetchImpl,
'https://api.github.com/repos/PyModel/pythinker-desktop-releases/releases/latest',
Expand Down Expand Up @@ -260,6 +284,11 @@ export async function collectReleaseStatus({
coverage: targetCoverage(Object.keys(value.downloads ?? {})),
}));

const brew = settledValue(brewResult, (value) => {
const version = brewFormulaVersion(value);
if (version === undefined) throw new Error('Homebrew formula has no pythinker-code tarball version.');
return { version };
});
const cliMissing = cliRelease.missing ?? expectedCliAssets;
const cliOk = npm.version === cliVersion
&& cliRelease.tag === cliTag
Expand All @@ -283,6 +312,13 @@ export async function collectReleaseStatus({
details: cdn.error
?? `platforms ${cdn.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(cdn.coverage?.missing ?? nativeTargets)}`,
},
{
lane: 'Homebrew',
expected: cliVersion,
observed: brew.version ?? 'unavailable',
ok: brew.version === cliVersion && brew.error === undefined,
details: brew.error ?? `Formula/pythinker-code.rb ${brew.version}`,
},
{
lane: 'Desktop Stable',
expected: desktopVersion,
Expand Down
22 changes: 22 additions & 0 deletions scripts/release/release-status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ function json(body, status = 200) {
function fixtureFetch({
cliAssets = expectedCliAssets,
nightlyAssets = desktopAssets(desktopNightlyVersion, 'nightly'),
brewVersion = '1.3.0',
} = {}) {
return async (input) => {
const url = new URL(String(input));
if (url.hostname === 'registry.npmjs.org') return json({ latest: '1.3.0' });
if (url.hostname === 'raw.githubusercontent.com') {
return new Response(
`url "https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${brewVersion}.tgz"\n`,
{ status: 200, headers: { 'content-type': 'text/plain' } },
);
}
if (url.hostname === 'code.pythinker.com') {
return json({
version: '1.3.0',
Expand Down Expand Up @@ -126,6 +133,7 @@ void test('reports all live release lanes aligned', async (t) => {
assert.equal(result.ok, true);
assert.equal(result.rows.every((row) => row.ok), true);
assert.match(renderReleaseStatus(result), /\| npm CLI \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u);
assert.match(renderReleaseStatus(result), /\| Homebrew \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u);
assert.match(renderReleaseStatus(result), /\| Desktop Nightly \| 0\.2\.2-nightly\.4102 \| 0\.2\.2-nightly\.4102 \| PASS \|/u);
});

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

void test('fails when the Homebrew formula lags the published CLI version', async (t) => {
const rootDir = await fixtureRoot(t);
const result = await collectReleaseStatus({
rootDir,
desktopCommitCount,
fetchImpl: fixtureFetch({ brewVersion: '1.2.0' }),
});

assert.equal(result.ok, false);
const brew = result.rows.find((row) => row.lane === 'Homebrew');
assert.equal(brew?.ok, false);
assert.equal(brew?.observed, '1.2.0');
});

void test('fails when the current desktop Nightly release is incomplete', async (t) => {
const rootDir = await fixtureRoot(t);
const result = await collectReleaseStatus({
Expand Down
4 changes: 4 additions & 0 deletions scripts/release/release-workflows.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ void test('release workflow uses full push-boundary lane signals and isolated jo
assert.match(workflow, /pythinker_release_tag: \$\{\{ steps\.pythinker-release\.outputs\.tag \|\|/u);
assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u);
assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u);
assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu);
});

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

void test('nightly reconciliation maintains one release drift issue', () => {
const workflow = read('.github/workflows/nightly.yml');
const publishJob = workflow.slice(workflow.indexOf('\n publish:'), workflow.indexOf('\n desktop-nightly:'));
assert.match(workflow, /uses: \.\/\.github\/workflows\/desktop-release\.yml/u);
assert.match(workflow, /^ desktop-nightly:/mu);
assert.match(workflow, /needs: \[publish, desktop-nightly\]/u);
assert.doesNotMatch(workflow, /cron: '0 /u);
assert.match(workflow, /scripts\/release\/release-status\.mjs/u);
assert.match(workflow, /Release lane drift detected/u);
assert.match(workflow, /issues: write/u);
assert.match(workflow, /pull-requests: read/u);
assert.equal(publishJob.includes('GITHUB_TOKEN: ${{ github.token }}'), true);
assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node)@v\d+/u);
assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 2);
});
72 changes: 63 additions & 9 deletions scripts/release/update-brew-formula.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,75 @@
/**
* Bump Formula/pythinker-code.rb in PyModel/homebrew-tap to the published npm
* tarball. `changeset publish` can succeed several minutes before the public
* GET of `/-/pythinker-code-<version>.tgz` returns 200, so the download polls
* until the tarball is fetchable (fetch, sleep, and clock are injected so the
* poll is unit-testable without a network or a real wait).
*/
import { createHash } from 'node:crypto';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

export const NPM_TARBALL_POLL_BUDGET_MS = 600_000;
export const NPM_TARBALL_POLL_INTERVAL_MS = 15_000;

function redactGitOutput(value, token) {
const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@');
return token.length >= 8 ? redacted.replaceAll(token, '***') : redacted;
}

function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}

export async function downloadNpmTarball(options) {
const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options;
const deadline = now() + budgetMs;
let attempts = 0;
let lastError;

for (;;) {
attempts += 1;
try {
const response = await fetchImpl(url);
if (response.status === 200) {
const tarball = Buffer.from(await response.arrayBuffer());
if (tarball.length > 0) return { tarball, attempts };
lastError = new Error('Failed to download npm tarball: empty body');
} else {
lastError = new Error(`Failed to download npm tarball: HTTP ${response.status}`);
}
} catch (error) {
lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error });
}

const message = lastError?.message ?? 'Failed to download npm tarball';
const remainingMs = deadline - now();
if (remainingMs <= 0) {
throw new Error(`${message} after ${attempts} attempt(s)`);
}
const waitMs = remainingMs < intervalMs ? remainingMs : intervalMs;
log(`${message} (attempt ${attempts}); retrying in ${waitMs / 1000}s`);
await sleep(waitMs);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function main() {
const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8'));
const version = packageJson.version;
const tarballUrl = `https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${version}.tgz`;
const response = await fetch(tarballUrl);
if (response.status !== 200) throw new Error(`Failed to download npm tarball: HTTP ${response.status}`);

const tarball = Buffer.from(await response.arrayBuffer());
if (tarball.length === 0) throw new Error('Failed to download npm tarball: empty body');
const { tarball } = await downloadNpmTarball({
url: tarballUrl,
fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }),
sleep: (ms) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
}),
now: () => Date.now(),
budgetMs: NPM_TARBALL_POLL_BUDGET_MS,
intervalMs: NPM_TARBALL_POLL_INTERVAL_MS,
});
const sha256 = createHash('sha256').update(tarball).digest('hex');

const token = process.env.TAP_GITHUB_TOKEN;
Expand Down Expand Up @@ -82,7 +134,9 @@ async function main() {
}
}

main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
if (process.argv[1] === import.meta.filename) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
106 changes: 106 additions & 0 deletions scripts/release/update-brew-formula.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { downloadNpmTarball } from './update-brew-formula.mjs';

const TARBALL_URL = 'https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-2.0.0.tgz';
const BODY = Buffer.from('pythinker-tarball');

function response(status, body = BODY) {
return new Response(body, { status });
}

function pollClock() {
let now = 0;
const sleeps = [];
const sleep = async (ms) => {
sleeps.push(ms);
now += ms;
};
return {
now: () => now,
sleeps,
sleep,
};
}

function pollOptions(clock, fetchImpl) {
return {
url: TARBALL_URL,
fetchImpl,
sleep: (ms) => clock.sleep(ms),
now: () => clock.now(),
log: () => {},
budgetMs: 45_000,
intervalMs: 15_000,
};
}

void test('returns the tarball when the first fetch is HTTP 200', async () => {
const clock = pollClock();
let fetches = 0;
const result = await downloadNpmTarball(
pollOptions(clock, async () => {
fetches += 1;
return response(200);
}),
);

assert.equal(fetches, 1);
assert.equal(result.attempts, 1);
assert.deepEqual(result.tarball, BODY);
assert.deepEqual(clock.sleeps, []);
});

void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => {
const clock = pollClock();
const statuses = [404, 404, 200];
const result = await downloadNpmTarball(
pollOptions(clock, async () => response(statuses.shift() ?? 500)),
);

assert.equal(result.attempts, 3);
assert.deepEqual(result.tarball, BODY);
assert.deepEqual(clock.sleeps, [15_000, 15_000]);
});

void test('retries an empty 200 body until a non-empty tarball arrives', async () => {
const clock = pollClock();
const bodies = [Buffer.alloc(0), BODY];
const result = await downloadNpmTarball(
pollOptions(clock, async () => response(200, bodies.shift() ?? BODY)),
);

assert.equal(result.attempts, 2);
assert.deepEqual(result.tarball, BODY);
assert.deepEqual(clock.sleeps, [15_000]);
});

void test('throws after the budget when every fetch is HTTP 404', async () => {
const clock = pollClock();
let fetches = 0;
await assert.rejects(
() =>
downloadNpmTarball(
pollOptions(clock, async () => {
fetches += 1;
return response(404);
}),
),
{ message: 'Failed to download npm tarball: HTTP 404 after 4 attempt(s)' },
);
assert.equal(fetches, 4);
assert.deepEqual(clock.sleeps, [15_000, 15_000, 15_000]);
});

void test('sleeps the leftover budget then fetches again before giving up', async () => {
const clock = pollClock();
const statuses = [404, 404, 404, 200];
const result = await downloadNpmTarball({
...pollOptions(clock, async () => response(statuses.shift() ?? 500)),
budgetMs: 40_000,
});
assert.equal(result.attempts, 4);
assert.deepEqual(result.tarball, BODY);
assert.deepEqual(clock.sleeps, [15_000, 15_000, 10_000]);
});
Loading