🚀 Foundational CI/CD Pipelines with GitHub Actions for VS Code Extensions
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.
📋 What You’ll Learn
| 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 |
🤔 Why CI/CD for a VS Code Extension?
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:
- Bugs sneak in — a passing local build doesn’t guarantee your code works on a clean environment with a different Node.js version.
- Releases are manual and error-prone — packaging, version-bumping, and uploading a
.vsixby hand is slow and forgettable. - Contributions are risky — without automated checks, reviewing pull requests relies entirely on human diligence.
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.
🏗️ Project Structure Prerequisites
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
The Critical package.json Scripts
Your 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.
⚙️ Workflow 1: Continuous Integration (CI)
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.
The Full Workflow File
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.
Breaking It Down
Triggers
on:
push:
branches: [main]
pull_request:
branches: [main]
- Push to
main: Every merge or direct push triggers the pipeline. - Pull request against
main: Every PR gets validated before merge. This is the quality gate protecting your default branch.
The Build Matrix
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?
- Your extension’s
devDependencies(esbuild, vitest, eslint) may behave differently across Node.js versions. - Users who clone and build your extension locally may be on different Node versions.
- Testing across versions prevents “works on my machine” surprises.
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).
Dependency Installation
- name: Install dependencies
run: npm ci
npm ci (not npm install) is the correct command for CI environments because:
- It strictly follows
package-lock.json— no unexpected version resolution. - It deletes
node_modules/first for a clean slate. - It’s faster: skips the dependency resolution step entirely.
Dependency Caching
- 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 Pipeline Stages
The steps run sequentially — if any step fails, the workflow stops:
Lint → Build → Test
- Lint (
npm run lint): Catches style issues, unused imports, and code quality problems. - Build (
npm run build): Compiles TypeScript, bundles with esbuild. Validates that the codebase compiles (this is where type errors surface). - Test (
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.
📦 Workflow 2: Release & Publish
The release workflow runs when you push a version tag (like v0.1.0). Its job: build, test, package, and publish the extension.
The Full Workflow File
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: $
Breaking It Down
Tag-Based Triggers
on:
push:
tags: ["v*"]
workflow_dispatch:
- Tag push: The workflow fires when you push a tag matching
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.
Build & Test (Again)
The release workflow re-runs build and test, even though CI already ran on the code. This is intentional:
- A tag might be applied to an older commit that didn’t go through CI.
- It guarantees the exact commit being released is valid.
- “Trust, but verify” — never publish untested code.
Packaging with 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"
Publishing to the Marketplace
- 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.
🔑 Setting Up the Marketplace Token
The VSCE_PAT secret is what allows GitHub Actions to publish on your behalf. Here’s how to create it:
Step 1: Create an Azure DevOps PAT
- Go to dev.azure.com
- Sign in with the Microsoft account associated with your VS Code Marketplace publisher
- Click your profile icon → Personal access tokens
- Click + New Token
- Configure:
- Name:
vsce-publish(or similar) - Organization: Select All accessible organizations
- Scopes: Select Custom defined, then check Marketplace → Manage
- Expiration: Choose an appropriate duration (set a calendar reminder to rotate it)
- Name:
- Click Create and copy the token immediately — it’s only shown once
Step 2: Add the Secret to GitHub
- Go to your repository → Settings → Secrets and variables → Actions
- Click New repository secret
- Name:
VSCE_PAT - Value: Paste the token from Step 1
- Click Add secret
The secret is now accessible to workflows as $ and is never exposed in logs.
🏷️ The Release Workflow: Tag, Push, Publish
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:
- Bumps
"version"inpackage.json(e.g.,0.1.0→0.1.1) - Creates a git commit:
v0.1.1 - Creates a git tag:
v0.1.1
When 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 ✅
🔍 Reading the Workflow Results
After a push or PR, check the Actions tab in your repository. Each workflow run shows:
- Green check ✅: All steps passed. Code is clean, compiled, tested, and (for releases) published.
- Red X ❌: A step failed. Click into the run to see which step failed and read the logs.
- Matrix view: For CI, you’ll see separate entries for each Node.js version in the matrix.
Common Failure Scenarios
| 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 |
🧩 Extending the Pipeline
Once you have the foundational two-workflow setup running, here are practical enhancements to consider:
Add a Code Coverage Step
- name: Test with coverage
run: npx vitest run --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Create a GitHub Release with the VSIX
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.
Add OS Matrix to CI
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: $
Branch Protection Rules
Complement the pipeline with GitHub branch protection on main:
- Repository Settings → Branches → Add rule
- Apply to
main - Enable:
- ✅ Require a pull request before merging
- ✅ Require status checks to pass (select your CI job)
- ✅ Require branches to be up to date before merging
This ensures no code reaches main without passing CI.
📊 Two Workflows, One Pipeline
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 ✅
🎯 Key Takeaways
- Two workflows are enough to start: CI for quality gates, Release for publishing. Don’t over-engineer day one.
npm ciovernpm install: Reproducible installs are non-negotiable in CI.- Test across Node.js versions: The build matrix catches compatibility issues before your users do.
- Tag-based releases: Push a tag → trigger a release. Simple, auditable, and reversible.
- Secrets stay secret: Use GitHub repository secrets for tokens. Never hardcode credentials.
- Fail fast: Order steps from cheapest to most expensive — lint before build, build before test.
✅ Practice: Verify Your Understanding
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.
- Simulate a release: Create a tag on your fork:
git tag v0.0.1-test git push origin v0.0.1-testWatch the release workflow run. It will build and package (the publish step will skip since you won’t have a
VSCE_PATsecret — that’s expected). - Inspect the artifact: After the release workflow completes, go to the workflow run and download the
.vsixartifact. Install it in VS Code withcode --install-extension <file>.vsixto confirm the package is valid.
📚 Further Reading
- GitHub Actions Documentation — Official guides and reference
- Publishing VS Code Extensions — The vsce tool and Marketplace setup
- VS Code Extension API — Extension development fundamentals
- vs-sonic-pi repository — The real-world example used in this article
- Node.js Release Schedule — Track LTS and EOL dates for your CI matrix
🤝 Contributing
Found an issue with this guide? Have a pipeline pattern to share?