Skip to content

Commit d825318

Browse files
committed
fix(release): advertise only published versions on the update channel
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.
1 parent db8ef5a commit d825318

4 files changed

Lines changed: 103 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download.

.github/workflows/release.yml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,23 @@ jobs:
203203
retention-days: 7
204204
if-no-files-found: error
205205

206-
# code.pythinker.com redeploys via Dokploy autodeploy on push to main
207-
# (app Pythinker/code builds apps/site/Dockerfile from the repo, no npm
208-
# registry dependency), so no deploy webhook is fired here — this job only
209-
# verifies the published release is internally consistent.
206+
# code.pythinker.com redeploys via Dokploy autodeploy on push to main (app
207+
# Pythinker/code builds apps/site/Dockerfile from the repo), so no deploy
208+
# webhook is fired here — this job verifies that the published release is
209+
# internally consistent and that the CDN is not advertising a version npm
210+
# does not have.
211+
#
212+
# It also runs on a `ci: release packages` merge that published nothing: that
213+
# commit bumps the version on main, so gating the check on a successful
214+
# publish hid the one case where the version and the published artifacts
215+
# diverge — and every client polled the CDN for a release that never existed.
210216
verify-cdn-release:
211217
timeout-minutes: 15
212218
name: Verify release consistency
213219
needs: release
214-
if: needs.release.outputs.packages_published == 'true'
220+
if: >-
221+
needs.release.outputs.packages_published == 'true'
222+
|| startsWith(github.event.head_commit.message, 'ci: release packages')
215223
runs-on: ubuntu-latest
216224
steps:
217225
- name: Checkout

apps/site/scripts/build-cdn.mjs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execFileSync } from 'node:child_process';
12
import { createHash } from 'node:crypto';
23
import { access, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
34
import { basename, dirname, join, parse, resolve } from 'node:path';
@@ -29,6 +30,47 @@ function parseArgs() {
2930
return { out, skipRg };
3031
}
3132

33+
/**
34+
* Resolve the version this CDN advertises from a *published* release, never from
35+
* the working tree.
36+
*
37+
* `apps/pythinker-code/package.json` is bumped by the `ci: release packages`
38+
* merge and this site autodeploys on every push to main, so deriving the
39+
* manifest from it advertised the next version the moment that merge landed —
40+
* before the npm publish and the GitHub release assets existed, and permanently
41+
* when the publish never ran at all. Installed clients then polled GitHub for
42+
* assets that did not exist (~6 minutes per launch, every launch). The npm
43+
* dist-tag is the publish barrier, so it is the only safe source.
44+
*
45+
* `publishedAt` comes from npm's own publish timestamp: stamping build time made
46+
* every unrelated site deploy re-anchor the clients' rollout eligibility window.
47+
*
48+
* A registry read failure throws on purpose: a failed image build leaves the
49+
* previous container serving the last good manifest, which is the safe outcome.
50+
*/
51+
async function resolvePublishedRelease(packageName) {
52+
const pinned = process.env.PYTHINKER_CDN_VERSION?.trim();
53+
if (pinned) return { version: pinned, publishedAt: new Date().toISOString() };
54+
const view = JSON.parse(
55+
execFileSync(
56+
'npm',
57+
['view', packageName, 'dist-tags', 'time', '--json', '--registry=https://registry.npmjs.org'],
58+
{ encoding: 'utf8', timeout: 60_000 },
59+
),
60+
);
61+
const version = view['dist-tags']?.latest;
62+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) {
63+
throw new Error(
64+
`npm dist-tag latest for ${packageName} is not a release version: ${String(version)}`,
65+
);
66+
}
67+
const publishedAt = view.time?.[version];
68+
return {
69+
version,
70+
publishedAt: typeof publishedAt === 'string' ? publishedAt : new Date().toISOString(),
71+
};
72+
}
73+
3274
async function copyPlugins(repoRoot, cdnRoot) {
3375
const source = join(repoRoot, 'plugins/cdn');
3476
const destination = join(cdnRoot, 'pythinker-code/plugins');
@@ -101,10 +143,11 @@ const repoRoot = await findRepoRoot();
101143
const packageJson = JSON.parse(
102144
await readFile(join(repoRoot, 'apps/pythinker-code/package.json'), 'utf8'),
103145
);
104-
const version = packageJson.version;
105-
if (typeof version !== 'string' || version.trim() === '') {
106-
throw new Error('apps/pythinker-code/package.json has no version');
146+
const packageName = packageJson.name;
147+
if (typeof packageName !== 'string' || packageName.trim() === '') {
148+
throw new Error('apps/pythinker-code/package.json has no name');
107149
}
150+
const { version, publishedAt } = await resolvePublishedRelease(packageName);
108151

109152
const siteDist = join(repoRoot, 'apps/site/dist');
110153
await access(join(siteDist, 'index.html'));
@@ -126,7 +169,7 @@ await mkdir(channelRoot, { recursive: true });
126169
await writeFile(join(channelRoot, 'latest'), `${version}\n`);
127170
await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({
128171
version,
129-
publishedAt: new Date().toISOString(),
172+
publishedAt,
130173
rollout: [],
131174
}, null, 2)}\n`);
132175
await cp(

scripts/release/verify-release-consistency.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ import { readFileSync } from 'node:fs';
44
const PACKAGE_NAME = '@pythoughts/pythinker-code';
55
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-]+)*)?$/;
66

7+
const CDN_MANIFEST_URL = 'https://code.pythinker.com/pythinker-code/latest.json';
8+
79
function fail(reason) {
810
console.error(`consistency failed: ${reason}`);
911
process.exit(1);
1012
}
1113

14+
/** Numeric major/minor/patch compare over two SEMVER regex matches. */
15+
function compareRelease(left, right) {
16+
for (let index = 1; index <= 3; index += 1) {
17+
const diff = Number(left[index]) - Number(right[index]);
18+
if (diff !== 0) return diff;
19+
}
20+
return 0;
21+
}
22+
1223
let localVersion;
1324
let distTags;
1425

@@ -49,4 +60,31 @@ try {
4960
}
5061
if (!gitTags.trim().split('\n').includes(releaseTag)) fail(`missing git tag ${releaseTag}`);
5162

63+
// The CDN manifest is what every installed client polls for updates, so a
64+
// version it advertises that npm does not have sends all of them into an install
65+
// that cannot succeed. Ahead of npm is a hard failure; behind is deploy lag,
66+
// since the site rebuilds on the next push to main.
67+
let cdnVersion;
68+
try {
69+
const response = await fetch(CDN_MANIFEST_URL, { signal: AbortSignal.timeout(15_000) });
70+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
71+
cdnVersion = JSON.parse(await response.text()).version;
72+
} catch (error) {
73+
console.warn(`warning: cannot read the CDN manifest (${error.message}) — CDN check skipped`);
74+
}
75+
76+
if (typeof cdnVersion === 'string' && cdnVersion !== distTags.latest) {
77+
const cdnMatch = cdnVersion.match(SEMVER);
78+
if (!cdnMatch) fail(`CDN manifest version is not semver: ${cdnVersion}`);
79+
if (compareRelease(cdnMatch, latestMatch) > 0) {
80+
fail(
81+
`CDN advertises ${cdnVersion} but npm latest is ${distTags.latest} — ` +
82+
'clients would try to install a release that does not exist',
83+
);
84+
}
85+
console.log(
86+
`CDN is behind npm (cdn=${cdnVersion} latest=${distTags.latest}); it catches up on the next push to main`,
87+
);
88+
}
89+
5290
console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`);

0 commit comments

Comments
 (0)