From 7801a6e524ce41738c6a69b61761ccff4b58307b Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 15:01:55 -0700 Subject: [PATCH 1/2] Add upstream sync tooling: sync_upstream.py, skill, upstream-sync workflow Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Qp4Gg5UQcjWVPZz3y58bWs --- .claude/skills/sync-upstream/SKILL.md | 83 +++++++ .codex/skills/sync-upstream/SKILL.md | 83 +++++++ .github/workflows/upstream-sync.yml | 192 ++++++++++++++++ local/scripts/sync_upstream.py | 313 ++++++++++++++++++++++++++ local/tests/test_sync_upstream.py | 140 ++++++++++++ 5 files changed, 811 insertions(+) create mode 100644 .claude/skills/sync-upstream/SKILL.md create mode 100644 .codex/skills/sync-upstream/SKILL.md create mode 100644 .github/workflows/upstream-sync.yml create mode 100755 local/scripts/sync_upstream.py create mode 100644 local/tests/test_sync_upstream.py diff --git a/.claude/skills/sync-upstream/SKILL.md b/.claude/skills/sync-upstream/SKILL.md new file mode 100644 index 000000000000..2a4785e51577 --- /dev/null +++ b/.claude/skills/sync-upstream/SKILL.md @@ -0,0 +1,83 @@ +--- +name: sync-upstream +description: Merge a new openai/codex rust-v* release into codex-local, repair conflicts and breakage within the allowed edit set, and escalate otherwise. Use for the upstream-sync issue/PR or when asked to sync upstream. +--- + +# sync-upstream + +Driver: `python3 local/scripts/sync_upstream.py `. Each prints ONE JSON +object `{ok, step, conflicts[], build_errors[], test_failures[], surface_violations[], next_action}`. +Exit codes: **0** ok, **1** needs repair (you fix it), **2** escalate (you stop). +Always do what `next_action` says. Never guess past it. + +## Checklist + +1. `status` -> if `new_release` is false, stop (nothing to do). Else note `latest`. +2. `merge ` -> creates `upstream-sync/`, merges the tag, takes upstream's + `codex-rs/Cargo.lock` and regenerates it, bumps `local/UPSTREAM_VERSION`. + Exit 0: go to step 4. Exit 1: step 3. Exit 2: ESCALATE. +3. `conflicts` -> repair each listed path by `kind`, `git add` it, rerun `conflicts` until + exit 0, then `git commit --no-edit`. + - `hook`: take UPSTREAM's version (`git checkout --theirs `), then re-apply the hook + by following that hook's recipe in `local/FORK_SURFACE.md`. Keep the edit as small as the + recorded line count. Do not hand-merge the old hunk. + - `local`: keep ours unless the file only exists upstream-side. + - `generated` (Cargo.lock): `git checkout --theirs`, then `cargo update --workspace` in `codex-rs/`. + - `other`: ESCALATE (see below). +4. `check` -> runs build, local crate tests, hook tests, `check_fork_surface.py`, and + the workflow check. Loop while exit is 1 (see Repair loop). Exit 0: go to step 5. +5. `check > /tmp/check.json; report --check-json /tmp/check.json --out /tmp/report.md`. + Use `markdown` from the report as the PR body (success) or issue comment (failure). + Push the branch and open the PR to `codex-local/main` only when `check` exited 0. + +## Repair loop (exit 1 from `check`) + +Fix in this order, rerun `check` after each batch: +- `build_errors`: upstream API drift. Typical causes: a hook call site no longer matches a + renamed/re-signatured upstream function, or a `codex-rs/local/*` crate uses a changed + upstream API. Read the compiler error, fix the call site (hook file) or the local crate. +- `test_failures`: fix the local crate or re-apply the hook; never delete or skip a test. +- `surface_violations`: a file outside the allowlist or over budget is modified. Revert it + to upstream's version (`git checkout -- `). If a hook legitimately needs a + bigger edit, that is an escalation, not a budget bump. +- `workflows_to_disable`: run `gh workflow disable -R `; not a code edit. + +## Allowed edits (nothing else) + +1. Allowlisted hook files (the `allow` list in `local/fork-surface.toml`), re-applied from the + `FORK_SURFACE.md` recipe onto upstream's version. +2. `codex-rs/local/**` and `local/**`. +3. Generated files (`codex-rs/Cargo.lock`). + +Never edit other upstream files to make things compile. Never add `codex-local-*` +dependencies to upstream crates. Never lower tests, loosen the budget, or edit the allowlist +to hide a violation. + +## Escalate (exit 2: stop, do not keep trying) + +- Any conflict with `kind: other` (outside the allowlist). +- A hook's target symbol (named in `FORK_SURFACE.md`) no longer exists or moved so the recipe + cannot be applied as written. +- `check` still exits 1 after 3 repair rounds, or the work needs more than ~150 changed lines + outside `codex-rs/local/**`. +- Any command exits 2. + +To escalate: leave the branch as is, run `report` (with `--check-json` if you have one), and +comment on the open `upstream-sync` issue with: the tag, the failing step, the JSON +`conflicts`/errors, and which rule above triggered. Then stop. + +## Notes + +- `check` skips cargo steps if `cargo` is missing and says so under `skipped`; a skipped step + is not a pass (`check` exits 1). `check_fork_surface.py` missing is also reported as skipped. +- Hook unit tests run if `FORK_SURFACE.md` has a line like ``Tests: `cargo test -p codex-core router` ``. +- Exit 2 from `check`: a fork-surface budget violation or a non-allowlisted path. Escalate. +- `check` exits 1 if any new upstream workflow could not be disabled (listed in + `workflows_to_disable`). +- `--tag`/`merge ` must match `^rust-v\d+\.\d+\.\d+$`; anything else exits 2. +- **Secret `UPSTREAM_SYNC_TOKEN`** (PAT or GitHub App token with `workflows` scope): the + upstream-sync workflow uses it to push and open the PR when set. When a merge touches + `.github/workflows/**` and it is NOT set, the workflow fails early with a clear message + (GITHUB_TOKEN cannot push workflow-file changes). Otherwise it falls back to GITHUB_TOKEN. +- Options: `--upstream-remote NAME` (default `upstream`, else `origin` if it is openai/codex); + `check --repo OWNER/NAME` disables new upstream workflows via `gh`. diff --git a/.codex/skills/sync-upstream/SKILL.md b/.codex/skills/sync-upstream/SKILL.md new file mode 100644 index 000000000000..2a4785e51577 --- /dev/null +++ b/.codex/skills/sync-upstream/SKILL.md @@ -0,0 +1,83 @@ +--- +name: sync-upstream +description: Merge a new openai/codex rust-v* release into codex-local, repair conflicts and breakage within the allowed edit set, and escalate otherwise. Use for the upstream-sync issue/PR or when asked to sync upstream. +--- + +# sync-upstream + +Driver: `python3 local/scripts/sync_upstream.py `. Each prints ONE JSON +object `{ok, step, conflicts[], build_errors[], test_failures[], surface_violations[], next_action}`. +Exit codes: **0** ok, **1** needs repair (you fix it), **2** escalate (you stop). +Always do what `next_action` says. Never guess past it. + +## Checklist + +1. `status` -> if `new_release` is false, stop (nothing to do). Else note `latest`. +2. `merge ` -> creates `upstream-sync/`, merges the tag, takes upstream's + `codex-rs/Cargo.lock` and regenerates it, bumps `local/UPSTREAM_VERSION`. + Exit 0: go to step 4. Exit 1: step 3. Exit 2: ESCALATE. +3. `conflicts` -> repair each listed path by `kind`, `git add` it, rerun `conflicts` until + exit 0, then `git commit --no-edit`. + - `hook`: take UPSTREAM's version (`git checkout --theirs `), then re-apply the hook + by following that hook's recipe in `local/FORK_SURFACE.md`. Keep the edit as small as the + recorded line count. Do not hand-merge the old hunk. + - `local`: keep ours unless the file only exists upstream-side. + - `generated` (Cargo.lock): `git checkout --theirs`, then `cargo update --workspace` in `codex-rs/`. + - `other`: ESCALATE (see below). +4. `check` -> runs build, local crate tests, hook tests, `check_fork_surface.py`, and + the workflow check. Loop while exit is 1 (see Repair loop). Exit 0: go to step 5. +5. `check > /tmp/check.json; report --check-json /tmp/check.json --out /tmp/report.md`. + Use `markdown` from the report as the PR body (success) or issue comment (failure). + Push the branch and open the PR to `codex-local/main` only when `check` exited 0. + +## Repair loop (exit 1 from `check`) + +Fix in this order, rerun `check` after each batch: +- `build_errors`: upstream API drift. Typical causes: a hook call site no longer matches a + renamed/re-signatured upstream function, or a `codex-rs/local/*` crate uses a changed + upstream API. Read the compiler error, fix the call site (hook file) or the local crate. +- `test_failures`: fix the local crate or re-apply the hook; never delete or skip a test. +- `surface_violations`: a file outside the allowlist or over budget is modified. Revert it + to upstream's version (`git checkout -- `). If a hook legitimately needs a + bigger edit, that is an escalation, not a budget bump. +- `workflows_to_disable`: run `gh workflow disable -R `; not a code edit. + +## Allowed edits (nothing else) + +1. Allowlisted hook files (the `allow` list in `local/fork-surface.toml`), re-applied from the + `FORK_SURFACE.md` recipe onto upstream's version. +2. `codex-rs/local/**` and `local/**`. +3. Generated files (`codex-rs/Cargo.lock`). + +Never edit other upstream files to make things compile. Never add `codex-local-*` +dependencies to upstream crates. Never lower tests, loosen the budget, or edit the allowlist +to hide a violation. + +## Escalate (exit 2: stop, do not keep trying) + +- Any conflict with `kind: other` (outside the allowlist). +- A hook's target symbol (named in `FORK_SURFACE.md`) no longer exists or moved so the recipe + cannot be applied as written. +- `check` still exits 1 after 3 repair rounds, or the work needs more than ~150 changed lines + outside `codex-rs/local/**`. +- Any command exits 2. + +To escalate: leave the branch as is, run `report` (with `--check-json` if you have one), and +comment on the open `upstream-sync` issue with: the tag, the failing step, the JSON +`conflicts`/errors, and which rule above triggered. Then stop. + +## Notes + +- `check` skips cargo steps if `cargo` is missing and says so under `skipped`; a skipped step + is not a pass (`check` exits 1). `check_fork_surface.py` missing is also reported as skipped. +- Hook unit tests run if `FORK_SURFACE.md` has a line like ``Tests: `cargo test -p codex-core router` ``. +- Exit 2 from `check`: a fork-surface budget violation or a non-allowlisted path. Escalate. +- `check` exits 1 if any new upstream workflow could not be disabled (listed in + `workflows_to_disable`). +- `--tag`/`merge ` must match `^rust-v\d+\.\d+\.\d+$`; anything else exits 2. +- **Secret `UPSTREAM_SYNC_TOKEN`** (PAT or GitHub App token with `workflows` scope): the + upstream-sync workflow uses it to push and open the PR when set. When a merge touches + `.github/workflows/**` and it is NOT set, the workflow fails early with a clear message + (GITHUB_TOKEN cannot push workflow-file changes). Otherwise it falls back to GITHUB_TOKEN. +- Options: `--upstream-remote NAME` (default `upstream`, else `origin` if it is openai/codex); + `check --repo OWNER/NAME` disables new upstream workflows via `gh`. diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 000000000000..8986b269d604 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,192 @@ +# upstream-sync: merges the newest upstream rust-v* release into codex-local/main via PR. +# +# Secret UPSTREAM_SYNC_TOKEN (optional but needed for some merges): a PAT or GitHub App token +# with `contents`, `pull requests` and `workflows` scope. Used to push the sync branch and open +# the PR. GITHUB_TOKEN cannot push changes to .github/workflows/**, so when the upstream range +# touches workflow files and this secret is unset, the run fails early with a clear message. +# When the upstream range does not touch workflows, GITHUB_TOKEN is used as a fallback. +name: upstream-sync + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + tag: + description: "Upstream tag to sync (default: newest stable rust-v*)" + required: false + type: string + force: + description: "Sync even if the tag is not newer than local/UPSTREAM_VERSION" + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +env: + BASE: codex-local/main + +jobs: + sync: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: codex-local/main + fetch-depth: 0 + + - name: Configure git and upstream remote + run: | + git config user.name "codex-local upstream-sync" + git config user.email "upstream-sync@users.noreply.github.com" + git remote add upstream https://github.com/openai/codex.git + git fetch upstream --tags --quiet + + - name: Status + id: status + env: + TAG_INPUT: ${{ inputs.tag }} + FORCE_INPUT: ${{ inputs.force }} + run: | + set -o pipefail + args=() + [ -n "$TAG_INPUT" ] && args+=(--tag "$TAG_INPUT") + [ "$FORCE_INPUT" = "true" ] && args+=(--force) + python3 local/scripts/sync_upstream.py status "${args[@]}" | tee status.json + echo "new=$(jq -r .new_release status.json)" >> "$GITHUB_OUTPUT" + echo "tag=$(jq -r .latest status.json)" >> "$GITHUB_OUTPUT" + + - name: Require UPSTREAM_SYNC_TOKEN when workflows change + if: steps.status.outputs.new == 'true' + env: + SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }} + TAG: ${{ steps.status.outputs.tag }} + run: | + changed="$(git diff --name-only "$(git merge-base HEAD "$TAG")" "$TAG" -- .github/workflows)" + if [ -n "$changed" ] && [ -z "$SYNC_TOKEN" ]; then + echo "::error title=UPSTREAM_SYNC_TOKEN required::Upstream $TAG changes .github/workflows/** but secret UPSTREAM_SYNC_TOKEN (PAT or App token with workflows scope) is not set; GITHUB_TOKEN cannot push workflow changes. Add the secret and re-run." + printf 'Workflow files changed upstream:\n%s\n' "$changed" + exit 1 + fi + + - name: Install Rust toolchain + if: steps.status.outputs.new == 'true' + run: | + channel=$(sed -n 's/^channel *= *"\(.*\)"/\1/p' codex-rs/rust-toolchain.toml) + rustup toolchain install "$channel" --profile minimal -c clippy -c rustfmt + rustup default "$channel" + + - uses: Swatinem/rust-cache@v2 + if: steps.status.outputs.new == 'true' + with: + workspaces: codex-rs + + - name: Merge + id: merge + if: steps.status.outputs.new == 'true' + continue-on-error: true + env: + TAG: ${{ steps.status.outputs.tag }} + run: | + set +e + python3 local/scripts/sync_upstream.py merge "$TAG" | tee merge.json + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Check + id: check + if: steps.status.outputs.new == 'true' && steps.merge.outputs.code == '0' + run: | + set +e + python3 local/scripts/sync_upstream.py check --repo "$GITHUB_REPOSITORY" | tee check.json + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Report + id: report + if: steps.status.outputs.new == 'true' + env: + TAG: ${{ steps.status.outputs.tag }} + MERGE_CODE: ${{ steps.merge.outputs.code }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + # merge.json is the report input when the merge itself failed. + src=check.json; [ "$MERGE_CODE" = "0" ] || src=merge.json + if [ "$MERGE_CODE" != "0" ]; then + # conflicts left in the index: record them, abort the half-done merge + git merge --abort 2>/dev/null || true + git checkout -q "$BASE" 2>/dev/null || true + fi + python3 - "$src" <<'PY' + import json, os, sys + d = json.load(open(sys.argv[1])) + lines = [f"## Upstream sync: {os.environ['TAG']}", "", + f"Result: {'PASS' if d['ok'] else 'FAIL'} (step {d['step']})", ""] + for k in ("conflicts", "build_errors", "test_failures", "surface_violations"): + lines += [f"### {k}"] + ([f"- `{x}`" for x in d[k]] or ["- none"]) + [""] + lines += [f"Next action: {d['next_action']}", "", + f"Run: {os.environ['RUN_URL']}"] + open("report.md", "w").write("\n".join(lines) + "\n") + PY + + - name: Open PR (success) + if: steps.status.outputs.new == 'true' && steps.merge.outputs.code == '0' && steps.check.outputs.code == '0' + env: + TAG: ${{ steps.status.outputs.tag }} + SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }} + run: | + tag="$TAG" + branch="upstream-sync/$tag" + # PAT/App token (workflows scope) when set, else GITHUB_TOKEN. + token="${SYNC_TOKEN:-$GH_TOKEN}" + export GH_TOKEN="$token" + git push "https://x-access-token:${token}@github.com/${GITHUB_REPOSITORY}.git" "$branch" + # Supersede older sync PRs. + gh pr list --state open --json number,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"upstream-sync/\")) | select(.headRefName != \"$branch\") | .number" | + while read -r n; do + gh pr close "$n" --delete-branch --comment "Superseded by $tag." + done + gh pr create --base "$BASE" --head "$branch" \ + --title "Upstream sync: $tag" --body-file report.md + + - name: Open or update issue (failure) + if: steps.status.outputs.new == 'true' && (steps.merge.outputs.code != '0' || steps.check.outputs.code != '0') + env: + TAG: ${{ steps.status.outputs.tag }} + MERGE_CODE: ${{ steps.merge.outputs.code }} + SYNC_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN }} + run: | + tag="$TAG" + branch="upstream-sync/$tag" + token="${SYNC_TOKEN:-$GH_TOKEN}" + export GH_TOKEN="$token" + # Keep the repair branch only when the merge itself completed. + if [ "$MERGE_CODE" = "0" ]; then + git push --force "https://x-access-token:${token}@github.com/${GITHUB_REPOSITORY}.git" "$branch" + fi + gh pr list --state open --json number,headRefName \ + --jq ".[] | select(.headRefName | startswith(\"upstream-sync/\")) | select(.headRefName != \"$branch\") | .number" | + while read -r n; do + gh pr close "$n" --delete-branch --comment "Superseded by $tag." + done + gh label create upstream-sync --color 0e8a16 --description "Upstream sync tracking" 2>/dev/null || true + n=$(gh issue list --label upstream-sync --state open --json number --jq '.[0].number') + if [ -n "$n" ]; then + gh issue edit "$n" --title "Upstream sync needs repair: $tag" + gh issue comment "$n" --body-file report.md + else + gh issue create --label upstream-sync --title "Upstream sync needs repair: $tag" --body-file report.md + fi diff --git a/local/scripts/sync_upstream.py b/local/scripts/sync_upstream.py new file mode 100755 index 000000000000..f6dd267504e2 --- /dev/null +++ b/local/scripts/sync_upstream.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Upstream tracking for codex-local (plan Phase 7). Stdlib only. + +Subcommands: status | merge | conflicts | check | report. +Each prints ONE JSON object: + {ok, step, conflicts[], build_errors[], test_failures[], surface_violations[], next_action} +(plus a few step-specific extra keys). Exit codes: 0 ok, 1 needs repair, 2 escalate. +""" +import argparse +import fnmatch +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STABLE = re.compile(r"^rust-v(\d+)\.(\d+)\.(\d+)$") +LOCKFILE = "codex-rs/Cargo.lock" +FORK_WORKFLOWS = ["local-*.yml", "upstream-sync.yml"] +ESCALATE_KINDS = {"over_line_budget", "over_file_budget", "not_allowlisted"} +LOCAL_CRATES = ["codex-local-cli", "codex-local-models", "codex-local-resume"] + + +class Escalate(Exception): + pass + + +def emit(step, ok, code, next_action, **kw): + out = {"ok": ok, "step": step, "conflicts": [], "build_errors": [], + "test_failures": [], "surface_violations": [], "next_action": next_action} + out.update(kw) + print(json.dumps(out, indent=2)) + return code + + +def run(cmd, root, check=False): + p = subprocess.run(cmd, cwd=root, capture_output=True, text=True) + if check and p.returncode: + raise Escalate(f"{' '.join(cmd)} failed: {p.stderr.strip()[:300]}") + return p + + +def git(root, *args, check=True): + return run(["git", *args], root, check) + + +def version_file(root): + return Path(root) / "local" / "UPSTREAM_VERSION" + + +def current_version(root): + return version_file(root).read_text().strip() + + +TAG_RE = re.compile(r"rust-v\d+\.\d+\.\d+", re.ASCII) # matched with fullmatch: ^...$ + + +def validate_tag(tag): + if not isinstance(tag, str) or not TAG_RE.fullmatch(tag): + raise Escalate(f"{tag!r} is not a stable tag (expected rust-vN.N.N)") + return tag + + +def tag_key(tag): + m = STABLE.match(tag) + return tuple(int(x) for x in m.groups()) if m else None + + +def resolve_remote(root, wanted): + remotes = git(root, "remote").stdout.split() + if wanted: + if wanted not in remotes: + raise Escalate(f"remote {wanted!r} not found") + return wanted + if "upstream" in remotes: + return "upstream" + if "origin" in remotes: + url = git(root, "remote", "get-url", "origin").stdout + if re.search(r"openai/codex(\.git)?\s*$", url): + return "origin" + raise Escalate("no upstream remote (add one or pass --upstream-remote)") + + +def newest_stable(root, remote): + out = git(root, "ls-remote", "--tags", remote, "refs/tags/rust-v*").stdout + tags = set() + for line in out.splitlines(): + ref = line.split("\t")[-1].removeprefix("refs/tags/").removesuffix("^{}") + if tag_key(ref): + tags.add(ref) + return max(tags, key=tag_key, default=None) + + +# ---- globs / classification ------------------------------------------------- +def glob_match(pattern, path): + rx = re.escape(pattern).replace(r"\*\*", "\0").replace(r"\*", "[^/]*").replace(r"\?", "[^/]") + return re.fullmatch(rx.replace("\0", ".*"), path) is not None + + +def load_policy(root): + p = Path(root) / "local" / "fork-surface.toml" + try: + import tomllib + d = tomllib.loads(p.read_text()) + except Exception: + return [], [LOCKFILE] + return d.get("allow", d.get("allowlist", [])), d.get("generated", [LOCKFILE]) + + +def classify(path, allow, generated): + if path.startswith(("local/", "codex-rs/local/")): + return "local" + if any(glob_match(g, path) for g in generated) or path == LOCKFILE: + return "generated" + if any(glob_match(g, path) for g in allow): + return "hook" + return "other" + + +def unmerged(root): + out = git(root, "diff", "--name-only", "--diff-filter=U").stdout + allow, generated = load_policy(root) + return [{"path": p, "kind": classify(p, allow, generated)} for p in out.splitlines() if p] + + +def conflict_result(step, root): + cs = unmerged(root) + if any(c["kind"] == "other" for c in cs): + return emit(step, False, 2, "ESCALATE: conflict outside the allowlist; stop and comment on the issue", conflicts=cs) + if cs: + return emit(step, False, 1, "resolve conflicts: hook -> re-apply FORK_SURFACE.md recipe on upstream's version; " + "generated -> take upstream and regenerate; then git add and run check", conflicts=cs) + return emit(step, True, 0, "run: sync_upstream.py check") + + +# ---- subcommands ------------------------------------------------------------ +def cmd_status(a, root): + remote = resolve_remote(root, a.upstream_remote) + cur = current_version(root) + newest = newest_stable(root, remote) + if a.tag: + newest = validate_tag(a.tag) + new = bool(newest) and (a.force or (tag_key(newest) or ()) > (tag_key(cur) or ())) + return emit("status", True, 0, f"merge {newest}" if new else "none", + current=cur, latest=newest, new_release=new, remote=remote) + + +def cmd_merge(a, root): + tag = validate_tag(a.tag) + remote = resolve_remote(root, a.upstream_remote) + if git(root, "rev-parse", "-q", "--verify", f"refs/tags/{tag}", check=False).returncode: + git(root, "fetch", remote, f"refs/tags/{tag}:refs/tags/{tag}") + branch = f"upstream-sync/{tag}" + git(root, "checkout", "-b", branch) + m = git(root, "merge", "--no-commit", "--no-ff", tag, check=False) + if m.returncode and "CONFLICT" not in m.stdout and not unmerged(root): + raise Escalate(f"merge failed: {(m.stderr or m.stdout).strip()[:300]}") + if LOCKFILE in [c["path"] for c in unmerged(root)]: + git(root, "checkout", f"{tag}", "--", LOCKFILE) # take upstream's + git(root, "add", LOCKFILE) + if not a.no_regen and shutil.which("cargo") and (root / "codex-rs").is_dir(): + run(["cargo", "update", "--workspace"], root / "codex-rs") + git(root, "add", LOCKFILE, check=False) + version_file(root).write_text(tag + "\n") + git(root, "add", "local/UPSTREAM_VERSION") + if unmerged(root): + return conflict_result("merge", root) + git(root, "commit", "-m", f"Merge {tag} into {branch}", "--no-verify") + return emit("merge", True, 0, "run: sync_upstream.py check", branch=branch) + + +def cmd_conflicts(a, root): + return conflict_result("conflicts", root) + + +def tail_matches(text, rx, limit=30): + return [l.strip() for l in text.splitlines() if re.search(rx, l)][:limit] + + +def hook_tests(root): + p = Path(root) / "local" / "FORK_SURFACE.md" + if not p.exists(): + return [] + return [m.group(1).split() for m in re.finditer(r"^\W*Tests?:\s*`(cargo test [^`]+)`", p.read_text(), re.M)] + + +def new_workflows(root, base): + if base is None: + base = "HEAD^1" if git(root, "rev-parse", "-q", "--verify", "HEAD^2", check=False).returncode == 0 else None + if base is None: + return [] + out = git(root, "diff", "--name-only", "--diff-filter=A", base, "HEAD", "--", ".github/workflows", check=False).stdout + names = [Path(p).name for p in out.splitlines() if p.endswith(".yml")] + return sorted(n for n in names if not any(fnmatch.fnmatch(n, g) for g in FORK_WORKFLOWS)) + + +def cmd_check(a, root): + d = {"build_errors": [], "test_failures": [], "surface_violations": []} + steps, skipped, escalate = [], [], [] + have_cargo = shutil.which("cargo") and (root / "codex-rs").is_dir() + cargo_dir = root / "codex-rs" + + def cargo(name, args, key, rx): + if not have_cargo: + skipped.append(name) + return + p = run(["cargo", *args], cargo_dir) + steps.append({"step": name, "ok": p.returncode == 0}) + if p.returncode: + d[key] += tail_matches(p.stdout + p.stderr, rx) or [f"{name} exited {p.returncode}"] + + cargo("build", ["check", "-p", "codex-local-cli"], "build_errors", r"^error") + cargo("local-tests", ["test", *sum((["-p", c] for c in LOCAL_CRATES), [])], "test_failures", r"FAILED|^error|panicked") + for i, cmd in enumerate(hook_tests(root)): + cargo(f"hook-test-{i}", cmd[1:], "test_failures", r"FAILED|^error|panicked") + fs = root / "local" / "scripts" / "check_fork_surface.py" + if fs.exists(): + p = run([sys.executable, str(fs), "--root", str(root)], root) + try: + v = json.loads(p.stdout).get("violations", []) + except ValueError: + v = [p.stderr.strip()[:300] or "check_fork_surface.py gave no JSON"] + if p.returncode and not v: + v = [f"check_fork_surface.py exited {p.returncode}"] + escalate += [x for x in v if isinstance(x, dict) and x.get("kind") in ESCALATE_KINDS] + d["surface_violations"] += [x if isinstance(x, str) else json.dumps(x) for x in v] + steps.append({"step": "fork-surface", "ok": p.returncode == 0}) + else: + skipped.append("fork-surface (check_fork_surface.py not present)") + nw = new_workflows(root, a.base) + disabled, failed_disable = [], [] + if nw: + for n in nw: + if a.repo and shutil.which("gh") and \ + run(["gh", "workflow", "disable", n, "-R", a.repo], root).returncode == 0: + disabled.append(n) + else: + failed_disable.append(n) # no repo, no gh, or gh failed: still enabled + steps.append({"step": "workflows", "ok": not failed_disable}) + ok = not (d["build_errors"] or d["test_failures"] or d["surface_violations"] + or skipped or failed_disable) + if escalate: + code, nxt = 2, ("ESCALATE: fork-surface budget exceeded or non-allowlisted path touched; " + "do not auto-repair, comment on the upstream-sync issue for a human decision") + elif ok: + code, nxt = 0, "run: sync_upstream.py report" + else: + code = 1 + nxt = ("repair (allowed edits only): fix build_errors first, then test_failures, then " + "surface_violations; rerun check") + if skipped: + nxt = f"install the missing tool(s) so required steps run (skipped: {', '.join(skipped)}); " + nxt + if failed_disable: + nxt = ("disable the listed workflows by hand (gh workflow disable ); " + nxt) + return emit("check", ok, code, nxt, steps=steps, skipped=skipped, new_workflows=nw, + workflows_disabled=disabled, workflows_to_disable=failed_disable, **d) + + +def cmd_report(a, root): + ver = current_version(root) + chk = {} + if a.check_json: + try: + chk = json.loads(Path(a.check_json).read_text()) + if not isinstance(chk, dict): + raise ValueError("expected a JSON object") + except (OSError, ValueError) as e: + return emit("report", False, 2, f"ESCALATE: --check-json {a.check_json!r} unusable: {e}", + error={"kind": "bad_check_json", "path": str(a.check_json), "message": str(e)}) + stat = git(root, "diff", "--stat", "HEAD^1", "HEAD", "--", ".", f":!{LOCKFILE}", check=False).stdout.strip().splitlines() + ok = chk.get("ok", True) + L = [f"## Upstream sync: {ver}", "", f"Result: {'PASS' if ok else 'FAIL'}", ""] + for title, key in [("Conflicts", "conflicts"), ("Build errors", "build_errors"), + ("Test failures", "test_failures"), ("Fork-surface violations", "surface_violations")]: + items = chk.get(key) or [] + lines = [f"- `{x}`" if isinstance(x, str) else f"- `{x['path']}` ({x['kind']})" for x in items] + L += [f"### {title}", *(lines or ["- none"]), ""] + for key, title in [("skipped", "Skipped steps"), ("new_workflows", "New upstream workflows"), + ("workflows_disabled", "Workflows disabled"), ("workflows_to_disable", "Workflows still to disable")]: + if chk.get(key): + L += [f"### {title}", *(f"- {x}" for x in chk[key]), ""] + L += ["### Diffstat (merge vs first parent, Cargo.lock excluded)", "```", *stat[-15:], "```"] + md = "\n".join(L) + "\n" + if a.out: + Path(a.out).write_text(md) + return emit("report", True, 0, "open PR" if ok else "open or update the upstream-sync issue", markdown=md) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--root", default=str(ROOT)) + ap.add_argument("--upstream-remote", default=None) + sub = ap.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("status"); s.set_defaults(fn=cmd_status) + s.add_argument("--tag"); s.add_argument("--force", action="store_true") + m = sub.add_parser("merge"); m.set_defaults(fn=cmd_merge) + m.add_argument("tag"); m.add_argument("--no-regen", action="store_true") + sub.add_parser("conflicts").set_defaults(fn=cmd_conflicts) + c = sub.add_parser("check"); c.set_defaults(fn=cmd_check) + c.add_argument("--repo"); c.add_argument("--base") + r = sub.add_parser("report"); r.set_defaults(fn=cmd_report) + r.add_argument("--check-json"); r.add_argument("--out") + a = ap.parse_args(argv) + try: + return a.fn(a, Path(a.root)) + except Escalate as e: + return emit(a.cmd, False, 2, f"ESCALATE: {e}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/local/tests/test_sync_upstream.py b/local/tests/test_sync_upstream.py new file mode 100644 index 000000000000..4b5a9b3ab2a0 --- /dev/null +++ b/local/tests/test_sync_upstream.py @@ -0,0 +1,140 @@ +import contextlib +import io +import json +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import sync_upstream as su # noqa: E402 + +KEYS = {"ok", "step", "conflicts", "build_errors", "test_failures", "surface_violations", "next_action"} + + +def git(cwd, *a): + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *a], cwd=cwd, check=True, capture_output=True) + + +def run_cli(root, *args): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = su.main(["--root", str(root), *args]) + return code, json.loads(buf.getvalue()) + + +class SyncTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + t = Path(self.tmp.name) + self.up, self.work = t / "up", t / "work" + for d in (self.up, self.work): + d.mkdir() + git(d, "init", "-q") + (self.up / "f").write_text("x") + git(self.up, "add", "."); git(self.up, "commit", "-qm", "c") + for tag in ["rust-v0.161.0", "rust-v0.162.1", "rust-v0.163.0-alpha.1", "rust-v0.163.0-rc.2", + "rust-v0.162.10", "rust-v0.164.0-beta.1", "other-v9.9.9"]: + git(self.up, "tag", tag) + (self.work / "local").mkdir() + (self.work / "local" / "UPSTREAM_VERSION").write_text("rust-v0.162.1\n") + (self.work / "local" / "fork-surface.toml").write_text('allow = ["codex-rs/core/src/hook.rs"]\n') + git(self.work, "remote", "add", "upstream", str(self.up)) + + def tearDown(self): + self.tmp.cleanup() + + def test_status_newest_stable(self): + code, out = run_cli(self.work, "status") + self.assertEqual(code, 0) + self.assertEqual(set(out) >= KEYS, True) + self.assertEqual(out["latest"], "rust-v0.162.10") + self.assertTrue(out["new_release"]) + self.assertEqual(out["next_action"], "merge rust-v0.162.10") + + def test_status_up_to_date(self): + (self.work / "local" / "UPSTREAM_VERSION").write_text("rust-v0.162.10\n") + code, out = run_cli(self.work, "status") + self.assertFalse(out["new_release"]) + self.assertEqual(out["next_action"], "none") + + def test_classification(self): + allow, gen = ["codex-rs/core/src/hook.rs"], ["codex-rs/Cargo.lock"] + c = lambda p: su.classify(p, allow, gen) + self.assertEqual(c("codex-rs/core/src/hook.rs"), "hook") + self.assertEqual(c("codex-rs/local/cli/src/main.rs"), "local") + self.assertEqual(c("local/FORK_SURFACE.md"), "local") + self.assertEqual(c("codex-rs/Cargo.lock"), "generated") + self.assertEqual(c("codex-rs/core/src/other.rs"), "other") + + def test_conflicts_clean_and_escalate(self): + code, out = run_cli(self.work, "conflicts") + self.assertEqual((code, out["ok"], out["conflicts"]), (0, True, [])) + # fabricate an unmerged path outside the allowlist + git(self.work, "add", "."); git(self.work, "commit", "-qm", "base") + git(self.work, "checkout", "-q", "-b", "m1"); (self.work / "z.rs").write_text("1") + git(self.work, "add", "z.rs"); git(self.work, "commit", "-qm", "1") + git(self.work, "checkout", "-q", "-b", "m2", "m1~1"); (self.work / "z.rs").write_text("2") + git(self.work, "add", "z.rs"); git(self.work, "commit", "-qm", "2") + subprocess.run(["git", "merge", "m1"], cwd=self.work, capture_output=True) + code, out = run_cli(self.work, "conflicts") + self.assertEqual(code, 2) + self.assertEqual(out["conflicts"], [{"path": "z.rs", "kind": "other"}]) + self.assertTrue(out["next_action"].startswith("ESCALATE")) + + def test_missing_remote_escalates(self): + git(self.work, "remote", "remove", "upstream") + code, out = run_cli(self.work, "status") + self.assertEqual(code, 2) + self.assertEqual(set(out) >= KEYS, True) + + def _stub_surface(self, violations): + d = self.work / "local" / "scripts" + d.mkdir(parents=True) + (d / "check_fork_surface.py").write_text( + "import json,sys\nprint(json.dumps({'ok': False, 'violations': %r}))\nsys.exit(1)\n" % (violations,)) + + def test_check_skipped_required_step_is_exit_1(self): + code, out = run_cli(self.work, "check") # no codex-rs, no fork-surface script + self.assertEqual(code, 1) + self.assertFalse(out["ok"]) + self.assertTrue(out["skipped"]) + + def test_check_budget_and_non_allowlisted_escalate_exit_2(self): + for v in ({"kind": "over_line_budget", "lines": 9, "max": 1}, + {"kind": "not_allowlisted", "path": "codex-rs/x.rs"}): + self._stub_surface([v]) + code, out = run_cli(self.work, "check") + self.assertEqual(code, 2) + self.assertTrue(out["next_action"].startswith("ESCALATE")) + (self.work / "local" / "scripts" / "check_fork_surface.py").unlink() + (self.work / "local" / "scripts").rmdir() + + def test_check_failed_workflow_disable_is_exit_1_and_listed(self): + fail = subprocess.CompletedProcess([], 1, "", "no") + with mock.patch.object(su, "new_workflows", return_value=["x.yml"]), \ + mock.patch.object(su.shutil, "which", return_value="/bin/gh"), \ + mock.patch.object(su, "run", return_value=fail): + code, out = run_cli(self.work, "check", "--repo", "o/r") + self.assertEqual(code, 1) + self.assertEqual(out["workflows_to_disable"], ["x.yml"]) + + def test_report_bad_check_json_is_structured_error(self): + code, out = run_cli(self.work, "report", "--check-json", str(self.work / "nope.json")) + self.assertEqual(code, 2) + self.assertEqual(out["error"]["kind"], "bad_check_json") + (self.work / "bad.json").write_text("{not json") + code, out = run_cli(self.work, "report", "--check-json", str(self.work / "bad.json")) + self.assertEqual(code, 2) + + def test_tag_validation(self): + for bad in ("rust-v1.2.3-rc.1", "v1.2.3", "rust-v1.2.3\n"): + self.assertEqual(run_cli(self.work, "status", "--tag", bad)[0], 2) + self.assertEqual(run_cli(self.work, "merge", bad)[0], 2) + self.assertEqual(run_cli(self.work, "status", "--tag", "rust-v0.163.0")[0], 0) + + +if __name__ == "__main__": + unittest.main() From ef62c2a8bfa8272f872cf301cce9c70ee64ef7ba Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 15:24:55 -0700 Subject: [PATCH 2/2] local: add Tests lines to FORK_SURFACE.md; CI hook-test step reads them Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RPCzgwU247gMtjybR5v4iR --- .github/workflows/local-ci.yml | 10 ++++++---- .github/workflows/upstream-sync.yml | 3 +++ local/FORK_SURFACE.md | 8 ++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/local-ci.yml b/.github/workflows/local-ci.yml index 7bc22062906d..87e892eb56a0 100644 --- a/.github/workflows/local-ci.yml +++ b/.github/workflows/local-ci.yml @@ -77,10 +77,12 @@ jobs: workspaces: codex-rs - name: Local crate tests run: cargo test -p codex-local-cli -p codex-local-models -p codex-local-resume - - name: Hook test, tool-name unflatten - run: cargo test -p codex-core --lib unflatten_tool_name - - name: Hook test, model provider factory - run: cargo test -p codex-model-provider local_models_factory + - name: Hook tests (Tests lines in local/FORK_SURFACE.md) + run: | + set -euo pipefail + grep -E '^[^A-Za-z0-9]*Tests?:' ../local/FORK_SURFACE.md \ + | sed -E 's/.*`(cargo test [^`]+)`.*/\1/' \ + | while read -r cmd; do echo "+ ${cmd}"; ${cmd}; done - name: Build codex-local run: cargo build --bin codex-local -p codex-local-cli - name: verify_install --quick diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 8986b269d604..c51db283c905 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -46,6 +46,9 @@ jobs: with: ref: codex-local/main fetch-depth: 0 + # No persisted GITHUB_TOKEN auth header: it would override the + # UPSTREAM_SYNC_TOKEN credentials used by the push step below. + persist-credentials: false - name: Configure git and upstream remote run: | diff --git a/local/FORK_SURFACE.md b/local/FORK_SURFACE.md index 98efcf7ffff1..43390abad84e 100644 --- a/local/FORK_SURFACE.md +++ b/local/FORK_SURFACE.md @@ -11,6 +11,14 @@ | H7 | TODO | TODO | TODO | | H8 | `codex-rs/core/src/tools/router.rs` | `ToolRouter::unflatten_tool_name` (+1 call in `dispatch_tool_call_with_code_mode_result_inner`): when a call has no/default namespace, its dotted name misses the registry, and the split at the first dot hits, dispatch under the split `(namespace, name)`. Works around Ollama flattening Responses `namespace` into the name (`multi_agent_v1.spawn_agent`). Unconditional: OpenAI names match `^[a-zA-Z0-9_-]+$`, so stock behaviour is unchanged. History keeps the model's original item. 16 upstream lines in router.rs (tests in router_tests.rs). Re-apply: add the method and call it on `tool_name` right after destructuring `call`. | done | +## Hook tests + +One line per hook, read by `local/scripts/sync_upstream.py check` and the rust job in `local-ci.yml`. H1 and H4 have no dedicated test. + +- Tests: `cargo test -p codex-model-provider local_models_factory` (H2) +- Tests: `cargo test -p codex-local-resume` (H3) +- Tests: `cargo test -p codex-core --lib unflatten_tool_name` (H8) + ## H1 re-apply recipe 1. In `codex-rs/cli/src/main.rs`: add `#[cfg(not(codex_local))]` above `fn main`; change `cli_main` to take a leading `argv: Vec`; replace `MultitoolCli::parse()` with `MultitoolCli::parse_from(argv)`; pass `std::env::args_os().collect()` from `main`.