Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 .changeset/calm-tools-publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'vscode-graphqlsp': patch
---

Publish versioned VS Code extension releases through the existing Changesets workflow using Microsoft Entra workload identity authentication.
6 changes: 5 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"commit": false,
"access": "public",
"baseBranch": "main",
"ignore": ["example", "fixtures", "vscode-graphqlsp"],
"ignore": ["example", "fixtures"],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "minor",
"snapshot": {
"prereleaseTemplate": "{tag}-{commit}",
Expand Down
42 changes: 42 additions & 0 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Releasing

Releases are managed by Changesets through `.github/workflows/release.yaml`.
Merging the generated `Version Packages` pull request stages unpublished npm
packages and publishes unpublished versions of `vscode-graphqlsp` to the VS
Code Marketplace. The protected `npm` GitHub environment gates both release
channels.

## VS Code Marketplace setup

Marketplace publishing uses `vsce publish --azure-credential` with Microsoft
Entra workload identity federation. It does not use a long-lived Personal
Access Token.

Before enabling the workflow:

1. Create an Entra application and service principal for Marketplace
publishing.
2. Add a federated credential that trusts GitHub's OIDC issuer for
`repo:0no-co/GraphQLSP:environment:npm` with audience
`api://AzureADTokenExchange`.
3. Add the service principal to the `0no-co` Visual Studio Marketplace
publisher with permission to publish extensions.
4. Configure these GitHub variables for the protected `npm` environment:
- `VSCE_AZURE_CLIENT_ID`
- `VSCE_AZURE_TENANT_ID`

## Versioning the extension

`vscode-graphqlsp` is private on npm, but Changesets versions it and generates
its changelog. Include it in a changeset whenever extension code changes or a
new bundled `@0no-co/graphqlsp` version should reach Marketplace users. The
extension and npm package versions remain independent.

The release check queries both npm and the Marketplace. An extension-only
release therefore still starts the publish job. The workflow packages the VSIX
before mutating remote release state, stages npm packages, publishes the exact
VSIX, and then lets `changesets/action` push tags and create GitHub releases.

npm staging and Marketplace publishing are not atomic. If Marketplace
publishing fails after npm staging, inspect or discard the existing npm stage
before rerunning the workflow.
28 changes: 23 additions & 5 deletions .github/scripts/has-unpublished-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { appendFileSync, existsSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

import { marketplaceVersionExists } from './marketplace.mjs';

// .staging is the vsix staging area of the VSCode extension; it contains a
// copy of the extension's manifest with the `private` flag stripped
const ignoredDirectories = new Set(['.git', 'dist', 'node_modules', '.staging']);
Expand Down Expand Up @@ -56,7 +58,7 @@ async function main() {
const packages = (await findPackageManifests(process.cwd())).sort((a, b) =>
a.name.localeCompare(b.name)
);
let hasUnpublished = false;
let hasUnpublishedNpm = false;

for (const pkg of packages) {
const isPublished = await hasPublishedVersion(pkg);
Expand All @@ -65,14 +67,30 @@ async function main() {
console.log(`${pkg.name}@${pkg.version} is already published`);
} else {
console.log(`${pkg.name}@${pkg.version} is not published yet`);
hasUnpublished = true;
hasUnpublishedNpm = true;
}
}

const extensionManifest = JSON.parse(
await readFile(path.join(workspaceRoot, 'packages/vscode-graphqlsp/package.json'), 'utf8')
);
const extensionId = `${extensionManifest.publisher}.${extensionManifest.name}`;
const extensionIsPublished = await marketplaceVersionExists(extensionManifest);
console.log(
`${extensionId}@${extensionManifest.version} is ${
extensionIsPublished ? 'already published' : 'not published yet'
}`
);

const hasUnpublishedExtension = !extensionIsPublished;
const hasUnpublished = hasUnpublishedNpm || hasUnpublishedExtension;
const output =
[`has_unpublished=${String(hasUnpublished)}`, `should_publish=${String(hasUnpublished)}`].join(
'\n'
) + '\n';
[
`has_unpublished=${String(hasUnpublished)}`,
`has_unpublished_npm=${String(hasUnpublishedNpm)}`,
`has_unpublished_vscode_extension=${String(hasUnpublishedExtension)}`,
`should_publish=${String(hasUnpublished)}`,
].join('\n') + '\n';

if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, output);
Expand Down
78 changes: 78 additions & 0 deletions .github/scripts/marketplace-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { marketplaceVersionExists } from './marketplace.mjs';

const manifest = {
name: 'vscode-graphqlsp',
publisher: '0no-co',
version: '0.1.0',
};

const response = body => ({
ok: true,
json: async () => body,
});

const galleryResult = extensions => ({ results: [{ extensions }] });

describe('marketplaceVersionExists', () => {
it('finds an exact published extension version', async () => {
const fetchImpl = async (_url, options) => {
const query = JSON.parse(options.body);
assert.equal(
query.filters[0].criteria[0].value,
'0no-co.vscode-graphqlsp'
);
return response(
galleryResult([
{
publisher: { publisherName: '0no-co' },
extensionName: 'vscode-graphqlsp',
versions: [{ version: '0.2.0' }, { version: '0.1.0' }],
},
])
);
};

assert.equal(await marketplaceVersionExists(manifest, fetchImpl), true);
});

it('reports an unpublished version or extension', async () => {
const otherVersion = async () =>
response(
galleryResult([
{
publisher: { publisherName: '0no-co' },
extensionName: 'vscode-graphqlsp',
versions: [{ version: '0.0.1' }],
},
])
);
const missingExtension = async () => response(galleryResult([]));

assert.equal(await marketplaceVersionExists(manifest, otherVersion), false);
assert.equal(
await marketplaceVersionExists(manifest, missingExtension),
false
);
});

it('fails closed on request and response errors', async () => {
const failedRequest = async () => ({
ok: false,
status: 503,
statusText: 'Unavailable',
});
const malformedResponse = async () => response({ results: [] });

await assert.rejects(
marketplaceVersionExists(manifest, failedRequest),
/503 Unavailable/
);
await assert.rejects(
marketplaceVersionExists(manifest, malformedResponse),
/invalid response/
);
});
});
53 changes: 53 additions & 0 deletions .github/scripts/marketplace.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const marketplaceQueryUrl =
'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery';

export async function marketplaceVersionExists(manifest, fetchImpl = fetch) {
const extensionId = `${manifest.publisher}.${manifest.name}`;
const response = await fetchImpl(marketplaceQueryUrl, {
method: 'POST',
headers: {
accept: 'application/json;api-version=7.2-preview.1',
'content-type': 'application/json',
},
body: JSON.stringify({
filters: [
{
criteria: [{ filterType: 7, value: extensionId }],
pageNumber: 1,
pageSize: 1,
sortBy: 0,
sortOrder: 0,
},
],
assetTypes: [],
// ExtensionQueryFlags.IncludeVersions
flags: 1,
}),
});

if (!response.ok) {
throw new Error(
`Failed to query ${extensionId}: ${response.status} ${response.statusText}`
);
}

const result = await response.json();
const extensions = result?.results?.[0]?.extensions;
if (!Array.isArray(extensions)) {
throw new Error(
`Marketplace returned an invalid response for ${extensionId}`
);
}

const extension = extensions.find(
entry =>
`${entry.publisher?.publisherName}.${entry.extensionName}`.toLowerCase() ===
extensionId.toLowerCase()
);
if (!extension) return false;
if (!Array.isArray(extension.versions)) {
throw new Error(`Marketplace returned no versions for ${extensionId}`);
}

return extension.versions.some(entry => entry.version === manifest.version);
}
63 changes: 61 additions & 2 deletions .github/scripts/stage-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";

import { marketplaceVersionExists } from "./marketplace.mjs";

const root = process.cwd();
const config = JSON.parse(readFileSync(join(root, ".changeset/config.json"), "utf8"));
const ignored = new Set(config.ignore || []);
const access = config.access || "public";
const vscodeExtensionDir = join(root, "packages/vscode-graphqlsp");
const vscodeExtensionManifest = readJson(join(vscodeExtensionDir, "package.json"));

function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
Expand Down Expand Up @@ -107,6 +111,35 @@ function createGitTag(tagName) {
console.log(`New tag: ${tagName}`);
}

const shouldPublishVsCodeExtension = !(await marketplaceVersionExists(
vscodeExtensionManifest
));
const vscodeExtensionId = `${vscodeExtensionManifest.publisher}.${vscodeExtensionManifest.name}`;
const vscodeExtensionVsix = join(
vscodeExtensionDir,
`${vscodeExtensionManifest.name}-${vscodeExtensionManifest.version}.vsix`
);

if (shouldPublishVsCodeExtension) {
console.log(
`Packaging ${vscodeExtensionId}@${vscodeExtensionManifest.version} before staging packages...`
);
const result = spawnSync(
"pnpm",
["--filter", vscodeExtensionManifest.name, "package"],
{
cwd: root,
encoding: "utf8",
stdio: "inherit",
}
);
if (result.status !== 0) process.exit(result.status || 1);
} else {
console.log(
`Skipping ${vscodeExtensionId}@${vscodeExtensionManifest.version}; already published.`
);
}

const staged = [];
for (const packageJsonPath of packageJsonPaths()) {
const pkg = readJson(packageJsonPath);
Expand Down Expand Up @@ -152,10 +185,36 @@ for (const packageJsonPath of packageJsonPaths()) {
});
}

if (shouldPublishVsCodeExtension) {
console.log(
`Publishing ${vscodeExtensionId}@${vscodeExtensionManifest.version} to the VS Code Marketplace...`
);
const result = spawnSync(
"pnpm",
[
"--filter",
vscodeExtensionManifest.name,
"exec",
"vsce",
"publish",
"--azure-credential",
"--packagePath",
vscodeExtensionVsix,
],
{
cwd: root,
encoding: "utf8",
stdio: "inherit",
}
);
if (result.status !== 0) process.exit(result.status || 1);
createGitTag(`${vscodeExtensionManifest.name}@${vscodeExtensionManifest.version}`);
}

if (staged.length === 0) {
console.log("No unpublished packages to stage.");
console.log("No unpublished npm packages to stage.");
} else {
console.log("Staged packages:");
console.log("Staged npm packages:");
for (const pkg of staged) {
console.log(`- ${pkg.name}@${pkg.version}${pkg.stageId ? ` (${pkg.stageId})` : ""}`);
}
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,15 @@ jobs:
- name: Build
run: pnpm --filter @0no-co/graphqlsp run build

- name: Test release scripts
if: matrix.typescript == '6.0.3'
run: node .github/scripts/marketplace-test.mjs

- name: Package VS Code extension
if: matrix.typescript == '6.0.3'
run: |
pnpm --filter vscode-graphqlsp package
unzip -t packages/vscode-graphqlsp/vscode-graphqlsp-*.vsix

- name: Test
run: pnpm run test:e2e
9 changes: 9 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jobs:
outputs:
has_changesets: ${{ steps.changesets.outputs.hasChangesets }}
has_unpublished_packages: ${{ steps.unpublished.outputs.has_unpublished }}
has_unpublished_vscode_extension: ${{ steps.unpublished.outputs.has_unpublished_vscode_extension }}
permissions:
contents: write
issues: write
Expand Down Expand Up @@ -87,6 +88,14 @@ jobs:
- name: Update npm
run: npm install -g npm@11.15.0

- name: Log in to Azure for VS Code Marketplace publishing
if: needs.release.outputs.has_unpublished_vscode_extension == 'true'
uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
with:
client-id: ${{ vars.VSCE_AZURE_CLIENT_ID }}
tenant-id: ${{ vars.VSCE_AZURE_TENANT_ID }}
allow-no-subscriptions: true

- name: Publish packages
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
Expand Down
7 changes: 7 additions & 0 deletions packages/vscode-graphqlsp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# vscode-graphqlsp

## 0.1.0

### Minor Changes

- Initial release with the bundled GraphQLSP TypeScript server plugin, GraphQL syntax highlighting, document symbols, and editor-managed plugin configuration.
Loading
Loading