Skip to content

Commit 26f3d18

Browse files
authored
fix(release): self-heal a lost CDN rebuild and surface tap push errors (#77)
## Related Issue No issue — the problem is described below. ## Problem The CDN manifest at `https://code.pythinker.com/pythinker-code/latest.json` is what every installed client polls for updates, and its correctness rested on exactly **one** fire-and-forget webhook POST in the `redeploy-cdn` job. On the 0.18.0 release that POST hit four consecutive `curl: (28)` 60-second connect timeouts. The job emitted only a `::warning::` (correctly — npm has already published irreversibly by then), and `verify-cdn-release` then polled a manifest that nobody had asked to rebuild. It sat at 0.17.1 for the full 600s budget and failed, and the release stayed invisible to every installed client until a human fired the webhook by hand. The pipeline had no path from "the trigger was lost" back to "ask again". 0.17.1 succeeded by timing, not by design. Separately, `scripts/release/update-brew-formula.mjs` ran `git push` under `stdio: 'ignore'` and replaced whatever git said with the constant string `Failed to push Homebrew tap`. The tap bump has now failed on 0.17.1 and 0.18.0 and GitHub's actual refusal has never been seen, so the auth root cause is still a guess. ## What changed **CDN — the consistency poll now heals a lost trigger.** `pollCdnUntilCaughtUp` takes an optional `retrigger` callback and `retriggerEveryAttempts`; `verify-release-consistency.mjs` supplies one that re-POSTs the Dokploy deploy webhook every 8 attempts (~2 min at the 15s interval, matching the Dokploy build time) while the CDN is behind. The poll is the only pipeline stage that both knows the CDN is still stale and is still running, so it is the right place for the retry. - A throwing `retrigger` is swallowed exactly like a failed fetch — a trigger that cannot be sent is lag, not a gate failure. - Never fires on `match` or `ahead`: more rebuilds cannot fix a manifest naming a release npm does not have. - The re-trigger count is reported in both the success and the timeout message, which is what distinguishes "we asked and the site never caught up" from "we never managed to ask". - Budget 600s → 900s (it now has to cover detecting a lost trigger *plus* a fresh build), job timeout 15 → 20 min. - `redeploy-cdn` curl: `--connect-timeout 15 --max-time 45 --retry 5 --retry-delay 15`. On 0.18.0 a single connect that never established consumed the whole 60s budget, so 3 attempts covered only ~3 minutes of a longer outage. - `redeploy-cdn` keeps its warn-don't-fail posture. `verify-cdn-release` stays the loud gate. **Homebrew tap — git's real error now reaches the log.** Both the clone and the push capture stdout and stderr and rethrow with the actual message. A redaction helper collapses the credentialed userinfo (`//x-access-token:…@`) to `//***@` and replaces the raw token, so the credential cannot land in a public Actions log. No auth mechanism or credential is changed here — this is the diagnostic that tells us which fix the tap actually needs. The dependency-injection shape of `cdn-consistency.mjs` is untouched, so all of this is unit-tested with no network and no real wait. ## Verification - New tests fail RED against the unmodified module (6 failed / 12 passed) — the callback is never invoked there — and GREEN after (18/18). Coverage: fires on cadence with an exact call count, never on immediate match, never on `ahead`, survives a throwing callback, fires while the manifest is unreachable, and reports `retriggers` on the timeout return. - `node --check` on all three release scripts → `SYNTAX_OK`. - Workflow assertion that `DOKPLOY_CDN_DEPLOY_WEBHOOK` and `timeout-minutes: 20` landed inside the `verify-cdn-release` block (red on the unchanged file, confirmed). - `pnpm run lint` → exit 0. Full pre-push gate green: 402 tests, typecheck, nix-hash-freshness. ## Checklist - [x] I have read the CONTRIBUTING document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved release reliability when CDN rebuild requests are delayed or temporarily lost. - Release verification now polls longer and automatically retries CDN rebuild requests. - Releases remain available through the correct update channel during deployment delays. - Improved publishing error details while keeping access credentials out of logs. - Homebrew tap updates now use more secure, repository-scoped authentication. - **Tests** - Added coverage for CDN recovery, retry behavior, continued polling, and timeout reporting. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 86a4f9a commit 26f3d18

7 files changed

Lines changed: 235 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Keep releases visible in the update channel when a CDN rebuild request is temporarily lost.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Use a scoped GitHub App token for Homebrew tap updates.

.github/workflows/release.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,14 @@ jobs:
264264
# nothing — an earlier version of this job was deleted because a
265265
# curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release
266266
# polls the manifest and is the gate that fails loudly.
267+
# On 0.18.0, one connect consumed the full 60-second budget. A short
268+
# connect timeout turns the same wall-clock budget into more attempts
269+
# during an outage instead of waiting on connections never made.
267270
status=$(curl -sS -o /dev/stderr -w '%{http_code}' -X POST "$WEBHOOK" \
268271
-H 'Content-Type: application/json' \
269272
-H 'X-GitHub-Event: push' \
270273
-d '{"ref":"refs/heads/main"}' \
271-
--retry 3 --retry-all-errors --retry-delay 10 --max-time 60) || status=000
274+
--connect-timeout 15 --max-time 45 --retry 5 --retry-all-errors --retry-delay 15) || status=000
272275
case "$status" in
273276
2*) echo "CDN redeploy triggered (HTTP $status)." ;;
274277
*) echo "::warning::CDN redeploy webhook returned HTTP $status — verify-cdn-release will catch a stale CDN." ;;
@@ -282,7 +285,7 @@ jobs:
282285
# publish hid the one case where the version and the published artifacts
283286
# diverge — and every client polled the CDN for a release that never existed.
284287
verify-cdn-release:
285-
timeout-minutes: 15
288+
timeout-minutes: 20
286289
name: Verify release consistency
287290
needs:
288291
- release
@@ -303,6 +306,8 @@ jobs:
303306
node-version-file: .nvmrc
304307

305308
- name: Verify release consistency
309+
env:
310+
DOKPLOY_CDN_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
306311
run: node scripts/release/verify-release-consistency.mjs
307312

308313
update-brew-tap:
@@ -320,10 +325,24 @@ jobs:
320325
with:
321326
node-version-file: .nvmrc
322327

328+
# Any permission-* input switches the token from inheriting every
329+
# permission the App installation holds to exactly the ones listed here.
330+
# Cloning and pushing the tap needs contents and nothing else.
331+
- name: Mint tap token
332+
id: tap-token
333+
uses: actions/create-github-app-token@v2
334+
with:
335+
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
336+
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
337+
owner: PyModel
338+
repositories: homebrew-tap
339+
permission-contents: write
340+
323341
- name: Bump formula
324342
env:
325-
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
343+
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
326344
run: |
345+
# The token comes from the App installation, not a PAT.
327346
if [ -z "$TAP_GITHUB_TOKEN" ]; then
328347
echo "TAP_GITHUB_TOKEN secret not set — skipping tap update" >&2
329348
exit 0

apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,125 @@ describe('pollCdnUntilCaughtUp', () => {
111111
expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 3 });
112112
});
113113

114+
it('re-triggers on the configured cadence while the CDN is behind', async () => {
115+
const { now, sleep } = fakeClock();
116+
let retriggerCalls = 0;
117+
const result = await pollCdnUntilCaughtUp({
118+
...base,
119+
now,
120+
sleep,
121+
fetchImpl: scriptedFetch([
122+
() => manifest('0.12.0'),
123+
() => manifest('0.12.0'),
124+
() => manifest('0.12.0'),
125+
() => manifest('0.12.0'),
126+
() => manifest('0.12.0'),
127+
() => manifest('0.12.0'),
128+
() => manifest('0.13.0'),
129+
]),
130+
retrigger: async () => {
131+
retriggerCalls += 1;
132+
},
133+
retriggerEveryAttempts: 3,
134+
});
135+
136+
expect(retriggerCalls).toBe(2);
137+
expect(result).toMatchObject({ ok: true, attempts: 7, retriggers: 2 });
138+
});
139+
140+
it('does not re-trigger when the first attempt matches', async () => {
141+
const { now, sleep } = fakeClock();
142+
let retriggerCalls = 0;
143+
const result = await pollCdnUntilCaughtUp({
144+
...base,
145+
now,
146+
sleep,
147+
fetchImpl: scriptedFetch([() => manifest('0.13.0')]),
148+
retrigger: async () => {
149+
retriggerCalls += 1;
150+
},
151+
retriggerEveryAttempts: 1,
152+
});
153+
154+
expect(retriggerCalls).toBe(0);
155+
expect(result.retriggers).toBe(0);
156+
});
157+
158+
it('does not re-trigger when the CDN is ahead', async () => {
159+
const { now, sleep } = fakeClock();
160+
let retriggerCalls = 0;
161+
const result = await pollCdnUntilCaughtUp({
162+
...base,
163+
now,
164+
sleep,
165+
fetchImpl: scriptedFetch([() => manifest('0.14.0')]),
166+
retrigger: async () => {
167+
retriggerCalls += 1;
168+
},
169+
retriggerEveryAttempts: 1,
170+
});
171+
172+
expect(retriggerCalls).toBe(0);
173+
expect(result).toMatchObject({ reason: 'ahead', retriggers: 0 });
174+
});
175+
176+
it('keeps polling when a re-trigger throws', async () => {
177+
const { now, sleep } = fakeClock();
178+
let retriggerCalls = 0;
179+
const result = await pollCdnUntilCaughtUp({
180+
...base,
181+
now,
182+
sleep,
183+
fetchImpl: scriptedFetch([() => manifest('0.12.0'), () => manifest('0.13.0')]),
184+
retrigger: async () => {
185+
retriggerCalls += 1;
186+
throw new Error('trigger failed');
187+
},
188+
retriggerEveryAttempts: 1,
189+
});
190+
191+
expect(retriggerCalls).toBe(1);
192+
expect(result).toMatchObject({ ok: true, attempts: 2, retriggers: 1 });
193+
});
194+
195+
it('re-triggers while the CDN is unreachable', async () => {
196+
const { now, sleep } = fakeClock();
197+
let retriggerCalls = 0;
198+
const result = await pollCdnUntilCaughtUp({
199+
...base,
200+
now,
201+
sleep,
202+
fetchImpl: scriptedFetch([
203+
() => {
204+
throw new Error('ECONNREFUSED');
205+
},
206+
() => manifest('0.13.0'),
207+
]),
208+
retrigger: async () => {
209+
retriggerCalls += 1;
210+
},
211+
retriggerEveryAttempts: 1,
212+
});
213+
214+
expect(retriggerCalls).toBe(1);
215+
expect(result).toMatchObject({ ok: true, retriggers: 1 });
216+
});
217+
218+
it('reports re-trigger attempts when the budget expires', async () => {
219+
const { now, sleep } = fakeClock();
220+
const result = await pollCdnUntilCaughtUp({
221+
...base,
222+
budgetMs: 45_000,
223+
now,
224+
sleep,
225+
fetchImpl: scriptedFetch([() => manifest('0.12.0')]),
226+
retrigger: async () => {},
227+
retriggerEveryAttempts: 1,
228+
});
229+
230+
expect(result).toMatchObject({ reason: 'timeout', attempts: 3, retriggers: 2 });
231+
});
232+
114233
it('treats an unreachable CDN as lag rather than a failure', async () => {
115234
const { now, sleep } = fakeClock();
116235
const result = await pollCdnUntilCaughtUp({

scripts/release/cdn-consistency.mjs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,16 @@ async function readCdnVersion(fetchImpl, url) {
6767
* half-written, and both that and plain lag resolve by waiting, so only the
6868
* budget decides. 'ahead' returns at once — more waiting cannot fix a manifest
6969
* that names a release npm does not have.
70+
*
71+
* A periodic re-trigger heals a lost deploy request. This poll is the only
72+
* pipeline stage that both knows the CDN is still behind and is still running.
7073
*/
7174
export async function pollCdnUntilCaughtUp(options) {
72-
const { fetchImpl, sleep, now, url, npmLatest, budgetMs, intervalMs } = options;
75+
const { fetchImpl, sleep, now, url, npmLatest, budgetMs, intervalMs, retrigger, retriggerEveryAttempts } = options;
7376
const deadline = now() + budgetMs;
7477
let cdnVersion = null;
7578
let attempts = 0;
79+
let retriggers = 0;
7680

7781
for (;;) {
7882
attempts += 1;
@@ -87,12 +91,25 @@ export async function pollCdnUntilCaughtUp(options) {
8791
// Deliberately swallowed: an unreachable CDN is lag, not a gate failure.
8892
}
8993

90-
if (classification === 'match') return { ok: true, reason: 'match', cdnVersion, attempts };
91-
if (classification === 'ahead') return { ok: false, reason: 'ahead', cdnVersion, attempts };
94+
if (classification === 'match') return { ok: true, reason: 'match', cdnVersion, attempts, retriggers };
95+
if (classification === 'ahead') return { ok: false, reason: 'ahead', cdnVersion, attempts, retriggers };
9296

9397
// Stop before a sleep that would run past the budget rather than after it.
9498
if (now() + intervalMs >= deadline) {
95-
return { ok: false, reason: 'timeout', cdnVersion, attempts };
99+
return { ok: false, reason: 'timeout', cdnVersion, attempts, retriggers };
100+
}
101+
if (
102+
typeof retrigger === 'function' &&
103+
Number.isInteger(retriggerEveryAttempts) &&
104+
retriggerEveryAttempts > 0 &&
105+
attempts % retriggerEveryAttempts === 0
106+
) {
107+
retriggers += 1;
108+
try {
109+
await retrigger();
110+
} catch {
111+
// Deliberately swallowed: a failed trigger is lag, not a gate failure.
112+
}
96113
}
97114
await sleep(intervalMs);
98115
}

scripts/release/update-brew-formula.mjs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66

7+
function redactGitOutput(value, token) {
8+
const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@');
9+
return token.length >= 8 ? redacted.replaceAll(token, '***') : redacted;
10+
}
11+
712
async function main() {
813
const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8'));
914
const version = packageJson.version;
@@ -22,10 +27,13 @@ async function main() {
2227
try {
2328
try {
2429
execFileSync('git', ['clone', `https://x-access-token:${token}@github.com/PyModel/homebrew-tap.git`, tapDir], {
25-
stdio: 'ignore',
30+
stdio: 'pipe',
2631
});
27-
} catch {
28-
throw new Error('Failed to clone Homebrew tap');
32+
} catch (error) {
33+
const stderr = redactGitOutput(error.stderr, token).trim();
34+
const stdout = redactGitOutput(error.stdout, token).trim();
35+
const message = redactGitOutput(error.message, token).trim();
36+
throw new Error(`Failed to clone Homebrew tap: ${stderr || stdout || message}`, { cause: error });
2937
}
3038

3139
const formulaPath = join(tapDir, 'Formula/pythinker-code.rb');
@@ -62,9 +70,12 @@ async function main() {
6270
{ cwd: tapDir, stdio: 'inherit' },
6371
);
6472
try {
65-
execFileSync('git', ['push', 'origin', 'main'], { cwd: tapDir, stdio: 'ignore' });
66-
} catch {
67-
throw new Error('Failed to push Homebrew tap');
73+
execFileSync('git', ['push', 'origin', 'main'], { cwd: tapDir, stdio: 'pipe' });
74+
} catch (error) {
75+
const stderr = redactGitOutput(error.stderr, token).trim();
76+
const stdout = redactGitOutput(error.stdout, token).trim();
77+
const message = redactGitOutput(error.message, token).trim();
78+
throw new Error(`Failed to push Homebrew tap: ${stderr || stdout || message}`, { cause: error });
6879
}
6980
} finally {
7081
rmSync(tapDir, { recursive: true, force: true });

scripts/release/verify-release-consistency.mjs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[
88

99
const CDN_MANIFEST_URL = 'https://code.pythinker.com/pythinker-code/latest.json';
1010

11-
// A Dokploy rebuild serves the new manifest in roughly two minutes. The budget
12-
// stays well under the job's own timeout-minutes so a stale CDN is reported
13-
// here rather than killed by the runner.
14-
const CDN_POLL_BUDGET_MS = 600_000;
11+
// The budget covers detecting a lost trigger and completing a fresh rebuild,
12+
// while staying under the job timeout so this gate can report a stale CDN.
13+
const CDN_POLL_BUDGET_MS = 900_000;
1514
const CDN_POLL_INTERVAL_MS = 15_000;
1615

1716
function fail(reason) {
@@ -59,6 +58,41 @@ try {
5958
}
6059
if (!gitTags.trim().split('\n').includes(releaseTag)) fail(`missing git tag ${releaseTag}`);
6160

61+
const webhook = process.env.DOKPLOY_CDN_DEPLOY_WEBHOOK;
62+
let retrigger;
63+
if (typeof webhook === 'string' && webhook.length > 0) {
64+
let isUsable;
65+
try {
66+
const url = new URL(webhook);
67+
isUsable = url.protocol === 'https:' && url.host.length > 0;
68+
} catch {
69+
isUsable = false;
70+
}
71+
if (isUsable) {
72+
retrigger = async () => {
73+
try {
74+
const response = await fetch(webhook, {
75+
method: 'POST',
76+
headers: {
77+
'Content-Type': 'application/json',
78+
'X-GitHub-Event': 'push',
79+
},
80+
body: '{"ref":"refs/heads/main"}',
81+
signal: AbortSignal.timeout(30_000),
82+
});
83+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
84+
console.log(`CDN rebuild request returned HTTP ${response.status}`);
85+
} catch (error) {
86+
const message = error instanceof Error ? error.message.replaceAll(webhook, '***') : 'unknown error';
87+
console.error(`CDN rebuild request failed: ${message}`);
88+
throw error;
89+
}
90+
};
91+
} else {
92+
console.error('warning: DOKPLOY_CDN_DEPLOY_WEBHOOK is not an https:// URL; CDN rebuild requests are disabled');
93+
}
94+
}
95+
6296
// The CDN manifest is what every installed client polls for updates. A version
6397
// it advertises that npm does not have sends all of them into an install that
6498
// cannot succeed; a version it never catches up to hides the release entirely.
@@ -75,6 +109,8 @@ const cdnPoll = await pollCdnUntilCaughtUp({
75109
npmLatest: distTags.latest,
76110
budgetMs: CDN_POLL_BUDGET_MS,
77111
intervalMs: CDN_POLL_INTERVAL_MS,
112+
retrigger,
113+
retriggerEveryAttempts: 8,
78114
});
79115

80116
if (cdnPoll.reason === 'ahead') {
@@ -87,11 +123,15 @@ if (!cdnPoll.ok) {
87123
fail(
88124
`CDN never caught up with npm within ${CDN_POLL_BUDGET_MS / 1000}s ` +
89125
`(cdn=${cdnPoll.cdnVersion ?? 'unreachable'} latest=${distTags.latest}, ` +
90-
`${cdnPoll.attempts} attempts) — every installed client polls this manifest, ` +
126+
`${cdnPoll.attempts} attempt(s), ${cdnPoll.retriggers} rebuild request(s)) — ` +
127+
'every installed client polls this manifest, ' +
91128
'so the release stays invisible until the site rebuilds',
92129
);
93130
}
94131

95-
console.log(`CDN matches npm (${cdnPoll.cdnVersion}) after ${cdnPoll.attempts} attempt(s)`);
132+
console.log(
133+
`CDN matches npm (${cdnPoll.cdnVersion}) after ${cdnPoll.attempts} attempt(s), ` +
134+
`${cdnPoll.retriggers} rebuild request(s)`,
135+
);
96136

97137
console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`);

0 commit comments

Comments
 (0)