diff --git a/.changeset/calm-tools-publish.md b/.changeset/calm-tools-publish.md new file mode 100644 index 0000000..64b0d51 --- /dev/null +++ b/.changeset/calm-tools-publish.md @@ -0,0 +1,5 @@ +--- +'vscode-graphqlsp': patch +--- + +Publish versioned VS Code extension releases through the existing Changesets workflow using Microsoft Entra workload identity authentication. diff --git a/.changeset/config.json b/.changeset/config.json index ed38694..bb0dea0 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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}", diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000..5a8c7c5 --- /dev/null +++ b/.github/RELEASING.md @@ -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. diff --git a/.github/scripts/has-unpublished-packages.mjs b/.github/scripts/has-unpublished-packages.mjs index dbae2e6..26e4d55 100644 --- a/.github/scripts/has-unpublished-packages.mjs +++ b/.github/scripts/has-unpublished-packages.mjs @@ -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']); @@ -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); @@ -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); diff --git a/.github/scripts/marketplace-test.mjs b/.github/scripts/marketplace-test.mjs new file mode 100644 index 0000000..684750a --- /dev/null +++ b/.github/scripts/marketplace-test.mjs @@ -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/ + ); + }); +}); diff --git a/.github/scripts/marketplace.mjs b/.github/scripts/marketplace.mjs new file mode 100644 index 0000000..daba935 --- /dev/null +++ b/.github/scripts/marketplace.mjs @@ -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); +} diff --git a/.github/scripts/stage-packages.mjs b/.github/scripts/stage-packages.mjs index e5323ff..ecf9e03 100644 --- a/.github/scripts/stage-packages.mjs +++ b/.github/scripts/stage-packages.mjs @@ -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")); @@ -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); @@ -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})` : ""}`); } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8b3a176..0286d1a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index abf77b5..8f2f27f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 @@ -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 diff --git a/packages/vscode-graphqlsp/CHANGELOG.md b/packages/vscode-graphqlsp/CHANGELOG.md new file mode 100644 index 0000000..11884fd --- /dev/null +++ b/packages/vscode-graphqlsp/CHANGELOG.md @@ -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. diff --git a/packages/vscode-graphqlsp/scripts/package.mjs b/packages/vscode-graphqlsp/scripts/package.mjs index 4ad30cf..cfbc565 100644 --- a/packages/vscode-graphqlsp/scripts/package.mjs +++ b/packages/vscode-graphqlsp/scripts/package.mjs @@ -42,6 +42,7 @@ for (const file of [ 'syntaxes', 'language-configuration.json', 'README.md', + 'CHANGELOG.md', 'LICENSE.md', '.vscodeignore', ]) { @@ -139,7 +140,7 @@ const output = path.join( ); run( path.join(extensionRoot, 'node_modules', '.bin', 'vsce'), - ['package', '--out', output], + ['package', '--changelog-path', 'CHANGELOG.md', '--out', output], stagingDir ); console.log(`\nPackaged ${output}`);