From d8253182f5e1eec36bcf48414279252b33af0c29 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 10:39:07 -0400 Subject: [PATCH 1/2] fix(release): advertise only published versions on the update channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CDN manifest took its version from apps/pythinker-code/package.json and the site autodeploys on every push to main, so a `ci: release packages` merge advertised the next version before — and, when a changeset landed while the version PR was open, without — npm and the GitHub release assets ever getting it. Clients then polled GitHub for assets that did not exist for about six minutes on every launch. Derive the advertised version from the npm dist-tag instead, take publishedAt from npm's own publish timestamp so unrelated site deploys stop re-anchoring the client rollout window, and run the release consistency check on version-bump merges that published nothing — it was gated on a successful publish, so it skipped exactly the case where the version and the published artifacts diverge. --- .../update-channel-published-versions-only.md | 5 ++ .github/workflows/release.yml | 18 +++++-- apps/site/scripts/build-cdn.mjs | 51 +++++++++++++++++-- .../release/verify-release-consistency.mjs | 38 ++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 .changeset/update-channel-published-versions-only.md diff --git a/.changeset/update-channel-published-versions-only.md b/.changeset/update-channel-published-versions-only.md new file mode 100644 index 00000000..8135cb77 --- /dev/null +++ b/.changeset/update-channel-published-versions-only.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12a3f886..161c2b61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -203,15 +203,23 @@ jobs: retention-days: 7 if-no-files-found: error - # code.pythinker.com redeploys via Dokploy autodeploy on push to main - # (app Pythinker/code builds apps/site/Dockerfile from the repo, no npm - # registry dependency), so no deploy webhook is fired here — this job only - # verifies the published release is internally consistent. + # code.pythinker.com redeploys via Dokploy autodeploy on push to main (app + # Pythinker/code builds apps/site/Dockerfile from the repo), so no deploy + # webhook is fired here — this job verifies that the published release is + # internally consistent and that the CDN is not advertising a version npm + # does not have. + # + # It also runs on a `ci: release packages` merge that published nothing: that + # commit bumps the version on main, so gating the check on a successful + # publish hid the one case where the version and the published artifacts + # diverge — and every client polled the CDN for a release that never existed. verify-cdn-release: timeout-minutes: 15 name: Verify release consistency needs: release - if: needs.release.outputs.packages_published == 'true' + if: >- + needs.release.outputs.packages_published == 'true' + || startsWith(github.event.head_commit.message, 'ci: release packages') runs-on: ubuntu-latest steps: - name: Checkout diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index c8b5d293..a41191bc 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, parse, resolve } from 'node:path'; @@ -29,6 +30,47 @@ function parseArgs() { return { out, skipRg }; } +/** + * Resolve the version this CDN advertises from a *published* release, never from + * the working tree. + * + * `apps/pythinker-code/package.json` is bumped by the `ci: release packages` + * merge and this site autodeploys on every push to main, so deriving the + * manifest from it advertised the next version the moment that merge landed — + * before the npm publish and the GitHub release assets existed, and permanently + * when the publish never ran at all. Installed clients then polled GitHub for + * assets that did not exist (~6 minutes per launch, every launch). The npm + * dist-tag is the publish barrier, so it is the only safe source. + * + * `publishedAt` comes from npm's own publish timestamp: stamping build time made + * every unrelated site deploy re-anchor the clients' rollout eligibility window. + * + * A registry read failure throws on purpose: a failed image build leaves the + * previous container serving the last good manifest, which is the safe outcome. + */ +async function resolvePublishedRelease(packageName) { + const pinned = process.env.PYTHINKER_CDN_VERSION?.trim(); + if (pinned) return { version: pinned, publishedAt: new Date().toISOString() }; + const view = JSON.parse( + execFileSync( + 'npm', + ['view', packageName, 'dist-tags', 'time', '--json', '--registry=https://registry.npmjs.org'], + { encoding: 'utf8', timeout: 60_000 }, + ), + ); + const version = view['dist-tags']?.latest; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error( + `npm dist-tag latest for ${packageName} is not a release version: ${String(version)}`, + ); + } + const publishedAt = view.time?.[version]; + return { + version, + publishedAt: typeof publishedAt === 'string' ? publishedAt : new Date().toISOString(), + }; +} + async function copyPlugins(repoRoot, cdnRoot) { const source = join(repoRoot, 'plugins/cdn'); const destination = join(cdnRoot, 'pythinker-code/plugins'); @@ -101,10 +143,11 @@ const repoRoot = await findRepoRoot(); const packageJson = JSON.parse( await readFile(join(repoRoot, 'apps/pythinker-code/package.json'), 'utf8'), ); -const version = packageJson.version; -if (typeof version !== 'string' || version.trim() === '') { - throw new Error('apps/pythinker-code/package.json has no version'); +const packageName = packageJson.name; +if (typeof packageName !== 'string' || packageName.trim() === '') { + throw new Error('apps/pythinker-code/package.json has no name'); } +const { version, publishedAt } = await resolvePublishedRelease(packageName); const siteDist = join(repoRoot, 'apps/site/dist'); await access(join(siteDist, 'index.html')); @@ -126,7 +169,7 @@ await mkdir(channelRoot, { recursive: true }); await writeFile(join(channelRoot, 'latest'), `${version}\n`); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ version, - publishedAt: new Date().toISOString(), + publishedAt, rollout: [], }, null, 2)}\n`); await cp( diff --git a/scripts/release/verify-release-consistency.mjs b/scripts/release/verify-release-consistency.mjs index 37696936..5956a736 100644 --- a/scripts/release/verify-release-consistency.mjs +++ b/scripts/release/verify-release-consistency.mjs @@ -4,11 +4,22 @@ import { readFileSync } from 'node:fs'; const PACKAGE_NAME = '@pythoughts/pythinker-code'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const CDN_MANIFEST_URL = 'https://code.pythinker.com/pythinker-code/latest.json'; + function fail(reason) { console.error(`consistency failed: ${reason}`); process.exit(1); } +/** Numeric major/minor/patch compare over two SEMVER regex matches. */ +function compareRelease(left, right) { + for (let index = 1; index <= 3; index += 1) { + const diff = Number(left[index]) - Number(right[index]); + if (diff !== 0) return diff; + } + return 0; +} + let localVersion; let distTags; @@ -49,4 +60,31 @@ try { } if (!gitTags.trim().split('\n').includes(releaseTag)) fail(`missing git tag ${releaseTag}`); +// The CDN manifest is what every installed client polls for updates, so a +// version it advertises that npm does not have sends all of them into an install +// that cannot succeed. Ahead of npm is a hard failure; behind is deploy lag, +// since the site rebuilds on the next push to main. +let cdnVersion; +try { + const response = await fetch(CDN_MANIFEST_URL, { signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + cdnVersion = JSON.parse(await response.text()).version; +} catch (error) { + console.warn(`warning: cannot read the CDN manifest (${error.message}) — CDN check skipped`); +} + +if (typeof cdnVersion === 'string' && cdnVersion !== distTags.latest) { + const cdnMatch = cdnVersion.match(SEMVER); + if (!cdnMatch) fail(`CDN manifest version is not semver: ${cdnVersion}`); + if (compareRelease(cdnMatch, latestMatch) > 0) { + fail( + `CDN advertises ${cdnVersion} but npm latest is ${distTags.latest} — ` + + 'clients would try to install a release that does not exist', + ); + } + console.log( + `CDN is behind npm (cdn=${cdnVersion} latest=${distTags.latest}); it catches up on the next push to main`, + ); +} + console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`); From 3becbb0003365e0226206b0b45f840f502c619c4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 15:42:08 -0400 Subject: [PATCH 2/2] fix(release): reject an unusable pinned version and a missing npm publish time The pinned-version escape hatch skipped the semver check the registry path applies, so an operator typo shipped a latest.json that every installed client fails to parse. It now answers to the same shape rule. publishedAt fell back to build time when npm's metadata was unreadable, which is exactly the re-anchoring of the rollout window the function's own comment records as the bug. An unreadable timestamp is a failed registry read, and a failed read already fails the build. --- apps/site/scripts/build-cdn.mjs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index a41191bc..4e646023 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -30,6 +30,8 @@ function parseArgs() { return { out, skipRg }; } +const RELEASE_VERSION = /^\d+\.\d+\.\d+$/; + /** * Resolve the version this CDN advertises from a *published* release, never from * the working tree. @@ -50,7 +52,18 @@ function parseArgs() { */ async function resolvePublishedRelease(packageName) { const pinned = process.env.PYTHINKER_CDN_VERSION?.trim(); - if (pinned) return { version: pinned, publishedAt: new Date().toISOString() }; + if (pinned) { + // The override answers to the same shape rule as the registry path below. + // A client rejects a manifest whose `version` is not semver, so an + // unusable override has to stop the build instead of publishing a + // latest.json that every installed client fails to parse. + if (!RELEASE_VERSION.test(pinned)) { + throw new Error(`PYTHINKER_CDN_VERSION is not a release version: ${pinned}`); + } + // Build time is the only timestamp available for a manual pin; the + // registry path below requires npm's own and never stamps one. + return { version: pinned, publishedAt: new Date().toISOString() }; + } const view = JSON.parse( execFileSync( 'npm', @@ -59,16 +72,21 @@ async function resolvePublishedRelease(packageName) { ), ); const version = view['dist-tags']?.latest; - if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) { + if (typeof version !== 'string' || !RELEASE_VERSION.test(version)) { throw new Error( `npm dist-tag latest for ${packageName} is not a release version: ${String(version)}`, ); } const publishedAt = view.time?.[version]; - return { - version, - publishedAt: typeof publishedAt === 'string' ? publishedAt : new Date().toISOString(), - }; + // Stamping build time here is the bug this function documents: it would move + // the rollout anchor on every unrelated site deploy. Unreadable registry + // metadata is a failed read, and a failed read must fail the build. + if (typeof publishedAt !== 'string' || !Number.isFinite(Date.parse(publishedAt))) { + throw new Error( + `npm has no usable publish time for ${packageName}@${version}: ${String(publishedAt)}`, + ); + } + return { version, publishedAt }; } async function copyPlugins(repoRoot, cdnRoot) {