Skip to content

feat(terminal): add xterm.js as a selectable backend - #83

Merged
cpendery (cpendery) merged 14 commits into
microsoft:mainfrom
aymanbagabas:feat/xtermjs-backend
Aug 25, 2026
Merged

cpendery (cpendery) merged 14 commits into
microsoft:mainfrom
aymanbagabas:feat/xtermjs-backend

Conversation

@aymanbagabas

@aymanbagabas Ayman Bagabas (aymanbagabas) commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

tui-test can already run a session on alacritty, ghostty, or rio. This adds the emulator behind VS Code's terminal as a fourth, so a suite can be run against what a large share of users are actually looking at. A test that passes on one emulator and fails on another is telling you something real about the program under test.

$ tui-test open --backend xtermjs
$ tui-test run --backend xtermjs -- vim notes.md

Selectable everywhere the other backends are: the CLI's --backend, and both the Python and Node bindings.

No Node at runtime

@xterm/headless and the unicode11 addon are vendored and evaluated into a QuickJS context per session, so the backend depends on nothing installed on the machine. shim.js is our own code; the bundles are dropped in unchanged at pinned versions.

They are committed rather than fetched, so a clone builds without a network and there is no build script. .github/scripts/vendor-xtermjs.sh --latest bumps pinned.json to the newest releases and re-fetches in one command, and .github/workflows/xtermjs-update.yml runs that same script weekly and opens a pull request when either package has a newer release.

The grid crosses that boundary packed rather than a cell at a time. Reading an 80x30 screen through the per-cell getters is 2,400 calls with ten property reads each, which costs milliseconds per poll; the shim flattens a row span into one string and one integer array, so a whole screen crosses as two values and this side decodes rather than traverses.

What the shim has to supply

Five things the Emulator contract asks for are missing from, or wrong in, the headless bundle's public surface. The shim supplies them rather than the Rust side pretending they are absent:

Contract How
Window title onTitleChange, with windowOptions.pushTitle/popTitle enabling the CSI 22/23 t stack the bundle implements but leaves off by default
Cursor visibility coreService.isCursorHidden
Cursor shape coreService.decPrivateModes.cursorStyle, which is absent until DECSCUSR sets one and so reads as a block until then
OSC 4/10/11/12 set, query, and reset parser.registerOscHandler, answering out of the same reply queue the terminal's own replies use, so answers keep the order they were asked in
SGR 59 xterm.js stores the reset as an explicit white, indistinguishable from a real 58;2;255;255;255 once written; the shim clears it where the parser sets it, while the two are still apart

A colour reply has to echo the terminator its query used, and an OSC handler is handed its payload but not that terminator. The shim scans the incoming bytes for it, carrying state between calls because a PTY read splits wherever it likes — including between the two bytes of an ST — and keying what it finds by OSC code so a title arriving between two queries cannot put the wrong terminator on a reply.

Verified against a real session rather than only in unit tests. The same probe run on both backends, querying the background, setting it, resetting it, and querying once more with each terminator:

alacritty:  <E>]11;rgb:0000/0000/0000<BEL> | <E>]11;rgb:6565/4343/2121<BEL> | <E>]11;rgb:0000/0000/0000<BEL> | ST:<E>]11;rgb:0000/0000/0000<E>\
xtermjs:    <E>]11;rgb:0000/0000/0000<BEL> | <E>]11;rgb:6565/4343/2121<BEL> | <E>]11;rgb:0000/0000/0000<BEL> | ST:<E>]11;rgb:0000/0000/0000<E>\

Byte-identical, terminator included.

Conformance

The backend passes the conformance suite, which is what makes swapping emulators safe, with one declared exception: xterm.js records a cell's underline colour only when that cell also has an underline style, so SGR 58 on its own — or a colour that outlives an SGR 24 — is not readable back off the cell.

Nothing renders differently, since a cell with no underline draws no underline colour either way. What is lost is the colour surviving in the cell vocabulary. I confirmed this against the bundle rather than inferring it: the cell reports isAttributeDefault(), and the colour is gone from the line's extended attributes while remaining in the current SGR state, so it is xterm.js's per-cell storage rather than anything this mapping does.

The suite grew a Divergences declaration for it, so the exception is a visible claim a reviewer can check rather than a quietly failing case or a backend that skips conformance altogether. It is deliberately narrow: a field earns a place there only when the limitation is in the emulator itself.

Also here

Backend::ALL becomes a slice, so each backend adds one line rather than doubling the number of cfg-gated definitions, and the list of names in a parse error is derived from it instead of repeated in prose that can drift out of step.

Notes for review

  • The backend is compiled in by default for the CLI and both bindings, matching how ghostty is wired, so --backend xtermjs works out of the box. Measured cost: 229.6 KiB of embedded JavaScript, and a release binary going 9,208,800 -> 10,506,064 bytes, so about 1.30 MB once QuickJS is compiled in. Happy to put it behind an off-by-default feature instead if the size matters more than the availability.
  • The xtermjs crate feature itself is off by default, so cargo build -p tui-test-rs is unaffected.
  • Conformance for the optional backends does not run in CI today, since cargo test --workspace uses default features while only clippy passes --all-features. That predates this PR and applies to ghostty and rio equally, but it does mean these 53 cases are green locally rather than on a runner. Worth a follow-up.
  • Unicode 11 is pinned deliberately, not incidentally: it is the only version that agrees with alacritty on every emoji width case measured. The table is in assets/xtermjs/README.md.

Review

Four reviewers went over this (shim correctness, Rust/FFI, architecture, supply chain). Three real divergences came back, all now fixed in the second commit and all reproduced before being touched:

  • A split ST was answered with BEL. The scan that recovers a query's terminator waited for the \ of an ST, but xterm.js ends an OSC on the ESC before it, so a PTY read splitting between those two bytes answered the wrong terminator. Recording it on the ESC keeps the two in step.
  • The terminator queue grew without bound. It recorded every OSC sequence while only colour ones claim what it records, so a shell that retitles the window on each prompt leaked an entry per prompt. Both the shim and supply-chain reviewers found this independently.
  • OSC 4;1x wrote to slot 1. parseInt took the leading digit of an index that was not a number.

The first two are contract behaviour rather than quirks of this backend, so they went into the conformance suite: both fail on xterm.js without the fix and pass on alacritty, which is what makes them worth asserting of every backend.

Also fixed: a usize -> u32 scrollback cast that wrapped a deep request into a shallow one, and the vendored licence, which reproduced only one of the two packages' notices.

Verified clean by review, worth recording:

  • Bundle provenance is byte-for-byte. Both vendored files match what npm publishes for @xterm/headless@6.0.0 and @xterm/addon-unicode11@0.9.0, reproducible with .github/scripts/vendor-xtermjs.sh.
  • No path from terminal bytes to evaluated code. Only the three static assets are ever evaluated; PTY bytes reach JS as a byte array.

Considered and not done, happy to be overruled:

  • A QuickJS memory cap. The one unbounded growth path was the queue above, and it is fixed; what remains is bounded by scrollback. A cap would need a number I have no evidence for, and one set too low breaks a legitimately deep session.
  • Bell tracking. xterm.js exposes onBell, but build_with_bells falls through to build exactly as ghostty's does. Worth doing for both backends together rather than making them differ.

Review round two

Ten threads from cpendery (@cpendery), all addressed and resolved. Two of them landed the other way from the review; both are called out below with the reasoning, and I am happy to reopen either.

Two were real bugs, both reproduced before being touched:

  • SGR 59 swallowed a genuine white underline. xterm.js stores the reset as an explicit RGB #ffffff, and every public getter then reports exactly what a real 58;2;255;255;255 reports, so reading the cell could only guess — and the old heuristic guessed "reset", meaning no cell could ever have a white underline. The shim now clears it where the parser sets it, while the two are still distinguishable. Verified the new conformance case fails on xterm.js with the old heuristic restored.
  • JS exceptions were suppressed. process and resize discarded their Result entirely, so a throw froze the grid and every later read answered from it as fact. Faults are now recorded and reported by the next operation, through a new Emulator::fault checked in Engine::with_session — before the operation runs, since a wrong answer off a stale grid is worse than a failure. This is not hypothetical: the shim reads several private xterm.js fields, so a bundle upgrade moving one is exactly how it fires.

Two went against the review, so the reasoning is here rather than buried in a thread:

  • Vendoring. I tried de-vendoring in 0fc1fbb and reverted it in 8ff376c: a build script put npm and a network on the path of anyone building from a clone, which is a heavier cost than keeping 264 KB out of git. The bundles stay committed, and .github/scripts/vendor-xtermjs.sh --latest bumps and re-fetches in one command.

  • How a stale pin is noticed. A weekly pull request rather than a release-time check. A release gate fires on someone else's schedule — an upstream publish turns a ready tag into a failed release — and a version-string comparison cannot see the failure that actually matters: the shim reads several private xterm.js fields, so a release moving one is the real risk. The weekly job runs the conformance suite against the new bundles before opening the PR, which nothing else does, since CI builds with default features. It opens one only on a real new release, decided from the vendored bytes rather than pinned.json alone.

    I also tried and reverted a release-time provenance check (bcb3c65 → 50b5d7e): it re-asserted something already settled when the files were written, for a mismatch that could not reach the artifact, and put a network dependency on the release path. Nothing xterm.js-specific runs at release time now.

The rest:

  • Added conformance cases for xterm.js's special cases: the underline reset above, and the isAttributeDefault() fast path where one call stands in for nineteen getters — that shortcut previously had a comment claiming it was verified with nothing checking it.
  • Trimmed assets/xtermjs/README.md from 114 lines to 74, and moved the deferred-addons section to xterm.js backend: revisit addon-unicode-graphemes and addon-clipboard (OSC 52) #175.
  • Dropped the Backend::ALL comment.
  • Answered the raster.rs question (recent clippy lints, isolated in 9a68c1c) and the OSC 10-12 one (fixed earlier in 8f6520b, with two conformance cases).

Every new conformance case is a case, not a snapshot of current behaviour: each was verified to fail on xterm.js without its fix and pass on alacritty, ghostty, and rio.

Testing

53 conformance cases against the xterm.js backend, plus the full workspace suite (514 tests), cargo clippy --workspace --all-targets --all-features -- -D warnings, the no-default-features clippy pass, fmt, and the Python and Node binding suites. cargo publish --dry-run packages 94 files and compiles from the tarball.

Also verified that a clone builds with no network and no Node: cargo build -p tui-test-rs --features xtermjs succeeds with npm off PATH, which is the property the vendored bundles exist to preserve.

Driven end to end through a real PTY as well: title, cursor, colour assertions, and SVG screenshots all agree with alacritty.

@cpendery cpendery (cpendery) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good, I think we can put alacritty as the default & include the other backends as features. For the client libraries, I think it makes sense to include xtermjs & alacritty by default

Comment thread crates/shell-use/assets/xterm/README.md Outdated
tui-test can already run a session on alacritty or ghostty, and this adds
the emulator behind VS Code's terminal as a third. A suite that passes on
one and fails on another is telling you something real about the program
under test, and xterm.js is what a large share of users are actually
looking at.

`@xterm/headless` and the unicode11 addon are vendored and evaluated into
a QuickJS context per session, so the backend adds no dependency on Node
or on anything installed on the machine. The grid crosses that boundary
packed rather than cell by cell: reading an 80x30 screen a getter at a
time is 2,400 calls with ten property reads each, so the shim flattens a
row span into one string and one integer array and this side decodes it.

Four things the contract asks for are not in the headless bundle's public
surface, and the shim supplies them rather than the Rust side pretending
they are absent: the window title, cursor visibility and shape, and the
`OSC` colour sequences. Colour answers are pushed onto the same queue the
terminal's own replies use, so they keep the order they were asked in.
A query's terminator has to be echoed and an OSC handler is not told which
one ended the sequence, so the incoming bytes are scanned for it, keyed by
OSC code so a title arriving between two queries cannot misroute a reply.

The backend passes the conformance suite, which is what makes swapping
emulators safe, with one declared exception: xterm.js records a cell's
underline colour only when that cell also has an underline style. Nothing
renders differently, and declaring it where the backend opts in keeps the
exception visible instead of turning it into a quietly failing case.

`Backend::ALL` becomes a slice so that each optional backend adds one line
rather than doubling the length-carrying definitions the next one has to
spell out, and the list of names in a parse error is derived from it
instead of being repeated in prose that can drift.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas Ayman Bagabas (aymanbagabas) changed the title feat: add xterm.js as a selectable terminal backend feat(terminal): add xterm.js as a selectable backend Aug 20, 2026
A colour query answered with the wrong terminator leaves a program that
reads until the one it sent waiting for something that never comes. The
scan that recovers the terminator waited for the `\` of an `ST`, but the
parser it feeds ends an OSC on the `ESC` before it, so a read that split
between the two bytes answered `BEL` to an `ST` query. Recording the
terminator on the `ESC` is what keeps the two in step.

The scan also recorded every sequence while only colour ones claim what it
records, so a shell that retitles the window on each prompt left an entry
per prompt for the life of the session. Only answered codes are recorded
now.

`OSC 4;1x` named no slot, but `parseInt` read the leading digit and wrote
to slot 1.

The first two are contract behaviour rather than quirks of this backend,
so they are conformance cases: both fail on xterm.js without this change
and pass on alacritty, which is what makes them worth asserting of every
backend rather than fixing quietly in one.

A profile can also ask for deeper scrollback than the count crossing into
JS holds, where the cast wrapped a deep request round to a shallow one.

The vendored licence now reproduces both packages' notices: `@xterm/headless`
ships none and declares MIT in its manifest, while the addon carries its own,
and the two differ. Their hashes are recorded alongside the commands that
reproduce them, so the bytes can be checked rather than trusted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
The bundles are copied out of the published tarballs unchanged, so a fresh
fetch has to reproduce them byte for byte. Checking that leaves no room for
one to arrive edited or from somewhere other than the release it claims,
which a hash recorded in the same commit as the bytes cannot rule out.

It runs only on commits that touch the vendored files, since that is the
only way they can change, and so costs nothing on the PRs that do not.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas

Copy link
Copy Markdown
Member Author

I think we can put alacritty as the default & include the other backends as features.

That's already how the crate is set up — tui-test-rs has default = [], so a plain cargo add tui-test-rs gets Alacritty only, and both ghostty and xtermjs are opt-in features. Nothing changed there in this PR.

For the client libraries, I think it makes sense to include xtermjs & alacritty by default

This PR does the additive half: xtermjs is now enabled for the CLI and both the Python and Node native packages, so --backend xtermjs and backend="xtermjs" work out of the box.

The other half — dropping Ghostty from the published bindings — I've deliberately left out, and I think it deserves its own PR. It's a change to what shipped packages contain rather than an addition, it touches a backend this PR doesn't otherwise go near, and it has a release-notes consequence for anyone already passing backend="ghostty". Folding it in here would put two unrelated stories in one diff.

It's also worth doing on its own merits, and the argument is stronger than just build times: Ghostty needs Zig 0.16 to build, and its upstream Zig build is the reason we don't publish Windows ARM64 artifacts at all today. xterm.js needs no external toolchain, so bindings carrying Alacritty + xterm.js could publish everywhere the CLI does. That looks like the real motivation for your suggestion, and it's a good one — I'd just rather land it where it can be reviewed as that change.

Happy to open it as a follow-up right after this, or to pull it in here if you'd rather have them together.

@aymanbagabas
Ayman Bagabas (aymanbagabas) marked this pull request as ready for review August 20, 2026 17:20
@cpendery

Copy link
Copy Markdown
Member

I think we can put alacritty as the default & include the other backends as features.

That's already how the crate is set up — tui-test-rs has default = [], so a plain cargo add tui-test-rs gets Alacritty only, and both ghostty and xtermjs are opt-in features. Nothing changed there in this PR.

For the client libraries, I think it makes sense to include xtermjs & alacritty by default

This PR does the additive half: xtermjs is now enabled for the CLI and both the Python and Node native packages, so --backend xtermjs and backend="xtermjs" work out of the box.

The other half — dropping Ghostty from the published bindings — I've deliberately left out, and I think it deserves its own PR. It's a change to what shipped packages contain rather than an addition, it touches a backend this PR doesn't otherwise go near, and it has a release-notes consequence for anyone already passing backend="ghostty". Folding it in here would put two unrelated stories in one diff.

It's also worth doing on its own merits, and the argument is stronger than just build times: Ghostty needs Zig 0.16 to build, and its upstream Zig build is the reason we don't publish Windows ARM64 artifacts at all today. xterm.js needs no external toolchain, so bindings carrying Alacritty + xterm.js could publish everywhere the CLI does. That looks like the real motivation for your suggestion, and it's a good one — I'd just rather land it where it can be reviewed as that change.

Happy to open it as a follow-up right after this, or to pull it in here if you'd rather have them together.

based on our offline sync, I think we want to include all backends in the bindings.

@aymanbagabas
Ayman Bagabas (aymanbagabas) marked this pull request as draft August 20, 2026 17:26
`assets/xterm` reads as the X11 terminal rather than the JavaScript one, so
it becomes `assets/xtermjs`, matching the backend and the feature. The
bundles keep the filenames npm publishes, since those are what the tarballs
contain.

A vendored bundle has nothing watching it: no lockfile, no dependency bot,
and no build that fails when it falls behind, so it can sit years out of
date without anyone noticing. A weekly job now compares both packages
against npm and opens a pull request when either has a newer release.

It runs conformance itself rather than leaving it to the pull request,
because nothing else would: CI runs the workspace with default features, so
the xterm.js cases never run there, and a pull request opened with
`GITHUB_TOKEN` starts no workflow of its own. The shim reaches for several
things xterm.js does not expose publicly, which is exactly what a release
can move, so an update that breaks one should say so rather than look
clean. The suite cannot see the emoji width table, which is the reason the
unicode11 addon is pinned where it is, so the pull request asks for that by
hand.

The pinned versions were written down in the workflow and again in prose,
which a bot would have to rewrite in both. They now live in `pinned.json`
alone, so nothing can claim a version the bytes are not.

Also recorded are the two addons deliberately not used yet, since the
reasons are easy to lose: unicode-graphemes is still experimental and
disagrees with alacritty on two of the measured widths, and the clipboard
addon implements an `OSC 52` that no backend supports today. Adding the
latter now would make xterm.js alone answer a query the others ignore,
which is the kind of divergence conformance exists to prevent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Rust 1.98 turns a constant chunk size into a `chunks_exact` lint, which
`-D warnings` makes an error, so this is a build failure rather than a
suggestion. `as_chunks` says the same thing and hands back arrays, so the
length is known at the type level instead of being a runtime invariant the
indexing below relies on. Stable since 1.88, comfortably under the 1.90
this workspace asks for, and the remainder is discarded either way.

The raster walk is untouched by the branch this lands on and fails the same
way on main; it is here because the clippy step compiles both and cannot
go green while either stands.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
They are 235 KB against the 65 KB of JavaScript the repository has of its
own, so counting them would make a Rust project read as mostly JavaScript
and rank it above Python.

`linguist-vendored` rather than the `linguist-detectable=false` the install
scripts use: those are ours and merely uninteresting, these are somebody
else's, and the vendored mark also collapses a minified bundle in diffs
instead of asking a reviewer to scroll past it.

`shim.js` is left alone. It is our code and counting it is correct.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>

@cpendery cpendery (cpendery) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some comments on some fixes, overall work looks really good.

nit: can we make sure the ci is actually running the xtermjs conformance tests? cargo test --workspace uses the default features so it'll skip the xterm tests even if clippy compiles them

Comment thread .github/workflows/xtermjs-update.yml
Comment thread crates/tui-test/assets/xtermjs/README.md Outdated
Comment thread crates/tui-test/assets/xtermjs/README.md
Comment thread crates/tui-test/src/terminal/backend.rs Outdated
Comment thread crates/tui-test/src/render/raster.rs
Comment thread crates/tui-test/assets/xtermjs/shim.js Outdated
Comment thread crates/tui-test/assets/xtermjs/shim.js Outdated
Comment thread crates/tui-test/assets/xtermjs/shim.js
Comment thread crates/tui-test/src/terminal/xtermjs.rs
The dynamic colors are a single list and the OSC code says only where in
it to start, so `OSC 10;a;b` sets the foreground and then the background.
Every parameter was being applied to the color the code named, so the last
one won and `OSC 10;#010203;#040506` left the foreground `#040506` and the
background untouched. Queries had the same shape: `OSC 10;?;?` answered
`OSC 10` twice with the foreground rather than answering the foreground
and then the background.

The list continues past the cursor into pointer, Tektronix, and highlight
colors this terminal has no notion of. Those positions are stepped over
rather than folded onto the last slot, so a long sequence sets what it
names and nothing else.

Both cases are contract behaviour rather than quirks of this backend, so
they are conformance cases: both fail on xterm.js without this change and
pass on alacritty, which already reads the list this way.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Main added the rio backend against the same extension points this branch
adds xterm.js to, so every conflict is the two backends landing in one
list. Both are kept everywhere: the `Backend` enum and its `as_str`,
`build`, `build_with_bells`, and `FromStr` arms, the CLI `BackendArg`,
the napi enum, and the TypeScript and Python backend literals.

`Backend::ALL` keeps this branch's slice form. Main spelled it as four
cfg-gated fixed-size arrays, one per ghostty/rio combination, which a
third optional backend would have doubled to eight; the slice takes one
line per backend instead. `expected()` stays derived from it rather than
repeating the same combinations a second time.

Cargo.lock is regenerated rather than hand-merged, and the README
conflicts take main's reorganized prose with xterm.js folded in.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Fetch the bundles rather than committing them (microsoft#83). `build.rs` pulls the
versions `pinned.json` names from npm into `OUT_DIR`, and prefers a copy
sitting next to it when there is one, which is the case in a published
crate: `cargo package` carries the bundles, so consuming a release needs
neither Node nor a network. Only building from a checkout does.

Keep `SGR 59` apart from a real white underline. xterm.js stores the
reset as an explicit RGB #ffffff, indistinguishable through the public
getters from `58;2;255;255;255`, so reading the cell could only guess;
the previous guess dropped genuine white underlines. Correct it where
the parser sets it, while the two are still distinguishable.

Report JS exceptions instead of returning each call's default in their
place. A throw left the grid frozen and every later read answered from
it as if it were fact. The first fault is recorded and reported by the
next operation, since the reader thread has nobody to return one to.

Also: drop the weekly update workflow for a release-time check that the
pinned versions are the latest published ones; drop the `Backend::ALL`
comment; shorten the vendoring notes and move the deferred-addons
section to microsoft#175; add conformance cases for the underline reset and for
the all-default cell fast path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Reverts the build-time npm fetch. A clone builds without a network again,
and there is no build script: `include_str!` reads the committed bundles
directly, and `Cargo.toml` no longer needs an explicit `include` list to
get them into the published crate, because git tracks them again.

`.github/scripts/vendor-xtermjs.sh` stays as the way to update them, and
grows `--latest` to bump `pinned.json` to the newest releases before
fetching. `.github/workflows/vendored.yml` comes back with it: it
re-fetches the pinned versions whenever these files change and fails if
what is committed is not byte for byte what npm publishes, which is what
makes a vendored blob checkable rather than trusted.

The release check that the pinned versions are the latest published ones
stays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Re-vendoring is a manual step, so nothing in CI should be reaching for
the tarballs. `.github/scripts/vendor-xtermjs.sh` is the only thing that
rewrites these files.

The release check stays: it compares version strings and never touches
the bundles, which is what keeps a release from shipping on a stale pin.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A vendored bundle has nothing watching it, and the release-time version
check answered that at the worst moment: it turned someone else's
publish into a release blocker, on a tag that was otherwise ready.

`.github/workflows/xtermjs-update.yml` runs the vendoring script weekly,
opens a pull request when either package has a newer release, and runs
the xterm.js conformance suite against the new bundles first, since
nothing else does — CI builds with default features, so those cases
never run there.

It decides there is something to do from the vendored files themselves
rather than from `pinned.json` alone, so a package republished at the
same version is still caught, and scopes that to the three files the
script writes so an unrelated README edit cannot open a spurious pull
request.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A sanity check on what is about to be published: the release `verify`
job re-fetches the versions `pinned.json` names and fails if the
committed bundles are not byte for byte what npm publishes.

This is provenance rather than freshness, which is what makes it safe to
block a release on. It can only fail on something under our control — an
edited blob, or a pin bumped without re-vendoring — so an upstream
release cannot turn a ready tag into a failed one. Falling behind is the
weekly job's business, and it opens a pull request instead.

Both failure modes were exercised against the real packages, and the
message names which file, which version it claims, and the script that
restores it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
This reverts commit bcb3c65.

The check re-asserted at release time something already settled when the
files were written, for a mismatch that cannot reach the artifact:
`pinned.json` is metadata, and the committed bytes are what compile in.
A pin disagreeing with them is a wrong note, not a wrong build.

The failure modes it named are covered elsewhere or not real. A
hand-edited bundle is a diff against a minified file in code review. A
pin bumped without re-vendoring is what the script and the weekly job
exist to prevent, and the weekly job corrects it on its next run.

Against that it put a network dependency and a Node toolchain on the
release path, and could block a ready tag on a metadata inconsistency —
the same objection that removed the freshness gate, only rarer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@cpendery
cpendery (cpendery) merged commit fe945ba into microsoft:main Aug 25, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants