Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 @@ -8,6 +8,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;
}
79 changes: 79 additions & 0 deletions cli/src/trusted-publishing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/******************************************************************************
* 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 { getIdToken, hasIdTokenSource } from './oidc';
import { AccessToken, Registry } from './registry';
import { TrustedPublishingOptions } from './trusted-publishing-options';

const tokens = new Map<string, Promise<string>>();

/**
* Whether the given options ask for trusted publishing. If the user did not decide explicitly,
* it is used whenever the surrounding CI system can provide an OIDC ID token.
*/
export function useTrustedPublishing(options: TrustedPublishingOptions): boolean {
return options.trustedPublishing ?? hasIdTokenSource(options);
}

/**
* Exchanges an OIDC ID token for a short-lived access token that can publish the given extension.
* The token is not stored, it is only valid for a few minutes.
*/
export function getTrustedPublishingToken(
registry: Registry,
namespace: string,
extension: string,
options: TrustedPublishingOptions
): Promise<string> {
// publishing fans out over targets and package paths, but one token is enough per extension
const key = `${namespace}.${extension}`;
let token = tokens.get(key);
if (!token) {
token = requestToken(registry, namespace, extension, options);
tokens.set(key, token);
}

return token;
}

async function requestToken(
registry: Registry,
namespace: string,
extension: string,
options: TrustedPublishingOptions
): Promise<string> {
const audience = options.oidcAudience ?? registry.url;
const idToken = await getIdToken(audience, options);

let result: AccessToken;
try {
result = await registry.requestTrustedPublishingToken(namespace, extension, idToken);
} catch (err) {
throw new Error(`${err.message}\n${registrationHint(registry, namespace, extension)}`);
}
if (result.error) {
throw new Error(`${result.error}\n${registrationHint(registry, namespace, extension)}`);
}
if (!result.value) {
throw new Error('The registry did not return a publishing token.');
}

const expires = result.expiresTimestamp ? `, expires at ${result.expiresTimestamp}` : '';
console.log(`\ud83d\udd10 Trusted publishing token issued for ${namespace}.${extension}${expires}`);
return result.value;
}

function registrationHint(registry: Registry, namespace: string, extension: string): string {
return `Check the trusted publishers registered for '${namespace}.${extension}' at `
+ `${registry.url}/user-settings/trusted-publishers.`;
}
Loading
Loading