From 5ab701aae9dd41c1f6144bb642b5e14bd1316ae7 Mon Sep 17 00:00:00 2001 From: collissimo <148455189+collissimo@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:47:56 +0200 Subject: [PATCH 1/3] Fork release: Claude resolves upstream merge conflicts for the build The hourly fork release failed whenever upstream no longer merged cleanly into collin/main. Now fork/resolve-conflicts.sh has Claude Code resolve the conflicted files (and nothing else), the result is cached per fork/upstream pair so retries cost nothing, and prepare hands the exact merge commit to the typecheck and build jobs as a bundle instead of each re-merging. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fork-release.yml | 130 +++++++++++++++++++++++++---- FORK.md | 10 ++- fork/resolve-conflicts.sh | 62 ++++++++++++++ 3 files changed, 186 insertions(+), 16 deletions(-) create mode 100755 fork/resolve-conflicts.sh diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index f5ed765be20bb..04ea122d913fc 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -10,8 +10,10 @@ # 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. -# Nothing is pushed to a branch. A merge conflict fails the run (and GitHub mails the owner): -# then sync collin/main by hand. +# 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 on: @@ -41,7 +43,7 @@ jobs: prepare: if: github.repository != 'pingdotgg/t3code' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 45 outputs: due: ${{ steps.due.outputs.due }} fork_sha: ${{ steps.refs.outputs.fork_sha }} @@ -87,6 +89,7 @@ jobs: # Plain git, as in release-desktop.yml: checkout's cleanup trips over the vendored .repos gitlinks. - name: Merge upstream into the fork + id: merge if: steps.due.outputs.due == 'true' env: GIT_TERMINAL_PROMPT: "0" @@ -103,11 +106,98 @@ jobs: git fetch -q --no-tags upstream "$UPSTREAM_SHA" git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout -q --detach "$FORK_SHA" - if ! git merge -q --no-edit -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA"; then - git diff --name-only --diff-filter=U | sed 's/^/::error::conflict: /' - echo "::error::upstream no longer merges cleanly into $FORK_BRANCH; sync it by hand" + if git merge -q --no-edit -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + else + git diff --name-only --diff-filter=U | sed 's/^/::warning::conflict: /' + echo "conflict=true" >> "$GITHUB_OUTPUT" + fi + + # One resolution per fork/upstream pair: a failed build leaves the pair due again every hour, + # 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 + if: steps.merge.outputs.conflict == 'true' && !inputs.force + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/resolution + key: fork-merge-${{ steps.refs.outputs.fork_sha }}-${{ steps.refs.outputs.upstream_sha }} + + - name: Resolve conflicts with Claude + if: steps.merge.outputs.conflict == 'true' && steps.earlier.outputs.cache-hit != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_AUTOUPDATER: "1" + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" + FORK_SHA: ${{ steps.refs.outputs.fork_sha }} + UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} + run: | + set -euo pipefail + [[ -n "$ANTHROPIC_API_KEY" ]] || unset ANTHROPIC_API_KEY + [[ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]] || unset CLAUDE_CODE_OAUTH_TOKEN + if [[ -z "${ANTHROPIC_API_KEY:-}${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + echo "::error::upstream no longer merges cleanly into $FORK_BRANCH and neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set; sync it by hand" + exit 1 + fi + resolution="$RUNNER_TEMP/resolution" + mkdir -p "$resolution" + npm install -g --silent @anthropic-ai/claude-code@2.1.306 + if bash fork/resolve-conflicts.sh; then + git bundle create -q "$resolution/merge.bundle" HEAD "^$FORK_SHA" "^$UPSTREAM_SHA" + else + touch "$resolution/failed" + fi + + - name: Remember the resolution + if: steps.merge.outputs.conflict == 'true' && steps.earlier.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/resolution + key: fork-merge-${{ steps.refs.outputs.fork_sha }}-${{ steps.refs.outputs.upstream_sha }} + + - name: Apply the resolution + if: steps.merge.outputs.conflict == 'true' + env: + CACHE_HIT: ${{ steps.earlier.outputs.cache-hit }} + run: | + set -euo pipefail + resolution="$RUNNER_TEMP/resolution" + if [[ -f "$resolution/failed" ]]; then + echo "::error::Claude could not resolve the conflicts with upstream (see the first run of these commits); sync $FORK_BRANCH by hand, or rerun with force" exit 1 fi + if [[ "$CACHE_HIT" == "true" ]]; then + git merge --abort + git fetch -q --no-tags "$resolution/merge.bundle" + git -c checkout.workers=0 checkout -q --detach FETCH_HEAD + fi + git log -1 --format='Merge %H' + + # The merge commit differs per job (timestamps) and a resolution must not be re-rolled, so the + # build jobs check out this exact commit. The sha file keeps the artifact non-empty when + # the merge is a plain fork or upstream commit, which they fetch anyway. + - name: Bundle the merge + if: steps.due.outputs.due == 'true' + env: + FORK_SHA: ${{ steps.refs.outputs.fork_sha }} + UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/merge" + merge_sha=$(git rev-parse HEAD) + echo "$merge_sha" > "$RUNNER_TEMP/merge/sha" + if [[ "$merge_sha" != "$FORK_SHA" && "$merge_sha" != "$UPSTREAM_SHA" ]]; then + git bundle create -q "$RUNNER_TEMP/merge/merge.bundle" HEAD "^$FORK_SHA" "^$UPSTREAM_SHA" + fi + + - if: steps.due.outputs.due == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: merge + path: ${{ runner.temp }}/merge + if-no-files-found: error + retention-days: 1 # Upstream's nightly scheme (scripts/resolve-nightly-release.ts: next patch of the desktop # version), so the app's nightly channel takes these. The run number is offset so a fork @@ -134,6 +224,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: merge + path: ${{ runner.temp }}/merge + - name: Check out the merge env: GIT_TERMINAL_PROMPT: "0" @@ -141,16 +236,17 @@ jobs: UPSTREAM_SHA: ${{ needs.prepare.outputs.upstream_sha }} run: | set -euo pipefail + merge="$RUNNER_TEMP/merge" git init -q . - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git remote add upstream "$UPSTREAM_URL" git fetch -q --no-tags origin "$FORK_SHA" git fetch -q --no-tags upstream "$UPSTREAM_SHA" + if [[ -f "$merge/merge.bundle" ]]; then + git fetch -q --no-tags "$merge/merge.bundle" + fi git sparse-checkout set --no-cone '/*' '!/.repos/' - git -c checkout.workers=0 checkout -q --detach "$FORK_SHA" - git merge -q --no-edit -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA" + git -c checkout.workers=0 checkout -q --detach "$(cat "$merge/sha")" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 @@ -196,6 +292,11 @@ jobs: env: VERSION: ${{ needs.prepare.outputs.version }} steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: merge + path: ${{ runner.temp }}/merge + - name: Check out the merge env: GIT_TERMINAL_PROMPT: "0" @@ -203,16 +304,17 @@ jobs: UPSTREAM_SHA: ${{ needs.prepare.outputs.upstream_sha }} run: | set -euo pipefail + merge="$RUNNER_TEMP/merge" git init -q . - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git remote add upstream "$UPSTREAM_URL" git fetch -q --no-tags origin "$FORK_SHA" git fetch -q --no-tags upstream "$UPSTREAM_SHA" + if [[ -f "$merge/merge.bundle" ]]; then + git fetch -q --no-tags "$merge/merge.bundle" + fi git sparse-checkout set --no-cone '/*' '!/.repos/' - git -c checkout.workers=0 checkout -q --detach "$FORK_SHA" - git merge -q --no-edit -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA" + git -c checkout.workers=0 checkout -q --detach "$(cat "$merge/sha")" - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 diff --git a/FORK.md b/FORK.md index be3ff8bf1db43..0b980e27a6d1c 100644 --- a/FORK.md +++ b/FORK.md @@ -53,8 +53,14 @@ Windows x64 (NSIS, `nightly.yml`) and Linux x64 (AppImage, `nightly-linux.yml`) publishes both in one prerelease `v-nightly..<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 nightly channel takes it, and a fork client and fork server on the other OS stay on the same -version. A merge conflict fails the run; sync `collin/main` by hand, then -`gh workflow run fork-release.yml`. `-f force=true` builds without new commits. +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 +`ANTHROPIC_API_KEY` (API credits) or `CLAUDE_CODE_OAUTH_TOKEN` (subscription, from +`claude setup-token`). Each fork/upstream pair is resolved once and cached, so hourly 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). - 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 diff --git a/fork/resolve-conflicts.sh b/fork/resolve-conflicts.sh new file mode 100755 index 0000000000000..2742ad8f4538e --- /dev/null +++ b/fork/resolve-conflicts.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Resolves the conflicts of an in-progress `git merge` with Claude Code, then commits the merge. +# fork-release.yml runs it when upstream no longer merges cleanly into collin/main; the result only +# feeds that build, collin/main itself is still synced by hand or by the daily T3 task. +# Needs the claude CLI and ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. +# Exit 1 = not resolved, nothing committed. +set -euo pipefail + +mapfile -t files < <(git diff --name-only --diff-filter=U) +if [[ ${#files[@]} -eq 0 ]]; then + echo "no merge conflicts to resolve" >&2 + exit 1 +fi + +# Everything outside the conflicted files must stay exactly as the merge left it. +excludes=() +for file in "${files[@]}"; do excludes+=(":(exclude,literal)$file"); done +outside() { git status --porcelain --untracked-files=all -- . "${excludes[@]}"; } +before=$(outside) + +list=$(printf -- '- %s\n' "${files[@]}") +prompt="This repository is a personal fork of pingdotgg/t3code (see FORK.md): the fork's own features +on top of upstream. A \`git merge\` of a new upstream commit into the fork stopped with conflicts in: + +$list +Resolve every conflict so the result keeps upstream's new behavior AND the fork's features. Read +both sides, and the commits that touched the lines (git log -p / git show, :2: is the fork, +:3: upstream), so you understand what each side meant. Adapt the fork's code to upstream's +renames and API changes rather than reverting them. + +Only edit the files listed above, and remove every conflict marker in them. For a modify/delete +conflict, keep the file if the fork still needs it, otherwise delete it with git rm. Do not stage, +commit, check out, or touch any other file. If you cannot tell what one side intended, leave that +conflict's markers in place and say why; an unresolved file is better than a wrong guess. + +End with one line per file: the file, and how you resolved it." + +claude -p "$prompt" \ + --model "${CLAUDE_MODEL:-opus}" \ + --max-budget-usd "${CLAUDE_MAX_BUDGET_USD:-5}" \ + --allowedTools Read Grep Glob Edit \ + "Bash(git diff:*)" "Bash(git log:*)" "Bash(git show:*)" "Bash(git status:*)" "Bash(git rm:*)" + +if [[ "$(outside)" != "$before" ]]; then + echo "::error::Claude changed files outside the conflicts:" + diff <(echo "$before") <(outside) || true + exit 1 +fi +unresolved=() +for file in "${files[@]}"; do + if [[ -f "$file" ]] && grep -qE '^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>)( |$)' "$file"; then + unresolved+=("$file") + fi +done +if [[ ${#unresolved[@]} -gt 0 ]]; then + printf '::error::still conflicted: %s\n' "${unresolved[@]}" + exit 1 +fi + +git add -A -- "${files[@]}" +git commit -q --no-edit +echo "Resolved ${#files[@]} conflicted file(s): $(git rev-parse HEAD)" From e31c798cdd52504b6b74d2f5474e4e61d4ae8e22 Mon Sep 17 00:00:00 2001 From: collissimo <148455189+collissimo@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:50:53 +0200 Subject: [PATCH 2/3] Fork release: run the resolve script of the workflow's own commit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fork-release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 04ea122d913fc..ae15957ff9ddd 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -143,7 +143,10 @@ jobs: resolution="$RUNNER_TEMP/resolution" mkdir -p "$resolution" npm install -g --silent @anthropic-ai/claude-code@2.1.306 - if bash fork/resolve-conflicts.sh; then + # The script of this workflow's commit, which differs from collin/main's on a dispatch from a branch. + git fetch -q --no-tags origin "$GITHUB_SHA" + git show "$GITHUB_SHA:fork/resolve-conflicts.sh" > "$RUNNER_TEMP/resolve-conflicts.sh" + if bash "$RUNNER_TEMP/resolve-conflicts.sh"; then git bundle create -q "$resolution/merge.bundle" HEAD "^$FORK_SHA" "^$UPSTREAM_SHA" else touch "$resolution/failed" From 5b22e799e50fa79176a2e8e1fbee42851db34c2a Mon Sep 17 00:00:00 2001 From: collissimo <148455189+collissimo@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:51:17 +0200 Subject: [PATCH 3/3] FORK.md: subscription token is the default credential for conflict resolution Co-Authored-By: Claude Opus 5.5 --- FORK.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FORK.md b/FORK.md index 0b980e27a6d1c..d92e6a85961cb 100644 --- a/FORK.md +++ b/FORK.md @@ -57,8 +57,8 @@ 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 -`ANTHROPIC_API_KEY` (API credits) or `CLAUDE_CODE_OAUTH_TOKEN` (subscription, from -`claude setup-token`). Each fork/upstream pair is resolved once and cached, so hourly retries cost +`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 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).