Ship your extension with confidence. This tutorial walks through building a real CI/CD pipeline for a VS Code extension β from running lint and tests on every push to automatically publishing releases to the VS Code Marketplace.
Weβll use the vs-sonic-pi extension as our concrete example throughout, but the patterns here apply to any VS Code extension built with TypeScript and Node.js.
| Topic | Section | Difficulty |
|---|---|---|
| Why CI/CD for extensions? | Overview | π’ Easy |
| Project structure prerequisites | Setup | π’ Easy |
| The CI workflow (lint β build β test) | Core | π‘ Intermediate |
| The Release workflow (package β publish) | Core | π‘ Intermediate |
| Secrets and marketplace tokens | Configuration | π‘ Intermediate |
| Extending the pipeline | Advanced | π΄ Advanced |
A VS Code extension is a software product. It has users, dependencies, and potential regressions β just like a web application or a CLI tool. Without CI/CD:
.vsix by hand is slow and forgettable.A CI/CD pipeline makes every commit and every PR a quality checkpoint. It gives you (and your contributors) confidence that the extension builds, passes lint, passes tests, and can be packaged β before any code reaches main.
Before setting up the pipeline, your extension project needs a few things in place. Hereβs what the vs-sonic-pi repo looks like:
vs-sonic-pi/
βββ .github/
β βββ workflows/
β βββ ci.yml # β Continuous Integration
β βββ release.yml # β Release & Publish
βββ src/
β βββ extension.ts # Extension entry point
βββ test/ # Test files
βββ dist/ # Build output (gitignored)
βββ package.json # Extension manifest + scripts
βββ tsconfig.json # TypeScript config
βββ esbuild.config.mjs # Bundler config
βββ eslint.config.mjs # Linter config
βββ vitest.config.ts # Test runner config
package.json ScriptsYour pipeline will call npm scripts, so the scripts section of package.json is the contract between your workflow and your codebase:
{
"scripts": {
"build": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --sourcemap",
"lint": "eslint src/",
"test": "vitest run",
"package": "vsce package"
}
}
| Script | Purpose | CI Usage |
|---|---|---|
build |
Bundles TypeScript β JavaScript with esbuild | Validates compilation on every push |
lint |
Runs ESLint over src/ |
Catches style and quality issues early |
test |
Runs Vitest test suite | Validates behavior on every push |
package |
Creates a .vsix installable file |
Release artifact |
If your project doesnβt have these scripts yet, add them before proceeding. The pipeline depends on them.
The CI workflow runs on every push to main and on every pull request. Its job: confirm the code is clean, compiles, and passes tests.
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js $
uses: actions/setup-node@v4
with:
node-version: $
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Test
run: npm test
Note: The original vs-sonic-pi repo uses Node 18 + 20 in its matrix. Node.js 18 reached end-of-life in April 2025, so for new projects you should use the current LTS versions (20 and 22). Always check the Node.js release schedule and align your matrix with active LTS versions.
on:
push:
branches: [main]
pull_request:
branches: [main]
main: Every merge or direct push triggers the pipeline.main: Every PR gets validated before merge. This is the quality gate protecting your default branch.strategy:
matrix:
node-version: [20, 22]
This runs the entire job twice β once on Node.js 20 and once on Node.js 22 (the current LTS versions). Why?
devDependencies (esbuild, vitest, eslint) may behave differently across Node.js versions.The matrix is expandable. If you need to test on Windows or macOS runners as well:
strategy:
matrix:
node-version: [20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: $
When does OS matter? For most pure-TypeScript VS Code extensions, Ubuntu-only CI is fine. Add Windows and macOS runners if your extension uses native Node modules, interacts with the file system using platform-specific paths, or spawns child processes (like vs-sonic-pi communicating with Sonic Pi over OSC).
- name: Install dependencies
run: npm ci
npm ci (not npm install) is the correct command for CI environments because:
package-lock.json β no unexpected version resolution.node_modules/ first for a clean slate.- uses: actions/setup-node@v4
with:
node-version: $
cache: npm
The cache: npm option tells the setup-node action to cache the npm global cache directory (~/.npm). On subsequent runs with the same package-lock.json, dependency downloads are skipped β often cutting a minute or more from install time.
The steps run sequentially β if any step fails, the workflow stops:
Lint β Build β Test
npm run lint): Catches style issues, unused imports, and code quality problems.npm run build): Compiles TypeScript, bundles with esbuild. Validates that the codebase compiles (this is where type errors surface).npm test): Runs the Vitest suite. Validates behavior and catches regressions.This ordering is intentional: linting is the cheapest check (fastest feedback), and tests are the most expensive. Fail fast.
The release workflow runs when you push a version tag (like v0.1.0). Its job: build, test, package, and publish the extension.
Create .github/workflows/release.yml:
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm test
- name: Install vsce
run: npm install -g @vscode/vsce
- name: Package extension
run: vsce package
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: vs-sonic-pi-vsix
path: "*.vsix"
- name: Publish to Marketplace
if: startsWith(github.ref, 'refs/tags/v')
run: vsce publish
env:
VSCE_PAT: $
on:
push:
tags: ["v*"]
workflow_dispatch:
v* (e.g., v0.1.0, v1.0.0-beta.1). This is the standard convention for version releases.workflow_dispatch: Allows you to trigger the workflow manually from the GitHub Actions tab β useful for re-publishing or testing.The release workflow re-runs build and test, even though CI already ran on the code. This is intentional:
vsce- name: Install vsce
run: npm install -g @vscode/vsce
- name: Package extension
run: vsce package
vsce (Visual Studio Code Extension Manager) is the official tool for packaging and publishing VS Code extensions. vsce package creates a .vsix file β a zip archive containing your extension, ready for installation or marketplace upload.
The .vsix is then uploaded as a GitHub Actions artifact so itβs available for download from the workflow run:
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: vs-sonic-pi-vsix
path: "*.vsix"
- name: Publish to Marketplace
if: startsWith(github.ref, 'refs/tags/v')
run: vsce publish
env:
VSCE_PAT: $
The if condition ensures this step only runs for tag pushes β not for workflow_dispatch runs (unless the dispatch is from a tag ref). The VSCE_PAT environment variable supplies a Personal Access Token scoped to the VS Code Marketplace.
The VSCE_PAT secret is what allows GitHub Actions to publish on your behalf. Hereβs how to create it:
vsce-publish (or similar)VSCE_PATThe secret is now accessible to workflows as $ and is never exposed in logs.
With both workflows in place, hereβs the complete release flow:
# 1. Ensure you're on main with latest changes
git switch main
git pull
# 2. Update version in package.json
npm version patch # or minor / major
# 3. Push the commit and tag
git push --follow-tags
npm version patch does three things:
"version" in package.json (e.g., 0.1.0 β 0.1.1)v0.1.1v0.1.1When you push the tag, the release workflow fires automatically:
Tag push (v0.1.1)
β Checkout β Install β Build β Test
β vsce package β Upload .vsix artifact
β vsce publish β Live on Marketplace β
After a push or PR, check the Actions tab in your repository. Each workflow run shows:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Lint step fails | ESLint errors in src/ |
Run npm run lint locally, fix issues |
| Build step fails | TypeScript/esbuild errors | Run npm run build locally, check imports |
| Test step fails | Failing or missing tests | Run npm test locally, update tests |
npm ci fails |
package-lock.json out of sync |
Run npm install locally, commit the lock file |
vsce package fails |
Missing icon, publisher, or repository in package.json |
Fill in required fields |
vsce publish fails |
Expired or invalid VSCE_PAT |
Regenerate the token and update the secret |
Once you have the foundational two-workflow setup running, here are practical enhancements to consider:
- name: Test with coverage
run: npx vitest run --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Add this after the upload artifact step in release.yml:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: "*.vsix"
generate_release_notes: true
env:
GITHUB_TOKEN: $
This creates a GitHub Release page with auto-generated release notes and attaches the .vsix for users who install extensions manually.
If your extension has platform-specific behavior (file paths, native modules):
strategy:
matrix:
node-version: [20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: $
Complement the pipeline with GitHub branch protection on main:
mainThis ensures no code reaches main without passing CI.
Hereβs how the two workflows fit together as a complete pipeline:
Developer writes code
β
βββ Push to branch / Open PR
β β
β βββ CI workflow runs
β βββ Lint β
β βββ Build β
(Node 18 + 20)
β βββ Test β
(Node 18 + 20)
β
βββ Merge PR to main
β β
β βββ CI workflow runs (again, on main)
β
βββ Tag & push (v1.0.0)
β
βββ Release workflow runs
βββ Build β
βββ Test β
βββ Package (.vsix) β
βββ Upload artifact β
βββ Publish to Marketplace β
npm ci over npm install: Reproducible installs are non-negotiable in CI.The best way to internalize a CI/CD pipeline is to trigger one yourself. Try these exercises:
Fork and trigger CI: Fork vs-sonic-pi, make a small change (e.g., add a comment to src/extension.ts), push to a branch, and open a pull request. Watch the CI workflow run in the Actions tab.
Break and fix the pipeline: In your fork, introduce a deliberate lint error (e.g., an unused variable). Push, watch CI fail, read the error log, fix it, push again, and confirm it passes.
git tag v0.0.1-test
git push origin v0.0.1-test
Watch the release workflow run. It will build and package (the publish step will skip since you wonβt have a VSCE_PAT secret β thatβs expected).
.vsix artifact. Install it in VS Code with code --install-extension <file>.vsix to confirm the package is valid.Found an issue with this guide? Have a pipeline pattern to share?