diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd96dd0fa9..b44fd582a2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,9 +21,7 @@ on: required: false permissions: - contents: write - id-token: write # Required for OIDC - actions: write # Required to trigger changelog workflow + contents: read concurrency: group: publish @@ -78,11 +76,21 @@ jobs: echo "Auto-incremented version: $VERSION" >> $GITHUB_STEP_SUMMARY fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + - name: Verify version is available on public npm + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + node scripts/npm-release.js preflight \ + @github/copilot-sdk \ + "$VERSION" \ + https://registry.npmjs.org - publish-nodejs: - name: Publish Node.js SDK + package-nodejs: + name: Package Node.js SDK needs: version runs-on: ubuntu-latest + permissions: + contents: read defaults: run: working-directory: ./nodejs @@ -91,8 +99,6 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "22.x" - - name: Update npm for OIDC support - run: npm i -g "npm@11.6.3" - run: npm ci --ignore-scripts - name: Set version run: node scripts/set-version.js @@ -101,21 +107,126 @@ jobs: - name: Build run: npm run build - name: Pack - run: npm pack + id: pack + run: | + TARBALL="$(npm pack . --json | jq -r '.[0].filename')" + if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + echo "::error::npm pack did not produce a tarball." + exit 1 + fi + echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" - name: Upload artifact uses: actions/upload-artifact@v7.0.0 with: name: nodejs-package - path: nodejs/*.tgz - - name: Publish to npm - if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' - run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org + path: nodejs/${{ steps.pack.outputs.tarball }} + if-no-files-found: error + + publish-nodejs: + name: Publish Node.js SDK + needs: package-nodejs + if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: "22.x" + - name: Update npm for OIDC support + run: npm i -g "npm@11.6.3" + - name: Download Node.js package + uses: actions/download-artifact@v8.0.0 + with: + name: nodejs-package + path: ./dist + - name: Publish tarball to public npm + env: + DIST_TAG: ${{ github.event.inputs.dist-tag }} + run: | + set -euo pipefail + shopt -s nullglob + TARBALLS=(./dist/*.tgz) + if [ "${#TARBALLS[@]}" -ne 1 ]; then + echo "::error::Expected exactly one Node.js package tarball, found ${#TARBALLS[@]}." + exit 1 + fi + node nodejs/scripts/npm-release.js publish \ + "${TARBALLS[0]}" \ + "$DIST_TAG" \ + https://registry.npmjs.org \ + public + + publish-nodejs-internal: + name: Publish Node.js SDK to internal feed + needs: publish-nodejs + environment: cicd + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: "22.x" + - name: Download Node.js package + uses: actions/download-artifact@v8.0.0 + with: + name: nodejs-package + path: ./dist + - name: Azure Login (OIDC -> id-cpd-ci) + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci + tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" + allow-no-subscriptions: true + - name: Configure feed auth + run: | + set -euo pipefail + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + - name: Publish tarball to internal feed + env: + DIST_TAG: ${{ github.event.inputs.dist-tag }} + run: | + set -euo pipefail + if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then + echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." + exit 1 + fi + shopt -s nullglob + TARBALLS=(./dist/*.tgz) + if [ "${#TARBALLS[@]}" -ne 1 ]; then + echo "::error::Expected exactly one Node.js package tarball, found ${#TARBALLS[@]}." + exit 1 + fi + node nodejs/scripts/npm-release.js publish \ + "${TARBALLS[0]}" \ + "$DIST_TAG" \ + "$FEED_URL" \ + azure publish-dotnet: name: Publish .NET SDK if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest + permissions: + contents: read + id-token: write defaults: run: working-directory: ./dotnet @@ -200,6 +311,9 @@ jobs: if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest + permissions: + contents: read + id-token: write defaults: run: working-directory: ./python @@ -237,6 +351,9 @@ jobs: name: Publish Java SDK if: github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' needs: version + permissions: + contents: write + id-token: write uses: ./.github/workflows/java-publish-maven.yml with: releaseVersion: ${{ needs.version.outputs.version }} @@ -245,7 +362,15 @@ jobs: github-release: name: Create GitHub Release - needs: [version, publish-nodejs, publish-dotnet, publish-python, publish-rust, publish-java] + needs: + [ + version, + publish-nodejs, + publish-dotnet, + publish-python, + publish-rust, + publish-java, + ] if: | always() && github.ref == 'refs/heads/main' && @@ -256,6 +381,9 @@ jobs: needs.publish-python.result == 'success' && needs.publish-rust.result == 'success' runs-on: ubuntu-latest + permissions: + actions: write + contents: write steps: - uses: actions/checkout@v6.0.2 - name: Create GitHub Release diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js new file mode 100644 index 0000000000..fe750bada0 --- /dev/null +++ b/nodejs/scripts/npm-release.js @@ -0,0 +1,92 @@ +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const PUBLIC_CONFLICT = + /^(?:npm (?:error|ERR!) code EPUBLISHCONFLICT|npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:You )?cannot publish over (?:the )?previously published versions(?:: [^\r\n]+)?\.?)\r?$/im; +const AZURE_CONFLICT = + /^npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:The feed '[^'\r\n]+' )?already contains file '[^'\r\n]+\.tgz' in package '[^'\r\n]+'\.?\r?$/im; + +export function runCommand(command, args, { stream = false } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (stream) process.stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (stream) process.stderr.write(chunk); + }); + child.on("error", reject); + child.on("close", (status) => resolve({ status: status ?? 1, stdout, stderr })); + }); +} + +export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { + const result = await runner("npm", [ + "view", + `${packageName}@${version}`, + "version", + "--json", + "--registry", + registry, + ]); + + if (result.status === 0) { + throw new Error(`${packageName}@${version} already exists on public npm.`); + } + + try { + if (JSON.parse(result.stdout)?.error?.code === "E404") return; + } catch { + // The failure below includes npm's output for diagnosis. + } + + const output = `${result.stdout}\n${result.stderr}`.trim(); + throw new Error( + `Could not confirm that ${packageName}@${version} is absent from public npm (npm exited ${result.status}).${output ? `\n${output}` : ""}` + ); +} + +export async function publishTarball(tarball, tag, registry, mode, runner = runCommand) { + const args = ["publish", tarball, "--tag", tag, "--registry", registry]; + if (mode === "public") args.push("--access", "public"); + if (mode !== "public" && mode !== "azure") throw new Error(`Unknown publish mode: ${mode}`); + + const result = await runner("npm", args, { stream: true }); + if (result.status === 0) return; + + const output = `${result.stdout}\n${result.stderr}`; + if (PUBLIC_CONFLICT.test(output) || (mode === "azure" && AZURE_CONFLICT.test(output))) { + console.log( + "Version already published; treating the immutable-version conflict as success." + ); + return; + } + + throw new Error(`npm publish failed with exit code ${result.status}.`); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (command === "preflight" && args.length === 3) { + await assertVersionAbsent(...args); + console.log(`${args[0]}@${args[1]} is available on public npm.`); + } else if (command === "publish" && args.length === 4) { + await publishTarball(...args); + } else { + throw new Error( + "Usage: npm-release.js preflight | publish " + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(`::error::${error.message}`); + process.exitCode = 1; + }); +} diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts new file mode 100644 index 0000000000..26caf7deaa --- /dev/null +++ b/nodejs/test/npm-release.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertVersionAbsent, publishTarball } from "../scripts/npm-release.js"; + +const packageName = "@github/copilot-sdk"; +const version = "1.2.3"; +const registry = "https://registry.example.test"; +const result = (status: number, stdout = "", stderr = "") => ({ status, stdout, stderr }); + +describe("npm release preflight", () => { + it("succeeds only for a structured E404 response", async () => { + const runner = vi + .fn() + .mockResolvedValue(result(1, JSON.stringify({ error: { code: "E404" } }))); + await expect( + assertVersionAbsent(packageName, version, registry, runner) + ).resolves.toBeUndefined(); + }); + + it.each([ + ["an existing version", result(0, JSON.stringify(version)), "already exists"], + ["a transient error", result(1, "", "npm error code E500"), "Could not confirm"], + ["malformed output", result(1, "not-json"), "Could not confirm"], + [ + "a non-404 error containing E404 and 404 text", + result( + 1, + JSON.stringify({ error: { code: "E500", summary: "version 1.2.3-E404.404" } }), + "npm error code E500 for 1.2.3-E404.404" + ), + "Could not confirm", + ], + ])("fails for %s", async (_name, response, message) => { + const runner = vi.fn().mockResolvedValue(response); + await expect(assertVersionAbsent(packageName, version, registry, runner)).rejects.toThrow( + message + ); + }); +}); + +describe("npm release publishing", () => { + it("succeeds after a normal publish", async () => { + const runner = vi.fn().mockResolvedValue(result(0)); + await expect( + publishTarball("package.tgz", "latest", registry, "public", runner) + ).resolves.toBeUndefined(); + }); + + it.each([ + ["npm error code EPUBLISHCONFLICT", "public"], + [ + "npm error 403 403 Forbidden - PUT https://registry.npmjs.org/package - You cannot publish over the previously published versions: 1.2.3.", + "public", + ], + [ + "npm error 403 403 Forbidden - The feed 'copilot-canary' already contains file 'copilot-sdk-0.0.0-29613896246.tgz' in package '@github/copilot-sdk 0.0.0-29613896246'.", + "azure", + ], + ])("recovers the immutable conflict: %s", async (error, mode) => { + const runner = vi.fn().mockResolvedValue(result(1, "", error)); + await expect( + publishTarball("package.tgz", "latest", registry, mode, runner) + ).resolves.toBeUndefined(); + }); + + it.each([ + ["a generic Azure 403", "403 Forbidden", "azure"], + [ + "an Azure non-tarball conflict", + "npm error 403 already contains file 'package.json' in package '@github/copilot-sdk/1.2.3'", + "azure", + ], + [ + "an embedded public phrase", + "npm error network timeout while parsing 'cannot publish over the previously published versions'", + "public", + ], + [ + "an embedded Azure phrase", + "npm error network timeout while parsing \"already contains file 'package.tgz' in package '@github/copilot-sdk/1.2.3'\"", + "azure", + ], + ])("fails for %s", async (_name, error, mode) => { + const runner = vi.fn().mockResolvedValue(result(1, "", error)); + await expect( + publishTarball("package.tgz", "latest", registry, mode, runner) + ).rejects.toThrow("npm publish failed"); + }); +});