From d43c41050e15115bea10590d27c14d930d6a5182 Mon Sep 17 00:00:00 2001 From: collissimo <148455189+collissimo@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:34 +0100 Subject: [PATCH 1/2] Fork release: Claude fixes type errors its conflict resolution leaves A file git merges without a conflict can still break: upstream's new code called resolveVersionMismatch, whose import the fork had replaced, and the typecheck job stopped the release. resolve-conflicts.sh now installs and typechecks the resolved merge (same apps as that job) and gives Claude up to two rounds to fix the files the errors name. The lockfile is upstream's, regenerated by the install, instead of hand-merged. vp is set up before the merge, while package.json is still clean. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fork-release.yml | 21 +++++- FORK.md | 4 +- fork/resolve-conflicts.sh | 116 +++++++++++++++++++++++++---- 3 files changed, 122 insertions(+), 19 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 1485635ed18bb..d43aa97791cee 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -98,8 +98,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" @@ -116,6 +115,24 @@ 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" + + # 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 + id: merge + if: steps.due.outputs.due == 'true' + env: + UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} + run: | + set -euo pipefail if git merge -q --no-edit -m "Merge upstream ${UPSTREAM_SHA:0:12}" "$UPSTREAM_SHA"; then echo "conflict=false" >> "$GITHUB_OUTPUT" else diff --git a/FORK.md b/FORK.md index a03f4b11c4d0a..a2a54a3e2f414 100644 --- a/FORK.md +++ b/FORK.md @@ -62,7 +62,9 @@ nightly channel takes it, and a fork client and fork server on the other OS stay 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 +call) for the build only. The script then typechecks the result; a file git merged cleanly can +still break (one side calls what the other renamed), so Claude gets up to two more calls to fix +the files the errors name. 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, diff --git a/fork/resolve-conflicts.sh b/fork/resolve-conflicts.sh index 2742ad8f4538e..eb153706bd374 100755 --- a/fork/resolve-conflicts.sh +++ b/fork/resolve-conflicts.sh @@ -2,7 +2,7 @@ # 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. +# Needs the claude CLI, vp, and ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. # Exit 1 = not resolved, nothing committed. set -euo pipefail @@ -12,14 +12,47 @@ if [[ ${#files[@]} -eq 0 ]]; then exit 1 fi -# Everything outside the conflicted files must stay exactly as the merge left it. +# Everything outside the files Claude may edit 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 +# A lockfile is regenerated, not merged by hand: upstream's, plus what the fork's package.json +# files add, which `vp install` writes back. +claude_files=() +for file in "${files[@]}"; do + if [[ "$file" == pnpm-lock.yaml ]]; then + git checkout -q --theirs -- pnpm-lock.yaml + else + claude_files+=("$file") + fi +done + +fixable=() +claude_run() { + claude -p "$1" \ + --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:*)" +} + +# Files Claude may edit later drop out of `outside`, so they drop out of the snapshot as well. +check_outside() { + local expected + expected=$(awk 'NR == FNR { allowed[$0]; next } !(substr($0, 4) in allowed)' \ + <(printf '%s\n' "${files[@]}" "${fixable[@]}") <(echo "$before")) + if [[ "$(outside)" != "$expected" ]]; then + echo "::error::Claude changed files it was not given:" + diff <(echo "$expected") <(outside) || true + exit 1 + fi +} + +if [[ ${#claude_files[@]} -gt 0 ]]; then + list=$(printf -- '- %s\n' "${claude_files[@]}") + claude_run "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 @@ -34,18 +67,9 @@ commit, check out, or touch any other file. If you cannot tell what one side int 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 + +check_outside unresolved=() for file in "${files[@]}"; do if [[ -f "$file" ]] && grep -qE '^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>)( |$)' "$file"; then @@ -57,6 +81,66 @@ if [[ ${#unresolved[@]} -gt 0 ]]; then exit 1 fi -git add -A -- "${files[@]}" +# Without the repository's commit hooks: they would lint the whole merge on the commit below. +VP_GIT_HOOKS=0 vp install + +# A clean textual merge can still break the build: one side calls what the other renamed or +# stopped importing, in a file git merged without a conflict. The typecheck job would only stop +# the release, so Claude fixes what it reports, in whichever files it names. Same apps as that job. +broken=() +typecheck() { + local app status=0 + broken=() + : > "$log" + for app in server desktop web; do + if ! (cd "apps/$app" && vp run typecheck) > "$log.$app" 2>&1; then + status=1 + # tsc prints paths relative to the app: src/x.ts:12:5 - error TS... + mapfile -t -O "${#broken[@]}" broken < <(sed 's/\x1b\[[0-9;]*m//g' "$log.$app" | + grep -oE '^[^ :(]+\.[cm]?tsx?:[0-9]+:[0-9]+ - error' | + cut -d: -f1 | sort -u | sed "s|^|apps/$app/|") + fi + cat "$log.$app" >> "$log" + done + return $status +} + +log="${RUNNER_TEMP:-/tmp}/resolve-typecheck.log" +for round in 1 2; do + if typecheck; then + break + fi + if [[ ${#broken[@]} -eq 0 ]]; then + echo "::error::typecheck failed without naming a file" + cat "$log" + exit 1 + fi + fixable+=("${broken[@]}") + for file in "${broken[@]}"; do excludes+=(":(exclude,literal)$file"); done + echo "Typecheck round $round failed in: ${broken[*]}" + claude_run "This repository is a personal fork of pingdotgg/t3code (see FORK.md). A \`git merge\` of a new +upstream commit into the fork has had its conflicts resolved, but the result does not typecheck. +Typical cause: one side renamed, moved, or stopped importing something the other side's new code +still uses, in a file git merged without a conflict. Compare both sides (git show HEAD: is the +fork, MERGE_HEAD: upstream, git log -p to see why each changed) and fix each error so both +upstream's new behavior and the fork's features stay. Do not silence errors with casts, any, or +ts-ignore, and do not delete either side's feature to make it compile. + +Only edit these files: $(printf '%s ' "${broken[@]}") +Do not stage, commit, or check out anything. Do not run the typecheck; this script runs it again +after you finish. + +The typecheck output: + +$(tail -c 60000 "$log")" + check_outside +done +if ! typecheck; then + echo "::error::the resolved merge still does not typecheck" + cat "$log" + exit 1 +fi + +git add -A -- "${files[@]}" "${fixable[@]}" git commit -q --no-edit echo "Resolved ${#files[@]} conflicted file(s): $(git rev-parse HEAD)" From 810480341cf923dc0065b4482e7e4acef1f00150 Mon Sep 17 00:00:00 2001 From: collissimo <148455189+collissimo@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:09:58 +0100 Subject: [PATCH 2/2] Fork release: every build typechecks first, and Claude fixes what breaks 22 of the last 23 fork releases failed on type errors: 21 on fork code that reached collin/main unchecked (upstream's CI needs Blacksmith runners, so PR checks never ran here), one on a merge git completed without a conflict. prepare now runs fork/resolve-merge.sh on every build, not only on conflicts: it resolves conflicts, installs, typechecks server, desktop and web, and gives Claude up to two rounds to fix the files the errors name, in the build only. That replaces the separate typecheck job, since prepare checks the exact commit the builds check out. Results are cached per fork/upstream pair. fork-check.yml typechecks pull requests into collin/main and pushes to it on GitHub's runners, so broken code is visible before it lands. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fork-check.yml | 55 +++++++ .github/workflows/fork-release.yml | 140 +++++------------- FORK.md | 21 ++- ...{resolve-conflicts.sh => resolve-merge.sh} | 96 ++++++------ 4 files changed, 157 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/fork-check.yml rename fork/{resolve-conflicts.sh => resolve-merge.sh} (65%) diff --git a/.github/workflows/fork-check.yml b/.github/workflows/fork-check.yml new file mode 100644 index 0000000000000..c37fe9100b265 --- /dev/null +++ b/.github/workflows/fork-check.yml @@ -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 diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index d43aa97791cee..4ea8319ea2d5d 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -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' }} @@ -127,82 +128,54 @@ jobs: run-install: false - name: Merge upstream into the fork - id: merge if: steps.due.outputs.due == 'true' env: UPSTREAM_SHA: ${{ steps.refs.outputs.upstream_sha }} run: | set -euo pipefail - 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 + 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 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 + # 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 + git show "$GITHUB_SHA:fork/resolve-merge.sh" > "$RUNNER_TEMP/resolve-merge.sh" + bash "$RUNNER_TEMP/resolve-merge.sh" - - 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 }} + - 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 @@ -221,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: @@ -284,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' @@ -456,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: diff --git a/FORK.md b/FORK.md index a2a54a3e2f414..629da124ef6e3 100644 --- a/FORK.md +++ b/FORK.md @@ -61,14 +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 -call) for the build only. The script then typechecks the result; a file git merged cleanly can -still break (one side calls what the other renamed), so Claude gets up to two more calls to fix -the files the errors name. 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 diff --git a/fork/resolve-conflicts.sh b/fork/resolve-merge.sh similarity index 65% rename from fork/resolve-conflicts.sh rename to fork/resolve-merge.sh index eb153706bd374..c7af190c6df50 100755 --- a/fork/resolve-conflicts.sh +++ b/fork/resolve-merge.sh @@ -1,43 +1,23 @@ #!/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, vp, and ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. -# Exit 1 = not resolved, nothing committed. +# Makes the checked-out fork build typecheck: resolves the conflicts of an in-progress `git merge` +# with Claude Code, typechecks, lets Claude fix what the typecheck reports, then commits. +# fork-release.yml runs it on every build (merge started with --no-commit, or nothing to merge); +# the result only feeds that build, collin/main itself is still synced by hand or by the daily T3 +# task. Needs vp, and for Claude the claude CLI and ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. +# Exit 1 = not buildable, 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 +# `vp install` rewrites the lockfile: upstream's when it conflicts, plus what the fork's +# package.json files add. +fixable=(pnpm-lock.yaml) # Everything outside the files Claude may edit must stay exactly as the merge left it. excludes=() -for file in "${files[@]}"; do excludes+=(":(exclude,literal)$file"); done +for file in "${files[@]}" "${fixable[@]}"; do excludes+=(":(exclude,literal)$file"); done outside() { git status --porcelain --untracked-files=all -- . "${excludes[@]}"; } before=$(outside) -# A lockfile is regenerated, not merged by hand: upstream's, plus what the fork's package.json -# files add, which `vp install` writes back. -claude_files=() -for file in "${files[@]}"; do - if [[ "$file" == pnpm-lock.yaml ]]; then - git checkout -q --theirs -- pnpm-lock.yaml - else - claude_files+=("$file") - fi -done - -fixable=() -claude_run() { - claude -p "$1" \ - --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:*)" -} - # Files Claude may edit later drop out of `outside`, so they drop out of the snapshot as well. check_outside() { local expected @@ -50,6 +30,27 @@ check_outside() { fi } +claude_run() { + if [[ -n "${GITHUB_ACTIONS:-}" && -z "${ANTHROPIC_API_KEY:-}${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + echo "::error::this build needs Claude, but neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set; sync collin/main by hand" + exit 1 + fi + claude -p "$1" \ + --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:*)" +} + +claude_files=() +for file in "${files[@]}"; do + if [[ "$file" == pnpm-lock.yaml ]]; then + git checkout -q --theirs -- pnpm-lock.yaml + else + claude_files+=("$file") + fi +done + if [[ ${#claude_files[@]} -gt 0 ]]; then list=$(printf -- '- %s\n' "${claude_files[@]}") claude_run "This repository is a personal fork of pingdotgg/t3code (see FORK.md): the fork's own features @@ -67,9 +68,9 @@ commit, check out, or touch any other file. If you cannot tell what one side int 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." + check_outside fi -check_outside unresolved=() for file in "${files[@]}"; do if [[ -f "$file" ]] && grep -qE '^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>)( |$)' "$file"; then @@ -85,8 +86,9 @@ fi VP_GIT_HOOKS=0 vp install # A clean textual merge can still break the build: one side calls what the other renamed or -# stopped importing, in a file git merged without a conflict. The typecheck job would only stop -# the release, so Claude fixes what it reports, in whichever files it names. Same apps as that job. +# stopped importing, in a file git merged without a conflict. And collin/main itself may not +# typecheck. Claude fixes what the typecheck reports, in the files it names. Same apps as the +# desktop build bundles. broken=() typecheck() { local app status=0 @@ -105,6 +107,12 @@ typecheck() { return $status } +if git rev-parse -q --verify MERGE_HEAD > /dev/null; then + sides="A \`git merge\` of a new upstream commit into the fork is in progress (HEAD is the fork, +MERGE_HEAD upstream); its conflicts are resolved," +else + sides="The fork's branch (HEAD)" +fi log="${RUNNER_TEMP:-/tmp}/resolve-typecheck.log" for round in 1 2; do if typecheck; then @@ -118,13 +126,13 @@ for round in 1 2; do fixable+=("${broken[@]}") for file in "${broken[@]}"; do excludes+=(":(exclude,literal)$file"); done echo "Typecheck round $round failed in: ${broken[*]}" - claude_run "This repository is a personal fork of pingdotgg/t3code (see FORK.md). A \`git merge\` of a new -upstream commit into the fork has had its conflicts resolved, but the result does not typecheck. + claude_run "This repository is a personal fork of pingdotgg/t3code (see FORK.md): the fork's own features +on top of upstream. $sides but the result does not typecheck. Typical cause: one side renamed, moved, or stopped importing something the other side's new code -still uses, in a file git merged without a conflict. Compare both sides (git show HEAD: is the -fork, MERGE_HEAD: upstream, git log -p to see why each changed) and fix each error so both -upstream's new behavior and the fork's features stay. Do not silence errors with casts, any, or -ts-ignore, and do not delete either side's feature to make it compile. +still uses, in a file git merged without a conflict. Read both sides and git log -p to see why each +changed, and fix each error so upstream's new behavior and the fork's features both stay. Where +the fork deliberately replaced a helper, new upstream code should use the fork's replacement. Do +not silence errors with casts, any, or ts-ignore, and do not delete a feature to make it compile. Only edit these files: $(printf '%s ' "${broken[@]}") Do not stage, commit, or check out anything. Do not run the typecheck; this script runs it again @@ -136,11 +144,15 @@ $(tail -c 60000 "$log")" check_outside done if ! typecheck; then - echo "::error::the resolved merge still does not typecheck" + echo "::error::the build still does not typecheck" cat "$log" exit 1 fi git add -A -- "${files[@]}" "${fixable[@]}" -git commit -q --no-edit -echo "Resolved ${#files[@]} conflicted file(s): $(git rev-parse HEAD)" +if git rev-parse -q --verify MERGE_HEAD > /dev/null; then + git commit -q --no-edit +elif ! git diff --cached --quiet; then + git commit -q -m "Fix type errors for the build" +fi +echo "Build commit: $(git rev-parse HEAD)"