Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2e26ce7
feat: Trusted Publishing support (#1980)
cstamas Jul 27, 2026
0a37bfd
fix import due to react-router bump
netomi Jul 27, 2026
38cd6c9
increment migration number after rebase
netomi Jul 28, 2026
a91c7ab
feat: adding TP enabled flag (#2002)
gnugomez Jul 28, 2026
3e799b9
Fix: the build fails due flyway migration conflict (#2003)
cstamas Jul 28, 2026
7bccdda
Do not use properties duplicated across (#2004)
cstamas Jul 28, 2026
75a7dbb
feat: add support for trusted publishing to cli (#2001)
netomi Jul 28, 2026
bf8d097
Multiple TP fixes (#2010)
cstamas Jul 29, 2026
5fb7e65
feat: adapting webui to new TP status endpoint (#2017)
gnugomez Jul 30, 2026
b2b33ee
Tokens cleanup (#2015)
cstamas Jul 30, 2026
94fe1a0
Align DDL with expectation (#2019)
cstamas Jul 30, 2026
f6f3a3f
Allow TP registration only for existing active extensions (#2020)
cstamas Jul 30, 2026
0ec40b1
Reconnect TP to Extension (#2021)
cstamas Jul 30, 2026
34e2f20
adapt frontend to backend changes (#2022)
netomi Jul 30, 2026
1b6459b
increment migration number after rebase
netomi Jul 31, 2026
23ed846
fix test after rebase: findAccessTokens was renamed to findPersonalAc…
netomi Jul 31, 2026
21fe2ef
remove empty file after rebase
netomi Jul 31, 2026
e854925
Introduce TPT token type + token scoping (#2034)
cstamas Aug 3, 2026
e199b86
Reformat (#2035)
cstamas Aug 3, 2026
ca95daa
feat: Configure active providers (#2045)
cstamas Aug 5, 2026
063e431
feat: PAT improvements (UUIDv7 for random + hashing) (#2048)
cstamas Aug 7, 2026
eae3d21
Merge remote-tracking branch 'upstream/main' into trusted-publishing-…
cstamas Aug 10, 2026
abe64e4
Apply fixes
cstamas Aug 10, 2026
6f49de5
Merge branch 'main' of https://github.com/eclipse-openvsx/openvsx int…
netomi Aug 21, 2026
a7478da
Update pull request branches in workflow
netomi Aug 21, 2026
77a2ca0
Apply suggestions from code review
netomi Aug 21, 2026
e487337
fix: stop leaking a notched DOM attribute from the publisher role filter
netomi Aug 21, 2026
cd609ca
Merge branch 'main' into trusted-publishing
netomi Aug 21, 2026
755f595
fix formatting
netomi Aug 21, 2026
ca161ef
suppress error log in test for expected error
netomi Aug 21, 2026
2459686
fix javadoc
netomi Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ on:
types: [opened, synchronize, reopened]
branches:
- main
- trusted-publishing

env:
REGISTRY: ghcr.io
Expand Down Expand Up @@ -51,6 +52,10 @@ jobs:
working-directory: cli
run: yarn

- name: Test CLI
working-directory: cli
run: yarn test

build-and-push-webui:
permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This change log covers only the command line interface (CLI) of Open VSX.

- Add an encrypted filestore as fallback to the system keychain if it cant be accessed ([#1950](https://github.com/eclipse/openvsx/pull/1950))
- Add `--allow-missing-repository` option to the `publish` command, passed on to `vsce` to package an extension whose `package.json` has no `repository` field without asking for confirmation ([#1735](https://github.com/eclipse-openvsx/openvsx/issues/1735))
- Support trusted publishing: `publish` can exchange an OIDC ID token for a short-lived publishing token, via `--trusted-publishing`, `--idToken` and `--oidcAudience`

#### Changed

Expand Down
29 changes: 29 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ Variants:
* `ovsx publish <file>`
publishes an already packaged file.

### Trusted Publishing

Instead of a long-lived personal access token, `ovsx` can publish from a CI workflow with a short-lived token that the registry issues in exchange for an OIDC ID token of the workflow. The registry only issues such a token if the workflow matches a trusted publisher that a namespace owner registered under [trusted publishers](https://open-vsx.org/user-settings/trusted-publishers). No access token needs to be stored as a secret.

On GitHub Actions the workflow needs the `id-token: write` permission, everything else is detected automatically:

```yaml
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
# only needed if the trusted publisher pins an environment
environment: publish
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- run: npm ci
- run: npx ovsx publish --trusted-publishing
```

Options:
* `--trusted-publishing` requires trusted publishing and fails if no ID token can be obtained. Without it, trusted publishing is used whenever an ID token is available and no access token was given; a `--pat` (or `OVSX_PAT`) always takes precedence.
* `--idToken <token>` passes the ID token explicitly. Use this on CI systems that expose the token as a variable, for example with GitLab CI's [`id_tokens`](https://docs.gitlab.com/ci/yaml/#id_tokens) keyword. The environment variable `OVSX_ID_TOKEN` does the same.
* `--oidcAudience <audience>` sets the audience requested for the ID token, by default the registry URL. Use it if the registry expects a different audience, and make sure it matches the `aud` claim the registry validates. The environment variable `OVSX_OIDC_AUDIENCE` does the same.

The issued token is valid for a few minutes only and is never written to the token store, but it does carry publishing rights, so treat CI logs accordingly.

### Create a Namespace

The `publisher` field of your extension's package.json defines the namespace into which the extension will be published. Before you publish the first extension in a namespace, you must create it. This requires an access token as described above.
Expand Down
6 changes: 4 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@
"eslint": "^9.15.0",
"limiter": "^2.1.0",
"rimraf": "^6.0.1",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"vitest": "^4.1.10"
},
"scripts": {
"clean": "rimraf lib",
"prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts",
"build": "tsc -p ./tsconfig.json && yarn run lint",
"watch": "tsc -w -p ./tsconfig.json",
"lint": "eslint -c ./configs/eslintrc.mjs src",
"test": "vitest run",
"lint": "eslint -c ./configs/eslintrc.mjs src test",
"prepare": "yarn run clean && yarn run prebuild && yarn run build",
"publish:next": "yarn npm publish --tag next",
"publish:latest": "yarn npm publish --tag latest",
Expand Down
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export * from './publish';
export * from './publish-options';
export * from './registry';
export * from './registry-options';
export * from './trusted-publishing';
export * from './trusted-publishing-options';
export * from './verify-pat';
export * from './verify-pat-options';
export { isLicenseOk } from './check-license';
Expand Down
7 changes: 5 additions & 2 deletions cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ module.exports = function (argv: string[]): void {
.option('--no-dependencies', 'Disable dependency detection via npm or yarn')
.option('--skip-duplicate', 'Fail silently if version already exists on the marketplace')
.option('--packageVersion <version>', 'Version of the provided VSIX packages.')
.action((extensionFile: string, { target, packagePath, baseContentUrl, baseImagesUrl, yarn, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion }) => {
.option('--trusted-publishing', 'Exchange an OIDC ID token for a short-lived publishing token. Enabled automatically when a CI system provides an ID token and no access token is given.')
.option('--idToken <token>', 'The OIDC ID token to exchange. Only needed on CI systems that provide the token directly, e.g. GitLab CI.')
.option('--oidcAudience <audience>', 'Audience to request for the OIDC ID token. Defaults to the registry URL.')
.action((extensionFile: string, { target, packagePath, baseContentUrl, baseImagesUrl, yarn, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience }) => {
if (extensionFile !== undefined && packagePath !== undefined) {
console.error('\u274c Please specify either a package file or a package path, but not both.\n');
publishCmd.help();
Expand All @@ -75,7 +78,7 @@ module.exports = function (argv: string[]): void {
if (extensionFile !== undefined && allowMissingRepository !== undefined)
console.warn("Ignoring option '--allow-missing-repository' for prepackaged extension.");
const { registryUrl, pat } = program.opts();
publish({ extensionFile, registryUrl, pat, targets: typeof target === 'string' ? [target] : target, packagePath: typeof packagePath === 'string' ? [packagePath] : packagePath, baseContentUrl, baseImagesUrl, yarn, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion })
publish({ extensionFile, registryUrl, pat, targets: typeof target === 'string' ? [target] : target, packagePath: typeof packagePath === 'string' ? [packagePath] : packagePath, baseContentUrl, baseImagesUrl, yarn, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience })
.then(results => {
const reasons = results.filter(result => result.status === 'rejected')
.map(rejectedResult => rejectedResult.reason);
Expand Down
92 changes: 92 additions & 0 deletions cli/src/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

import * as http from 'http';
import * as followRedirects from 'follow-redirects';
import { TrustedPublishingOptions } from './trusted-publishing-options';
import { statusError } from './util';

/**
* Whether an OIDC ID token can be obtained without user interaction.
*/
export function hasIdTokenSource(options: TrustedPublishingOptions): boolean {
return Boolean(options.idToken) || isGitHubActionsIdTokenAvailable();
}

/**
* Obtains an OIDC ID token for the given audience from the surrounding CI system.
*/
export async function getIdToken(audience: string, options: TrustedPublishingOptions): Promise<string> {
// CI systems such as GitLab CI provide the ID token directly as an environment variable
if (options.idToken) {
return options.idToken;
}
if (isGitHubActionsIdTokenAvailable()) {
return getGitHubActionsIdToken(audience);
}
throw new Error('No OIDC ID token available for trusted publishing.\n'
+ "On GitHub Actions, grant the job the 'id-token: write' permission.\n"
+ 'On other CI systems, pass the ID token via the --idToken argument '
+ 'or the OVSX_ID_TOKEN environment variable.');
}

function isGitHubActionsIdTokenAvailable(): boolean {
return Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
}

async function getGitHubActionsIdToken(audience: string): Promise<string> {
// the request URL already carries an api-version query parameter, so keep its query intact
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL!);
url.searchParams.set('audience', audience);
const response = await getJson<GitHubIdTokenResponse>(url, {
'Authorization': `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
'Accept': 'application/json'
});
if (!response.value) {
throw new Error('GitHub Actions did not return an OIDC ID token.');
}
return response.value;
}

/**
* Minimal JSON GET that is not bound to the registry: the request must not carry any registry
* credentials, as it is sent to the CI system's token service.
*/
function getJson<T>(url: URL, headers: http.OutgoingHttpHeaders): Promise<T> {
return new Promise((resolve, reject) => {
const protocol = url.protocol === 'https:' ? followRedirects.https : followRedirects.http;
const request = protocol.request(url, { method: 'GET', headers }, response => {
response.setEncoding('utf-8');
let json = '';
response.on('data', chunk => json += chunk);
response.on('end', () => {
if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
reject(statusError(response));
} else {
try {
resolve(JSON.parse(json));
} catch (err) {
reject(err);
}
}
});
});
request.on('error', reject);
request.end();
});
}

interface GitHubIdTokenResponse {
count?: number;
value?: string;
}
3 changes: 2 additions & 1 deletion cli/src/publish-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import { RegistryOptions } from './registry-options';
import { TrustedPublishingOptions } from './trusted-publishing-options';

export interface PublishCommonOptions extends RegistryOptions {
export interface PublishCommonOptions extends RegistryOptions, TrustedPublishingOptions {
/**
* Path to the vsix file to be published. Cannot be used together with `packagePath`.
*/
Expand Down
10 changes: 7 additions & 3 deletions cli/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@
********************************************************************************/
import { createVSIX, IPackageOptions } from '@vscode/vsce';
import { getPAT } from './pat';
import { createTempFile, addEnvOptions } from './util';
import { createTempFile, addEnvOptions, addTrustedPublishingEnvOptions } from './util';
import { Extension, Registry } from './registry';
import { checkLicense } from './check-license';
import { readVSIXPackage } from './zip';
import { PublishOptions, PublishCommonOptions } from './publish-options';
import { getTrustedPublishingToken, useTrustedPublishing } from './trusted-publishing';

/**
* Publishes an extension.
*/
export async function publish(options: PublishOptions = {}): Promise<PromiseSettledResult<void>[]> {
addEnvOptions(options);
addTrustedPublishingEnvOptions(options);
const internalPublishOptions: InternalPublishOptions[] = [];
const packagePaths = options.packagePath || [undefined];
const targets = options.targets || [undefined];
Expand Down Expand Up @@ -48,8 +50,10 @@ async function doPublish(options: InternalPublishOptions = {}): Promise<void> {
}

if (!options.pat) {
const namespace = (await readVSIXPackage(options.extensionFile!)).publisher;
options.pat = await getPAT(namespace, options);
const manifest = await readVSIXPackage(options.extensionFile!);
options.pat = useTrustedPublishing(options)
? await getTrustedPublishingToken(registry, manifest.publisher, manifest.name, options)
: await getPAT(manifest.publisher, options);
}

let extension: Extension | undefined;
Expand Down
22 changes: 22 additions & 0 deletions cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { rejectError, statusError } from './util';
export const DEFAULT_URL = 'https://open-vsx.org';
export const DEFAULT_NAMESPACE_SIZE = 1024;
export const DEFAULT_PUBLISH_SIZE = 512 * 1024 * 1024;
export const DEFAULT_TOKEN_REQUEST_SIZE = 8 * 1024;

export class Registry {

Expand Down Expand Up @@ -76,6 +77,18 @@ export class Registry {
}
}

requestTrustedPublishingToken(namespace: string, extension: string, idToken: string): Promise<AccessToken> {
try {
const url = this.getUrl(['api', '-', 'trusted-publishing', 'token']);
const request = { namespace, extension, token: idToken };
return this.post(JSON.stringify(request), url, {
'Content-Type': 'application/json'
}, DEFAULT_TOKEN_REQUEST_SIZE);
} catch (err) {
return rejectError(err);
}
}

getMetadata(namespace: string, extension: string, target?: string): Promise<Extension> {
try {
const segments = ['api', namespace, extension];
Expand Down Expand Up @@ -265,6 +278,15 @@ export interface Extension extends Response {
bundledExtensions?: ExtensionReference[];
}

export interface AccessToken extends Response {
id: number;
value?: string;
description: string;
createdTimestamp: string;
accessedTimestamp?: string;
expiresTimestamp?: string;
}

export interface UserData {
loginName: string;
fullName?: string;
Expand Down
30 changes: 30 additions & 0 deletions cli/src/trusted-publishing-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/******************************************************************************
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*****************************************************************************/

export interface TrustedPublishingOptions {
/**
* Obtain a short-lived publishing token by exchanging an OIDC ID token. If unset, trusted
* publishing is used whenever an ID token source is detected and no access token is available.
*/
trustedPublishing?: boolean;
/**
* The OIDC ID token to exchange. Only needed for CI systems that expose the token directly,
* such as GitLab CI; on GitHub Actions the token is requested from the workflow runtime.
*/
idToken?: string;
/**
* The audience to request for the OIDC ID token. Defaults to the registry URL and must match
* the audience the registry expects.
*/
oidcAudience?: string;
}
Loading