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
55 changes: 55 additions & 0 deletions .github/workflows/fork-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Fork check: typechecks pull requests into collin/main and every push to it.
#
# Upstream's ci.yml runs on Blacksmith runners a fork does not have, so its checks stay queued
# forever and broken code reached collin/main unnoticed. This runs what fork-release.yml needs:
# a frozen install and the typecheck of the apps the desktop build bundles.
name: Fork check

on:
pull_request:
branches: [collin/main]
push:
branches: [collin/main]

permissions:
contents: read

concurrency:
group: fork-check-${{ github.ref }}
cancel-in-progress: true

jobs:
typecheck:
if: github.repository != 'pingdotgg/t3code'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Plain git, as in release-desktop.yml: checkout's cleanup trips over the vendored .repos gitlinks.
- name: Check out
env:
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch -q --no-tags origin "$GITHUB_SHA"
git sparse-checkout set --no-cone '/*' '!/.repos/'
git -c checkout.workers=0 checkout -q --detach "$GITHUB_SHA"

- name: Setup Vite+
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: false
run-install: false

- name: Install dependencies
timeout-minutes: 15
run: VP_GIT_HOOKS=0 vp install --frozen-lockfile

- name: Typecheck server, desktop and web
run: |
set -euo pipefail
for app in server desktop web; do
(cd "apps/$app" && vp run typecheck)
done
159 changes: 53 additions & 106 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
# 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.
# Nothing is pushed to a branch. vp pack does not typecheck, so prepare does: Claude Code resolves
# merge conflicts and fixes type errors for the build only (fork/resolve-merge.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 make it typecheck, 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' }}
Expand Down Expand Up @@ -98,8 +99,7 @@ jobs:
fi

# Plain git, as in release-desktop.yml: checkout's cleanup trips over the vendored .repos gitlinks.
- name: Merge upstream into the fork
id: merge
- name: Check out the fork
if: steps.due.outputs.due == 'true'
env:
GIT_TERMINAL_PROMPT: "0"
Expand All @@ -116,76 +116,66 @@ 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
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 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
# Before the merge: a conflicted package.json would break reading the Node version. The
# resolve script installs and typechecks with it.
- name: Setup Vite+
if: steps.due.outputs.due == 'true'
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: false
run-install: false

- name: Merge upstream into the fork
if: steps.due.outputs.due == 'true'
env:
UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }}
run: |
set -euo pipefail
git merge -q --no-commit --no-ff -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA" ||
[[ -n "$(git diff --name-only --diff-filter=U)" ]]
git diff --name-only --diff-filter=U | sed 's/^/::warning::conflict: /'

# One result 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 Claude's work. force asks Claude again.
- name: Restore an earlier result for these commits
id: earlier
if: steps.merge.outputs.conflict == 'true' && !inputs.force
if: steps.due.outputs.due == '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 }}
path: ${{ runner.temp }}/merge
key: fork-build-${{ 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'
# Resolves conflicts and fixes type errors (also of a clean merge, or of collin/main itself)
# with Claude, so a build only fails when Claude cannot make it typecheck.
- name: Resolve and typecheck
if: steps.due.outputs.due == '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 }}
git show "$GITHUB_SHA:fork/resolve-merge.sh" > "$RUNNER_TEMP/resolve-merge.sh"
bash "$RUNNER_TEMP/resolve-merge.sh"

- name: Apply the resolution
if: steps.merge.outputs.conflict == 'true'
env:
CACHE_HIT: ${{ steps.earlier.outputs.cache-hit }}
- name: Apply the earlier result
if: steps.earlier.outputs.cache-hit == 'true'
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
merge="$RUNNER_TEMP/merge"
git merge --abort 2>/dev/null || true
if [[ -f "$merge/merge.bundle" ]]; then
git fetch -q --no-tags "$merge/merge.bundle"
fi
git log -1 --format='Merge %H'
git -c checkout.workers=0 checkout -q --detach "$(cat "$merge/sha")"

# 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
Expand All @@ -204,6 +194,13 @@ jobs:
git bundle create -q "$RUNNER_TEMP/merge/merge.bundle" HEAD "^$FORK_SHA" "^$UPSTREAM_SHA"
fi

- name: Remember the result
if: steps.due.outputs.due == 'true' && steps.earlier.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ runner.temp }}/merge
key: fork-build-${{ steps.refs.outputs.fork_sha }}-${{ steps.refs.outputs.upstream_sha }}

- if: steps.due.outputs.due == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
Expand Down Expand Up @@ -267,56 +264,6 @@ jobs:
echo "::error::The Cloudflare watcher missed upstream $UPSTREAM_TAG (published $published). Check it with: npx wrangler tail (in fork/upstream-nightly-watcher)"
exit 1

# vp pack does not typecheck, so a type error would otherwise ship. Only the apps the desktop
# build bundles; publish waits for this.
typecheck:
needs: prepare
if: needs.prepare.outputs.due == 'true'
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 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 "$(cat "$merge/sha")"

- name: Setup Vite+
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1
with:
node-version-file: package.json
cache: false
run-install: false

- name: Install dependencies
timeout-minutes: 15
run: vp install

- name: Typecheck server, desktop and web
run: |
set -euo pipefail
for app in server desktop web; do
(cd "apps/$app" && vp run typecheck)
done

build:
needs: prepare
if: needs.prepare.outputs.due == 'true'
Expand Down Expand Up @@ -439,7 +386,7 @@ jobs:
# and GitHub refuses the same for a tag on an older fork commit whose workflows differ from the
# head's (a fork commit pushed during the build). The release notes record what was built.
publish:
needs: [prepare, typecheck, build]
needs: [prepare, build]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand Down
19 changes: 13 additions & 6 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,19 @@ upstream's (the offset sorts a fork build above an upstream nightly of the same
nightly channel takes it, and a fork client and fork server on the other OS stay on the same
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 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).
`vp pack` does not typecheck, so every build typechecks the merge first
(`fork/resolve-merge.sh`): Claude Code (Opus, at most $5 per call) resolves merge conflicts, then
gets up to two calls to fix whatever the typecheck reports, also in files git merged cleanly (one
side calls what the other renamed) or broken on `collin/main` itself. Fixes go into the build
only. 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 checked once and cached, so retries cost nothing.
When Claude cannot make it typecheck, the run fails: sync `collin/main` by hand, then
`gh workflow run fork-release.yml` (`-f force=true` asks Claude again instead).

Pull requests into `collin/main` and pushes to it run `.github/workflows/fork-check.yml` (frozen
install and the same typecheck). Upstream's CI needs Blacksmith runners a fork does not have; its
workflows are disabled in this repository (Actions settings), otherwise their checks stay queued.

Watcher setup (once, or after the token expires), from `fork/upstream-nightly-watcher/`:
`npx wrangler deploy`, then copy a fine-grained token for `collissimo/t3code` (Contents: read and
Expand Down
62 changes: 0 additions & 62 deletions fork/resolve-conflicts.sh

This file was deleted.

Loading
Loading