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
133 changes: 119 additions & 14 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand All @@ -103,11 +106,101 @@ 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
# 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"
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
Expand All @@ -134,23 +227,29 @@ 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"
FORK_SHA: ${{ needs.prepare.outputs.fork_sha }}
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
Expand Down Expand Up @@ -196,23 +295,29 @@ 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"
FORK_SHA: ${{ needs.prepare.outputs.fork_sha }}
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
Expand Down
10 changes: 8 additions & 2 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ Windows x64 (NSIS, `nightly.yml`) and Linux x64 (AppImage, `nightly-linux.yml`)
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
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
`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).

- 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
Expand Down
62 changes: 62 additions & 0 deletions fork/resolve-conflicts.sh
Original file line number Diff line number Diff line change
@@ -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:<path> is the fork,
:3:<path> 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)"
Loading