CI/CD
Pre-Release
Verter is pre-release software. APIs may change between releases — see the API Stability document.
Verter uses GitHub Actions for continuous integration, testing, and releases.
Workflows
CI (ci.yml)
Runs on push to main and on pull requests. The detect-changes job hands the changed-file list to scripts/ci-impact.mjs, the one selection authority, which composes every job gate from two halves: its path filters (PATH_FILTERS, one per lane) own every non-crate input, and the crate-graph classifier owns crates/**. A consumer lane (the native binding, the wasm32 artifact, the debug and release LSP builds and everything behind them, the compile contracts, BF2, the provider suites, Svelte conformance, the sfc-projection verifiers) runs when a changed crate is in the transitive dependency closure of that lane's root crates as cargo metadata --all-features reports it, so a change to a crate an artifact does not link no longer rebuilds it. Two kinds of gate come out: a subsystem gate ("the native binding changed") drives that subsystem's suites, and an artifact gate ("the native artifact is needed") is the OR of every consumer's gate and drives the producer, so a consumer is never scheduled behind a producer that was not (transport equivalence, which compares both artifacts, demands both).
Selection fails closed. Workspace manifests, the lockfile, the toolchain pin, .cargo/ and proc-macro crates force every impact-bearing lane on. ci.yml, the local actions, the classifier itself, and any file that no crate, no path filter and the explicit inert list owns force every lane on. No directory is inert by assumption: schemas/** and test-corpora/**, which Rust tests read, are owned by the rust filter, and a file under an inert tree that a lane reads (docs/package.json, docs/audit-footprint/api-reference.md) is owned by that lane's filter. The inert list is data, scripts/ci-inert-paths.json, and every entry states why no ci.yml job reads it. An inert entry cannot narrow what a lane selects (ownership wins), so editing the list runs the classifier's own tests rather than every lane. Entries name what has been reviewed, not whole families of future trees: a framework vertical lists its contract data, and an executable spec or a new product added beside it later still fails the audit until it is classified. A framework vertical's entries live in their own file, scripts/ci-inert-paths.d/framework-<name>.json, never in the shared list, so verticals that land together never edit the same file; a tests/framework-<name>/ entry anywhere else fails the audit, naming the file it belongs in. A change that touches only inert paths — prose, the kernel and framework architecture products — runs no lane, and CI Required passes in about a minute. A workflow other than ci.yml runs on its own trigger; its file selects only the lanes whose self-tests read it. Every run, detect-changes first audits the selection against the tree (node scripts/ci-impact.mjs --audit): a change that adds a path no crate, filter or inert entry owns, or that moves a file a filter names, fails there in a minute, naming the path — not after the fallback ran every lane, and not in a later change that happens to run the classifier's tests.
Filter globs are plain positive globs (** spans path segments, * stays in one); an exclusion is the filter's own exclude list. The filters used to be paths-filter YAML in ci.yml, where the js filter's '!packages/playground/**' line, under the default some quantifier, matched every file outside the playground: every pull request, docs included, ran the JavaScript and native lanes, and no path ever reached the fail-closed fallback. scripts/ci-impact.test.mjs holds the selection to its contract: outcome tests (a docs-only change runs nothing, a playground change does not run the js lane, a verter_compiler change runs the sfc-projection verifiers), every tracked file owned by a crate, a filter or the inert list, every filter glob matching a tracked file, every lane root a workspace crate, and every consumer gate implying its producer's artifact gate over every input combination. Lane roots are derived from the lanes' own inventories where one exists (the provider selectors, the compile-contract owners). node scripts/ci-impact.mjs --range origin/main..HEAD prints the gates CI would publish for a local branch.
A merge is not tested twice. On a push to main, detect-changes first runs scripts/release-proof.mjs --landed: when the pushed commit is the squash of a pull request, and a successful CI run of THAT pull request recorded testing exactly the squash's tree, every lane is off and CI Required passes on that proof, so the commit on main keeps its green tick without re-running the pull request's lanes. The record is a ci-tested notice (pull=<number> tree=<sha>) that detect-changes leaves on every pull request run: a pull_request run tests refs/pull/N/merge, not the head, and GitHub forgets which pull request a run belonged to once it merges. Another pull request sharing the head tested another base, so its run never counts. A force-push, a direct push, a run that tested another tree, or any commit the proof cannot vouch for runs CI as usual.
- Rust changes (
crates/**,Cargo.toml, etc.) --rust-fmt,rust-clippy,rust-build-configs, one provider-freerust-test-buildarchive consumed byrust-test; the requiredrust-observesmoke (node scripts/observe-lane.mjs smoke) builds a small host-backed example withsemantic-observe, verifies generated output and a stored compile audit record, and checks that disabling audit preserves the output and stores no record. It builds no test harnesses or full-suite fixture metadata. The full instrumented Rust suite runs nightly. The closure-gatedrust-providers-livejob (the serial tsgo libtest lane; the tsserver lane runs intsserver-nightly.yml), the standalonecompiler-contractslane, BF2 and the Svelte conformance lane run when their root crates' closure is touched - Proto changes --
proto-fmtregenerates with the pinnedbuf/oxfmttools and byte-compares the complete committed TypeScript binding tree - JS changes (
packages/**,package.json, etc.) --js-build-test, plus the native binding's workspace suites; its script self-tests runtest:scripts:ci, which omits the tests another required lane already owns (the ARH dirty twins inarchitecture-health, the lane self-tests in their lanes, the sfc-projection verifiers insfc-projection, the aggregate test in its own step) - Native binding changes (verter_napi's crate closure plus any JS change) --
native-testruns the native-backed package suites against the default release binding, which reports unavailable audit capture, so its capture-aware specs assert the unavailable outcome;native-observe-testbuilds the binding withsemantic-observe(pnpm --filter @verter/native build:observe:debug), fails unlesscaptureAvailability()is"Available", and runs@verter/nativeand@verter/typeinfoso the record-reading cases still run - Script changes (
scripts/**) --js-build-testfor the scripts' self-tests; a script a lane executes also selects that lane - SFC projection changes (
tests/sfc-projection/**,scripts/sfc-projection/**, the lockfile, and the verter_compiler / verter_language / verter_session / verter_validation_probe crate closure) --sfc-projectionrunspnpm run test:sfc-projectionon two shards: each node's protocol runscargo testand typechecks its probes on both TypeScript engines. Shard 1 runs the node verifiers itsSFC_SHARD_1title pattern names; shard 2 runs the whole chain with that pattern skipped, so a new or renamed verifier runs in shard 2 - Docs and other CI-inert paths only -- no lane;
CI Requiredpasses - WASM changes (verter_wasm's crate closure plus
packages/wasm/**,packages/browser-host/**) --wasm-build; on a pull request it skipswasm-opt(the optimised module is built and checked on pushes tomain, by the dailyNightlyplayground build and byrelease.yml) - Browser-host changes (
packages/browser-host/**,scripts/browser-host-gate*.mjs,tests/browser-host/**, plus the wasm filter) --browser-hostrunsnode scripts/browser-host-gate.mjs --skip-buildafterwasm-buildin the official Playwright container, pinned to the version resolved inpnpm-lock.yamlwith its browsers and system libraries preinstalled: wasm32 census, native same-fixture probe, Chromium/Firefox/WebKit workers; missing browsers, exports, tests or empty operation arrays fail - JetBrains plugin changes (
extensions/jetbrains/**,scripts/jetbrains-gate*.mjs,tests/jetbrains-product/**) --jetbrains-pluginrunsnode scripts/jetbrains-gate.mjson a JDK 21 runner: the pinned Gradle build's real JVM tests (including JBT1H real-IDE capture hooks), the IntelliJ plugin verifier (every failure level, pinned WebStorm build only) and installable packaging, failing closed on missing JDK/SDK, build output or test evidence. The comparison recorder (packages/dx-harness/jetbrains) is typechecked and unit-tested on thedx-harness-hermeticlane; that job is the Node side of the jetbrains-product profile.
Most jobs run independently. Core nextest alone consumes the shared archive. Caches are restored on every ref and saved only from main (save-if on rust-cache, a restore/save split for Gradle): the repository's cache is capped at 10 GB, and a pull request's saves, readable by that pull request alone, used to evict the main entries every other pull request restores. The VS Code host and sccache's per-compile entries are the exceptions. Real tsgo provider tests run serially with libtest in their own job so the third-party engine has explicit initialization and lifecycle ownership; that job and the editor-contracts job run every engine or editor even after an earlier one failed and decide with a final verdict step, so one run reports every regression. The provider runner also prints PROVIDER TEST TIME for each serial libtest result and writes target/provider-timings/{tsserver,tsgo}.json (under CARGO_TARGET_DIR when set). CI uploads these as provider-test-timings (provider-test-timings-tsserver in the tsserver nightly). Durations measure the stdout stream edges from the flushed test name to its terminal status; Cargo compilation and failure-detail output are excluded. Pipe batching can round very short tests down to zero, so timings are advisory and never change a test verdict or timeout. The Svelte compiler benchmark's measurement (the 1.10x wall-time and RSS fences) runs nightly; the PR-side svelte-compiler-benchmark job proves only the benchmark contract, the corpus against the official goldens and the manifest's oracle proof. Compile-fail fixtures run through node scripts/compile-contracts.mjs, outside Rust test discovery; Svelte conformance is also a dedicated Cargo/libtest job.
Compiler Contracts uses the existing compiler_contracts change gate on PRs: the ten fixture-owner crates and the workspace runner crates are lane roots, so their transitive dependencies select the lane too. The excluded verter_compile_contracts_session_observe runner (including its separate Cargo.lock) is owned by the path filter, alongside the runner script, self-tests, generated-artifact inputs and nightly workflow. Workspace Cargo inputs, the toolchain, .cargo/ and ci.yml select the lane through the shared fail-closed rules. Unrelated editor, package and prose changes skip it; CI Required accepts that skip and still rejects any failed or cancelled matrix leg.
The full suite is partitioned into four independent runner jobs. Each job executes its configurations serially, keeping their original Cargo feature sets separate:
| Group | Configurations |
|---|---|
core | audit, CSS syntax, identity, language, semantic, session-query, type-runtime, workspace; session test-support variants |
compiler | compiler-default, compiler-bench, session soak-support; generated Svelte freshness and isolated default-feature compiler build/lib tests |
session | session-default |
session-observe | excluded semantic-observe runner |
Run one group locally with node scripts/compile-contracts.mjs --group core; omitting --group runs all fourteen configurations. --list-groups prints the matrix inventory. The workflow self-test checks both PR and nightly matrices against it. Matrix failure cancellation is disabled so every group reports.
Every group has its own target cache; within it, outer Cargo runners and all trybuild projects share target/compiler-contracts. Separate GitHub runners prevent concurrent writers to the generated fixture workspaces. Immutable cache keys include OS/architecture, group, toolchain/configuration, manifests, both workspace and excluded-runner lockfiles, and the checkout revision; restore prefixes reuse compatible earlier builds while source-only changes can refresh the saved cache. Both workflows save only on main, which nightly also warms. The sccache wrapper is explicitly enabled with incremental compilation off and a common namespace across groups and workflows. These jobs disable dev/test debug-symbol emission, including inside trybuild's generated workspaces, while retaining debug assertions and every original feature configuration. Only the compiler group needs the pnpm install and the isolated compiler build/tests. Both workflows disable setup-node's automatic package-manager cache detection; only the compiler group explicitly enables pnpm caching. The plain production cargo build remains necessary: the compiler's test targets enable its test-support dev-dependency self-edge. Each invocation logs COMPILE CONTRACT TIME for its fetches and configurations plus a group total, and each matrix leg uploads its log even on failure.
tsserver nightly (tsserver-nightly.yml)
tsgo is the main platform and pull request CI tests it in full; tsserver is the legacy engine. Every tsserver-specific lane runs once a day on main (04:30 UTC, and on workflow_dispatch) in this workflow instead of on every pull request: the serial tsserver provider lane (provider-ci.mjs run tsserver), the @verter/typescript-plugin suites (native-free, including the carrier-routing guard, and the native-backed src/tsc specs after a release native build), the VS Code E2E routes on tsserver (@tsserver and editor-owned-project@editor-tsserver, from printCiMatrix.js --tier=tsserver; pull requests run --tier=pr, every other required route) and the editor-neutral LSP contract's tsserver route (VERTER_EDITOR_NEUTRAL_ROUTES=tsserver; pull requests run tsgo,shared-tsgo). Its workflow and job names are stable, and the tsserver nightly result job fails when any lane did not succeed. The DX harness still runs on pull requests: its pinned tsserver is the reference baseline verter-lsp is compared against, not the engine under test.
Benchmark (benchmark.yml)
Triggered via /benchmark PR comment or manual dispatch. Compares Verter compilation performance against Vue's official compiler.
Perf Gate (perf-gate.yml)
Pull requests run the perf harness correctness specs and corpus manifest check. Scheduled and manual comparisons build separate candidate and baseline native packages and binaries, then measure them interleaved on the same runner. The scheduled baseline is the candidate's immediate parent; manual dispatch may supply baseline-ref. Both refs resolve once before building. Resolved checkout identities belong in the run artifact, never in the committed corpus manifest. The gate refuses a baseline equal to the candidate, missing or equal build paths, engine mismatches, skipped workloads, incomplete coverage and content differences. Immediate-parent comparisons detect changes against that parent; they do not establish cumulative performance against a historical release. Local predicate self-checks remain labeled as self-checks. Both CI lanes build @verter/lsp-test-client and import the runtime LSP driver before running the harness. The driver's package entry is generated dist, which a frozen dependency install alone does not produce; Vitest's source alias does not verify that runtime prerequisite.
LSP Benchmark (lsp-benchmark.yml)
Triggered via /lsp-benchmark PR comment or manual dispatch. Runs the Verter-vs-Volar LSP benchmark on Linux, macOS, and Windows and reports per-OS values.
Integration Test (integration-test.yml)
Tests Verter against real-world open-source Vue projects to validate compatibility.
Trigger methods:
- Manual (
workflow_dispatch) -- select source (artifact/npm) and projects via the Actions tab - After Release (
workflow_call) -- automatically triggered after successful npm publish - PR Comment -- comment
/integrationon any PR (requires write permission)
Test matrix includes: Vuetify, PrimeVue, Element Plus, Shadcn-vue, and other popular Vue projects.
Test process for each project:
- Baseline -- build and test with the standard Vue compiler, record timing
- Verter -- replace
vue()withverter()in Vite config, rebuild and retest - Compare -- generate performance and compatibility comparison report
Per-project steps retain both baseline and Verter results even when a build or test command fails, so the aggregate report remains useful. The aggregate PR check fails when Verter introduces a project failure and reports neutral when only warnings remain.
Release (release.yml)
Triggered on push of tags matching v* (e.g., v0.0.1-beta.1, v1.0.0).
Nothing is tested or built twice. A release lands as the squash of a release pull request (release: v<version>), and that pull request's CI does the work once:
- the ordinary CI lanes are the tests (a version bump rewrites the workspace manifests, which selects every lane);
Release Checkruns this workflow withdry_run: truebeside them: every build, the VSIX packaging, the Neovim floor/nightly legs CI does not run, and the clean-room smoke test of the built packages. Publishing is skipped.
The tag push then runs only validate and the publish jobs. validate runs scripts/release-proof.mjs, which requires the tag to be the squash of a merged release: … pull request, and a successful CI run of that pull request whose ci-tested notice records exactly the tagged tree (see "A merge is not tested twice" above), whose Release Check ran, and which still holds every artifact family. It then hands that run's id to the publish jobs, which download its artifacts: what ships is what was rehearsed. Without that proof nothing is published.
Job graph:
Release pull request (ci.yml): the CI lanes + Release Check (dry_run):
validate
+-- build-native / build-lsp / build-tsc / build-mcp (matrix: 7 targets)
+-- build-wasm
+-- build-editor-lsp
+-- editor-neovim (v0.11.0 floor, nightly)
+-- clean-room (needs: the builds, editor-neovim)
+-- build-vsix (needs: build-lsp, build-native, build-mcp)
Tag push (release.yml):
validate (release proof → artifacts-run)
+-- publish-npm (downloads artifacts-run)
+-- publish-vscode (downloads artifacts-run's VSIXes)
+-- integration-test (consumes the published npm packages)
+-- github-release (downloads artifacts-run)The GitHub Release is gated on builds, not on publishing. Every asset it uploads — native bindings, WASM, the verter-lsp and verter-mcp binaries and the platform VSIXes — is build output, so a failed npm or Marketplace publish no longer withholds the release and its downloadable assets; publishing runs in parallel and is retried on its own. Packaging the VSIXes is therefore a build job (build-vsix, run in the rehearsal); publish-vscode only pushes the prebuilt artifact, and stays ordered after publish-npm so the extension never lands on the Marketplace before the packages of the same version reach the registry. The release remains test-gated through the proof.
Consequence to be aware of: the release can now exist for a version whose npm publish failed. That is the intended trade — the assets are the durable artifact, and a failed publish job is re-runnable — but it means a red publish-npm needs acting on, not ignoring.
Native build matrix:
| Target | Runner | Method |
|---|---|---|
x86_64-unknown-linux-gnu | ubuntu-latest | Direct |
x86_64-unknown-linux-musl | ubuntu-latest | Cross-compile |
aarch64-unknown-linux-gnu | ubuntu-latest | Cross-compile |
aarch64-unknown-linux-musl | ubuntu-latest | Cross-compile |
x86_64-apple-darwin | macos-13 | Direct |
aarch64-apple-darwin | macos-latest | Direct |
x86_64-pc-windows-msvc | windows-latest | Direct |
Binary build matrices: build-lsp (the verter-lsp server), build-mcp (the verter-mcp MCP server) and build-tsc (verter-tsc) cover the same 7 targets as build-native. Each names its artifacts after the npm platform package they feed (lsp-<npm-pkg>, mcp-<npm-pkg>, e.g. lsp-linux-x64-gnu), so publish-npm stages them by directory name; build-vsix maps its five VSIX targets onto those same artifacts (the two musl legs serve the npm channel only -- the VSIX has no musl target).
Publishing process. The npm publish runs through scripts/release-publish.mjs, the one publish path CI and a local release share (see Publishing locally). No Rust crate is published to crates.io: the compiler depends on workspace-internal crates, and Rust is consumed through the npm packages (the napi bindings, the WASM build and the server binaries).
- Stage --
stage --artifacts <dir>: every platform package's binary (named by the package's ownfileslist), the wasm build and the napi-generated loader are copied from the downloaded build artifacts into the tree. Fails closed when any platform package cannot be fed. - Prepare --
prepare:pnpm run build:ts,@verter/native'sbuild:types, and its hermetic packaging guards. - npm packages --
publish-npm --dist-tag <tag> --provenance: platform packages first (e.g.,@verter/native-darwin-arm64,@verter/lsp-linux-x64-gnu), then the main packages in the topological orderscripts/lib/publish-set.mjsderives from the product dependency closure (marketplace-only packages such asverter-vscodeare excluded). Each package is packed once withpnpm pack(which resolvesworkspace:ranges), shipped binaries get the executable bit inside the tarball, and the tarball is published. Only "already published" is tolerated; every package is attempted before a failure fails the job. - Verify --
verify-npm: every package in the publish set is visible on the registry at the released version. A package that already has versions is served from a cached packument that can lag the publish by several minutes, so the check polls for about fifteen minutes before failing. - GitHub Release -- created with the release notes (
git-cliff --latest) and the staged binary assets. The release workflow never commits tomain:CHANGELOG.mdis regenerated bypnpm bumpand lands with the release pull request.
Release assets (28). Each one is staged under an explicit, platform-qualified name before gh release create runs, and the step writes the full list -- name and size -- to the workflow run summary:
| Family | Count | Asset names |
|---|---|---|
| Native bindings | 7 | verter-native.<triple>.node |
| LSP server | 7 | verter-lsp-<platform>[.exe] |
| MCP server | 7 | verter-mcp-<platform>[.exe] |
| VS Code extension | 5 | verter-vscode-<target>.vsix |
| WASM | 2 | verter_wasm_bg.wasm, verter_wasm.js |
Staging fails the job on a missing source, a duplicate asset name, or a family whose count is short -- a partial release is a failed release. The summary is written before that check, so a failed run still shows what it managed to stage. Two things deliberately do not ship as assets: the native-loader artifact (index.js, an npm-only file that a blanket extension sweep used to attach as an opaque asset) and the relay shim inside the LSP artifacts (a VSIX internal). verter-tsc is npm-only -- its only consumption path is npx inside a Node project, whereas verter-lsp and verter-mcp must be launchable by editors and agent hosts on machines with no Node at all.
Release IDE (release-ide.yml)
The editor distribution's lane: every editor Verter supports, on one version, triggered by an ide/v* tag. It publishes the two halves of "using Verter in an editor":
- The VS Code Marketplace. Five platform VSIXes (linux x64/arm64, darwin x64/arm64, win32 x64), each carrying its own
verter-lsp,verter-mcpand napi binding, packaged by the samepackages/vue-vscode/package.mjsthe monorepo release uses and published withvsce publish --packagePathunder thevscode-marketplaceenvironment (verter.verter-vscode). - Other supported editors. Helix, Zed, Lapce and Neovim launch the engine directly. The GitHub Release for the tag carries
verter-lsp-<platform>andverter-mcp-<platform>for all seven targets — including the two musl ones, which have no vsce target at all. Starting with IDE 0.0.4, it also carriesverter-lapce.tar.gz(WASI core plugin),verter-zed.tar.gz(WASI component extension),verter-nvim.tar.gz(Lua runtime) andverter-helix.tar.gz(configuration). Each archive includes installation instructions, a license and the matchingIDE_VERSION.
One version for all of them, because every editor package is a launcher for the same verter-lsp build — the VSIX embeds it, the Zed extension and the Lapce volt spawn it, Helix and nvim run it directly. ide/vX.Y.Z is what makes "which engine is in my editor?" answerable, and scripts/set-ide-version.mjs writes that version into all three editor manifests: packages/vue-vscode/package.json, extensions/zed/extension.toml and extensions/lapce/volt.toml.
The reusable editor-packages.yml workflow builds the two WASM plugins using their locked standalone Cargo manifests, then runs scripts/package-editor-integrations.mjs. That script rejects mismatched editor versions, missing files, wrong WASM formats and existing output archives. It packages only explicit runtime files. Zed's installed manifest records the locked extension API version and loads extension.wasm at the archive root. Pull requests changing these packages run the same workflow and archive tests. Both publishers wait for this build; GitHub asset staging requires all four archives as well as all engine binaries and VSIXes.
Neovim support requires 0.11+; there is no classic Vim package. The JetBrains plugin is currently a health-check skeleton without language support, so it is not part of the IDE release distribution.
Registry publication for Zed (zed-industries/extensions) and Lapce (plugins.lapce.dev) is a roadmap item in their READMEs. When it lands it is a job in this workflow, against this same tag — not a lane of its own.
It validates that the tag agrees with the editor manifests before it builds anything, and scripts/set-ide-version.mjs --check refuses a prerelease version there and then: the Marketplace takes plain MAJOR.MINOR.PATCH and vsce would otherwise reject it an hour later, after seven cross-compiles. workflow_dispatch runs it as a rehearsal — everything is built and packaged, nothing is published, because the Marketplace has no unpublish.
The relay shim is a VSIX internal and is deliberately not a release asset. The verter-lsp / @verter/lsp-* npm packages and the crates stay on the monorepo's version line and are published by release.yml.
Release Tag (release-tag.yml)
Triggered on every push to main. Turns a version commit into the tag that triggers that lane's release workflow. The scope of the release commit names the lane; scripts/release-lanes.mjs is the table of them, and the workflow itself is lane-agnostic:
| Commit subject | Tag | Version source | Workflow |
|---|---|---|---|
release: v<version> | v<version> | Cargo.toml [workspace.package] | release.yml |
release(ide): v<version> | ide/v<version> | the editor manifests | release-ide.yml |
- Exits cleanly unless the HEAD commit subject matches
release: v<version>orrelease(<lane>): v<version> - Asks the lane table where that lane's version lives, reads it from the tree, and fails if it disagrees with the message
- Exits cleanly if the lane's tag already exists (idempotency)
- Runs that lane's own verification — the monorepo's is
scripts/set-version.mjs --checkplusscripts/check-versions.mjs; the editors' isscripts/set-ide-version.mjs --check - Creates and pushes the annotated tag
A release commit naming a lane that does not exist fails rather than doing nothing: a typo'd scope is a release nobody notices never happened.
Adding a lane — when a package moves onto its own version line — is an entry in scripts/release-lanes.mjs plus the workflow that listens on its tag. Nothing in release-tag.yml changes. node scripts/release-lanes.mjs list prints the current table.
See Publishing a Release for the full flow.
Nightly (nightly.yml)
Triggered daily at 06:00 UTC on main and manually through workflow_dispatch.
The full four-group compile-contract suite runs every night without change filters, using the same runner, feature configurations and cache keys as PR CI. It also retains the compiler group's generated-artifact and default-feature lib checks. The existing nightly scale and coverage lanes run alongside it.
The WASM/playground build:
- Builds WASM via
cargo build --target wasm32-unknown-unknown,wasm-bindgen, and awasm-optsize pass - Smoke tests the WASM binary
- Uploads commit-specific WASM assets to the
nightlyGitHub Release - Updates
nightly-manifest.json(keeps last 50 commits) - Cleans up old assets beyond the 50-commit window
- Builds and deploys the playground to production (via Netlify)
An independent, read-only rust-observe job runs the complete instrumented Rust suite with node scripts/observe-lane.mjs run. The runner derives every workspace member declaring semantic-observe from Cargo metadata, enables the feature for all of them, prepares their foreign-crate fixture metadata, and uses the same provider-free core filter as before. Test failures fail the nightly job; its JUnit report and schema-checked metadata are retained, including after test failures; the separate JUnit artifact is kept for seven days. This suite does not gate pull requests. Normal correctness tests, including concurrent-session isolation, remain in ordinary PR CI; the separate native and WASM instrumentation package jobs are unchanged.
Nextest JUnit evidence
Every job that runs nextest with the ci profile (the Rust Test (Core n/4) shards, the nightly instrumentation lane, BF2 Authoritative, and the nightly core coverage lane) uploads its junit.xml with a junit-metadata.json beside it, through .github/actions/nextest-evidence, under a stable artifact name (nextest-evidence-<job>[-<partition>]), also when tests fail. The document (schema v1, scripts/ci-junit-metadata.mjs) carries the tested tree and its parents read from the checked-out commit object, tool versions, the environment fingerprint, the run configuration and each test's hermetic flag. A test is hermetic only when the operator-approved candidate list scripts/manifests/ci-hermetic-candidates.json names it (see Perturbation reruns below). The environment fingerprint is the allow-listed snapshot the nextest step itself writes (ci-junit-metadata.mjs snapshot-env, run inside that step's shell after any cargo llvm-cov show-env), and command, cargo features, filter and partition are read from the cargo argument vector the run used. The same script's check mode fails the job when a required field is missing or the test list differs from the junit.xml. No required check reads these files; existing JUnit locate and Test report steps remain independent of the evidence step, so deleting the evidence step restores the previous CI.
Perturbation reruns
.github/workflows/perturbation.yml (weekly, manual dispatch, and on pull requests and branch pushes that change its own machinery) is a separate, non-required workflow; disabling it stops the reruns and changes no required check. It builds one nextest archive, then runs the whole core suite from it once per configured rerun (scripts/manifests/perturbation.json fixes the count and each rerun's clock offset and time zone), each on its own runner, through node scripts/perturbation.mjs run:
- unprivileged (no container,
sudoor runner administration; the uid is recorded), with the runner's OS image identity (distribution and release from/etc/os-release, kernel fromuname) recorded; - the environment cleared to the JUnit metadata allow-list plus
PATHand the toolchain homes, with HOME and TMPDIR in fresh per-job directories; - network isolation beyond loopback through an unprivileged user and network namespace (
unshare) where the runner allows one, recorded as applied or not with the reason; - its own temporary directory, runner-supplied variable values (
CI,ImageOS,ImageVersion), host, and wall clock (a whole-day offset through libfaketime built from its pinned release source, monotonic clock untouched) and time zone, each recorded inperturbation-record.json.
The libfaketime build verifies the release checksum, then applies .github/patches/libfaketime-const.patch to preserve const on two read-only search-result pointers for newer libc headers. Compiler warnings remain errors; the release version and checksum stay pinned.
Each rerun uploads its junit.xml and JUnit metadata document (perturbation-rerun-<n>-nextest-evidence) and its record (perturbation-rerun-<n>-record). The compare job (node scripts/perturbation.mjs compare) writes perturbation-comparison.json, listing every test's outcome in each rerun with its hermetic flag, and a proposed next version of the candidate list, uploaded together with the reruns' metadata documents as perturbation-comparison. The reruns are comparable only when they tested one tree with one command, every rerun has an exact base environment fingerprint match (OS and image identity, architecture, toolchain pins, allow-listed variables as the runner supplied them), and each rerun varied every dimension, including a wall-clock shift that was applied and read back off the real clock by its offset (a time zone alone changes neither Rust's SystemTime nor the UTC date); otherwise the job fails and proposes no candidate. A candidate passed in every rerun and no exclusion names it; a test whose outcome differs in any rerun, that is missing from one, or that fails in every rerun is never a candidate.
scripts/manifests/ci-hermetic-candidates.json is versioned (version) and stays approved: false until the operator approves it by committing the proposal with approved: true, approvedBy and approvedOn; only an approved list sets a hermetic flag. Its exclusions keep tests non-hermetic whatever the reruns show, each group with its reason: verter_lsp (its build script embeds the build date), verter_bench (it embeds the compiling checkout's identity), the modules whose test code reads the clock for a budget, deadline or timeout, and the modules whose test code, or a test helper it calls, depends on any build-environment value cargo fixes at compile time through env!/option_env! (the env!("CARGO") dependency-closure guards, CARGO_MANIFEST_DIR/CARGO_TARGET_TMPDIR/OUT_DIR paths, CARGO_BIN_EXE_<name> binaries and CARGO_PKG_* metadata). One pattern may appear under several reasons, and a test excluded for several reasons reports all of them. A new test module that reads the clock for a timing budget or depends on a build-environment value gets an exclusion pattern in the same change. node scripts/perturbation.mjs check-list validates the list.
Versioning
Pre-release Flow
alpha -> beta -> rc -> stable| Version Pattern | npm dist-tag | GitHub Release | Example |
|---|---|---|---|
X.Y.Z-alpha.N | alpha | prerelease | 0.0.1-alpha.1 |
X.Y.Z-beta.N | beta | prerelease | 0.0.1-beta.1 |
X.Y.Z-rc.N | rc | prerelease | 0.0.1-rc.1 |
X.Y.Z | latest | release | 1.0.0 |
Pre-releases are published with --tag <channel> to avoid polluting the latest dist-tag.
Publishing a Release
The repository publishes on two independent version lines — the monorepo (npm) and the editor distribution (the Marketplace + the engine binaries every other editor launches) — and both follow the same shape: bump locally, open the release pull request (its CI runs the lanes and the lane's rehearsal), squash-merge it with the exact release subject, release-tag.yml tags, and the lane's workflow proves that pull request's CI and publishes its artifacts. node scripts/release-lanes.mjs list prints them.
The monorepo
Releases start from a local version bump and end with an automatic tag:
- Run
pnpm bump. The script computes the next version from the conventional commits since the lastv*tag (viagit-cliff --bumped-versionwhen git-cliff is installed, otherwise from the commit types directly:feat-> minor,fix/perf-> patch, breaking changes -> major; a pre-release stays in its channel and increments its counter). Overrides:pnpm bump -- <version>for an explicit version,pnpm bump -- --prerelease <alpha|beta|rc>for a pre-release channel,pnpm bump -- --dry-runto print without changing anything. pnpm bumpwrites the version across the whole release surface withscripts/set-version.mjs: theCargo.tomlworkspace version (which every crate inherits),Cargo.lock, and every package in the npm publish set — the publishablepackages/*packages plus the platform sub-packages underpackages/{native,verter-lsp,verter-mcp,verter-tsc}/npm/*. The target set comes fromscripts/lib/publish-set.mjs, the same authority the release workflow publishes from; private packages are never touched.pnpm bumprequiresscripts/check-versions.mjsto pass, refuses to run on a dirty tree, and refuses a version that is not greater than the current one. It then regeneratesCHANGELOG.mdwith the git-cliff pinned in the root devDependencies (--tag v<version>labels the commits since the lastv*tag as the new release). On success it creates exactly one commit,release: v<version>, holding the version change and the changelog. It never creates a tag and never pushes. The changelog has to ride in this commit:mainaccepts changes only through a pull request whoseCI Requiredcheck passed, so the release workflow cannot add it later.- Review the commit, push it on a branch and open a pull request titled
release: v<version>. Its CI runs every lane and theRelease Checkrehearsal (every build, the packaging and the clean-room smoke test); squash-merge it onceCI Requiredis green, with the commit subject exactlyrelease: v<version>(remove the(#N)GitHub appends:release-tag.ymltags only an exact release subject). Do not push the version commit tomaindirectly: the tag publishes only the artifacts of a merged release pull request's green CI, so a direct push can never publish. - The
release-tag.ymlworkflow detects the version commit onmain— the commit message must matchrelease: v<version>and agree with the workspace version in the tree, and the tag must not exist yet. It re-verifies the whole surface (set-version.mjs --checkandcheck-versions.mjs), then creates and pushes the annotated tagv<version>. For any other commit — including a re-run on an already-tagged release commit — it is a no-op. - The tag push triggers the
release.ymlworkflow, which proves the release pull request's CI (see above) and publishes the artifacts it built.
The editor distribution
Every editor package ships on one version. packages/vue-vscode is private: true and MARKETPLACE_ONLY so it is not in the npm publish set, and the Zed/Lapce manifests are not npm packages at all — pnpm bump moves none of them:
- Run
pnpm bump:ide. It computes the next version from the conventional commits touching the editor payload (crates/,packages/,extensions/) since the lastide/v*tag, or takes an explicit one:pnpm bump:ide -- 0.2.0.--dry-runprints without changing anything. There is no--prerelease: Marketplace versions are plainMAJOR.MINOR.PATCH, and VS Code's own pre-release channel is an odd minor, which is just an explicit version. - It writes the version with
scripts/set-ide-version.mjsinto all three editor manifests —packages/vue-vscode/package.json(fromMARKETPLACE_ONLYinscripts/lib/publish-set.mjs),extensions/zed/extension.tomlandextensions/lapce/volt.toml— verifies them, refuses a dirty tree and a version that is not greater than the current one, and creates exactly one commit:release(ide): v<version>. No tag, no push. - Review the commit, push it on a branch and open a pull request titled
release(ide): v<version>. Its CI runs the lanes and theRelease IDE Checkrehearsal: seven LSP and MCP targets and five napi targets are cross-compiled and five VSIXes are packaged. Squash-merge it onceCI Requiredis green, with the subject exactlyrelease(ide): v<version>; as for the monorepo release, a version commit pushed straight tomaincan never publish. release-tag.ymltagside/v<version>.- The tag triggers
release-ide.yml, which proves the release pull request's CI and publishes that run's artifacts: the five VSIXes to the Marketplace, and a GitHub Release for the tag carrying the VSIXes plus the per-platform engine binaries every other editor launches.
The manifests were versioned separately before the lane existed (the extension at 0.0.2, the Zed extension and the Lapce volt at 0.1.0). pnpm bump:ide takes the highest of them as the current version and says so, so the first unified release moves all three forward and none of them backwards.
A release(ide): v<version> pull request's CI runs release-ide.yml as the Release IDE Check rehearsal beside the CI lanes, and the ide/v* tag publishes that run's artifacts after the same proof, so the editors are neither tested nor built twice. To rehearse without a pull request, dispatch release-ide.yml manually — the dry run builds and packages everything and cannot reach the Marketplace.
Publishing locally
When release.yml cannot finish publishing a release its pull request already proved (a publish credential problem, a registry outage), the npm publish runs locally through the SAME code the workflow runs — scripts/release-publish.mjs — against the SAME build artifacts: the release pull request's CI run, found by the same proof (its native-*, tsc-*, lsp-*, mcp-*, wasm and native-loader artifacts are retained for 90 days), and downloaded again whenever the cached download is not that run's. Nothing is rebuilt from a developer machine: the binaries are the release pull request's CI builds, and the TypeScript packages are built from the tagged commit, which the script requires to be checked out.
git fetch --tags && git checkout v0.0.1-beta.5 # the tagged commit, clean tree
npm login # a user with publish rights + 2FA
node scripts/release-publish.mjs local # the whole thing, interactive
node scripts/release-publish.mjs local --run 35334967938 # pick among the proven runs (it must pass the same proof)
node scripts/release-publish.mjs local --dry-run # rehearse: pack + `npm publish --dry-run`local runs the preflight (tag = HEAD = workspace version, clean tree, set-version.mjs --check, npm whoami), downloads the proven CI run's artifacts with gh run download into .release/<tag>/artifacts/ (gitignored, reused on the next invocation; --redownload refreshes), then stage → prepare → publish-npm → verify-npm. Each step is also its own subcommand (node scripts/release-publish.mjs <step>), so a run that stopped half way is resumed from the step that failed; a package already on the registry is skipped, never re-published.
Two-factor authentication. npm accepts a one-time password for a short window, so 44 packages cannot ride one code. publish-npm --interactive-otp (what local uses) asks for a code the first time the registry demands one and again exactly when a code is rejected as expired, retrying the same package — a batch of publishes shares one code and you type a fresh one only when npm asks. Pass --otp <code> to seed the first batch.
Executable bits. A tarball packed on Windows records mode 0644 for every file, so a verter-lsp / verter-mcp / verter-tsc platform package published straight from pnpm publish installs and then cannot be spawned. publish-npm therefore packs each package to a tarball and sets 0755 on the shipped binaries inside the archive (the files the platform package's own files list names) before npm publish <tarball> — on every host, so CI and a local release produce the same tarballs.
Not covered by the local path: the GitHub Release with its staged assets, the platform VSIXes and the Marketplace publish — release.yml owns those.
Version Checking
node scripts/check-versions.mjs # Human-readable output
node scripts/check-versions.mjs --json # JSON for CI consumptionThis script compares local versions against published versions, detects pre-release channels, and computes the topological publish order. The publish set is not hand-maintained: scripts/lib/publish-set.mjs derives it from the product roots (@verter/typeinfo, @verter/component-meta, @verter/unplugin, verter-tsc, verter-vscode) by walking runtime dependency fields (dependencies + optionalDependencies + peerDependencies) across workspace packages. It throws if a package in the closure is private (marketplace-only packages exempt) or if a dependency cycle exists.
Build Order
pnpm build (host developer build) builds in order:
native -> lsp -> ts packagesIt never builds WASM and never runs wasm-opt. pnpm dist (publication-ready artifacts) adds a WASM step between lsp and ts packages, building the full @verter/wasm lane (bindgen + cached wasm-opt):
native -> lsp -> wasm (bindgen + wasm-opt) -> ts packagesCommon rebuild sequences:
| What changed | Rebuild commands |
|---|---|
Rust crate (verter_compiler) | pnpm run build:native then rebuild downstream consumers |
Rust LSP (verter_lsp) | pnpm run build:lsp then restart VS Code extension host |
Unplugin (packages/unplugin) | pnpm run build:ts |
| WASM, developer iteration | pnpm run build:wasm (bindgen only, no wasm-opt, no playground copy) |
| WASM, publication-ready | pnpm --filter @verter/wasm build (bindgen + cached wasm-opt) |
| Host developer build | pnpm build (native + lsp + ts, in order) |
| Publication-ready artifacts | pnpm dist (native + lsp + wasm + ts, in order) |
Required GitHub Secrets
| Secret | Purpose |
|---|---|
NETLIFY_AUTH_TOKEN | Netlify playground deployment |
NETLIFY_SITE_ID | Netlify site identification |
NPM_TOKEN | npm publishing (with --provenance) |
VSCE_PAT | VS Code Marketplace publishing (verter.verter-vscode) |
RELEASE_TAG_SSH_KEY | the deploy key release-tag.yml pushes tags with, so the tag push starts a release workflow (a push made with GITHUB_TOKEN never does) |
The GITHUB_TOKEN is automatically provided for GitHub Release creation, nightly asset management, and PR comments.
Representative change comparisons
Before/after CI comparisons read the versioned CI change corpus: its passing and failing change inputs, platform and cache cells, and concurrent supersession streams. The collection protocol defines the replay commands, paired repetitions and the queue, execution, resource and landing metrics. Consumers keep their results outside the repository. The corpus claims no measured savings and replaces no exact-tree required check.
No measured baseline exists yet. The current CI baseline records what can and cannot be read today, the driver that collects it, and what stays held until a collection reports measured.