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/update-channel-published-versions-only.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions apps/site/scripts/build-cdn.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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'));
Expand All @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions scripts/release/verify-release-consistency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

let localVersion;
let distTags;

Expand Down Expand Up @@ -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 ?? '-'}`);
Loading