From 1ed2d8d6f1e37f96e2e9f648d7ca255aed369380 Mon Sep 17 00:00:00 2001 From: pseudo <27822378+pseudoseed@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:18:27 -0600 Subject: [PATCH] feat(skills): a sync-upstream routine that proves fork changes survive Upstream syncs were done by hand and lost fork changes without anyone noticing. The new sync-upstream skill makes the sync a fixed routine: branch from fork main, merge, run fork-delta-check.py to list every fork line the merge dropped, run every test suite one at a time, check web and iOS live, open a PR, and release only from the merged main. ship-mac.sh now refuses anything but a clean origin/main and creates the release as a draft pinned to the built commit, so going live stays a separate, approved step. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/ship-pseudocode/SKILL.md | 11 +- .../skills/ship-pseudocode/scripts/_common.sh | 15 ++ .../ship-pseudocode/scripts/ship-mac.sh | 11 +- .agents/skills/sync-upstream/SKILL.md | 118 ++++++++++++ .../sync-upstream/scripts/fork-delta-check.py | 182 ++++++++++++++++++ .../sync-upstream/scripts/run-suites.sh | 69 +++++++ 6 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 .agents/skills/sync-upstream/SKILL.md create mode 100755 .agents/skills/sync-upstream/scripts/fork-delta-check.py create mode 100755 .agents/skills/sync-upstream/scripts/run-suites.sh diff --git a/.agents/skills/ship-pseudocode/SKILL.md b/.agents/skills/ship-pseudocode/SKILL.md index 08de0f5e78c3e..d06d97fa00319 100644 --- a/.agents/skills/ship-pseudocode/SKILL.md +++ b/.agents/skills/ship-pseudocode/SKILL.md @@ -25,12 +25,15 @@ Homebrew, export the branding variables the desktop build needs, bump the iOS bu the signing team to every target, and verify the result before claiming success. -`ship-mac.sh` ends with `spctl` reporting `source=Notarized Developer ID`, then publishes the DMG to -GitHub Releases under the tag `pseudocode-v`, taking the version from +`ship-mac.sh` builds only from `origin/main` with a clean tree: release changes reach main through +a merged PR first, and the release is cut from that merged commit. It ends with `spctl` reporting +`source=Notarized Developer ID`, then uploads the DMG to a **draft** GitHub release under the tag +`pseudocode-v`, pinned to the built commit, taking the version from `apps/server/package.json`, which is the one electron-builder stamps onto the artifact. Anything less than a notarized result is a failure, not a warning, and -publishing only happens after verification passes: an unnotarized DMG on a release page is worse -than no DMG, because macOS refuses to open it. +uploading only happens after verification passes: an unnotarized DMG on a release page is worse +than no DMG, because macOS refuses to open it. Going live is a separate step, once Chris approves +that release: `gh release edit pseudocode-v --repo pseudoseed/t3code_dev --draft=false --latest`. The tag is deliberately not `v`. The release workflow triggers on `v*.*.*` and cannot produce a usable artifact here, so a tag in that shape starts a run guaranteed to fail. Re-running diff --git a/.agents/skills/ship-pseudocode/scripts/_common.sh b/.agents/skills/ship-pseudocode/scripts/_common.sh index b675799352c0f..a6d6613f64a34 100644 --- a/.agents/skills/ship-pseudocode/scripts/_common.sh +++ b/.agents/skills/ship-pseudocode/scripts/_common.sh @@ -32,6 +32,21 @@ load_env_local() { set +a } +# A release is built from fork main as merged on GitHub, so its tag can point at the exact +# commit that produced the artifact. Anything else is a build of code main does not have. +require_main_head() { + git fetch --quiet origin main + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "HEAD is not origin/main. Merge the release PR, then build from origin/main:" >&2 + echo " git switch --detach origin/main" >&2 + exit 1 + fi + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "Uncommitted changes. A release builds exactly what origin/main holds." >&2 + exit 1 + fi +} + require_vars() { local missing=() for name in "$@"; do diff --git a/.agents/skills/ship-pseudocode/scripts/ship-mac.sh b/.agents/skills/ship-pseudocode/scripts/ship-mac.sh index d298a11060f1a..15c7e6ffcee77 100755 --- a/.agents/skills/ship-pseudocode/scripts/ship-mac.sh +++ b/.agents/skills/ship-pseudocode/scripts/ship-mac.sh @@ -14,6 +14,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(repo_root)" cd "$ROOT" +require_main_head +COMMIT="$(git rev-parse HEAD)" setup_path "$ROOT" load_env_local "$ROOT" require_vars T3CODE_APP_NAME T3CODE_APP_ICON T3CODE_DESKTOP_UPDATE_REPOSITORY @@ -91,13 +93,18 @@ if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then echo "Adding $VERSION $ARCH artifacts to $TAG" gh release upload "$TAG" "${ASSETS[@]}" --repo "$REPO" --clobber else - echo "Creating release $TAG" + # A draft, pinned to the built commit: going live is a separate, approved step, and the tag + # GitHub creates on publish lands on the commit that produced these files. + echo "Creating draft release $TAG" gh release create "$TAG" "${ASSETS[@]}" \ --repo "$REPO" \ + --draft \ + --target "$COMMIT" \ --title "PseudoCode $VERSION" \ --notes "Desktop build for macOS. Signed and notarized." fi echo echo "Shipped: $DMG" -echo "Published: $(gh release view "$TAG" --repo "$REPO" --json url --jq .url)" +echo "Draft: $(gh release view "$TAG" --repo "$REPO" --json url --jq .url)" +echo "Publish once approved: gh release edit $TAG --repo $REPO --draft=false --latest" diff --git a/.agents/skills/sync-upstream/SKILL.md b/.agents/skills/sync-upstream/SKILL.md new file mode 100644 index 0000000000000..45f0f9d9eeb85 --- /dev/null +++ b/.agents/skills/sync-upstream/SKILL.md @@ -0,0 +1,118 @@ +--- +name: sync-upstream +description: Merge upstream T3 Code (pingdotgg/t3code) into the PseudoCode fork and release the result, keeping every fork change. Use when asked to sync, pull, or merge upstream (Chris also calls it "origin", meaning the repo the fork came from) into the fork, or to cut a release after such a sync. +--- + +# Sync upstream + +The goal is **parity**: after the sync, the fork has every upstream change and every fork change. +Fork changes are fork-only features, fork behaviour inside upstream code, and renamed product text +("PseudoCode" for "T3 Code", "Cloud Connect" for "T3 Connect"). + +**Names.** Chris says "origin" or "upstream" for pingdotgg/t3code. In git, that repo is the remote +`upstream`, which has pushing disabled. The git remote `origin` is the fork, pseudoseed/t3code_dev. +Every push and PR in this routine goes to the fork. + +Scripts live in `.agents/skills/sync-upstream/scripts/`. Run them from the repo root. + +## Steps + +### 1. Branch from fork main + +```sh +git fetch origin main && git fetch upstream main +git switch -c t3/sync-upstream-$(date +%Y%m%d) origin/main +git config user.email +``` + +The email must print `27822378+pseudoseed@users.noreply.github.com`. Chris keeps his address +private, and GitHub refuses a push whose commits carry it. Done when the branch exists and the email +matches. + +### 2. Merge + +```sh +git merge --no-ff upstream/main +``` + +Resolve every conflict so both sides survive: upstream's change and the fork's behaviour or text. +When upstream rewrote code the fork had changed, carry the fork's intent into upstream's new shape, +including its conventions (for example `Effect.fn` in place of plain arrow functions). Resolving a +whole file to one side is how fork changes go missing. Done when +`git diff --name-only --diff-filter=U` prints nothing and the merge is committed. + +### 3. Prove fork changes survived + +```sh +.agents/skills/sync-upstream/scripts/fork-delta-check.py -v +``` + +It compares the fork's changes (fork main against the last upstream commit it merged) with the +working tree and prints 2 groups: + +- **DROPPED**: nothing upstream did explains the loss. Restore the line, or name in the PR body + the upstream change that does the same job. +- **REWRITTEN**: upstream rewrote the code the fork had changed. Read the new code and confirm the + fork's intent is in it. + +Run it before the sync PR merges. Its defaults compare against `origin/main`, which stops holding +the old fork once the PR lands. Done when every line it lists is restored or accounted for in the PR +body. + +### 4. Bump the version + +Raise the version in the 4 files [ship-pseudocode](../ship-pseudocode/SKILL.md) names, and commit +it separately: `chore(release): bump PseudoCode to `. + +### 5. Run every check + +```sh +vp run check:changed +.agents/skills/sync-upstream/scripts/run-suites.sh +``` + +`run-suites.sh` runs every package's tests one at a time, the one place in this repo where the full +suites run locally: Chris needs a sync green before it ships. Fix each failure at its cause. When +you believe a failure belongs to this machine rather than the code, prove it: run the same test in +a clean upstream worktree (`git worktree add ../t3-upstream-check upstream/main`, then `vp i` +there). If it fails there too, report it with that evidence as a failure, never as a pass, and +remove the worktree. Done when both commands pass, or every remaining failure carries that proof. + +### 6. Check the app live + +Drive web with [test-t3-app](../test-t3-app/SKILL.md) and iOS with +[test-t3-mobile](../test-t3-mobile/SKILL.md), against copied real data. Cover the fork features the +merge touched. Done when each surface was driven and what you saw is written down for the PR. + +### 7. Open the PR + +Push the branch to `origin` and open a PR into the fork's `main`. The body names the upstream +commit merged, the fork changes restored, each DROPPED line accepted and why, the test counts, and +what the live check covered. Chris merges it with a **merge commit**; a squash would orphan the +commits the release tag points at. Done when the PR is open. Stop there until Chris merges. + +### 8. Release from merged main + +```sh +git fetch origin main && git switch --detach origin/main +.agents/skills/ship-pseudocode/scripts/ship-mac.sh +.agents/skills/ship-pseudocode/scripts/ship-ios.sh +``` + +`ship-mac.sh` refuses any commit other than a clean `origin/main` and leaves the release as a +draft. Publish only after Chris approves that release: +`gh release edit pseudocode-v --repo pseudoseed/t3code_dev --draft=false --latest`. + +## When something fails + +| Symptom | Cause | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A replay test hangs for 2 minutes, or mismatches on product text | Upstream's fixtures say "T3 Code" where the fork's code now says "PseudoCode" (Codex `clientInfo`, Muse `runtime_info`). Rename the text in the fixture. | +| A test expects "T3 Code" or "T3 Connect" copy | Same rename; the fork's string is the one the app shows. Update the expectation. | +| A migration test fails under Node 26 only | Node 26 bundles SQLite 3.54, which drops the JSON subtype of a subquery inside `json_set`. Wrap the subquery in `json(...)`. | +| React Compiler coverage test names a fork component | The compiler skips components with `try`/`finally`. Rewrite as a promise chain with `.finally`. | +| Lint `t3code/no-unreasoned-state-sync` on a fork effect | Upstream's rule now covers fork code. Add the reason comment the rule asks for, or remove the effect. | +| Tests killed with exit 137 | Suites ran in parallel and ran out of memory. Use `run-suites.sh`. | +| Desktop suite: "Electron failed to install correctly" after a fresh `vp i` | Parallel test workers raced to download Electron. It is installed by the end of the run; run `run-suites.sh apps/desktop` again. | +| Push declined "due to email privacy restrictions" | A commit carries Chris's private email. Fix `user.email`, then re-author only the commits not yet pushed. | +| GuestPage browser test: distance from bottom 2074, expected under 2 | Fails on this Mac on clean upstream as well, and passes on upstream's Linux CI. Not root-caused. Report it with the clean-upstream run as evidence. | diff --git a/.agents/skills/sync-upstream/scripts/fork-delta-check.py b/.agents/skills/sync-upstream/scripts/fork-delta-check.py new file mode 100755 index 0000000000000..07307c2568cbe --- /dev/null +++ b/.agents/skills/sync-upstream/scripts/fork-delta-check.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""List fork changes an upstream merge dropped. + +The fork's changes are the lines fork main added on top of the last upstream commit it merged. +Each one is looked up in the merge result, ignoring whitespace so upstream's reformatting is not +reported. Missing lines are sorted into two groups: + + DROPPED nothing upstream did explains the loss. Restore it, or say in the PR why not. + REWRITTEN upstream rewrote or deleted the code the fork had changed. Check the fork's intent + survived in the new code; often it did, in different words. + +Lines upstream itself added and later removed (the fork had them early) are counted, not listed. + + fork-delta-check.py [-v] [--fork REF] [--upstream REF] [--merged REF] + +Defaults: --fork origin/main, --upstream upstream/main, --merged the working tree, so it works +mid-merge with conflicts still open. Run it before the sync PR merges: afterwards origin/main +already contains the merge and there is nothing left to compare. +""" + +import argparse +import os +import re +import subprocess +import sys + +SKIP_PREFIXES = (".repos/", ".agent-scratch/", "pnpm-lock.yaml") +# Lines this short or this generic match somewhere in nearly every file and prove nothing. +MIN_LENGTH = 13 +GENERIC = re.compile(r"^[\s{}()\[\];,.:*/`'\"<>=|&!?-]*$") +WHITESPACE = re.compile(r"\s+") +# The fork renames product text; undo it before comparing a fork line with upstream's history. +BRAND_ALIASES = (("PseudoCode", "T3 Code"), ("Cloud Connect", "T3 Connect")) + + +def git(*args, check=True): + result = subprocess.run(["git", *args], capture_output=True, text=True) + if check and result.returncode != 0: + sys.exit(f"git {' '.join(args)} failed:\n{result.stderr}") + return result.stdout + + +def squash(text): + return WHITESPACE.sub("", text) + + +def unbrand(text): + for fork_name, upstream_name in BRAND_ALIASES: + text = text.replace(fork_name, upstream_name) + return squash(text) + + +def meaningful(text): + return len(text) >= MIN_LENGTH and not GENERIC.match(text) + + +def fork_hunks(base, fork, path): + """(added lines, squashed base lines around them) for each hunk of the fork's change. + + The base lines are the ones the fork replaced plus 2 lines of context, so an addition made + inside a block upstream later deleted is tied to that block. + """ + hunks = [] + for line in git("diff", "-U2", base, fork, "--", path).splitlines(): + if line.startswith("@@"): + hunks.append(([], [])) + elif not hunks or line.startswith(("+++", "---")): + continue + elif line.startswith("+"): + text = line[1:].strip() + if meaningful(text): + hunks[-1][0].append(text) + elif line.startswith(("-", " ")) and meaningful(line[1:].strip()): + hunks[-1][1].append(squash(line[1:])) + return [hunk for hunk in hunks if hunk[0]] + + +def merged_content(merged, path): + if merged is None: + if not os.path.isfile(path): + return None + with open(path, encoding="utf-8", errors="replace") as handle: + return handle.read() + result = subprocess.run(["git", "show", f"{merged}:{path}"], capture_output=True, text=True) + return result.stdout if result.returncode == 0 else None + + +def find_elsewhere(merged, line): + args = ["grep", "-l", "-F", "-e", line] + if merged is not None: + args.append(merged) + found = git(*args, check=False).split() + return [f.split(":", 1)[1] if merged is not None else f for f in found][:3] + + +def upstream_removed(base, upstream, paths): + """Squashed lines upstream's own commits removed from these paths since the fork's base.""" + removed = set() + log = git("log", "-p", "--no-merges", "--format=", f"{base}..{upstream}", "--", *paths) + for line in log.splitlines(): + if line.startswith("-") and not line.startswith("---"): + removed.add(squash(line[1:])) + return removed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--fork", default="origin/main") + parser.add_argument("--upstream", default="upstream/main") + parser.add_argument("--merged", help="commit to check; default is the working tree") + parser.add_argument("-v", "--verbose", action="store_true", help="print each missing line") + args = parser.parse_args() + + os.chdir(git("rev-parse", "--show-toplevel").strip()) + base = git("merge-base", args.fork, args.upstream).strip() + numstat = git("diff", "--numstat", "--diff-filter=AMR", base, args.fork).splitlines() + + checked = 0 + missing = [] + for row in numstat: + added, _, path = row.split("\t", 2) + # Binary files show "-"; renames show "old => new" and the new path is what matters. + if added == "-" or path.startswith(SKIP_PREFIXES): + continue + if " => " in path: + path = re.sub(r"\{[^}]* => ([^}]*)\}", r"\1", path).replace("//", "/") + path = path.split(" => ")[-1] + hunks = fork_hunks(base, args.fork, path) + if not hunks: + continue + checked += 1 + content = merged_content(args.merged, path) + squashed = squash(content) if content is not None else "" + lost = [ + (line, replaced) + for lines, replaced in hunks + for line in lines + if squash(line) not in squashed + ] + if lost: + missing.append((path, sum(len(h[0]) for h in hunks), lost, content is None)) + + removed = upstream_removed(base, args.upstream, [m[0] for m in missing]) if missing else set() + groups = {"DROPPED": [], "REWRITTEN": []} + upstream_own = 0 + for path, total, lost, gone in missing: + by_group = {"DROPPED": [], "REWRITTEN": []} + for line, replaced in lost: + if unbrand(line) in removed: + upstream_own += 1 + elif any(old in removed for old in replaced): + by_group["REWRITTEN"].append(line) + else: + by_group["DROPPED"].append(line) + for group, lines in by_group.items(): + if lines: + groups[group].append((path, total, lines, gone)) + + for group, files in groups.items(): + print(f"{group}: {sum(len(f[2]) for f in files)} lines in {len(files)} files") + for path, total, lines, gone in files: + note = "" + if gone: + elsewhere = find_elsewhere(args.merged, lines[0]) + note = f" (file gone; first line now in {', '.join(elsewhere)})" if elsewhere else " (file gone)" + print(f" {len(lines)}/{total}\t{path}{note}") + if args.verbose: + for line in lines[:10]: + print(f" {line[:160]}") + if len(lines) > 10: + print(f" ... {len(lines) - 10} more") + print() + + print( + f"Fork delta {base[:10]}..{args.fork}, {checked} files, checked against" + f" {args.merged or 'the working tree'}. {upstream_own} missing lines were upstream's own" + " code that upstream later removed, not listed." + ) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/sync-upstream/scripts/run-suites.sh b/.agents/skills/sync-upstream/scripts/run-suites.sh new file mode 100755 index 0000000000000..7cb2a7706dc33 --- /dev/null +++ b/.agents/skills/sync-upstream/scripts/run-suites.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Every workspace test suite, one package at a time, each to its own log. +# +# run-suites.sh [package-dir ...] # no arguments: every package with a test script +# +# `vp run -r test` runs 4 suites at once, and on a Mac the web, mobile and relay suites are +# killed for memory (exit 137). One at a time is slower and finishes. Prints one line per +# package and exits non-zero if any failed. Logs: .agent-scratch/sync-tests/.log +set -uo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" +export PATH="$ROOT/node_modules/.bin:/opt/homebrew/opt/node@26/bin:$PATH" +LOGS="$ROOT/.agent-scratch/sync-tests" +mkdir -p "$LOGS" + +if [ "$#" -gt 0 ]; then + PACKAGES=("$@") +else + # shellcheck disable=SC2207 + PACKAGES=($(git ls-files '*package.json' | grep -v '^\.repos/' | xargs node -e ' + for (const file of process.argv.slice(1)) { + if (file === "package.json") continue; + if (require(`./${file}`).scripts?.test) console.log(file.replace(/\/package\.json$/, "")); + }')) +fi + +# The desktop suite's Linux icon tests write files whose names differ only by case. A Mac's +# default volume is case-insensitive, so that suite gets its temp files on a case-sensitive one. +CS_IMAGE="$ROOT/.agent-scratch/case-sensitive.sparseimage" +CS_MOUNT="$ROOT/.agent-scratch/case-sensitive" +detach_cs() { hdiutil detach "$CS_MOUNT" -quiet 2>/dev/null || true; } +mount_cs() { + [ -f "$CS_IMAGE" ] || hdiutil create -size 2g -type SPARSE -fs "Case-sensitive APFS" \ + -volname T3CS -quiet "${CS_IMAGE%.sparseimage}" || return 1 + mkdir -p "$CS_MOUNT" + trap detach_cs EXIT + hdiutil attach "$CS_IMAGE" -nobrowse -mountpoint "$CS_MOUNT" -quiet || return 1 + mkdir -p "$CS_MOUNT/tmp" +} + +failed=0 +for dir in "${PACKAGES[@]}"; do + log="$LOGS/$(echo "$dir" | tr / _).log" + if [ "$dir" = "apps/desktop" ] && [ "$(uname)" = "Darwin" ]; then + mount_cs || { echo "Could not mount a case-sensitive volume at $CS_MOUNT." >&2; exit 1; } + (cd "$dir" && TMPDIR="$CS_MOUNT/tmp" vp run test) > "$log" 2>&1 + status=$? + detach_cs + else + (cd "$dir" && vp run test) > "$log" 2>&1 + status=$? + fi + # Vitest prints one "Tests N passed (N)" line per run; strip colour codes before reading it. + tests="$(sed 's/\x1b\[[0-9;]*m//g' "$log" | grep -E '^ +Tests +[0-9]' | tail -1 | sed 's/^ *Tests *//')" + if [ "$status" -eq 0 ]; then + echo "PASS $dir ${tests:-no tests}" + else + failed=$((failed + 1)) + echo "FAIL $dir ${tests:-exit $status} $log" + fi +done + +echo +if [ "$failed" -gt 0 ]; then + echo "$failed of ${#PACKAGES[@]} packages failed." + exit 1 +fi +echo "All ${#PACKAGES[@]} packages passed."