diff --git a/.github/workflows/fork-check.yml b/.github/workflows/fork-check.yml index c37fe9100b26..b1c2dd51e2e8 100644 --- a/.github/workflows/fork-check.yml +++ b/.github/workflows/fork-check.yml @@ -36,6 +36,9 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout -q --detach "$GITHUB_SHA" + - name: Test the release repair script + run: bash fork/resolve-merge.test.sh + - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: diff --git a/FORK.md b/FORK.md index 20337418cf54..785aa78340bd 100644 --- a/FORK.md +++ b/FORK.md @@ -68,8 +68,11 @@ side calls what the other renamed) or broken on `collin/main` itself. Fixes go i 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). +When Claude cannot make it typecheck, the run fails. The T3 scheduled task "Fork repair" looks for +a red `Fork release` or `Fork check` on `collin/main` every four hours, fixes the cause in a pull +request and merges it once `Fork check` is green; it needs the machine with T3 Code running. By +hand: sync `collin/main`, 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 diff --git a/fork/resolve-merge.sh b/fork/resolve-merge.sh index c7af190c6df5..a9e00092bff5 100755 --- a/fork/resolve-merge.sh +++ b/fork/resolve-merge.sh @@ -90,6 +90,26 @@ VP_GIT_HOOKS=0 vp install # typecheck. Claude fixes what the typecheck reports, in the files it names. Same apps as the # desktop build bundles. broken=() +# The tracked files a typecheck log blames, from the repository root. tsc paths are relative to the +# app, and the layout depends on the tool and on whether it prints to a terminal: +# src/x.ts(12,5): error TS... or src/x.ts:12:5 - error TS... +# A layout nothing here knows must still reach Claude, so any path on an error line counts. +blamed_files() { + local app=$1 log_file=$2 text paths path + text=$(sed 's/\x1b\[[0-9;]*m//g' "$log_file") + paths=$(grep -oE '^[^ :(]+\.[cm]?tsx?(\([0-9]+,[0-9]+\):|:[0-9]+:[0-9]+ -) error' <<<"$text" | + sed -E 's/[(:].*$//' || true) + if [[ -z "$paths" ]]; then + paths=$(grep -E 'error TS[0-9]+' <<<"$text" | grep -oE '[^ :(]+\.[cm]?tsx?' || true) + fi + for path in $(sort -u <<<"$paths"); do + path=$(realpath -m --relative-to=. "apps/$app/$path") + if git ls-files --error-unmatch -- "$path" > /dev/null 2>&1; then + echo "$path" + fi + done +} + typecheck() { local app status=0 broken=() @@ -97,10 +117,7 @@ typecheck() { 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/|") + mapfile -t -O "${#broken[@]}" broken < <(blamed_files "$app" "$log.$app") fi cat "$log.$app" >> "$log" done @@ -149,7 +166,12 @@ if ! typecheck; then exit 1 fi -git add -A -- "${files[@]}" "${fixable[@]}" +# git rm already staged a deleted conflict file, and git add refuses a path that is gone. +for file in "${files[@]}" "${fixable[@]}"; do + if [[ -e "$file" ]] || git ls-files --error-unmatch -- "$file" > /dev/null 2>&1; then + git add -A -- "$file" + fi +done if git rev-parse -q --verify MERGE_HEAD > /dev/null; then git commit -q --no-edit elif ! git diff --cached --quiet; then diff --git a/fork/resolve-merge.test.sh b/fork/resolve-merge.test.sh new file mode 100644 index 000000000000..68a32f0f55cd --- /dev/null +++ b/fork/resolve-merge.test.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Runs fork/resolve-merge.sh against throwaway repositories with stubbed vp and claude. Its repair +# path only runs when a release is already broken, so a regression there stays silent until then. +set -euo pipefail + +script=$(realpath "$(dirname "$0")/resolve-merge.sh") +root=$(mktemp -d) +trap 'rm -rf "$root"' EXIT +failed=0 + +mkdir "$root/bin" +# Reports whatever $TSC_LINE says while the file it names (relative to the app) holds BROKEN. +cat > "$root/bin/vp" <<'STUB' +#!/usr/bin/env bash +[[ "$1" == run && "$(basename "$PWD")" == server ]] || exit 0 +if grep -rqs BROKEN src ../../packages; then + printf '~/apps/server$ tsc --noEmit\n%b\n' "$TSC_LINE" + exit 2 +fi +STUB +cat > "$root/bin/claude" <<'STUB' +#!/usr/bin/env bash +for file in $(git diff --name-only --diff-filter=U); do git rm -q -f -- "$file"; done +grep -rl BROKEN apps packages | xargs -r sed -i 's/BROKEN/fixed/' +STUB +chmod +x "$root/bin/vp" "$root/bin/claude" +export PATH="$root/bin:$PATH" CLAUDE_CODE_OAUTH_TOKEN=test + +new_repo() { + git init -q -b base "$1" + git -C "$1" config user.name test + git -C "$1" config user.email test@example.com + mkdir -p "$1"/apps/{server,desktop,web}/src "$1/packages/shared/src" + touch "$1/pnpm-lock.yaml" "$1"/apps/{server,desktop,web}/src/keep.ts +} + +report() { + if [[ "$2" == 0 ]]; then + echo "ok: $1" + else + echo "FAILED: $1" + cat "$root/output" || true + failed=1 + fi +} + +check_repair() { + local name=$1 path=$2 line=$3 repo status=0 + repo="$root/repo$RANDOM" + new_repo "$repo" + echo "const x = BROKEN;" > "$repo/$path" + git -C "$repo" add -A + git -C "$repo" commit -qm base + (cd "$repo" && RUNNER_TEMP="$root" TSC_LINE="$line" bash "$script") > "$root/output" 2>&1 || status=1 + if [[ $status == 0 && "$(git -C "$repo" show "HEAD:$path")" != "const x = fixed;" ]]; then + status=1 + fi + grep -qxF "Typecheck round 1 failed in: $path" "$root/output" || status=1 + report "$name" "$status" +} + +# Names a second file, so only the path at the start of the line may count. +error="error TS2304: Cannot find name 'BROKEN' from src/keep.ts." +check_repair "parenthesized errors reach Claude" apps/server/src/a.ts "src/a.ts(1,11): $error" +check_repair "pretty errors reach Claude" apps/server/src/a.ts "\033[96msrc/a.ts\033[0m:1:11 - $error" +check_repair "errors in a package reach Claude" packages/shared/src/a.ts "../../packages/shared/src/a.ts(1,11): $error" +check_repair "an unknown layout still reaches Claude" apps/server/src/a.ts "error TS2304 in src/a.ts" + +# A modify/delete conflict Claude settles with git rm must not break the commit. +repo="$root/deleted" +new_repo "$repo" +echo one > "$repo/apps/web/src/gone.ts" +git -C "$repo" add -A +git -C "$repo" commit -qm base +git -C "$repo" checkout -qb upstream +git -C "$repo" rm -q apps/web/src/gone.ts +git -C "$repo" commit -qm "upstream deletes" +git -C "$repo" checkout -q base +echo two > "$repo/apps/web/src/gone.ts" +git -C "$repo" commit -qam "fork edits" +git -C "$repo" merge -q --no-commit --no-ff upstream > /dev/null 2>&1 || true +status=0 +(cd "$repo" && RUNNER_TEMP="$root" TSC_LINE="" bash "$script") > "$root/output" 2>&1 || status=1 +if [[ $status == 0 && "$(git -C "$repo" rev-list --parents -n 1 HEAD | wc -w)" != 3 ]]; then + status=1 +fi +report "a conflict settled with git rm commits the merge" "$status" + +exit "$failed"