VS Code Extension Framework — Design#
The behaviour in the spec is delivered by a shared reusable
workflow and a template repository, following the org's Process-* /
Template-* convention (as Process-PSModule
does for PowerShell modules). A repository opts in with a short caller workflow
and a single .github/vscode-extension.yml extension settings file. Build and
test settings have secure, working defaults; release decisions follow
Release Management.
Step logic
lives in versioned scripts the workflow calls, never inline shell.
The pipeline#
The pipeline is a DAG of single-purpose stages, so each stage does one thing and every downstream stage reuses the exact output of the stages before it:
flowchart LR
find["Find version\ntags + resolved decision"] --> build["Build\nstamp · bundle · package VSIX"]
build --> test["Test\nreal VS Code host · matrix"]
lint["Lint & type-check"] --> release
test --> release["Release\npublish the built VSIX"]
- Find version — validate the release decision and compute the version once,
from the latest
vX.Y.Ztag or release plus the explicit or configured bump. Independent of the other stages and required by the merge ruleset; a missing decision is a failed check, not a post-merge surprise. - Lint & type-check — static analysis and the type checker. Independent, so it fails fast in parallel with the build.
- Build — stamp the computed version into the manifest, compile the production bundle, and package one VSIX. Uploads the bundle, the VSIX, and the stamped manifest as artifacts.
- Test — download a real editor host and run the extension test suite against the exact bundle and manifest that were built.
- Release — publish the exact VSIX that was built and tested. Depends on
both
testandlint.
The built-once contract#
The version is computed once and flows through the pipeline as artifacts, so the thing that ships is the thing that was tested:
- Find version decides
vX.Y.Zfrom tags plus the resolved explicit or configured bump. - Build stamps it into
package.json(andpackage-lock.json), compiles the bundle, and runs the packaging CLI to produce a single VSIX. The stamped manifest is uploaded alongside the VSIX so the manifest under test — including the version the running extension reports — matches the packaged artifact. - Test restores that bundle and manifest and tests them.
- Release restores and publishes that same VSIX.
Build, test, and release never re-compile or re-package; they pass the built artifact forward.
Build#
Dependencies are installed with npm ci so the lockfile is authoritative and the
install is reproducible. The TypeScript source is then bundled into a single
self-contained file with esbuild, with vscode marked external (the host
provides it). The output format is CommonJS by default — esbuild folds even
pure-ESM dependencies into that output; an extension whose own entry point
requires ECMAScript-module semantics (top-level await, or a deliberate
ESM-first choice) overrides the bundler format to esm, on a host new enough to
load ESM extension bundles. The pipeline produces one build: the release
bundle, always minified, with a source map emitted as a side artifact for
debugging rather than a separate development build. The bundle is the extension entry point
(dist/extension.js); packaging it with @vscode/vsce package --no-dependencies
produces the VSIX. Bundling first is what lets the VSIX omit node_modules and
stay small.
Test#
A real editor is downloaded and driven by the VS Code test harness
(@vscode/test-cli), which runs the extension test suite inside an Extension
Development Host. The test job runs as a matrix over:
- the host versions the extension supports — at least the minimum declared
in
engines.vscodeand the current stable; Insiders runs as an optional, non-blocking job, because it updates daily and can fail on VS Code regressions unrelated to the extension; and - the operating systems the extension declares — Linux, macOS, Windows.
Framework-level tests (the counterpart to the PSModule source-code tests) assert repository conventions — that the manifest is well-formed, contributions are declared, and the activation path is sound — independent of the extension's own feature tests.
Lint and type-check#
ESLint and the TypeScript compiler in no-emit mode run as one independent stage so a style or typing error fails fast, in parallel with the build. Both must pass before the release stage runs, alongside a green test result.
Versioning#
Versioning is Release Management applied to a VSIX artifact — this framework does not re-implement it:
- The shared resolver uses one explicit owned bump label or, without one, the
optional
DefaultBumpin.github/release.config.yml. A validrelease:skipreports a no-release decision without skipping validation; otherwise a missing level fails the required PR decision check. Invalid defaults and conflicting owned labels also fail. No implicit patch is selected. - The version check re-evaluates source, release-label, and release-settings changes and follows the shared pre-merge gate.
- The version is computed once and stamped into the manifest; it is never hand-edited.
- A prerelease is requested by
release:pre-releasewith a resolved bump on an open pull request (or by a prerelease branch), producing a prerelease VSIX that is never promoted to latest. When such a build is also published to the VS Code Marketplace, it goes out with@vscode/vsce publish --pre-releaseand an odd minor-version number, the Marketplace's pre-release-channel convention.
Publishing and distribution#
Every release attaches the VSIX to a GitHub Release whose name is the
version, and creates the git tag through the Releases API against an explicit
commit SHA — the merge commit for a merged pull request, the head commit for a
prerelease — rather than by pushing a tag over git. This keeps the release stage
at contents: write with no workflows scope, which is both least-privilege and
required on GitHub Enterprise Cloud with data residency, where that scope is not
grantable.
- Install without a marketplace. A hosted one-liner install script fetches
the latest release's VSIX asset through the authenticated GitHub CLI
(
gh api), so it works even where unauthenticated asset access is disabled, and installs it withcode --install-extension, honouring a host override so it targets Insiders, Cursor, or VSCodium. This is always available. - Marketplace publication (optional). Where the repository configures it, the
same VSIX is published to the VS Code Marketplace (
@vscode/vsce publish) and/or Open VSX (ovsx publish). Neither registry supports OIDC, so each publish token is a long-lived, publish-scoped personal access token held in a protected GitHub environment. It is opt-in and never blocks the GitHub-Release install path.
Prerelease tags, releases, and their VSIX assets are cleaned up when the pull request closes; stable releases are never touched.
Documentation and dependencies#
- Documentation. The extension's README and user docs live in the extension's own repository and are built and published like any repository's documentation (Documentation Model) — there is no internal-versus-user split. This framework's own documentation is this capability.
- Dependencies. The template configures automated updates for npm packages and pinned Actions through Dependency Updates; those pull requests use the normal review and release path.
Least privilege and serialisation#
The workflow is contents: read at the top level; only the release job elevates
to contents: write, because it alone creates the tag and the release. The
separate pull-request-close job that cleans up a prerelease's tag, release, and
VSIX assets is the only other place that needs contents: write. Release runs
for the same ref are serialised and queue rather than cancel — an in-flight
release is never aborted mid-write — via a concurrency group keyed by workflow
and ref with cancel-in-progress disabled, inherited from the
GitHub Actions standard.
Pull-request validation runs, by contrast, use a per-branch concurrency group
with cancel-in-progress enabled, so a new push supersedes stale in-flight runs.
Every external Action is pinned to a commit SHA; organization- or initiative-owned automation may use the controlled major-tag exception.
Configuration surface#
| Surface | Where |
|---|---|
| Adoption (opt-in) | a short caller workflow that calls the reusable workflow |
| Host + OS matrix, marketplace toggle, extras | .github/vscode-extension.yml |
| Explicit bump / prerelease / skip | owned pull-request label |
| Optional default bump | DefaultBump in .github/release.config.yml |
| Pre-merge decision validation | version check required by the branch ruleset or protection |
| Release branches + path filter | .github/release.config.yml (Release Management) |
| Marketplace publish tokens | a GitHub environment's secrets |
Extension manifest (engines.vscode, contributes, activation) |
package.json |
Repository structure#
The template repository lays down the whole shape, mirroring Template-PSModule:
src/— the extension source (extension.ts) and its tests.package.json— the extension manifest:publisher,engines.vscode,contributes,activationEvents, andmainpointing at the bundle.- the bundler config,
tsconfig.json, and.vscodeignore. install/— the hosted one-liner install scripts..github/— the caller workflow,vscode-extension.yml,release.config.yml, anddependabot.yml.
Where this connects#
- Spec — the requirements this design delivers.
- Release Management — the release mechanics this pipeline drives to ship a VSIX.
- Dependency Updates — how the extension's dependencies stay current.
- Merge Automation — how the pipeline's named status checks gate the merge.
- GitHub Actions — how the reusable workflow is authored (SHA pins, least privilege, concurrency).
- TypeScript — how the extension source is written.
- Testing — the testing approach the host-based tests follow.
- Security — why consumers install pinned, immutable versions.