πŸš€ 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:

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]

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?

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:

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
  1. Lint (npm run lint): Catches style issues, unused imports, and code quality problems.
  2. Build (npm run build): Compiles TypeScript, bundles with esbuild. Validates that the codebase compiles (this is where type errors surface).
  3. 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:

Build & Test (Again)

The release workflow re-runs build and test, even though CI already ran on the code. This is intentional:

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

  1. Go to dev.azure.com
  2. Sign in with the Microsoft account associated with your VS Code Marketplace publisher
  3. Click your profile icon β†’ Personal access tokens
  4. Click + New Token
  5. Configure:
  6. Click Create and copy the token immediately β€” it’s only shown once

Step 2: Add the Secret to GitHub

  1. Go to your repository β†’ Settings β†’ Secrets and variables β†’ Actions
  2. Click New repository secret
  3. Name: VSCE_PAT
  4. Value: Paste the token from Step 1
  5. 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:

  1. Bumps "version" in package.json (e.g., 0.1.0 β†’ 0.1.1)
  2. Creates a git commit: v0.1.1
  3. 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:

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:

  1. Repository Settings β†’ Branches β†’ Add rule
  2. Apply to main
  3. Enable:

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

  1. Two workflows are enough to start: CI for quality gates, Release for publishing. Don’t over-engineer day one.
  2. npm ci over npm install: Reproducible installs are non-negotiable in CI.
  3. Test across Node.js versions: The build matrix catches compatibility issues before your users do.
  4. Tag-based releases: Push a tag β†’ trigger a release. Simple, auditable, and reversible.
  5. Secrets stay secret: Use GitHub repository secrets for tokens. Never hardcode credentials.
  6. 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:

  1. 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.

  2. 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.

  3. Simulate a release: Create a tag on your fork:
    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).

  4. Inspect the artifact: After the release workflow completes, go to the workflow run and download the .vsix artifact. Install it in VS Code with code --install-extension <file>.vsix to confirm the package is valid.

πŸ“š Further Reading


🀝 Contributing

Found an issue with this guide? Have a pipeline pattern to share?