diff --git a/.github/actions/spelling/allow/allow.txt b/.github/actions/spelling/allow/allow.txt index 0faae3da1..99c6b8e94 100644 --- a/.github/actions/spelling/allow/allow.txt +++ b/.github/actions/spelling/allow/allow.txt @@ -84,6 +84,7 @@ greenfield greppable haikus handover +hashtable historicals hstrings https diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 71ba80c66..ec37cdac1 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -126,6 +126,7 @@ Resources/(?!en) ^XamlStyler\.json$ ^\.clang-format$ ^\.github/actions/spelling/ +^\.github/skills/ ^\.github/workflows/spelling\d*\.yml$ ^\.vsconfig$ ^\Qbuild/config/release.gdnbaselines\E$ diff --git a/.github/skills/upstream-sync/SKILL.md b/.github/skills/upstream-sync/SKILL.md new file mode 100644 index 000000000..f44a9d73b --- /dev/null +++ b/.github/skills/upstream-sync/SKILL.md @@ -0,0 +1,163 @@ +--- +name: upstream-sync +description: 'Periodically sync new commits from microsoft/terminal into this manually-forked intelligent-terminal repo by cherry-picking commit-by-commit onto a dated sync branch, auto-skipping revert pairs and empty commits, auto-resolving known take-upstream files, and stopping cleanly on genuine conflicts. The agent (you, reading this file) is the orchestrator — the PowerShell scripts are atomic operations you invoke one at a time. Use when the user asks to "sync upstream", "pull from microsoft/terminal", "run upstream sync", "catch up to upstream", or wires this into a scheduler (weekly/daily). Designed to be safe under repeated unattended runs.' +license: MIT +--- + +# Upstream Sync (microsoft/terminal → intelligent-terminal) + +Cherry-pick commit-by-commit from `https://github.com/microsoft/terminal` +into this fork, preserving per-commit attribution, auto-skipping picks +that cancel each other out, and stopping cleanly the moment a +human-judgment conflict appears. + +**You — the agent reading this file — are the orchestrator.** Each step +in the run is a single atomic call into one of the `scripts/*.ps1` files. +There is intentionally no PowerShell driver, because every interesting +decision (build-fix vs. open stuck issue, retry vs. bail, finalize vs. +dry-run) wants LLM judgment the operator can audit in your transcript. + +## When to Use This Skill + +- User asks to "sync upstream", "pull from microsoft/terminal", "catch up to upstream", or "run upstream sync". +- A scheduler invokes the agent on a weekly/daily cadence. +- The previous run left an `upstream-sync-stuck` labeled issue open and the human has finished resolving the conflict — **close the issue** (that IS the lock-clear signal) and re-run. + +## When NOT to Use This Skill + +- User wants a **one-shot rebase** of a single feature branch onto upstream — that's a normal `git rebase`, not this skill. +- An `upstream-sync-stuck` labeled issue is open on `microsoft/intelligent-terminal` — do not re-run; resolve the conflict on the stuck branch first, then close the issue. + +## Prerequisites + +- `git` 2.38+ (for `cherry-pick --keep-redundant-commits`), `gh` CLI authenticated against `microsoft/intelligent-terminal` (needs push to `upstream-sync/*` topic branches + issue/label create), PowerShell 7+ (`pwsh`) on PATH. +- **`origin` MUST point at `microsoft/intelligent-terminal`.** All scripts push to `origin` and pass `-R microsoft/intelligent-terminal` explicitly. Verify with `git remote -v` before running. +- **Full git history on `origin/main`** (no shallow clone). Watermark discovery walks up to 5000 commits scanning for `cherry picked from commit ` trailers. In CI, use `actions/checkout@v4` with `fetch-depth: 0`. +- Windows build host with Visual Studio 2022, Windows SDK, `vswhere`, and `tools\razzle.cmd` / `bz`. Build is a hard gate before finalize. +- **No `state.json` to bootstrap.** Watermark = newest `(cherry picked from commit )` trailer on `origin/main`. If the fork has never used `cherry-pick -x`, run the one-time [First-time sync](./references/recovery-procedures.md#first-time-sync-seeding-the-watermark). + +## State Model (no state file) + +Every persistent fact lives in the source that owns it: + +| Question | Source of truth | +|---|---| +| Last-synced upstream commit? | Newest `(cherry picked from commit )` trailer on `origin/main` whose target is reachable from `upstream/main`. Derived by [`02-compute-pending.ps1`](./scripts/02-compute-pending.ps1). | +| What's pending? | `git log --cherry-pick --right-only --no-merges origin/main...upstream/main`, drop SHAs at/before the watermark. Patch-id based, so a picked-then-reverted commit correctly re-appears. | +| Is the scheduler locked? | Any open issue with the `upstream-sync-stuck` label on `microsoft/intelligent-terminal`. Closing it IS the lock-clear signal. | +| Where do build logs go? | `Generated Files/upstream-sync//` — gitignored by the repo root's `**/Generated Files/`. Never committed. | + +### Why cherry-pick (not rebase or merge) + +- **Rebase upstream/main** — ❌ fork history contains old "Merge upstream" commits; rebase replays them and explodes conflicts (verified failure on sister repo). +- **Merge upstream/main** — ⚠️ works, but collapses the whole sync into one blob commit, killing per-commit review and `git bisect`. +- **Cherry-pick commit-by-commit** — ✅ preserves authorship + per-commit content, allows mechanical revert-pair skipping, produces a reviewable PR. + +## Run a sync + +Eight-step orchestration. Full commands, JSON contracts, and +failure-handling for every step are in +[`references/run-a-sync.md`](./references/run-a-sync.md). The flow: + +``` +1. Preconditions (clean tree, on main FF, no open stuck issue) +2. Create branch upstream-sync/ +3. Fetch upstream (verify owner/repo identity of the `upstream` remote) +4. Compute pending ← scripts/02-compute-pending.ps1 (→ JSON) +5. Cherry-pick loop ← scripts/03-cherry-pick-one.ps1 per SHA (→ JSON) + picked / skipped-empty → continue + stuck → 5a (push branch, file upstream-sync-stuck issue, EXIT) +6. (No commits picked? exit clean.) + before build: re-pin build/pgo/Terminal.PGO.props if upstream bumped + its Windows Terminal Major.Minor (else PGO build fails) +7. Build ← scripts/04-try-build.ps1 (→ JSON) + build-ok → step 8 + build-failed → ONE focused fix commit, re-build; else 7a + build-inconclusive → 7a + 7a. Surface failure to operator, EXIT (no issue filed for build failures) +8. Finalize: push, `gh pr create`, optional `gh pr merge --rebase --auto` +``` + +**Key invariants** (the agent MUST hold these — full rationale in the runbook): + +- **Atomic per-commit.** Each pick is one commit on the sync branch carrying the upstream author/date and the `(cherry picked from commit )` trailer. Never amend across picks. +- **Stuck → exit clean.** On a Tier-3 conflict (script returns `status: "stuck"`), do NOT continue past it — push the branch, file the labeled issue, exit. The human resolves on the stuck branch, merges (no squash), closes the issue. +- **One build-fix commit, max.** Build-blocking fixes land as exactly one extra commit on the sync branch. Anything bigger → exit; the operator pushes a manual fix or runs a follow-up PR. +- **Never squash-merge the sync PR.** Squashing destroys per-commit attribution AND the trailer the next sync uses as its watermark. The PR body banner warns reviewers; the recipe arms `--rebase --auto`. + +## Recovery procedures (rare) + +Direct-to-main escape hatch, first-time watermark seeding, squash-merge +recovery: [`references/recovery-procedures.md`](./references/recovery-procedures.md). +Each requires explicit operator action. + +## After-PR review handling — fix-in-PR vs. follow-up PR + +Once the sync PR is open, reviewers (Copilot + humans) will comment. +**Only build-blocking fixes** belong on the sync branch as one focused +extra commit. Everything else — code-quality, logic-bug suggestions, +translation corrections, spelling-allowlist migrations, doc nits, design +feedback — goes into a **follow-up PR** that targets the sync branch +(not `main`). The cherry-pick PR must stay reviewable as "per-commit, +faithful to upstream + minimum must-merge delta". + +Full rubric, worktree mechanics, PR-body template: +[`references/follow-up-pr.md`](./references/follow-up-pr.md). + +## Gotchas + +- **Never squash-merge the sync PR.** Squashing destroys per-commit + attribution AND collapses the trailers the next run uses as its + watermark. The PR body opens with a banner; step 8 arms + `gh pr merge --rebase --auto`. +- **Never strip the `(cherry picked from commit )` trailer** when + hand-resolving a stuck pick. That trailer IS the watermark. +- **Never rebase `upstream/main` onto this fork.** Use cherry-pick — + rebase replays old "Merge upstream" commits and explodes. +- **Don't amend substantive review fixes into the sync PR.** Only + build-blocking fixes (max one extra commit). Everything else → follow-up PR. +- **`.github/workflows/spelling2.yml` always conflicts** and is always + "take upstream wholesale". The Tier-0 list in + [`references/03-known-conflicts.md`](./references/03-known-conflicts.md) + handles it — extend the list when you find the next file with this pattern. +- **`gh pr create` on Windows can fail with "Head sha can't be blank"** + on freshly-pushed branches. The step-8 recipe wraps it in a 3× retry. + Do not "fix" it to use `--head :` (that points `gh` at a fork). +- **Cherry-pick over `git revert`-style commits is intentional, not + skipped.** We only skip revert-pairs where **both** sides are inside + the pending range. A revert of an already-merged commit must land — + otherwise the fork diverges silently. +- **Single-host scheduler.** The stuck-lock is a read-then-check gate, + not an atomic lease. Run from ONE host. For multi-host fan-out, layer + atomic locking on top (GitHub Actions `concurrency: upstream-sync` is + easiest). +- **CRLF/LF on manifest files.** Cherry-picks preserve upstream endings, + but Tier-2 LLM-touched resolutions on `.yml`/`.xml`/`.csproj`/winget + manifests may downgrade to LF. Re-normalize before staging — see + [`references/03-conflict-triage.md`](./references/03-conflict-triage.md#line-endings). +- **PGO pin follows upstream.** `build/pgo/Terminal.PGO.props` hard-pins the + PGO database to upstream's Windows Terminal `Major.Minor` (our `custom.props` + stays `0.1`, so it can't be derived). When a sync bumps the upstream version, + re-pin those two values or the build fails with `Could not find matching PGO + package`. Step 7's pre-build check covers this. + +## Troubleshooting + +| Issue | Solution | +|---|---| +| `02-compute-pending.ps1` throws "No 'cherry picked from commit' trailer …" | Fork has never used `cherry-pick -x` yet. Run the one-time [First-time sync](./references/recovery-procedures.md#first-time-sync-seeding-the-watermark). | +| Stuck issue prevents new run | Resolve on the stuck branch, open + merge a PR (keep the trailer, don't squash), then **close the stuck issue**. The next scheduler tick proceeds. | +| `03-cherry-pick-one.ps1` returns `"skipped-empty"` | Expected for upstream no-op commits and fork-already-applied patches. The loop skips and continues. | +| Same file conflicts every run | Add it to [`references/03-known-conflicts.md`](./references/03-known-conflicts.md) with the right strategy (`take-upstream` or `take-ours`; `union` is reserved and currently escalates instead of auto-resolving). | +| `gh pr create` returns "Head sha can't be blank" | Step 8 retries 3×. On slow networks, the operator may need a manual second run. | + +## References + +- [`references/run-a-sync.md`](./references/run-a-sync.md) — full eight-step procedure with commands. +- [`references/03-conflict-triage.md`](./references/03-conflict-triage.md) — Tier 0/1/2/3 conflict-resolution rubric. +- [`references/03-known-conflicts.md`](./references/03-known-conflicts.md) — files with fixed Tier-0 resolutions. +- [`references/follow-up-pr.md`](./references/follow-up-pr.md) — fix-in-PR vs. follow-up PR rubric and worktree workflow. +- [`references/recovery-procedures.md`](./references/recovery-procedures.md) — direct-to-main, first-time seed, squash-merge recovery. +- [`scripts/02-compute-pending.ps1`](./scripts/02-compute-pending.ps1) — derive watermark + pending list (no state file). +- [`scripts/03-cherry-pick-one.ps1`](./scripts/03-cherry-pick-one.ps1) — cherry-pick one SHA with author/date pinning + Tier-0/Tier-1. +- [`scripts/04-try-build.ps1`](./scripts/04-try-build.ps1) — run `bz no_clean`; log to `Generated Files/...`. diff --git a/.github/skills/upstream-sync/references/03-conflict-triage.md b/.github/skills/upstream-sync/references/03-conflict-triage.md new file mode 100644 index 000000000..d1a851612 --- /dev/null +++ b/.github/skills/upstream-sync/references/03-conflict-triage.md @@ -0,0 +1,166 @@ +# Conflict Triage — Resolution Tiers + +When a cherry-pick conflicts, apply tiers **in order**. Stop at the first +tier that fully resolves the conflict. + +## Tier 0 — Known take-{upstream,ours} files + +Some files have a fixed correct resolution that never changes. Examples: + +- `.github/workflows/spelling2.yml` — always take upstream (verified on sister repo `agentic-terminal`). + +The list of these paths lives in [`03-known-conflicts.md`](./03-known-conflicts.md). + +**Algorithm:** + +```pwsh +$conflictingPaths = git diff --name-only --diff-filter=U +$tier0List = Get-KnownConflicts # parses 03-known-conflicts.md +foreach ($p in $conflictingPaths) { + $entry = $tier0List | Where-Object { $_.Path -eq $p } + if (-not $entry) { return $false } # Tier 0 doesn't cover this commit + switch ($entry.Strategy) { + 'take-upstream' { git checkout --theirs -- $p; git add -- $p } + 'take-ours' { git checkout --ours -- $p; git add -- $p } + 'union' { <# escalates — Tier-3 #> } + } +} +git cherry-pick --continue --no-edit +``` + +If `git status` is now clean and the cherry-pick continued, **Tier 0 fully resolved** — record the file(s) auto-resolved and move on. + +> **Note:** `union` is recognized by [`03-known-conflicts.md`](./03-known-conflicts.md) parsing for forward compatibility but is **not implemented** in [`scripts/03-cherry-pick-one.ps1`](../scripts/03-cherry-pick-one.ps1) — a `union` entry currently falls through to Tier-3 with the path listed under `conflict_paths`. Author Tier-0 entries with `take-upstream` or `take-ours` only. + +## Tier 1 — Empty after staging + +After Tier 0 (or with no conflicts to begin with), if the staged diff is +empty, the commit has already been applied to the fork in some prior +form. Skip it without recording a commit: + +```pwsh +if ((git diff --cached --quiet; $LASTEXITCODE) -eq 0) { + git cherry-pick --skip # equivalent to reset + advance + return @{ status = 'skipped-empty' } +} +``` + +## Tier 2 — LLM-assisted trivial textual (opt-in) + +Disabled by default. The orchestrator in [`SKILL.md`](../SKILL.md) does +not invoke Tier-2 — if you want it, an agent walking the cherry-pick loop +can opt in per-conflict using the rubric below. Even when invoked, this +tier only fires when **all** of the following hold: + +- No more than 3 conflicting files. +- Each file has fewer than 5 conflict hunks. +- Each hunk has fewer than 30 lines on either side. +- No conflicting file is in `src/cascadia/TerminalProtocol/`, + `src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp`, or + `tools/wta/**` (these are fork-only and shouldn't conflict; if they + somehow do, that's a Tier-3 signal). + +**Delegation:** + +Spawn a fresh sub-agent (Memory Assistant rules require fresh — never +self-review). Prompt template: + +> You are resolving a git cherry-pick conflict mechanically. Below are +> the conflict markers in ``. The fork ("ours") adds AI-agent +> integration; upstream ("theirs") is microsoft/terminal. Produce ONLY +> the resolved file content — no commentary, no markers. If you cannot +> resolve with high confidence (≥0.9), respond with the single token +> `LOW_CONFIDENCE` and nothing else. +> +> Confidence rubric: +> - **High**: changes are non-overlapping in intent (e.g., upstream +> added a new function near our edit; merge order is obvious). +> - **Low**: both sides modified the same logic / same lines / same +> public API — semantic decision needed. + +**Acceptance:** If the agent returns `LOW_CONFIDENCE`, escalate to +Tier 3. If it returns content, **verify with a second fresh agent**: + +> Compare the resolved file against the "ours" version and the "theirs" +> version. Does the resolution preserve all behavioral intent from both +> sides? Respond `OK` or `NOT_OK: `. + +Stage only if both agents agree `high`/`OK`. Otherwise, route to Tier 3. + +## Tier 3 — Stop and escalate (cherry-pick conflict) + +Anything not resolved by Tier 0–2: + +~~~pwsh +git cherry-pick --abort +# Push the branch, ensure the 'upstream-sync-stuck' label exists, and +# file a plain-markdown issue with the stuck SHA, upstream URL, author, +# branch name, and conflicting paths. See SKILL.md step 5a for the +# exact recipe. Surface the issue URL + branch to the operator and exit. +~~~ + +The issue body **must** include: + +- The conflicting commit SHA, subject, author, and upstream URL. +- The list of conflicting paths. +- The exact local branch name where the human picks up. +- The exact resume action: (1) check out the stuck branch, + (2) **re-run the cherry-pick to reproduce the conflict** — the script + calls `git cherry-pick --abort` before returning, so a fresh checkout + has no `MERGE_MSG` / conflict markers to resolve. Use + `git cherry-pick -x ` (the `-x` preserves the + `(cherry picked from commit )` trailer — critical for the + watermark). (3) Resolve, `git add`, `git cherry-pick --continue`. + (4) Push, open a PR, merge keeping the trailer (no squash). + (5) CLOSE the stuck issue (that's the lock-clear signal — no script). + +No fenced YAML metadata block is needed. Closing the labeled issue IS +the lock-clear signal; nothing parses the body back. + +## Tier 4 — Post-pick build failed + +The cherry-picks all applied cleanly, but [`scripts/04-try-build.ps1`](../scripts/04-try-build.ps1) +said NO before the PR could be finalized. The build runs before finalize +on purpose — see [`SKILL.md` step 7](../SKILL.md#7-build) for the +build-then-finalize ordering and the one-focused-fix-commit rule. + +| Sub-kind | Trigger | Action | +|---|---|---| +| **build-failed** | `bz no_clean` exited non-zero within timeout | Try ONE focused build-fix commit per [SKILL.md step 7](../SKILL.md#7-build). If that fails or scope is too large → surface the failure to the operator and exit (no issue is filed). | +| **build-inconclusive** | Wall-clock cap (default 45 min) hit | Surface the timeout to the operator and exit (don't guess at fixing a hang). | + +No stuck issue is filed for build failures. The operator either fixes +the underlying defect on `main` and re-runs (the next sync re-attempts +the same range and re-validates), or pushes a manual fix commit on top +of the sync branch and finishes step 8 by hand. + +## Line endings + +If any Tier-2 resolution touches a file with CRLF line endings (most +`.csproj`, `.xml`, winget manifests, and many `.yml` files on this repo), +re-normalize before staging: + +```pwsh +# Inside Tier-2, after writing the resolved content: +$bytes = [System.IO.File]::ReadAllBytes($p) +# Preserve the file's original BOM presence — UTF-8-with-BOM is right +# for .resw / .csproj on this repo, but UTF-8-without-BOM is right for +# many .yml / .md files. Adding a BOM where one wasn't there before +# introduces unrelated encoding diffs and can break tooling. +$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF +$text = [System.Text.Encoding]::UTF8.GetString($bytes) -replace "`r?`n", "`r`n" +[System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($hasBom))) +``` + +(Skipping this is how the winget-pkgs submission broke last time — +LF mid-file fails CI even though the rest of the file is CRLF.) + +## What is NOT a conflict for our purposes + +- **Upstream renamed a file we never touched** — git follows the rename + automatically. No conflict. +- **Upstream deleted a file we never touched** — git removes it. No conflict. +- **Upstream modified a file in a fork-only directory** (e.g., upstream + somehow touched `tools/wta/`) — impossible by construction since + upstream doesn't know those files exist. If it ever happens, it's a + Tier-3 signal that the fork-only directory is misnamed. diff --git a/.github/skills/upstream-sync/references/03-known-conflicts.md b/.github/skills/upstream-sync/references/03-known-conflicts.md new file mode 100644 index 000000000..029048eff --- /dev/null +++ b/.github/skills/upstream-sync/references/03-known-conflicts.md @@ -0,0 +1,54 @@ +# Known Conflicts (Tier-0 auto-resolution list) + +This file is the **source of truth** for paths that always conflict the +same way and have a known correct resolution. The Tier-0 step in the +cherry-pick loop parses this file and auto-resolves matching paths. + +## Format + +Each entry is an H2 path heading, a one-line `Strategy:` line, and a +"Why" paragraph. The parser reads the heading text as the literal path. +Implemented Tier-0 strategies are `take-upstream` and `take-ours`; +`union` is reserved and currently escalates instead of auto-resolving. + +--- + +## `.github/workflows/spelling2.yml` + +**Strategy:** `take-upstream` + +**Why:** Upstream maintains this workflow and updates it frequently with +new excludes/patterns. The fork has never had a reason to diverge from +upstream's version — every prior conflict on this file was resolved by +taking upstream wholesale. Verified on sister repo `agentic-terminal`. + +--- + +## How to add a new entry + +When the cherry-pick loop hits a Tier-3 conflict on a file that, on +inspection, has the same shape every time (e.g., a generated file, a +workflow upstream owns, a config we copy verbatim), add it here **after** +the manual resolution PR merges. Format: + +```markdown +## `` + +**Strategy:** `take-upstream` + +**Why:** +``` + +Then re-run the next sync — the previously-stuck file should now +auto-resolve under Tier 0. + +## What does NOT belong here + +- Files that conflict but where the correct resolution **depends on the + commit** (those need Tier-2 or human judgment, not a fixed rule). +- Fork-only files (`tools/wta/**`, `src/cascadia/TerminalProtocol/**`) + — these shouldn't conflict at all; if they do, the cause is structural + and a Tier-0 entry would mask it. +- Files where "take upstream" would lose a fork-specific feature. + Re-check by diffing fork-`HEAD` vs upstream for that file before adding. diff --git a/.github/skills/upstream-sync/references/follow-up-pr.md b/.github/skills/upstream-sync/references/follow-up-pr.md new file mode 100644 index 000000000..707b037b5 --- /dev/null +++ b/.github/skills/upstream-sync/references/follow-up-pr.md @@ -0,0 +1,150 @@ +# After-PR Review Handling: fix-in-PR vs. follow-up PR + +Once the sync PR is open, reviewers (GitHub Copilot bot and humans) will +leave comments. This doc is the decision rubric and the mechanics. + +## Why this exists + +The cherry-pick PR's value to the reviewer is "**N small commits, each +faithful to one upstream commit, plus the bare minimum to make CI +green**". Two failure modes break that: + +1. **Squashing fixes into the sync PR** (e.g. amending the cherry-pick + commits or adding many follow-up commits to the same branch) + destroys per-commit attribution, original author/committer dates, and + makes "which upstream commit broke X?" impossible to `git bisect`. +2. **Substantive feedback bundled into the sync PR** forces the + reviewer to mentally subtract those commits from every + upstream-comparison check ("is this difference here because the + cherry-pick was wrong, or because someone added a fix?"). + +So we keep the sync PR pristine and route everything else to a +follow-up PR. + +## Decision rubric + +| Comment / failure | Fix where | Why | +|---|---|---| +| Build error introduced by the cherry-pick batch | **Sync PR** — one focused commit | The sync PR is broken; the sync PR must be made buildable before merge. | +| Duplicate `.resw` keys / manifest collisions surfaced by `bz` only at build time | **Sync PR** — one focused commit | Same reason — without it the sync PR can't merge. | +| CI gate failure on the sync PR (check-spelling, lint, format) that is genuinely caused by the cherry-picked content | **Sync PR** — one focused commit | Same reason. | +| Copilot reviewer correctness finding (e.g. accumulator drift past clamp floor, missing cleanup on exception path) | **Follow-up PR** | Reviewer added value but the change is a *refactor* of upstream code, not a build fix. Cherry-pick PR stays faithful. | +| Translation error (`zh-TW` 工作模式 vs 工作階段) | **Follow-up PR** | The cherry-pick faithfully imported what upstream shipped. Correcting it is fork-local improvement. | +| Spelling-allowlist migration (move from `expect.txt` → `allow/*.txt`) | **Follow-up PR** | Repo-policy hygiene, not a build blocker. | +| Typo in a comment cherry-picked from upstream | **Follow-up PR** | Same. | +| Human reviewer asks for a design change / API tweak | **Follow-up PR** *or* push upstream | Fork-local refactor or upstream contribution, never bundled into the sync. | + +When in doubt, ask: *"If this commit didn't exist, would the sync PR +still merge cleanly with a green build?"* — if yes, it belongs in the +follow-up PR. + +## Mechanics + +### Worktree setup + +Keep the primary worktree on `main` and open a sibling worktree for the +follow-up so the primary stays clean: + +```pwsh +$syncPr = 220 +$syncBranch = 'upstream-sync/2026-06-04-091512-a3f1' # the sync PR's head — copy the exact name from the PR, branches are date+UTC-HHmmss+random-hex per run +$fixBranch = "dev/$env:USERNAME/sync-$syncPr-review-fixes" +$fixWorktree = "..\it-$syncPr-fix" + +# Sync branch tip must be local for the worktree to land at the right base +git fetch origin $syncBranch +git worktree add $fixWorktree -b $fixBranch "origin/$syncBranch" + +Push-Location $fixWorktree +# … apply fixes, commit, push from here … +Pop-Location +``` + +### Commit shape + +One focused commit per concern, mirroring the +[`copilot-pr-review-loop`](../../copilot-pr-review-loop/SKILL.md) +"one commit per round" rule: + +``` +fix(terminal): # e.g. code-path corrections from Copilot +fix(loc): # e.g. translation corrections +build(spelling): # e.g. allow/expect migrations +``` + +Don't bundle them into a single mega-commit — `git blame` should still +point each line back to the specific finding that justified it. + +### PR base + +```pwsh +gh pr create ` + --repo microsoft/intelligent-terminal ` + --base $syncBranch ` # NOT main + --head $fixBranch ` + --title "fix(sync-): " ` + --body-file .pr-body.txt +``` + +The base is the **sync branch**, not `main`. That way the follow-up +rides along with the sync PR and only its fix commits show in the diff. +If the sync PR merges to `main` before the follow-up does, rebase the +follow-up onto `main` (the merge strategy will collapse it cleanly). + +### Reply + resolve on the sync PR + +After opening the follow-up, walk every review thread on the sync PR +that was deferred to the follow-up and reply + resolve: + +> Addressed in # (branch +> `dev//sync--review-fixes` based on this PR's head). +> Will be merged together with this PR (or rebased onto `main` if this +> lands first). + +Use [`copilot-pr-review-loop/scripts/06-reply-and-resolve.ps1`](../../copilot-pr-review-loop/scripts/06-reply-and-resolve.ps1) +to do this in a loop: + +```pwsh +$ids = @('PRRT_kw...','PRRT_kw...',...) +$script = '.github/skills/copilot-pr-review-loop/scripts/06-reply-and-resolve.ps1' +$body = "Addressed in # ..." +foreach ($id in $ids) { pwsh -NoProfile -File $script -ThreadId $id -Body $body } +``` + +### When the sync PR merges first + +Rebase the follow-up onto `main`: + +```pwsh +cd $fixWorktree +git fetch origin main +git rebase origin/main +git push --force-with-lease +``` + +Then change the follow-up's base on GitHub: + +```pwsh +gh pr edit --repo microsoft/intelligent-terminal --base main +``` + +## Anti-patterns + +- ❌ Amending or rewriting the cherry-pick commits to "fix" a review + finding. Destroys the per-commit attribution that was the whole + reason for cherry-picking. +- ❌ Adding more than one extra commit to the sync PR. If a second + build-fix commit is needed, that's a smell — investigate whether the + first fix was incomplete or whether the second item really is a + follow-up. +- ❌ Opening the follow-up against `main` while the sync PR is still + unmerged. Diff fills up with the cherry-pick commits and the + reviewer can't see what changed. +- ❌ Resolving a sync-PR thread without leaving a pointer to the + follow-up PR. Future-you will have no record of where the fix went. + +## Cross-references + +- [SKILL.md § After-PR review handling](../SKILL.md#after-pr-review-handling--fix-in-pr-vs-follow-up-pr) — short version of this doc. +- [`copilot-pr-review-loop`](../../copilot-pr-review-loop/SKILL.md) — the skill that drives the follow-up PR through its own review rounds. +- [`03-conflict-triage.md`](./03-conflict-triage.md) — Tier 0/1/2/3/4 conflict resolution rubric (referenced when deciding whether a build failure belongs on the sync PR or a follow-up). diff --git a/.github/skills/upstream-sync/references/recovery-procedures.md b/.github/skills/upstream-sync/references/recovery-procedures.md new file mode 100644 index 000000000..871e87428 --- /dev/null +++ b/.github/skills/upstream-sync/references/recovery-procedures.md @@ -0,0 +1,70 @@ +# Recovery procedures (rarely needed) + +Three off-the-happy-path procedures that the agent does **not** drive +itself — they require explicit operator decisions. Linked from +[`SKILL.md`](../SKILL.md) so the main runbook stays focused on the +common case. + +## Direct-to-main (admin-only escape hatch) + +If the operator explicitly says "skip the PR, push straight to main": +after step 7 succeeds, skip step 8 and instead: + +```pwsh +git switch main +git merge --ff-only $branch +git push origin main +git branch -D $branch +``` + +Requires bypass-branch-protection rights. No PR, no review checkpoint — +use only for explicit admin runs. + +## First-time sync (seeding the watermark) + +If the fork has no `(cherry picked from commit )` trailer on +`origin/main` yet, `02-compute-pending.ps1` will throw. To seed, +commit an **empty seed commit** carrying the trailer in its message — +the same format `cherry-pick -x` would emit, written by hand exactly +once: + +```pwsh +git remote add upstream https://github.com/microsoft/terminal.git +git fetch upstream main --no-tags +git switch main +# Pick an upstream SHA you consider "already in this fork" (typically +# the commit the fork was originally branched from). Write the trailer +# EXACTLY as cherry-pick -x would so the next sync picks it up. +$seedSha = '<40-char-upstream-sha-already-in-fork>' +git commit --allow-empty -m "chore(upstream): seed upstream-sync watermark" -m "(cherry picked from commit $seedSha)" +git push origin main +``` + +That one merged trailer becomes the watermark. From then on, every +run extends it. No script needed — the bootstrap is one +`commit --allow-empty` ever. + +## Squash-merge recovery (don't do this, but if you did) + +The PR banner shouts "do not squash" and step 8 arms +`gh pr merge --rebase --auto` (the merge strategy is ultimately a +GitHub UI choice — there is no `-AutoMergeStrategy` flag). If a +reviewer squash-merges anyway, the outcome depends on what GitHub +preserved in the squash commit's body: + +- **Trailers preserved (common case).** The squash body concatenates + every cherry-picked message, so it contains MANY + `(cherry picked from commit )` trailers. + [`02-compute-pending.ps1`](../scripts/02-compute-pending.ps1) walks + these bottom-up (newest-first) so it picks the **last** trailer — + which corresponds to the newest upstream commit in the batch. The + watermark does NOT move backward, and **no recovery is needed**. + Per-commit attribution on `main` is gone (that's the squash cost), + but the next sync resumes correctly. +- **Trailers stripped or newest trailer removed.** If the reviewer + hand-edited the squash body and dropped the newest trailer (or all + of them), the watermark will resolve to an older SHA and the next + sync re-picks the gap. To prevent that, do a one-time + `commit --allow-empty` on `main` carrying the trailer for the + upstream HEAD that was actually merged — same shape as the first-time + seed above. diff --git a/.github/skills/upstream-sync/references/run-a-sync.md b/.github/skills/upstream-sync/references/run-a-sync.md new file mode 100644 index 000000000..b39a3bb3d --- /dev/null +++ b/.github/skills/upstream-sync/references/run-a-sync.md @@ -0,0 +1,376 @@ +# Run a sync — full procedure + +This is the orchestration the agent executes step-by-step. The high-level +flow and invariants are in [`SKILL.md`](../SKILL.md); this file is the +full runbook, with commands. **Do not skip steps.** Each step's "On +failure" path is mandatory. + +## 1. Preconditions (bail fast) + +```pwsh +# (a) working tree clean +git status --porcelain +# → empty? continue. nonempty? bail and tell the operator. + +# (b) on main, fast-forward +git switch main +git pull --ff-only + +# (c) no open stuck-lock +gh issue list -R microsoft/intelligent-terminal --label upstream-sync-stuck --state open --json number,title,url +# → []? continue. +# → nonempty? STOP. Surface the issue URL(s) to the operator and exit. +# The lock-clear signal is the human closing the issue, not a script. +``` + +## 2. Build a branch name + +```pwsh +$utc = (Get-Date).ToUniversalTime() +$date = $utc.ToString('yyyy-MM-dd') +$tstamp = $utc.ToString('HHmmss') +$rand = [guid]::NewGuid().ToString('N').Substring(0,4) +$branch = "upstream-sync/$date-$tstamp-$rand" +git switch -c $branch +``` + +The fresh suffix means re-runs never collide with stale local branches. + +## 3. Fetch upstream + +```pwsh +# Accept any equivalent form pointing at microsoft/terminal — HTTPS with +# or without .git, SSH form, etc. We only care about owner/repo identity. +$expectedOwnerRepo = 'microsoft/terminal' +$existing = git remote get-url upstream 2>$null +function Get-OwnerRepo([string] $url) { + if (-not $url) { return $null } + $u = $url.Trim() + # https://host/owner/repo[.git] + if ($u -match '^https?://[^/]+/([^/]+/[^/]+?)(?:\.git)?/?$') { return $Matches[1].ToLowerInvariant() } + # ssh://[user@]host[:port]/owner/repo[.git] + if ($u -match '^ssh://(?:[^@/]+@)?[^/:]+(?::\d+)?/([^/]+/[^/]+?)(?:\.git)?/?$') { return $Matches[1].ToLowerInvariant() } + # scp-style: [user@]host:owner/repo[.git] + if ($u -match '^(?:[^@:/]+@)?[^:/]+:([^/]+/[^/]+?)(?:\.git)?/?$') { return $Matches[1].ToLowerInvariant() } + return $null +} +if (-not $existing) { + git remote add upstream "https://github.com/$expectedOwnerRepo.git" +} elseif ((Get-OwnerRepo $existing) -ne $expectedOwnerRepo) { + throw "Remote 'upstream' points at '$existing' but this skill requires $expectedOwnerRepo. Aborting so we don't silently sync from the wrong fork." +} +git fetch upstream main --no-tags 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } +if ($LASTEXITCODE -ne 0) { throw "git fetch upstream main failed." } +$upstreamSha = (git rev-parse upstream/main).Trim() +``` + +## 4. Compute pending + +```pwsh +$pendingJson = pwsh -NoProfile -File .github/skills/upstream-sync/scripts/02-compute-pending.ps1 +if ($LASTEXITCODE -ne 0) { throw "02-compute-pending.ps1 exited $LASTEXITCODE." } +$pending = $pendingJson | ConvertFrom-Json +# $pending.from = last-synced watermark SHA +# $pending.to = upstream/main SHA +# $pending.pending = SHAs to pick, oldest-first +# $pending.dropped_pairs = [[picked, reverted], ...] — auto-cancelled +# $pending.skipped_empty = SHAs the script can statically prove are no-ops +``` + +If `$pending.pending.Count -eq 0`: nothing to do. Delete the branch, tell +the operator "fork is up to date with ", exit. + +## 5. Cherry-pick loop + +For each SHA in `$pending.pending`: + +```pwsh +$pickJson = pwsh -NoProfile -File .github/skills/upstream-sync/scripts/03-cherry-pick-one.ps1 -Sha $sha +if ($LASTEXITCODE -ne 0) { throw "03-cherry-pick-one.ps1 exited $LASTEXITCODE for $sha." } +$pick = $pickJson | ConvertFrom-Json +``` + +Branch on `$pick.status`: + +- `"picked"` — record `$sha` in `$picked`, continue. +- `"skipped-empty"` — record `$sha` in `$skippedEmpty`, continue. +- `"stuck"` — **stop the loop** and go to step 5a. + +### 5a. On stuck — open the Tier-3 issue and exit + +Push the branch, ensure the lock label exists, and file the issue with a +plain-markdown body. Closing the issue is the lock-clear signal — no +machine-readable metadata is needed. + +```pwsh +git push -u origin $branch 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } +if ($LASTEXITCODE -ne 0) { throw "git push of $branch failed." } + +# Idempotent — `gh label create` returns non-zero when the label already +# exists. We swallow that specific case but surface everything else so an +# auth/repo-typo failure doesn't get silently absorbed. +$labelOut = gh label create 'upstream-sync-stuck' ` + --color B60205 ` + --description 'Upstream sync paused — close to clear the lock' ` + -R microsoft/intelligent-terminal 2>&1 +$labelOutText = ($labelOut | Out-String) +if ($LASTEXITCODE -ne 0 -and $labelOutText -notmatch 'already exists') { + throw "gh label create failed: $labelOutText" +} + +$author = git log -1 --format='%an <%ae>' $sha +$subject = git log -1 --format='%s' $sha +$shortSha = $sha.Substring(0,9) +$hasConflicts = $pick.conflict_paths -and $pick.conflict_paths.Count -gt 0 +$pathLines = if ($hasConflicts) { + ($pick.conflict_paths | ForEach-Object { "- ``$_``" }) -join "`n" +} else { '_(none — cherry-pick failed without producing unmerged paths)_' } +$headline = if ($hasConflicts) { + 'Upstream sync stopped at a conflict that needs human judgment.' +} else { + 'Upstream sync stopped on a non-conflict cherry-pick failure (e.g. merge commit without `-m`, unsupported git option, hook failure).' +} +$body = @" +> [!CAUTION] +> $headline +> **Close this issue when resolved — that IS the lock-clear signal.** + +**Stuck on:** ``$sha`` — $subject +**Upstream commit:** https://github.com/microsoft/terminal/commit/$sha +**Author:** $author +**Sync branch:** ``$branch`` + +**Conflicting paths:** +$pathLines + +$(if ($pick.error) { "**Error:** ``$($pick.error)``" }) + +**To resume** (the script ran ``git cherry-pick --abort`` — the branch +has no in-progress conflict state to resolve directly): + +``````pwsh +git fetch origin +git switch $branch +git cherry-pick -x $sha # re-run to reproduce the conflict; -x keeps the trailer +# ... resolve conflicts ... +git add -A +git cherry-pick --continue +git push +# Open a PR against main, merge without squashing (the trailer must survive), +# then close this issue to clear the lock. +`````` + +See [references/03-conflict-triage.md](https://github.com/microsoft/intelligent-terminal/blob/main/.github/skills/upstream-sync/references/03-conflict-triage.md) for the resolution rubric. +"@ + +$bodyFile = New-TemporaryFile +try { + Set-Content -Encoding utf8 -Path $bodyFile -Value $body + $issueUrl = gh issue create -R microsoft/intelligent-terminal ` + --title "Upstream sync stuck at $shortSha" ` + --label upstream-sync-stuck ` + --body-file $bodyFile + if ($LASTEXITCODE -ne 0) { throw "gh issue create failed." } +} finally { + Remove-Item -Force -ErrorAction SilentlyContinue $bodyFile +} +``` + +Surface `$issueUrl` and `$branch` to the operator. The human: + +1. `git fetch origin && git switch $branch` to pick up the sync branch. +2. **Re-run the cherry-pick to reproduce the conflict** — `03-cherry-pick-one.ps1` + calls `git cherry-pick --abort` before returning `stuck`, so the branch + itself has no conflict markers / `MERGE_MSG`. Use the stuck SHA from + the issue body: `git cherry-pick -x ` (the `-x` preserves + the `(cherry picked from commit )` trailer — critical for the + watermark). +3. Resolve the conflicts, `git add` the resolutions, `git cherry-pick --continue`. +4. Push, open a PR against `main`, merge it (the `-x` trailer must + survive the merge — DO NOT squash). +5. Close the stuck issue. + +EXIT — do not attempt the build. +See [`03-conflict-triage.md`](./03-conflict-triage.md) for the Tier-0/1/2/3 +resolution rubric. + +## 6. (No commits picked? exit clean.) + +If the loop emitted only `empty` results (rare — every pending commit was +a no-op) there is nothing to build or finalize. Delete the branch, report +"sync completed with 0 commits picked (all empty)", exit. + +## 7. Build + +### Pre-build check — re-pin the PGO database if upstream bumped its version + +This fork keeps its own product version (`custom.props` → `VersionMajor.VersionMinor` += `0.1`), so `build/pgo/Terminal.PGO.props` **cannot** derive the PGO database +package version automatically — it carries a hard-coded pin to the upstream +Windows Terminal `Major.Minor`. When a sync pulls in an upstream version bump +(e.g. `1.25 → 1.26`), that pin must follow, or the build fails with: + +``` +Microsoft.PGO-Helpers.Cpp.targets : Error : Could not find matching PGO package. +``` + +(No `Microsoft.Internal.Windows.Terminal.PGODatabase` is published for `0.1` on the +TerminalDependencies feed — only upstream versions exist.) + +Before building, compare the two values — no script needed, just read both: + +``` +# upstream product version the picked commits may have advanced: +git show upstream/main:custom.props # → / + +# current pin in build/pgo/Terminal.PGO.props: +# / +``` + +If upstream's `Major.Minor` is newer than the pin, edit those two +`` / `` values in +`build/pgo/Terminal.PGO.props` to match, and land it as its own mechanical +commit on the sync branch (it's a deterministic, upstream-driven re-pin, not a +judgment fix, so it does not consume the step-7 build-fix budget): + +``` +git add build/pgo/Terminal.PGO.props +git commit -m "chore(upstream): re-pin PGO database to ." +``` + +If the values already match, do nothing and proceed to the build. + +### Run the build + +```pwsh +$buildJson = pwsh -NoProfile -File .github/skills/upstream-sync/scripts/04-try-build.ps1 +if ($LASTEXITCODE -ne 0) { throw "04-try-build.ps1 exited $LASTEXITCODE before producing a JSON result." } +$build = $buildJson | ConvertFrom-Json +``` + +Build runs BEFORE finalize on purpose — if it fails, the fix lands as ONE +extra commit on the same branch so it ends up in the same PR. + +Branch on `$build.kind`: + +- `"build-ok"` — continue to step 8. +- `"build-failed"` — try ONE focused build-fix: + 1. Read `$build.log_tail`. Decide if the failure is small, mechanical, and + clearly caused by the cherry-pick batch (e.g. duplicate `.resw` key + from a fork-local commit colliding with an upstream rename, missing + `#include` resolved by the upstream batch, etc.). + 2. If yes — fix it, `git add` only the affected files, commit with + subject `chore(upstream): fix build after upstream sync` (carry the + `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` + trailer if you authored the fix), re-run `04-try-build.ps1`, go back + to start of step 7. + 3. If no (large change, scope creep, requires design decisions) — go to step 7a. + 4. If the fix attempt itself fails to compile — go to step 7a. Do NOT + pile up multiple fix commits; the one-fix-per-PR rule is policy. +- `"build-inconclusive"` — go to step 7a (timeout, treated as a real failure + unless the operator explicitly opts out). + +### 7a. On build failure — surface and exit + +The fix attempt didn't land or the failure is too big for a one-commit +fix. Don't open an issue. Surface the failure to the operator and exit — +they'll either fix the underlying defect on `main` and re-run, or push a +manual fix commit on top of `$branch` and continue to step 8 by hand. + +```pwsh +[Console]::Error.WriteLine("Build failed after upstream sync:") +[Console]::Error.WriteLine(" branch: $branch") +[Console]::Error.WriteLine(" kind: $($build.kind)") +[Console]::Error.WriteLine(" exit_code: $($build.exit_code)") +[Console]::Error.WriteLine(" log_path: $($build.log_path)") +[Console]::Error.WriteLine("--- log tail ---") +[Console]::Error.WriteLine($build.log_tail) +exit 1 +``` + +## 8. Finalize the PR + +The agent pushes the branch and opens the PR directly with `git` and +`gh`. There is no wrapper script — there's nothing here that needs more +than `gh pr create` plus a body string composed from `$picked` / +`$build` / `$pending`. + +Compose the body: + +```pwsh +$banner = @" +> [!WARNING] +> **DO NOT squash-merge this PR.** Squashing collapses every cherry-picked +> upstream commit into one, destroying per-commit attribution, original +> author dates, the ``(cherry picked from commit )`` trailers that the +> NEXT upstream sync uses as its watermark, and ``git bisect`` resolution. +> Merge with **"Rebase and merge"** (preferred — flat history, all +> $($picked.Count) commit(s) land individually) or **"Create a merge commit"**. + +> [!NOTE] +> **Review-fix policy.** Only build-blocking fixes belong on this branch +> as **one** focused extra commit. All other Copilot / human review +> feedback goes into a **follow-up PR** based on this PR's head. See +> [``.github/skills/upstream-sync/references/follow-up-pr.md``](https://github.com/microsoft/intelligent-terminal/blob/main/.github/skills/upstream-sync/references/follow-up-pr.md). + +--- + +"@ + +$summary = @" +Syncs **$($picked.Count)** commit(s) from microsoft/terminal up to ``$($upstreamSha.Substring(0,9))``. + +## Picked +$( ($picked | ForEach-Object { "- ``$($_.Substring(0,9))`` $(git log -1 --format='%s' $_)" }) -join "`n" ) + +$(if ($pending.dropped_pairs.Count) { @" +## Auto-dropped revert pairs +$( ($pending.dropped_pairs | ForEach-Object { "- ``$($_[0].Substring(0,9))`` ↔ ``$($_[1].Substring(0,9))`` (cancel out within range)" }) -join "`n" ) +"@ }) + +$(if ($skippedEmpty.Count) { @" +## Skipped (empty / already applied) +$( ($skippedEmpty | ForEach-Object { "- ``$($_.Substring(0,9))``" }) -join "`n" ) +"@ }) + +## Build +``04-try-build.ps1`` → ``$($build.kind)`` (exit $($build.exit_code), $($build.duration_ms) ms). Log: ``$($build.log_path)`` (gitignored). +"@ + +$bodyFile = New-TemporaryFile +[System.IO.File]::WriteAllText($bodyFile, ($banner + $summary), (New-Object System.Text.UTF8Encoding($false))) +``` + +Push and create the PR. `gh pr create` on Windows can occasionally fail +with "Head sha can't be blank" right after a push — retry up to 3× with +a short delay: + +```pwsh +git push -u origin $branch 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } +if ($LASTEXITCODE -ne 0) { throw "git push of $branch failed." } + +$short = $upstreamSha.Substring(0,9) +$title = "chore(upstream): sync microsoft/terminal up to $short" + +$prUrl = $null +for ($attempt = 1; $attempt -le 3; $attempt++) { + $prOut = gh pr create -R microsoft/intelligent-terminal ` + --base main --head $branch --title $title --body-file $bodyFile 2>&1 + $prExit = $LASTEXITCODE + $prText = ($prOut | Out-String).Trim() + if ($prExit -eq 0) { + $urlLine = ($prOut | Where-Object { "$_" -match '^https://github.com/' } | Select-Object -Last 1) + if ($urlLine) { $prUrl = "$urlLine".Trim(); break } + } + Write-Warning "gh pr create attempt $attempt failed (exit $prExit). Full output:`n$prText" + Start-Sleep -Seconds 5 +} +Remove-Item -LiteralPath $bodyFile -Force +if (-not $prUrl) { throw "gh pr create did not return a URL after 3 attempts." } + +# Optional: arm GitHub auto-merge with rebase (NEVER squash). +# Skip this if the operator wants to merge by hand. +gh pr merge -R microsoft/intelligent-terminal $prUrl --rebase --auto --delete-branch +``` + +Surface `$prUrl` to the operator. Done. diff --git a/.github/skills/upstream-sync/scripts/02-compute-pending.ps1 b/.github/skills/upstream-sync/scripts/02-compute-pending.ps1 new file mode 100644 index 000000000..6cb5d9af4 --- /dev/null +++ b/.github/skills/upstream-sync/scripts/02-compute-pending.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Compute the pending cherry-pick list with revert-pair detection. + +.DESCRIPTION + Reads the last-synced upstream watermark from origin/main's + `cherry picked from commit ` trailers, lists commits patch-id-aware + between watermark and upstream/main (oldest first), detects revert pairs + within the range and drops them, detects upstream-empty commits and drops + them, and emits the final pending list as JSON. + + Step 3's inline recipe must have been run first (we need upstream/main + in this clone). + +.OUTPUTS + JSON object on stdout: + { + "from": "", + "to": "", + "pending": [ "", ... ], + "dropped_pairs": [ ["", ""], ... ], + "skipped_empty": [ "", ... ] + } +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# --- Inlined helpers (single-use) ---------- + +function Resolve-FullCommitSha { + param([Parameter(Mandatory)] [string] $Sha) + $full = (git rev-parse "$Sha^{commit}" 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $full) { throw "Could not resolve commit SHA '$Sha'." } + return $full.Trim() +} + +function Get-LastSyncedUpstreamSha { + # Walks origin/main newest-first, returns the first `(cherry picked from + # commit )` trailer whose target is reachable from upstream/main. + # Capped at 5000 most-recent commits — well beyond any realistic + # weekly-sync deployment. Resolves the trailer SHA to its full 40-char + # form before the ancestry check so an abbreviated/ambiguous trailer + # doesn't silently skip an otherwise-valid watermark candidate. + # + # A single commit body can carry multiple `(cherry picked from + # commit )` trailers (e.g. a squash-merge of several upstream + # picks). Git appends new trailers, so the LAST match in the body is + # the most-recent upstream commit — scan all matches and check them + # newest-first to avoid moving the watermark backward. + $commits = @(git log origin/main --max-count=5000 --grep='cherry picked from commit' --format='%H' 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git log on origin/main failed while deriving last-synced SHA." } + foreach ($c in $commits) { + # %B comes back as string[] (one element per line) — join to a + # single string so [regex]::Matches doesn't coerce the array to + # the literal "System.String[]". + $body = (git log -1 --format='%B' $c 2>$null) -join "`n" + $allMatches = [regex]::Matches($body, '\(cherry picked from commit ([0-9a-fA-F]{7,40})\)') + if ($allMatches.Count -eq 0) { continue } + # Walk trailers bottom-up (newest-first within the same commit). + for ($i = $allMatches.Count - 1; $i -ge 0; $i--) { + $fullSha = $null + try { $fullSha = Resolve-FullCommitSha $allMatches[$i].Groups[1].Value } catch { continue } + $null = git merge-base --is-ancestor $fullSha upstream/main 2>$null + if ($LASTEXITCODE -eq 0) { return $fullSha } + } + } + throw "No 'cherry picked from commit' trailer pointing at upstream/main was found on origin/main (scanned the most recent 5000 commits). The very first sync needs an operator to seed the watermark commit (see SKILL.md - 'First-time sync')." +} + +function Get-PendingUpstreamShas { + # Patch-id-aware oldest-first list of upstream/main commits not yet on + # origin/main. `--cherry-pick` drops picked-then-reverted pairs by patch + # ID. The optional ancestry filter drops any SHA already reachable from + # the watermark, so the walk stays bounded even on a long-untouched fork. + param([string] $Since) + $out = @(git log --cherry-pick --right-only --no-merges --format='%H' --reverse 'origin/main...upstream/main' 2>$null) + if ($LASTEXITCODE -ne 0) { throw "git log --cherry-pick failed while computing pending list." } + $shas = @($out | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[0-9a-fA-F]{40}$' }) + if ($Since) { + $filtered = New-Object 'System.Collections.Generic.List[string]' + foreach ($sha in $shas) { + # git merge-base --is-ancestor $sha $Since: + # exit 0 = $sha is reachable from $Since (the watermark) — + # i.e. it was already cherry-picked at or before + # the trailer we read; drop it. + # exit 1 = $sha is NOT reachable from $Since — it is newer + # than the watermark; keep it for picking. + # exit >1 = real error (bad object, missing ref, shallow + # clone — pending list cannot be trusted). + $null = git merge-base --is-ancestor $sha $Since 2>$null + switch ($LASTEXITCODE) { + 0 { } # at/before watermark, drop + 1 { [void] $filtered.Add($sha) } + default { throw "git merge-base --is-ancestor failed (exit $LASTEXITCODE) on $sha vs $Since; pending list cannot be trusted." } + } + } + $shas = @($filtered) + } + # PowerShell streams arrays. Use `,$shas` would wrap into a single + # pipeline object; the caller's `@(...)` then receives a 1-element + # array containing the nested array, breaking the subsequent + # foreach. Just emit the array — `@(Get-PendingUpstreamShas …)` + # rebuilds a flat string[] from the stream. + return $shas +} + +# --- Main logic ------------------------------------------------------------ + +$from = Get-LastSyncedUpstreamSha +$to = (git rev-parse upstream/main).Trim() +if ($LASTEXITCODE -ne 0) { throw "git rev-parse upstream/main failed." } + +if ($from -eq $to) { + @{ from = $from; to = $to; pending = @(); dropped_pairs = @(); skipped_empty = @() } | ConvertTo-Json -Depth 5 + return +} + +$all = @(Get-PendingUpstreamShas -Since $from) + +# Build sha -> first-line / body lookup (one git invocation per commit is +# fine at typical batch sizes). +$info = @{} +foreach ($sha in $all) { + $subj = git log -1 --format='%s' $sha 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git log --format=%s failed for $sha : $subj" } + # %B is multi-line — flatten to a single string so `-match` later + # operates on the body rather than an array of lines (where -match + # filters elements instead of returning a single bool + $Matches). + $bodyArr = git log -1 --format='%B' $sha 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git log --format=%B failed for $sha : $bodyArr" } + $body = $bodyArr -join "`n" + $info[$sha] = @{ subject = $subj; body = $body } +} + +# Detect revert pairs WITHIN the range. `--cherry-pick` already handled +# pairs that crossed the watermark boundary; this catches same-batch +# original+revert that wasn't on origin/main yet at computation time. +$dropped = New-Object 'System.Collections.Generic.HashSet[string]' +$pairs = @() +foreach ($sha in $all) { + if ($dropped.Contains($sha)) { continue } + $body = $info[$sha].body + $subj = $info[$sha].subject + + $targetSha = $null + if ($body -match 'This reverts commit ([0-9a-fA-F]{40})\b') { + $targetSha = $Matches[1] + } elseif ($subj -match '^Revert "') { + # Fallback: match by quoted subject, but only against commits + # earlier in the oldest-first range, and only when the match is + # unique (otherwise leave the revert as a normal pick rather + # than risk dropping the wrong commit). + $origSubject = $subj -replace '^Revert "', '' -replace '"\s*$', '' -replace '"\.?\s*$','' + $prefix = @() + foreach ($candidateSha in $all) { + if ($candidateSha -eq $sha) { break } + $prefix += $candidateSha + } + $candidates = @($prefix | Where-Object { + $info[$_].subject -eq $origSubject -and -not $dropped.Contains($_) + }) + if ($candidates.Count -eq 1) { $targetSha = $candidates[0] } + } + + if ($targetSha -and $info.ContainsKey($targetSha) -and -not $dropped.Contains($targetSha)) { + [void] $dropped.Add($sha) + [void] $dropped.Add($targetSha) + $pairs += ,@($targetSha, $sha) + } +} + +# Detect upstream-empty (no files touched). +$empty = @() +foreach ($sha in $all) { + if ($dropped.Contains($sha)) { continue } + $files = git diff-tree --no-commit-id --name-only -r $sha 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git diff-tree failed for $sha : $files" } + if (-not $files) { + $empty += $sha + [void] $dropped.Add($sha) + } +} + +$pending = $all | Where-Object { -not $dropped.Contains($_) } + +[ordered] @{ + from = $from + to = $to + pending = @($pending) + dropped_pairs = @($pairs) + skipped_empty = @($empty) +} | ConvertTo-Json -Depth 5 diff --git a/.github/skills/upstream-sync/scripts/03-cherry-pick-one.ps1 b/.github/skills/upstream-sync/scripts/03-cherry-pick-one.ps1 new file mode 100644 index 000000000..32ae9127f --- /dev/null +++ b/.github/skills/upstream-sync/scripts/03-cherry-pick-one.ps1 @@ -0,0 +1,262 @@ +<# +.SYNOPSIS + Cherry-pick one upstream commit with Tier-0/Tier-1 auto-resolution. + +.DESCRIPTION + Runs `git cherry-pick -x `. On conflict, attempts Tier-0 (known + take-{upstream,ours} files from 03-known-conflicts.md), then Tier-1 (empty + after staging → skip). Anything else returns 'stuck' and leaves the + cherry-pick aborted. + +.PARAMETER Sha + The upstream commit to pick. + +.OUTPUTS + JSON status object on stdout: + { "sha": "...", + "status": "picked" | "skipped-empty" | "stuck", + "tier0_paths": ["..."], // files Tier-0 auto-resolved (may be empty) + "conflict_paths": ["..."], // unmerged files when status = 'stuck' + "error": "..." // human-readable explanation when status = 'stuck'; empty otherwise + } +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Sha +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-KnownConflicts { + $md = Join-Path (Split-Path $PSScriptRoot -Parent) 'references/03-known-conflicts.md' + if (-not (Test-Path $md)) { return @() } + $lines = Get-Content -LiteralPath $md + $entries = @() + $current = $null + foreach ($l in $lines) { + if ($l -match '^##\s+`([^`]+)`\s*$') { + if ($current) { $entries += $current } + $current = @{ Path = $Matches[1]; Strategy = $null } + } elseif ($current -and $l -match '^\*\*Strategy:\*\*\s+`(take-upstream|take-ours|union)`') { + $current.Strategy = $Matches[1] + } + } + if ($current) { $entries += $current } + return $entries | Where-Object { $_.Strategy } +} + +function Get-ConflictPaths { + # core.quotepath=off keeps non-ASCII paths in raw UTF-8 so Tier-0 + # path matching against 03-known-conflicts.md works without C-quoting. + # PowerShell already streams external-command stdout as string[] + # (one element per line), so iterate directly — no -split needed. + $u = git -c core.quotepath=off diff --name-only --diff-filter=U 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git diff --name-only --diff-filter=U failed: $u" } + return @($u | ForEach-Object { "$_".TrimEnd("`r") } | Where-Object { $_ }) +} + +$result = [ordered] @{ + sha = $Sha + status = 'unknown' + tier0_paths = @() + conflict_paths = @() + error = '' +} + +# Capture upstream's author AND committer identity + dates so the +# resulting commit is per-commit identical (modulo SHA + GPG signature) +# to the upstream original. cherry-pick preserves author by default; +# we pin both sides via env for symmetry and clarity. Each iteration of +# the pick loop runs this lookup against the specific upstream SHA, so +# every commit gets its own original dates — never a single "run time" +# fixed timestamp. +# +# Note: this does NOT reproduce GPG signatures (we don't hold upstream's +# keys). The fork doesn't enforce signed commits, so "committed by X but +# unsigned" is acceptable. +$fullSha = (git rev-parse $Sha).Trim() +if ($LASTEXITCODE -ne 0) { throw "Could not resolve upstream commit $Sha." } +$prePickHead = (git rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0) { throw "Could not record pre-pick HEAD before cherry-picking $Sha." } + +# Use ASCII unit separator (\x1F) as the delimiter — that byte is forbidden +# in git ident strings, so it cannot collide with a tab inside an author +# or committer name (git allows tabs in idents). Validate field count +# defensively before binding to GIT_* env vars. +$us = [char]0x1F +$info = (git log -1 --format="%an${us}%ae${us}%aI${us}%cn${us}%ce${us}%cI" $fullSha) -split $us +if ($LASTEXITCODE -ne 0) { throw "git log failed resolving author/committer for $fullSha (exit $LASTEXITCODE)." } +if ($info.Count -ne 6) { throw "Unexpected field count parsing author/committer for ${fullSha}: got $($info.Count) (expected 6)." } + +# Capture caller's existing GIT_AUTHOR_* / GIT_COMMITTER_* env so the finally +# block can restore them. Higher-level automation may have set these +# intentionally — we don't want to silently wipe them out after our pick. +$prevEnv = @{ + GIT_AUTHOR_NAME = $env:GIT_AUTHOR_NAME + GIT_AUTHOR_EMAIL = $env:GIT_AUTHOR_EMAIL + GIT_AUTHOR_DATE = $env:GIT_AUTHOR_DATE + GIT_COMMITTER_NAME = $env:GIT_COMMITTER_NAME + GIT_COMMITTER_EMAIL = $env:GIT_COMMITTER_EMAIL + GIT_COMMITTER_DATE = $env:GIT_COMMITTER_DATE +} + +$env:GIT_AUTHOR_NAME = $info[0] +$env:GIT_AUTHOR_EMAIL = $info[1] +$env:GIT_AUTHOR_DATE = $info[2] +$env:GIT_COMMITTER_NAME = $info[3] +$env:GIT_COMMITTER_EMAIL = $info[4] +$env:GIT_COMMITTER_DATE = $info[5] + +try { + +# Attempt the pick. Capture the stream so we can both forward it to our +# own stderr (for live operator visibility) AND surface the trailing +# context in the stuck result for non-conflict failures (e.g. merge +# commit without -m, hook failures, unsupported git version). +$pickOutput = git cherry-pick --keep-redundant-commits -x $fullSha 2>&1 +$pickCode = $LASTEXITCODE +$pickOutput | ForEach-Object { [Console]::Error.WriteLine($_) } +$pickTail = (@($pickOutput) | Select-Object -Last 20 | Out-String).Trim() + +if ($pickCode -eq 0) { + # Tier-1 check: did we just create an empty commit (allowed by --keep-redundant-commits)? + $changedOut = git diff-tree --no-commit-id --name-only -r HEAD 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git diff-tree failed after cherry-pick: $(($changedOut | Out-String).Trim())" } + $changed = @($changedOut | Where-Object { $_ -and $_.Trim() }) + if (-not $changed) { + $msgOut = git log -1 --format='%B' HEAD 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git log --format='%B' HEAD failed after cherry-pick: $(($msgOut | Out-String).Trim())" } + $commitMessage = @($msgOut) -join "`n" + $expectedFooter = "\(cherry picked from commit $([regex]::Escape($fullSha))\)" + if ($commitMessage -notmatch $expectedFooter) { + throw "Refusing to reset --hard ${prePickHead}: HEAD does not contain the cherry-pick footer for $fullSha. Investigate before retrying." + } + git reset --hard $prePickHead | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to reset empty cherry-pick back to $prePickHead." } + $result.status = 'skipped-empty' + } else { + $result.status = 'picked' + } + $result | ConvertTo-Json -Compress + return +} + +# Conflict — or some other non-zero exit (e.g. trying to cherry-pick a +# merge commit without -m, hook failures, etc.). For *real* conflicts +# Get-ConflictPaths returns the unmerged paths and we try Tier-0; for +# non-conflict failures the list is empty and we must NOT fall through +# to `cherry-pick --continue` (which would explode), nor leave the repo +# mid-cherry-pick. Abort and surface a controlled stuck result. +$conflicts = Get-ConflictPaths +if (-not $conflicts -or $conflicts.Count -eq 0) { + Write-Warning "git cherry-pick exited $pickCode with no conflict paths — likely a non-conflict failure (e.g. merge commit without -m, unsupported git option). Aborting and marking stuck.`nLast git output:`n$pickTail" + # If cherry-pick failed *before* starting (bad args / unsupported + # option / old git), --abort itself fails with "no cherry-pick or + # revert in progress". That's benign here — there is nothing to + # clean up. Capture and surface the abort outcome in the stuck + # result instead of throwing, so the operator still gets the JSON. + $abortOut = git cherry-pick --abort 2>&1 + $abortCode = $LASTEXITCODE + $abortNote = '' + if ($abortCode -ne 0) { + $abortText = ($abortOut | Out-String).Trim() + if ($abortText -match 'no cherry-pick or revert in progress') { + $abortNote = ' (no in-progress cherry-pick to abort — failed before starting)' + } else { + $abortNote = " (cherry-pick --abort also failed: exit $abortCode; $abortText)" + } + } + $result.status = 'stuck' + $result.conflict_paths = @() + $result.error = "git cherry-pick exited $pickCode with no conflict paths$abortNote. Last output: $pickTail" + $result | ConvertTo-Json -Compress + return +} +$result.conflict_paths = $conflicts +$known = Get-KnownConflicts +$unhandled = @() +foreach ($p in $conflicts) { + $e = $known | Where-Object { $_.Path -eq $p } | Select-Object -First 1 + if (-not $e) { $unhandled += $p; continue } + $checkoutOk = $true + switch ($e.Strategy) { + 'take-upstream' { + git checkout --theirs -- $p 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { $checkoutOk = $false } + } + 'take-ours' { + git checkout --ours -- $p 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { $checkoutOk = $false } + } + 'union' { + Write-Warning "union strategy not implemented yet for $p" + $unhandled += $p + continue + } + } + if (-not $checkoutOk) { + Write-Warning "git checkout for Tier-0 strategy '$($e.Strategy)' failed on $p; falling back to stuck." + $unhandled += $p + continue + } + git add -- $p 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "git add failed for Tier-0 path $p; falling back to stuck." + $unhandled += $p + continue + } + $result.tier0_paths += $p +} + +if ($unhandled.Count -gt 0) { + git cherry-pick --abort | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git cherry-pick --abort failed after unhandled conflicts; repository may still be mid-cherry-pick." } + $result.status = 'stuck' + $result.conflict_paths = $unhandled + $result.error = "Cherry-pick of $Sha hit conflicts not covered by Tier-0 known-conflicts rules in $($unhandled.Count) path(s)." + $result | ConvertTo-Json -Compress + return +} + +# All conflicts handled by Tier-0; continue the pick (preserve original message). +git cherry-pick --continue --no-edit 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } +if ($LASTEXITCODE -ne 0) { + # Could still be empty after Tier-0. + $stagedOut = git diff --cached --name-only 2>&1 + if ($LASTEXITCODE -ne 0) { throw "git diff --cached --name-only failed after cherry-pick --continue: $(($stagedOut | Out-String).Trim())" } + $staged = @($stagedOut | Where-Object { $_ -and $_.Trim() }) + if (-not $staged) { + git cherry-pick --skip | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git cherry-pick --skip failed after an empty Tier-0 continuation." } + $result.status = 'skipped-empty' + $result | ConvertTo-Json -Compress + return + } + git cherry-pick --abort | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git cherry-pick --abort failed after Tier-0 continuation failed; repository may still be mid-cherry-pick." } + $result.status = 'stuck' + # Capture any unmerged files left after the failed --continue so the + # Tier-3 issue body lists *something* concrete. + $stillUnmerged = @(git diff --name-only --diff-filter=U 2>$null) + if ($stillUnmerged.Count -gt 0) { $result.conflict_paths = $stillUnmerged } + $result.error = "git cherry-pick --continue failed after Tier-0 auto-resolution for $Sha; the residual conflict is not mechanically resolvable." + $result | ConvertTo-Json -Compress + return +} + +$result.status = 'picked' +$result | ConvertTo-Json -Compress + +} finally { + # Restore — not delete — so callers that intentionally set these + # env vars don't see them wiped after our pick. + foreach ($k in $prevEnv.Keys) { + $prev = $prevEnv[$k] + if ($null -eq $prev) { + Remove-Item "Env:$k" -ErrorAction SilentlyContinue + } else { + Set-Item "Env:$k" -Value $prev + } + } +} diff --git a/.github/skills/upstream-sync/scripts/04-try-build.ps1 b/.github/skills/upstream-sync/scripts/04-try-build.ps1 new file mode 100644 index 000000000..4705ae11c --- /dev/null +++ b/.github/skills/upstream-sync/scripts/04-try-build.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Try-build. Runs the configured build command in a razzle environment and + captures the result. Default `-BuildCommand`: `tools\razzle.cmd && bz no_clean` + (the script wraps it with `cmd /c "..."` internally — pass only the + cmd.exe command string, not the wrapper). + +.DESCRIPTION + Run AFTER cherry-picking (03) and BEFORE finalizing the PR (SKILL.md + step 8). If the build fails, the agent commits a fix on the same sync + branch so it lands in the same PR — that is why try-build is step 04, + not the last step. + +.PARAMETER BuildCommand + Override the default build command. Must be a string the cmd.exe shell + can execute (razzle is cmd-based; PowerShell-keyword chaining like 'if' + won't work — keep it cmd-shell-friendly with &&). + +.PARAMETER TimeoutMinutes + Wall-clock cap. Default 45. On timeout, the build is killed and the + result is classified as 'build-inconclusive'. + +.PARAMETER LogDir + Where to write the full build log. Default: + `Generated Files/upstream-sync//build-logs/` (gitignored). + +.OUTPUTS + JSON on stdout: + { + "kind": "build-ok" | "build-failed" | "build-inconclusive", + "exit_code": , + "duration_ms": , + "command": "", + "log_path": "", + "log_tail": "" + } +#> +[CmdletBinding()] +param( + [string] $BuildCommand = 'tools\razzle.cmd && bz no_clean', + [int] $TimeoutMinutes = 45, + [string] $LogDir +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# --- Inlined helpers (single-use) ---------- + +function Get-RepoRoot { + $r = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0) { throw "Not inside a git repo." } + return $r.Trim() +} + +function Get-GeneratedDir { + # Per-skill, per-day artifact dir under the repo's gitignored + # `Generated Files/` root (matches the workspace convention; the repo's + # top-level .gitignore has `**/Generated Files/`). + param([string] $Sub) + $root = Get-RepoRoot + $date = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') + $path = Join-Path $root "Generated Files/upstream-sync/$date" + if ($Sub) { $path = Join-Path $path $Sub } + if (-not (Test-Path -LiteralPath $path)) { + New-Item -ItemType Directory -Path $path -Force | Out-Null + } + return $path +} + +function ConvertTo-RepoRelativePath { + # Normalize to forward-slash, repo-relative form so callers can safely + # embed it in committed text without leaking machine-specific drive + # letters / user dirs. + param([Parameter(Mandatory)] [string] $Path) + $root = ((Get-RepoRoot) -replace '\\','/').TrimEnd('/') + $abs = $Path -replace '\\','/' + if ($abs.Equals($root, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "ConvertTo-RepoRelativePath: refusing to return empty (path == repo root): $Path" + } + $prefix = "$root/" + if ($abs.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $abs.Substring($prefix.Length) + } + throw "ConvertTo-RepoRelativePath: '$Path' is not under repo root '$root'." +} + +# --- Main logic ------------------------------------------------------------ + +try { + $root = Get-RepoRoot + if (-not $LogDir) { + $LogDir = Get-GeneratedDir -Sub 'build-logs' + } elseif (-not [System.IO.Path]::IsPathRooted($LogDir)) { + # Treat caller-supplied relative paths as repo-relative so the + # later ConvertTo-RepoRelativePath call succeeds. + $LogDir = Join-Path $root $LogDir + } + if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } + + $stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHHmmss.fff') + $suffix = [guid]::NewGuid().ToString('N').Substring(0,4) + $logPath = Join-Path $LogDir "$stamp-$suffix.log" + + # WorkingDirectory is already $root via ProcessStartInfo below, so + # we don't need a `cd /d "" &&` prefix — that nested quoting + # is brittle under cmd.exe (paths with spaces/quotes break it). + $cmdLine = "/c $BuildCommand" + $started = Get-Date + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $shell = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' } + $psi.FileName = $shell + $psi.Arguments = $cmdLine + $psi.WorkingDirectory = $root + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $psi + $baseWriter = $null + $writer = $null + + try { + # Tee stdout/stderr to the log file as the build runs. The synchronized + # wrapper serializes concurrent stdout/stderr DataReceived callbacks. + # Open the log + register handlers *before* Start() so the earliest + # build banner (razzle preamble, MSBuild startup) cannot be lost. + $baseWriter = [System.IO.StreamWriter]::new($logPath, $false, [System.Text.UTF8Encoding]::new($false)) + $writer = [System.IO.TextWriter]::Synchronized($baseWriter) + $proc.add_OutputDataReceived({ param($s,$e) if ($null -ne $e.Data) { $writer.WriteLine($e.Data) } }) + $proc.add_ErrorDataReceived({ param($s,$e) if ($null -ne $e.Data) { $writer.WriteLine($e.Data) } }) + [void]$proc.Start() + $proc.BeginOutputReadLine() + $proc.BeginErrorReadLine() + + $timeoutMs = $TimeoutMinutes * 60 * 1000 + $exited = $proc.WaitForExit($timeoutMs) + $kind = $null + $exitCode = $null + + if (-not $exited) { + try { $proc.Kill($true) } catch { } + $proc.WaitForExit() + $kind = 'build-inconclusive' + $exitCode = -1 + } else { + # WaitForExit(timeout) can return before async output callbacks drain. + # The parameterless wait completes only after redirected output events finish. + $proc.WaitForExit() + $exitCode = $proc.ExitCode + $kind = if ($exitCode -eq 0) { 'build-ok' } else { 'build-failed' } + } + } + finally { + if ($writer) { try { $writer.Flush() } catch {} + try { $writer.Close() } catch {} } + if ($baseWriter) { try { $baseWriter.Dispose() } catch {} } + if ($proc) { try { $proc.Dispose() } catch {} } + } + + $ended = Get-Date + $durationMs = [int]($ended - $started).TotalMilliseconds + + $tailLines = if (Test-Path -LiteralPath $logPath) { + @(Get-Content -LiteralPath $logPath -Tail 200) -join "`n" + } else { '' } + + $logPathForReport = ConvertTo-RepoRelativePath $logPath # fails fast if outside repo — log_path is a public contract field + + [ordered] @{ + kind = $kind + exit_code = $exitCode + duration_ms = $durationMs + command = $BuildCommand + log_path = $logPathForReport + log_tail = $tailLines + } | ConvertTo-Json -Depth 4 +} +catch { + # $ErrorActionPreference='Stop' turns Write-Error into a terminating + # error, which would shadow the original exception. Emit diagnostics + # straight to stderr instead so we preserve the original record on + # the rethrow below. + [Console]::Error.WriteLine($_.Exception.Message) + [Console]::Error.WriteLine($_.ScriptStackTrace) + throw +}