From c6a0b063ac5bcdea618e5e3b30921744ea370818 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 8 Aug 2026 11:45:57 -0400 Subject: [PATCH 1/5] fix(ci): redeploy the CDN after the release and gate on it catching up The site autodeploy fires on the push that starts the release, but build-cdn.mjs reads the version from npm dist-tags, which only moves when the publish finishes. The first build baked in the previous version and nothing rebuilt it, so 0.13.0 stayed invisible to every installed client while CI reported success. Add a redeploy-cdn job that fires the Dokploy webhook after publish-native-assets, and turn the consistency check from a warning into a poll that fails when the CDN never catches up. --- .github/workflows/release.yml | 59 +++++- .../scripts/release/cdn-consistency.test.ts | 173 ++++++++++++++++++ scripts/release/cdn-consistency.mjs | 86 +++++++++ .../release/verify-release-consistency.mjs | 71 +++---- 4 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts create mode 100644 scripts/release/cdn-consistency.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 161c2b61..242e9373 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -204,10 +204,57 @@ jobs: 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), 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. + # Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy + # fires on the `ci: release packages` push — which STARTS the release — while + # apps/site/scripts/build-cdn.mjs reads the version from npm's dist-tag, which + # only moves when the publish FINISHES. The first build therefore bakes in the + # previous version and nothing rebuilds it, so the release stays invisible to + # every installed client. This job fires a second deploy after the publish. + # + # It must run after publish-native-assets: latest.json only gets its + # per-platform `platforms` block once the native zips exist on the release. + # That job is itself conditional and SKIPS on an npm-only release, and a job + # whose `needs` includes a skipped job is skipped too — hence `always()`, and + # hence the explicit upstream result assertions it forces us to spell out. + redeploy-cdn: + timeout-minutes: 10 + name: Redeploy CDN + needs: + - release + - publish-native-assets + if: >- + always() + && needs.release.result == 'success' + && needs.publish-native-assets.result != 'failure' + && needs.publish-native-assets.result != 'cancelled' + && (needs.release.outputs.packages_published == 'true' + || startsWith(github.event.head_commit.message, 'ci: release packages')) + runs-on: ubuntu-latest + steps: + - name: Trigger Dokploy rebuild + env: + WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy." + exit 0 + fi + # The webhook matches the branch from the request body: a bare POST + # answers 301 {"message":"Branch Not Match"} and deploys nothing. + # + # A transient failure must never fail the workflow. npm has already + # published by now and that is irreversible, so dying here buys + # nothing — an earlier version of this job was deleted because a + # curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release + # polls the manifest and is the gate that fails loudly. + curl -sS -X POST "$WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/main"}' \ + --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ + || echo "::warning::CDN redeploy webhook failed — verify-cdn-release will catch a stale CDN." + + # Verifies that the published release is internally consistent and that the + # CDN caught up with npm. It polls, so it must run after redeploy-cdn. # # 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 @@ -216,7 +263,9 @@ jobs: verify-cdn-release: timeout-minutes: 15 name: Verify release consistency - needs: release + needs: + - release + - redeploy-cdn if: >- needs.release.outputs.packages_published == 'true' || startsWith(github.event.head_commit.message, 'ci: release packages') diff --git a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts new file mode 100644 index 00000000..d6b4073f --- /dev/null +++ b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyCdnVersion, + compareRelease, + pollCdnUntilCaughtUp, +} from '../../../../../scripts/release/cdn-consistency.mjs'; + +const URL = 'https://cdn.example/latest.json'; + +/** Response stub shaped like the subset of `fetch` the poll actually reads. */ +function manifest(version: unknown, ok = true, status = 200) { + return { + ok, + status, + text: async () => JSON.stringify({ version }), + }; +} + +/** + * Fake clock and sleep: `sleep` advances the clock instead of waiting, so a + * ten-minute budget resolves instantly and the test asserts real elapsed logic. + */ +function fakeClock() { + let current = 0; + return { + now: () => current, + sleep: async (ms: number) => { + current += ms; + }, + }; +} + +/** Serve one scripted outcome per attempt. */ +function scriptedFetch(steps: readonly (() => unknown)[]) { + let index = 0; + return async () => { + const step = steps[Math.min(index, steps.length - 1)]; + index += 1; + return step?.(); + }; +} + +describe('compareRelease', () => { + it('orders by major, then minor, then patch', () => { + const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; + expect(compareRelease(parse('1.0.0'), parse('0.9.9'))).toBeGreaterThan(0); + expect(compareRelease(parse('0.13.0'), parse('0.12.0'))).toBeGreaterThan(0); + expect(compareRelease(parse('0.12.1'), parse('0.12.2'))).toBeLessThan(0); + expect(compareRelease(parse('0.12.0'), parse('0.12.0'))).toBe(0); + }); +}); + +describe('classifyCdnVersion', () => { + it('reports an equal version as a match', () => { + expect(classifyCdnVersion('0.13.0', '0.13.0')).toBe('match'); + }); + + it('reports an older CDN version as behind', () => { + expect(classifyCdnVersion('0.12.0', '0.13.0')).toBe('behind'); + expect(classifyCdnVersion('0.13.0', '1.0.0')).toBe('behind'); + }); + + it('reports a newer CDN version as ahead', () => { + expect(classifyCdnVersion('0.14.0', '0.13.0')).toBe('ahead'); + }); + + it('rejects anything that is not a stable release version', () => { + expect(classifyCdnVersion('not-a-version', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion('0.13.0-beta.1', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion('', '0.13.0')).toBe('invalid'); + expect(classifyCdnVersion(undefined, '0.13.0')).toBe('invalid'); + }); +}); + +describe('pollCdnUntilCaughtUp', () => { + const base = { url: URL, npmLatest: '0.13.0', budgetMs: 600_000, intervalMs: 15_000 }; + + it('resolves on the first attempt when the CDN already matches', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.13.0')]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', cdnVersion: '0.13.0', attempts: 1 }); + }); + + it('keeps polling while the CDN is behind and succeeds once it catches up', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([ + () => manifest('0.12.0'), + () => manifest('0.12.0'), + () => manifest('0.13.0'), + ]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 3 }); + }); + + it('treats an unreachable CDN as lag rather than a failure', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([ + () => { + throw new Error('ECONNREFUSED'); + }, + () => manifest('0.13.0', false, 502), + () => ({ ok: true, status: 200, text: async () => 'not json' }), + () => manifest('0.13.0'), + ]), + }); + + expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 4 }); + }); + + it('fails immediately when the CDN is ahead of npm', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.14.0')]), + }); + + expect(result).toMatchObject({ + ok: false, + reason: 'ahead', + cdnVersion: '0.14.0', + attempts: 1, + }); + }); + + it('gives up with the last observed version once the budget expires', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + budgetMs: 45_000, + now, + sleep, + fetchImpl: scriptedFetch([() => manifest('0.12.0')]), + }); + + expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: '0.12.0' }); + expect(result.attempts).toBe(3); + }); + + it('reports a null version when the CDN was never readable', async () => { + const { now, sleep } = fakeClock(); + const result = await pollCdnUntilCaughtUp({ + ...base, + budgetMs: 15_000, + now, + sleep, + fetchImpl: scriptedFetch([ + () => { + throw new Error('ENOTFOUND'); + }, + ]), + }); + + expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: null }); + }); +}); diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs new file mode 100644 index 00000000..b89487e7 --- /dev/null +++ b/scripts/release/cdn-consistency.mjs @@ -0,0 +1,86 @@ +/** + * Pure CDN-versus-npm consistency logic for the release gate. + * + * The CDN manifest is built from npm's dist-tag, but the site rebuild is + * triggered by the push that *starts* the release — several minutes before the + * publish that moves the dist-tag. The first build therefore advertises the + * previous version and nothing rebuilds it on its own, so the release stays + * invisible to every installed client. The pipeline fires a redeploy after the + * publish and then polls here until the manifest catches up. + * + * Everything is dependency-injected (fetch, sleep, clock) so the gate is unit + * testable without a network or a real wait. + */ + +/** Stable release semver. The CDN manifest never advertises a prerelease. */ +const RELEASE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +/** Numeric major/minor/patch compare over two RELEASE_SEMVER matches. */ +export 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; +} + +/** + * Where the CDN sits relative to the npm dist-tag. + * + * 'ahead' means clients would be pointed at a release that does not exist; + * 'behind' is ordinary deploy lag; 'invalid' means the manifest is unusable. + */ +export function classifyCdnVersion(cdnVersion, npmLatest) { + const cdnMatch = typeof cdnVersion === 'string' ? cdnVersion.match(RELEASE_SEMVER) : null; + const npmMatch = typeof npmLatest === 'string' ? npmLatest.match(RELEASE_SEMVER) : null; + if (cdnMatch === null || npmMatch === null) return 'invalid'; + const diff = compareRelease(cdnMatch, npmMatch); + if (diff === 0) return 'match'; + return diff > 0 ? 'ahead' : 'behind'; +} + +async function readCdnVersion(fetchImpl, url) { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = JSON.parse(await response.text()); + return typeof body.version === 'string' ? body.version : null; +} + +/** + * Poll the CDN manifest until it advertises `npmLatest`. + * + * A fetch failure, a non-ok status and an unparseable manifest are all treated + * exactly like 'behind': mid-deploy the origin is legitimately unreachable or + * half-written, and both that and plain lag resolve by waiting, so only the + * budget decides. 'ahead' returns at once — more waiting cannot fix a manifest + * that names a release npm does not have. + */ +export async function pollCdnUntilCaughtUp(options) { + const { fetchImpl, sleep, now, url, npmLatest, budgetMs, intervalMs } = options; + const deadline = now() + budgetMs; + let cdnVersion = null; + let attempts = 0; + + for (;;) { + attempts += 1; + let classification = 'unreachable'; + try { + const observed = await readCdnVersion(fetchImpl, url); + if (observed !== null) { + cdnVersion = observed; + classification = classifyCdnVersion(observed, npmLatest); + } + } catch { + // Deliberately swallowed: an unreachable CDN is lag, not a gate failure. + } + + if (classification === 'match') return { ok: true, reason: 'match', cdnVersion, attempts }; + if (classification === 'ahead') return { ok: false, reason: 'ahead', cdnVersion, attempts }; + + // Stop before a sleep that would run past the budget rather than after it. + if (now() + intervalMs >= deadline) { + return { ok: false, reason: 'timeout', cdnVersion, attempts }; + } + await sleep(intervalMs); + } +} diff --git a/scripts/release/verify-release-consistency.mjs b/scripts/release/verify-release-consistency.mjs index 5956a736..03e287de 100644 --- a/scripts/release/verify-release-consistency.mjs +++ b/scripts/release/verify-release-consistency.mjs @@ -1,25 +1,24 @@ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import { pollCdnUntilCaughtUp } from './cdn-consistency.mjs'; + 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'; +// A Dokploy rebuild serves the new manifest in roughly two minutes. The budget +// stays well under the job's own timeout-minutes so a stale CDN is reported +// here rather than killed by the runner. +const CDN_POLL_BUDGET_MS = 600_000; +const CDN_POLL_INTERVAL_MS = 15_000; + 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; @@ -60,31 +59,39 @@ 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`); -} +// The CDN manifest is what every installed client polls for updates. A version +// it advertises that npm does not have sends all of them into an install that +// cannot succeed; a version it never catches up to hides the release entirely. +// The pipeline triggers a rebuild before this job, so both are hard failures — +// waiting it out is the whole point of the poll. +const cdnPoll = await pollCdnUntilCaughtUp({ + fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + now: () => Date.now(), + url: CDN_MANIFEST_URL, + npmLatest: distTags.latest, + budgetMs: CDN_POLL_BUDGET_MS, + intervalMs: CDN_POLL_INTERVAL_MS, +}); -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`, +if (cdnPoll.reason === 'ahead') { + fail( + `CDN advertises ${cdnPoll.cdnVersion} but npm latest is ${distTags.latest} — ` + + 'clients would try to install a release that does not exist', ); } +if (!cdnPoll.ok) { + fail( + `CDN never caught up with npm within ${CDN_POLL_BUDGET_MS / 1000}s ` + + `(cdn=${cdnPoll.cdnVersion ?? 'unreachable'} latest=${distTags.latest}, ` + + `${cdnPoll.attempts} attempts) — every installed client polls this manifest, ` + + 'so the release stays invisible until the site rebuilds', + ); +} + +console.log(`CDN matches npm (${cdnPoll.cdnVersion}) after ${cdnPoll.attempts} attempt(s)`); console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`); From e7ad109839d550b87abd744e638670cfacbb2ca0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 8 Aug 2026 11:51:12 -0400 Subject: [PATCH 2/5] fix(tui): let only the dot carry background-task status The started line rendered dot and wording in periwinkle and the completed line rendered both in green, so an ambient background task drew as much attention as the work the user asked for. Keep the wording dim on every phase and colour the bullet alone: dim while running, green on completion, red on failure. --- .../messages/background-agent-status.ts | 13 ++++-- .../messages/background-agent-status.test.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts index 3d968bad..994f4fbb 100644 --- a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts @@ -15,17 +15,22 @@ export class BackgroundAgentStatusComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const tone: keyof ColorPalette = + // Only the bullet carries the status. A background task is ambient — it is + // not what the user asked for — so the wording stays dim and the eye picks + // the line out by colour of the dot alone, never by a fully coloured line. + const bulletTone: keyof ColorPalette = this.data.phase === 'started' - ? 'primary' + ? 'textDim' : this.data.phase === 'completed' ? 'success' : 'error'; const bullet = - this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); + this.data.phase === 'failed' + ? currentTheme.fg(bulletTone, FAILURE_MARK) + : currentTheme.fg(bulletTone, STATUS_BULLET); const text = - currentTheme.fg(tone, this.data.headline) + + currentTheme.fg('textDim', this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); diff --git a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts index fbc2efbc..03c35032 100644 --- a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,8 +1,10 @@ import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -45,6 +47,46 @@ describe('BackgroundAgentStatusComponent', () => { ); }); + it('colours only the bullet by phase and keeps the wording dim', () => { + const started = new BackgroundAgentStatusComponent({ + phase: 'started', + headline: 'bash task started in background', + detail: 'E2E: contained brand mark', + }); + const completed = new BackgroundAgentStatusComponent({ + phase: 'completed', + headline: 'bash task completed in background', + detail: 'E2E: contained brand mark · exit 0', + }); + + // Colours are off by default under vitest, which would make every + // assertion below compare bare strings and pass for the wrong reason. + const previousLevel = chalk.level; + chalk.level = 3; + try { + const startedLine = started.render(120)[1] ?? ''; + const completedLine = completed.render(120)[1] ?? ''; + + // A running task is ambient: dim dot, dim wording, no accent colour. + expect(startedLine).toContain(currentTheme.fg('textDim', STATUS_BULLET)); + expect(startedLine).toContain(currentTheme.fg('textDim', 'bash task started in background')); + expect(startedLine).not.toContain( + currentTheme.fg('primary', 'bash task started in background'), + ); + + // Completion turns the dot green — and only the dot. + expect(completedLine).toContain(currentTheme.fg('success', STATUS_BULLET)); + expect(completedLine).toContain( + currentTheme.fg('textDim', 'bash task completed in background'), + ); + expect(completedLine).not.toContain( + currentTheme.fg('success', 'bash task completed in background'), + ); + } finally { + chalk.level = previousLevel; + } + }); + it('keeps status lines within very narrow widths', () => { const component = new BackgroundAgentStatusComponent({ phase: 'started', From a13fce07d10e210682f518b74db2c2264ca7b76a Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 8 Aug 2026 11:51:36 -0400 Subject: [PATCH 3/5] chore: add changeset for the background-task status colours --- .changeset/dim-background-task-status-wording.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dim-background-task-status-wording.md diff --git a/.changeset/dim-background-task-status-wording.md b/.changeset/dim-background-task-status-wording.md new file mode 100644 index 00000000..ceb28a61 --- /dev/null +++ b/.changeset/dim-background-task-status-wording.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Dim the wording on background task status lines so only the status dot is coloured. From ec62c16751c1d736d4289f2dd3ba3082fe788c83 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 8 Aug 2026 12:03:01 -0400 Subject: [PATCH 4/5] fix: address PR review findings Reject a non-https webhook URL before curl sees it, and add --fail so an HTTP error status reaches the retries and the warning instead of exiting 0. Compare release identifiers with BigInt so two versions past 2^53 cannot round to the same float and read as a match. Drop the conditional fallbacks from the status colour test. --- .github/workflows/release.yml | 17 +++++++++++++++-- .../scripts/release/cdn-consistency.test.ts | 7 +++++++ .../messages/background-agent-status.test.ts | 4 ++-- scripts/release/cdn-consistency.mjs | 13 ++++++++++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 242e9373..5242f7e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -239,6 +239,17 @@ jobs: echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK not set — skipping CDN redeploy." exit 0 fi + # The URL is itself the deploy credential, so never send it over a + # scheme that puts it on the wire in cleartext. Warn rather than fail: + # verify-cdn-release runs only if this job succeeds, and failing here + # would drop the consistency gate instead of tripping it. + case "$WEBHOOK" in + https://*) ;; + *) + echo "::warning::DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL — refusing to send the deploy credential in cleartext." + exit 0 + ;; + esac # The webhook matches the branch from the request body: a bare POST # answers 301 {"message":"Branch Not Match"} and deploys nothing. # @@ -246,8 +257,10 @@ jobs: # published by now and that is irreversible, so dying here buys # nothing — an earlier version of this job was deleted because a # curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release - # polls the manifest and is the gate that fails loudly. - curl -sS -X POST "$WEBHOOK" \ + # polls the manifest and is the gate that fails loudly. `--fail` is + # what makes an HTTP error status reach the retries and the warning + # instead of exiting 0 and reading as a successful deploy. + curl -sS --fail -X POST "$WEBHOOK" \ -H 'Content-Type: application/json' \ -d '{"ref":"refs/heads/main"}' \ --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ diff --git a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts index d6b4073f..1c483e25 100644 --- a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts +++ b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts @@ -49,6 +49,13 @@ describe('compareRelease', () => { expect(compareRelease(parse('0.12.1'), parse('0.12.2'))).toBeLessThan(0); expect(compareRelease(parse('0.12.0'), parse('0.12.0'))).toBe(0); }); + + it('separates identifiers that float arithmetic would round together', () => { + const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; + // 9007199254740992 and 9007199254740993 are the same IEEE-754 double. + expect(compareRelease(parse('9007199254740993.0.0'), parse('9007199254740992.0.0'))).toBe(1); + expect(compareRelease(parse('0.9007199254740992.0'), parse('0.9007199254740993.0'))).toBe(-1); + }); }); describe('classifyCdnVersion', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts index 03c35032..22ed9f0f 100644 --- a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts @@ -64,8 +64,8 @@ describe('BackgroundAgentStatusComponent', () => { const previousLevel = chalk.level; chalk.level = 3; try { - const startedLine = started.render(120)[1] ?? ''; - const completedLine = completed.render(120)[1] ?? ''; + const startedLine = started.render(120).join('\n'); + const completedLine = completed.render(120).join('\n'); // A running task is ambient: dim dot, dim wording, no accent colour. expect(startedLine).toContain(currentTheme.fg('textDim', STATUS_BULLET)); diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs index b89487e7..8eab0f1c 100644 --- a/scripts/release/cdn-consistency.mjs +++ b/scripts/release/cdn-consistency.mjs @@ -15,11 +15,18 @@ /** Stable release semver. The CDN manifest never advertises a prerelease. */ const RELEASE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; -/** Numeric major/minor/patch compare over two RELEASE_SEMVER matches. */ +/** + * Numeric major/minor/patch compare over two RELEASE_SEMVER matches. + * + * BigInt rather than Number: semver puts no ceiling on an identifier, and two + * distinct versions past 2^53 would round to the same float and compare equal — + * reporting a stale or impossible CDN as a match. + */ export 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; + const a = BigInt(left[index]); + const b = BigInt(right[index]); + if (a !== b) return a < b ? -1 : 1; } return 0; } From dc94f1da29c9172675eae85e821db6a327d0fd8b Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 8 Aug 2026 12:05:19 -0400 Subject: [PATCH 5/5] docs: document the CDN manifest read helper --- scripts/release/cdn-consistency.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs index 8eab0f1c..35e2d244 100644 --- a/scripts/release/cdn-consistency.mjs +++ b/scripts/release/cdn-consistency.mjs @@ -46,6 +46,12 @@ export function classifyCdnVersion(cdnVersion, npmLatest) { return diff > 0 ? 'ahead' : 'behind'; } +/** + * Read the version the CDN manifest currently advertises. + * + * Throws on transport, status and parse failures alike; the caller treats all + * three the same way, so they are deliberately not distinguished here. + */ async function readCdnVersion(fetchImpl, url) { const response = await fetchImpl(url); if (!response.ok) throw new Error(`HTTP ${response.status}`);