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..4e646023 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,65 @@ 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. + * + * `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) { + // 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', + ['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' || !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]; + // 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) { const source = join(repoRoot, 'plugins/cdn'); const destination = join(cdnRoot, 'pythinker-code/plugins'); @@ -101,10 +161,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 +187,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 ?? '-'}`);