Skip to content

Commit a7074c9

Browse files
authored
Merge branch 'main' into feat/desktop-app
2 parents 8abe06d + 26f3d18 commit a7074c9

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)