Token-Free VS Code Extension Publishing From CI (Work in Progress)
Status: working end-to-end. One
Run workflowclick publishes the extension from CI to both the VS Code Marketplace (token-free, via Entra ID
- GitHub OIDC) and Open VSX — confirmed with a real
1.0.1release to both. The full map, including every landmine, is below. Still tagged WIP only because a couple of cosmetic follow-ups remain (Open VSX namespace verification); the hard problems are solved.
Publishing an npm package from GitHub Actions with no stored secret is a solved
problem. You wire up a trusted publisher, add id-token: write, and npm does an
OIDC token exchange at publish time. It took me one small workflow and it worked
on the first release.
I assumed publishing a VS Code extension from the same repo would be the same shape. It is not. The VS Code Marketplace runs on Azure DevOps, and Azure DevOps is in the middle of retiring the exact credential the Marketplace requires — while the replacement (Microsoft Entra ID + OIDC) is real but under-documented, and studded with failure modes that each look like a different bug. This post is the map I wish I’d had.
The shape of the problem
The repo is a pnpm monorepo. Six public packages go to npm; one private package — a VS Code extension — goes to the VS Code Marketplace and, if you want to reach everyone, Open VSX as well. Three registries, three auth stories:
| Registry | Tool | Auth |
|---|---|---|
| npm | npm publish |
OIDC trusted publishing — no token |
| VS Code Marketplace | vsce |
Azure DevOps PAT or Entra ID |
| Open VSX | ovsx |
A plain access token |
npm is the easy one and the useful contrast. The trusted-publisher config lives
on npmjs.com, the workflow just declares id-token: write, and npm validates
the OIDC token’s repository / workflow / environment claims. No secret is
ever stored. That is the bar every other registry should be measured against.
Two marketplaces, not one
First non-obvious thing, before any auth: Open VSX is not the VS Code
Marketplace. Microsoft’s Marketplace serves Microsoft’s official VS Code
build, and Microsoft’s terms bar every other editor from using it. So VSCodium,
Cursor, Windsurf, Gitpod, Theia, and code-server all pull from
Open VSX instead — a vendor-neutral registry run by the
Eclipse Foundation. Publish to only one and you’ve left out half your potential
users. The workflow packages the .vsix once and pushes the same artifact
to both.
Open VSX is the merciful half: it uses a plain token (OVSX_PAT) and it’s
unaffected by anything Microsoft is deprecating. One catch — ovsx publish
won’t create your namespace, so the very first publish dies with
❌ Unknown publisher: knowvah. Run ovsx create-namespace knowvah once (the
workflow does it idempotently) and it’s smooth after that. Everything else
hard in this post is the Marketplace.
Why not a PAT
The obvious way to publish to the Marketplace is a
Personal Access Token:
create one in Azure DevOps with the Marketplace → Manage scope, store it as
VSCE_PAT, and run vsce publish -p $VSCE_PAT. I built exactly that first. Then
I read the fine print.
The Marketplace isn’t scoped to a single Azure DevOps organization, so its PAT has to be created with “All accessible organizations.” That’s a global PAT — and global PATs stop working on December 1, 2026. Building your release pipeline on a credential with a hard expiry, five months out, where the exact scope you need is the thing being retired, is building on sand.
Microsoft’s recommended replacement is Entra ID short-lived auth. For CI
that means: GitHub OIDC → an Entra token → vsce publish --azure-credential.
No stored secret, same model as npm. That’s the path I took. It is also where
the minefield starts.
The workflow
Here’s the whole publish workflow. It builds, packages the .vsix once, and
publishes to both registries — Marketplace via Entra/OIDC, Open VSX via token.
I’ll unpack the non-obvious parts below it.
name: Publish VS Code Extension
# Publishes the `dot-vscode` extension to the VS Code Marketplace (vsce) and
# Open VSX (ovsx). It packages the vsix once and pushes the same artifact to
# both registries.
#
# Auth model (deliberately NOT a global Azure DevOps PAT — those retire on
# 2026-12-01, and the Marketplace requires exactly that global scope):
# - VS Code Marketplace: Microsoft Entra ID via GitHub OIDC. `azure/login`
# exchanges the workflow's OIDC token for an Entra token (no stored secret);
# `vsce publish --azure-credential` uses it. REQUIRES the publisher to be
# owned by an Entra ORG tenant (the OIDC/service-principal flow does not
# work for a personally-owned publisher — microsoft/vscode-vsce#1023).
# - Open VSX: a plain access token (OVSX_PAT) — unaffected by the retirement.
on:
workflow_dispatch: {}
push:
tags:
- 'dot-vscode-v*'
concurrency: publish-vscode-$
permissions:
contents: read
id-token: write # OIDC → Entra token for the Marketplace (no stored PAT)
jobs:
publish:
runs-on: ubuntu-latest
environment: vscode-marketplace
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
with:
version: 10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Build every package: the extension's esbuild bundle resolves the
# workspace deps via their dist/ output.
- run: pnpm -r build
# Package the vsix once, then publish the SAME artifact to both registries.
- name: Package vsix
id: pack
working-directory: packages/vscode
run: |
set -euo pipefail
pnpm package
version="$(node -p "require('./package.json').version")"
vsix="dot-vscode-${version}.vsix"
test -f "$vsix"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "vsix=$vsix" >> "$GITHUB_OUTPUT"
# For a tag trigger, refuse to publish if the tag and the packaged version
# disagree (guards against a stale tag).
- name: Verify tag matches version
if: startsWith(github.ref, 'refs/tags/')
run: |
set -euo pipefail
expected="dot-vscode-v$"
if [ "$" != "$expected" ]; then
echo "::error::Tag $ != $expected"; exit 1
fi
# OIDC → Entra token. No stored secret; the identity is trusted via a
# federated credential on the `vscode-marketplace` environment.
- name: Azure login (Entra ID via OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: $
tenant-id: $
allow-no-subscriptions: true
- name: Publish to VS Code Marketplace (Entra ID)
working-directory: packages/vscode
run: pnpm exec vsce publish --azure-credential --packagePath "$"
- name: Publish to Open VSX
working-directory: packages/vscode
env:
OVSX_PAT: $
run: pnpm exec ovsx publish "$"
That’s ~90 lines of YAML. The YAML was never the hard part. The hard part is every assumption baked into it that only reveals itself when it fails.
Landmine 1: restricted actions turn every push into a silent startup_failure
Unrelated to publishing, but it bit me first, so it goes first. The repo has
Allowed actions set to Selected — a good hardening default that only lets
allow-listed actions run. The moment I added a workflow using an action that
wasn’t on the list, GitHub refused to start the run with a startup_failure
and the unhelpful message “This run likely failed because of a workflow file
issue.” The workflow file was fine. The action just wasn’t permitted.
Worse: a startup_failure cannot be re-run — there’s no job to retry — and
a workflow that only triggers on push has no other way to fire. The fix is two
parts. Allow-list the actions:
{
"github_owned_allowed": true,
"verified_allowed": true,
"patterns_allowed": [
"googleapis/release-please-action@*",
"pnpm/action-setup@*",
"Azure/login@*"
]
}
gh api repos/OWNER/REPO/actions/permissions/selected-actions -X PUT --input allowlist.json
…and give the workflow a manual trigger so you can re-run it without waiting for
another push. workflow_dispatch earns its keep here:
on:
workflow_dispatch: {}
push:
tags: ['dot-vscode-v*']
Takeaway: if you lock down allowed actions, every new third-party action —
including azure/login — needs a matching pattern, or the run dies before it
starts, with a message that points you at the wrong thing.
Landmine 2: immutable OIDC subjects and AADSTS700213
This is the one that cost me the most time, because the error is precise and the cause is somewhere you’re not looking.
To trust the workflow, you create a federated credential on the Entra identity: it says “issue tokens to whoever presents this OIDC subject.” The subject for an environment-scoped job has historically been:
repo:OWNER/REPO:environment:vscode-marketplace
Except GitHub shipped immutable subject claims in 2026. Once enabled (default for repos created after 2026-07-15; opt-in otherwise), the OIDC token’s subject embeds the numeric org ID and repo ID, so a renamed or re-created repo can’t impersonate the original:
repo:OWNER@ORG_ID/REPO@REPO_ID:environment:vscode-marketplace
Note the @ separators — @ is illegal in GitHub names, so it’s an unambiguous
delimiter. You can read the current state (and the exact prefix) straight from
the API:
gh api repos/OWNER/REPO/actions/oidc/customization/sub
# {"use_default": true,
# "use_immutable_subject": true,
# "sub_claim_prefix": "repo:knowvah@111111111/dot-plugins@2222222222"}
If your repo emits the immutable subject but your federated credential is
configured with the legacy one — or vice versa — the token and the credential
don’t match, and azure/login fails with:
AADSTS700213: No matching federated identity record found for presented
assertion subject 'repo:knowvah@111111111/dot-plugins@2222222222:environment:vscode-marketplace'.
The error is doing you a favor: it prints the subject the token actually
carried. Copy that string verbatim into the federated credential’s Subject
field (leave Issuer https://token.actions.githubusercontent.com and Audience
api://AzureADTokenExchange alone). If the Azure portal’s GitHub wizard won’t
let you edit the subject, delete the credential and re-add it as a custom /
other issuer credential with the exact string.
Takeaway: the federated-credential subject must be byte-for-byte the token’s
sub claim. With immutable subjects on, that means the ID form. Read
sub_claim_prefix from the API instead of guessing, and when in doubt, read the
subject back out of the AADSTS700213 message.
Landmine 3: managed identity, not app registration
The intuitive Entra object to create is an App Registration — it supports
federated credentials, it has a client ID, azure/login accepts it. It
authenticates perfectly. Then vsce publish fails at the actual upload with:
InvalidAccessException: The requested operation is not allowed
The workaround, per the practitioners who’ve been down this road, is to use a
user-assigned managed identity instead of an app registration
(writeup).
Same federated-credential mechanism, same client-id/tenant-id inputs to
azure/login, so the workflow doesn’t change — but a managed identity is an
Azure resource, so it needs an Azure subscription behind it (an app
registration doesn’t).
And the whole path only works at all if the publisher is owned by an Entra org tenant, not a personal Microsoft account. A personally-owned publisher fails with a “corporate credentials required” error that Microsoft closed as not planned (vscode-vsce#1023).
Takeaway: user-assigned managed identity + org-tenant-owned publisher. App registration and personal publisher each authenticate and then fail at publish, which sends you debugging the wrong layer.
Landmine 4: authorizing the identity on the publisher (the undocumented step)
Here’s the one with no real documentation. Once the identity can authenticate, it still isn’t authorized to publish — you have to add it as a member of the publisher with the Contributor role, at marketplace.visualstudio.com/manage.
The catch: the Members search does not accept the identity’s client ID, tenant ID, object ID, or resource ID. It wants the identity’s Team Foundation ID — a value you only get by making the identity call an Azure DevOps API as itself, which also serves to register it with Azure DevOps in the first place.
The clean way to make that call, without spinning up a VM or storing a secret,
is to reuse the federation you just built: a throwaway workflow_dispatch
workflow that logs in via OIDC and hits the identity endpoint. First attempt
used the profile API — /_apis/profile/profiles/me — which 404s for a service
principal / managed identity (it’s a user profile endpoint). The endpoint
that works for a non-human identity is connectionData, whose
authenticatedUser.id is the ID you’re after. Here’s the current probe:
name: Get Azure DevOps profile ID (throwaway)
# TEMPORARY bootstrap helper — DELETE once you have the ID.
#
# Authenticates as the Marketplace managed identity via GitHub OIDC and probes
# Azure DevOps for the identity's Team Foundation ID — the value you add as a
# member of the publisher (role: Contributor). The member search does not
# accept the client/tenant/object/resource IDs.
on:
workflow_dispatch: {}
permissions:
contents: read
id-token: write # OIDC → Entra token (no stored secret)
jobs:
profile-id:
runs-on: ubuntu-latest
environment: vscode-marketplace # must match the federated-credential subject
steps:
- name: Azure login (Entra ID via OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: $
tenant-id: $
allow-no-subscriptions: true
- name: Probe Azure DevOps identity endpoints
run: |
set -uo pipefail # not -e: run every probe even if one fails
AZDO_RESOURCE="499b84ac-1321-427f-aa17-267ca6975798"
echo "Requesting an Azure DevOps token as the managed identity..."
token="$(az account get-access-token --resource "$AZDO_RESOURCE" --query accessToken -o tsv)"
probe() {
echo "=================================================="
echo "GET $1"
code="$(curl -sS -o /tmp/body -w '%{http_code}' \
-H "Authorization: Bearer $token" \
-H 'Accept: application/json' "$1" || echo 000)"
echo "HTTP $code"
if command -v jq >/dev/null && jq . /tmp/body >/dev/null 2>&1; then
jq . /tmp/body
else
cat /tmp/body 2>/dev/null || true; echo
fi
echo
}
# connectionData is the canonical "who am I" — works for service
# principals / managed identities. authenticatedUser.id is the Team
# Foundation ID; .subjectDescriptor is the modern descriptor.
probe 'https://app.vssps.visualstudio.com/_apis/connectionData'
# profile/me is user-centric and typically 404s for an SP/MI — kept
# only as a comparison data point.
probe 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1'
That constant — 499b84ac-1321-427f-aa17-267ca6975798 — is the fixed Azure
DevOps application ID. You request a token for that resource, then call the
account-level SPS host (app.vssps.visualstudio.com) with it.
There’s a provisioning wrinkle: the first call can 404, because the
identity isn’t known to Azure DevOps yet — and that very call is what registers
it. Re-run the probe and it comes back 200 with the identity. What you’re
looking for is authenticatedUser.id:
{
"authenticatedUser": {
"id": "33333333-3333-3333-3333-333333333333",
"descriptor": "Microsoft.VisualStudio.Services.Claims.AadServicePrincipal;<tenant>\\<client-id>",
"subjectDescriptor": "aadsp.MzMzMzMzMzMtMzMzMy0zMzMzLTMzMzMtMzMzMzMzMzMzMzMz",
"isActive": true
}
}
Once the identity is registered, profile/me starts returning 200 too (same
GUID as publicAlias), but connectionData is the call that works from a cold
start. Take that id — 33333333-… — to marketplace.visualstudio.com/manage
→ the publisher → Members → Add, paste it, set the role to Contributor,
save. (If the raw ID won’t resolve in the search, fall back to the identity’s
display name, <tenant>\<client-id>.)
That was the last wall. With the managed identity now a Contributor member —
authorized, not merely authenticated — vsce publish --azure-credential
uploaded the extension to the Marketplace on the next run. No token stored
anywhere in the pipeline.
Takeaway: authentication ≠ authorization. The identity must be a Contributor
member of the publisher, added by a Team Foundation ID that you can only obtain
by calling connectionData as the identity — profile/me is the wrong
endpoint for a non-human identity, and the first call may 404 while it
provisions.
Landmine 5: npm and the Marketplace disagree about what a keyword is
This one surfaced after auth was fully working — a publish that had gone green started failing:
Error: The supplied tag '@knowvah/dot-engine' is not a valid tag. It must not
be empty or start with a special character (!,@,#,$,%,^,&,*).
The extension’s engine dependency had been renamed from graphviz-ts to the
scoped @knowvah/dot-engine, and a blanket find-and-replace swept the
package.json keywords array up with it. On npm, a keywords entry can be
anything — a scoped @scope/name included. But VS Code maps keywords to
Marketplace tags, and a tag can’t start with a special character like @.
So the exact manifest npm is happy with, the Marketplace rejects — and nothing
catches it until vsce validates at publish time, long after the rename looked
done.
The fix is a one-word manifest change (drop the @… keyword or swap it for a
real search term like svg). The lesson is the trap: keywords is
overloaded — a free-form list on npm, a constrained tag list on the
Marketplace — so a rename that’s correct for one registry can silently break the
other.
Takeaway: keep Marketplace keywords free of @, /, and leading special
characters, and when you scope-rename a dependency, don’t let the rename touch
the extension’s keywords.
A couple of things that were quietly correct
Not everything fought back. Worth writing down because they’re easy to get wrong and I didn’t have to touch them again:
- Pin actions to SHAs. Every
uses:in these workflows is a full commit SHA with the tag in a comment (# v2). With Selected actions enabled and a security posture that cares, a mutable tag is a supply-chain hole; the SHA closes it. - Scope
id-token: writeto the job that needs it, and put the publish job behind anenvironment(vscode-marketplace) whose deployment branches are locked tomain. The federated-credential subject is environment-based, so the environment gate and the OIDC trust reinforce each other: a token minted from any other branch or environment won’t match. - Package once, publish twice. Building the
.vsixa single time and handing the identical artifact to bothvsceandovsxmeans the two registries can never drift.
Where this stands
What’s working now — the whole point of the exercise:
- npm publishing via OIDC — done, and the easy contrast that set my (wrong) expectations.
- VS Code Marketplace publishing works end-to-end with no stored token.
GitHub OIDC →
azure/login→ an Entra token →vsce publish --azure-credential, authorized by a user-assigned managed identity that’s a Contributor member of the publisher. Immutable subject claims enabled;AADSTS700213and the app-registrationInvalidAccessExceptionboth behind me. The extension uploaded.
If you’re walking this path, this is the order that matters — get these four right and most of the pain in this post evaporates:
- User-assigned managed identity (not an app registration).
- Federated credential with the immutable subject
(
repo:OWNER@ORG_ID/REPO@REPO_ID:environment:ENV). - Contributor member of the publisher, added by the Team Foundation ID from
connectionData. - Publish with
vsce publish --azure-credential.
- Open VSX also works — once the namespace exists. The first publish failed
with
Unknown publisher: knowvahpurely becauseovsxwon’t create the namespace for you;ovsx create-namespace knowvahfixed it, and the workflow now does that step idempotently.
Both publish steps are also wrapped to be idempotent on re-run — if one registry succeeds and the other fails (exactly what happened here: Marketplace published, Open VSX didn’t), re-running skips the already-published registry instead of colliding on it. A two-registry pipeline needs that, or every partial failure forces a version bump to recover.
Both registries then took 1.0.1 cleanly on a single Run workflow — the
Marketplace publish reported Published knowvah.dot-vscode v1.0.1 and Open VSX
🚀 Published knowvah.dot-vscode v1.0.1 (its create-namespace no-op’d on the
already-existing namespace, and the idempotent wrapper carried on). The
Marketplace listing then sat in its usual post-publish verification scan for
a few minutes before going searchable. That’s the finish line.
The only things left are cosmetic: verifying Open VSX namespace ownership (for the “verified” badge), and a couple of UI screenshots.
Sources
- Retirement of global PATs in Azure DevOps
- Immutable subject claims for GitHub Actions OIDC
- Service principals & managed identities on Azure DevOps
- vscode-vsce #1023 —
--azure-credentialand personal publishers - Publishing to the VS Code Marketplace with CI (Emre Ozsahin)
- Publishing Extensions — VS Code API