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
20 changes: 15 additions & 5 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,28 @@
# release and version, so a fork client and a fork server on the other OS update in step.
#
# Each build is collin/main merged with the commit of upstream's newest nightly, so the fork
# follows upstream release by release. GitHub cannot wake a fork when upstream publishes, so a
# cheap check runs every hour and builds only when that nightly or collin/main is new.
# follows upstream release by release. GitHub cannot wake a fork when upstream publishes (and npm
# registry hooks are gone), and scheduled runs in a fork fire hours late or not at all. So
# fork/upstream-nightly-watcher, a Cloudflare Worker, checks every minute and sends
# repository_dispatch once per new upstream nightly; the cron is only a fallback. Every trigger
# builds only when that nightly or collin/main is new.
# Nothing is pushed to a branch. Claude Code resolves a merge conflict for the build only
# (fork/resolve-conflicts.sh, with the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret);
# collin/main itself is still synced by fork/sync.sh. When Claude cannot resolve it, the run fails
# (and GitHub mails the owner): then sync collin/main by hand.
name: Fork release
# The watcher reads this title to dispatch each upstream nightly only once.
run-name: ${{ github.event.client_payload.upstream_tag && format('Fork release of {0}', github.event.client_payload.upstream_tag) || 'Fork release' }}

on:
repository_dispatch:
types: [upstream-nightly]
push:
branches: [collin/main]
schedule:
# A run without a new upstream nightly or fork commit ends after two API calls.
- cron: "23 * * * *"
# Fallback for a missed or failed dispatch. A run without a new upstream nightly or fork
# commit ends after two API calls.
- cron: "23 */6 * * *"
workflow_dispatch:
inputs:
force:
Expand Down Expand Up @@ -113,7 +123,7 @@ jobs:
echo "conflict=true" >> "$GITHUB_OUTPUT"
fi

# One resolution per fork/upstream pair: a failed build leaves the pair due again every hour,
# One resolution per fork/upstream pair: a failed build leaves the pair due again on every run,
# and a retry must neither pay for nor re-roll the resolution. force asks Claude again.
- name: Restore an earlier resolution of these commits
id: earlier
Expand Down
17 changes: 13 additions & 4 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ The installed app is a fork build, so the update button stays on the fork. The d
for updates in the repository it was built in (`app-update.yml`, from `GITHUB_REPOSITORY`); an
official Nightly would update to upstream and lose the fork features.

`.github/workflows/fork-release.yml` checks every hour (and on dispatch) for a new upstream
nightly or a new `collin/main` commit. It merges the commit of upstream's newest nightly into
`collin/main` for the build only, skips when the last release already has both commits, builds
`.github/workflows/fork-release.yml` runs when upstream publishes a nightly, on every push to
`collin/main`, every 6 hours as a fallback, and on dispatch. GitHub cannot notify a fork of
upstream releases and its cron fires hours late in a fork, so the Cloudflare Worker in
`fork/upstream-nightly-watcher/` checks every minute and sends one `repository_dispatch` per new
upstream nightly (a failed build is retried by the 6-hour cron, not redispatched).

Each run merges the commit of upstream's newest nightly into `collin/main` for the build only, skips when the last release already has both commits, builds
Windows x64 (NSIS, `nightly.yml`) and Linux x64 (AppImage, `nightly-linux.yml`) side by side, and
publishes both in one prerelease `v<x.y.z>-nightly.<date>.<10000 + run>`. The version scheme is
upstream's (the offset sorts a fork build above an upstream nightly of the same day), so the app's
Expand All @@ -58,10 +62,15 @@ version. `-f force=true` builds without new commits.
A merge conflict is resolved by Claude Code (`fork/resolve-conflicts.sh`, Opus, at most $5 per
run) for the build only; the typecheck job still gates the release. It needs the repository secret
`CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`; bills the subscription's monthly Agent SDK
credit) or `ANTHROPIC_API_KEY` (Console API credits; wins when both are set). Each fork/upstream pair is resolved once and cached, so hourly retries cost
credit) or `ANTHROPIC_API_KEY` (Console API credits; wins when both are set). Each fork/upstream pair is resolved once and cached, so retries cost
nothing. When Claude cannot resolve it, every run of that pair fails: sync `collin/main` by hand,
then `gh workflow run fork-release.yml` (`-f force=true` asks Claude again instead).

Watcher setup (once, or after the token expires), from `fork/upstream-nightly-watcher/`:
`npx wrangler deploy`, then `npx wrangler secret put GITHUB_TOKEN` with a fine-grained token
for `collissimo/t3code` (Contents: read and write, Actions: read). `npx wrangler tail` shows
each check.

- Every machine must run a fork build once (installed by hand); from then on the update button,
including "update everything" for connected servers, stays on the fork. A desktop app on the
other feed (an official build) is never offered a server update from a fork client: it could
Expand Down
1 change: 1 addition & 0 deletions fork/upstream-nightly-watcher/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.wrangler/
54 changes: 54 additions & 0 deletions fork/upstream-nightly-watcher/worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Starts fork-release.yml once per new upstream nightly. GitHub cannot notify a fork of upstream
// releases and scheduled runs in a fork are unreliable, so this checks every minute.
// Secret GITHUB_TOKEN: fine-grained token for the fork with Contents read/write (dispatch) and
// Actions read (runs); the unauthenticated API limit (60/h) is too low for these reads.

async function github(env, path, init = {}) {
const response = await fetch(`https://api.github.com/${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${env.GITHUB_TOKEN}`,
"user-agent": "t3code-upstream-nightly-watcher",
"x-github-api-version": "2022-11-28",
...init.headers,
},
});
if (!response.ok) {
throw new Error(`${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`);
}
return response.status === 204 ? null : response.json();
}

async function check(env) {
// Same choice as fork-release.yml: the newest published nightly.
const upstream = await github(env, `repos/${env.UPSTREAM}/releases?per_page=30`);
const nightly = upstream
.filter((release) => !release.draft && release.tag_name.includes("-nightly."))
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
if (!nightly) return "no upstream nightly";
const tag = nightly.tag_name;

// fork-release.yml writes "builds of upstream <tag> with" into each release.
const [latest] = await github(env, `repos/${env.FORK}/releases?per_page=1`);
if (latest?.body?.includes(`upstream ${tag} `)) return `${tag} already released`;

// A failed build must not be redispatched every minute; the workflow's cron retries it.
const workflow = `repos/${env.FORK}/actions/workflows/fork-release.yml`;
const runs = await github(env, `${workflow}/runs?event=repository_dispatch&per_page=5`);
if (runs.workflow_runs.some((run) => run.display_title === `Fork release of ${tag}`)) {
return `${tag} already dispatched`;
}

await github(env, `repos/${env.FORK}/dispatches`, {
method: "POST",
body: JSON.stringify({ event_type: "upstream-nightly", client_payload: { upstream_tag: tag } }),
});
return `dispatched ${tag}`;
}

export default {
async scheduled(_controller, env) {
console.log(await check(env));
},
};
11 changes: 11 additions & 0 deletions fork/upstream-nightly-watcher/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name = "t3code-upstream-nightly-watcher"
main = "worker.js"
compatibility_date = "2026-10-01"
workers_dev = false

[triggers]
crons = ["* * * * *"]

[vars]
UPSTREAM = "pingdotgg/t3code"
FORK = "collissimo/t3code"
Loading