From bd556d948da1b15c63be77849e07257276d1624a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 14:08:27 -0700 Subject: [PATCH 01/23] Add gated unstable SDK publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish.yml | 490 ++++++++++++++- .github/workflows/sdk-canary.yml | 565 ++++++++---------- docs/developer-docs/secrets.md | 2 + docs/developer-docs/unstable-releases.md | 105 ++++ nodejs/README.md | 4 + nodejs/package.json | 2 + nodejs/scripts/npm-release.js | 282 ++++++++- nodejs/scripts/release-manifest.ts | 237 ++++++++ nodejs/scripts/releaseArtifacts.ts | 15 +- nodejs/scripts/runtime-package-acquisition.ts | 264 ++++++++ nodejs/scripts/set-cli-version.js | 6 +- nodejs/scripts/unstable-version.ts | 137 +++++ nodejs/test/npm-release.test.ts | 226 +++++-- nodejs/test/release-manifest.test.ts | 60 ++ nodejs/test/release-workflows.test.ts | 69 +++ .../test/runtime-package-acquisition.test.ts | 142 +++++ nodejs/test/runtimeArtifacts.test.ts | 44 ++ nodejs/test/unstable-version.test.ts | 67 +++ 18 files changed, 2304 insertions(+), 413 deletions(-) create mode 100644 docs/developer-docs/unstable-releases.md create mode 100644 nodejs/scripts/release-manifest.ts create mode 100644 nodejs/scripts/runtime-package-acquisition.ts create mode 100644 nodejs/scripts/unstable-version.ts create mode 100644 nodejs/test/release-manifest.test.ts create mode 100644 nodejs/test/release-workflows.test.ts create mode 100644 nodejs/test/runtime-package-acquisition.test.ts create mode 100644 nodejs/test/unstable-version.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb412e6db6..db96505766 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,18 +19,41 @@ on: description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." type: string required: false + runtime_version: + description: "Exact signed runtime version (required for unstable)" + type: string + required: false + runtime_sha: + description: "Full github/copilot-agent-runtime SHA (required for unstable)" + type: string + required: false + runtime_source: + description: "Runtime package source (required for unstable)" + type: choice + required: false + options: + - github-packages + runtime_run_id: + description: "Source runtime workflow run ID (required for unstable)" + type: string + required: false + resume_run_id: + description: "Exceptional recovery: original SDK workflow run ID" + type: string + required: false permissions: contents: read concurrency: - group: publish + group: publish-${{ inputs.dist-tag == 'unstable' && 'unstable' || 'release' }} cancel-in-progress: false jobs: # Shared job to calculate version once for all publish jobs version: name: Calculate Version + if: inputs.dist-tag != 'unstable' runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.VERSION }} @@ -87,6 +110,7 @@ jobs: package-nodejs: name: Package Node.js SDK + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -124,8 +148,8 @@ jobs: publish-nodejs: name: Publish Node.js SDK - needs: package-nodejs - if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' + needs: [version, package-nodejs] + if: inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: actions: read @@ -146,6 +170,7 @@ jobs: - name: Publish tarball to public npm env: DIST_TAG: ${{ github.event.inputs.dist-tag }} + VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail shopt -s nullglob @@ -161,25 +186,33 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ + "$PACKAGE_NAME" \ + "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public + public \ + "$INTEGRITY" done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ + @github/copilot-sdk \ + "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public + public \ + "$INTEGRITY" publish-nodejs-internal: name: Publish Node.js SDK to internal feed - needs: publish-nodejs + needs: [version, publish-nodejs] environment: cicd runs-on: ubuntu-latest permissions: @@ -218,6 +251,7 @@ jobs: - name: Publish tarball to internal feed env: DIST_TAG: ${{ github.event.inputs.dist-tag }} + VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then @@ -237,21 +271,461 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ + "$PACKAGE_NAME" \ + "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure + azure \ + "$INTEGRITY" done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ + @github/copilot-sdk \ + "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure + azure \ + "$INTEGRITY" + + unstable-plan: + name: Freeze unstable release identity + if: inputs.dist-tag == 'unstable' + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + id-token: write + outputs: + artifact_name: ${{ steps.recover.outputs.artifact_name || steps.plan.outputs.artifact_name }} + runtime_run_id: ${{ steps.recover.outputs.runtime_run_id || steps.plan.outputs.runtime_run_id }} + runtime_sha: ${{ steps.recover.outputs.runtime_sha || steps.plan.outputs.runtime_sha }} + runtime_version: ${{ steps.recover.outputs.runtime_version || steps.plan.outputs.runtime_version }} + sdk_ref: ${{ steps.recover.outputs.sdk_ref || steps.plan.outputs.sdk_ref }} + sdk_sha: ${{ steps.recover.outputs.sdk_sha || steps.plan.outputs.sdk_sha }} + sdk_version: ${{ steps.recover.outputs.sdk_version || steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.recover.outputs.workflow_created_at || steps.plan.outputs.workflow_created_at }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download original retained release + if: inputs.resume_run_id != '' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./recovery + pattern: nodejs-unstable-* + repository: ${{ github.repository }} + run-id: ${{ inputs.resume_run_id }} + - name: Validate exceptional recovery identity + if: inputs.resume_run_id != '' + id: recover + env: + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + run: | + set -euo pipefail + [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::resume_run_id must be numeric."; exit 1; } + MANIFEST="./recovery/release-manifest.json" + [ -f "$MANIFEST" ] || + { echo "::error::Original run does not contain one retained unstable release artifact."; exit 1; } + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify "$MANIFEST" ./recovery + [ "$(jq -r .channel "$MANIFEST")" = "unstable" ] || + { echo "::error::Recovery artifact is not an unstable release."; exit 1; } + [ "$(jq -r .workflow.runId "$MANIFEST")" = "$RESUME_RUN_ID" ] || + { echo "::error::Manifest workflow run ID does not match resume_run_id."; exit 1; } + { + echo "artifact_name=nodejs-unstable-$(jq -r .sdk.version "$MANIFEST")" + echo "runtime_run_id=$(jq -r .runtime.runId "$MANIFEST")" + echo "runtime_sha=$(jq -r .runtime.sha "$MANIFEST")" + echo "runtime_version=$(jq -r .runtime.version "$MANIFEST")" + echo "sdk_ref=$(jq -r .sdk.ref "$MANIFEST")" + echo "sdk_sha=$(jq -r .sdk.sha "$MANIFEST")" + echo "sdk_version=$(jq -r .sdk.version "$MANIFEST")" + echo "workflow_created_at=$(jq -r .workflow.createdAt "$MANIFEST")" + } >> "$GITHUB_OUTPUT" + - name: Validate runtime handoff and calculate version + if: inputs.resume_run_id == '' + id: plan + working-directory: ./nodejs + env: + GH_TOKEN: ${{ github.token }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + [ "$RUNTIME_SOURCE" = "github-packages" ] || + { echo "::error::Unstable runtime_source must be github-packages."; exit 1; } + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" + if [ -n "$SDK_VERSION_OVERRIDE" ]; then + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" https://registry.npmjs.org + done + fi + { + echo "artifact_name=nodejs-unstable-$SDK_VERSION" + echo "runtime_run_id=$RUNTIME_RUN_ID" + echo "runtime_sha=$RUNTIME_SHA" + echo "runtime_version=$RUNTIME_VERSION" + echo "sdk_ref=$GITHUB_REF" + echo "sdk_sha=$SDK_SHA" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + - name: Azure login for explicit-version preflight + if: inputs.resume_run_id == '' && inputs.version != '' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Reject an explicit version already present internally + if: inputs.resume_run_id == '' && inputs.version != '' + working-directory: ./nodejs + env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} + 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" + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" + done + + unstable-acquire-runtime: + name: Acquire signed unstable runtime packages + if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' + needs: unstable-plan + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Configure authentication-only GitHub Packages access + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: | + echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + RUNTIME_SHA: ${{ needs.unstable-plan.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --registry https://npm.pkg.github.com \ + --output "$RUNNER_TEMP/runtime-packages" + - uses: actions/upload-artifact@v7.0.0 + with: + name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + if-no-files-found: error + retention-days: 7 + + unstable-test: + name: Runtime-backed unstable tests (${{ matrix.os }}) + if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' + needs: [unstable-plan, unstable-acquire-runtime] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} + run: | + set -euo pipefail + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + "$runtime_path" --version | grep -F "$RUNTIME_VERSION" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + unstable-package: + name: Build retained unstable release + if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' + needs: [unstable-plan, unstable-acquire-runtime, unstable-test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} + SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} + run: | + set -euo pipefail + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: unstable + RUNTIME_RUN_ID: ${{ needs.unstable-plan.outputs.runtime_run_id }} + RUNTIME_SHA: ${{ needs.unstable-plan.outputs.runtime_sha }} + RUNTIME_SOURCE: github-packages + RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} + SDK_REF: ${{ needs.unstable-plan.outputs.sdk_ref }} + SDK_SHA: ${{ needs.unstable-plan.outputs.sdk_sha }} + SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.unstable-plan.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ needs.unstable-plan.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + unstable-publish-internal: + name: Publish and verify unstable SDK internally + if: | + always() && + inputs.dist-tag == 'unstable' && + needs.unstable-plan.result == 'success' && + (inputs.resume_run_id != '' || needs.unstable-package.result == 'success') + needs: [unstable-plan, unstable-package] + runs-on: ubuntu-latest + environment: cicd + 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 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download current retained release + if: inputs.resume_run_id == '' + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.unstable-plan.outputs.artifact_name }} + path: ./dist + - name: Download original retained release + if: inputs.resume_run_id != '' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./dist + pattern: ${{ needs.unstable-plan.outputs.artifact_name }} + repository: ${{ github.repository }} + run-id: ${{ inputs.resume_run_id }} + - name: Validate retained release + env: + EXPECTED_RUN_ID: ${{ inputs.resume_run_id || github.run_id }} + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "$EXPECTED_RUN_ID" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + 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 exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist unstable "$FEED_URL" azure + - name: Clean install and runtime version check + env: + RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} + SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} + run: | + set -euo pipefail + VERIFY_ROOT="$RUNNER_TEMP/sdk-unstable-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" + "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" + + unstable-publish-public: + name: Publish unstable SDK publicly + if: inputs.dist-tag == 'unstable' + needs: [unstable-plan, unstable-publish-internal] + 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 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Update npm for trusted publishing + run: npm install --global npm@11.6.3 + - name: Download current retained release + if: inputs.resume_run_id == '' + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.unstable-plan.outputs.artifact_name }} + path: ./dist + - name: Download original retained release + if: inputs.resume_run_id != '' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./dist + pattern: ${{ needs.unstable-plan.outputs.artifact_name }} + repository: ${{ github.repository }} + run-id: ${{ inputs.resume_run_id }} + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + - name: Publish the same tarballs to public npm + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist unstable https://registry.npmjs.org public publish-dotnet: name: Publish .NET SDK diff --git a/.github/workflows/sdk-canary.yml b/.github/workflows/sdk-canary.yml index ac57ca6c99..7cd3820d40 100644 --- a/.github/workflows/sdk-canary.yml +++ b/.github/workflows/sdk-canary.yml @@ -1,45 +1,47 @@ name: "SDK Canary Test/Publish" -# Nightly-style canary pipeline. First installs an explicit version of the -# @github/copilot runtime, builds the Node SDK, and runs the Node e2e suite -# against it to prove runtime <-> SDK compatibility. When that gate passes (and -# mode allows), publishes an SDK canary pinned to the tested runtime to the -# internal Azure Artifacts feed only (never public npm). - env: - HUSKY: 0 - # Internal org-scoped Azure Artifacts feed — single source of truth so the - # feed name isn't repeated across steps. The SDK canary publishes here and - # (when runtime_source=internal) installs the runtime from here; it must NEVER - # reach public npm (@github/copilot-sdk is a live public package). - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - # Azure DevOps resource ID used to mint an ADO access token for the feed. ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + HUSKY: 0 on: workflow_dispatch: inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - canary + default: canary runtime_version: - description: "Exact github/copilot-cli release (public) or @github/copilot package version (internal)" + description: "Exact runtime package version" + required: true + type: string + runtime_sha: + description: "Full github/copilot-agent-runtime source SHA" required: true type: string runtime_source: - description: "Where to install the runtime from" + description: "Runtime package registry" required: true type: choice options: - - public - - internal - default: public + - azure + default: azure + runtime_run_id: + description: "Source runtime workflow run ID" + required: true + type: string mode: - description: "publish (tests must pass), publish-force (publish even if tests fail), or tests-only (run gate, never publish)" - required: false + description: "Run tests and package verification, with optional internal publication" + required: true type: choice - default: publish options: - - publish - - publish-force - tests-only + - internal + default: tests-only repository_dispatch: types: [runtime-canary] @@ -47,96 +49,144 @@ permissions: contents: read id-token: write -# Serialize runs per ref so two overlapping canary runs can't race the feed -# publish. cancel-in-progress: false — never kill an in-flight publish. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: sdk-canary-${{ github.ref }} cancel-in-progress: false jobs: resolve: - name: "Resolve runtime inputs" + name: Resolve canary inputs if: github.event.repository.fork == false runs-on: ubuntu-latest permissions: {} outputs: - RUNTIME_VERSION: ${{ steps.normalize.outputs.RUNTIME_VERSION }} - RUNTIME_SOURCE: ${{ steps.normalize.outputs.RUNTIME_SOURCE }} - PUBLISH_MODE: ${{ steps.normalize.outputs.PUBLISH_MODE }} + mode: ${{ steps.normalize.outputs.mode }} + runtime_run_id: ${{ steps.normalize.outputs.runtime_run_id }} + runtime_sha: ${{ steps.normalize.outputs.runtime_sha }} + runtime_source: ${{ steps.normalize.outputs.runtime_source }} + runtime_version: ${{ steps.normalize.outputs.runtime_version }} steps: - # Normalize whichever trigger fired into a single (RUNTIME_VERSION, - # RUNTIME_SOURCE, PUBLISH_MODE) triple that every downstream step - # references. workflow_dispatch reads the human-supplied inputs; - # repository_dispatch reads client_payload and forces source=internal - # (a runtime canary only exists on the feed), defaulting mode to publish. - - name: Normalize inputs + - name: Normalize and validate inputs id: normalize env: EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.runtime_version }} - INPUT_SOURCE: ${{ inputs.runtime_source }} + INPUT_CHANNEL: ${{ inputs.channel }} INPUT_MODE: ${{ inputs.mode }} - PAYLOAD_VERSION: ${{ github.event.client_payload.runtime_version }} - PAYLOAD_SOURCE: ${{ github.event.client_payload.runtime_source }} + INPUT_RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + INPUT_RUNTIME_SHA: ${{ inputs.runtime_sha }} + INPUT_RUNTIME_SOURCE: ${{ inputs.runtime_source }} + INPUT_RUNTIME_VERSION: ${{ inputs.runtime_version }} + PAYLOAD_CHANNEL: ${{ github.event.client_payload.channel }} PAYLOAD_MODE: ${{ github.event.client_payload.mode }} + PAYLOAD_RUNTIME_RUN_ID: ${{ github.event.client_payload.runtime_run_id }} + PAYLOAD_RUNTIME_SHA: ${{ github.event.client_payload.runtime_sha }} + PAYLOAD_RUNTIME_SOURCE: ${{ github.event.client_payload.runtime_source }} + PAYLOAD_RUNTIME_VERSION: ${{ github.event.client_payload.runtime_version }} run: | set -euo pipefail - case "$EVENT_NAME" in - workflow_dispatch) - VERSION="$INPUT_VERSION" - SOURCE="$INPUT_SOURCE" - MODE="$INPUT_MODE" - ;; - repository_dispatch) - VERSION="$PAYLOAD_VERSION" - # A runtime canary only ever exists on the internal feed. - SOURCE="${PAYLOAD_SOURCE:-internal}" - MODE="${PAYLOAD_MODE:-publish}" - ;; - *) - echo "::error::Unsupported event '$EVENT_NAME'." - exit 1 - ;; - esac - if [ -z "$VERSION" ]; then echo "::error::Could not determine runtime version."; exit 1; fi - if [ -z "$SOURCE" ]; then SOURCE="public"; fi - case "$SOURCE" in - public|internal) ;; - *) echo "::error::Invalid runtime source '$SOURCE'. Expected one of: public, internal."; exit 1 ;; - esac - if [ -z "$MODE" ]; then MODE="publish"; fi + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + CHANNEL="$INPUT_CHANNEL" + MODE="$INPUT_MODE" + RUNTIME_RUN_ID="$INPUT_RUNTIME_RUN_ID" + RUNTIME_SHA="$INPUT_RUNTIME_SHA" + RUNTIME_SOURCE="$INPUT_RUNTIME_SOURCE" + RUNTIME_VERSION="$INPUT_RUNTIME_VERSION" + else + CHANNEL="${PAYLOAD_CHANNEL:-canary}" + MODE="${PAYLOAD_MODE:-internal}" + RUNTIME_RUN_ID="$PAYLOAD_RUNTIME_RUN_ID" + RUNTIME_SHA="$PAYLOAD_RUNTIME_SHA" + RUNTIME_SOURCE="${PAYLOAD_RUNTIME_SOURCE:-azure}" + RUNTIME_VERSION="$PAYLOAD_RUNTIME_VERSION" + case "$MODE" in + publish|publish-force) MODE="internal" ;; + esac + case "$RUNTIME_SOURCE" in + internal) RUNTIME_SOURCE="azure" ;; + esac + fi + [ "$CHANNEL" = "canary" ] || { echo "::error::sdk-canary.yml only accepts channel=canary."; exit 1; } + [ "$RUNTIME_SOURCE" = "azure" ] || { echo "::error::Canary runtime_source must be azure."; exit 1; } case "$MODE" in - publish|publish-force|tests-only) ;; - *) echo "::error::Invalid publish mode '$MODE'. Expected one of: publish, publish-force, tests-only."; exit 1 ;; + tests-only|internal) ;; + *) echo "::error::Canary mode must be tests-only or internal."; exit 1 ;; esac - echo "Resolved RUNTIME_VERSION=$VERSION RUNTIME_SOURCE=$SOURCE PUBLISH_MODE=$MODE" - echo "RUNTIME_VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "RUNTIME_SOURCE=$SOURCE" >> "$GITHUB_OUTPUT" - echo "PUBLISH_MODE=$MODE" >> "$GITHUB_OUTPUT" - - - name: Validate runtime version (semver) + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + { + echo "mode=$MODE" + echo "runtime_run_id=$RUNTIME_RUN_ID" + echo "runtime_sha=$RUNTIME_SHA" + echo "runtime_source=$RUNTIME_SOURCE" + echo "runtime_version=$RUNTIME_VERSION" + } >> "$GITHUB_OUTPUT" + + acquire-runtime: + name: Acquire exact runtime packages + needs: resolve + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + id-token: write + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + 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: Download and validate all runtime platforms env: - RUNTIME_VERSION: ${{ steps.normalize.outputs.RUNTIME_VERSION }} + RUNTIME_SHA: ${{ needs.resolve.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} run: | - if [[ ! "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then - echo "::error::Invalid runtime version '$RUNTIME_VERSION'. Expected semver (e.g. 1.0.69 or 1.0.70-canary.abc123)." - exit 1 - fi + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --registry "$FEED_URL" \ + --output "$RUNNER_TEMP/runtime-packages" + - uses: actions/upload-artifact@v7.0.0 + with: + name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + if-no-files-found: error + retention-days: 7 test: - name: "E2E tests (${{ matrix.os }})" - needs: resolve - if: github.event.repository.fork == false - environment: cicd + name: Runtime-backed Node tests (${{ matrix.os }}) + needs: [resolve, acquire-runtime] strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} - env: - POWERSHELL_UPDATECHECK: Off - RUNTIME_VERSION: ${{ needs.resolve.outputs.RUNTIME_VERSION }} - RUNTIME_SOURCE: ${{ needs.resolve.outputs.RUNTIME_SOURCE }} + environment: cicd defaults: run: shell: bash @@ -146,283 +196,168 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - cache: "npm" - cache-dependency-path: "./nodejs/package-lock.json" + cache: npm + cache-dependency-path: ./nodejs/package-lock.json node-version: 22 - - - name: Install SDK dependencies - run: npm ci --ignore-scripts - + - run: npm ci --ignore-scripts - name: Install test harness dependencies working-directory: ./test/harness run: npm ci --ignore-scripts - - - name: Azure Login (OIDC -> id-cpd-ci) - if: env.RUNTIME_SOURCE == 'internal' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + - uses: actions/download-artifact@v8.0.0 with: - client-id: "${{ vars.CPD_ID_CLIENT_ID }}" # id-cpd-ci - tenant-id: "${{ vars.CPD_ID_TENANT_ID }}" - allow-no-subscriptions: true - - # Route ONLY @github/* (the runtime + its platform packages) to the - # internal feed via a scoped registry. All other deps (e.g. detect-libc) - # still resolve from public npm. A global --registry would break because - # detect-libc is not on the feed. - - name: Configure canary feed (.npmrc) - if: env.RUNTIME_SOURCE == 'internal' - run: | - set -euo pipefail - TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" - echo "::add-mask::$TOKEN" - # Derive the protocol-relative auth scopes from FEED_URL so the feed - # name lives in exactly one place (the workflow-level env). - FEED_AUTH_REGISTRY="${FEED_URL#https:}" - FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" - NPMRC="$(printf '%s\n' \ - "@github:registry=${FEED_URL}" \ - "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ - "${FEED_AUTH_BASE}:_authToken=${TOKEN}")" - printf '%s\n' "$NPMRC" > .npmrc - echo "Wrote scoped @github registry .npmrc to ./nodejs" - - - name: Override runtime version - run: | - set -euo pipefail - if [ "$RUNTIME_SOURCE" = "internal" ]; then - echo "Installing internal @github/copilot@${RUNTIME_VERSION}" - npm install "@github/copilot@${RUNTIME_VERSION}" --save-exact --ignore-scripts - node scripts/set-cli-version.js "$RUNTIME_VERSION" --npm-package - else - echo "Pinning github/copilot-cli release ${RUNTIME_VERSION}" - node scripts/set-cli-version.js "$RUNTIME_VERSION" - npm install --ignore-scripts - fi - - - name: Verify release runtime + name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} run: | set -euo pipefail - runtime_path=$(npm run --silent prepare:runtime -- --print-path) - node -e " - const fs = require('node:fs'); - const path = require('node:path'); - const runtime = process.argv[1]; - const runtimeStat = fs.statSync(runtime); - if (!runtimeStat.isFile()) throw new Error('Runtime wrapper is not a file'); - if (process.platform !== 'win32' && (runtimeStat.mode & 0o111) === 0) { - throw new Error('Runtime wrapper is not executable'); - } - if (!fs.statSync(path.join(path.dirname(runtime), 'runtime.node')).isFile()) { - throw new Error('runtime.node is not adjacent to the runtime wrapper'); - } - " "$runtime_path" - legacy_path=$(npm run --silent prepare:runtime -- --print-legacy-path) - node "$legacy_path" --version | grep -F "$RUNTIME_VERSION" + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + "$runtime_path" --version | grep -F "$RUNTIME_VERSION" echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - - name: Build SDK - run: npm run build - + - run: npm run build - name: Warm up PowerShell if: runner.os == 'Windows' run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - - name: Run Node.js SDK e2e tests + - name: Run Node SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} run: npm test - publish: - name: "Publish SDK canary (internal feed)" - needs: [resolve, test] - # Publish runs only when the gate permits it. Mode governs behavior: - # - tests-only: never publish (skips this job entirely). - # - publish: publish only when the e2e gate is green (the default for both - # the human and automated triggers). - # - publish-force: publish even on a non-green gate — a human-acknowledged - # flake override, audited via the ::warning:: step below and the run actor. - # publish-force only skips the e2e *signal* — the publish job still runs the - # build (so a broken build can't publish) and enforces the feed-only guards. - if: > - !cancelled() && - github.event.repository.fork == false && - needs.resolve.result == 'success' && - needs.resolve.outputs.PUBLISH_MODE != 'tests-only' && - (needs.test.result == 'success' || - needs.resolve.outputs.PUBLISH_MODE == 'publish-force') - environment: cicd + package: + name: Build and verify nine SDK packages + needs: [resolve, acquire-runtime, test] runs-on: ubuntu-latest permissions: + actions: read contents: read - id-token: write - env: - RUNTIME_VERSION: ${{ needs.resolve.outputs.RUNTIME_VERSION }} - RUNTIME_SOURCE: ${{ needs.resolve.outputs.RUNTIME_SOURCE }} + outputs: + artifact_name: ${{ steps.identity.outputs.artifact_name }} + sdk_version: ${{ steps.identity.outputs.sdk_version }} defaults: run: shell: bash working-directory: ./nodejs steps: - - name: Warn — publishing despite failed e2e gate (publish-force) - # always() so this audit is never skipped by prior-step status; it fires - # specifically when publish proceeded on a non-green gate via publish-force. - # Runs at the workspace root because it executes before checkout, so the - # job's default working-directory (./nodejs) does not exist yet. - if: always() && needs.test.result != 'success' && needs.resolve.outputs.PUBLISH_MODE == 'publish-force' - working-directory: ${{ github.workspace }} - run: | - echo "::warning title=e2e gate bypassed::Publishing SDK canary despite a non-passing e2e gate (test job result: ${{ needs.test.result }}) via publish-force. Triggered by '${{ github.actor }}' through '${{ github.event_name }}'. The e2e signal was bypassed; build + feed-only guards still apply." - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json node-version: 22 - - # Default public registry: installs build deps and the currently pinned - # runtime. Do NOT write any feed .npmrc or scoped @github:registry line - # here, or npm ci would try to fetch the runtime from the upstream-less - # feed and 404. - - name: Install SDK dependencies - run: npm ci --ignore-scripts - - - name: Compute SDK canary version - id: sdkver + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Freeze SDK canary version + id: identity env: - RUN_NUMBER: ${{ github.run_number }} - SHA: ${{ github.sha }} + SDK_SHA: ${{ github.sha }} run: | set -euo pipefail - SHORT_SHA="${SHA:0:7}" - # Base the canary on the NEXT patch of the public SDK latest so canaries - # correlate with public releases: they sort ABOVE the current public - # latest and BELOW the eventual real release of that next patch (a - # prerelease of X.Y.Z always sorts below X.Y.Z), so a canary can never - # shadow the real release when it ships. - # Reuse the repo's own version helper (scripts/get-version.js) so this - # stays consistent with publish.yml: `current` returns the latest public - # dist-tag version, read-only from public npm (never the feed), then - # we bump the patch ourselves to keep strict patch+1 semantics. - PUBLIC_LATEST="$(node scripts/get-version.js current || true)" - BASE="${PUBLIC_LATEST%%-*}"; BASE="${BASE%%+*}" - if [[ "$BASE" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - NEXT="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$(( BASH_REMATCH[3] + 1 ))" - else - echo "::error::Could not resolve public SDK latest version (got '$PUBLIC_LATEST'); refusing to publish a canary with an unknown base." - exit 1 - fi - SDK_VERSION="${NEXT}-canary.${RUN_NUMBER}.g${SHORT_SHA}" - if [[ ! "$SDK_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then - echo "::error::Computed SDK canary version '$SDK_VERSION' is not valid semver." - exit 1 - fi - echo "SDK canary version: $SDK_VERSION" - echo "SDK_VERSION=$SDK_VERSION" >> "$GITHUB_OUTPUT" - - - name: Set package and runtime versions + PUBLIC_LATEST="$(node scripts/get-version.js current)" + BASE="${PUBLIC_LATEST%%-*}" + IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" + SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" + npm exec -- semver "$SDK_VERSION" + echo "sdk_version=$SDK_VERSION" >> "$GITHUB_OUTPUT" + echo "artifact_name=nodejs-canary-$SDK_VERSION" >> "$GITHUB_OUTPUT" + - name: Build package set env: - SDK_VERSION: ${{ steps.sdkver.outputs.SDK_VERSION }} + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} + SDK_VERSION: ${{ steps.identity.outputs.sdk_version }} run: | set -euo pipefail - npm version "$SDK_VERSION" --no-git-tag-version --allow-same-version - if [ "$RUNTIME_SOURCE" = "internal" ]; then - npm pkg set "dependencies.@github/copilot=$RUNTIME_VERSION" - node scripts/set-cli-version.js "$RUNTIME_VERSION" --npm-package - else - node scripts/set-cli-version.js "$RUNTIME_VERSION" - fi - echo "Pinned github/copilot-cli release to $(npm pkg get copilotCliVersion)" - - - name: Build SDK - run: npm run build - - - name: Package public release runtimes - if: env.RUNTIME_SOURCE == 'public' - run: npm run pack:release + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create retained release manifest + env: + RELEASE_CHANNEL: canary + RUNTIME_RUN_ID: ${{ needs.resolve.outputs.runtime_run_id }} + RUNTIME_SHA: ${{ needs.resolve.outputs.runtime_sha }} + RUNTIME_SOURCE: azure + RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION: ${{ steps.identity.outputs.sdk_version }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + GH_TOKEN: ${{ github.token }} + run: | + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + export WORKFLOW_CREATED_AT + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + publish-internal: + name: Publish and verify SDK canary internally + if: needs.resolve.outputs.mode == 'internal' + needs: [resolve, package] + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Azure Login (OIDC -> id-cpd-ci) + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.package.outputs.artifact_name }} + path: ./dist + - name: Azure login 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 - - # Auth-only .npmrc: just the two token lines, NO scoped registry line. - # The publish target is supplied explicitly via publishConfig + --registry. - - name: Configure feed auth (.npmrc) + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access run: | set -euo pipefail TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" echo "::add-mask::$TOKEN" - # Derive the protocol-relative auth scopes from FEED_URL (single source - # of truth). NO scoped @github:registry line here — publish target is - # supplied explicitly via publishConfig + --registry. 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}" > .npmrc - echo "Wrote auth-only .npmrc to ./nodejs" - - # Belt and suspenders (2 of 3): pin the publish target in the package too. - - name: Set publishConfig registry - run: npm pkg set "publishConfig.registry=$FEED_URL" - - # Belt and suspenders (3 of 3): fail loudly unless the effective publish - # target is the internal feed. Guards against ever reaching public npm. - - name: Assert publish target is the internal feed + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + - name: Publish exact manifest package set run: | - set -euo pipefail - EFFECTIVE="$(npm pkg get publishConfig.registry | tr -d '"')" - echo "Effective publishConfig.registry: $EFFECTIVE" - if [ "$EFFECTIVE" != "$FEED_URL" ]; then - echo "::error::publishConfig.registry ('$EFFECTIVE') is not the internal feed ('$FEED_URL'). Refusing to publish." - exit 1 - fi - - - name: Publish SDK canary to internal feed - run: | - set -euo pipefail - if [ "$RUNTIME_SOURCE" = "internal" ]; then - node scripts/npm-release.js publish . canary "$FEED_URL" azure - exit - fi - shopt -s nullglob - TARBALLS=(./github-copilot-sdk-*.tgz) - if [ "${#TARBALLS[@]}" -ne 9 ]; then - echo "::error::Expected nine Node.js package tarballs, found ${#TARBALLS[@]}." - exit 1 - fi - MAIN_TARBALL="" - for TARBALL in "${TARBALLS[@]}"; do - PACKAGE_NAME="$(tar -xOf "$TARBALL" package/package.json | jq -r .name)" - if [ "$PACKAGE_NAME" = "@github/copilot-sdk" ]; then - MAIN_TARBALL="$TARBALL" - else - node scripts/npm-release.js publish "$TARBALL" canary "$FEED_URL" azure - fi - done - if [ -z "$MAIN_TARBALL" ]; then - echo "::error::Main @github/copilot-sdk tarball not found." - exit 1 - fi - node scripts/npm-release.js publish "$MAIN_TARBALL" canary "$FEED_URL" azure - - - name: Summarize published canary + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist canary "$FEED_URL" azure + - name: Clean install and runtime version check env: - SDK_VERSION: ${{ steps.sdkver.outputs.SDK_VERSION }} + RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} + SDK_VERSION: ${{ needs.package.outputs.sdk_version }} run: | set -euo pipefail - { - echo "## SDK canary published" - echo "" - echo "| | |" - echo "| --- | --- |" - if [ "$RUNTIME_SOURCE" = "public" ]; then - echo "| Runtime consumed | \`github/copilot-cli@${RUNTIME_VERSION}\` release assets |" - else - echo "| Runtime consumed | \`@github/copilot@${RUNTIME_VERSION}\` |" - fi - echo "| Canary SDK produced | \`@github/copilot-sdk@${SDK_VERSION}\` |" - echo "| Feed | ${FEED_URL} |" - } >> "$GITHUB_STEP_SUMMARY" + VERIFY_ROOT="$RUNNER_TEMP/sdk-canary-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" + "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" diff --git a/docs/developer-docs/secrets.md b/docs/developer-docs/secrets.md index 573f4f22e1..762f75d9b7 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -61,6 +61,8 @@ These secrets are used by the Java SDK Maven Central publishing workflow (`java- ## Secrets not managed in this repository * **`GITHUB_TOKEN`**: Automatically provided by GitHub Actions. No manual management required. + The unstable Node SDK workflow grants it `packages: read` only while acquiring + signed runtime packages from GitHub Packages. ## Further reading diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md new file mode 100644 index 0000000000..6c79eb7ebb --- /dev/null +++ b/docs/developer-docs/unstable-releases.md @@ -0,0 +1,105 @@ +# Canary and unstable Node SDK releases + +The SDK release workflows consume exact runtime platform packages produced by +`github/copilot-agent-runtime`. Canary releases remain internal. Unstable +releases publish the same self-contained Node SDK tarballs internally and then +to public npm. + +## Runtime handoff + +The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each +handoff includes the exact runtime version, full source SHA, and source workflow +run ID. + +Canary dispatches `.github/workflows/sdk-canary.yml` with these inputs: + +* `channel`: `canary` +* `runtime_version`: Exact Azure runtime package version +* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +* `runtime_source`: `azure` +* `runtime_run_id`: Source runtime workflow run ID +* `mode`: `tests-only` or `internal` + +Unstable dispatches `.github/workflows/publish.yml` with these inputs: + +* `dist-tag`: `unstable` +* `runtime_version`: Exact signed GitHub Packages runtime version +* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +* `runtime_source`: `github-packages` +* `runtime_run_id`: Source runtime workflow run ID + +Maintainers can dispatch `publish.yml` directly with the same unstable inputs. +The optional `version` input must be an unstable SemVer. Do not reuse an +explicit version after an artifact has been built. + +## Release gates + +Both channels acquire all eight `@github/copilot-` packages with an +explicit registry argument. The workflows validate npm integrity, runtime +version and SHA metadata, platform metadata, repository metadata, and required +runtime files. Authentication configuration does not map the entire `@github` +scope to GitHub Packages. + +The workflows run runtime-backed Node SDK tests on Ubuntu, macOS, and Windows. +They then build and verify eight self-contained +`@github/copilot-sdk-` packages and the +`@github/copilot-sdk` umbrella package. The checked-in +`COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; runtime npm packages are +build inputs rather than published dependencies. + +An unstable run freezes a version from the nearest eligible SDK release on the +selected branch's first-parent history, the workflow run number, and the SDK +SHA. The packaging job writes all nine tarballs and `release-manifest.json` to +one retained artifact. Publication jobs use that artifact without rebuilding +or recalculating its identity. + +## Publication order + +Canary `tests-only` runs stop after package verification. Canary `internal` +runs publish platform packages before the umbrella package to the Azure +`copilot-canary` feed, then perform a clean install and runtime version check. +No canary job has a public npm publication path. + +Every unstable run publishes the retained platform tarballs and umbrella +tarball to Azure first. A clean internal install must start the exact selected +runtime before public publication begins. The public job uses npm trusted +publishing from `publish.yml` and publishes the same tarballs under the +`unstable` dist-tag, with the umbrella package last. + +Before either publication, the workflow checks all nine package coordinates. +An existing package counts as complete only when registry integrity matches +the retained manifest. A mismatch fails the release. After all package +contents are present, the workflow updates the channel dist-tag. +Azure authentication allows the workflow to add or advance its tag, but it +refuses to rewind a tag that points to a newer version. Public npm trusted +publishing sets `unstable` as each missing package is published. The workflow +then verifies all nine `@unstable` resolutions. It fails rather than attempting +a separate public dist-tag mutation if any resolution differs. + +## Recovery + +Use **Re-run failed jobs** on the original workflow run for normal recovery. +The run number, frozen version, and retained artifact remain unchanged. Do not +rerun a successful packaging job merely to recover a publication job. + +Use `resume_run_id` only when the original run cannot be resumed. Start a new +manual `publish.yml` run with `dist-tag=unstable` and the original SDK workflow +run ID. The recovery path downloads the original retained artifact, verifies +its manifest and all nine SHA-512 integrity values, and uses the recorded SDK +and runtime identities. It never rebuilds or substitutes packages. + +## Registry setup + +The Azure `copilot-canary` feed continues to use the `cicd` environment and +Azure workload identity. GitHub Packages acquisition uses the workflow +`GITHUB_TOKEN` with `packages: read`. + +Before enabling unstable dispatch, publish the eight signed runtime package +coordinates once, set each GitHub Package to public visibility, and confirm +that this repository can read all eight with its workflow token. Public +visibility does not remove GitHub Packages npm authentication. + +Confirm npm trusted publisher configuration authorizes +`.github/workflows/publish.yml` for `@github/copilot-sdk` and all eight +`@github/copilot-sdk-` package names. Do not add a separate protected +SDK publication environment. diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..2fb98ce972 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -21,6 +21,10 @@ release's `SHA256SUMS.txt`. `npm run pack:release` builds the main package and all platform packages. Set `COPILOT_CLI_DOWNLOAD_BASE_URL` to use a release mirror while packaging. +Release workflows instead set `COPILOT_SDK_RUNTIME_PACKAGE_DIR` to a directory +containing validated runtime npm package roots named for all eight platforms. +This keeps `COPILOT_CLI_USE_NPM_PACKAGE` false and embeds those runtime files in +the self-contained SDK platform packages. ## Installation diff --git a/nodejs/package.json b/nodejs/package.json index 4918bc7274..211938123b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -36,8 +36,10 @@ "auth:refresh": "node ../scripts/npm-auth-refresh.mjs --run", "clean": "rimraf --glob dist *.tgz", "build": "tsx esbuild-copilotsdk-nodejs.ts", + "acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts", "pack:release": "tsx scripts/package-sdk.ts", "verify:release-packages": "tsx scripts/verify-release-packages.ts", + "release:manifest": "tsx scripts/release-manifest.ts", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index fe750bada0..a2d1e91104 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -1,13 +1,11 @@ +import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; 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) => { + return new Promise((resolveResult, reject) => { const child = spawn(command, args, { shell: false }); let stdout = ""; let stderr = ""; @@ -21,65 +19,289 @@ export function runCommand(command, args, { stream = false } = {}) { if (stream) process.stderr.write(chunk); }); child.on("error", reject); - child.on("close", (status) => resolve({ status: status ?? 1, stdout, stderr })); + child.on("close", (status) => resolveResult({ status: status ?? 1, stdout, stderr })); }); } -export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { +function parseNpmJson(result) { + for (const output of [result.stdout, result.stderr]) { + try { + return JSON.parse(output); + } catch { + // The caller reports the complete npm output if neither stream is JSON. + } + } + return undefined; +} + +export async function getRegistryIntegrity(packageName, version, registry, runner = runCommand) { const result = await runner("npm", [ "view", `${packageName}@${version}`, - "version", + "dist.integrity", "--json", "--registry", registry, ]); - - if (result.status === 0) { - throw new Error(`${packageName}@${version} already exists on public npm.`); + const parsed = parseNpmJson(result); + if (result.status === 0 && typeof parsed === "string") { + return parsed; } - - try { - if (JSON.parse(result.stdout)?.error?.code === "E404") return; - } catch { - // The failure below includes npm's output for diagnosis. + if (result.status !== 0 && parsed?.error?.code === "E404") { + return undefined; } + const output = `${result.stdout}\n${result.stderr}`.trim(); + throw new Error( + `Could not read ${packageName}@${version} integrity from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` + ); +} +export async function getRegistryTagVersion(packageName, tag, registry, runner = runCommand) { + const result = await runner("npm", [ + "view", + `${packageName}@${tag}`, + "version", + "--json", + "--registry", + registry, + ]); + const parsed = parseNpmJson(result); + if (result.status === 0 && typeof parsed === "string") { + return parsed; + } + if (result.status !== 0 && parsed?.error?.code === "E404") { + return undefined; + } 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}` : ""}` + `Could not read ${packageName}@${tag} from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` ); } -export async function publishTarball(tarball, tag, registry, mode, runner = runCommand) { +export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { + const existing = await getRegistryIntegrity(packageName, version, registry, runner); + if (existing !== undefined) { + throw new Error(`${packageName}@${version} already exists on ${registry}.`); + } +} + +export async function assertPublishedIntegrity( + packageName, + version, + expectedIntegrity, + registry, + runner = runCommand +) { + const existing = await getRegistryIntegrity(packageName, version, registry, runner); + if (existing === undefined) { + return "missing"; + } + if (existing !== expectedIntegrity) { + throw new Error( + `${packageName}@${version} on ${registry} has integrity ${existing}, expected ${expectedIntegrity}.` + ); + } + return "matching"; +} + +export async function publishTarball(tarball, tag, registry, mode, identity, runner = runCommand) { + if (!identity?.name || !identity?.version || !identity?.integrity) { + throw new Error("Publishing requires an expected package name, version, and integrity."); + } 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." + if (result.status !== 0) { + const state = await assertPublishedIntegrity( + identity.name, + identity.version, + identity.integrity, + registry, + runner ); + if (state !== "matching") { + throw new Error(`npm publish failed with exit code ${result.status}.`); + } + console.log(`${identity.name}@${identity.version} already exists with matching integrity.`); return; } + const state = await assertPublishedIntegrity( + identity.name, + identity.version, + identity.integrity, + registry, + runner + ); + if (state !== "matching") { + throw new Error( + `${identity.name}@${identity.version} was not readable with matching integrity after publication.` + ); + } +} - throw new Error(`npm publish failed with exit code ${result.status}.`); +function readReleaseManifest(manifestPath, packageDirectory) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.packages)) { + throw new Error("Unsupported release manifest."); + } + if (manifest.packages.length !== 9) { + throw new Error(`Expected nine release packages, found ${manifest.packages.length}.`); + } + const expectedNames = new Set([ + "@github/copilot-sdk", + "@github/copilot-sdk-darwin-arm64", + "@github/copilot-sdk-darwin-x64", + "@github/copilot-sdk-linux-arm64", + "@github/copilot-sdk-linux-x64", + "@github/copilot-sdk-linuxmusl-arm64", + "@github/copilot-sdk-linuxmusl-x64", + "@github/copilot-sdk-win32-arm64", + "@github/copilot-sdk-win32-x64", + ]); + const names = new Set(); + for (const packed of manifest.packages) { + if ( + typeof packed.name !== "string" || + typeof packed.filename !== "string" || + typeof packed.integrity !== "string" || + typeof packed.size !== "number" + ) { + throw new Error("Release manifest contains an invalid package entry."); + } + if (names.has(packed.name)) { + throw new Error(`Duplicate package in release manifest: ${packed.name}`); + } + if (!expectedNames.has(packed.name)) { + throw new Error(`Unexpected package in release manifest: ${packed.name}`); + } + names.add(packed.name); + const tarball = resolve(packageDirectory, packed.filename); + if ( + dirname(tarball) !== resolve(packageDirectory) || + basename(tarball) !== packed.filename + ) { + throw new Error(`Unsafe release package filename: ${packed.filename}`); + } + const bytes = readFileSync(tarball); + const localIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if (bytes.length !== packed.size || localIntegrity !== packed.integrity) { + throw new Error(`Local release package does not match manifest: ${packed.filename}`); + } + } + if (names.size !== expectedNames.size) { + throw new Error("Release manifest does not contain the exact Node SDK package set."); + } + return manifest; +} + +export async function publishManifest( + manifestPath, + packageDirectory, + tag, + registry, + mode, + runner = runCommand +) { + const manifest = readReleaseManifest(manifestPath, packageDirectory); + const packages = manifest.packages + .map((packed) => ({ + ...packed, + version: manifest.sdk.version, + tarball: resolve(packageDirectory, packed.filename), + })) + .sort((left, right) => { + if (left.name === "@github/copilot-sdk") return 1; + if (right.name === "@github/copilot-sdk") return -1; + return left.name.localeCompare(right.name); + }); + + const states = new Map(); + for (const packed of packages) { + states.set( + packed.name, + await assertPublishedIntegrity( + packed.name, + packed.version, + packed.integrity, + registry, + runner + ) + ); + } + const semver = await import("semver"); + for (const packed of packages) { + const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { + throw new Error( + `${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` + ); + } + if ( + mode === "public" && + states.get(packed.name) === "matching" && + taggedVersion !== packed.version + ) { + throw new Error( + `${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.` + ); + } + } + for (const packed of packages) { + if (states.get(packed.name) === "missing") { + await publishTarball(packed.tarball, tag, registry, mode, packed, runner); + } + } + for (const packed of packages) { + const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + if (taggedVersion === packed.version) { + continue; + } + if (mode === "public") { + throw new Error( + `${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.` + ); + } + if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { + throw new Error( + `${packed.name}@${tag} advanced to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` + ); + } + const result = await runner( + "npm", + ["dist-tag", "add", `${packed.name}@${packed.version}`, tag, "--registry", registry], + { stream: true } + ); + if (result.status !== 0) { + throw new Error(`Failed to set ${packed.name}@${packed.version} dist-tag ${tag}.`); + } + } } 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); + console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`); + } else if (command === "publish" && args.length === 7) { + const [tarball, name, version, tag, registry, mode, expectedIntegrity] = args; + const localIntegrity = `sha512-${createHash("sha512") + .update(readFileSync(tarball)) + .digest("base64")}`; + if (expectedIntegrity !== localIntegrity) { + throw new Error(`Expected integrity does not match ${tarball}.`); + } + await publishTarball(tarball, tag, registry, mode, { + name, + version, + integrity: localIntegrity, + }); + } else if (command === "publish-manifest" && args.length === 5) { + await publishManifest(...args); } else { throw new Error( - "Usage: npm-release.js preflight | publish " + "Usage: npm-release.js preflight | publish | publish-manifest " ); } } diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts new file mode 100644 index 0000000000..9181033afa --- /dev/null +++ b/nodejs/scripts/release-manifest.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; +import * as semver from "semver"; +import { x as extractTar } from "tar"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +export interface ReleaseManifestPackage { + filename: string; + integrity: string; + name: string; + size: number; +} + +export interface ReleaseManifest { + channel: "canary" | "unstable"; + packages: ReleaseManifestPackage[]; + runtime: { + repository: "github/copilot-agent-runtime"; + runId: string; + sha: string; + source: "azure" | "github-packages"; + version: string; + }; + schemaVersion: 1; + sdk: { + ref: string; + repository: "github/copilot-sdk"; + sha: string; + version: string; + }; + workflow: { + createdAt: string; + runId: string; + runNumber: string; + }; +} + +export interface ReleaseManifestMetadata { + channel: ReleaseManifest["channel"]; + createdAt: string; + runtimeSha: string; + runtimeSource: ReleaseManifest["runtime"]["source"]; + runtimeRunId: string; + runtimeVersion: string; + sdkRef: string; + sdkSha: string; + sdkVersion: string; + workflowRunId: string; + workflowRunNumber: string; +} + +const expectedPackageNames = new Set([ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), +]); + +function integrity(buffer: Buffer): string { + return `sha512-${createHash("sha512").update(buffer).digest("base64")}`; +} + +async function readPackedManifest(archive: string): Promise<{ name: string; version: string }> { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-release-manifest-")); + try { + await extractTar({ + cwd: root, + file: archive, + strict: true, + filter: (entryPath) => entryPath === "package/package.json", + }); + return JSON.parse(readFileSync(join(root, "package", "package.json"), "utf8")) as { + name: string; + version: string; + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function validateFullSha(value: string, label: string): void { + assert.match(value, /^[0-9a-f]{40}$/i, `${label} must be a full 40-character SHA`); +} + +export async function createReleaseManifest( + packageDirectory: string, + metadata: ReleaseManifestMetadata +): Promise { + validateFullSha(metadata.sdkSha, "SDK SHA"); + validateFullSha(metadata.runtimeSha, "Runtime SHA"); + assert(Number.isFinite(Date.parse(metadata.createdAt)), "Workflow creation time is invalid"); + const packages: ReleaseManifestPackage[] = []; + for (const archive of globSync("github-copilot-sdk-*.tgz", { + cwd: packageDirectory, + absolute: true, + })) { + const packed = await readPackedManifest(archive); + if (packed.version !== metadata.sdkVersion || !expectedPackageNames.has(packed.name)) { + continue; + } + const bytes = readFileSync(archive); + packages.push({ + filename: basename(archive), + integrity: integrity(bytes), + name: packed.name, + size: bytes.length, + }); + } + packages.sort((left, right) => left.name.localeCompare(right.name)); + assert.deepEqual( + packages.map(({ name }) => name), + [...expectedPackageNames].sort(), + "Release artifact must contain exactly the nine expected Node packages" + ); + return { + schemaVersion: 1, + channel: metadata.channel, + sdk: { + version: metadata.sdkVersion, + sha: metadata.sdkSha, + ref: metadata.sdkRef, + repository: "github/copilot-sdk", + }, + runtime: { + version: metadata.runtimeVersion, + sha: metadata.runtimeSha, + source: metadata.runtimeSource, + repository: "github/copilot-agent-runtime", + runId: metadata.runtimeRunId, + }, + workflow: { + runId: metadata.workflowRunId, + runNumber: metadata.workflowRunNumber, + createdAt: metadata.createdAt, + }, + packages, + }; +} + +export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { + assert.equal(manifest.schemaVersion, 1, "Unsupported release manifest schema"); + assert( + manifest.channel === "canary" || manifest.channel === "unstable", + "Invalid release channel" + ); + validateFullSha(manifest.sdk.sha, "SDK SHA"); + validateFullSha(manifest.runtime.sha, "Runtime SHA"); + assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); + assert(semver.valid(manifest.runtime.version), "Invalid runtime version"); + assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); + assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); + assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); + assert( + Number.isFinite(Date.parse(manifest.workflow.createdAt)), + "Invalid workflow creation time" + ); + assert.equal(manifest.sdk.repository, "github/copilot-sdk"); + assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); + assert.equal( + manifest.runtime.source, + manifest.channel === "canary" ? "azure" : "github-packages", + "Runtime source does not match the release channel" + ); + assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); + assert.deepEqual( + manifest.packages.map(({ name }) => name).sort(), + [...expectedPackageNames].sort(), + "Release manifest package names do not match the expected package set" + ); + for (const packed of manifest.packages) { + const archive = resolve(packageDirectory, packed.filename); + assert.equal( + dirname(archive), + resolve(packageDirectory), + `Unsafe release filename: ${packed.filename}` + ); + const bytes = readFileSync(archive); + assert.equal(statSync(archive).size, packed.size, `Size mismatch for ${packed.filename}`); + assert.equal( + integrity(bytes), + packed.integrity, + `Integrity mismatch for ${packed.filename}` + ); + } +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +async function main(): Promise { + const [command, manifestPath = "release-manifest.json", packageDirectory = "."] = + process.argv.slice(2); + if (command === "create") { + const manifest = await createReleaseManifest(packageDirectory, { + channel: requiredEnvironment("RELEASE_CHANNEL") as ReleaseManifest["channel"], + createdAt: requiredEnvironment("WORKFLOW_CREATED_AT"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + runtimeSource: requiredEnvironment( + "RUNTIME_SOURCE" + ) as ReleaseManifest["runtime"]["source"], + runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), + runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), + sdkRef: requiredEnvironment("SDK_REF"), + sdkSha: requiredEnvironment("SDK_SHA"), + sdkVersion: requiredEnvironment("SDK_VERSION"), + workflowRunId: requiredEnvironment("WORKFLOW_RUN_ID"), + workflowRunNumber: requiredEnvironment("WORKFLOW_RUN_NUMBER"), + }); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + verifyReleaseManifest(manifest, packageDirectory); + return; + } + if (command === "verify") { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ReleaseManifest; + verifyReleaseManifest(manifest, packageDirectory); + return; + } + throw new Error("Usage: release-manifest.ts create|verify [manifest-path] [package-directory]"); +} + +const scriptPath = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (scriptPath) { + main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/nodejs/scripts/releaseArtifacts.ts b/nodejs/scripts/releaseArtifacts.ts index 2731d878c5..cf493f47cb 100644 --- a/nodejs/scripts/releaseArtifacts.ts +++ b/nodejs/scripts/releaseArtifacts.ts @@ -16,6 +16,7 @@ export interface EnsureCopilotPackageOptions { environment?: NodeJS.ProcessEnv; fetch?: typeof globalThis.fetch; fetchTimeoutMs?: number; + packageDirectory?: string; platform?: string; } @@ -107,6 +108,18 @@ export async function ensureCopilotPackage( options: EnsureCopilotPackageOptions = {} ): Promise { const platform = options.platform ?? getRuntimePlatform(); + const environment = options.environment ?? process.env; + const packageDirectory = + options.packageDirectory ?? environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; + if (packageDirectory) { + const packageRoot = join(packageDirectory, platform); + validateFile(join(packageRoot, "package.json"), `${platform} runtime package manifest`); + validateFile( + join(packageRoot, "prebuilds", platform, "runtime.node"), + "Copilot runtime.node" + ); + return packageRoot; + } // lgtm[js/trivial-conditional] This generated constant is true for internal canary builds. if (version === COPILOT_CLI_VERSION && COPILOT_CLI_USE_NPM_PACKAGE) { const packageName = `@github/copilot-${platform}`; @@ -130,7 +143,7 @@ export async function ensureCopilotPackage( } const baseUrl = ( - (options.environment ?? process.env).COPILOT_CLI_DOWNLOAD_BASE_URL ?? + environment.COPILOT_CLI_DOWNLOAD_BASE_URL ?? "https://github.com/github/copilot-cli/releases/download" ).replace(/\/+$/, ""); const fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; diff --git a/nodejs/scripts/runtime-package-acquisition.ts b/nodejs/scripts/runtime-package-acquisition.ts new file mode 100644 index 0000000000..5521f54a46 --- /dev/null +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { x as extractTar } from "tar"; +import { RUNTIME_PLATFORMS, validateFile } from "../src/runtimeArtifacts.js"; + +interface CommandResult { + status: number; + stderr: string; + stdout: string; +} + +interface RuntimePackageManifest { + copilotRuntime?: { + sourceRepository?: string; + sourceSha?: string; + }; + cpu?: string[]; + libc?: string[]; + name?: string; + os?: string[]; + repository?: string | { url?: string }; + version?: string; +} + +export interface AcquireRuntimePackagesOptions { + outputDirectory: string; + registry: string; + runtimeSha: string; + runtimeVersion: string; +} + +export type CommandRunner = ( + command: string, + args: string[], + options?: { cwd?: string } +) => Promise; + +export function getSourceRuntimePackageName(platform: string): string { + return `@github/copilot-${platform}`; +} + +export function runCommand( + command: string, + args: string[], + options: { cwd?: string } = {} +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + shell: false, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (status) => resolveResult({ status: status ?? 1, stdout, stderr })); + }); +} + +function parseJsonOutput(result: CommandResult, description: string): T { + if (result.status !== 0) { + throw new Error( + `${description} failed with exit code ${result.status}: ${result.stderr || result.stdout}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error(`${description} returned invalid JSON: ${result.stdout}`); + } +} + +function validatePlatformMetadata(manifest: RuntimePackageManifest, platform: string): void { + const [osName, cpu] = platform.replace("linuxmusl", "linux").split("-"); + assert.deepEqual(manifest.os, [osName], `Invalid os metadata for ${platform}`); + assert.deepEqual(manifest.cpu, [cpu], `Invalid cpu metadata for ${platform}`); + if (platform.startsWith("linux")) { + assert.deepEqual( + manifest.libc, + [platform.startsWith("linuxmusl") ? "musl" : "glibc"], + `Invalid libc metadata for ${platform}` + ); + } else { + assert.equal(manifest.libc, undefined, `Unexpected libc metadata for ${platform}`); + } +} + +function repositoryUrl(repository: RuntimePackageManifest["repository"]): string { + return typeof repository === "string" ? repository : (repository?.url ?? ""); +} + +export function validateRuntimePackageRoot( + packageRoot: string, + platform: string, + runtimeVersion: string, + runtimeSha: string +): void { + const manifestPath = join(packageRoot, "package.json"); + validateFile(manifestPath, `${platform} runtime package manifest`); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RuntimePackageManifest; + assert.equal(manifest.name, getSourceRuntimePackageName(platform)); + assert.equal(manifest.version, runtimeVersion); + assert.equal(manifest.copilotRuntime?.sourceRepository, "github/copilot-agent-runtime"); + assert.equal(manifest.copilotRuntime?.sourceSha, runtimeSha.toLowerCase()); + assert( + repositoryUrl(manifest.repository).includes("github/copilot-agent-runtime"), + `${manifest.name} does not link to github/copilot-agent-runtime` + ); + validatePlatformMetadata(manifest, platform); + + const windows = platform.startsWith("win32"); + for (const requiredPath of [ + "LICENSE.md", + windows ? "copilot.exe" : "copilot", + join("prebuilds", platform, windows ? "copilot-runtime.exe" : "copilot-runtime"), + join("prebuilds", platform, "runtime.node"), + join("copilot-sdk", "extension.js"), + join("preloads", "extension_bootstrap.mjs"), + join("sdk", "index.js"), + ]) { + validateFile(join(packageRoot, requiredPath), `${manifest.name} ${requiredPath}`); + } +} + +function sha512Integrity(path: string): string { + return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`; +} + +export async function acquireRuntimePackages( + options: AcquireRuntimePackagesOptions, + runner: CommandRunner = runCommand +): Promise { + assert.match(options.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + assert.match(options.registry, /^https:\/\//, "Runtime registry must use HTTPS"); + const outputDirectory = resolve(options.outputDirectory); + const tarballDirectory = join(outputDirectory, "tarballs"); + mkdirSync(tarballDirectory, { recursive: true }); + const acquired: { + filename: string; + integrity: string; + name: string; + platform: string; + version: string; + }[] = []; + + for (const platform of RUNTIME_PLATFORMS) { + const packageName = getSourceRuntimePackageName(platform); + const spec = `${packageName}@${options.runtimeVersion}`; + const viewResult = await runner("npm", [ + "view", + spec, + "dist.integrity", + "--json", + "--registry", + options.registry, + ]); + const registryIntegrity = parseJsonOutput( + viewResult, + `Reading registry integrity for ${spec}` + ); + assert.match( + registryIntegrity, + /^sha512-[A-Za-z0-9+/]+={0,2}$/, + `Invalid registry integrity for ${spec}` + ); + const packResult = await runner("npm", [ + "pack", + spec, + "--json", + "--pack-destination", + tarballDirectory, + "--registry", + options.registry, + ]); + const packed = parseJsonOutput<{ filename: string; integrity?: string }[]>( + packResult, + `Downloading ${spec}` + ); + assert.equal(packed.length, 1, `npm pack returned an unexpected result for ${spec}`); + const tarball = join(tarballDirectory, basename(packed[0].filename)); + validateFile(tarball, `${spec} tarball`); + assert.equal(sha512Integrity(tarball), registryIntegrity, `Integrity mismatch for ${spec}`); + if (packed[0].integrity) { + assert.equal( + packed[0].integrity, + registryIntegrity, + `npm pack integrity mismatch for ${spec}` + ); + } + + const extractionRoot = join(outputDirectory, `.extract-${platform}`); + const packageRoot = join(extractionRoot, "package"); + rmSync(extractionRoot, { recursive: true, force: true }); + mkdirSync(extractionRoot, { recursive: true }); + try { + await extractTar({ cwd: extractionRoot, file: tarball, strict: true }); + validateRuntimePackageRoot( + packageRoot, + platform, + options.runtimeVersion, + options.runtimeSha + ); + const destination = join(outputDirectory, platform); + rmSync(destination, { recursive: true, force: true }); + renameSync(packageRoot, destination); + } finally { + rmSync(extractionRoot, { recursive: true, force: true }); + } + acquired.push({ + filename: basename(tarball), + integrity: registryIntegrity, + name: packageName, + platform, + version: options.runtimeVersion, + }); + } + + assert.equal(acquired.length, 8); + writeFileSync( + join(outputDirectory, "runtime-packages.json"), + `${JSON.stringify( + { + runtimeVersion: options.runtimeVersion, + runtimeSha: options.runtimeSha, + registry: options.registry, + packages: acquired, + }, + null, + 2 + )}\n` + ); +} + +function parseArguments(args: string[]): AcquireRuntimePackagesOptions { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || !value) { + throw new Error( + "Usage: runtime-package-acquisition.ts --version --sha --registry --output " + ); + } + values.set(key, value); + } + return { + runtimeVersion: values.get("--version") ?? "", + runtimeSha: values.get("--sha") ?? "", + registry: values.get("--registry") ?? "", + outputDirectory: values.get("--output") ?? "", + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + acquireRuntimePackages(parseArguments(process.argv.slice(2))).catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/nodejs/scripts/set-cli-version.js b/nodejs/scripts/set-cli-version.js index ea45f90ada..e94d04bea6 100644 --- a/nodejs/scripts/set-cli-version.js +++ b/nodejs/scripts/set-cli-version.js @@ -4,9 +4,9 @@ import { fileURLToPath } from "node:url"; const [version, mode] = process.argv.slice(2); if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z._-]+)?$/.test(version)) { - throw new Error("Usage: set-cli-version.js [--npm-package]"); + throw new Error("Usage: set-cli-version.js [--npm-package|--local-package]"); } -if (mode !== undefined && mode !== "--npm-package") { +if (mode !== undefined && mode !== "--npm-package" && mode !== "--local-package") { throw new Error(`Unknown option: ${mode}`); } @@ -30,7 +30,7 @@ const cliAssets = [ "copilot-win32-x64.zip", ]; const useNpmPackage = mode === "--npm-package"; -if (!useNpmPackage) { +if (mode === undefined) { const checksumsUrl = `https://github.com/github/copilot-cli/releases/download/v${version}/SHA256SUMS.txt`; const response = await fetch(checksumsUrl); if (!response.ok) { diff --git a/nodejs/scripts/unstable-version.ts b/nodejs/scripts/unstable-version.ts new file mode 100644 index 0000000000..c8905ebef8 --- /dev/null +++ b/nodejs/scripts/unstable-version.ts @@ -0,0 +1,137 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as semver from "semver"; + +export interface ReleaseRecord { + draft?: boolean; + published_at: string | null; + tag_name: string; +} + +export interface UnstableVersionOptions { + createdAt: string; + firstParentTags: string[]; + releases: ReleaseRecord[]; + runNumber: string; + sdkSha: string; + versionOverride?: string; +} + +function canonicalVersion(tag: string): string | undefined { + if (!tag.startsWith("v")) { + return undefined; + } + const version = tag.slice(1); + return semver.valid(version) === version ? version : undefined; +} + +export function targetCoreFromBaseline(baseline: string): string { + const parsed = semver.parse(baseline); + if (!parsed) { + throw new Error(`Invalid SDK release baseline: ${baseline}`); + } + if (parsed.prerelease.length > 0) { + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; + } + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +export function calculateUnstableVersion(options: UnstableVersionOptions): string { + if (!/^[0-9]+$/.test(options.runNumber)) { + throw new Error(`Invalid workflow run number: ${options.runNumber}`); + } + if (!/^[0-9a-f]{40}$/i.test(options.sdkSha)) { + throw new Error(`Invalid full SDK SHA: ${options.sdkSha}`); + } + const createdAt = Date.parse(options.createdAt); + if (!Number.isFinite(createdAt)) { + throw new Error(`Invalid workflow creation time: ${options.createdAt}`); + } + + if (options.versionOverride) { + const parsed = semver.parse(options.versionOverride); + if ( + !parsed || + semver.valid(options.versionOverride) !== options.versionOverride || + parsed.prerelease[0] !== "unstable" + ) { + throw new Error( + `Explicit unstable SDK version must be valid SemVer with an unstable prerelease: ${options.versionOverride}` + ); + } + return options.versionOverride; + } + + const eligibleTags = new Set( + options.releases + .filter( + (release) => + !release.draft && + release.published_at !== null && + Date.parse(release.published_at) <= createdAt && + canonicalVersion(release.tag_name) !== undefined + ) + .map((release) => release.tag_name) + ); + const baselineTag = options.firstParentTags.find((tag) => eligibleTags.has(tag)); + const baseline = baselineTag ? canonicalVersion(baselineTag) : undefined; + if (!baseline) { + throw new Error( + "No eligible SDK release tag was found on the selected SDK branch's first-parent history." + ); + } + + return `${targetCoreFromBaseline(baseline)}-unstable.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; +} + +function getFirstParentTags(sdkSha: string): string[] { + const commits = execFileSync("git", ["rev-list", "--first-parent", sdkSha], { + encoding: "utf8", + }) + .trim() + .split(/\r?\n/) + .filter(Boolean); + const position = new Map(commits.map((commit, index) => [commit, index])); + return execFileSync("git", ["tag", "--list", "v*"], { encoding: "utf8" }) + .trim() + .split(/\r?\n/) + .filter((tag) => canonicalVersion(tag) !== undefined) + .map((tag) => ({ + tag, + commit: execFileSync("git", ["rev-parse", `${tag}^{commit}`], { + encoding: "utf8", + }).trim(), + })) + .filter(({ commit }) => position.has(commit)) + .sort( + (left, right) => + (position.get(left.commit) ?? Number.MAX_SAFE_INTEGER) - + (position.get(right.commit) ?? Number.MAX_SAFE_INTEGER) + ) + .map(({ tag }) => tag); +} + +function requireEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const releasesPath = requireEnvironment("SDK_RELEASES_FILE"); + const releases = JSON.parse(readFileSync(releasesPath, "utf8")) as ReleaseRecord[]; + const sdkSha = requireEnvironment("SDK_SHA"); + const version = calculateUnstableVersion({ + createdAt: requireEnvironment("WORKFLOW_CREATED_AT"), + firstParentTags: getFirstParentTags(sdkSha), + releases, + runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER"), + sdkSha, + versionOverride: process.env.SDK_VERSION_OVERRIDE?.trim() || undefined, + }); + process.stdout.write(`${version}\n`); +} diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 26caf7deaa..06d431d7f6 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -1,13 +1,24 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { assertVersionAbsent, publishTarball } from "../scripts/npm-release.js"; +import { + assertPublishedIntegrity, + assertVersionAbsent, + publishManifest, + publishTarball, +} from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; -const version = "1.2.3"; +const version = "1.2.3-unstable.7.gabcdef0"; const registry = "https://registry.example.test"; +const integrity = "sha512-expected"; +const identity = { name: packageName, version, integrity }; const result = (status: number, stdout = "", stderr = "") => ({ status, stdout, stderr }); describe("npm release preflight", () => { - it("succeeds only for a structured E404 response", async () => { + it("recognizes only a structured E404 as absent", async () => { const runner = vi .fn() .mockResolvedValue(result(1, JSON.stringify({ error: { code: "E404" } }))); @@ -16,73 +27,176 @@ describe("npm release preflight", () => { ).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); + it("accepts an existing package only when integrity matches", async () => { + const matching = vi.fn().mockResolvedValue(result(0, JSON.stringify(integrity))); + await expect( + assertPublishedIntegrity(packageName, version, integrity, registry, matching) + ).resolves.toBe("matching"); + + const conflicting = vi + .fn() + .mockResolvedValue(result(0, JSON.stringify("sha512-conflicting"))); + await expect( + assertPublishedIntegrity(packageName, version, integrity, registry, conflicting) + ).rejects.toThrow("has integrity sha512-conflicting"); + }); + + it("does not treat malformed or transient failures as absence", async () => { + const runner = vi.fn().mockResolvedValue(result(1, "not-json", "npm error code E500")); await expect(assertVersionAbsent(packageName, version, registry, runner)).rejects.toThrow( - message + "Could not read" ); }); }); describe("npm release publishing", () => { - it("succeeds after a normal publish", async () => { - const runner = vi.fn().mockResolvedValue(result(0)); + it("verifies registry integrity after a normal publish", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(0)) + .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); await expect( - publishTarball("package.tgz", "latest", registry, "public", runner) + publishTarball("package.tgz", "unstable", registry, "public", identity, 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)); + it("recovers a publication conflict only when registry integrity matches", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) + .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) + publishTarball("package.tgz", "unstable", registry, "public", identity, 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)); + it("fails a publication conflict with different content", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) + .mockResolvedValueOnce(result(0, JSON.stringify("sha512-other"))); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) - ).rejects.toThrow("npm publish failed"); + publishTarball("package.tgz", "unstable", registry, "public", identity, runner) + ).rejects.toThrow("sha512-other"); + }); + + it("preflights all packages, publishes platforms before the umbrella, and tags last", async () => { + const directory = mkdtempSync(join(tmpdir(), "copilot-sdk-npm-release-")); + mkdirSync(directory, { recursive: true }); + const packages = [ + "@github/copilot-sdk", + ...[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linuxmusl-arm64", + "linuxmusl-x64", + "win32-arm64", + "win32-x64", + ].map((platform) => `@github/copilot-sdk-${platform}`), + ].map((name, index) => { + const filename = `package-${index}.tgz`; + const bytes = Buffer.from(name); + writeFileSync(join(directory, filename), bytes); + return { + filename, + integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`, + name, + size: bytes.length, + }; + }); + const manifestPath = join(directory, "release-manifest.json"); + writeFileSync( + manifestPath, + JSON.stringify({ schemaVersion: 1, sdk: { version }, packages }) + ); + const calls: string[][] = []; + const runner = vi.fn(async (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "view") { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name); + if (args[2] === "version") { + return result(0, JSON.stringify(version)); + } + return result( + calls + .filter((call) => call[0] === "publish") + .some((call) => call[1].includes(packed!.filename)) + ? 0 + : 1, + calls + .filter((call) => call[0] === "publish") + .some((call) => call[1].includes(packed!.filename)) + ? JSON.stringify(packed!.integrity) + : JSON.stringify({ error: { code: "E404" } }) + ); + } + return result(0); + }); + + try { + await publishManifest(manifestPath, directory, "unstable", registry, "public", runner); + const publishCalls = calls.filter((args) => args[0] === "publish"); + expect(publishCalls).toHaveLength(9); + expect(publishCalls.at(-1)?.[1]).toContain("package-0.tgz"); + expect(calls.filter((args) => args[0] === "dist-tag")).toHaveLength(0); + expect( + Math.max( + ...calls.map((args, index) => + args[0] === "view" && args[2] === "version" ? index : -1 + ) + ) + ).toBeGreaterThan(calls.map((args) => args[0]).lastIndexOf("publish")); + + const staleTagRunner = vi.fn(async (_command: string, args: string[]) => { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name)!; + return result( + 0, + JSON.stringify(args[2] === "version" ? "9.0.0-unstable.1" : packed.integrity) + ); + }); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "public", + staleTagRunner + ) + ).rejects.toThrow("refusing to rewind"); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "azure", + staleTagRunner + ) + ).rejects.toThrow("refusing to rewind"); + const missingTagRunner = vi.fn(async (_command: string, args: string[]) => { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name)!; + return args[2] === "version" + ? result(1, JSON.stringify({ error: { code: "E404" } })) + : result(0, JSON.stringify(packed.integrity)); + }); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "public", + missingTagRunner + ) + ).rejects.toThrow("Public trusted publishing cannot repair dist-tags"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); }); diff --git a/nodejs/test/release-manifest.test.ts b/nodejs/test/release-manifest.test.ts new file mode 100644 index 0000000000..5c7e1648bd --- /dev/null +++ b/nodejs/test/release-manifest.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { c as createTar } from "tar"; +import { afterEach, describe, expect, it } from "vitest"; +import { createReleaseManifest, verifyReleaseManifest } from "../scripts/release-manifest.js"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +const roots: string[] = []; +const sdkSha = "abcdef0123456789abcdef0123456789abcdef01"; +const runtimeSha = "123456789abcdef0123456789abcdef012345678"; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function packageTarball(root: string, name: string, version: string): Promise { + const packageRoot = join(root, "staging", name.replaceAll("/", "-")); + mkdirSync(join(packageRoot, "package"), { recursive: true }); + writeFileSync(join(packageRoot, "package", "package.json"), JSON.stringify({ name, version })); + const filename = `${name.replace("@github/", "github-").replaceAll("/", "-")}-${version}.tgz`; + await createTar({ cwd: packageRoot, file: join(root, filename), gzip: true }, ["package"]); +} + +describe("release manifest", () => { + it("freezes and verifies the exact nine-package release identity", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-manifest-")); + roots.push(root); + const version = "1.0.13-unstable.8123.gabcdef0"; + for (const name of [ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), + ]) { + await packageTarball(root, name, version); + } + const manifest = await createReleaseManifest(root, { + channel: "unstable", + createdAt: "2026-09-04T00:00:00Z", + runtimeRunId: "9001", + runtimeSha, + runtimeSource: "github-packages", + runtimeVersion: "1.0.83-5.unstable.123.g1234567", + sdkRef: "feature/unstable", + sdkSha, + sdkVersion: version, + workflowRunId: "812300", + workflowRunNumber: "8123", + }); + + expect(manifest.packages).toHaveLength(9); + expect(manifest.runtime.runId).toBe("9001"); + expect(() => verifyReleaseManifest(manifest, root)).not.toThrow(); + + const damaged = join(root, manifest.packages[0].filename); + writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); + expect(() => verifyReleaseManifest(manifest, root)).toThrow("Size mismatch"); + }); +}); diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts new file mode 100644 index 0000000000..004e1a7689 --- /dev/null +++ b/nodejs/test/release-workflows.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = join(import.meta.dirname, "..", ".."); +const canary = readFileSync(join(repositoryRoot, ".github", "workflows", "sdk-canary.yml"), "utf8"); +const publish = readFileSync(join(repositoryRoot, ".github", "workflows", "publish.yml"), "utf8"); + +describe("SDK canary workflow contract", () => { + it("accepts only the exact Azure canary handoff", () => { + for (const input of [ + "channel:", + "runtime_version:", + "runtime_sha:", + "runtime_source:", + "runtime_run_id:", + "mode:", + ]) { + expect(canary).toContain(input); + } + expect(canary).toContain("- canary"); + expect(canary).toContain("- azure"); + expect(canary).toContain("- tests-only"); + expect(canary).toContain("- internal"); + expect(canary).not.toContain("registry.npmjs.org"); + expect(canary).not.toContain("npm.pkg.github.com"); + }); + + it("tests all hosts and packages before optional internal publication", () => { + expect(canary).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(canary).toContain("npm run acquire:runtime-packages"); + expect(canary).toContain("npm run verify:release-packages"); + expect(canary).toContain("publish-manifest"); + expect(canary.indexOf("npm run verify:release-packages")).toBeLessThan( + canary.indexOf("publish-manifest") + ); + }); +}); + +describe("unstable publishing workflow contract", () => { + it("requires the authenticated GitHub Packages runtime handoff", () => { + expect(publish).toContain("runtime_source:"); + expect(publish).toContain("- github-packages"); + expect(publish).toContain("packages: read"); + expect(publish).toContain("//npm.pkg.github.com/:_authToken="); + expect(publish).not.toContain("@github:registry=https://npm.pkg.github.com"); + }); + + it("freezes, tests, packages once, then publishes internal-first", () => { + expect(publish).toContain("scripts/unstable-version.ts"); + expect(publish).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(publish).toContain("release-manifest.json"); + expect(publish).toContain("COPILOT_CLI_USE_NPM_PACKAGE = false"); + expect(publish.indexOf("unstable-publish-internal:")).toBeLessThan( + publish.indexOf("unstable-publish-public:") + ); + expect(publish).toContain("needs: [unstable-plan, unstable-publish-internal]"); + }); + + it("supports retained-artifact recovery without enabling non-Node release paths", () => { + expect(publish).toContain("resume_run_id:"); + expect(publish).toContain("run-id: ${{ inputs.resume_run_id }}"); + expect(publish).toContain("Manifest workflow run ID does not match resume_run_id"); + expect( + publish.match(/github\.event\.inputs\.dist-tag != 'unstable'/g)?.length + ).toBeGreaterThan(3); + expect(publish).toContain("github.event.inputs.dist-tag != 'unstable' &&"); + }); +}); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts new file mode 100644 index 0000000000..d2a08a8f49 --- /dev/null +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { c as createTar } from "tar"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + acquireRuntimePackages, + getSourceRuntimePackageName, + validateRuntimePackageRoot, +} from "../scripts/runtime-package-acquisition.js"; +import { RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +const roots: string[] = []; +const runtimeVersion = "1.0.83-5.unstable.123.gabcdef0"; +const runtimeSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function temporaryRoot(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function createRuntimePackage(root: string, platform: string): Promise { + const packageRoot = join(root, platform, "package"); + const windows = platform.startsWith("win32"); + const [osName, cpu] = platform.replace("linuxmusl", "linux").split("-"); + mkdirSync(join(packageRoot, "prebuilds", platform), { recursive: true }); + mkdirSync(join(packageRoot, "copilot-sdk"), { recursive: true }); + mkdirSync(join(packageRoot, "preloads"), { recursive: true }); + mkdirSync(join(packageRoot, "sdk"), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ + name: getSourceRuntimePackageName(platform), + version: runtimeVersion, + repository: "https://github.com/github/copilot-agent-runtime.git", + os: [osName], + cpu: [cpu], + ...(platform.startsWith("linux") + ? { libc: [platform.startsWith("linuxmusl") ? "musl" : "glibc"] } + : {}), + copilotRuntime: { + sourceRepository: "github/copilot-agent-runtime", + sourceSha: runtimeSha, + }, + }) + ); + for (const path of [ + "LICENSE.md", + windows ? "copilot.exe" : "copilot", + join("prebuilds", platform, windows ? "copilot-runtime.exe" : "copilot-runtime"), + join("prebuilds", platform, "runtime.node"), + join("copilot-sdk", "extension.js"), + join("preloads", "extension_bootstrap.mjs"), + join("sdk", "index.js"), + ]) { + writeFileSync(join(packageRoot, path), path); + } + const archive = join(root, `${platform}.tgz`); + await createTar({ cwd: join(root, platform), file: archive, gzip: true }, ["package"]); + return archive; +} + +describe("runtime npm package acquisition", () => { + it("downloads and validates all eight exact runtime platform packages", async () => { + const root = temporaryRoot("copilot-runtime-acquisition-"); + const output = join(root, "output"); + const archives = new Map(); + for (const platform of RUNTIME_PLATFORMS) { + const path = await createRuntimePackage(root, platform); + archives.set(platform, { + path, + integrity: `sha512-${createHash("sha512") + .update(readFileSync(path)) + .digest("base64")}`, + }); + } + const runner = vi.fn(async (_command: string, args: string[]) => { + const spec = args[1]; + const platform = RUNTIME_PLATFORMS.find((candidate) => + spec.startsWith(`${getSourceRuntimePackageName(candidate)}@`) + ); + expect(platform).toBeDefined(); + const archive = archives.get(platform!)!; + if (args[0] === "view") { + return { status: 0, stdout: JSON.stringify(archive.integrity), stderr: "" }; + } + const destination = args[args.indexOf("--pack-destination") + 1]; + const filename = basename(archive.path); + mkdirSync(destination, { recursive: true }); + copyFileSync(archive.path, join(destination, filename)); + return { + status: 0, + stdout: JSON.stringify([{ filename, integrity: archive.integrity }]), + stderr: "", + }; + }); + + await acquireRuntimePackages( + { + outputDirectory: output, + registry: "https://npm.pkg.github.com", + runtimeSha, + runtimeVersion, + }, + runner + ); + + expect(runner).toHaveBeenCalledTimes(16); + const acquisition = JSON.parse(readFileSync(join(output, "runtime-packages.json"), "utf8")); + expect(acquisition.packages).toHaveLength(8); + for (const platform of RUNTIME_PLATFORMS) { + validateRuntimePackageRoot( + join(output, platform), + platform, + runtimeVersion, + runtimeSha + ); + } + }); + + it("rejects mismatched source identity metadata", async () => { + const root = temporaryRoot("copilot-runtime-identity-"); + await createRuntimePackage(root, "linux-x64"); + const packageRoot = join(root, "linux-x64", "package"); + const manifestPath = join(packageRoot, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.copilotRuntime.sourceSha = "0".repeat(40); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + expect(() => + validateRuntimePackageRoot(packageRoot, "linux-x64", runtimeVersion, runtimeSha) + ).toThrow(); + }); +}); diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts index ffd22f21ef..d6882df23f 100644 --- a/nodejs/test/runtimeArtifacts.test.ts +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -76,12 +76,34 @@ describe("release runtime selection", () => { expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toMatchObject({ copilotCliVersion: "9.9.9-canary.test", }); + expect(existsSync(join(root, "copilot-cli.json"))).toBe(false); expect(readFileSync(join(root, "src", "cliVersion.ts"), "utf8")).toContain( "COPILOT_CLI_USE_NPM_PACKAGE = true" ); }); + it("can pin a pre-acquired package while preserving embedded runtime packaging", () => { + const root = mkdtempSync(join(tmpdir(), "copilot-local-package-version-")); + mkdirSync(join(root, "scripts"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}\n"); + writeFileSync( + join(root, "scripts", "set-cli-version.js"), + readFileSync(join(import.meta.dirname, "../scripts/set-cli-version.js")) + ); + + const result = spawnSync( + process.execPath, + [join(root, "scripts", "set-cli-version.js"), "9.9.9-unstable.test", "--local-package"], + { encoding: "utf8" } + ); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(root, "src", "cliVersion.ts"), "utf8")).toContain( + "COPILOT_CLI_USE_NPM_PACKAGE = false" + ); + }); + it.each([ ["darwin", "arm64", false, "darwin-arm64"], ["darwin", "x64", false, "darwin-x64"], @@ -241,6 +263,28 @@ describe("ensureRuntimeBundle", () => { }); describe("release package acquisition", () => { + it("uses a pre-acquired runtime package directory without network access", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-runtime-packages-")); + const platform = "linux-x64"; + const packageRoot = join(root, platform); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), "{}"); + writeFileSync(join(prebuilds, "runtime.node"), "runtime"); + const fetcher = vi.fn(() => { + throw new Error("local runtime package resolution must not fetch"); + }); + + await expect( + ensureCopilotPackage("1.2.3-unstable.1", { + fetch: fetcher, + packageDirectory: root, + platform, + }) + ).resolves.toBe(packageRoot); + expect(fetcher).not.toHaveBeenCalled(); + }); + it("downloads, verifies, and caches a release package for packaging", async () => { const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-release-source-")); const packageRoot = join(sourceRoot, "package"); diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts new file mode 100644 index 0000000000..d23f963c4a --- /dev/null +++ b/nodejs/test/unstable-version.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { calculateUnstableVersion, targetCoreFromBaseline } from "../scripts/unstable-version.js"; + +const sha = "abcdef0123456789abcdef0123456789abcdef01"; +const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ + tag_name, + published_at, +}); + +describe("unstable SDK version planning", () => { + it("increments a stable baseline patch", () => { + expect(targetCoreFromBaseline("1.0.11")).toBe("1.0.12"); + }); + + it("uses a prerelease baseline's release core", () => { + expect(targetCoreFromBaseline("1.0.13-preview.4")).toBe("1.0.13"); + }); + + it("selects the nearest eligible release on first-parent history", () => { + expect( + calculateUnstableVersion({ + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.13-preview.4", "v1.0.12", "v1.0.11"], + releases: [ + release("v1.0.13-preview.4"), + release("v1.0.12", "2026-09-05T00:00:00Z"), + release("v1.0.11"), + ], + runNumber: "8123", + sdkSha: sha, + }) + ).toBe("1.0.13-unstable.8123.gabcdef0"); + }); + + it("is stable across retries and unique across new workflow runs", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.11"], + releases: [release("v1.0.11")], + runNumber: "8123", + sdkSha: sha, + }; + expect(calculateUnstableVersion(options)).toBe(calculateUnstableVersion(options)); + expect(calculateUnstableVersion({ ...options, runNumber: "8124" })).not.toBe( + calculateUnstableVersion(options) + ); + }); + + it("accepts only explicit unstable SemVer overrides", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: [], + releases: [], + runNumber: "8123", + sdkSha: sha, + }; + expect( + calculateUnstableVersion({ + ...options, + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1"); + expect(() => + calculateUnstableVersion({ ...options, versionOverride: "2.0.0-preview.1" }) + ).toThrow("unstable prerelease"); + }); +}); From d709198011902edc303c9d1f6517308e164fa41e Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 14:28:11 -0700 Subject: [PATCH 02/23] Share runtime-backed Node release pipeline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish.yml | 246 +---------- .../workflows/runtime-backed-node-release.yml | 389 ++++++++++++++++++ .github/workflows/sdk-canary.yml | 249 +---------- docs/developer-docs/unstable-releases.md | 6 + nodejs/test/release-workflows.test.ts | 56 ++- 5 files changed, 464 insertions(+), 482 deletions(-) create mode 100644 .github/workflows/runtime-backed-node-release.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index db96505766..8ec65435ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -453,242 +453,34 @@ jobs: node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" done - unstable-acquire-runtime: - name: Acquire signed unstable runtime packages - if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' + unstable-runtime-backed-release: + name: Run unstable SDK pipeline + if: inputs.dist-tag == 'unstable' needs: unstable-plan - runs-on: ubuntu-latest - permissions: - contents: read - packages: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Configure authentication-only GitHub Packages access - env: - NODE_AUTH_TOKEN: ${{ github.token }} - run: | - echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" - - name: Download and validate all runtime platforms - env: - RUNTIME_SHA: ${{ needs.unstable-plan.outputs.runtime_sha }} - RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} - run: | - npm run acquire:runtime-packages -- \ - --version "$RUNTIME_VERSION" \ - --sha "$RUNTIME_SHA" \ - --registry https://npm.pkg.github.com \ - --output "$RUNNER_TEMP/runtime-packages" - - uses: actions/upload-artifact@v7.0.0 - with: - name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - if-no-files-found: error - retention-days: 7 - - unstable-test: - name: Runtime-backed unstable tests (${{ matrix.os }}) - if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' - needs: [unstable-plan, unstable-acquire-runtime] - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - environment: cicd - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Select the acquired runtime - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} - run: | - set -euo pipefail - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - "$runtime_path" --version | grep -F "$RUNTIME_VERSION" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - run: npm run build - - name: Warm up PowerShell - if: runner.os == 'Windows' - run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - name: Run Node SDK tests - env: - COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test - - unstable-package: - name: Build retained unstable release - if: inputs.dist-tag == 'unstable' && inputs.resume_run_id == '' - needs: [unstable-plan, unstable-acquire-runtime, unstable-test] - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: unstable-runtime-${{ needs.unstable-plan.outputs.runtime_version }}-${{ needs.unstable-plan.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Build and verify exact package set - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} - SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} - run: | - set -euo pipefail - VERSION="$SDK_VERSION" node scripts/set-version.js - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts - npm run build - npm run pack:release - npm run verify:release-packages - - name: Create immutable release manifest - env: - RELEASE_CHANNEL: unstable - RUNTIME_RUN_ID: ${{ needs.unstable-plan.outputs.runtime_run_id }} - RUNTIME_SHA: ${{ needs.unstable-plan.outputs.runtime_sha }} - RUNTIME_SOURCE: github-packages - RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} - SDK_REF: ${{ needs.unstable-plan.outputs.sdk_ref }} - SDK_SHA: ${{ needs.unstable-plan.outputs.sdk_sha }} - SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} - WORKFLOW_CREATED_AT: ${{ needs.unstable-plan.outputs.workflow_created_at }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - npm run release:manifest -- create release-manifest.json . - npm run release:manifest -- verify release-manifest.json . - - uses: actions/upload-artifact@v7.0.0 - with: - name: ${{ needs.unstable-plan.outputs.artifact_name }} - path: | - nodejs/release-manifest.json - nodejs/github-copilot-sdk-*.tgz - if-no-files-found: error - retention-days: 30 - - unstable-publish-internal: - name: Publish and verify unstable SDK internally - if: | - always() && - inputs.dist-tag == 'unstable' && - needs.unstable-plan.result == 'success' && - (inputs.resume_run_id != '' || needs.unstable-package.result == 'success') - needs: [unstable-plan, unstable-package] - runs-on: ubuntu-latest - environment: cicd + uses: ./.github/workflows/runtime-backed-node-release.yml 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 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Download current retained release - if: inputs.resume_run_id == '' - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.unstable-plan.outputs.artifact_name }} - path: ./dist - - name: Download original retained release - if: inputs.resume_run_id != '' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./dist - pattern: ${{ needs.unstable-plan.outputs.artifact_name }} - repository: ${{ github.repository }} - run-id: ${{ inputs.resume_run_id }} - - name: Validate retained release - env: - EXPECTED_RUN_ID: ${{ inputs.resume_run_id || github.run_id }} - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "$EXPECTED_RUN_ID" ] || - { echo "::error::Retained release belongs to a different workflow run."; exit 1; } - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - run: | - 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 exact tarballs internally - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist unstable "$FEED_URL" azure - - name: Clean install and runtime version check - env: - RUNTIME_VERSION: ${{ needs.unstable-plan.outputs.runtime_version }} - SDK_VERSION: ${{ needs.unstable-plan.outputs.sdk_version }} - run: | - set -euo pipefail - VERIFY_ROOT="$RUNNER_TEMP/sdk-unstable-verification" - mkdir -p "$VERIFY_ROOT" - cd "$VERIFY_ROOT" - npm init -y >/dev/null - printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" - npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" - RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" - "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" + packages: read + with: + artifact_name: ${{ needs.unstable-plan.outputs.artifact_name }} + channel: unstable + mode: internal + resume_run_id: ${{ inputs.resume_run_id }} + runtime_run_id: ${{ needs.unstable-plan.outputs.runtime_run_id }} + runtime_sha: ${{ needs.unstable-plan.outputs.runtime_sha }} + runtime_source: github-packages + runtime_version: ${{ needs.unstable-plan.outputs.runtime_version }} + sdk_ref: ${{ needs.unstable-plan.outputs.sdk_ref }} + sdk_sha: ${{ needs.unstable-plan.outputs.sdk_sha }} + sdk_version: ${{ needs.unstable-plan.outputs.sdk_version }} + secrets: inherit unstable-publish-public: name: Publish unstable SDK publicly if: inputs.dist-tag == 'unstable' - needs: [unstable-plan, unstable-publish-internal] + needs: [unstable-plan, unstable-runtime-backed-release] runs-on: ubuntu-latest permissions: actions: read diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml new file mode 100644 index 0000000000..9330eeb39c --- /dev/null +++ b/.github/workflows/runtime-backed-node-release.yml @@ -0,0 +1,389 @@ +name: Runtime-backed Node SDK release + +on: + workflow_call: + inputs: + artifact_name: + required: false + type: string + default: "" + channel: + required: true + type: string + mode: + required: true + type: string + resume_run_id: + required: false + type: string + default: "" + runtime_run_id: + required: true + type: string + runtime_sha: + required: true + type: string + runtime_source: + required: true + type: string + runtime_version: + required: true + type: string + sdk_ref: + required: true + type: string + sdk_sha: + required: true + type: string + sdk_version: + required: false + type: string + default: "" + outputs: + artifact_name: + value: ${{ jobs.boundary.outputs.artifact_name }} + sdk_version: + value: ${{ jobs.boundary.outputs.sdk_version }} + secrets: + COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY: + required: true + +env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + HUSKY: 0 + +jobs: + boundary: + name: Validate shared release boundary + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + artifact_name: ${{ steps.validate.outputs.artifact_name }} + sdk_version: ${{ steps.validate.outputs.sdk_version }} + workflow_created_at: ${{ steps.validate.outputs.workflow_created_at }} + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Enforce channel, source, mode, and identity + id: validate + env: + ARTIFACT_NAME: ${{ inputs.artifact_name }} + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + MODE: ${{ inputs.mode }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ inputs.sdk_ref }} + SDK_SHA: ${{ inputs.sdk_sha }} + SDK_VERSION: ${{ inputs.sdk_version }} + run: | + set -euo pipefail + case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in + canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; + *) echo "::error::Invalid runtime-backed release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; + esac + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + [[ "$SDK_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::sdk_sha must be a lowercase full SHA."; exit 1; } + [ -n "$SDK_REF" ] || { echo "::error::sdk_ref is required."; exit 1; } + if [ -n "$RESUME_RUN_ID" ]; then + [ "$CHANNEL" = "unstable" ] || + { echo "::error::Only unstable releases support resume_run_id."; exit 1; } + [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::resume_run_id must be numeric."; exit 1; } + fi + if [ "$CHANNEL" = "canary" ]; then + [ -z "$RESUME_RUN_ID" ] || + { echo "::error::Canary cannot resume another workflow run."; exit 1; } + PUBLIC_LATEST="$(node scripts/get-version.js current)" + BASE="${PUBLIC_LATEST%%-*}" + IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" + SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" + else + [ -n "$SDK_VERSION" ] || { echo "::error::Unstable sdk_version is required."; exit 1; } + [[ "$SDK_VERSION" =~ -unstable\. ]] || + { echo "::error::Unstable sdk_version must use the unstable prerelease identifier."; exit 1; } + fi + npm exec -- semver "$SDK_VERSION" >/dev/null + EXPECTED_ARTIFACT="nodejs-${CHANNEL}-${SDK_VERSION}" + if [ -n "$ARTIFACT_NAME" ] && [ "$ARTIFACT_NAME" != "$EXPECTED_ARTIFACT" ]; then + echo "::error::artifact_name must be $EXPECTED_ARTIFACT." + exit 1 + fi + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + { + echo "artifact_name=$EXPECTED_ARTIFACT" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + + acquire-runtime: + name: Acquire exact runtime packages + if: inputs.resume_run_id == '' + needs: boundary + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + id-token: write + packages: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Azure login + if: inputs.runtime_source == 'azure' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + if: inputs.runtime_source == 'azure' + run: | + 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: Configure authentication-only GitHub Packages access + if: inputs.runtime_source == 'github-packages' + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + REGISTRY: ${{ inputs.runtime_source == 'azure' && env.FEED_URL || 'https://npm.pkg.github.com' }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --registry "$REGISTRY" \ + --output "$RUNNER_TEMP/runtime-packages" + - uses: actions/upload-artifact@v7.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + if-no-files-found: error + retention-days: 7 + + test: + name: Runtime-backed Node tests (${{ matrix.os }}) + if: inputs.resume_run_id == '' + needs: [boundary, acquire-runtime] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + "$runtime_path" --version | grep -F "$RUNTIME_VERSION" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + package: + name: Build and verify nine SDK packages + if: inputs.resume_run_id == '' + needs: [boundary, acquire-runtime, test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + run: | + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ inputs.sdk_ref }} + SDK_SHA: ${{ inputs.sdk_sha }} + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.boundary.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ needs.boundary.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + publish-internal: + name: Publish and verify SDK internally + if: | + always() && + !cancelled() && + inputs.mode == 'internal' && + needs.boundary.result == 'success' && + (inputs.resume_run_id != '' || needs.package.result == 'success') + needs: [boundary, package] + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download current retained release + if: inputs.resume_run_id == '' + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.boundary.outputs.artifact_name }} + path: ./dist + - name: Download original retained release + if: inputs.resume_run_id != '' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./dist + pattern: ${{ needs.boundary.outputs.artifact_name }} + repository: ${{ github.repository }} + run-id: ${{ inputs.resume_run_id }} + - name: Validate retained release + env: + EXPECTED_RUN_ID: ${{ inputs.resume_run_id || github.run_id }} + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "$EXPECTED_RUN_ID" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || + { echo "::error::Retained release channel does not match the requested channel."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + 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 exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure + - name: Clean install and runtime version check + env: + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + run: | + VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" + "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" diff --git a/.github/workflows/sdk-canary.yml b/.github/workflows/sdk-canary.yml index 7cd3820d40..f2ea2312ae 100644 --- a/.github/workflows/sdk-canary.yml +++ b/.github/workflows/sdk-canary.yml @@ -1,10 +1,5 @@ name: "SDK Canary Test/Publish" -env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - HUSKY: 0 - on: workflow_dispatch: inputs: @@ -125,239 +120,21 @@ jobs: echo "runtime_version=$RUNTIME_VERSION" } >> "$GITHUB_OUTPUT" - acquire-runtime: - name: Acquire exact runtime packages + runtime-backed-release: + name: Run canary SDK pipeline needs: resolve - runs-on: ubuntu-latest - environment: cicd - permissions: - contents: read - id-token: write - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - 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: Download and validate all runtime platforms - env: - RUNTIME_SHA: ${{ needs.resolve.outputs.runtime_sha }} - RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} - run: | - npm run acquire:runtime-packages -- \ - --version "$RUNTIME_VERSION" \ - --sha "$RUNTIME_SHA" \ - --registry "$FEED_URL" \ - --output "$RUNNER_TEMP/runtime-packages" - - uses: actions/upload-artifact@v7.0.0 - with: - name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - if-no-files-found: error - retention-days: 7 - - test: - name: Runtime-backed Node tests (${{ matrix.os }}) - needs: [resolve, acquire-runtime] - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - environment: cicd - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Select the acquired runtime - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} - run: | - set -euo pipefail - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - "$runtime_path" --version | grep -F "$RUNTIME_VERSION" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - run: npm run build - - name: Warm up PowerShell - if: runner.os == 'Windows' - run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - name: Run Node SDK tests - env: - COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test - - package: - name: Build and verify nine SDK packages - needs: [resolve, acquire-runtime, test] - runs-on: ubuntu-latest + uses: ./.github/workflows/runtime-backed-node-release.yml permissions: actions: read contents: read - outputs: - artifact_name: ${{ steps.identity.outputs.artifact_name }} - sdk_version: ${{ steps.identity.outputs.sdk_version }} - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: canary-runtime-${{ needs.resolve.outputs.runtime_version }}-${{ needs.resolve.outputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Freeze SDK canary version - id: identity - env: - SDK_SHA: ${{ github.sha }} - run: | - set -euo pipefail - PUBLIC_LATEST="$(node scripts/get-version.js current)" - BASE="${PUBLIC_LATEST%%-*}" - IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" - SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" - npm exec -- semver "$SDK_VERSION" - echo "sdk_version=$SDK_VERSION" >> "$GITHUB_OUTPUT" - echo "artifact_name=nodejs-canary-$SDK_VERSION" >> "$GITHUB_OUTPUT" - - name: Build package set - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} - SDK_VERSION: ${{ steps.identity.outputs.sdk_version }} - run: | - set -euo pipefail - VERSION="$SDK_VERSION" node scripts/set-version.js - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts - npm run build - npm run pack:release - npm run verify:release-packages - - name: Create retained release manifest - env: - RELEASE_CHANNEL: canary - RUNTIME_RUN_ID: ${{ needs.resolve.outputs.runtime_run_id }} - RUNTIME_SHA: ${{ needs.resolve.outputs.runtime_sha }} - RUNTIME_SOURCE: azure - RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - SDK_VERSION: ${{ steps.identity.outputs.sdk_version }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - GH_TOKEN: ${{ github.token }} - run: | - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - export WORKFLOW_CREATED_AT - npm run release:manifest -- create release-manifest.json . - npm run release:manifest -- verify release-manifest.json . - - uses: actions/upload-artifact@v7.0.0 - with: - name: ${{ steps.identity.outputs.artifact_name }} - path: | - nodejs/release-manifest.json - nodejs/github-copilot-sdk-*.tgz - if-no-files-found: error - retention-days: 30 - - publish-internal: - name: Publish and verify SDK canary internally - if: needs.resolve.outputs.mode == 'internal' - needs: [resolve, package] - runs-on: ubuntu-latest - environment: cicd - permissions: - contents: read id-token: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.package.outputs.artifact_name }} - path: ./dist - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - 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 exact manifest package set - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist canary "$FEED_URL" azure - - name: Clean install and runtime version check - env: - RUNTIME_VERSION: ${{ needs.resolve.outputs.runtime_version }} - SDK_VERSION: ${{ needs.package.outputs.sdk_version }} - run: | - set -euo pipefail - VERIFY_ROOT="$RUNNER_TEMP/sdk-canary-verification" - mkdir -p "$VERIFY_ROOT" - cd "$VERIFY_ROOT" - npm init -y >/dev/null - printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" - npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" - RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" - "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" + with: + channel: canary + mode: ${{ needs.resolve.outputs.mode }} + runtime_run_id: ${{ needs.resolve.outputs.runtime_run_id }} + runtime_sha: ${{ needs.resolve.outputs.runtime_sha }} + runtime_source: ${{ needs.resolve.outputs.runtime_source }} + runtime_version: ${{ needs.resolve.outputs.runtime_version }} + sdk_ref: ${{ github.ref }} + sdk_sha: ${{ github.sha }} + secrets: inherit diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 6c79eb7ebb..4013fef2dd 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -11,6 +11,12 @@ The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each handoff includes the exact runtime version, full source SHA, and source workflow run ID. +`sdk-canary.yml` and `publish.yml` remain separate entry points and trust +boundaries. Both invoke `runtime-backed-node-release.yml`, which owns runtime +acquisition, cross-platform tests, packaging, manifest retention, recovery, and +optional internal publication. Only `publish.yml` contains public npm +publication. + Canary dispatches `.github/workflows/sdk-canary.yml` with these inputs: * `channel`: `canary` diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 004e1a7689..df3f8fc0c3 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -3,8 +3,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const repositoryRoot = join(import.meta.dirname, "..", ".."); -const canary = readFileSync(join(repositoryRoot, ".github", "workflows", "sdk-canary.yml"), "utf8"); -const publish = readFileSync(join(repositoryRoot, ".github", "workflows", "publish.yml"), "utf8"); +const workflow = (name: string) => + readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); +const canary = workflow("sdk-canary.yml"); +const publish = workflow("publish.yml"); +const shared = workflow("runtime-backed-node-release.yml"); describe("SDK canary workflow contract", () => { it("accepts only the exact Azure canary handoff", () => { @@ -22,17 +25,31 @@ describe("SDK canary workflow contract", () => { expect(canary).toContain("- azure"); expect(canary).toContain("- tests-only"); expect(canary).toContain("- internal"); + }); + + it("delegates implementation without granting public capability", () => { + expect(canary).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); + expect(canary).toContain("channel: canary"); expect(canary).not.toContain("registry.npmjs.org"); - expect(canary).not.toContain("npm.pkg.github.com"); + expect(canary).not.toContain("unstable-publish-public"); + }); +}); + +describe("shared runtime-backed Node pipeline", () => { + it("enforces the channel, source, and mode matrix again", () => { + expect(shared).toContain("canary:azure:tests-only"); + expect(shared).toContain("canary:azure:internal"); + expect(shared).toContain("unstable:github-packages:internal"); + expect(shared).not.toContain("registry.npmjs.org"); }); - it("tests all hosts and packages before optional internal publication", () => { - expect(canary).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); - expect(canary).toContain("npm run acquire:runtime-packages"); - expect(canary).toContain("npm run verify:release-packages"); - expect(canary).toContain("publish-manifest"); - expect(canary.indexOf("npm run verify:release-packages")).toBeLessThan( - canary.indexOf("publish-manifest") + it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { + expect(shared).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(shared).toContain("npm run acquire:runtime-packages"); + expect(shared).toContain("npm run verify:release-packages"); + expect(shared).toContain("publish-manifest"); + expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( + shared.indexOf("publish-manifest") ); }); }); @@ -41,25 +58,26 @@ describe("unstable publishing workflow contract", () => { it("requires the authenticated GitHub Packages runtime handoff", () => { expect(publish).toContain("runtime_source:"); expect(publish).toContain("- github-packages"); - expect(publish).toContain("packages: read"); - expect(publish).toContain("//npm.pkg.github.com/:_authToken="); - expect(publish).not.toContain("@github:registry=https://npm.pkg.github.com"); + expect(shared).toContain("packages: read"); + expect(shared).toContain("//npm.pkg.github.com/:_authToken="); + expect(shared).not.toContain("@github:registry=https://npm.pkg.github.com"); }); - it("freezes, tests, packages once, then publishes internal-first", () => { + it("freezes identity, delegates internal preparation, then publishes publicly", () => { expect(publish).toContain("scripts/unstable-version.ts"); - expect(publish).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); - expect(publish).toContain("release-manifest.json"); - expect(publish).toContain("COPILOT_CLI_USE_NPM_PACKAGE = false"); - expect(publish.indexOf("unstable-publish-internal:")).toBeLessThan( + expect(publish).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); + expect(shared).toContain("release-manifest.json"); + expect(shared).toContain("COPILOT_CLI_USE_NPM_PACKAGE = false"); + expect(publish.indexOf("unstable-runtime-backed-release:")).toBeLessThan( publish.indexOf("unstable-publish-public:") ); - expect(publish).toContain("needs: [unstable-plan, unstable-publish-internal]"); + expect(publish).toContain("needs: [unstable-plan, unstable-runtime-backed-release]"); }); it("supports retained-artifact recovery without enabling non-Node release paths", () => { expect(publish).toContain("resume_run_id:"); expect(publish).toContain("run-id: ${{ inputs.resume_run_id }}"); + expect(shared).toContain("run-id: ${{ inputs.resume_run_id }}"); expect(publish).toContain("Manifest workflow run ID does not match resume_run_id"); expect( publish.match(/github\.event\.inputs\.dist-tag != 'unstable'/g)?.length From 5d41b9a7d398f1513383d11aa94c8dbdcbcea4eb Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 15:00:37 -0700 Subject: [PATCH 03/23] Unify runtime-driven SDK publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish.yml | 269 +--------- .../workflows/runtime-backed-node-release.yml | 3 + .github/workflows/runtime-sdk.yml | 479 ++++++++++++++++++ .github/workflows/sdk-canary.yml | 140 ----- docs/developer-docs/secrets.md | 4 +- docs/developer-docs/unstable-releases.md | 65 +-- nodejs/scripts/runtime-dispatch-ledger.ts | 238 +++++++++ nodejs/test/release-workflows.test.ts | 120 +++-- nodejs/test/runtime-dispatch-ledger.test.ts | 104 ++++ 9 files changed, 940 insertions(+), 482 deletions(-) create mode 100644 .github/workflows/runtime-sdk.yml delete mode 100644 .github/workflows/sdk-canary.yml create mode 100644 nodejs/scripts/runtime-dispatch-ledger.ts create mode 100644 nodejs/test/runtime-dispatch-ledger.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ec65435ca..f0b035f3d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,46 +14,22 @@ on: options: - latest - prerelease - - unstable version: description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." type: string required: false - runtime_version: - description: "Exact signed runtime version (required for unstable)" - type: string - required: false - runtime_sha: - description: "Full github/copilot-agent-runtime SHA (required for unstable)" - type: string - required: false - runtime_source: - description: "Runtime package source (required for unstable)" - type: choice - required: false - options: - - github-packages - runtime_run_id: - description: "Source runtime workflow run ID (required for unstable)" - type: string - required: false - resume_run_id: - description: "Exceptional recovery: original SDK workflow run ID" - type: string - required: false permissions: contents: read concurrency: - group: publish-${{ inputs.dist-tag == 'unstable' && 'unstable' || 'release' }} + group: publish cancel-in-progress: false jobs: # Shared job to calculate version once for all publish jobs version: name: Calculate Version - if: inputs.dist-tag != 'unstable' runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.VERSION }} @@ -63,6 +39,14 @@ jobs: run: working-directory: ./nodejs steps: + - name: Validate release channel + env: + DIST_TAG: ${{ inputs.dist-tag }} + run: | + case "$DIST_TAG" in + latest|prerelease) ;; + *) echo "::error::publish.yml only accepts latest or prerelease."; exit 1 ;; + esac - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: @@ -89,7 +73,7 @@ jobs: else if [[ "$VERSION" != *-* ]]; then echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY - echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable" + echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" exit 1 fi fi @@ -110,7 +94,6 @@ jobs: package-nodejs: name: Package Node.js SDK - if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -149,7 +132,7 @@ jobs: publish-nodejs: name: Publish Node.js SDK needs: [version, package-nodejs] - if: inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: actions: read @@ -295,233 +278,8 @@ jobs: azure \ "$INTEGRITY" - unstable-plan: - name: Freeze unstable release identity - if: inputs.dist-tag == 'unstable' - runs-on: ubuntu-latest - environment: cicd - permissions: - actions: read - contents: read - id-token: write - outputs: - artifact_name: ${{ steps.recover.outputs.artifact_name || steps.plan.outputs.artifact_name }} - runtime_run_id: ${{ steps.recover.outputs.runtime_run_id || steps.plan.outputs.runtime_run_id }} - runtime_sha: ${{ steps.recover.outputs.runtime_sha || steps.plan.outputs.runtime_sha }} - runtime_version: ${{ steps.recover.outputs.runtime_version || steps.plan.outputs.runtime_version }} - sdk_ref: ${{ steps.recover.outputs.sdk_ref || steps.plan.outputs.sdk_ref }} - sdk_sha: ${{ steps.recover.outputs.sdk_sha || steps.plan.outputs.sdk_sha }} - sdk_version: ${{ steps.recover.outputs.sdk_version || steps.plan.outputs.sdk_version }} - workflow_created_at: ${{ steps.recover.outputs.workflow_created_at || steps.plan.outputs.workflow_created_at }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Download original retained release - if: inputs.resume_run_id != '' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./recovery - pattern: nodejs-unstable-* - repository: ${{ github.repository }} - run-id: ${{ inputs.resume_run_id }} - - name: Validate exceptional recovery identity - if: inputs.resume_run_id != '' - id: recover - env: - RESUME_RUN_ID: ${{ inputs.resume_run_id }} - run: | - set -euo pipefail - [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::resume_run_id must be numeric."; exit 1; } - MANIFEST="./recovery/release-manifest.json" - [ -f "$MANIFEST" ] || - { echo "::error::Original run does not contain one retained unstable release artifact."; exit 1; } - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify "$MANIFEST" ./recovery - [ "$(jq -r .channel "$MANIFEST")" = "unstable" ] || - { echo "::error::Recovery artifact is not an unstable release."; exit 1; } - [ "$(jq -r .workflow.runId "$MANIFEST")" = "$RESUME_RUN_ID" ] || - { echo "::error::Manifest workflow run ID does not match resume_run_id."; exit 1; } - { - echo "artifact_name=nodejs-unstable-$(jq -r .sdk.version "$MANIFEST")" - echo "runtime_run_id=$(jq -r .runtime.runId "$MANIFEST")" - echo "runtime_sha=$(jq -r .runtime.sha "$MANIFEST")" - echo "runtime_version=$(jq -r .runtime.version "$MANIFEST")" - echo "sdk_ref=$(jq -r .sdk.ref "$MANIFEST")" - echo "sdk_sha=$(jq -r .sdk.sha "$MANIFEST")" - echo "sdk_version=$(jq -r .sdk.version "$MANIFEST")" - echo "workflow_created_at=$(jq -r .workflow.createdAt "$MANIFEST")" - } >> "$GITHUB_OUTPUT" - - name: Validate runtime handoff and calculate version - if: inputs.resume_run_id == '' - id: plan - working-directory: ./nodejs - env: - GH_TOKEN: ${{ github.token }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_SHA: ${{ github.sha }} - SDK_VERSION_OVERRIDE: ${{ inputs.version }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - set -euo pipefail - [ "$RUNTIME_SOURCE" = "github-packages" ] || - { echo "::error::Unstable runtime_source must be github-packages."; exit 1; } - [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || - { echo "::error::runtime_version must be exact SemVer."; exit 1; } - [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || - { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } - [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::runtime_run_id must be numeric."; exit 1; } - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" - export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" - export WORKFLOW_CREATED_AT - SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" - if [ -n "$SDK_VERSION_OVERRIDE" ]; then - for PACKAGE in \ - @github/copilot-sdk \ - @github/copilot-sdk-darwin-arm64 \ - @github/copilot-sdk-darwin-x64 \ - @github/copilot-sdk-linux-arm64 \ - @github/copilot-sdk-linux-x64 \ - @github/copilot-sdk-linuxmusl-arm64 \ - @github/copilot-sdk-linuxmusl-x64 \ - @github/copilot-sdk-win32-arm64 \ - @github/copilot-sdk-win32-x64; do - node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" https://registry.npmjs.org - done - fi - { - echo "artifact_name=nodejs-unstable-$SDK_VERSION" - echo "runtime_run_id=$RUNTIME_RUN_ID" - echo "runtime_sha=$RUNTIME_SHA" - echo "runtime_version=$RUNTIME_VERSION" - echo "sdk_ref=$GITHUB_REF" - echo "sdk_sha=$SDK_SHA" - echo "sdk_version=$SDK_VERSION" - echo "workflow_created_at=$WORKFLOW_CREATED_AT" - } >> "$GITHUB_OUTPUT" - - name: Azure login for explicit-version preflight - if: inputs.resume_run_id == '' && inputs.version != '' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Reject an explicit version already present internally - if: inputs.resume_run_id == '' && inputs.version != '' - working-directory: ./nodejs - env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} - 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" - for PACKAGE in \ - @github/copilot-sdk \ - @github/copilot-sdk-darwin-arm64 \ - @github/copilot-sdk-darwin-x64 \ - @github/copilot-sdk-linux-arm64 \ - @github/copilot-sdk-linux-x64 \ - @github/copilot-sdk-linuxmusl-arm64 \ - @github/copilot-sdk-linuxmusl-x64 \ - @github/copilot-sdk-win32-arm64 \ - @github/copilot-sdk-win32-x64; do - node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" - done - - unstable-runtime-backed-release: - name: Run unstable SDK pipeline - if: inputs.dist-tag == 'unstable' - needs: unstable-plan - uses: ./.github/workflows/runtime-backed-node-release.yml - permissions: - actions: read - contents: read - id-token: write - packages: read - with: - artifact_name: ${{ needs.unstable-plan.outputs.artifact_name }} - channel: unstable - mode: internal - resume_run_id: ${{ inputs.resume_run_id }} - runtime_run_id: ${{ needs.unstable-plan.outputs.runtime_run_id }} - runtime_sha: ${{ needs.unstable-plan.outputs.runtime_sha }} - runtime_source: github-packages - runtime_version: ${{ needs.unstable-plan.outputs.runtime_version }} - sdk_ref: ${{ needs.unstable-plan.outputs.sdk_ref }} - sdk_sha: ${{ needs.unstable-plan.outputs.sdk_sha }} - sdk_version: ${{ needs.unstable-plan.outputs.sdk_version }} - secrets: inherit - - unstable-publish-public: - name: Publish unstable SDK publicly - if: inputs.dist-tag == 'unstable' - needs: [unstable-plan, unstable-runtime-backed-release] - 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 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Update npm for trusted publishing - run: npm install --global npm@11.6.3 - - name: Download current retained release - if: inputs.resume_run_id == '' - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.unstable-plan.outputs.artifact_name }} - path: ./dist - - name: Download original retained release - if: inputs.resume_run_id != '' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./dist - pattern: ${{ needs.unstable-plan.outputs.artifact_name }} - repository: ${{ github.repository }} - run-id: ${{ inputs.resume_run_id }} - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - - name: Publish the same tarballs to public npm - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist unstable https://registry.npmjs.org public - publish-dotnet: name: Publish .NET SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -564,7 +322,6 @@ jobs: publish-rust: name: Publish Rust SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -608,7 +365,6 @@ jobs: publish-python: name: Publish Python SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -646,7 +402,7 @@ jobs: publish-java: name: Publish Java SDK - if: github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' needs: version permissions: contents: write @@ -671,7 +427,6 @@ jobs: if: | always() && github.ref == 'refs/heads/main' && - github.event.inputs.dist-tag != 'unstable' && needs.version.result == 'success' && needs.publish-nodejs.result == 'success' && needs.publish-dotnet.result == 'success' && diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml index 9330eeb39c..c383477507 100644 --- a/.github/workflows/runtime-backed-node-release.yml +++ b/.github/workflows/runtime-backed-node-release.yml @@ -318,6 +318,9 @@ jobs: (inputs.resume_run_id != '' || needs.package.result == 'success') needs: [boundary, package] runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.channel == 'unstable' && 'unstable' || inputs.sdk_ref }} + cancel-in-progress: false environment: cicd permissions: actions: read diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml new file mode 100644 index 0000000000..64eea0cee5 --- /dev/null +++ b/.github/workflows/runtime-sdk.yml @@ -0,0 +1,479 @@ +name: Runtime-driven Node SDK +run-name: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} + +on: + workflow_dispatch: + inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - canary + - unstable + runtime_version: + description: "Exact runtime package version" + required: true + type: string + runtime_sha: + description: "Full github/copilot-agent-runtime source SHA" + required: true + type: string + runtime_source: + description: "Runtime package registry" + required: true + type: choice + options: + - azure + - github-packages + runtime_run_id: + description: "Source runtime workflow run ID and idempotency key" + required: true + type: string + mode: + description: "tests-only for canary verification; internal for publication" + required: true + type: choice + options: + - tests-only + - internal + default: internal + version: + description: "Unstable SDK version override for a direct manual run" + required: false + type: string + resume_run_id: + description: "Exceptional recovery from the canonical SDK workflow run" + required: false + type: string + +permissions: + contents: read + +jobs: + claim-runtime-dispatch: + name: Claim runtime dispatch + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + cancel-in-progress: false + permissions: + actions: read + contents: read + outputs: + canonical_run_id: ${{ steps.existing.outputs.canonical_run_id || steps.created.outputs.canonical_run_id }} + role: ${{ steps.existing.outputs.role || steps.created.outputs.role }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Validate entry boundary + env: + CHANNEL: ${{ inputs.channel }} + MODE: ${{ inputs.mode }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in + canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; + *) echo "::error::Invalid runtime-driven release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; + esac + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + if [ "$CHANNEL" = "canary" ] && { [ -n "$VERSION" ] || [ -n "$RESUME_RUN_ID" ]; }; then + echo "::error::Canary runs do not accept version or resume_run_id." + exit 1 + fi + if [ -n "$RESUME_RUN_ID" ]; then + [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::resume_run_id must be numeric."; exit 1; } + fi + - name: Find the canonical dispatch marker + id: lookup + env: + GH_TOKEN: ${{ github.token }} + MARKER_NAME: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + RUN_TITLE: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} + run: | + set -euo pipefail + for ATTEMPT in 1 2 3 4 5 6; do + gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$MARKER_NAME&per_page=100" \ + > "$RUNNER_TEMP/artifacts.json" + MATCHES="$(jq --arg name "$MARKER_NAME" \ + '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ + "$RUNNER_TEMP/artifacts.json")" + if [ "$MATCHES" -gt 1 ]; then + echo "::error::More than one unexpired $MARKER_NAME artifact exists." + exit 1 + fi + if [ "$MATCHES" -eq 1 ]; then + jq --arg name "$MARKER_NAME" \ + '.artifacts[] | select(.name == $name and .expired == false)' \ + "$RUNNER_TEMP/artifacts.json" > "$RUNNER_TEMP/artifact.json" + { + echo "found=true" + echo "artifact_id=$(jq -r .id "$RUNNER_TEMP/artifact.json")" + echo "artifact_run_id=$(jq -r .workflow_run.id "$RUNNER_TEMP/artifact.json")" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + gh api "/repos/$GITHUB_REPOSITORY/actions/workflows/runtime-sdk.yml/runs?event=workflow_dispatch&per_page=100" \ + > "$RUNNER_TEMP/runs.json" + EARLIER="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ + '[.workflow_runs[] | select(.display_title == $title and .id < $current)] | length' \ + "$RUNNER_TEMP/runs.json")" + if [ "$EARLIER" -eq 0 ] && [ "$GITHUB_RUN_ATTEMPT" -eq 1 ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$ATTEMPT" -lt 6 ]; then + echo "An earlier matching run is visible; waiting for its marker (attempt $ATTEMPT/6)." + sleep 10 + fi + done + + ACTIVE="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ + '[.workflow_runs[] | select( + .display_title == $title and + .id < $current and + .status != "completed" + )] | length' "$RUNNER_TEMP/runs.json")" + if [ "$ACTIVE" -gt 0 ]; then + echo "::error::An earlier matching run is still initializing without a visible marker. Retry this run later." + exit 1 + fi + if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then + echo "::error::This rerun's canonical marker is not visible. Retry after the artifact index is consistent." + exit 1 + fi + echo "Earlier matching runs completed before claiming; none could have started release work." + echo "found=false" >> "$GITHUB_OUTPUT" + - name: Download the existing marker + if: steps.lookup.outputs.found == 'true' + env: + ARTIFACT_ID: ${{ steps.lookup.outputs.artifact_id }} + ARTIFACT_RUN_ID: ${{ steps.lookup.outputs.artifact_run_id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/marker" + gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" > "$RUNNER_TEMP/marker.zip" + unzip -q "$RUNNER_TEMP/marker.zip" -d "$RUNNER_TEMP/marker" + gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$ARTIFACT_RUN_ID" > "$RUNNER_TEMP/run.json" + - name: Validate the existing marker and API provenance + if: steps.lookup.outputs.found == 'true' + id: existing + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_RUN_ID: ${{ github.run_id }} + MODE: ${{ inputs.mode }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts validate \ + "$RUNNER_TEMP/marker/marker.json" "$RUNNER_TEMP/artifact.json" "$RUNNER_TEMP/run.json" + - name: Mirror the canonical run + if: steps.existing.outputs.role == 'duplicate' + env: + CANONICAL_RUN_ID: ${{ steps.existing.outputs.canonical_run_id }} + GH_TOKEN: ${{ github.token }} + run: | + set +e + gh run watch "$CANONICAL_RUN_ID" --exit-status + RESULT=$? + set -e + if [ "$RESULT" -ne 0 ]; then + echo "::error::Canonical SDK run $CANONICAL_RUN_ID failed or was canceled. Re-run that original run; this duplicate will not mint another SDK version." + exit "$RESULT" + fi + echo "Canonical SDK run $CANONICAL_RUN_ID succeeded; this duplicate is complete." + - name: Create the canonical marker + if: steps.lookup.outputs.found == 'false' + id: created + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_RUN_ID: ${{ github.run_id }} + MODE: ${{ inputs.mode }} + RESUME_RUN_ID: ${{ inputs.resume_run_id }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + if [ -n "$RESUME_RUN_ID" ]; then + echo "::error::resume_run_id requires the canonical dispatch marker." + exit 1 + fi + mkdir -p "$RUNNER_TEMP/new-marker" + node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts create \ + "$RUNNER_TEMP/new-marker/marker.json" + { + echo "role=owner" + echo "canonical_run_id=$GITHUB_RUN_ID" + } >> "$GITHUB_OUTPUT" + - name: Persist the canonical marker + if: steps.lookup.outputs.found == 'false' + uses: actions/upload-artifact@v7.0.0 + with: + name: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + path: ${{ runner.temp }}/new-marker/marker.json + retention-days: 90 + + plan: + name: Freeze runtime-backed release identity + if: needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery' + needs: claim-runtime-dispatch + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + id-token: write + outputs: + artifact_name: ${{ steps.recover.outputs.artifact_name || steps.plan.outputs.artifact_name }} + resume_run_id: ${{ steps.recover.outputs.resume_run_id }} + sdk_version: ${{ steps.recover.outputs.sdk_version || steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.recover.outputs.workflow_created_at || steps.plan.outputs.workflow_created_at }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download the canonical retained release + if: needs.claim-runtime-dispatch.outputs.role == 'recovery' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./recovery + pattern: nodejs-unstable-* + repository: ${{ github.repository }} + run-id: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }} + - name: Validate exceptional recovery identity + if: needs.claim-runtime-dispatch.outputs.role == 'recovery' + id: recover + env: + CANONICAL_RUN_ID: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + set -euo pipefail + MANIFEST="./recovery/release-manifest.json" + [ -f "$MANIFEST" ] || + { echo "::error::Canonical run does not contain one retained unstable release artifact."; exit 1; } + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify "$MANIFEST" ./recovery + jq -e \ + --arg run "$CANONICAL_RUN_ID" \ + --arg runtimeRun "$RUNTIME_RUN_ID" \ + --arg runtimeSha "$RUNTIME_SHA" \ + --arg runtimeSource "$RUNTIME_SOURCE" \ + --arg runtimeVersion "$RUNTIME_VERSION" \ + --arg sdkRef "$SDK_REF" \ + --arg sdkSha "$SDK_SHA" \ + '.channel == "unstable" and + .workflow.runId == $run and + .runtime.runId == $runtimeRun and + .runtime.sha == $runtimeSha and + .runtime.source == $runtimeSource and + .runtime.version == $runtimeVersion and + .sdk.ref == $sdkRef and + .sdk.sha == $sdkSha' "$MANIFEST" >/dev/null || + { echo "::error::Canonical release manifest does not match the claimed runtime dispatch."; exit 1; } + { + echo "artifact_name=nodejs-unstable-$(jq -r .sdk.version "$MANIFEST")" + echo "resume_run_id=$CANONICAL_RUN_ID" + echo "sdk_version=$(jq -r .sdk.version "$MANIFEST")" + echo "workflow_created_at=$(jq -r .workflow.createdAt "$MANIFEST")" + } >> "$GITHUB_OUTPUT" + - name: Calculate the release identity + if: needs.claim-runtime-dispatch.outputs.role == 'owner' + id: plan + working-directory: ./nodejs + env: + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + SDK_VERSION="" + ARTIFACT_NAME="" + if [ "$CHANNEL" = "unstable" ]; then + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" + ARTIFACT_NAME="nodejs-unstable-$SDK_VERSION" + fi + { + echo "artifact_name=$ARTIFACT_NAME" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + - name: Reject an explicit version already present publicly + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + working-directory: ./nodejs + env: + SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} + run: | + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" https://registry.npmjs.org + done + - name: Azure login for explicit-version preflight + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Reject an explicit version already present internally + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + working-directory: ./nodejs + env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} + 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" + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" + done + + runtime-backed-release: + name: Run runtime-backed SDK pipeline + if: needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery' + needs: [claim-runtime-dispatch, plan] + uses: ./.github/workflows/runtime-backed-node-release.yml + permissions: + actions: read + contents: read + id-token: write + packages: read + with: + artifact_name: ${{ needs.plan.outputs.artifact_name }} + channel: ${{ inputs.channel }} + mode: ${{ inputs.mode }} + resume_run_id: ${{ needs.plan.outputs.resume_run_id }} + runtime_run_id: ${{ inputs.runtime_run_id }} + runtime_sha: ${{ inputs.runtime_sha }} + runtime_source: ${{ inputs.runtime_source }} + runtime_version: ${{ inputs.runtime_version }} + sdk_ref: ${{ github.ref }} + sdk_sha: ${{ github.sha }} + sdk_version: ${{ needs.plan.outputs.sdk_version }} + secrets: inherit + + publish-public: + name: Publish unstable SDK publicly + if: inputs.channel == 'unstable' && (needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery') + needs: [claim-runtime-dispatch, plan, runtime-backed-release] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-public-unstable + cancel-in-progress: false + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Update npm for trusted publishing + run: npm install --global npm@11.6.3 + - name: Download current retained release + if: needs.plan.outputs.resume_run_id == '' + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.runtime-backed-release.outputs.artifact_name }} + path: ./dist + - name: Download canonical retained release + if: needs.plan.outputs.resume_run_id != '' + uses: actions/download-artifact@v8.0.0 + with: + github-token: ${{ github.token }} + merge-multiple: true + path: ./dist + pattern: ${{ needs.runtime-backed-release.outputs.artifact_name }} + repository: ${{ github.repository }} + run-id: ${{ needs.plan.outputs.resume_run_id }} + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ + dist/release-manifest.json dist + - name: Publish the same tarballs to public npm + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist unstable https://registry.npmjs.org public diff --git a/.github/workflows/sdk-canary.yml b/.github/workflows/sdk-canary.yml deleted file mode 100644 index f2ea2312ae..0000000000 --- a/.github/workflows/sdk-canary.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: "SDK Canary Test/Publish" - -on: - workflow_dispatch: - inputs: - channel: - description: "Release channel" - required: true - type: choice - options: - - canary - default: canary - runtime_version: - description: "Exact runtime package version" - required: true - type: string - runtime_sha: - description: "Full github/copilot-agent-runtime source SHA" - required: true - type: string - runtime_source: - description: "Runtime package registry" - required: true - type: choice - options: - - azure - default: azure - runtime_run_id: - description: "Source runtime workflow run ID" - required: true - type: string - mode: - description: "Run tests and package verification, with optional internal publication" - required: true - type: choice - options: - - tests-only - - internal - default: tests-only - repository_dispatch: - types: [runtime-canary] - -permissions: - contents: read - id-token: write - -concurrency: - group: sdk-canary-${{ github.ref }} - cancel-in-progress: false - -jobs: - resolve: - name: Resolve canary inputs - if: github.event.repository.fork == false - runs-on: ubuntu-latest - permissions: {} - outputs: - mode: ${{ steps.normalize.outputs.mode }} - runtime_run_id: ${{ steps.normalize.outputs.runtime_run_id }} - runtime_sha: ${{ steps.normalize.outputs.runtime_sha }} - runtime_source: ${{ steps.normalize.outputs.runtime_source }} - runtime_version: ${{ steps.normalize.outputs.runtime_version }} - steps: - - name: Normalize and validate inputs - id: normalize - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_CHANNEL: ${{ inputs.channel }} - INPUT_MODE: ${{ inputs.mode }} - INPUT_RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - INPUT_RUNTIME_SHA: ${{ inputs.runtime_sha }} - INPUT_RUNTIME_SOURCE: ${{ inputs.runtime_source }} - INPUT_RUNTIME_VERSION: ${{ inputs.runtime_version }} - PAYLOAD_CHANNEL: ${{ github.event.client_payload.channel }} - PAYLOAD_MODE: ${{ github.event.client_payload.mode }} - PAYLOAD_RUNTIME_RUN_ID: ${{ github.event.client_payload.runtime_run_id }} - PAYLOAD_RUNTIME_SHA: ${{ github.event.client_payload.runtime_sha }} - PAYLOAD_RUNTIME_SOURCE: ${{ github.event.client_payload.runtime_source }} - PAYLOAD_RUNTIME_VERSION: ${{ github.event.client_payload.runtime_version }} - run: | - set -euo pipefail - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - CHANNEL="$INPUT_CHANNEL" - MODE="$INPUT_MODE" - RUNTIME_RUN_ID="$INPUT_RUNTIME_RUN_ID" - RUNTIME_SHA="$INPUT_RUNTIME_SHA" - RUNTIME_SOURCE="$INPUT_RUNTIME_SOURCE" - RUNTIME_VERSION="$INPUT_RUNTIME_VERSION" - else - CHANNEL="${PAYLOAD_CHANNEL:-canary}" - MODE="${PAYLOAD_MODE:-internal}" - RUNTIME_RUN_ID="$PAYLOAD_RUNTIME_RUN_ID" - RUNTIME_SHA="$PAYLOAD_RUNTIME_SHA" - RUNTIME_SOURCE="${PAYLOAD_RUNTIME_SOURCE:-azure}" - RUNTIME_VERSION="$PAYLOAD_RUNTIME_VERSION" - case "$MODE" in - publish|publish-force) MODE="internal" ;; - esac - case "$RUNTIME_SOURCE" in - internal) RUNTIME_SOURCE="azure" ;; - esac - fi - [ "$CHANNEL" = "canary" ] || { echo "::error::sdk-canary.yml only accepts channel=canary."; exit 1; } - [ "$RUNTIME_SOURCE" = "azure" ] || { echo "::error::Canary runtime_source must be azure."; exit 1; } - case "$MODE" in - tests-only|internal) ;; - *) echo "::error::Canary mode must be tests-only or internal."; exit 1 ;; - esac - [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || - { echo "::error::runtime_version must be exact SemVer."; exit 1; } - [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || - { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } - [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::runtime_run_id must be numeric."; exit 1; } - { - echo "mode=$MODE" - echo "runtime_run_id=$RUNTIME_RUN_ID" - echo "runtime_sha=$RUNTIME_SHA" - echo "runtime_source=$RUNTIME_SOURCE" - echo "runtime_version=$RUNTIME_VERSION" - } >> "$GITHUB_OUTPUT" - - runtime-backed-release: - name: Run canary SDK pipeline - needs: resolve - uses: ./.github/workflows/runtime-backed-node-release.yml - permissions: - actions: read - contents: read - id-token: write - with: - channel: canary - mode: ${{ needs.resolve.outputs.mode }} - runtime_run_id: ${{ needs.resolve.outputs.runtime_run_id }} - runtime_sha: ${{ needs.resolve.outputs.runtime_sha }} - runtime_source: ${{ needs.resolve.outputs.runtime_source }} - runtime_version: ${{ needs.resolve.outputs.runtime_version }} - sdk_ref: ${{ github.ref }} - sdk_sha: ${{ github.sha }} - secrets: inherit diff --git a/docs/developer-docs/secrets.md b/docs/developer-docs/secrets.md index 762f75d9b7..8f6904b609 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -10,7 +10,7 @@ This document covers secrets management for the github/copilot-sdk repository. I These secrets are used by the per-language SDK test workflows and the canary workflow. * **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments. - * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `sdk-canary.yml` + * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `runtime-sdk.yml` ## Agentic workflow secrets @@ -61,7 +61,7 @@ These secrets are used by the Java SDK Maven Central publishing workflow (`java- ## Secrets not managed in this repository * **`GITHUB_TOKEN`**: Automatically provided by GitHub Actions. No manual management required. - The unstable Node SDK workflow grants it `packages: read` only while acquiring + The runtime-driven Node SDK workflow grants it `packages: read` only while acquiring signed runtime packages from GitHub Packages. ## Further reading diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 4013fef2dd..d39832ecea 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -11,32 +11,25 @@ The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each handoff includes the exact runtime version, full source SHA, and source workflow run ID. -`sdk-canary.yml` and `publish.yml` remain separate entry points and trust -boundaries. Both invoke `runtime-backed-node-release.yml`, which owns runtime -acquisition, cross-platform tests, packaging, manifest retention, recovery, and -optional internal publication. Only `publish.yml` contains public npm -publication. +The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This +runtime-driven Node entry is separate from `publish.yml`, which remains the +manual stable and prerelease entry for all SDK languages. `runtime-sdk.yml` +invokes `runtime-backed-node-release.yml` for runtime acquisition, +cross-platform tests, packaging, manifest retention, recovery, and optional +internal publication. It alone contains public unstable npm publication. -Canary dispatches `.github/workflows/sdk-canary.yml` with these inputs: +The runtime dispatch includes these inputs: -* `channel`: `canary` -* `runtime_version`: Exact Azure runtime package version +* `channel`: `canary` or `unstable` +* `runtime_version`: Exact runtime package version * `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -* `runtime_source`: `azure` -* `runtime_run_id`: Source runtime workflow run ID -* `mode`: `tests-only` or `internal` +* `runtime_source`: `azure` for canary or `github-packages` for unstable +* `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key +* `mode`: `tests-only` or `internal` for canary; `internal` for unstable -Unstable dispatches `.github/workflows/publish.yml` with these inputs: - -* `dist-tag`: `unstable` -* `runtime_version`: Exact signed GitHub Packages runtime version -* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -* `runtime_source`: `github-packages` -* `runtime_run_id`: Source runtime workflow run ID - -Maintainers can dispatch `publish.yml` directly with the same unstable inputs. -The optional `version` input must be an unstable SemVer. Do not reuse an -explicit version after an artifact has been built. +Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The +optional `version` input is available only for unstable and must be an unstable +SemVer. Do not reuse an explicit version after an artifact has been built. ## Release gates @@ -69,7 +62,7 @@ No canary job has a public npm publication path. Every unstable run publishes the retained platform tarballs and umbrella tarball to Azure first. A clean internal install must start the exact selected runtime before public publication begins. The public job uses npm trusted -publishing from `publish.yml` and publishes the same tarballs under the +publishing from `runtime-sdk.yml` and publishes the same tarballs under the `unstable` dist-tag, with the umbrella package last. Before either publication, the workflow checks all nine package coordinates. @@ -88,11 +81,19 @@ Use **Re-run failed jobs** on the original workflow run for normal recovery. The run number, frozen version, and retained artifact remain unchanged. Do not rerun a successful packaging job merely to recover a publication job. -Use `resume_run_id` only when the original run cannot be resumed. Start a new -manual `publish.yml` run with `dist-tag=unstable` and the original SDK workflow -run ID. The recovery path downloads the original retained artifact, verifies -its manifest and all nine SHA-512 integrity values, and uses the recorded SDK -and runtime identities. It never rebuilds or substitutes packages. +Each `runtime_run_id` is serialized and claimed by a 90-day marker artifact. +The marker records the canonical SDK run and complete runtime/input +provenance, but the runtime run ID is not part of the immutable release +identity. Exact duplicate dispatches wait for and mirror the canonical run. +If that run fails or is canceled, rerun the original run rather than +dispatching another release. + +Use `resume_run_id` only when the canonical run cannot be rerun. Start a new +manual `runtime-sdk.yml` unstable run with the same dispatch tuple and the +canonical SDK workflow run ID. The workflow validates the marker and GitHub API +provenance, downloads the canonical retained artifact, verifies its manifest +and all nine SHA-512 integrity values, and uses the recorded identities. It +never rebuilds or substitutes packages. ## Registry setup @@ -106,6 +107,8 @@ that this repository can read all eight with its workflow token. Public visibility does not remove GitHub Packages npm authentication. Confirm npm trusted publisher configuration authorizes -`.github/workflows/publish.yml` for `@github/copilot-sdk` and all eight -`@github/copilot-sdk-` package names. Do not add a separate protected -SDK publication environment. +both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for +`@github/copilot-sdk` and all eight `@github/copilot-sdk-` package +names. The first identity publishes stable and prerelease versions; the second +publishes unstable versions. Do not add an npm token, workflow indirection, or +a separate protected SDK publication environment. diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts new file mode 100644 index 0000000000..b739354eca --- /dev/null +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface RuntimeDispatchMarker { + canonicalRunId: string; + channel: "canary" | "unstable"; + createdAt: string; + mode: "internal" | "tests-only"; + runtime: { + repository: "github/copilot-agent-runtime"; + runId: string; + sha: string; + source: "azure" | "github-packages"; + version: string; + }; + schemaVersion: 1; + sdk: { + ref: string; + repository: "github/copilot-sdk"; + versionOverride: string; + sha: string; + }; + workflow: ".github/workflows/runtime-sdk.yml"; +} + +interface ArtifactApiResponse { + expired: boolean; + workflow_run?: { id?: number }; +} + +interface WorkflowRunApiResponse { + event: string; + head_branch: string; + head_sha: string; + id: number; + name: string; + path: string; + repository: { full_name: string }; +} + +export interface ExpectedDispatch { + channel: RuntimeDispatchMarker["channel"]; + currentRunId: string; + mode: RuntimeDispatchMarker["mode"]; + resumeRunId: string; + runtimeRunId: string; + runtimeSha: string; + runtimeSource: RuntimeDispatchMarker["runtime"]["source"]; + runtimeVersion: string; + sdkRef: string; + sdkSha: string; + versionOverride: string; +} + +export type DispatchRole = "duplicate" | "owner" | "recovery"; + +const workflowPath = ".github/workflows/runtime-sdk.yml"; +const workflowName = "Runtime-driven Node SDK"; + +function validateInputs(expected: ExpectedDispatch): void { + assert.match(expected.currentRunId, /^[0-9]+$/, "Current workflow run ID must be numeric"); + assert.match(expected.runtimeRunId, /^[0-9]+$/, "Runtime workflow run ID must be numeric"); + assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + assert.match(expected.sdkSha, /^[0-9a-f]{40}$/, "SDK SHA must be lowercase full SHA"); + assert(expected.sdkRef.length > 0, "SDK ref is required"); + assert( + expected.channel === "canary" + ? expected.runtimeSource === "azure" && + (expected.mode === "tests-only" || expected.mode === "internal") && + expected.resumeRunId === "" + : expected.runtimeSource === "github-packages" && expected.mode === "internal", + "Invalid channel, runtime source, mode, or recovery combination" + ); + if (expected.resumeRunId) { + assert.match(expected.resumeRunId, /^[0-9]+$/, "Resume workflow run ID must be numeric"); + } +} + +export function createRuntimeDispatchMarker(expected: ExpectedDispatch): RuntimeDispatchMarker { + validateInputs(expected); + return { + schemaVersion: 1, + canonicalRunId: expected.currentRunId, + channel: expected.channel, + mode: expected.mode, + runtime: { + repository: "github/copilot-agent-runtime", + runId: expected.runtimeRunId, + sha: expected.runtimeSha, + source: expected.runtimeSource, + version: expected.runtimeVersion, + }, + sdk: { + repository: "github/copilot-sdk", + ref: expected.sdkRef, + sha: expected.sdkSha, + versionOverride: expected.versionOverride, + }, + workflow: workflowPath, + createdAt: new Date().toISOString(), + }; +} + +export function validateRuntimeDispatchMarker( + marker: RuntimeDispatchMarker, + artifact: ArtifactApiResponse, + workflowRun: WorkflowRunApiResponse, + expected: ExpectedDispatch +): DispatchRole { + validateInputs(expected); + assert.equal(marker.schemaVersion, 1, "Unsupported dispatch marker schema"); + assert.match(marker.canonicalRunId, /^[0-9]+$/, "Canonical workflow run ID must be numeric"); + assert.equal(artifact.expired, false, "Dispatch marker artifact is expired"); + assert.equal( + String(artifact.workflow_run?.id), + marker.canonicalRunId, + "Artifact workflow run ID does not match its marker" + ); + assert.equal(String(workflowRun.id), marker.canonicalRunId, "Workflow run provenance mismatch"); + assert.equal(workflowRun.repository.full_name, "github/copilot-sdk"); + assert.equal(workflowRun.path, workflowPath); + assert.equal(workflowRun.name, workflowName); + assert.equal(workflowRun.event, "workflow_dispatch"); + assert.equal(workflowRun.head_sha, marker.sdk.sha); + assert.equal(workflowRun.head_branch, marker.sdk.ref.replace(/^refs\/(heads|tags)\//, "")); + assert.deepEqual( + { + channel: marker.channel, + mode: marker.mode, + runtime: marker.runtime, + sdk: marker.sdk, + workflow: marker.workflow, + }, + { + channel: expected.channel, + mode: expected.mode, + runtime: { + repository: "github/copilot-agent-runtime", + runId: expected.runtimeRunId, + sha: expected.runtimeSha, + source: expected.runtimeSource, + version: expected.runtimeVersion, + }, + sdk: { + repository: "github/copilot-sdk", + ref: expected.sdkRef, + sha: expected.sdkSha, + versionOverride: expected.versionOverride, + }, + workflow: workflowPath, + }, + "runtime_run_id is already claimed by a different release tuple" + ); + + if (marker.canonicalRunId === expected.currentRunId) { + return "owner"; + } + if (expected.resumeRunId) { + assert.equal( + expected.resumeRunId, + marker.canonicalRunId, + "resume_run_id must identify the canonical workflow run" + ); + return "recovery"; + } + return "duplicate"; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +function expectedFromEnvironment(): ExpectedDispatch { + return { + channel: requiredEnvironment("CHANNEL") as ExpectedDispatch["channel"], + currentRunId: requiredEnvironment("CURRENT_RUN_ID"), + mode: requiredEnvironment("MODE") as ExpectedDispatch["mode"], + resumeRunId: process.env.RESUME_RUN_ID?.trim() ?? "", + runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + runtimeSource: requiredEnvironment("RUNTIME_SOURCE") as ExpectedDispatch["runtimeSource"], + runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), + sdkRef: requiredEnvironment("SDK_REF"), + sdkSha: requiredEnvironment("SDK_SHA"), + versionOverride: process.env.VERSION_OVERRIDE?.trim() ?? "", + }; +} + +function main(): void { + const [command, markerPath, artifactPath, runPath] = process.argv.slice(2); + const expected = expectedFromEnvironment(); + if (command === "create" && markerPath) { + writeFileSync( + markerPath, + `${JSON.stringify(createRuntimeDispatchMarker(expected), null, 2)}\n` + ); + return; + } + if (command === "validate" && markerPath && artifactPath && runPath) { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as RuntimeDispatchMarker; + const artifact = JSON.parse(readFileSync(artifactPath, "utf8")) as ArtifactApiResponse; + const run = JSON.parse(readFileSync(runPath, "utf8")) as WorkflowRunApiResponse; + const role = validateRuntimeDispatchMarker(marker, artifact, run, expected); + if (process.env.GITHUB_OUTPUT) { + writeFileSync( + process.env.GITHUB_OUTPUT, + `role=${role}\ncanonical_run_id=${marker.canonicalRunId}\n`, + { + flag: "a", + } + ); + } else { + console.log(role); + } + return; + } + throw new Error( + "Usage: runtime-dispatch-ledger.ts create | validate " + ); +} + +const scriptPath = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (scriptPath) { + try { + main(); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index df3f8fc0c3..d791ba66e5 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -5,33 +5,80 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = join(import.meta.dirname, "..", ".."); const workflow = (name: string) => readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); -const canary = workflow("sdk-canary.yml"); const publish = workflow("publish.yml"); +const runtimeSdk = workflow("runtime-sdk.yml"); const shared = workflow("runtime-backed-node-release.yml"); +const ledger = readFileSync( + join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts"), + "utf8" +); -describe("SDK canary workflow contract", () => { - it("accepts only the exact Azure canary handoff", () => { - for (const input of [ - "channel:", - "runtime_version:", - "runtime_sha:", - "runtime_source:", - "runtime_run_id:", - "mode:", +describe("normal publishing workflow contract", () => { + it("remains the stable and prerelease entry without runtime handoff inputs", () => { + expect(publish).toContain("- latest"); + expect(publish).toContain("- prerelease"); + expect(publish).not.toContain("- unstable"); + expect(publish).not.toContain("runtime_version:"); + expect(publish).not.toContain("runtime_run_id:"); + expect(publish).not.toContain("resume_run_id:"); + expect(publish).not.toContain("runtime-backed-node-release.yml"); + expect(publish).toContain("publish.yml only accepts latest or prerelease"); + }); + + it("retains all normal SDK publication paths", () => { + for (const job of [ + "publish-nodejs:", + "publish-dotnet:", + "publish-rust:", + "publish-python:", + "publish-java:", + "github-release:", ]) { - expect(canary).toContain(input); + expect(publish).toContain(job); } - expect(canary).toContain("- canary"); - expect(canary).toContain("- azure"); - expect(canary).toContain("- tests-only"); - expect(canary).toContain("- internal"); + }); +}); + +describe("runtime-driven Node SDK entry contract", () => { + it("owns both strict runtime handoff matrices", () => { + expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); + expect(runtimeSdk).toContain("canary:azure:tests-only"); + expect(runtimeSdk).toContain("canary:azure:internal"); + expect(runtimeSdk).toContain("unstable:github-packages:internal"); + expect(runtimeSdk).toContain("runtime_run_id:"); + expect(runtimeSdk).toContain("runtime_source:"); + }); + + it("serializes and durably claims each runtime run", () => { + expect(runtimeSdk).toContain("group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); + expect(runtimeSdk).toContain("cancel-in-progress: false"); + expect(runtimeSdk).toContain("sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); + expect(runtimeSdk).toContain("More than one unexpired"); + expect(runtimeSdk).toContain("for ATTEMPT in 1 2 3 4 5 6"); + expect(runtimeSdk).toContain("actions/workflows/runtime-sdk.yml/runs"); + expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts validate"); + expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); + expect(runtimeSdk).toContain("retention-days: 90"); + }); + + it("delegates preparation before its separately serialized public publication", () => { + expect(runtimeSdk).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); + expect(runtimeSdk).toContain("scripts/unstable-version.ts"); + expect(runtimeSdk).toContain("group: sdk-runtime-public-unstable"); + expect(runtimeSdk.indexOf("runtime-backed-release:")).toBeLessThan( + runtimeSdk.indexOf("publish-public:") + ); + expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); }); - it("delegates implementation without granting public capability", () => { - expect(canary).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); - expect(canary).toContain("channel: canary"); - expect(canary).not.toContain("registry.npmjs.org"); - expect(canary).not.toContain("unstable-publish-public"); + it("only recovers the canonical retained release", () => { + expect(ledger).toContain("resume_run_id must identify the canonical workflow run"); + expect(runtimeSdk).toContain( + "run-id: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }}" + ); + expect(runtimeSdk).toContain( + "Canonical release manifest does not match the claimed runtime dispatch" + ); }); }); @@ -48,40 +95,9 @@ describe("shared runtime-backed Node pipeline", () => { expect(shared).toContain("npm run acquire:runtime-packages"); expect(shared).toContain("npm run verify:release-packages"); expect(shared).toContain("publish-manifest"); + expect(shared).toContain("group: sdk-runtime-internal-"); expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( shared.indexOf("publish-manifest") ); }); }); - -describe("unstable publishing workflow contract", () => { - it("requires the authenticated GitHub Packages runtime handoff", () => { - expect(publish).toContain("runtime_source:"); - expect(publish).toContain("- github-packages"); - expect(shared).toContain("packages: read"); - expect(shared).toContain("//npm.pkg.github.com/:_authToken="); - expect(shared).not.toContain("@github:registry=https://npm.pkg.github.com"); - }); - - it("freezes identity, delegates internal preparation, then publishes publicly", () => { - expect(publish).toContain("scripts/unstable-version.ts"); - expect(publish).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); - expect(shared).toContain("release-manifest.json"); - expect(shared).toContain("COPILOT_CLI_USE_NPM_PACKAGE = false"); - expect(publish.indexOf("unstable-runtime-backed-release:")).toBeLessThan( - publish.indexOf("unstable-publish-public:") - ); - expect(publish).toContain("needs: [unstable-plan, unstable-runtime-backed-release]"); - }); - - it("supports retained-artifact recovery without enabling non-Node release paths", () => { - expect(publish).toContain("resume_run_id:"); - expect(publish).toContain("run-id: ${{ inputs.resume_run_id }}"); - expect(shared).toContain("run-id: ${{ inputs.resume_run_id }}"); - expect(publish).toContain("Manifest workflow run ID does not match resume_run_id"); - expect( - publish.match(/github\.event\.inputs\.dist-tag != 'unstable'/g)?.length - ).toBeGreaterThan(3); - expect(publish).toContain("github.event.inputs.dist-tag != 'unstable' &&"); - }); -}); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts new file mode 100644 index 0000000000..eda6efc697 --- /dev/null +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + createRuntimeDispatchMarker, + type ExpectedDispatch, + validateRuntimeDispatchMarker, +} from "../scripts/runtime-dispatch-ledger.js"; + +const expected: ExpectedDispatch = { + channel: "unstable", + currentRunId: "200", + mode: "internal", + resumeRunId: "", + runtimeRunId: "100", + runtimeSha: "a".repeat(40), + runtimeSource: "github-packages", + runtimeVersion: "1.2.3-unstable.4", + sdkRef: "refs/heads/main", + sdkSha: "b".repeat(40), + versionOverride: "", +}; + +function provenance(canonicalRunId: string) { + return { + artifact: { expired: false, workflow_run: { id: Number(canonicalRunId) } }, + run: { + event: "workflow_dispatch", + head_branch: "main", + head_sha: expected.sdkSha, + id: Number(canonicalRunId), + name: "Runtime-driven Node SDK", + path: ".github/workflows/runtime-sdk.yml", + repository: { full_name: "github/copilot-sdk" }, + }, + }; +} + +describe("runtime dispatch ledger", () => { + it("creates a canonical marker without adding the runtime run to release identity", () => { + const marker = createRuntimeDispatchMarker(expected); + expect(marker.canonicalRunId).toBe("200"); + expect(marker.runtime.runId).toBe("100"); + expect(marker).not.toHaveProperty("sdk.version"); + }); + + it("retains ownership for a rerun of the canonical workflow run", () => { + const marker = createRuntimeDispatchMarker(expected); + const api = provenance("200"); + expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( + "owner" + ); + }); + + it("recognizes an exact duplicate and an authorized recovery", () => { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( + "duplicate" + ); + expect( + validateRuntimeDispatchMarker(marker, api.artifact, api.run, { + ...expected, + resumeRunId: "199", + }) + ).toBe("recovery"); + }); + + it("rejects marker tuple collisions and forged API provenance", () => { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + expect(() => + validateRuntimeDispatchMarker(marker, api.artifact, api.run, { + ...expected, + runtimeSha: "c".repeat(40), + }) + ).toThrow(/already claimed/); + expect(() => + validateRuntimeDispatchMarker( + marker, + { ...api.artifact, workflow_run: { id: 198 } }, + api.run, + expected + ) + ).toThrow(/Artifact workflow run ID/); + expect(() => + validateRuntimeDispatchMarker( + marker, + api.artifact, + { ...api.run, path: ".github/workflows/publish.yml" }, + expected + ) + ).toThrow(); + }); + + it("requires recovery to name the canonical run", () => { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + expect(() => + validateRuntimeDispatchMarker(marker, api.artifact, api.run, { + ...expected, + resumeRunId: "198", + }) + ).toThrow(/canonical workflow run/); + }); +}); From 7e58a0dfe853e8653fd3eef68640828136856509 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:00:37 -0700 Subject: [PATCH 04/23] Harden runtime-driven SDK workflows Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/publish.yml | 13 +++ .../workflows/runtime-backed-node-release.yml | 51 +++------- .github/workflows/runtime-sdk.yml | 98 ++----------------- docs/developer-docs/unstable-releases.md | 32 +++--- nodejs/scripts/runtime-dispatch-ledger.ts | 20 +--- nodejs/test/release-workflows.test.ts | 26 ++--- nodejs/test/runtime-dispatch-ledger.test.ts | 20 +--- 7 files changed, 65 insertions(+), 195 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f0b035f3d4..5d45de75dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -76,6 +76,19 @@ jobs: echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" exit 1 fi + PRERELEASE_NAMESPACE="$(node -e ' + const semver = require("semver"); + const parsed = semver.parse(process.argv[1]); + if (!parsed) process.exit(2); + process.stdout.write(String(parsed.prerelease[0] ?? "")); + ' "$VERSION")" || + { echo "::error::Version '$VERSION' is not valid SemVer."; exit 1; } + case "$PRERELEASE_NAMESPACE" in + canary|unstable) + echo "::error::The '$PRERELEASE_NAMESPACE' prerelease namespace is reserved for runtime-driven SDK releases." + exit 1 + ;; + esac fi echo "Using manual version override: $VERSION" >> $GITHUB_STEP_SUMMARY else diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml index c383477507..22893b074f 100644 --- a/.github/workflows/runtime-backed-node-release.yml +++ b/.github/workflows/runtime-backed-node-release.yml @@ -13,10 +13,6 @@ on: mode: required: true type: string - resume_run_id: - required: false - type: string - default: "" runtime_run_id: required: true type: string @@ -83,7 +79,6 @@ jobs: CHANNEL: ${{ inputs.channel }} GH_TOKEN: ${{ github.token }} MODE: ${{ inputs.mode }} - RESUME_RUN_ID: ${{ inputs.resume_run_id }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} RUNTIME_SOURCE: ${{ inputs.runtime_source }} @@ -106,15 +101,7 @@ jobs: [[ "$SDK_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::sdk_sha must be a lowercase full SHA."; exit 1; } [ -n "$SDK_REF" ] || { echo "::error::sdk_ref is required."; exit 1; } - if [ -n "$RESUME_RUN_ID" ]; then - [ "$CHANNEL" = "unstable" ] || - { echo "::error::Only unstable releases support resume_run_id."; exit 1; } - [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::resume_run_id must be numeric."; exit 1; } - fi if [ "$CHANNEL" = "canary" ]; then - [ -z "$RESUME_RUN_ID" ] || - { echo "::error::Canary cannot resume another workflow run."; exit 1; } PUBLIC_LATEST="$(node scripts/get-version.js current)" BASE="${PUBLIC_LATEST%%-*}" IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" @@ -139,7 +126,6 @@ jobs: acquire-runtime: name: Acquire exact runtime packages - if: inputs.resume_run_id == '' needs: boundary runs-on: ubuntu-latest environment: cicd @@ -201,7 +187,6 @@ jobs: test: name: Runtime-backed Node tests (${{ matrix.os }}) - if: inputs.resume_run_id == '' needs: [boundary, acquire-runtime] permissions: contents: read @@ -237,7 +222,6 @@ jobs: run: | node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - "$runtime_path" --version | grep -F "$RUNTIME_VERSION" echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - run: npm run build - name: Warm up PowerShell @@ -250,7 +234,6 @@ jobs: package: name: Build and verify nine SDK packages - if: inputs.resume_run_id == '' needs: [boundary, acquire-runtime, test] runs-on: ubuntu-latest permissions: @@ -315,11 +298,11 @@ jobs: !cancelled() && inputs.mode == 'internal' && needs.boundary.result == 'success' && - (inputs.resume_run_id != '' || needs.package.result == 'success') + needs.package.result == 'success' needs: [boundary, package] runs-on: ubuntu-latest concurrency: - group: sdk-runtime-internal-${{ inputs.channel == 'unstable' && 'unstable' || inputs.sdk_ref }} + group: sdk-runtime-internal-${{ inputs.channel }} cancel-in-progress: false environment: cicd permissions: @@ -333,28 +316,15 @@ jobs: node-version: 22 - run: npm ci --ignore-scripts working-directory: ./nodejs - - name: Download current retained release - if: inputs.resume_run_id == '' + - name: Download retained release uses: actions/download-artifact@v8.0.0 with: name: ${{ needs.boundary.outputs.artifact_name }} path: ./dist - - name: Download original retained release - if: inputs.resume_run_id != '' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./dist - pattern: ${{ needs.boundary.outputs.artifact_name }} - repository: ${{ github.repository }} - run-id: ${{ inputs.resume_run_id }} - name: Validate retained release - env: - EXPECTED_RUN_ID: ${{ inputs.resume_run_id || github.run_id }} run: | node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "$EXPECTED_RUN_ID" ] || + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || { echo "::error::Retained release belongs to a different workflow run."; exit 1; } [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || { echo "::error::Retained release channel does not match the requested channel."; exit 1; } @@ -377,9 +347,8 @@ jobs: run: | node nodejs/scripts/npm-release.js publish-manifest \ dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure - - name: Clean install and runtime version check + - name: Clean install and package version check env: - RUNTIME_VERSION: ${{ inputs.runtime_version }} SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} run: | VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" @@ -388,5 +357,11 @@ jobs: npm init -y >/dev/null printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" - RUNTIME="./node_modules/@github/copilot-sdk-linux-x64/prebuilds/linux-x64/copilot-runtime" - "$RUNTIME" --version | grep -F "$RUNTIME_VERSION" + node -e ' + const expected = process.argv[1]; + const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); + const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); + if (umbrella.version !== expected || platform.version !== expected) { + throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); + } + ' "$SDK_VERSION" diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 64eea0cee5..cb4921a4ec 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -42,10 +42,6 @@ on: description: "Unstable SDK version override for a direct manual run" required: false type: string - resume_run_id: - description: "Exceptional recovery from the canonical SDK workflow run" - required: false - type: string permissions: contents: read @@ -79,7 +75,6 @@ jobs: env: CHANNEL: ${{ inputs.channel }} MODE: ${{ inputs.mode }} - RESUME_RUN_ID: ${{ inputs.resume_run_id }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} RUNTIME_SOURCE: ${{ inputs.runtime_source }} @@ -97,14 +92,10 @@ jobs: { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || { echo "::error::runtime_run_id must be numeric."; exit 1; } - if [ "$CHANNEL" = "canary" ] && { [ -n "$VERSION" ] || [ -n "$RESUME_RUN_ID" ]; }; then - echo "::error::Canary runs do not accept version or resume_run_id." + if [ "$CHANNEL" = "canary" ] && [ -n "$VERSION" ]; then + echo "::error::Canary runs do not accept a version override." exit 1 fi - if [ -n "$RESUME_RUN_ID" ]; then - [[ "$RESUME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::resume_run_id must be numeric."; exit 1; } - fi - name: Find the canonical dispatch marker id: lookup env: @@ -185,7 +176,6 @@ jobs: CHANNEL: ${{ inputs.channel }} CURRENT_RUN_ID: ${{ github.run_id }} MODE: ${{ inputs.mode }} - RESUME_RUN_ID: ${{ inputs.resume_run_id }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} RUNTIME_SOURCE: ${{ inputs.runtime_source }} @@ -218,7 +208,6 @@ jobs: CHANNEL: ${{ inputs.channel }} CURRENT_RUN_ID: ${{ github.run_id }} MODE: ${{ inputs.mode }} - RESUME_RUN_ID: ${{ inputs.resume_run_id }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} RUNTIME_SOURCE: ${{ inputs.runtime_source }} @@ -227,10 +216,6 @@ jobs: SDK_SHA: ${{ github.sha }} VERSION_OVERRIDE: ${{ inputs.version }} run: | - if [ -n "$RESUME_RUN_ID" ]; then - echo "::error::resume_run_id requires the canonical dispatch marker." - exit 1 - fi mkdir -p "$RUNNER_TEMP/new-marker" node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts create \ "$RUNNER_TEMP/new-marker/marker.json" @@ -248,7 +233,7 @@ jobs: plan: name: Freeze runtime-backed release identity - if: needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery' + if: needs.claim-runtime-dispatch.outputs.role == 'owner' needs: claim-runtime-dispatch runs-on: ubuntu-latest environment: cicd @@ -257,10 +242,9 @@ jobs: contents: read id-token: write outputs: - artifact_name: ${{ steps.recover.outputs.artifact_name || steps.plan.outputs.artifact_name }} - resume_run_id: ${{ steps.recover.outputs.resume_run_id }} - sdk_version: ${{ steps.recover.outputs.sdk_version || steps.plan.outputs.sdk_version }} - workflow_created_at: ${{ steps.recover.outputs.workflow_created_at || steps.plan.outputs.workflow_created_at }} + artifact_name: ${{ steps.plan.outputs.artifact_name }} + sdk_version: ${{ steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} defaults: run: shell: bash @@ -275,59 +259,7 @@ jobs: node-version: 22 - run: npm ci --ignore-scripts working-directory: ./nodejs - - name: Download the canonical retained release - if: needs.claim-runtime-dispatch.outputs.role == 'recovery' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./recovery - pattern: nodejs-unstable-* - repository: ${{ github.repository }} - run-id: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }} - - name: Validate exceptional recovery identity - if: needs.claim-runtime-dispatch.outputs.role == 'recovery' - id: recover - env: - CANONICAL_RUN_ID: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - VERSION_OVERRIDE: ${{ inputs.version }} - run: | - set -euo pipefail - MANIFEST="./recovery/release-manifest.json" - [ -f "$MANIFEST" ] || - { echo "::error::Canonical run does not contain one retained unstable release artifact."; exit 1; } - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify "$MANIFEST" ./recovery - jq -e \ - --arg run "$CANONICAL_RUN_ID" \ - --arg runtimeRun "$RUNTIME_RUN_ID" \ - --arg runtimeSha "$RUNTIME_SHA" \ - --arg runtimeSource "$RUNTIME_SOURCE" \ - --arg runtimeVersion "$RUNTIME_VERSION" \ - --arg sdkRef "$SDK_REF" \ - --arg sdkSha "$SDK_SHA" \ - '.channel == "unstable" and - .workflow.runId == $run and - .runtime.runId == $runtimeRun and - .runtime.sha == $runtimeSha and - .runtime.source == $runtimeSource and - .runtime.version == $runtimeVersion and - .sdk.ref == $sdkRef and - .sdk.sha == $sdkSha' "$MANIFEST" >/dev/null || - { echo "::error::Canonical release manifest does not match the claimed runtime dispatch."; exit 1; } - { - echo "artifact_name=nodejs-unstable-$(jq -r .sdk.version "$MANIFEST")" - echo "resume_run_id=$CANONICAL_RUN_ID" - echo "sdk_version=$(jq -r .sdk.version "$MANIFEST")" - echo "workflow_created_at=$(jq -r .workflow.createdAt "$MANIFEST")" - } >> "$GITHUB_OUTPUT" - name: Calculate the release identity - if: needs.claim-runtime-dispatch.outputs.role == 'owner' id: plan working-directory: ./nodejs env: @@ -410,7 +342,7 @@ jobs: runtime-backed-release: name: Run runtime-backed SDK pipeline - if: needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery' + if: needs.claim-runtime-dispatch.outputs.role == 'owner' needs: [claim-runtime-dispatch, plan] uses: ./.github/workflows/runtime-backed-node-release.yml permissions: @@ -422,7 +354,6 @@ jobs: artifact_name: ${{ needs.plan.outputs.artifact_name }} channel: ${{ inputs.channel }} mode: ${{ inputs.mode }} - resume_run_id: ${{ needs.plan.outputs.resume_run_id }} runtime_run_id: ${{ inputs.runtime_run_id }} runtime_sha: ${{ inputs.runtime_sha }} runtime_source: ${{ inputs.runtime_source }} @@ -434,7 +365,7 @@ jobs: publish-public: name: Publish unstable SDK publicly - if: inputs.channel == 'unstable' && (needs.claim-runtime-dispatch.outputs.role == 'owner' || needs.claim-runtime-dispatch.outputs.role == 'recovery') + if: inputs.channel == 'unstable' && needs.claim-runtime-dispatch.outputs.role == 'owner' needs: [claim-runtime-dispatch, plan, runtime-backed-release] runs-on: ubuntu-latest concurrency: @@ -453,22 +384,11 @@ jobs: working-directory: ./nodejs - name: Update npm for trusted publishing run: npm install --global npm@11.6.3 - - name: Download current retained release - if: needs.plan.outputs.resume_run_id == '' + - name: Download retained release uses: actions/download-artifact@v8.0.0 with: name: ${{ needs.runtime-backed-release.outputs.artifact_name }} path: ./dist - - name: Download canonical retained release - if: needs.plan.outputs.resume_run_id != '' - uses: actions/download-artifact@v8.0.0 - with: - github-token: ${{ github.token }} - merge-multiple: true - path: ./dist - pattern: ${{ needs.runtime-backed-release.outputs.artifact_name }} - repository: ${{ github.repository }} - run-id: ${{ needs.plan.outputs.resume_run_id }} - name: Validate retained release run: | node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index d39832ecea..f44ee9412e 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -15,17 +15,17 @@ The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This runtime-driven Node entry is separate from `publish.yml`, which remains the manual stable and prerelease entry for all SDK languages. `runtime-sdk.yml` invokes `runtime-backed-node-release.yml` for runtime acquisition, -cross-platform tests, packaging, manifest retention, recovery, and optional -internal publication. It alone contains public unstable npm publication. +cross-platform tests, packaging, manifest retention, and optional internal +publication. It alone contains public unstable npm publication. The runtime dispatch includes these inputs: -* `channel`: `canary` or `unstable` -* `runtime_version`: Exact runtime package version -* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -* `runtime_source`: `azure` for canary or `github-packages` for unstable -* `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key -* `mode`: `tests-only` or `internal` for canary; `internal` for unstable +- `channel`: `canary` or `unstable` +- `runtime_version`: Exact runtime package version +- `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +- `runtime_source`: `azure` for canary or `github-packages` for unstable +- `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key +- `mode`: `tests-only` or `internal` for canary; `internal` for unstable Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The optional `version` input is available only for unstable and must be an unstable @@ -56,14 +56,15 @@ or recalculating its identity. Canary `tests-only` runs stop after package verification. Canary `internal` runs publish platform packages before the umbrella package to the Azure -`copilot-canary` feed, then perform a clean install and runtime version check. +`copilot-canary` feed, then perform a clean install and package version check. No canary job has a public npm publication path. Every unstable run publishes the retained platform tarballs and umbrella tarball to Azure first. A clean internal install must start the exact selected -runtime before public publication begins. The public job uses npm trusted -publishing from `runtime-sdk.yml` and publishes the same tarballs under the -`unstable` dist-tag, with the umbrella package last. +SDK package version before public publication begins. The strict acquisition +and package validation gates verify the embedded runtime identity. The public +job uses npm trusted publishing from `runtime-sdk.yml` and publishes the same +tarballs under the `unstable` dist-tag, with the umbrella package last. Before either publication, the workflow checks all nine package coordinates. An existing package counts as complete only when registry integrity matches @@ -88,13 +89,6 @@ identity. Exact duplicate dispatches wait for and mirror the canonical run. If that run fails or is canceled, rerun the original run rather than dispatching another release. -Use `resume_run_id` only when the canonical run cannot be rerun. Start a new -manual `runtime-sdk.yml` unstable run with the same dispatch tuple and the -canonical SDK workflow run ID. The workflow validates the marker and GitHub API -provenance, downloads the canonical retained artifact, verifies its manifest -and all nine SHA-512 integrity values, and uses the recorded identities. It -never rebuilds or substitutes packages. - ## Registry setup The Azure `copilot-canary` feed continues to use the `cicd` environment and diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index b739354eca..aecab955d2 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -44,7 +44,6 @@ export interface ExpectedDispatch { channel: RuntimeDispatchMarker["channel"]; currentRunId: string; mode: RuntimeDispatchMarker["mode"]; - resumeRunId: string; runtimeRunId: string; runtimeSha: string; runtimeSource: RuntimeDispatchMarker["runtime"]["source"]; @@ -54,7 +53,7 @@ export interface ExpectedDispatch { versionOverride: string; } -export type DispatchRole = "duplicate" | "owner" | "recovery"; +export type DispatchRole = "duplicate" | "owner"; const workflowPath = ".github/workflows/runtime-sdk.yml"; const workflowName = "Runtime-driven Node SDK"; @@ -68,14 +67,10 @@ function validateInputs(expected: ExpectedDispatch): void { assert( expected.channel === "canary" ? expected.runtimeSource === "azure" && - (expected.mode === "tests-only" || expected.mode === "internal") && - expected.resumeRunId === "" + (expected.mode === "tests-only" || expected.mode === "internal") : expected.runtimeSource === "github-packages" && expected.mode === "internal", - "Invalid channel, runtime source, mode, or recovery combination" + "Invalid channel, runtime source, or mode combination" ); - if (expected.resumeRunId) { - assert.match(expected.resumeRunId, /^[0-9]+$/, "Resume workflow run ID must be numeric"); - } } export function createRuntimeDispatchMarker(expected: ExpectedDispatch): RuntimeDispatchMarker { @@ -157,14 +152,6 @@ export function validateRuntimeDispatchMarker( if (marker.canonicalRunId === expected.currentRunId) { return "owner"; } - if (expected.resumeRunId) { - assert.equal( - expected.resumeRunId, - marker.canonicalRunId, - "resume_run_id must identify the canonical workflow run" - ); - return "recovery"; - } return "duplicate"; } @@ -181,7 +168,6 @@ function expectedFromEnvironment(): ExpectedDispatch { channel: requiredEnvironment("CHANNEL") as ExpectedDispatch["channel"], currentRunId: requiredEnvironment("CURRENT_RUN_ID"), mode: requiredEnvironment("MODE") as ExpectedDispatch["mode"], - resumeRunId: process.env.RESUME_RUN_ID?.trim() ?? "", runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), runtimeSha: requiredEnvironment("RUNTIME_SHA"), runtimeSource: requiredEnvironment("RUNTIME_SOURCE") as ExpectedDispatch["runtimeSource"], diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index d791ba66e5..0100b4aef7 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -8,10 +8,6 @@ const workflow = (name: string) => const publish = workflow("publish.yml"); const runtimeSdk = workflow("runtime-sdk.yml"); const shared = workflow("runtime-backed-node-release.yml"); -const ledger = readFileSync( - join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts"), - "utf8" -); describe("normal publishing workflow contract", () => { it("remains the stable and prerelease entry without runtime handoff inputs", () => { @@ -23,6 +19,10 @@ describe("normal publishing workflow contract", () => { expect(publish).not.toContain("resume_run_id:"); expect(publish).not.toContain("runtime-backed-node-release.yml"); expect(publish).toContain("publish.yml only accepts latest or prerelease"); + expect(publish).toContain( + "prerelease namespace is reserved for runtime-driven SDK releases" + ); + expect(publish).toContain("canary|unstable"); }); it("retains all normal SDK publication paths", () => { @@ -59,6 +59,7 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts validate"); expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); expect(runtimeSdk).toContain("retention-days: 90"); + expect(runtimeSdk).not.toContain("resume_run_id"); }); it("delegates preparation before its separately serialized public publication", () => { @@ -71,14 +72,10 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); }); - it("only recovers the canonical retained release", () => { - expect(ledger).toContain("resume_run_id must identify the canonical workflow run"); - expect(runtimeSdk).toContain( - "run-id: ${{ needs.claim-runtime-dispatch.outputs.canonical_run_id }}" - ); - expect(runtimeSdk).toContain( - "Canonical release manifest does not match the claimed runtime dispatch" - ); + it("requires duplicates and failures to use the canonical workflow run", () => { + expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); + expect(runtimeSdk).toContain("Re-run that original run"); + expect(runtimeSdk).not.toContain("run-id:"); }); }); @@ -95,7 +92,10 @@ describe("shared runtime-backed Node pipeline", () => { expect(shared).toContain("npm run acquire:runtime-packages"); expect(shared).toContain("npm run verify:release-packages"); expect(shared).toContain("publish-manifest"); - expect(shared).toContain("group: sdk-runtime-internal-"); + expect(shared).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); + expect(shared).not.toContain('"$runtime_path" --version'); + expect(shared).not.toContain('"$RUNTIME" --version'); + expect(shared).not.toContain("resume_run_id"); expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( shared.indexOf("publish-manifest") ); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index eda6efc697..c26357e445 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -9,7 +9,6 @@ const expected: ExpectedDispatch = { channel: "unstable", currentRunId: "200", mode: "internal", - resumeRunId: "", runtimeRunId: "100", runtimeSha: "a".repeat(40), runtimeSource: "github-packages", @@ -50,18 +49,12 @@ describe("runtime dispatch ledger", () => { ); }); - it("recognizes an exact duplicate and an authorized recovery", () => { + it("recognizes an exact duplicate", () => { const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); const api = provenance("199"); expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( "duplicate" ); - expect( - validateRuntimeDispatchMarker(marker, api.artifact, api.run, { - ...expected, - resumeRunId: "199", - }) - ).toBe("recovery"); }); it("rejects marker tuple collisions and forged API provenance", () => { @@ -90,15 +83,4 @@ describe("runtime dispatch ledger", () => { ) ).toThrow(); }); - - it("requires recovery to name the canonical run", () => { - const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); - const api = provenance("199"); - expect(() => - validateRuntimeDispatchMarker(marker, api.artifact, api.run, { - ...expected, - resumeRunId: "198", - }) - ).toThrow(/canonical workflow run/); - }); }); From c2378adbb9259751ce35420d6ce5729c8e9c53d4 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:36:05 -0700 Subject: [PATCH 05/23] Fix runtime SDK reruns and canary versions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-backed-node-release.yml | 8 +++++++- .github/workflows/runtime-sdk.yml | 6 +----- nodejs/test/release-workflows.test.ts | 5 +++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml index 22893b074f..c6749406a5 100644 --- a/.github/workflows/runtime-backed-node-release.yml +++ b/.github/workflows/runtime-backed-node-release.yml @@ -103,7 +103,13 @@ jobs: [ -n "$SDK_REF" ] || { echo "::error::sdk_ref is required."; exit 1; } if [ "$CHANNEL" = "canary" ]; then PUBLIC_LATEST="$(node scripts/get-version.js current)" - BASE="${PUBLIC_LATEST%%-*}" + BASE="$(node -e ' + const semver = require("semver"); + const parsed = semver.parse(process.argv[1]); + if (!parsed) process.exit(1); + process.stdout.write(`${parsed.major}.${parsed.minor}.${parsed.patch}`); + ' "$PUBLIC_LATEST")" || + { echo "::error::Current public SDK version is not valid SemVer: $PUBLIC_LATEST"; exit 1; } IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" else diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index cb4921a4ec..b0a8ac4fa5 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -131,7 +131,7 @@ jobs: EARLIER="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ '[.workflow_runs[] | select(.display_title == $title and .id < $current)] | length' \ "$RUNNER_TEMP/runs.json")" - if [ "$EARLIER" -eq 0 ] && [ "$GITHUB_RUN_ATTEMPT" -eq 1 ]; then + if [ "$EARLIER" -eq 0 ]; then echo "found=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -151,10 +151,6 @@ jobs: echo "::error::An earlier matching run is still initializing without a visible marker. Retry this run later." exit 1 fi - if [ "$GITHUB_RUN_ATTEMPT" -gt 1 ]; then - echo "::error::This rerun's canonical marker is not visible. Retry after the artifact index is consistent." - exit 1 - fi echo "Earlier matching runs completed before claiming; none could have started release work." echo "found=false" >> "$GITHUB_OUTPUT" - name: Download the existing marker diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 0100b4aef7..f730c01b85 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -56,6 +56,8 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeSdk).toContain("More than one unexpired"); expect(runtimeSdk).toContain("for ATTEMPT in 1 2 3 4 5 6"); expect(runtimeSdk).toContain("actions/workflows/runtime-sdk.yml/runs"); + expect(runtimeSdk).toContain('if [ "$EARLIER" -eq 0 ]; then'); + expect(runtimeSdk).not.toContain('GITHUB_RUN_ATTEMPT" -gt 1'); expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts validate"); expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); expect(runtimeSdk).toContain("retention-days: 90"); @@ -96,6 +98,9 @@ describe("shared runtime-backed Node pipeline", () => { expect(shared).not.toContain('"$runtime_path" --version'); expect(shared).not.toContain('"$RUNTIME" --version'); expect(shared).not.toContain("resume_run_id"); + expect(shared).toContain("const parsed = semver.parse(process.argv[1])"); + expect(shared).toContain("parsed.major}.${parsed.minor}.${parsed.patch"); + expect(shared).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( shared.indexOf("publish-manifest") ); From 1cb1ed0ee8cf07084b3031c423aea635fd616ab5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:47:53 -0700 Subject: [PATCH 06/23] Fix pre-check working directory Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/publish.yml | 1 + nodejs/test/release-workflows.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d45de75dd..77d0a3832c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,6 +40,7 @@ jobs: working-directory: ./nodejs steps: - name: Validate release channel + working-directory: . env: DIST_TAG: ${{ inputs.dist-tag }} run: | diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index f730c01b85..74a9d1ee48 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -19,6 +19,7 @@ describe("normal publishing workflow contract", () => { expect(publish).not.toContain("resume_run_id:"); expect(publish).not.toContain("runtime-backed-node-release.yml"); expect(publish).toContain("publish.yml only accepts latest or prerelease"); + expect(publish).toMatch(/- name: Validate release channel\s+working-directory: \.\s+env:/); expect(publish).toContain( "prerelease namespace is reserved for runtime-driven SDK releases" ); From 06246f28432e929118407cbb8685eb79e5c5780a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 07:59:52 -0700 Subject: [PATCH 07/23] Unify runtime-driven SDK workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .../workflows/runtime-backed-node-release.yml | 373 ------------------ .github/workflows/runtime-sdk.yml | 282 +++++++++++-- docs/developer-docs/unstable-releases.md | 5 +- nodejs/test/release-workflows.test.ts | 52 +-- 4 files changed, 289 insertions(+), 423 deletions(-) delete mode 100644 .github/workflows/runtime-backed-node-release.yml diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml deleted file mode 100644 index c6749406a5..0000000000 --- a/.github/workflows/runtime-backed-node-release.yml +++ /dev/null @@ -1,373 +0,0 @@ -name: Runtime-backed Node SDK release - -on: - workflow_call: - inputs: - artifact_name: - required: false - type: string - default: "" - channel: - required: true - type: string - mode: - required: true - type: string - runtime_run_id: - required: true - type: string - runtime_sha: - required: true - type: string - runtime_source: - required: true - type: string - runtime_version: - required: true - type: string - sdk_ref: - required: true - type: string - sdk_sha: - required: true - type: string - sdk_version: - required: false - type: string - default: "" - outputs: - artifact_name: - value: ${{ jobs.boundary.outputs.artifact_name }} - sdk_version: - value: ${{ jobs.boundary.outputs.sdk_version }} - secrets: - COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY: - required: true - -env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - HUSKY: 0 - -jobs: - boundary: - name: Validate shared release boundary - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - outputs: - artifact_name: ${{ steps.validate.outputs.artifact_name }} - sdk_version: ${{ steps.validate.outputs.sdk_version }} - workflow_created_at: ${{ steps.validate.outputs.workflow_created_at }} - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Enforce channel, source, mode, and identity - id: validate - env: - ARTIFACT_NAME: ${{ inputs.artifact_name }} - CHANNEL: ${{ inputs.channel }} - GH_TOKEN: ${{ github.token }} - MODE: ${{ inputs.mode }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ inputs.sdk_ref }} - SDK_SHA: ${{ inputs.sdk_sha }} - SDK_VERSION: ${{ inputs.sdk_version }} - run: | - set -euo pipefail - case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in - canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; - *) echo "::error::Invalid runtime-backed release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; - esac - [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || - { echo "::error::runtime_version must be exact SemVer."; exit 1; } - [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || - { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } - [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::runtime_run_id must be numeric."; exit 1; } - [[ "$SDK_SHA" =~ ^[0-9a-f]{40}$ ]] || - { echo "::error::sdk_sha must be a lowercase full SHA."; exit 1; } - [ -n "$SDK_REF" ] || { echo "::error::sdk_ref is required."; exit 1; } - if [ "$CHANNEL" = "canary" ]; then - PUBLIC_LATEST="$(node scripts/get-version.js current)" - BASE="$(node -e ' - const semver = require("semver"); - const parsed = semver.parse(process.argv[1]); - if (!parsed) process.exit(1); - process.stdout.write(`${parsed.major}.${parsed.minor}.${parsed.patch}`); - ' "$PUBLIC_LATEST")" || - { echo "::error::Current public SDK version is not valid SemVer: $PUBLIC_LATEST"; exit 1; } - IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" - SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" - else - [ -n "$SDK_VERSION" ] || { echo "::error::Unstable sdk_version is required."; exit 1; } - [[ "$SDK_VERSION" =~ -unstable\. ]] || - { echo "::error::Unstable sdk_version must use the unstable prerelease identifier."; exit 1; } - fi - npm exec -- semver "$SDK_VERSION" >/dev/null - EXPECTED_ARTIFACT="nodejs-${CHANNEL}-${SDK_VERSION}" - if [ -n "$ARTIFACT_NAME" ] && [ "$ARTIFACT_NAME" != "$EXPECTED_ARTIFACT" ]; then - echo "::error::artifact_name must be $EXPECTED_ARTIFACT." - exit 1 - fi - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - { - echo "artifact_name=$EXPECTED_ARTIFACT" - echo "sdk_version=$SDK_VERSION" - echo "workflow_created_at=$WORKFLOW_CREATED_AT" - } >> "$GITHUB_OUTPUT" - - acquire-runtime: - name: Acquire exact runtime packages - needs: boundary - runs-on: ubuntu-latest - environment: cicd - permissions: - contents: read - id-token: write - packages: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Azure login - if: inputs.runtime_source == 'azure' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - if: inputs.runtime_source == 'azure' - run: | - 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: Configure authentication-only GitHub Packages access - if: inputs.runtime_source == 'github-packages' - env: - NODE_AUTH_TOKEN: ${{ github.token }} - run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" - - name: Download and validate all runtime platforms - env: - REGISTRY: ${{ inputs.runtime_source == 'azure' && env.FEED_URL || 'https://npm.pkg.github.com' }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - run: | - npm run acquire:runtime-packages -- \ - --version "$RUNTIME_VERSION" \ - --sha "$RUNTIME_SHA" \ - --registry "$REGISTRY" \ - --output "$RUNNER_TEMP/runtime-packages" - - uses: actions/upload-artifact@v7.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - if-no-files-found: error - retention-days: 7 - - test: - name: Runtime-backed Node tests (${{ matrix.os }}) - needs: [boundary, acquire-runtime] - permissions: - contents: read - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - environment: cicd - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Select the acquired runtime - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ inputs.runtime_version }} - run: | - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - run: npm run build - - name: Warm up PowerShell - if: runner.os == 'Windows' - run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - name: Run Node SDK tests - env: - COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test - - package: - name: Build and verify nine SDK packages - needs: [boundary, acquire-runtime, test] - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages - - name: Build and verify exact package set - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} - run: | - VERSION="$SDK_VERSION" node scripts/set-version.js - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts - npm run build - npm run pack:release - npm run verify:release-packages - - name: Create immutable release manifest - env: - RELEASE_CHANNEL: ${{ inputs.channel }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ inputs.sdk_ref }} - SDK_SHA: ${{ inputs.sdk_sha }} - SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} - WORKFLOW_CREATED_AT: ${{ needs.boundary.outputs.workflow_created_at }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - npm run release:manifest -- create release-manifest.json . - npm run release:manifest -- verify release-manifest.json . - - uses: actions/upload-artifact@v7.0.0 - with: - name: ${{ needs.boundary.outputs.artifact_name }} - path: | - nodejs/release-manifest.json - nodejs/github-copilot-sdk-*.tgz - if-no-files-found: error - retention-days: 30 - - publish-internal: - name: Publish and verify SDK internally - if: | - always() && - !cancelled() && - inputs.mode == 'internal' && - needs.boundary.result == 'success' && - needs.package.result == 'success' - needs: [boundary, package] - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-internal-${{ inputs.channel }} - cancel-in-progress: false - environment: cicd - permissions: - actions: read - contents: read - id-token: write - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Download retained release - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.boundary.outputs.artifact_name }} - path: ./dist - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || - { echo "::error::Retained release belongs to a different workflow run."; exit 1; } - [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || - { echo "::error::Retained release channel does not match the requested channel."; exit 1; } - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - run: | - 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 exact tarballs internally - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure - - name: Clean install and package version check - env: - SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} - run: | - VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" - mkdir -p "$VERIFY_ROOT" - cd "$VERIFY_ROOT" - npm init -y >/dev/null - printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" - npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" - node -e ' - const expected = process.argv[1]; - const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); - const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); - if (umbrella.version !== expected || platform.version !== expected) { - throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); - } - ' "$SDK_VERSION" diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index b0a8ac4fa5..94b5ca2e2b 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -46,6 +46,11 @@ on: permissions: contents: read +env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + HUSKY: 0 + jobs: claim-runtime-dispatch: name: Claim runtime dispatch @@ -267,16 +272,26 @@ jobs: run: | set -euo pipefail WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - SDK_VERSION="" - ARTIFACT_NAME="" - if [ "$CHANNEL" = "unstable" ]; then + if [ "$CHANNEL" = "canary" ]; then + PUBLIC_LATEST="$(node scripts/get-version.js current)" + BASE="$(node -e ' + const semver = require("semver"); + const parsed = semver.parse(process.argv[1]); + if (!parsed) process.exit(1); + process.stdout.write(`${parsed.major}.${parsed.minor}.${parsed.patch}`); + ' "$PUBLIC_LATEST")" || + { echo "::error::Current public SDK version is not valid SemVer: $PUBLIC_LATEST"; exit 1; } + IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" + SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" + else gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" export WORKFLOW_CREATED_AT SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" - ARTIFACT_NAME="nodejs-unstable-$SDK_VERSION" fi + npm exec -- semver "$SDK_VERSION" >/dev/null + ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" { echo "artifact_name=$ARTIFACT_NAME" echo "sdk_version=$SDK_VERSION" @@ -336,33 +351,252 @@ jobs: node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" done - runtime-backed-release: - name: Run runtime-backed SDK pipeline - if: needs.claim-runtime-dispatch.outputs.role == 'owner' - needs: [claim-runtime-dispatch, plan] - uses: ./.github/workflows/runtime-backed-node-release.yml + acquire-runtime: + name: Acquire exact runtime packages + needs: plan + runs-on: ubuntu-latest + environment: cicd permissions: - actions: read contents: read id-token: write packages: read - with: - artifact_name: ${{ needs.plan.outputs.artifact_name }} - channel: ${{ inputs.channel }} - mode: ${{ inputs.mode }} - runtime_run_id: ${{ inputs.runtime_run_id }} - runtime_sha: ${{ inputs.runtime_sha }} - runtime_source: ${{ inputs.runtime_source }} - runtime_version: ${{ inputs.runtime_version }} - sdk_ref: ${{ github.ref }} - sdk_sha: ${{ github.sha }} - sdk_version: ${{ needs.plan.outputs.sdk_version }} - secrets: inherit + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Azure login + if: inputs.runtime_source == 'azure' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + if: inputs.runtime_source == 'azure' + run: | + 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: Configure authentication-only GitHub Packages access + if: inputs.runtime_source == 'github-packages' + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + REGISTRY: ${{ inputs.runtime_source == 'azure' && env.FEED_URL || 'https://npm.pkg.github.com' }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --registry "$REGISTRY" \ + --output "$RUNNER_TEMP/runtime-packages" + - uses: actions/upload-artifact@v7.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + if-no-files-found: error + retention-days: 7 + + test: + name: Runtime-backed Node tests (${{ matrix.os }}) + needs: [plan, acquire-runtime] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + package: + name: Build and verify nine SDK packages + needs: [plan, acquire-runtime, test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} + run: | + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.plan.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ needs.plan.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + publish-internal: + name: Publish and verify SDK internally + if: | + always() && + !cancelled() && + inputs.mode == 'internal' && + needs.plan.result == 'success' && + needs.package.result == 'success' + needs: [plan, package] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.channel }} + cancel-in-progress: false + environment: cicd + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download retained release + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.plan.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || + { echo "::error::Retained release channel does not match the requested channel."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + 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 exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure + - name: Clean install and package version check + env: + SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} + run: | + VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + node -e ' + const expected = process.argv[1]; + const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); + const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); + if (umbrella.version !== expected || platform.version !== expected) { + throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); + } + ' "$SDK_VERSION" publish-public: name: Publish unstable SDK publicly if: inputs.channel == 'unstable' && needs.claim-runtime-dispatch.outputs.role == 'owner' - needs: [claim-runtime-dispatch, plan, runtime-backed-release] + needs: [claim-runtime-dispatch, plan, publish-internal] runs-on: ubuntu-latest concurrency: group: sdk-runtime-public-unstable @@ -383,7 +617,7 @@ jobs: - name: Download retained release uses: actions/download-artifact@v8.0.0 with: - name: ${{ needs.runtime-backed-release.outputs.artifact_name }} + name: ${{ needs.plan.outputs.artifact_name }} path: ./dist - name: Validate retained release run: | diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index f44ee9412e..4cba95811c 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -14,9 +14,8 @@ run ID. The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This runtime-driven Node entry is separate from `publish.yml`, which remains the manual stable and prerelease entry for all SDK languages. `runtime-sdk.yml` -invokes `runtime-backed-node-release.yml` for runtime acquisition, -cross-platform tests, packaging, manifest retention, and optional internal -publication. It alone contains public unstable npm publication. +owns runtime acquisition, cross-platform tests, packaging, manifest retention, +optional internal publication, and public unstable npm publication. The runtime dispatch includes these inputs: diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 74a9d1ee48..51853ae59b 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,7 +7,6 @@ const workflow = (name: string) => readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); const publish = workflow("publish.yml"); const runtimeSdk = workflow("runtime-sdk.yml"); -const shared = workflow("runtime-backed-node-release.yml"); describe("normal publishing workflow contract", () => { it("remains the stable and prerelease entry without runtime handoff inputs", () => { @@ -41,6 +40,14 @@ describe("normal publishing workflow contract", () => { }); describe("runtime-driven Node SDK entry contract", () => { + it("contains the runtime-backed implementation without a single-caller reusable workflow", () => { + expect( + existsSync( + join(repositoryRoot, ".github", "workflows", "runtime-backed-node-release.yml") + ) + ).toBe(false); + }); + it("owns both strict runtime handoff matrices", () => { expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); expect(runtimeSdk).toContain("canary:azure:tests-only"); @@ -66,12 +73,12 @@ describe("runtime-driven Node SDK entry contract", () => { }); it("delegates preparation before its separately serialized public publication", () => { - expect(runtimeSdk).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); expect(runtimeSdk).toContain("scripts/unstable-version.ts"); expect(runtimeSdk).toContain("group: sdk-runtime-public-unstable"); - expect(runtimeSdk.indexOf("runtime-backed-release:")).toBeLessThan( + expect(runtimeSdk.indexOf("publish-internal:")).toBeLessThan( runtimeSdk.indexOf("publish-public:") ); + expect(runtimeSdk).toContain("needs: [claim-runtime-dispatch, plan, publish-internal]"); expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); }); @@ -82,28 +89,27 @@ describe("runtime-driven Node SDK entry contract", () => { }); }); -describe("shared runtime-backed Node pipeline", () => { - it("enforces the channel, source, and mode matrix again", () => { - expect(shared).toContain("canary:azure:tests-only"); - expect(shared).toContain("canary:azure:internal"); - expect(shared).toContain("unstable:github-packages:internal"); - expect(shared).not.toContain("registry.npmjs.org"); +describe("runtime-backed Node release implementation", () => { + it("enforces the channel, source, and mode matrix", () => { + expect(runtimeSdk).toContain("canary:azure:tests-only"); + expect(runtimeSdk).toContain("canary:azure:internal"); + expect(runtimeSdk).toContain("unstable:github-packages:internal"); }); it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { - expect(shared).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); - expect(shared).toContain("npm run acquire:runtime-packages"); - expect(shared).toContain("npm run verify:release-packages"); - expect(shared).toContain("publish-manifest"); - expect(shared).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); - expect(shared).not.toContain('"$runtime_path" --version'); - expect(shared).not.toContain('"$RUNTIME" --version'); - expect(shared).not.toContain("resume_run_id"); - expect(shared).toContain("const parsed = semver.parse(process.argv[1])"); - expect(shared).toContain("parsed.major}.${parsed.minor}.${parsed.patch"); - expect(shared).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); - expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( - shared.indexOf("publish-manifest") + expect(runtimeSdk).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); + expect(runtimeSdk).toContain("npm run verify:release-packages"); + expect(runtimeSdk).toContain("publish-manifest"); + expect(runtimeSdk).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); + expect(runtimeSdk).not.toContain('"$runtime_path" --version'); + expect(runtimeSdk).not.toContain('"$RUNTIME" --version'); + expect(runtimeSdk).not.toContain("resume_run_id"); + expect(runtimeSdk).toContain("const parsed = semver.parse(process.argv[1])"); + expect(runtimeSdk).toContain("parsed.major}.${parsed.minor}.${parsed.patch"); + expect(runtimeSdk).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); + expect(runtimeSdk.indexOf("npm run verify:release-packages")).toBeLessThan( + runtimeSdk.indexOf("publish-manifest") ); }); }); From 0ad696e9ffaf4245fbebeb82d20b0d8e3f1d8148 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 08:26:11 -0700 Subject: [PATCH 08/23] Simplify runtime SDK release orchestration Move artifact-ledger claim handling and package-set preflight into tested release scripts, keeping the workflow focused on job orchestration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 163 +------------- nodejs/scripts/npm-release.js | 35 ++- nodejs/scripts/runtime-dispatch-ledger.ts | 229 ++++++++++++++++---- nodejs/test/npm-release.test.ts | 15 ++ nodejs/test/release-workflows.test.ts | 35 ++- nodejs/test/runtime-dispatch-ledger.test.ts | 125 ++++++++++- 6 files changed, 386 insertions(+), 216 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 94b5ca2e2b..4b96dd9241 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -62,8 +62,8 @@ jobs: actions: read contents: read outputs: - canonical_run_id: ${{ steps.existing.outputs.canonical_run_id || steps.created.outputs.canonical_run_id }} - role: ${{ steps.existing.outputs.role || steps.created.outputs.role }} + canonical_run_id: ${{ steps.claim.outputs.canonical_run_id }} + role: ${{ steps.claim.outputs.role }} defaults: run: shell: bash @@ -76,106 +76,12 @@ jobs: node-version: 22 - run: npm ci --ignore-scripts working-directory: ./nodejs - - name: Validate entry boundary - env: - CHANNEL: ${{ inputs.channel }} - MODE: ${{ inputs.mode }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in - canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; - *) echo "::error::Invalid runtime-driven release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; - esac - [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || - { echo "::error::runtime_version must be exact SemVer."; exit 1; } - [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || - { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } - [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || - { echo "::error::runtime_run_id must be numeric."; exit 1; } - if [ "$CHANNEL" = "canary" ] && [ -n "$VERSION" ]; then - echo "::error::Canary runs do not accept a version override." - exit 1 - fi - - name: Find the canonical dispatch marker - id: lookup - env: - GH_TOKEN: ${{ github.token }} - MARKER_NAME: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} - RUN_TITLE: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} - run: | - set -euo pipefail - for ATTEMPT in 1 2 3 4 5 6; do - gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$MARKER_NAME&per_page=100" \ - > "$RUNNER_TEMP/artifacts.json" - MATCHES="$(jq --arg name "$MARKER_NAME" \ - '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ - "$RUNNER_TEMP/artifacts.json")" - if [ "$MATCHES" -gt 1 ]; then - echo "::error::More than one unexpired $MARKER_NAME artifact exists." - exit 1 - fi - if [ "$MATCHES" -eq 1 ]; then - jq --arg name "$MARKER_NAME" \ - '.artifacts[] | select(.name == $name and .expired == false)' \ - "$RUNNER_TEMP/artifacts.json" > "$RUNNER_TEMP/artifact.json" - { - echo "found=true" - echo "artifact_id=$(jq -r .id "$RUNNER_TEMP/artifact.json")" - echo "artifact_run_id=$(jq -r .workflow_run.id "$RUNNER_TEMP/artifact.json")" - } >> "$GITHUB_OUTPUT" - exit 0 - fi - - gh api "/repos/$GITHUB_REPOSITORY/actions/workflows/runtime-sdk.yml/runs?event=workflow_dispatch&per_page=100" \ - > "$RUNNER_TEMP/runs.json" - EARLIER="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ - '[.workflow_runs[] | select(.display_title == $title and .id < $current)] | length' \ - "$RUNNER_TEMP/runs.json")" - if [ "$EARLIER" -eq 0 ]; then - echo "found=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$ATTEMPT" -lt 6 ]; then - echo "An earlier matching run is visible; waiting for its marker (attempt $ATTEMPT/6)." - sleep 10 - fi - done - - ACTIVE="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ - '[.workflow_runs[] | select( - .display_title == $title and - .id < $current and - .status != "completed" - )] | length' "$RUNNER_TEMP/runs.json")" - if [ "$ACTIVE" -gt 0 ]; then - echo "::error::An earlier matching run is still initializing without a visible marker. Retry this run later." - exit 1 - fi - echo "Earlier matching runs completed before claiming; none could have started release work." - echo "found=false" >> "$GITHUB_OUTPUT" - - name: Download the existing marker - if: steps.lookup.outputs.found == 'true' - env: - ARTIFACT_ID: ${{ steps.lookup.outputs.artifact_id }} - ARTIFACT_RUN_ID: ${{ steps.lookup.outputs.artifact_run_id }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/marker" - gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" > "$RUNNER_TEMP/marker.zip" - unzip -q "$RUNNER_TEMP/marker.zip" -d "$RUNNER_TEMP/marker" - gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$ARTIFACT_RUN_ID" > "$RUNNER_TEMP/run.json" - - name: Validate the existing marker and API provenance - if: steps.lookup.outputs.found == 'true' - id: existing + - name: Claim or resolve the canonical dispatch + id: claim env: CHANNEL: ${{ inputs.channel }} CURRENT_RUN_ID: ${{ github.run_id }} + GH_TOKEN: ${{ github.token }} MODE: ${{ inputs.mode }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} @@ -184,13 +90,11 @@ jobs: SDK_REF: ${{ github.ref }} SDK_SHA: ${{ github.sha }} VERSION_OVERRIDE: ${{ inputs.version }} - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts validate \ - "$RUNNER_TEMP/marker/marker.json" "$RUNNER_TEMP/artifact.json" "$RUNNER_TEMP/run.json" + run: node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts claim "$RUNNER_TEMP/new-marker/marker.json" - name: Mirror the canonical run - if: steps.existing.outputs.role == 'duplicate' + if: steps.claim.outputs.role == 'duplicate' env: - CANONICAL_RUN_ID: ${{ steps.existing.outputs.canonical_run_id }} + CANONICAL_RUN_ID: ${{ steps.claim.outputs.canonical_run_id }} GH_TOKEN: ${{ github.token }} run: | set +e @@ -202,30 +106,8 @@ jobs: exit "$RESULT" fi echo "Canonical SDK run $CANONICAL_RUN_ID succeeded; this duplicate is complete." - - name: Create the canonical marker - if: steps.lookup.outputs.found == 'false' - id: created - env: - CHANNEL: ${{ inputs.channel }} - CURRENT_RUN_ID: ${{ github.run_id }} - MODE: ${{ inputs.mode }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - VERSION_OVERRIDE: ${{ inputs.version }} - run: | - mkdir -p "$RUNNER_TEMP/new-marker" - node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts create \ - "$RUNNER_TEMP/new-marker/marker.json" - { - echo "role=owner" - echo "canonical_run_id=$GITHUB_RUN_ID" - } >> "$GITHUB_OUTPUT" - name: Persist the canonical marker - if: steps.lookup.outputs.found == 'false' + if: steps.claim.outputs.created == 'true' uses: actions/upload-artifact@v7.0.0 with: name: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} @@ -302,19 +184,7 @@ jobs: working-directory: ./nodejs env: SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} - run: | - for PACKAGE in \ - @github/copilot-sdk \ - @github/copilot-sdk-darwin-arm64 \ - @github/copilot-sdk-darwin-x64 \ - @github/copilot-sdk-linux-arm64 \ - @github/copilot-sdk-linux-x64 \ - @github/copilot-sdk-linuxmusl-arm64 \ - @github/copilot-sdk-linuxmusl-x64 \ - @github/copilot-sdk-win32-arm64 \ - @github/copilot-sdk-win32-x64; do - node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" https://registry.npmjs.org - done + run: node scripts/npm-release.js preflight-package-set "$SDK_VERSION" https://registry.npmjs.org - name: Azure login for explicit-version preflight if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 @@ -338,18 +208,7 @@ jobs: printf '%s\n' \ "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" - for PACKAGE in \ - @github/copilot-sdk \ - @github/copilot-sdk-darwin-arm64 \ - @github/copilot-sdk-darwin-x64 \ - @github/copilot-sdk-linux-arm64 \ - @github/copilot-sdk-linux-x64 \ - @github/copilot-sdk-linuxmusl-arm64 \ - @github/copilot-sdk-linuxmusl-x64 \ - @github/copilot-sdk-win32-arm64 \ - @github/copilot-sdk-win32-x64; do - node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" - done + node scripts/npm-release.js preflight-package-set "$SDK_VERSION" "$FEED_URL" acquire-runtime: name: Acquire exact runtime packages diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index a2d1e91104..a48f9fd2e3 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -4,6 +4,18 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +export const sdkPackageNames = [ + "@github/copilot-sdk", + "@github/copilot-sdk-darwin-arm64", + "@github/copilot-sdk-darwin-x64", + "@github/copilot-sdk-linux-arm64", + "@github/copilot-sdk-linux-x64", + "@github/copilot-sdk-linuxmusl-arm64", + "@github/copilot-sdk-linuxmusl-x64", + "@github/copilot-sdk-win32-arm64", + "@github/copilot-sdk-win32-x64", +]; + export function runCommand(command, args, { stream = false } = {}) { return new Promise((resolveResult, reject) => { const child = spawn(command, args, { shell: false }); @@ -85,6 +97,12 @@ export async function assertVersionAbsent(packageName, version, registry, runner } } +export async function assertPackageSetVersionAbsent(version, registry, runner = runCommand) { + for (const packageName of sdkPackageNames) { + await assertVersionAbsent(packageName, version, registry, runner); + } +} + export async function assertPublishedIntegrity( packageName, version, @@ -149,17 +167,7 @@ function readReleaseManifest(manifestPath, packageDirectory) { if (manifest.packages.length !== 9) { throw new Error(`Expected nine release packages, found ${manifest.packages.length}.`); } - const expectedNames = new Set([ - "@github/copilot-sdk", - "@github/copilot-sdk-darwin-arm64", - "@github/copilot-sdk-darwin-x64", - "@github/copilot-sdk-linux-arm64", - "@github/copilot-sdk-linux-x64", - "@github/copilot-sdk-linuxmusl-arm64", - "@github/copilot-sdk-linuxmusl-x64", - "@github/copilot-sdk-win32-arm64", - "@github/copilot-sdk-win32-x64", - ]); + const expectedNames = new Set(sdkPackageNames); const names = new Set(); for (const packed of manifest.packages) { if ( @@ -284,6 +292,9 @@ async function main() { if (command === "preflight" && args.length === 3) { await assertVersionAbsent(...args); console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`); + } else if (command === "preflight-package-set" && args.length === 2) { + await assertPackageSetVersionAbsent(...args); + console.log(`All SDK packages at ${args[0]} are available on ${args[1]}.`); } else if (command === "publish" && args.length === 7) { const [tarball, name, version, tag, registry, mode, expectedIntegrity] = args; const localIntegrity = `sha512-${createHash("sha512") @@ -301,7 +312,7 @@ async function main() { await publishManifest(...args); } else { throw new Error( - "Usage: npm-release.js preflight | publish | publish-manifest " + "Usage: npm-release.js preflight | preflight-package-set | publish | publish-manifest " ); } } diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index aecab955d2..6747a0e8d3 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export interface RuntimeDispatchMarker { @@ -25,12 +26,15 @@ export interface RuntimeDispatchMarker { workflow: ".github/workflows/runtime-sdk.yml"; } -interface ArtifactApiResponse { +export interface ArtifactApiResponse { expired: boolean; + id: number; + name: string; workflow_run?: { id?: number }; } -interface WorkflowRunApiResponse { +export interface WorkflowRunApiResponse { + display_title: string; event: string; head_branch: string; head_sha: string; @@ -38,6 +42,7 @@ interface WorkflowRunApiResponse { name: string; path: string; repository: { full_name: string }; + status: string; } export interface ExpectedDispatch { @@ -55,22 +60,56 @@ export interface ExpectedDispatch { export type DispatchRole = "duplicate" | "owner"; +export interface DispatchClaim { + canonicalRunId: string; + created: boolean; + marker: RuntimeDispatchMarker; + role: DispatchRole; +} + +export interface DispatchLedgerClient { + downloadMarker(artifactId: number): Promise; + getWorkflowRun(runId: number): Promise; + listArtifacts(markerName: string): Promise; + listWorkflowRuns(): Promise; +} + +export interface ClaimOptions { + attempts?: number; + delay?: (milliseconds: number) => Promise; + delayMilliseconds?: number; + onWait?: (attempt: number, attempts: number) => void; +} + const workflowPath = ".github/workflows/runtime-sdk.yml"; const workflowName = "Runtime-driven Node SDK"; +const runtimeVersionPattern = + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; function validateInputs(expected: ExpectedDispatch): void { assert.match(expected.currentRunId, /^[0-9]+$/, "Current workflow run ID must be numeric"); assert.match(expected.runtimeRunId, /^[0-9]+$/, "Runtime workflow run ID must be numeric"); assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + assert.match( + expected.runtimeVersion, + runtimeVersionPattern, + "Runtime version must be exact SemVer" + ); assert.match(expected.sdkSha, /^[0-9a-f]{40}$/, "SDK SHA must be lowercase full SHA"); assert(expected.sdkRef.length > 0, "SDK ref is required"); assert( expected.channel === "canary" ? expected.runtimeSource === "azure" && (expected.mode === "tests-only" || expected.mode === "internal") - : expected.runtimeSource === "github-packages" && expected.mode === "internal", + : expected.channel === "unstable" && + expected.runtimeSource === "github-packages" && + expected.mode === "internal", "Invalid channel, runtime source, or mode combination" ); + assert( + expected.channel !== "canary" || expected.versionOverride === "", + "Canary runs do not accept a version override" + ); } export function createRuntimeDispatchMarker(expected: ExpectedDispatch): RuntimeDispatchMarker { @@ -155,6 +194,71 @@ export function validateRuntimeDispatchMarker( return "duplicate"; } +export async function claimRuntimeDispatch( + expected: ExpectedDispatch, + client: DispatchLedgerClient, + options: ClaimOptions = {} +): Promise { + validateInputs(expected); + const attempts = options.attempts ?? 6; + const delayMilliseconds = options.delayMilliseconds ?? 10_000; + const delay = + options.delay ?? + ((milliseconds: number) => + new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))); + const markerName = `sdk-runtime-dispatch-${expected.runtimeRunId}`; + const runTitle = `Runtime-driven SDK from runtime run ${expected.runtimeRunId}`; + let earlierRuns: WorkflowRunApiResponse[] = []; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const artifacts = (await client.listArtifacts(markerName)).filter( + (artifact) => artifact.name === markerName && !artifact.expired + ); + assert(artifacts.length <= 1, `More than one unexpired ${markerName} artifact exists.`); + const artifact = artifacts[0]; + if (artifact) { + const marker = await client.downloadMarker(artifact.id); + const canonicalRunId = Number(marker.canonicalRunId); + const workflowRun = await client.getWorkflowRun(canonicalRunId); + return { + canonicalRunId: marker.canonicalRunId, + created: false, + marker, + role: validateRuntimeDispatchMarker(marker, artifact, workflowRun, expected), + }; + } + + earlierRuns = (await client.listWorkflowRuns()).filter( + (run) => run.display_title === runTitle && run.id < Number(expected.currentRunId) + ); + if (earlierRuns.length === 0) { + const marker = createRuntimeDispatchMarker(expected); + return { + canonicalRunId: expected.currentRunId, + created: true, + marker, + role: "owner", + }; + } + if (attempt < attempts) { + options.onWait?.(attempt, attempts); + await delay(delayMilliseconds); + } + } + + assert( + !earlierRuns.some((run) => run.status !== "completed"), + "An earlier matching run is still initializing without a visible marker. Retry this run later." + ); + const marker = createRuntimeDispatchMarker(expected); + return { + canonicalRunId: expected.currentRunId, + created: true, + marker, + role: "owner", + }; +} + function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) { @@ -178,47 +282,96 @@ function expectedFromEnvironment(): ExpectedDispatch { }; } -function main(): void { - const [command, markerPath, artifactPath, runPath] = process.argv.slice(2); - const expected = expectedFromEnvironment(); - if (command === "create" && markerPath) { - writeFileSync( - markerPath, - `${JSON.stringify(createRuntimeDispatchMarker(expected), null, 2)}\n` - ); - return; +function githubClient(): DispatchLedgerClient { + const apiUrl = requiredEnvironment("GITHUB_API_URL"); + const repository = requiredEnvironment("GITHUB_REPOSITORY"); + const token = requiredEnvironment("GH_TOKEN"); + const temporaryDirectory = requiredEnvironment("RUNNER_TEMP"); + + async function request(path: string): Promise { + const response = await fetch(`${apiUrl}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) { + throw new Error(`GitHub API request failed (${response.status}): ${path}`); + } + return response; } - if (command === "validate" && markerPath && artifactPath && runPath) { - const marker = JSON.parse(readFileSync(markerPath, "utf8")) as RuntimeDispatchMarker; - const artifact = JSON.parse(readFileSync(artifactPath, "utf8")) as ArtifactApiResponse; - const run = JSON.parse(readFileSync(runPath, "utf8")) as WorkflowRunApiResponse; - const role = validateRuntimeDispatchMarker(marker, artifact, run, expected); - if (process.env.GITHUB_OUTPUT) { - writeFileSync( - process.env.GITHUB_OUTPUT, - `role=${role}\ncanonical_run_id=${marker.canonicalRunId}\n`, - { - flag: "a", - } + + return { + async listArtifacts(markerName) { + const response = await request( + `/repos/${repository}/actions/artifacts?name=${encodeURIComponent(markerName)}&per_page=100` ); - } else { - console.log(role); - } - return; + return ((await response.json()) as { artifacts: ArtifactApiResponse[] }).artifacts; + }, + async listWorkflowRuns() { + const response = await request( + `/repos/${repository}/actions/workflows/runtime-sdk.yml/runs?event=workflow_dispatch&per_page=100` + ); + return ((await response.json()) as { workflow_runs: WorkflowRunApiResponse[] }) + .workflow_runs; + }, + async downloadMarker(artifactId) { + const zipPath = join(temporaryDirectory, "dispatch-marker.zip"); + const markerDirectory = join(temporaryDirectory, "dispatch-marker"); + rmSync(markerDirectory, { force: true, recursive: true }); + mkdirSync(markerDirectory, { recursive: true }); + const response = await request( + `/repos/${repository}/actions/artifacts/${artifactId}/zip` + ); + writeFileSync(zipPath, Buffer.from(await response.arrayBuffer())); + execFileSync("unzip", ["-q", zipPath, "-d", markerDirectory]); + return JSON.parse( + readFileSync(join(markerDirectory, "marker.json"), "utf8") + ) as RuntimeDispatchMarker; + }, + async getWorkflowRun(runId) { + const response = await request(`/repos/${repository}/actions/runs/${runId}`); + return (await response.json()) as WorkflowRunApiResponse; + }, + }; +} + +async function main(): Promise { + const [command, markerPath] = process.argv.slice(2); + if (command !== "claim" || !markerPath) { + throw new Error("Usage: runtime-dispatch-ledger.ts claim "); + } + const claim = await claimRuntimeDispatch(expectedFromEnvironment(), githubClient(), { + onWait: (attempt, attempts) => + console.log( + `An earlier matching run is visible; waiting for its marker (attempt ${attempt}/${attempts}).` + ), + }); + if (claim.created) { + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, `${JSON.stringify(claim.marker, null, 2)}\n`); + } + const output = `role=${claim.role}\ncanonical_run_id=${claim.canonicalRunId}\ncreated=${claim.created}\n`; + if (process.env.GITHUB_OUTPUT) { + writeFileSync(process.env.GITHUB_OUTPUT, output, { flag: "a" }); + } else { + process.stdout.write(output); } - throw new Error( - "Usage: runtime-dispatch-ledger.ts create | validate " - ); } -const scriptPath = process.argv[1] - ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) - : false; -if (scriptPath) { +async function runMain(): Promise { try { - main(); + await main(); } catch (error) { console.error(`::error::${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } } + +const scriptPath = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (scriptPath) { + void runMain(); +} diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 06d431d7f6..801e1713a3 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -4,10 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + assertPackageSetVersionAbsent, assertPublishedIntegrity, assertVersionAbsent, publishManifest, publishTarball, + sdkPackageNames, } from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; @@ -47,6 +49,19 @@ describe("npm release preflight", () => { "Could not read" ); }); + + it("checks the complete nine-package SDK set", async () => { + const runner = vi + .fn() + .mockResolvedValue(result(1, JSON.stringify({ error: { code: "E404" } }))); + await expect( + assertPackageSetVersionAbsent(version, registry, runner) + ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(9); + expect( + runner.mock.calls.map(([, args]) => args[1].slice(0, args[1].lastIndexOf("@"))) + ).toEqual(sdkPackageNames); + }); }); describe("npm release publishing", () => { diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 51853ae59b..4499ce5390 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -7,6 +7,10 @@ const workflow = (name: string) => readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); const publish = workflow("publish.yml"); const runtimeSdk = workflow("runtime-sdk.yml"); +const runtimeDispatchLedger = readFileSync( + join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts"), + "utf8" +); describe("normal publishing workflow contract", () => { it("remains the stable and prerelease entry without runtime handoff inputs", () => { @@ -50,23 +54,26 @@ describe("runtime-driven Node SDK entry contract", () => { it("owns both strict runtime handoff matrices", () => { expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); - expect(runtimeSdk).toContain("canary:azure:tests-only"); - expect(runtimeSdk).toContain("canary:azure:internal"); - expect(runtimeSdk).toContain("unstable:github-packages:internal"); expect(runtimeSdk).toContain("runtime_run_id:"); expect(runtimeSdk).toContain("runtime_source:"); + expect(runtimeDispatchLedger).toContain('expected.channel === "canary"'); + expect(runtimeDispatchLedger).toContain('expected.runtimeSource === "azure"'); + expect(runtimeDispatchLedger).toMatch( + /expected\.runtimeSource === "github-packages"\s+&&\s+expected\.mode === "internal"/ + ); }); it("serializes and durably claims each runtime run", () => { expect(runtimeSdk).toContain("group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); expect(runtimeSdk).toContain("cancel-in-progress: false"); expect(runtimeSdk).toContain("sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); - expect(runtimeSdk).toContain("More than one unexpired"); - expect(runtimeSdk).toContain("for ATTEMPT in 1 2 3 4 5 6"); - expect(runtimeSdk).toContain("actions/workflows/runtime-sdk.yml/runs"); - expect(runtimeSdk).toContain('if [ "$EARLIER" -eq 0 ]; then'); - expect(runtimeSdk).not.toContain('GITHUB_RUN_ATTEMPT" -gt 1'); - expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts validate"); + expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts claim"); + expect(runtimeSdk).toContain("steps.claim.outputs.created == 'true'"); + expect(runtimeSdk).not.toContain("actions/artifacts"); + expect(runtimeSdk).not.toContain("actions/workflows/runtime-sdk.yml/runs"); + expect(runtimeDispatchLedger).toContain("More than one unexpired"); + expect(runtimeDispatchLedger).toContain("attempts ?? 6"); + expect(runtimeDispatchLedger).toContain("actions/workflows/runtime-sdk.yml/runs"); expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); expect(runtimeSdk).toContain("retention-days: 90"); expect(runtimeSdk).not.toContain("resume_run_id"); @@ -91,9 +98,11 @@ describe("runtime-driven Node SDK entry contract", () => { describe("runtime-backed Node release implementation", () => { it("enforces the channel, source, and mode matrix", () => { - expect(runtimeSdk).toContain("canary:azure:tests-only"); - expect(runtimeSdk).toContain("canary:azure:internal"); - expect(runtimeSdk).toContain("unstable:github-packages:internal"); + expect(runtimeDispatchLedger).toContain('expected.mode === "tests-only"'); + expect(runtimeDispatchLedger).toContain('expected.mode === "internal"'); + expect(runtimeDispatchLedger).toContain( + "Invalid channel, runtime source, or mode combination" + ); }); it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { @@ -101,6 +110,8 @@ describe("runtime-backed Node release implementation", () => { expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); expect(runtimeSdk).toContain("npm run verify:release-packages"); expect(runtimeSdk).toContain("publish-manifest"); + expect(runtimeSdk.match(/preflight-package-set/g)).toHaveLength(2); + expect(runtimeSdk).not.toContain("for PACKAGE in"); expect(runtimeSdk).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); expect(runtimeSdk).not.toContain('"$runtime_path" --version'); expect(runtimeSdk).not.toContain('"$RUNTIME" --version'); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index c26357e445..4e9e9d75f1 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + claimRuntimeDispatch, createRuntimeDispatchMarker, + type DispatchLedgerClient, type ExpectedDispatch, validateRuntimeDispatchMarker, } from "../scripts/runtime-dispatch-ledger.js"; @@ -20,8 +22,14 @@ const expected: ExpectedDispatch = { function provenance(canonicalRunId: string) { return { - artifact: { expired: false, workflow_run: { id: Number(canonicalRunId) } }, + artifact: { + expired: false, + id: 10, + name: "sdk-runtime-dispatch-100", + workflow_run: { id: Number(canonicalRunId) }, + }, run: { + display_title: "Runtime-driven SDK from runtime run 100", event: "workflow_dispatch", head_branch: "main", head_sha: expected.sdkSha, @@ -29,10 +37,23 @@ function provenance(canonicalRunId: string) { name: "Runtime-driven Node SDK", path: ".github/workflows/runtime-sdk.yml", repository: { full_name: "github/copilot-sdk" }, + status: "completed", }, }; } +function client(overrides: Partial = {}): DispatchLedgerClient { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + return { + downloadMarker: async () => marker, + getWorkflowRun: async () => api.run, + listArtifacts: async () => [api.artifact], + listWorkflowRuns: async () => [], + ...overrides, + }; +} + describe("runtime dispatch ledger", () => { it("creates a canonical marker without adding the runtime run to release identity", () => { const marker = createRuntimeDispatchMarker(expected); @@ -57,6 +78,97 @@ describe("runtime dispatch ledger", () => { ); }); + it("orchestrates exact duplicates and canonical reruns without creating another marker", async () => { + await expect(claimRuntimeDispatch(expected, client())).resolves.toMatchObject({ + canonicalRunId: "199", + created: false, + role: "duplicate", + }); + + const marker = createRuntimeDispatchMarker(expected); + const api = provenance("200"); + await expect( + claimRuntimeDispatch( + expected, + client({ + downloadMarker: async () => marker, + getWorkflowRun: async () => api.run, + listArtifacts: async () => [api.artifact], + }) + ) + ).resolves.toMatchObject({ + canonicalRunId: "200", + created: false, + role: "owner", + }); + }); + + it("rejects multiple exact markers", async () => { + const api = provenance("199"); + await expect( + claimRuntimeDispatch( + expected, + client({ listArtifacts: async () => [api.artifact, { ...api.artifact, id: 11 }] }) + ) + ).rejects.toThrow("More than one unexpired"); + }); + + it("allows a markerless rerun of the same workflow run to claim", async () => { + const api = provenance("200"); + await expect( + claimRuntimeDispatch( + expected, + client({ + listArtifacts: async () => [], + listWorkflowRuns: async () => [api.run], + }) + ) + ).resolves.toMatchObject({ + canonicalRunId: "200", + created: true, + role: "owner", + }); + }); + + it("retries while an earlier matching run is still initializing", async () => { + const api = provenance("199"); + const delay = vi.fn(async () => undefined); + await expect( + claimRuntimeDispatch( + expected, + client({ + listArtifacts: async () => [], + listWorkflowRuns: async () => [{ ...api.run, status: "in_progress" }], + }), + { attempts: 2, delay } + ) + ).rejects.toThrow("still initializing"); + expect(delay).toHaveBeenCalledTimes(1); + }); + + it("resolves a marker that becomes visible during the bounded retry", async () => { + const api = provenance("199"); + const listArtifacts = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([api.artifact]); + await expect( + claimRuntimeDispatch( + expected, + client({ + listArtifacts, + listWorkflowRuns: async () => [api.run], + }), + { attempts: 2, delay: async () => undefined } + ) + ).resolves.toMatchObject({ + canonicalRunId: "199", + created: false, + role: "duplicate", + }); + expect(listArtifacts).toHaveBeenCalledTimes(2); + }); + it("rejects marker tuple collisions and forged API provenance", () => { const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); const api = provenance("199"); @@ -83,4 +195,13 @@ describe("runtime dispatch ledger", () => { ) ).toThrow(); }); + + it("rejects unknown channels at the extracted entry boundary", () => { + expect(() => + createRuntimeDispatchMarker({ + ...expected, + channel: "invalid" as ExpectedDispatch["channel"], + }) + ).toThrow("Invalid channel"); + }); }); From 0c5da6bbb1abf12fbab5628c5d624477abd6bc47 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 08:52:47 -0700 Subject: [PATCH 09/23] Harden runtime SDK release queueing Preserve every serialized release job and reject non-canonical dispatch identities before claiming the runtime ledger key. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 3 +++ nodejs/scripts/runtime-dispatch-ledger.ts | 26 +++++++++++++++++---- nodejs/test/release-workflows.test.ts | 3 +++ nodejs/test/runtime-dispatch-ledger.test.ts | 12 ++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 4b96dd9241..f9440290fb 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -58,6 +58,7 @@ jobs: concurrency: group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} cancel-in-progress: false + queue: max permissions: actions: read contents: read @@ -390,6 +391,7 @@ jobs: concurrency: group: sdk-runtime-internal-${{ inputs.channel }} cancel-in-progress: false + queue: max environment: cicd permissions: actions: read @@ -460,6 +462,7 @@ jobs: concurrency: group: sdk-runtime-public-unstable cancel-in-progress: false + queue: max permissions: actions: read contents: read diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index 6747a0e8d3..ea0e53c984 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -83,12 +83,24 @@ export interface ClaimOptions { const workflowPath = ".github/workflows/runtime-sdk.yml"; const workflowName = "Runtime-driven Node SDK"; +const canonicalNumericIdPattern = /^(0|[1-9][0-9]*)$/; const runtimeVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; function validateInputs(expected: ExpectedDispatch): void { - assert.match(expected.currentRunId, /^[0-9]+$/, "Current workflow run ID must be numeric"); - assert.match(expected.runtimeRunId, /^[0-9]+$/, "Runtime workflow run ID must be numeric"); + for (const [name, value] of Object.entries(expected)) { + assert.equal(value, value.trim(), `${name} must not contain surrounding whitespace`); + } + assert.match( + expected.currentRunId, + canonicalNumericIdPattern, + "Current workflow run ID must be canonical numeric" + ); + assert.match( + expected.runtimeRunId, + canonicalNumericIdPattern, + "Runtime workflow run ID must be canonical numeric" + ); assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); assert.match( expected.runtimeVersion, @@ -145,7 +157,11 @@ export function validateRuntimeDispatchMarker( ): DispatchRole { validateInputs(expected); assert.equal(marker.schemaVersion, 1, "Unsupported dispatch marker schema"); - assert.match(marker.canonicalRunId, /^[0-9]+$/, "Canonical workflow run ID must be numeric"); + assert.match( + marker.canonicalRunId, + canonicalNumericIdPattern, + "Canonical workflow run ID must be canonical numeric" + ); assert.equal(artifact.expired, false, "Dispatch marker artifact is expired"); assert.equal( String(artifact.workflow_run?.id), @@ -260,7 +276,7 @@ export async function claimRuntimeDispatch( } function requiredEnvironment(name: string): string { - const value = process.env[name]?.trim(); + const value = process.env[name]; if (!value) { throw new Error(`${name} is required.`); } @@ -278,7 +294,7 @@ function expectedFromEnvironment(): ExpectedDispatch { runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), sdkRef: requiredEnvironment("SDK_REF"), sdkSha: requiredEnvironment("SDK_SHA"), - versionOverride: process.env.VERSION_OVERRIDE?.trim() ?? "", + versionOverride: process.env.VERSION_OVERRIDE ?? "", }; } diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 4499ce5390..950c0cacdb 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -66,6 +66,7 @@ describe("runtime-driven Node SDK entry contract", () => { it("serializes and durably claims each runtime run", () => { expect(runtimeSdk).toContain("group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); expect(runtimeSdk).toContain("cancel-in-progress: false"); + expect(runtimeSdk.match(/queue: max/g)).toHaveLength(3); expect(runtimeSdk).toContain("sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts claim"); expect(runtimeSdk).toContain("steps.claim.outputs.created == 'true'"); @@ -74,6 +75,8 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeDispatchLedger).toContain("More than one unexpired"); expect(runtimeDispatchLedger).toContain("attempts ?? 6"); expect(runtimeDispatchLedger).toContain("actions/workflows/runtime-sdk.yml/runs"); + expect(runtimeDispatchLedger).toContain("canonicalNumericIdPattern"); + expect(runtimeDispatchLedger).not.toContain("process.env[name]?.trim()"); expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); expect(runtimeSdk).toContain("retention-days: 90"); expect(runtimeSdk).not.toContain("resume_run_id"); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index 4e9e9d75f1..0905437c8f 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -204,4 +204,16 @@ describe("runtime dispatch ledger", () => { }) ).toThrow("Invalid channel"); }); + + it("rejects non-canonical raw identity values", () => { + for (const changed of [ + { runtimeRunId: "0100" }, + { currentRunId: "0200" }, + { runtimeVersion: " 1.2.3-unstable.4" }, + { sdkRef: "refs/heads/main " }, + { versionOverride: " 1.2.3-unstable.4" }, + ]) { + expect(() => createRuntimeDispatchMarker({ ...expected, ...changed })).toThrow(); + } + }); }); From 4063d71969be96096441933fa058e88e29726b4e Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 09:12:19 -0700 Subject: [PATCH 10/23] Validate runtime release inputs Reject zero workflow IDs and require the complete runtime acquisition CLI contract before resolving or modifying output paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- nodejs/scripts/runtime-dispatch-ledger.ts | 2 +- nodejs/scripts/runtime-package-acquisition.ts | 37 ++++++++++++++----- nodejs/test/runtime-dispatch-ledger.test.ts | 13 +++++++ .../test/runtime-package-acquisition.test.ts | 29 +++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index ea0e53c984..41b4b9f111 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -83,7 +83,7 @@ export interface ClaimOptions { const workflowPath = ".github/workflows/runtime-sdk.yml"; const workflowName = "Runtime-driven Node SDK"; -const canonicalNumericIdPattern = /^(0|[1-9][0-9]*)$/; +const canonicalNumericIdPattern = /^[1-9][0-9]*$/; const runtimeVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; diff --git a/nodejs/scripts/runtime-package-acquisition.ts b/nodejs/scripts/runtime-package-acquisition.ts index 5521f54a46..5f46099a63 100644 --- a/nodejs/scripts/runtime-package-acquisition.ts +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -137,6 +137,10 @@ export async function acquireRuntimePackages( ): Promise { assert.match(options.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); assert.match(options.registry, /^https:\/\//, "Runtime registry must use HTTPS"); + assert( + options.outputDirectory.trim().length > 0, + "Runtime package output directory is required" + ); const outputDirectory = resolve(options.outputDirectory); const tarballDirectory = join(outputDirectory, "tarballs"); mkdirSync(tarballDirectory, { recursive: true }); @@ -236,23 +240,38 @@ export async function acquireRuntimePackages( ); } -function parseArguments(args: string[]): AcquireRuntimePackagesOptions { +export function parseArguments(args: string[]): AcquireRuntimePackagesOptions { + const optionNames = new Set(["--version", "--sha", "--registry", "--output"]); const values = new Map(); + if (args.length !== optionNames.size * 2) { + throw new Error( + "Usage: runtime-package-acquisition.ts --version --sha --registry --output " + ); + } for (let index = 0; index < args.length; index += 2) { const key = args[index]; const value = args[index + 1]; - if (!key?.startsWith("--") || !value) { - throw new Error( - "Usage: runtime-package-acquisition.ts --version --sha --registry --output " - ); + if (!key || !optionNames.has(key)) { + throw new Error(`Unknown runtime package acquisition option: ${key ?? ""}`); + } + if (values.has(key)) { + throw new Error(`Duplicate runtime package acquisition option: ${key}`); + } + if (!value || value.trim().length === 0 || value.startsWith("--")) { + throw new Error(`Runtime package acquisition option ${key} requires a non-empty value`); } values.set(key, value); } + const requiredValue = (key: string): string => { + const value = values.get(key); + assert(value !== undefined, `Missing runtime package acquisition option: ${key}`); + return value; + }; return { - runtimeVersion: values.get("--version") ?? "", - runtimeSha: values.get("--sha") ?? "", - registry: values.get("--registry") ?? "", - outputDirectory: values.get("--output") ?? "", + runtimeVersion: requiredValue("--version"), + runtimeSha: requiredValue("--sha"), + registry: requiredValue("--registry"), + outputDirectory: requiredValue("--output"), }; } diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index 0905437c8f..47a2405fde 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -207,7 +207,9 @@ describe("runtime dispatch ledger", () => { it("rejects non-canonical raw identity values", () => { for (const changed of [ + { runtimeRunId: "0" }, { runtimeRunId: "0100" }, + { currentRunId: "0" }, { currentRunId: "0200" }, { runtimeVersion: " 1.2.3-unstable.4" }, { sdkRef: "refs/heads/main " }, @@ -216,4 +218,15 @@ describe("runtime dispatch ledger", () => { expect(() => createRuntimeDispatchMarker({ ...expected, ...changed })).toThrow(); } }); + + it("rejects a zero canonical run ID in an existing marker", () => { + const marker = { + ...createRuntimeDispatchMarker(expected), + canonicalRunId: "0", + }; + const api = provenance("0"); + expect(() => + validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected) + ).toThrow("Canonical workflow run ID must be canonical numeric"); + }); }); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts index d2a08a8f49..e06a65a6f6 100644 --- a/nodejs/test/runtime-package-acquisition.test.ts +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { acquireRuntimePackages, getSourceRuntimePackageName, + parseArguments, validateRuntimePackageRoot, } from "../scripts/runtime-package-acquisition.js"; import { RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; @@ -69,6 +70,34 @@ async function createRuntimePackage(root: string, platform: string): Promise { + it("requires exactly one non-empty value for every CLI option", () => { + const valid = [ + "--version", + runtimeVersion, + "--sha", + runtimeSha, + "--registry", + "https://npm.pkg.github.com", + "--output", + "runtime-packages", + ]; + expect(parseArguments(valid)).toEqual({ + outputDirectory: "runtime-packages", + registry: "https://npm.pkg.github.com", + runtimeSha, + runtimeVersion, + }); + for (const invalid of [ + valid.slice(0, -2), + [...valid.slice(0, -2), "--outpt", "runtime-packages"], + [...valid.slice(0, -2), "--sha", runtimeSha], + [...valid.slice(0, -1), ""], + [...valid.slice(0, -1), "--unknown"], + ]) { + expect(() => parseArguments(invalid)).toThrow(); + } + }); + it("downloads and validates all eight exact runtime platform packages", async () => { const root = temporaryRoot("copilot-runtime-acquisition-"); const output = join(root, "output"); From 0553aeabf3593c429212c1ff6e9a3e5a876b02ba Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 11:07:42 -0700 Subject: [PATCH 11/23] Freeze canary SDK release baseline Derive canary versions from stable GitHub releases published by the canonical workflow creation time so reruns retain the same release identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 24 ++------ nodejs/scripts/unstable-version.ts | 82 +++++++++++++++++++++------ nodejs/test/release-workflows.test.ts | 7 ++- nodejs/test/unstable-version.test.ts | 33 ++++++++++- 4 files changed, 109 insertions(+), 37 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index f9440290fb..bfcefc1384 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -149,30 +149,18 @@ jobs: env: CHANNEL: ${{ inputs.channel }} GH_TOKEN: ${{ github.token }} + SDK_CHANNEL: ${{ inputs.channel }} SDK_SHA: ${{ github.sha }} SDK_VERSION_OVERRIDE: ${{ inputs.version }} WORKFLOW_RUN_NUMBER: ${{ github.run_number }} run: | set -euo pipefail WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - if [ "$CHANNEL" = "canary" ]; then - PUBLIC_LATEST="$(node scripts/get-version.js current)" - BASE="$(node -e ' - const semver = require("semver"); - const parsed = semver.parse(process.argv[1]); - if (!parsed) process.exit(1); - process.stdout.write(`${parsed.major}.${parsed.minor}.${parsed.patch}`); - ' "$PUBLIC_LATEST")" || - { echo "::error::Current public SDK version is not valid SemVer: $PUBLIC_LATEST"; exit 1; } - IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" - SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" - else - gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" - export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" - export WORKFLOW_CREATED_AT - SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" - fi + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" npm exec -- semver "$SDK_VERSION" >/dev/null ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" { diff --git a/nodejs/scripts/unstable-version.ts b/nodejs/scripts/unstable-version.ts index c8905ebef8..47501335d9 100644 --- a/nodejs/scripts/unstable-version.ts +++ b/nodejs/scripts/unstable-version.ts @@ -6,6 +6,7 @@ import * as semver from "semver"; export interface ReleaseRecord { draft?: boolean; + prerelease?: boolean; published_at: string | null; tag_name: string; } @@ -19,6 +20,13 @@ export interface UnstableVersionOptions { versionOverride?: string; } +export interface CanaryVersionOptions { + createdAt: string; + releases: ReleaseRecord[]; + runNumber: string; + sdkSha: string; +} + function canonicalVersion(tag: string): string | undefined { if (!tag.startsWith("v")) { return undefined; @@ -38,17 +46,54 @@ export function targetCoreFromBaseline(baseline: string): string { return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; } -export function calculateUnstableVersion(options: UnstableVersionOptions): string { - if (!/^[0-9]+$/.test(options.runNumber)) { - throw new Error(`Invalid workflow run number: ${options.runNumber}`); +function validateReleaseIdentity(createdAt: string, runNumber: string, sdkSha: string): number { + if (!/^[0-9]+$/.test(runNumber)) { + throw new Error(`Invalid workflow run number: ${runNumber}`); + } + if (!/^[0-9a-f]{40}$/i.test(sdkSha)) { + throw new Error(`Invalid full SDK SHA: ${sdkSha}`); } - if (!/^[0-9a-f]{40}$/i.test(options.sdkSha)) { - throw new Error(`Invalid full SDK SHA: ${options.sdkSha}`); + const createdAtTime = Date.parse(createdAt); + if (!Number.isFinite(createdAtTime)) { + throw new Error(`Invalid workflow creation time: ${createdAt}`); } - const createdAt = Date.parse(options.createdAt); - if (!Number.isFinite(createdAt)) { - throw new Error(`Invalid workflow creation time: ${options.createdAt}`); + return createdAtTime; +} + +export function calculateCanaryVersion(options: CanaryVersionOptions): string { + const createdAt = validateReleaseIdentity(options.createdAt, options.runNumber, options.sdkSha); + const baseline = options.releases + .filter((release) => { + if (release.draft || release.prerelease || release.published_at === null) { + return false; + } + const version = canonicalVersion(release.tag_name); + return ( + version !== undefined && + semver.prerelease(version) === null && + Date.parse(release.published_at) <= createdAt + ); + }) + .sort((left, right) => { + const publishedDifference = + Date.parse(right.published_at!) - Date.parse(left.published_at!); + if (publishedDifference !== 0) { + return publishedDifference; + } + return semver.rcompare( + canonicalVersion(left.tag_name)!, + canonicalVersion(right.tag_name)! + ); + }) + .map((release) => canonicalVersion(release.tag_name)!)[0]; + if (!baseline) { + throw new Error("No stable SDK release was published before this workflow run."); } + return `${targetCoreFromBaseline(baseline)}-canary.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; +} + +export function calculateUnstableVersion(options: UnstableVersionOptions): string { + const createdAt = validateReleaseIdentity(options.createdAt, options.runNumber, options.sdkSha); if (options.versionOverride) { const parsed = semver.parse(options.versionOverride); @@ -125,13 +170,18 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1 const releasesPath = requireEnvironment("SDK_RELEASES_FILE"); const releases = JSON.parse(readFileSync(releasesPath, "utf8")) as ReleaseRecord[]; const sdkSha = requireEnvironment("SDK_SHA"); - const version = calculateUnstableVersion({ - createdAt: requireEnvironment("WORKFLOW_CREATED_AT"), - firstParentTags: getFirstParentTags(sdkSha), - releases, - runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER"), - sdkSha, - versionOverride: process.env.SDK_VERSION_OVERRIDE?.trim() || undefined, - }); + const createdAt = requireEnvironment("WORKFLOW_CREATED_AT"); + const runNumber = requireEnvironment("WORKFLOW_RUN_NUMBER"); + const version = + requireEnvironment("SDK_CHANNEL") === "canary" + ? calculateCanaryVersion({ createdAt, releases, runNumber, sdkSha }) + : calculateUnstableVersion({ + createdAt, + firstParentTags: getFirstParentTags(sdkSha), + releases, + runNumber, + sdkSha, + versionOverride: process.env.SDK_VERSION_OVERRIDE?.trim() || undefined, + }); process.stdout.write(`${version}\n`); } diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 950c0cacdb..df8a74692f 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -119,8 +119,11 @@ describe("runtime-backed Node release implementation", () => { expect(runtimeSdk).not.toContain('"$runtime_path" --version'); expect(runtimeSdk).not.toContain('"$RUNTIME" --version'); expect(runtimeSdk).not.toContain("resume_run_id"); - expect(runtimeSdk).toContain("const parsed = semver.parse(process.argv[1])"); - expect(runtimeSdk).toContain("parsed.major}.${parsed.minor}.${parsed.patch"); + expect(runtimeSdk).toContain("SDK_CHANNEL: ${{ inputs.channel }}"); + expect(runtimeSdk).not.toContain("scripts/get-version.js current"); + expect(runtimeSdk.indexOf("WORKFLOW_CREATED_AT=")).toBeLessThan( + runtimeSdk.indexOf("scripts/unstable-version.ts") + ); expect(runtimeSdk).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); expect(runtimeSdk.indexOf("npm run verify:release-packages")).toBeLessThan( runtimeSdk.indexOf("publish-manifest") diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts index d23f963c4a..576b760b59 100644 --- a/nodejs/test/unstable-version.test.ts +++ b/nodejs/test/unstable-version.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { calculateUnstableVersion, targetCoreFromBaseline } from "../scripts/unstable-version.js"; +import { + calculateCanaryVersion, + calculateUnstableVersion, + targetCoreFromBaseline, +} from "../scripts/unstable-version.js"; const sha = "abcdef0123456789abcdef0123456789abcdef01"; const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ @@ -65,3 +69,30 @@ describe("unstable SDK version planning", () => { ).toThrow("unstable prerelease"); }); }); + +describe("canary SDK version planning", () => { + it("freezes the stable baseline at workflow creation time", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + releases: [ + release("v1.0.11", "2026-09-01T00:00:00Z"), + release("v1.0.12", "2026-09-05T00:00:00Z"), + release("v1.0.13-preview.1", "2026-09-03T00:00:00Z"), + { + ...release("v2.0.0", "2026-09-02T00:00:00Z"), + prerelease: true, + }, + ], + runNumber: "8123", + sdkSha: sha, + }; + const planned = calculateCanaryVersion(options); + expect(planned).toBe("1.0.12-canary.8123.gabcdef0"); + expect( + calculateCanaryVersion({ + ...options, + releases: [...options.releases, release("v1.0.13", "2026-09-06T00:00:00Z")], + }) + ).toBe(planned); + }); +}); From e20d0b0c74d55d66dc28a43c5874e973f65616ed Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 9 Sep 2026 16:37:14 -0700 Subject: [PATCH 12/23] Use GitHub Packages for runtime inputs Acquire both runtime-driven channels from GitHub Packages while retaining Azure for internal SDK outputs and restoring production conflict-based package publication semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 32 +---- docs/developer-docs/unstable-releases.md | 42 ++++--- nodejs/scripts/npm-release.js | 88 +++----------- nodejs/scripts/release-manifest.ts | 14 +-- nodejs/scripts/runtime-dispatch-ledger.ts | 17 +-- nodejs/scripts/runtime-package-acquisition.ts | 6 +- nodejs/test/npm-release.test.ts | 111 +++++++++--------- nodejs/test/release-manifest.test.ts | 2 +- nodejs/test/release-workflows.test.ts | 32 +++-- nodejs/test/runtime-dispatch-ledger.test.ts | 2 +- .../test/runtime-package-acquisition.test.ts | 31 +++++ 11 files changed, 164 insertions(+), 213 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index bfcefc1384..136e2bb028 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -19,13 +19,6 @@ on: description: "Full github/copilot-agent-runtime source SHA" required: true type: string - runtime_source: - description: "Runtime package registry" - required: true - type: choice - options: - - azure - - github-packages runtime_run_id: description: "Source runtime workflow run ID and idempotency key" required: true @@ -86,7 +79,6 @@ jobs: MODE: ${{ inputs.mode }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} RUNTIME_VERSION: ${{ inputs.runtime_version }} SDK_REF: ${{ github.ref }} SDK_SHA: ${{ github.sha }} @@ -206,7 +198,6 @@ jobs: environment: cicd permissions: contents: read - id-token: write packages: read defaults: run: @@ -220,38 +211,20 @@ jobs: cache-dependency-path: ./nodejs/package-lock.json node-version: 22 - run: npm ci --ignore-scripts - - name: Azure login - if: inputs.runtime_source == 'azure' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - if: inputs.runtime_source == 'azure' - run: | - 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: Configure authentication-only GitHub Packages access - if: inputs.runtime_source == 'github-packages' env: NODE_AUTH_TOKEN: ${{ github.token }} run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" - name: Download and validate all runtime platforms env: - REGISTRY: ${{ inputs.runtime_source == 'azure' && env.FEED_URL || 'https://npm.pkg.github.com' }} + NODE_AUTH_TOKEN: ${{ github.token }} RUNTIME_SHA: ${{ inputs.runtime_sha }} RUNTIME_VERSION: ${{ inputs.runtime_version }} run: | npm run acquire:runtime-packages -- \ --version "$RUNTIME_VERSION" \ --sha "$RUNTIME_SHA" \ - --registry "$REGISTRY" \ + --registry https://npm.pkg.github.com \ --output "$RUNNER_TEMP/runtime-packages" - uses: actions/upload-artifact@v7.0.0 with: @@ -346,7 +319,6 @@ jobs: RELEASE_CHANNEL: ${{ inputs.channel }} RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_SOURCE: ${{ inputs.runtime_source }} RUNTIME_VERSION: ${{ inputs.runtime_version }} SDK_REF: ${{ github.ref }} SDK_SHA: ${{ github.sha }} diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 4cba95811c..6b56930e02 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -19,12 +19,11 @@ optional internal publication, and public unstable npm publication. The runtime dispatch includes these inputs: -- `channel`: `canary` or `unstable` -- `runtime_version`: Exact runtime package version -- `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -- `runtime_source`: `azure` for canary or `github-packages` for unstable -- `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key -- `mode`: `tests-only` or `internal` for canary; `internal` for unstable +* `channel`: `canary` or `unstable` +* `runtime_version`: Exact runtime package version +* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +* `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key +* `mode`: `tests-only` or `internal` for canary; `internal` for unstable Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The optional `version` input is available only for unstable and must be an unstable @@ -32,11 +31,10 @@ SemVer. Do not reuse an explicit version after an artifact has been built. ## Release gates -Both channels acquire all eight `@github/copilot-` packages with an -explicit registry argument. The workflows validate npm integrity, runtime -version and SHA metadata, platform metadata, repository metadata, and required -runtime files. Authentication configuration does not map the entire `@github` -scope to GitHub Packages. +Both channels acquire all eight `@github/copilot-` packages from +GitHub Packages with the job-scoped `GITHUB_TOKEN`. The workflows validate npm +integrity, runtime version and SHA metadata, the exact package set, platform +metadata, repository metadata, and required runtime files. The workflows run runtime-backed Node SDK tests on Ubuntu, macOS, and Windows. They then build and verify eight self-contained @@ -65,15 +63,16 @@ and package validation gates verify the embedded runtime identity. The public job uses npm trusted publishing from `runtime-sdk.yml` and publishes the same tarballs under the `unstable` dist-tag, with the umbrella package last. -Before either publication, the workflow checks all nine package coordinates. -An existing package counts as complete only when registry integrity matches -the retained manifest. A mismatch fails the release. After all package -contents are present, the workflow updates the channel dist-tag. +The workflow validates all nine retained tarballs against the local +`release-manifest.json` SHA-512 values before publication. A successful +`npm publish` completes a package publication. A recognized immutable-version +conflict means the package was already published and also completes that +package publication; output feeds do not need to expose `dist.integrity`. Azure authentication allows the workflow to add or advance its tag, but it refuses to rewind a tag that points to a newer version. Public npm trusted -publishing sets `unstable` as each missing package is published. The workflow -then verifies all nine `@unstable` resolutions. It fails rather than attempting -a separate public dist-tag mutation if any resolution differs. +publishing sets `unstable` during publication. The workflow then verifies all +nine `@unstable` resolutions. It fails rather than attempting a separate +public dist-tag mutation if any resolution differs. ## Recovery @@ -94,10 +93,9 @@ The Azure `copilot-canary` feed continues to use the `cicd` environment and Azure workload identity. GitHub Packages acquisition uses the workflow `GITHUB_TOKEN` with `packages: read`. -Before enabling unstable dispatch, publish the eight signed runtime package -coordinates once, set each GitHub Package to public visibility, and confirm -that this repository can read all eight with its workflow token. Public -visibility does not remove GitHub Packages npm authentication. +Before enabling runtime dispatch, publish the eight signed runtime package +coordinates to GitHub Packages and confirm that this repository can read all +eight with its workflow token. Confirm npm trusted publisher configuration authorizes both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index a48f9fd2e3..3adce9ded9 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -15,6 +15,10 @@ export const sdkPackageNames = [ "@github/copilot-sdk-win32-arm64", "@github/copilot-sdk-win32-x64", ]; +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((resolveResult, reject) => { @@ -46,11 +50,11 @@ function parseNpmJson(result) { return undefined; } -export async function getRegistryIntegrity(packageName, version, registry, runner = runCommand) { +export async function getRegistryVersion(packageName, version, registry, runner = runCommand) { const result = await runner("npm", [ "view", `${packageName}@${version}`, - "dist.integrity", + "version", "--json", "--registry", registry, @@ -64,7 +68,7 @@ export async function getRegistryIntegrity(packageName, version, registry, runne } const output = `${result.stdout}\n${result.stderr}`.trim(); throw new Error( - `Could not read ${packageName}@${version} integrity from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` + `Could not read ${packageName}@${version} from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` ); } @@ -91,7 +95,7 @@ export async function getRegistryTagVersion(packageName, tag, registry, runner = } export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { - const existing = await getRegistryIntegrity(packageName, version, registry, runner); + const existing = await getRegistryVersion(packageName, version, registry, runner); if (existing !== undefined) { throw new Error(`${packageName}@${version} already exists on ${registry}.`); } @@ -103,25 +107,6 @@ export async function assertPackageSetVersionAbsent(version, registry, runner = } } -export async function assertPublishedIntegrity( - packageName, - version, - expectedIntegrity, - registry, - runner = runCommand -) { - const existing = await getRegistryIntegrity(packageName, version, registry, runner); - if (existing === undefined) { - return "missing"; - } - if (existing !== expectedIntegrity) { - throw new Error( - `${packageName}@${version} on ${registry} has integrity ${existing}, expected ${expectedIntegrity}.` - ); - } - return "matching"; -} - export async function publishTarball(tarball, tag, registry, mode, identity, runner = runCommand) { if (!identity?.name || !identity?.version || !identity?.integrity) { throw new Error("Publishing requires an expected package name, version, and integrity."); @@ -131,32 +116,19 @@ export async function publishTarball(tarball, tag, registry, mode, identity, run if (mode !== "public" && mode !== "azure") throw new Error(`Unknown publish mode: ${mode}`); const result = await runner("npm", args, { stream: true }); - if (result.status !== 0) { - const state = await assertPublishedIntegrity( - identity.name, - identity.version, - identity.integrity, - registry, - runner - ); - if (state !== "matching") { - throw new Error(`npm publish failed with exit code ${result.status}.`); - } - console.log(`${identity.name}@${identity.version} already exists with matching integrity.`); + if (result.status === 0) { return; } - const state = await assertPublishedIntegrity( - identity.name, - identity.version, - identity.integrity, - registry, - runner - ); - if (state !== "matching") { - throw new Error( - `${identity.name}@${identity.version} was not readable with matching integrity after publication.` + + const output = `${result.stdout}\n${result.stderr}`; + if (PUBLIC_CONFLICT.test(output) || (mode === "azure" && AZURE_CONFLICT.test(output))) { + console.log( + `${identity.name}@${identity.version} is already published; treating the immutable-version conflict as success.` ); + return; } + + throw new Error(`npm publish failed with exit code ${result.status}.`); } function readReleaseManifest(manifestPath, packageDirectory) { @@ -225,19 +197,6 @@ export async function publishManifest( return left.name.localeCompare(right.name); }); - const states = new Map(); - for (const packed of packages) { - states.set( - packed.name, - await assertPublishedIntegrity( - packed.name, - packed.version, - packed.integrity, - registry, - runner - ) - ); - } const semver = await import("semver"); for (const packed of packages) { const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); @@ -246,20 +205,9 @@ export async function publishManifest( `${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` ); } - if ( - mode === "public" && - states.get(packed.name) === "matching" && - taggedVersion !== packed.version - ) { - throw new Error( - `${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.` - ); - } } for (const packed of packages) { - if (states.get(packed.name) === "missing") { - await publishTarball(packed.tarball, tag, registry, mode, packed, runner); - } + await publishTarball(packed.tarball, tag, registry, mode, packed, runner); } for (const packed of packages) { const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts index 9181033afa..11238441ce 100644 --- a/nodejs/scripts/release-manifest.ts +++ b/nodejs/scripts/release-manifest.ts @@ -23,7 +23,7 @@ export interface ReleaseManifest { repository: "github/copilot-agent-runtime"; runId: string; sha: string; - source: "azure" | "github-packages"; + source: "github-packages"; version: string; }; schemaVersion: 1; @@ -44,7 +44,6 @@ export interface ReleaseManifestMetadata { channel: ReleaseManifest["channel"]; createdAt: string; runtimeSha: string; - runtimeSource: ReleaseManifest["runtime"]["source"]; runtimeRunId: string; runtimeVersion: string; sdkRef: string; @@ -127,7 +126,7 @@ export async function createReleaseManifest( runtime: { version: metadata.runtimeVersion, sha: metadata.runtimeSha, - source: metadata.runtimeSource, + source: "github-packages", repository: "github/copilot-agent-runtime", runId: metadata.runtimeRunId, }, @@ -159,11 +158,7 @@ export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirector ); assert.equal(manifest.sdk.repository, "github/copilot-sdk"); assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); - assert.equal( - manifest.runtime.source, - manifest.channel === "canary" ? "azure" : "github-packages", - "Runtime source does not match the release channel" - ); + assert.equal(manifest.runtime.source, "github-packages", "Invalid runtime package source"); assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); assert.deepEqual( manifest.packages.map(({ name }) => name).sort(), @@ -203,9 +198,6 @@ async function main(): Promise { channel: requiredEnvironment("RELEASE_CHANNEL") as ReleaseManifest["channel"], createdAt: requiredEnvironment("WORKFLOW_CREATED_AT"), runtimeSha: requiredEnvironment("RUNTIME_SHA"), - runtimeSource: requiredEnvironment( - "RUNTIME_SOURCE" - ) as ReleaseManifest["runtime"]["source"], runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), sdkRef: requiredEnvironment("SDK_REF"), diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index 41b4b9f111..efd87fe539 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -13,7 +13,7 @@ export interface RuntimeDispatchMarker { repository: "github/copilot-agent-runtime"; runId: string; sha: string; - source: "azure" | "github-packages"; + source: "github-packages"; version: string; }; schemaVersion: 1; @@ -51,7 +51,6 @@ export interface ExpectedDispatch { mode: RuntimeDispatchMarker["mode"]; runtimeRunId: string; runtimeSha: string; - runtimeSource: RuntimeDispatchMarker["runtime"]["source"]; runtimeVersion: string; sdkRef: string; sdkSha: string; @@ -111,12 +110,9 @@ function validateInputs(expected: ExpectedDispatch): void { assert(expected.sdkRef.length > 0, "SDK ref is required"); assert( expected.channel === "canary" - ? expected.runtimeSource === "azure" && - (expected.mode === "tests-only" || expected.mode === "internal") - : expected.channel === "unstable" && - expected.runtimeSource === "github-packages" && - expected.mode === "internal", - "Invalid channel, runtime source, or mode combination" + ? expected.mode === "tests-only" || expected.mode === "internal" + : expected.channel === "unstable" && expected.mode === "internal", + "Invalid channel or mode combination" ); assert( expected.channel !== "canary" || expected.versionOverride === "", @@ -135,7 +131,7 @@ export function createRuntimeDispatchMarker(expected: ExpectedDispatch): Runtime repository: "github/copilot-agent-runtime", runId: expected.runtimeRunId, sha: expected.runtimeSha, - source: expected.runtimeSource, + source: "github-packages", version: expected.runtimeVersion, }, sdk: { @@ -190,7 +186,7 @@ export function validateRuntimeDispatchMarker( repository: "github/copilot-agent-runtime", runId: expected.runtimeRunId, sha: expected.runtimeSha, - source: expected.runtimeSource, + source: "github-packages", version: expected.runtimeVersion, }, sdk: { @@ -290,7 +286,6 @@ function expectedFromEnvironment(): ExpectedDispatch { mode: requiredEnvironment("MODE") as ExpectedDispatch["mode"], runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), runtimeSha: requiredEnvironment("RUNTIME_SHA"), - runtimeSource: requiredEnvironment("RUNTIME_SOURCE") as ExpectedDispatch["runtimeSource"], runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), sdkRef: requiredEnvironment("SDK_REF"), sdkSha: requiredEnvironment("SDK_SHA"), diff --git a/nodejs/scripts/runtime-package-acquisition.ts b/nodejs/scripts/runtime-package-acquisition.ts index 5f46099a63..1eb27b56e9 100644 --- a/nodejs/scripts/runtime-package-acquisition.ts +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -136,7 +136,11 @@ export async function acquireRuntimePackages( runner: CommandRunner = runCommand ): Promise { assert.match(options.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); - assert.match(options.registry, /^https:\/\//, "Runtime registry must use HTTPS"); + assert.equal( + options.registry, + "https://npm.pkg.github.com", + "Runtime packages must come from GitHub Packages" + ); assert( options.outputDirectory.trim().length > 0, "Runtime package output directory is required" diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 801e1713a3..08fda83934 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { assertPackageSetVersionAbsent, - assertPublishedIntegrity, assertVersionAbsent, publishManifest, publishTarball, @@ -29,18 +28,12 @@ describe("npm release preflight", () => { ).resolves.toBeUndefined(); }); - it("accepts an existing package only when integrity matches", async () => { - const matching = vi.fn().mockResolvedValue(result(0, JSON.stringify(integrity))); - await expect( - assertPublishedIntegrity(packageName, version, integrity, registry, matching) - ).resolves.toBe("matching"); - - const conflicting = vi - .fn() - .mockResolvedValue(result(0, JSON.stringify("sha512-conflicting"))); - await expect( - assertPublishedIntegrity(packageName, version, integrity, registry, conflicting) - ).rejects.toThrow("has integrity sha512-conflicting"); + it("rejects an existing package version without reading registry integrity", async () => { + const existing = vi.fn().mockResolvedValue(result(0, JSON.stringify(version))); + await expect(assertVersionAbsent(packageName, version, registry, existing)).rejects.toThrow( + "already exists" + ); + expect(existing.mock.calls[0][1][2]).toBe("version"); }); it("does not treat malformed or transient failures as absence", async () => { @@ -65,37 +58,41 @@ describe("npm release preflight", () => { }); describe("npm release publishing", () => { - it("verifies registry integrity after a normal publish", async () => { - const runner = vi - .fn() - .mockResolvedValueOnce(result(0)) - .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); + it("treats a successful publish as success without registry metadata", async () => { + const runner = vi.fn().mockResolvedValue(result(0)); await expect( publishTarball("package.tgz", "unstable", registry, "public", identity, runner) ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(1); }); - it("recovers a publication conflict only when registry integrity matches", async () => { - const runner = vi - .fn() - .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) - .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); + it("accepts recognized immutable-version conflicts without registry integrity", async () => { + const runner = vi.fn().mockResolvedValue(result(1, "", "npm error code EPUBLISHCONFLICT")); await expect( publishTarball("package.tgz", "unstable", registry, "public", identity, runner) ).resolves.toBeUndefined(); + + runner.mockResolvedValue( + result( + 1, + "", + "npm error 403 https://pkgs.dev.azure.com/example - The feed 'copilot-canary' already contains file 'package.tgz' in package '@github/copilot-sdk'." + ) + ); + await expect( + publishTarball("package.tgz", "canary", registry, "azure", identity, runner) + ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(2); }); - it("fails a publication conflict with different content", async () => { - const runner = vi - .fn() - .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) - .mockResolvedValueOnce(result(0, JSON.stringify("sha512-other"))); + it("rejects unrecognized publication failures", async () => { + const runner = vi.fn().mockResolvedValue(result(1, "", "npm error E500")); await expect( publishTarball("package.tgz", "unstable", registry, "public", identity, runner) - ).rejects.toThrow("sha512-other"); + ).rejects.toThrow("npm publish failed"); }); - it("preflights all packages, publishes platforms before the umbrella, and tags last", async () => { + it("validates all packages, publishes platforms before the umbrella, and tags last", async () => { const directory = mkdtempSync(join(tmpdir(), "copilot-sdk-npm-release-")); mkdirSync(directory, { recursive: true }); const packages = [ @@ -130,23 +127,7 @@ describe("npm release publishing", () => { const runner = vi.fn(async (_command: string, args: string[]) => { calls.push(args); if (args[0] === "view") { - const name = args[1].slice(0, args[1].lastIndexOf("@")); - const packed = packages.find((candidate) => candidate.name === name); - if (args[2] === "version") { - return result(0, JSON.stringify(version)); - } - return result( - calls - .filter((call) => call[0] === "publish") - .some((call) => call[1].includes(packed!.filename)) - ? 0 - : 1, - calls - .filter((call) => call[0] === "publish") - .some((call) => call[1].includes(packed!.filename)) - ? JSON.stringify(packed!.integrity) - : JSON.stringify({ error: { code: "E404" } }) - ); + return result(0, JSON.stringify(version)); } return result(0); }); @@ -157,13 +138,7 @@ describe("npm release publishing", () => { expect(publishCalls).toHaveLength(9); expect(publishCalls.at(-1)?.[1]).toContain("package-0.tgz"); expect(calls.filter((args) => args[0] === "dist-tag")).toHaveLength(0); - expect( - Math.max( - ...calls.map((args, index) => - args[0] === "view" && args[2] === "version" ? index : -1 - ) - ) - ).toBeGreaterThan(calls.map((args) => args[0]).lastIndexOf("publish")); + expect(calls.some((args) => args.includes("dist.integrity"))).toBe(false); const staleTagRunner = vi.fn(async (_command: string, args: string[]) => { const name = args[1].slice(0, args[1].lastIndexOf("@")); @@ -193,12 +168,32 @@ describe("npm release publishing", () => { staleTagRunner ) ).rejects.toThrow("refusing to rewind"); + const azureConflictRunner = vi.fn(async (_command: string, args: string[]) => + args[0] === "view" + ? result(0, JSON.stringify(version)) + : result( + 1, + "", + "npm error 403 https://pkgs.dev.azure.com/example - The feed 'copilot-canary' already contains file 'package.tgz' in package '@github/copilot-sdk'." + ) + ); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "azure", + azureConflictRunner + ) + ).resolves.toBeUndefined(); + expect( + azureConflictRunner.mock.calls.some(([, args]) => args.includes("dist.integrity")) + ).toBe(false); const missingTagRunner = vi.fn(async (_command: string, args: string[]) => { - const name = args[1].slice(0, args[1].lastIndexOf("@")); - const packed = packages.find((candidate) => candidate.name === name)!; - return args[2] === "version" + return args[0] === "view" ? result(1, JSON.stringify({ error: { code: "E404" } })) - : result(0, JSON.stringify(packed.integrity)); + : result(0); }); await expect( publishManifest( diff --git a/nodejs/test/release-manifest.test.ts b/nodejs/test/release-manifest.test.ts index 5c7e1648bd..f9d2fce1ff 100644 --- a/nodejs/test/release-manifest.test.ts +++ b/nodejs/test/release-manifest.test.ts @@ -40,7 +40,6 @@ describe("release manifest", () => { createdAt: "2026-09-04T00:00:00Z", runtimeRunId: "9001", runtimeSha, - runtimeSource: "github-packages", runtimeVersion: "1.0.83-5.unstable.123.g1234567", sdkRef: "feature/unstable", sdkSha, @@ -51,6 +50,7 @@ describe("release manifest", () => { expect(manifest.packages).toHaveLength(9); expect(manifest.runtime.runId).toBe("9001"); + expect(manifest.runtime.source).toBe("github-packages"); expect(() => verifyReleaseManifest(manifest, root)).not.toThrow(); const damaged = join(root, manifest.packages[0].filename); diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index df8a74692f..bcb52d3fd9 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -11,6 +11,15 @@ const runtimeDispatchLedger = readFileSync( join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts"), "utf8" ); +const acquisitionJob = runtimeSdk.slice( + runtimeSdk.indexOf(" acquire-runtime:"), + runtimeSdk.indexOf(" test:") +); +const internalPublicationJob = runtimeSdk.slice( + runtimeSdk.indexOf(" publish-internal:"), + runtimeSdk.indexOf(" publish-public:") +); +const publicPublicationJob = runtimeSdk.slice(runtimeSdk.indexOf(" publish-public:")); describe("normal publishing workflow contract", () => { it("remains the stable and prerelease entry without runtime handoff inputs", () => { @@ -55,12 +64,10 @@ describe("runtime-driven Node SDK entry contract", () => { it("owns both strict runtime handoff matrices", () => { expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); expect(runtimeSdk).toContain("runtime_run_id:"); - expect(runtimeSdk).toContain("runtime_source:"); + expect(runtimeSdk).not.toContain("runtime_source:"); expect(runtimeDispatchLedger).toContain('expected.channel === "canary"'); - expect(runtimeDispatchLedger).toContain('expected.runtimeSource === "azure"'); - expect(runtimeDispatchLedger).toMatch( - /expected\.runtimeSource === "github-packages"\s+&&\s+expected\.mode === "internal"/ - ); + expect(runtimeDispatchLedger).toContain('source: "github-packages"'); + expect(runtimeDispatchLedger).not.toContain("runtimeSource"); }); it("serializes and durably claims each runtime run", () => { @@ -103,14 +110,23 @@ describe("runtime-backed Node release implementation", () => { it("enforces the channel, source, and mode matrix", () => { expect(runtimeDispatchLedger).toContain('expected.mode === "tests-only"'); expect(runtimeDispatchLedger).toContain('expected.mode === "internal"'); - expect(runtimeDispatchLedger).toContain( - "Invalid channel, runtime source, or mode combination" - ); + expect(runtimeDispatchLedger).toContain("Invalid channel or mode combination"); }); it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { expect(runtimeSdk).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); + expect(acquisitionJob).toContain("packages: read"); + expect(acquisitionJob).toContain("NODE_AUTH_TOKEN: ${{ github.token }}"); + expect(acquisitionJob).toContain("--registry https://npm.pkg.github.com"); + expect(acquisitionJob).not.toContain("azure/login"); + expect(acquisitionJob).not.toContain("FEED_URL"); + expect(internalPublicationJob).toContain("azure/login"); + expect(internalPublicationJob).toContain('"$FEED_URL" azure'); + expect(internalPublicationJob).not.toContain("registry.npmjs.org"); + expect(publicPublicationJob).toContain("https://registry.npmjs.org public"); + expect(publicPublicationJob).not.toContain("azure/login"); + expect(publicPublicationJob).not.toContain("FEED_URL"); expect(runtimeSdk).toContain("npm run verify:release-packages"); expect(runtimeSdk).toContain("publish-manifest"); expect(runtimeSdk.match(/preflight-package-set/g)).toHaveLength(2); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index 47a2405fde..4bfcbe2e0e 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -13,7 +13,6 @@ const expected: ExpectedDispatch = { mode: "internal", runtimeRunId: "100", runtimeSha: "a".repeat(40), - runtimeSource: "github-packages", runtimeVersion: "1.2.3-unstable.4", sdkRef: "refs/heads/main", sdkSha: "b".repeat(40), @@ -59,6 +58,7 @@ describe("runtime dispatch ledger", () => { const marker = createRuntimeDispatchMarker(expected); expect(marker.canonicalRunId).toBe("200"); expect(marker.runtime.runId).toBe("100"); + expect(marker.runtime.source).toBe("github-packages"); expect(marker).not.toHaveProperty("sdk.version"); }); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts index e06a65a6f6..3f68b52073 100644 --- a/nodejs/test/runtime-package-acquisition.test.ts +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -155,6 +155,37 @@ describe("runtime npm package acquisition", () => { } }); + it("requires GitHub Packages and strict registry integrity", async () => { + const root = temporaryRoot("copilot-runtime-registry-"); + const runner = vi + .fn() + .mockResolvedValue({ status: 0, stdout: JSON.stringify("sha1-invalid"), stderr: "" }); + await expect( + acquireRuntimePackages( + { + outputDirectory: join(root, "output"), + registry: "https://pkgs.dev.azure.com/example/npm/registry/", + runtimeSha, + runtimeVersion, + }, + runner + ) + ).rejects.toThrow("must come from GitHub Packages"); + expect(runner).not.toHaveBeenCalled(); + + await expect( + acquireRuntimePackages( + { + outputDirectory: join(root, "output"), + registry: "https://npm.pkg.github.com", + runtimeSha, + runtimeVersion, + }, + runner + ) + ).rejects.toThrow("Invalid registry integrity"); + }); + it("rejects mismatched source identity metadata", async () => { const root = temporaryRoot("copilot-runtime-identity-"); await createRuntimePackage(root, "linux-x64"); From 13bdde647a226e461051035aea739aaebcc36b4c Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 9 Sep 2026 17:13:46 -0700 Subject: [PATCH 13/23] Validate runtime release channels Require runtime versions to carry the exact selected prerelease channel before dispatch claims and release manifest creation or verification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- nodejs/scripts/release-manifest.ts | 4 ++- nodejs/scripts/runtime-dispatch-ledger.ts | 9 ++--- nodejs/scripts/runtime-release-identity.ts | 21 +++++++++++ nodejs/test/release-manifest.test.ts | 22 +++++++++++- nodejs/test/runtime-dispatch-ledger.test.ts | 39 +++++++++++++++++++++ 5 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 nodejs/scripts/runtime-release-identity.ts diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts index 11238441ce..2650c91a62 100644 --- a/nodejs/scripts/release-manifest.ts +++ b/nodejs/scripts/release-manifest.ts @@ -8,6 +8,7 @@ import { globSync } from "glob"; import * as semver from "semver"; import { x as extractTar } from "tar"; import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; +import { validateRuntimeVersionChannel } from "./runtime-release-identity.js"; export interface ReleaseManifestPackage { filename: string; @@ -90,6 +91,7 @@ export async function createReleaseManifest( ): Promise { validateFullSha(metadata.sdkSha, "SDK SHA"); validateFullSha(metadata.runtimeSha, "Runtime SHA"); + validateRuntimeVersionChannel(metadata.runtimeVersion, metadata.channel); assert(Number.isFinite(Date.parse(metadata.createdAt)), "Workflow creation time is invalid"); const packages: ReleaseManifestPackage[] = []; for (const archive of globSync("github-copilot-sdk-*.tgz", { @@ -148,7 +150,7 @@ export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirector validateFullSha(manifest.sdk.sha, "SDK SHA"); validateFullSha(manifest.runtime.sha, "Runtime SHA"); assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); - assert(semver.valid(manifest.runtime.version), "Invalid runtime version"); + validateRuntimeVersionChannel(manifest.runtime.version, manifest.channel); assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts index efd87fe539..91b864079b 100644 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { validateRuntimeVersionChannel } from "./runtime-release-identity.js"; export interface RuntimeDispatchMarker { canonicalRunId: string; @@ -83,8 +84,6 @@ export interface ClaimOptions { const workflowPath = ".github/workflows/runtime-sdk.yml"; const workflowName = "Runtime-driven Node SDK"; const canonicalNumericIdPattern = /^[1-9][0-9]*$/; -const runtimeVersionPattern = - /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; function validateInputs(expected: ExpectedDispatch): void { for (const [name, value] of Object.entries(expected)) { @@ -101,11 +100,7 @@ function validateInputs(expected: ExpectedDispatch): void { "Runtime workflow run ID must be canonical numeric" ); assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); - assert.match( - expected.runtimeVersion, - runtimeVersionPattern, - "Runtime version must be exact SemVer" - ); + validateRuntimeVersionChannel(expected.runtimeVersion, expected.channel); assert.match(expected.sdkSha, /^[0-9a-f]{40}$/, "SDK SHA must be lowercase full SHA"); assert(expected.sdkRef.length > 0, "SDK ref is required"); assert( diff --git a/nodejs/scripts/runtime-release-identity.ts b/nodejs/scripts/runtime-release-identity.ts new file mode 100644 index 0000000000..6be614e3ae --- /dev/null +++ b/nodejs/scripts/runtime-release-identity.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import * as semver from "semver"; + +export type RuntimeReleaseChannel = "canary" | "unstable"; + +export function validateRuntimeVersionChannel( + version: string, + channel: RuntimeReleaseChannel +): void { + assert(channel === "canary" || channel === "unstable", "Invalid channel"); + const parsed = semver.parse(version); + assert(parsed, "Runtime version must be exact SemVer"); + const canonicalVersion = `${parsed.version}${ + parsed.build.length > 0 ? `+${parsed.build.join(".")}` : "" + }`; + assert.equal(version, canonicalVersion, "Runtime version must be exact SemVer"); + assert( + parsed.prerelease.some((identifier) => identifier === channel), + `Runtime version '${version}' does not belong to the '${channel}' channel` + ); +} diff --git a/nodejs/test/release-manifest.test.ts b/nodejs/test/release-manifest.test.ts index f9d2fce1ff..ac95d7c3d5 100644 --- a/nodejs/test/release-manifest.test.ts +++ b/nodejs/test/release-manifest.test.ts @@ -40,7 +40,7 @@ describe("release manifest", () => { createdAt: "2026-09-04T00:00:00Z", runtimeRunId: "9001", runtimeSha, - runtimeVersion: "1.0.83-5.unstable.123.g1234567", + runtimeVersion: "1.0.83-5.unstable.123.g1234567+build.42", sdkRef: "feature/unstable", sdkSha, sdkVersion: version, @@ -53,6 +53,26 @@ describe("release manifest", () => { expect(manifest.runtime.source).toBe("github-packages"); expect(() => verifyReleaseManifest(manifest, root)).not.toThrow(); + const mismatched = structuredClone(manifest); + mismatched.runtime.version = "1.0.83-5.canary.123.g1234567.unsigned"; + expect(() => verifyReleaseManifest(mismatched, root)).toThrow( + "does not belong to the 'unstable' channel" + ); + await expect( + createReleaseManifest(root, { + channel: "canary", + createdAt: "2026-09-04T00:00:00Z", + runtimeRunId: "9001", + runtimeSha, + runtimeVersion: "1.0.83-5.unstable.123.g1234567", + sdkRef: "feature/unstable", + sdkSha, + sdkVersion: version, + workflowRunId: "812300", + workflowRunNumber: "8123", + }) + ).rejects.toThrow("does not belong to the 'canary' channel"); + const damaged = join(root, manifest.packages[0].filename); writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); expect(() => verifyReleaseManifest(manifest, root)).toThrow("Size mismatch"); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts index 4bfcbe2e0e..3f59be5b75 100644 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -205,6 +205,45 @@ describe("runtime dispatch ledger", () => { ).toThrow("Invalid channel"); }); + it.each([ + ["canary", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.2.4-canary.8.gdef5678.unsigned"], + ["canary", "9.9.9-canary.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0"], + ["unstable", "9.9.9-unstable.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0+build.42"], + ] satisfies [ExpectedDispatch["channel"], string][])( + "accepts a %s runtime version with valid producer suffixes: %s", + (channel, runtimeVersion) => { + expect(() => + createRuntimeDispatchMarker({ + ...expected, + channel, + mode: channel === "canary" ? "tests-only" : "internal", + runtimeVersion, + }) + ).not.toThrow(); + } + ); + + it.each([ + ["unstable", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.0.83-5.unstable.123.gabcdef0"], + ["canary", "1.2.4-canaryish.7.gdef5678"], + ] satisfies [ExpectedDispatch["channel"], string][])( + "rejects a runtime version outside the %s channel: %s", + (channel, runtimeVersion) => { + expect(() => + createRuntimeDispatchMarker({ + ...expected, + channel, + mode: channel === "canary" ? "tests-only" : "internal", + runtimeVersion, + }) + ).toThrow(`does not belong to the '${channel}' channel`); + } + ); + it("rejects non-canonical raw identity values", () => { for (const changed of [ { runtimeRunId: "0" }, From a11b7c364e56ad83abdf10624243e724739427a5 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 10 Sep 2026 10:50:05 -0700 Subject: [PATCH 14/23] Preserve runtime artifacts in SDK tests Archive acquired runtime packages before artifact upload so Unix executable modes survive restoration, and persist the package root for legacy CLI test resolution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 26 +++++++++++++---- nodejs/test/release-workflows.test.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 136e2bb028..68dea3c382 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -226,10 +226,13 @@ jobs: --sha "$RUNTIME_SHA" \ --registry https://npm.pkg.github.com \ --output "$RUNNER_TEMP/runtime-packages" - - uses: actions/upload-artifact@v7.0.0 + - name: Archive validated runtime packages + run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages + - name: Upload validated runtime packages + uses: actions/upload-artifact@v7.0.0 with: name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages + path: ${{ runner.temp }}/runtime-packages.tar.gz if-no-files-found: error retention-days: 7 @@ -259,10 +262,15 @@ jobs: - name: Install test harness dependencies working-directory: ./test/harness run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 + - name: Download validated runtime packages + uses: actions/download-artifact@v8.0.0 with: name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + rm -rf "$RUNNER_TEMP/runtime-packages" + tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP" - name: Select the acquired runtime env: COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages @@ -270,6 +278,7 @@ jobs: run: | node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV" echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - run: npm run build - name: Warm up PowerShell @@ -298,10 +307,15 @@ jobs: cache-dependency-path: ./nodejs/package-lock.json node-version: 22 - run: npm ci --ignore-scripts - - uses: actions/download-artifact@v8.0.0 + - name: Download validated runtime packages + uses: actions/download-artifact@v8.0.0 with: name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + rm -rf "$RUNNER_TEMP/runtime-packages" + tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP" - name: Build and verify exact package set env: COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index bcb52d3fd9..d1c4271839 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -15,6 +15,11 @@ const acquisitionJob = runtimeSdk.slice( runtimeSdk.indexOf(" acquire-runtime:"), runtimeSdk.indexOf(" test:") ); +const testJob = runtimeSdk.slice(runtimeSdk.indexOf(" test:"), runtimeSdk.indexOf(" package:")); +const packageJob = runtimeSdk.slice( + runtimeSdk.indexOf(" package:"), + runtimeSdk.indexOf(" publish-internal:") +); const internalPublicationJob = runtimeSdk.slice( runtimeSdk.indexOf(" publish-internal:"), runtimeSdk.indexOf(" publish-public:") @@ -145,4 +150,41 @@ describe("runtime-backed Node release implementation", () => { runtimeSdk.indexOf("publish-manifest") ); }); + + it("preserves runtime package modes across every artifact boundary", () => { + expect(acquisitionJob).toContain( + 'tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages' + ); + expect(acquisitionJob).toContain("path: ${{ runner.temp }}/runtime-packages.tar.gz"); + expect(acquisitionJob).not.toContain("path: ${{ runner.temp }}/runtime-packages\n"); + + for (const consumer of [testJob, packageJob]) { + expect(consumer).toContain("path: ${{ runner.temp }}/runtime-package-artifact"); + expect(consumer).toContain( + 'tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP"' + ); + expect(consumer).not.toContain("path: ${{ runner.temp }}/runtime-packages\n"); + } + expect(testJob.indexOf("Extract validated runtime packages")).toBeLessThan( + testJob.indexOf("Select the acquired runtime") + ); + expect(packageJob.indexOf("Extract validated runtime packages")).toBeLessThan( + packageJob.indexOf("Build and verify exact package set") + ); + }); + + it("persists and consumes the restored runtime package directory", () => { + expect(testJob).toContain( + 'echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV"' + ); + expect(testJob).toContain('echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV"'); + expect(packageJob).toContain( + "COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages" + ); + expect(packageJob.indexOf("Extract validated runtime packages")).toBeLessThan( + packageJob.indexOf( + "COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages" + ) + ); + }); }); From 4a61952246b41b7e7d683a98302dc52994bccd66 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 10 Sep 2026 12:56:45 -0700 Subject: [PATCH 15/23] Treat runtime run IDs as provenance Remove cross-run dispatch claiming so each SDK workflow invocation creates its own release identity while reruns retain the same deterministic version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 115 +----- docs/developer-docs/unstable-releases.md | 16 +- nodejs/scripts/runtime-dispatch-ledger.ts | 383 ------------------- nodejs/scripts/runtime-release-identity.ts | 68 ++++ nodejs/scripts/unstable-version.ts | 2 +- nodejs/test/release-workflows.test.ts | 78 ++-- nodejs/test/runtime-dispatch-ledger.test.ts | 271 ------------- nodejs/test/runtime-release-identity.test.ts | 102 +++++ nodejs/test/unstable-version.test.ts | 38 +- 9 files changed, 276 insertions(+), 797 deletions(-) delete mode 100644 nodejs/scripts/runtime-dispatch-ledger.ts delete mode 100644 nodejs/test/runtime-dispatch-ledger.test.ts create mode 100644 nodejs/test/runtime-release-identity.test.ts diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 68dea3c382..a2e619fb4d 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -1,5 +1,5 @@ name: Runtime-driven Node SDK -run-name: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} +run-name: "Runtime-driven SDK #${{ github.run_number }} from runtime run ${{ inputs.runtime_run_id }}" on: workflow_dispatch: @@ -20,7 +20,7 @@ on: required: true type: string runtime_run_id: - description: "Source runtime workflow run ID and idempotency key" + description: "Source runtime workflow run ID for provenance" required: true type: string mode: @@ -32,7 +32,7 @@ on: - internal default: internal version: - description: "Unstable SDK version override for a direct manual run" + description: "Unstable SemVer base for a direct manual run; workflow identity is appended" required: false type: string @@ -45,78 +45,13 @@ env: HUSKY: 0 jobs: - claim-runtime-dispatch: - name: Claim runtime dispatch - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} - cancel-in-progress: false - queue: max - permissions: - actions: read - contents: read - outputs: - canonical_run_id: ${{ steps.claim.outputs.canonical_run_id }} - role: ${{ steps.claim.outputs.role }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Claim or resolve the canonical dispatch - id: claim - env: - CHANNEL: ${{ inputs.channel }} - CURRENT_RUN_ID: ${{ github.run_id }} - GH_TOKEN: ${{ github.token }} - MODE: ${{ inputs.mode }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - VERSION_OVERRIDE: ${{ inputs.version }} - run: node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts claim "$RUNNER_TEMP/new-marker/marker.json" - - name: Mirror the canonical run - if: steps.claim.outputs.role == 'duplicate' - env: - CANONICAL_RUN_ID: ${{ steps.claim.outputs.canonical_run_id }} - GH_TOKEN: ${{ github.token }} - run: | - set +e - gh run watch "$CANONICAL_RUN_ID" --exit-status - RESULT=$? - set -e - if [ "$RESULT" -ne 0 ]; then - echo "::error::Canonical SDK run $CANONICAL_RUN_ID failed or was canceled. Re-run that original run; this duplicate will not mint another SDK version." - exit "$RESULT" - fi - echo "Canonical SDK run $CANONICAL_RUN_ID succeeded; this duplicate is complete." - - name: Persist the canonical marker - if: steps.claim.outputs.created == 'true' - uses: actions/upload-artifact@v7.0.0 - with: - name: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} - path: ${{ runner.temp }}/new-marker/marker.json - retention-days: 90 - plan: name: Freeze runtime-backed release identity - if: needs.claim-runtime-dispatch.outputs.role == 'owner' - needs: claim-runtime-dispatch runs-on: ubuntu-latest environment: cicd permissions: actions: read contents: read - id-token: write outputs: artifact_name: ${{ steps.plan.outputs.artifact_name }} sdk_version: ${{ steps.plan.outputs.sdk_version }} @@ -135,6 +70,16 @@ jobs: node-version: 22 - run: npm ci --ignore-scripts working-directory: ./nodejs + - name: Validate runtime release inputs + working-directory: ./nodejs + env: + CHANNEL: ${{ inputs.channel }} + MODE: ${{ inputs.mode }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: npx tsx scripts/runtime-release-identity.ts - name: Calculate the release identity id: plan working-directory: ./nodejs @@ -160,36 +105,6 @@ jobs: echo "sdk_version=$SDK_VERSION" echo "workflow_created_at=$WORKFLOW_CREATED_AT" } >> "$GITHUB_OUTPUT" - - name: Reject an explicit version already present publicly - if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' - working-directory: ./nodejs - env: - SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} - run: node scripts/npm-release.js preflight-package-set "$SDK_VERSION" https://registry.npmjs.org - - name: Azure login for explicit-version preflight - if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Reject an explicit version already present internally - if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' - working-directory: ./nodejs - env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} - 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" - node scripts/npm-release.js preflight-package-set "$SDK_VERSION" "$FEED_URL" acquire-runtime: name: Acquire exact runtime packages @@ -430,8 +345,8 @@ jobs: publish-public: name: Publish unstable SDK publicly - if: inputs.channel == 'unstable' && needs.claim-runtime-dispatch.outputs.role == 'owner' - needs: [claim-runtime-dispatch, plan, publish-internal] + if: inputs.channel == 'unstable' + needs: [plan, publish-internal] runs-on: ubuntu-latest concurrency: group: sdk-runtime-public-unstable diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 6b56930e02..40eef9a0d8 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -22,12 +22,13 @@ The runtime dispatch includes these inputs: * `channel`: `canary` or `unstable` * `runtime_version`: Exact runtime package version * `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -* `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key +* `runtime_run_id`: Source runtime workflow run ID for provenance * `mode`: `tests-only` or `internal` for canary; `internal` for unstable Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The optional `version` input is available only for unstable and must be an unstable -SemVer. Do not reuse an explicit version after an artifact has been built. +SemVer base. The workflow appends its run number and SDK SHA so each new +dispatch still creates a unique version. ## Release gates @@ -80,12 +81,11 @@ Use **Re-run failed jobs** on the original workflow run for normal recovery. The run number, frozen version, and retained artifact remain unchanged. Do not rerun a successful packaging job merely to recover a publication job. -Each `runtime_run_id` is serialized and claimed by a 90-day marker artifact. -The marker records the canonical SDK run and complete runtime/input -provenance, but the runtime run ID is not part of the immutable release -identity. Exact duplicate dispatches wait for and mirror the canonical run. -If that run fails or is canceled, rerun the original run rather than -dispatching another release. +The runtime run ID is retained as provenance only. Re-running the same SDK +workflow run retries its frozen SDK version and retained artifact. A new +workflow dispatch creates a new SDK release identity and version, even when it +uses the same runtime run, version, and SHA. This allows any number of SDK +releases to reuse the same immutable runtime packages. ## Registry setup diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts deleted file mode 100644 index 91b864079b..0000000000 --- a/nodejs/scripts/runtime-dispatch-ledger.ts +++ /dev/null @@ -1,383 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { validateRuntimeVersionChannel } from "./runtime-release-identity.js"; - -export interface RuntimeDispatchMarker { - canonicalRunId: string; - channel: "canary" | "unstable"; - createdAt: string; - mode: "internal" | "tests-only"; - runtime: { - repository: "github/copilot-agent-runtime"; - runId: string; - sha: string; - source: "github-packages"; - version: string; - }; - schemaVersion: 1; - sdk: { - ref: string; - repository: "github/copilot-sdk"; - versionOverride: string; - sha: string; - }; - workflow: ".github/workflows/runtime-sdk.yml"; -} - -export interface ArtifactApiResponse { - expired: boolean; - id: number; - name: string; - workflow_run?: { id?: number }; -} - -export interface WorkflowRunApiResponse { - display_title: string; - event: string; - head_branch: string; - head_sha: string; - id: number; - name: string; - path: string; - repository: { full_name: string }; - status: string; -} - -export interface ExpectedDispatch { - channel: RuntimeDispatchMarker["channel"]; - currentRunId: string; - mode: RuntimeDispatchMarker["mode"]; - runtimeRunId: string; - runtimeSha: string; - runtimeVersion: string; - sdkRef: string; - sdkSha: string; - versionOverride: string; -} - -export type DispatchRole = "duplicate" | "owner"; - -export interface DispatchClaim { - canonicalRunId: string; - created: boolean; - marker: RuntimeDispatchMarker; - role: DispatchRole; -} - -export interface DispatchLedgerClient { - downloadMarker(artifactId: number): Promise; - getWorkflowRun(runId: number): Promise; - listArtifacts(markerName: string): Promise; - listWorkflowRuns(): Promise; -} - -export interface ClaimOptions { - attempts?: number; - delay?: (milliseconds: number) => Promise; - delayMilliseconds?: number; - onWait?: (attempt: number, attempts: number) => void; -} - -const workflowPath = ".github/workflows/runtime-sdk.yml"; -const workflowName = "Runtime-driven Node SDK"; -const canonicalNumericIdPattern = /^[1-9][0-9]*$/; - -function validateInputs(expected: ExpectedDispatch): void { - for (const [name, value] of Object.entries(expected)) { - assert.equal(value, value.trim(), `${name} must not contain surrounding whitespace`); - } - assert.match( - expected.currentRunId, - canonicalNumericIdPattern, - "Current workflow run ID must be canonical numeric" - ); - assert.match( - expected.runtimeRunId, - canonicalNumericIdPattern, - "Runtime workflow run ID must be canonical numeric" - ); - assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); - validateRuntimeVersionChannel(expected.runtimeVersion, expected.channel); - assert.match(expected.sdkSha, /^[0-9a-f]{40}$/, "SDK SHA must be lowercase full SHA"); - assert(expected.sdkRef.length > 0, "SDK ref is required"); - assert( - expected.channel === "canary" - ? expected.mode === "tests-only" || expected.mode === "internal" - : expected.channel === "unstable" && expected.mode === "internal", - "Invalid channel or mode combination" - ); - assert( - expected.channel !== "canary" || expected.versionOverride === "", - "Canary runs do not accept a version override" - ); -} - -export function createRuntimeDispatchMarker(expected: ExpectedDispatch): RuntimeDispatchMarker { - validateInputs(expected); - return { - schemaVersion: 1, - canonicalRunId: expected.currentRunId, - channel: expected.channel, - mode: expected.mode, - runtime: { - repository: "github/copilot-agent-runtime", - runId: expected.runtimeRunId, - sha: expected.runtimeSha, - source: "github-packages", - version: expected.runtimeVersion, - }, - sdk: { - repository: "github/copilot-sdk", - ref: expected.sdkRef, - sha: expected.sdkSha, - versionOverride: expected.versionOverride, - }, - workflow: workflowPath, - createdAt: new Date().toISOString(), - }; -} - -export function validateRuntimeDispatchMarker( - marker: RuntimeDispatchMarker, - artifact: ArtifactApiResponse, - workflowRun: WorkflowRunApiResponse, - expected: ExpectedDispatch -): DispatchRole { - validateInputs(expected); - assert.equal(marker.schemaVersion, 1, "Unsupported dispatch marker schema"); - assert.match( - marker.canonicalRunId, - canonicalNumericIdPattern, - "Canonical workflow run ID must be canonical numeric" - ); - assert.equal(artifact.expired, false, "Dispatch marker artifact is expired"); - assert.equal( - String(artifact.workflow_run?.id), - marker.canonicalRunId, - "Artifact workflow run ID does not match its marker" - ); - assert.equal(String(workflowRun.id), marker.canonicalRunId, "Workflow run provenance mismatch"); - assert.equal(workflowRun.repository.full_name, "github/copilot-sdk"); - assert.equal(workflowRun.path, workflowPath); - assert.equal(workflowRun.name, workflowName); - assert.equal(workflowRun.event, "workflow_dispatch"); - assert.equal(workflowRun.head_sha, marker.sdk.sha); - assert.equal(workflowRun.head_branch, marker.sdk.ref.replace(/^refs\/(heads|tags)\//, "")); - assert.deepEqual( - { - channel: marker.channel, - mode: marker.mode, - runtime: marker.runtime, - sdk: marker.sdk, - workflow: marker.workflow, - }, - { - channel: expected.channel, - mode: expected.mode, - runtime: { - repository: "github/copilot-agent-runtime", - runId: expected.runtimeRunId, - sha: expected.runtimeSha, - source: "github-packages", - version: expected.runtimeVersion, - }, - sdk: { - repository: "github/copilot-sdk", - ref: expected.sdkRef, - sha: expected.sdkSha, - versionOverride: expected.versionOverride, - }, - workflow: workflowPath, - }, - "runtime_run_id is already claimed by a different release tuple" - ); - - if (marker.canonicalRunId === expected.currentRunId) { - return "owner"; - } - return "duplicate"; -} - -export async function claimRuntimeDispatch( - expected: ExpectedDispatch, - client: DispatchLedgerClient, - options: ClaimOptions = {} -): Promise { - validateInputs(expected); - const attempts = options.attempts ?? 6; - const delayMilliseconds = options.delayMilliseconds ?? 10_000; - const delay = - options.delay ?? - ((milliseconds: number) => - new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))); - const markerName = `sdk-runtime-dispatch-${expected.runtimeRunId}`; - const runTitle = `Runtime-driven SDK from runtime run ${expected.runtimeRunId}`; - let earlierRuns: WorkflowRunApiResponse[] = []; - - for (let attempt = 1; attempt <= attempts; attempt += 1) { - const artifacts = (await client.listArtifacts(markerName)).filter( - (artifact) => artifact.name === markerName && !artifact.expired - ); - assert(artifacts.length <= 1, `More than one unexpired ${markerName} artifact exists.`); - const artifact = artifacts[0]; - if (artifact) { - const marker = await client.downloadMarker(artifact.id); - const canonicalRunId = Number(marker.canonicalRunId); - const workflowRun = await client.getWorkflowRun(canonicalRunId); - return { - canonicalRunId: marker.canonicalRunId, - created: false, - marker, - role: validateRuntimeDispatchMarker(marker, artifact, workflowRun, expected), - }; - } - - earlierRuns = (await client.listWorkflowRuns()).filter( - (run) => run.display_title === runTitle && run.id < Number(expected.currentRunId) - ); - if (earlierRuns.length === 0) { - const marker = createRuntimeDispatchMarker(expected); - return { - canonicalRunId: expected.currentRunId, - created: true, - marker, - role: "owner", - }; - } - if (attempt < attempts) { - options.onWait?.(attempt, attempts); - await delay(delayMilliseconds); - } - } - - assert( - !earlierRuns.some((run) => run.status !== "completed"), - "An earlier matching run is still initializing without a visible marker. Retry this run later." - ); - const marker = createRuntimeDispatchMarker(expected); - return { - canonicalRunId: expected.currentRunId, - created: true, - marker, - role: "owner", - }; -} - -function requiredEnvironment(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required.`); - } - return value; -} - -function expectedFromEnvironment(): ExpectedDispatch { - return { - channel: requiredEnvironment("CHANNEL") as ExpectedDispatch["channel"], - currentRunId: requiredEnvironment("CURRENT_RUN_ID"), - mode: requiredEnvironment("MODE") as ExpectedDispatch["mode"], - runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), - runtimeSha: requiredEnvironment("RUNTIME_SHA"), - runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), - sdkRef: requiredEnvironment("SDK_REF"), - sdkSha: requiredEnvironment("SDK_SHA"), - versionOverride: process.env.VERSION_OVERRIDE ?? "", - }; -} - -function githubClient(): DispatchLedgerClient { - const apiUrl = requiredEnvironment("GITHUB_API_URL"); - const repository = requiredEnvironment("GITHUB_REPOSITORY"); - const token = requiredEnvironment("GH_TOKEN"); - const temporaryDirectory = requiredEnvironment("RUNNER_TEMP"); - - async function request(path: string): Promise { - const response = await fetch(`${apiUrl}${path}`, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - if (!response.ok) { - throw new Error(`GitHub API request failed (${response.status}): ${path}`); - } - return response; - } - - return { - async listArtifacts(markerName) { - const response = await request( - `/repos/${repository}/actions/artifacts?name=${encodeURIComponent(markerName)}&per_page=100` - ); - return ((await response.json()) as { artifacts: ArtifactApiResponse[] }).artifacts; - }, - async listWorkflowRuns() { - const response = await request( - `/repos/${repository}/actions/workflows/runtime-sdk.yml/runs?event=workflow_dispatch&per_page=100` - ); - return ((await response.json()) as { workflow_runs: WorkflowRunApiResponse[] }) - .workflow_runs; - }, - async downloadMarker(artifactId) { - const zipPath = join(temporaryDirectory, "dispatch-marker.zip"); - const markerDirectory = join(temporaryDirectory, "dispatch-marker"); - rmSync(markerDirectory, { force: true, recursive: true }); - mkdirSync(markerDirectory, { recursive: true }); - const response = await request( - `/repos/${repository}/actions/artifacts/${artifactId}/zip` - ); - writeFileSync(zipPath, Buffer.from(await response.arrayBuffer())); - execFileSync("unzip", ["-q", zipPath, "-d", markerDirectory]); - return JSON.parse( - readFileSync(join(markerDirectory, "marker.json"), "utf8") - ) as RuntimeDispatchMarker; - }, - async getWorkflowRun(runId) { - const response = await request(`/repos/${repository}/actions/runs/${runId}`); - return (await response.json()) as WorkflowRunApiResponse; - }, - }; -} - -async function main(): Promise { - const [command, markerPath] = process.argv.slice(2); - if (command !== "claim" || !markerPath) { - throw new Error("Usage: runtime-dispatch-ledger.ts claim "); - } - const claim = await claimRuntimeDispatch(expectedFromEnvironment(), githubClient(), { - onWait: (attempt, attempts) => - console.log( - `An earlier matching run is visible; waiting for its marker (attempt ${attempt}/${attempts}).` - ), - }); - if (claim.created) { - mkdirSync(dirname(markerPath), { recursive: true }); - writeFileSync(markerPath, `${JSON.stringify(claim.marker, null, 2)}\n`); - } - const output = `role=${claim.role}\ncanonical_run_id=${claim.canonicalRunId}\ncreated=${claim.created}\n`; - if (process.env.GITHUB_OUTPUT) { - writeFileSync(process.env.GITHUB_OUTPUT, output, { flag: "a" }); - } else { - process.stdout.write(output); - } -} - -async function runMain(): Promise { - try { - await main(); - } catch (error) { - console.error(`::error::${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } -} - -const scriptPath = process.argv[1] - ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) - : false; -if (scriptPath) { - void runMain(); -} diff --git a/nodejs/scripts/runtime-release-identity.ts b/nodejs/scripts/runtime-release-identity.ts index 6be614e3ae..a4a62ecc23 100644 --- a/nodejs/scripts/runtime-release-identity.ts +++ b/nodejs/scripts/runtime-release-identity.ts @@ -1,7 +1,21 @@ import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import * as semver from "semver"; export type RuntimeReleaseChannel = "canary" | "unstable"; +export type RuntimeReleaseMode = "internal" | "tests-only"; + +export interface RuntimeReleaseInputs { + channel: RuntimeReleaseChannel; + mode: RuntimeReleaseMode; + runtimeRunId: string; + runtimeSha: string; + runtimeVersion: string; + versionOverride: string; +} + +const canonicalNumericIdPattern = /^[1-9][0-9]*$/; export function validateRuntimeVersionChannel( version: string, @@ -19,3 +33,57 @@ export function validateRuntimeVersionChannel( `Runtime version '${version}' does not belong to the '${channel}' channel` ); } + +export function validateRuntimeReleaseInputs(inputs: RuntimeReleaseInputs): void { + assert(inputs.channel === "canary" || inputs.channel === "unstable", "Invalid release channel"); + assert( + inputs.channel === "canary" + ? inputs.mode === "tests-only" || inputs.mode === "internal" + : inputs.mode === "internal", + "Invalid channel or mode combination" + ); + assert.match( + inputs.runtimeRunId, + canonicalNumericIdPattern, + "Runtime workflow run ID must be a positive canonical integer" + ); + assert.match(inputs.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + validateRuntimeVersionChannel(inputs.runtimeVersion, inputs.channel); + assert.equal( + inputs.versionOverride, + inputs.versionOverride.trim(), + "SDK version override must not contain surrounding whitespace" + ); + assert( + inputs.channel !== "canary" || inputs.versionOverride === "", + "Canary runs do not accept a version override" + ); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + throw new Error(`${name} is required.`); + } + return value; +} + +function main(): void { + validateRuntimeReleaseInputs({ + channel: requiredEnvironment("CHANNEL") as RuntimeReleaseChannel, + mode: requiredEnvironment("MODE") as RuntimeReleaseMode, + runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), + versionOverride: process.env.VERSION_OVERRIDE ?? "", + }); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + try { + main(); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/nodejs/scripts/unstable-version.ts b/nodejs/scripts/unstable-version.ts index 47501335d9..c65b7e9799 100644 --- a/nodejs/scripts/unstable-version.ts +++ b/nodejs/scripts/unstable-version.ts @@ -106,7 +106,7 @@ export function calculateUnstableVersion(options: UnstableVersionOptions): strin `Explicit unstable SDK version must be valid SemVer with an unstable prerelease: ${options.versionOverride}` ); } - return options.versionOverride; + return `${parsed.major}.${parsed.minor}.${parsed.patch}-${parsed.prerelease.join(".")}.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; } const eligibleTags = new Set( diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index d1c4271839..0ef6853e07 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -7,10 +7,14 @@ const workflow = (name: string) => readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); const publish = workflow("publish.yml"); const runtimeSdk = workflow("runtime-sdk.yml"); -const runtimeDispatchLedger = readFileSync( - join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts"), +const runtimeReleaseIdentity = readFileSync( + join(repositoryRoot, "nodejs", "scripts", "runtime-release-identity.ts"), "utf8" ); +const planJob = runtimeSdk.slice( + runtimeSdk.indexOf(" plan:"), + runtimeSdk.indexOf(" acquire-runtime:") +); const acquisitionJob = runtimeSdk.slice( runtimeSdk.indexOf(" acquire-runtime:"), runtimeSdk.indexOf(" test:") @@ -70,52 +74,62 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); expect(runtimeSdk).toContain("runtime_run_id:"); expect(runtimeSdk).not.toContain("runtime_source:"); - expect(runtimeDispatchLedger).toContain('expected.channel === "canary"'); - expect(runtimeDispatchLedger).toContain('source: "github-packages"'); - expect(runtimeDispatchLedger).not.toContain("runtimeSource"); + expect(runtimeReleaseIdentity).toContain('inputs.channel === "canary"'); + expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); + expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); + expect(runtimeSdk).toContain("npx tsx scripts/runtime-release-identity.ts"); }); - it("serializes and durably claims each runtime run", () => { - expect(runtimeSdk).toContain("group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); + it("uses the runtime run ID only as provenance", () => { + expect(runtimeSdk).toContain( + 'description: "Source runtime workflow run ID for provenance"' + ); + expect(runtimeSdk).toContain( + 'run-name: "Runtime-driven SDK #${{ github.run_number }} from runtime run ${{ inputs.runtime_run_id }}"' + ); + expect(runtimeSdk).toContain( + 'description: "Unstable SemVer base for a direct manual run; workflow identity is appended"' + ); + expect(runtimeSdk).not.toContain("claim-runtime-dispatch"); + expect(runtimeSdk).not.toContain("sdk-runtime-dispatch-"); + expect(runtimeSdk).not.toContain("runtime-dispatch-ledger"); + expect(runtimeSdk).not.toContain("canonical_run"); + expect(runtimeSdk).not.toContain("CANONICAL_RUN_ID"); + expect(runtimeSdk).not.toContain("gh run watch"); + expect( + existsSync(join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts")) + ).toBe(false); + expect( + existsSync(join(repositoryRoot, "nodejs", "test", "runtime-dispatch-ledger.test.ts")) + ).toBe(false); expect(runtimeSdk).toContain("cancel-in-progress: false"); - expect(runtimeSdk.match(/queue: max/g)).toHaveLength(3); - expect(runtimeSdk).toContain("sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); - expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts claim"); - expect(runtimeSdk).toContain("steps.claim.outputs.created == 'true'"); - expect(runtimeSdk).not.toContain("actions/artifacts"); - expect(runtimeSdk).not.toContain("actions/workflows/runtime-sdk.yml/runs"); - expect(runtimeDispatchLedger).toContain("More than one unexpired"); - expect(runtimeDispatchLedger).toContain("attempts ?? 6"); - expect(runtimeDispatchLedger).toContain("actions/workflows/runtime-sdk.yml/runs"); - expect(runtimeDispatchLedger).toContain("canonicalNumericIdPattern"); - expect(runtimeDispatchLedger).not.toContain("process.env[name]?.trim()"); - expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); - expect(runtimeSdk).toContain("retention-days: 90"); + expect(runtimeSdk.match(/queue: max/g)).toHaveLength(2); expect(runtimeSdk).not.toContain("resume_run_id"); }); - it("delegates preparation before its separately serialized public publication", () => { + it("plans every invocation before separately serialized publication", () => { expect(runtimeSdk).toContain("scripts/unstable-version.ts"); + expect(planJob).not.toContain("needs:"); + expect(planJob).not.toContain("if: needs."); expect(runtimeSdk).toContain("group: sdk-runtime-public-unstable"); expect(runtimeSdk.indexOf("publish-internal:")).toBeLessThan( runtimeSdk.indexOf("publish-public:") ); - expect(runtimeSdk).toContain("needs: [claim-runtime-dispatch, plan, publish-internal]"); + expect(runtimeSdk).toContain("needs: [plan, publish-internal]"); expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); - }); - - it("requires duplicates and failures to use the canonical workflow run", () => { - expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); - expect(runtimeSdk).toContain("Re-run that original run"); - expect(runtimeSdk).not.toContain("run-id:"); + expect(runtimeSdk).not.toContain("needs.claim-runtime-dispatch"); }); }); describe("runtime-backed Node release implementation", () => { it("enforces the channel, source, and mode matrix", () => { - expect(runtimeDispatchLedger).toContain('expected.mode === "tests-only"'); - expect(runtimeDispatchLedger).toContain('expected.mode === "internal"'); - expect(runtimeDispatchLedger).toContain("Invalid channel or mode combination"); + expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); + expect(runtimeReleaseIdentity).toContain('inputs.mode === "internal"'); + expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); + expect(runtimeReleaseIdentity).toContain( + "Runtime workflow run ID must be a positive canonical integer" + ); + expect(runtimeReleaseIdentity).toContain("validateRuntimeVersionChannel"); }); it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { @@ -134,7 +148,7 @@ describe("runtime-backed Node release implementation", () => { expect(publicPublicationJob).not.toContain("FEED_URL"); expect(runtimeSdk).toContain("npm run verify:release-packages"); expect(runtimeSdk).toContain("publish-manifest"); - expect(runtimeSdk.match(/preflight-package-set/g)).toHaveLength(2); + expect(runtimeSdk).not.toContain("preflight-package-set"); expect(runtimeSdk).not.toContain("for PACKAGE in"); expect(runtimeSdk).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); expect(runtimeSdk).not.toContain('"$runtime_path" --version'); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts deleted file mode 100644 index 3f59be5b75..0000000000 --- a/nodejs/test/runtime-dispatch-ledger.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - claimRuntimeDispatch, - createRuntimeDispatchMarker, - type DispatchLedgerClient, - type ExpectedDispatch, - validateRuntimeDispatchMarker, -} from "../scripts/runtime-dispatch-ledger.js"; - -const expected: ExpectedDispatch = { - channel: "unstable", - currentRunId: "200", - mode: "internal", - runtimeRunId: "100", - runtimeSha: "a".repeat(40), - runtimeVersion: "1.2.3-unstable.4", - sdkRef: "refs/heads/main", - sdkSha: "b".repeat(40), - versionOverride: "", -}; - -function provenance(canonicalRunId: string) { - return { - artifact: { - expired: false, - id: 10, - name: "sdk-runtime-dispatch-100", - workflow_run: { id: Number(canonicalRunId) }, - }, - run: { - display_title: "Runtime-driven SDK from runtime run 100", - event: "workflow_dispatch", - head_branch: "main", - head_sha: expected.sdkSha, - id: Number(canonicalRunId), - name: "Runtime-driven Node SDK", - path: ".github/workflows/runtime-sdk.yml", - repository: { full_name: "github/copilot-sdk" }, - status: "completed", - }, - }; -} - -function client(overrides: Partial = {}): DispatchLedgerClient { - const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); - const api = provenance("199"); - return { - downloadMarker: async () => marker, - getWorkflowRun: async () => api.run, - listArtifacts: async () => [api.artifact], - listWorkflowRuns: async () => [], - ...overrides, - }; -} - -describe("runtime dispatch ledger", () => { - it("creates a canonical marker without adding the runtime run to release identity", () => { - const marker = createRuntimeDispatchMarker(expected); - expect(marker.canonicalRunId).toBe("200"); - expect(marker.runtime.runId).toBe("100"); - expect(marker.runtime.source).toBe("github-packages"); - expect(marker).not.toHaveProperty("sdk.version"); - }); - - it("retains ownership for a rerun of the canonical workflow run", () => { - const marker = createRuntimeDispatchMarker(expected); - const api = provenance("200"); - expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( - "owner" - ); - }); - - it("recognizes an exact duplicate", () => { - const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); - const api = provenance("199"); - expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( - "duplicate" - ); - }); - - it("orchestrates exact duplicates and canonical reruns without creating another marker", async () => { - await expect(claimRuntimeDispatch(expected, client())).resolves.toMatchObject({ - canonicalRunId: "199", - created: false, - role: "duplicate", - }); - - const marker = createRuntimeDispatchMarker(expected); - const api = provenance("200"); - await expect( - claimRuntimeDispatch( - expected, - client({ - downloadMarker: async () => marker, - getWorkflowRun: async () => api.run, - listArtifacts: async () => [api.artifact], - }) - ) - ).resolves.toMatchObject({ - canonicalRunId: "200", - created: false, - role: "owner", - }); - }); - - it("rejects multiple exact markers", async () => { - const api = provenance("199"); - await expect( - claimRuntimeDispatch( - expected, - client({ listArtifacts: async () => [api.artifact, { ...api.artifact, id: 11 }] }) - ) - ).rejects.toThrow("More than one unexpired"); - }); - - it("allows a markerless rerun of the same workflow run to claim", async () => { - const api = provenance("200"); - await expect( - claimRuntimeDispatch( - expected, - client({ - listArtifacts: async () => [], - listWorkflowRuns: async () => [api.run], - }) - ) - ).resolves.toMatchObject({ - canonicalRunId: "200", - created: true, - role: "owner", - }); - }); - - it("retries while an earlier matching run is still initializing", async () => { - const api = provenance("199"); - const delay = vi.fn(async () => undefined); - await expect( - claimRuntimeDispatch( - expected, - client({ - listArtifacts: async () => [], - listWorkflowRuns: async () => [{ ...api.run, status: "in_progress" }], - }), - { attempts: 2, delay } - ) - ).rejects.toThrow("still initializing"); - expect(delay).toHaveBeenCalledTimes(1); - }); - - it("resolves a marker that becomes visible during the bounded retry", async () => { - const api = provenance("199"); - const listArtifacts = vi - .fn() - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([api.artifact]); - await expect( - claimRuntimeDispatch( - expected, - client({ - listArtifacts, - listWorkflowRuns: async () => [api.run], - }), - { attempts: 2, delay: async () => undefined } - ) - ).resolves.toMatchObject({ - canonicalRunId: "199", - created: false, - role: "duplicate", - }); - expect(listArtifacts).toHaveBeenCalledTimes(2); - }); - - it("rejects marker tuple collisions and forged API provenance", () => { - const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); - const api = provenance("199"); - expect(() => - validateRuntimeDispatchMarker(marker, api.artifact, api.run, { - ...expected, - runtimeSha: "c".repeat(40), - }) - ).toThrow(/already claimed/); - expect(() => - validateRuntimeDispatchMarker( - marker, - { ...api.artifact, workflow_run: { id: 198 } }, - api.run, - expected - ) - ).toThrow(/Artifact workflow run ID/); - expect(() => - validateRuntimeDispatchMarker( - marker, - api.artifact, - { ...api.run, path: ".github/workflows/publish.yml" }, - expected - ) - ).toThrow(); - }); - - it("rejects unknown channels at the extracted entry boundary", () => { - expect(() => - createRuntimeDispatchMarker({ - ...expected, - channel: "invalid" as ExpectedDispatch["channel"], - }) - ).toThrow("Invalid channel"); - }); - - it.each([ - ["canary", "1.2.4-canary.7.gdef5678.signed"], - ["canary", "1.2.4-canary.8.gdef5678.unsigned"], - ["canary", "9.9.9-canary.test"], - ["unstable", "1.0.83-5.unstable.123.gabcdef0"], - ["unstable", "9.9.9-unstable.test"], - ["unstable", "1.0.83-5.unstable.123.gabcdef0+build.42"], - ] satisfies [ExpectedDispatch["channel"], string][])( - "accepts a %s runtime version with valid producer suffixes: %s", - (channel, runtimeVersion) => { - expect(() => - createRuntimeDispatchMarker({ - ...expected, - channel, - mode: channel === "canary" ? "tests-only" : "internal", - runtimeVersion, - }) - ).not.toThrow(); - } - ); - - it.each([ - ["unstable", "1.2.4-canary.7.gdef5678.signed"], - ["canary", "1.0.83-5.unstable.123.gabcdef0"], - ["canary", "1.2.4-canaryish.7.gdef5678"], - ] satisfies [ExpectedDispatch["channel"], string][])( - "rejects a runtime version outside the %s channel: %s", - (channel, runtimeVersion) => { - expect(() => - createRuntimeDispatchMarker({ - ...expected, - channel, - mode: channel === "canary" ? "tests-only" : "internal", - runtimeVersion, - }) - ).toThrow(`does not belong to the '${channel}' channel`); - } - ); - - it("rejects non-canonical raw identity values", () => { - for (const changed of [ - { runtimeRunId: "0" }, - { runtimeRunId: "0100" }, - { currentRunId: "0" }, - { currentRunId: "0200" }, - { runtimeVersion: " 1.2.3-unstable.4" }, - { sdkRef: "refs/heads/main " }, - { versionOverride: " 1.2.3-unstable.4" }, - ]) { - expect(() => createRuntimeDispatchMarker({ ...expected, ...changed })).toThrow(); - } - }); - - it("rejects a zero canonical run ID in an existing marker", () => { - const marker = { - ...createRuntimeDispatchMarker(expected), - canonicalRunId: "0", - }; - const api = provenance("0"); - expect(() => - validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected) - ).toThrow("Canonical workflow run ID must be canonical numeric"); - }); -}); diff --git a/nodejs/test/runtime-release-identity.test.ts b/nodejs/test/runtime-release-identity.test.ts new file mode 100644 index 0000000000..578a8367bb --- /dev/null +++ b/nodejs/test/runtime-release-identity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + type RuntimeReleaseInputs, + validateRuntimeReleaseInputs, +} from "../scripts/runtime-release-identity.js"; + +const inputs: RuntimeReleaseInputs = { + channel: "unstable", + mode: "internal", + runtimeRunId: "100", + runtimeSha: "a".repeat(40), + runtimeVersion: "1.2.3-unstable.4", + versionOverride: "", +}; + +describe("runtime release identity", () => { + it.each([ + ["canary", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.2.4-canary.8.gdef5678.unsigned"], + ["canary", "9.9.9-canary.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0"], + ["unstable", "9.9.9-unstable.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0+build.42"], + ] satisfies [RuntimeReleaseInputs["channel"], string][])( + "accepts a %s runtime version with valid producer suffixes: %s", + (channel, runtimeVersion) => { + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel, + mode: channel === "canary" ? "tests-only" : "internal", + runtimeVersion, + }) + ).not.toThrow(); + } + ); + + it.each([ + ["unstable", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.0.83-5.unstable.123.gabcdef0"], + ["canary", "1.2.4-canaryish.7.gdef5678"], + ] satisfies [RuntimeReleaseInputs["channel"], string][])( + "rejects a runtime version outside the %s channel: %s", + (channel, runtimeVersion) => { + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel, + mode: channel === "canary" ? "tests-only" : "internal", + runtimeVersion, + }) + ).toThrow(`does not belong to the '${channel}' channel`); + } + ); + + it("enforces the channel and mode matrix", () => { + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel: "canary", + mode: "tests-only", + runtimeVersion: "1.2.3-canary.4", + }) + ).not.toThrow(); + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + mode: "tests-only", + }) + ).toThrow("Invalid channel or mode combination"); + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel: "invalid" as RuntimeReleaseInputs["channel"], + }) + ).toThrow("Invalid release channel"); + }); + + it("rejects non-canonical provenance and identity inputs", () => { + for (const changed of [ + { runtimeRunId: "0" }, + { runtimeRunId: "0100" }, + { runtimeVersion: " 1.2.3-unstable.4" }, + { runtimeSha: "A".repeat(40) }, + { versionOverride: " 1.2.3-unstable.4" }, + ]) { + expect(() => validateRuntimeReleaseInputs({ ...inputs, ...changed })).toThrow(); + } + }); + + it("rejects canary SDK version overrides", () => { + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel: "canary", + mode: "internal", + runtimeVersion: "1.2.3-canary.4", + versionOverride: "1.2.3-canary.manual", + }) + ).toThrow("Canary runs do not accept a version override"); + }); +}); diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts index 576b760b59..9c9e4505cb 100644 --- a/nodejs/test/unstable-version.test.ts +++ b/nodejs/test/unstable-version.test.ts @@ -6,6 +6,7 @@ import { } from "../scripts/unstable-version.js"; const sha = "abcdef0123456789abcdef0123456789abcdef01"; +const otherSha = "123456789abcdef0123456789abcdef012345678"; const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ tag_name, published_at, @@ -48,9 +49,12 @@ describe("unstable SDK version planning", () => { expect(calculateUnstableVersion({ ...options, runNumber: "8124" })).not.toBe( calculateUnstableVersion(options) ); + expect(calculateUnstableVersion({ ...options, sdkSha: otherSha })).not.toBe( + calculateUnstableVersion(options) + ); }); - it("accepts only explicit unstable SemVer overrides", () => { + it("appends workflow identity to explicit unstable SemVer bases", () => { const options = { createdAt: "2026-09-04T00:00:00Z", firstParentTags: [], @@ -63,7 +67,21 @@ describe("unstable SDK version planning", () => { ...options, versionOverride: "2.0.0-unstable.manual.1", }) - ).toBe("2.0.0-unstable.manual.1"); + ).toBe("2.0.0-unstable.manual.1.8123.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + runNumber: "8124", + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1.8124.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + sdkSha: otherSha, + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1.8123.g1234567"); expect(() => calculateUnstableVersion({ ...options, versionOverride: "2.0.0-preview.1" }) ).toThrow("unstable prerelease"); @@ -71,6 +89,22 @@ describe("unstable SDK version planning", () => { }); describe("canary SDK version planning", () => { + it("is stable across retries and unique across new workflow runs", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + releases: [release("v1.0.11")], + runNumber: "8123", + sdkSha: sha, + }; + expect(calculateCanaryVersion(options)).toBe(calculateCanaryVersion(options)); + expect(calculateCanaryVersion({ ...options, runNumber: "8124" })).not.toBe( + calculateCanaryVersion(options) + ); + expect(calculateCanaryVersion({ ...options, sdkSha: otherSha })).not.toBe( + calculateCanaryVersion(options) + ); + }); + it("freezes the stable baseline at workflow creation time", () => { const options = { createdAt: "2026-09-04T00:00:00Z", From 3f0d07f7fb9434970abb5ba3eaea995214927ea8 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Thu, 10 Sep 2026 13:41:40 -0700 Subject: [PATCH 16/23] Fix runtime-backed SDK test restoration Normalize Windows Git Bash paths before extracting runtime artifacts and scope ambient acquired packages to exact requested runtime versions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 16 +++-- nodejs/scripts/releaseArtifacts.ts | 47 ++++++++++--- nodejs/test/release-workflows.test.ts | 7 +- nodejs/test/runtimeArtifacts.test.ts | 99 +++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index a2e619fb4d..5c836bc266 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -184,8 +184,12 @@ jobs: path: ${{ runner.temp }}/runtime-package-artifact - name: Extract validated runtime packages run: | - rm -rf "$RUNNER_TEMP/runtime-packages" - tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP" + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - name: Select the acquired runtime env: COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages @@ -229,8 +233,12 @@ jobs: path: ${{ runner.temp }}/runtime-package-artifact - name: Extract validated runtime packages run: | - rm -rf "$RUNNER_TEMP/runtime-packages" - tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP" + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - name: Build and verify exact package set env: COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages diff --git a/nodejs/scripts/releaseArtifacts.ts b/nodejs/scripts/releaseArtifacts.ts index cf493f47cb..e9b6c1107f 100644 --- a/nodejs/scripts/releaseArtifacts.ts +++ b/nodejs/scripts/releaseArtifacts.ts @@ -1,5 +1,13 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { dirname, join } from "node:path"; import { x as extractTar } from "tar"; import { @@ -24,6 +32,24 @@ const packageDownloads = new Map>(); const checksumDownloads = new Map>>(); const DEFAULT_FETCH_TIMEOUT_MS = 60_000; +function validateLocalPackage( + packageDirectory: string, + platform: string, + expectedVersion?: string +): string | undefined { + const packageRoot = join(packageDirectory, platform); + const manifestPath = join(packageRoot, "package.json"); + validateFile(manifestPath, `${platform} runtime package manifest`); + if (expectedVersion !== undefined) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }; + if (manifest.version !== expectedVersion) { + return undefined; + } + } + validateFile(join(packageRoot, "prebuilds", platform, "runtime.node"), "Copilot runtime.node"); + return packageRoot; +} + async function fetchWithRetry( fetcher: typeof globalThis.fetch, url: string, @@ -109,16 +135,15 @@ export async function ensureCopilotPackage( ): Promise { const platform = options.platform ?? getRuntimePlatform(); const environment = options.environment ?? process.env; - const packageDirectory = - options.packageDirectory ?? environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; - if (packageDirectory) { - const packageRoot = join(packageDirectory, platform); - validateFile(join(packageRoot, "package.json"), `${platform} runtime package manifest`); - validateFile( - join(packageRoot, "prebuilds", platform, "runtime.node"), - "Copilot runtime.node" - ); - return packageRoot; + if (options.packageDirectory) { + return validateLocalPackage(options.packageDirectory, platform)!; + } + const workflowPackageDirectory = environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; + if (workflowPackageDirectory) { + const packageRoot = validateLocalPackage(workflowPackageDirectory, platform, version); + if (packageRoot) { + return packageRoot; + } } // lgtm[js/trivial-conditional] This generated constant is true for internal canary builds. if (version === COPILOT_CLI_VERSION && COPILOT_CLI_USE_NPM_PACKAGE) { diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 0ef6853e07..daf97a4a39 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -174,8 +174,13 @@ describe("runtime-backed Node release implementation", () => { for (const consumer of [testJob, packageJob]) { expect(consumer).toContain("path: ${{ runner.temp }}/runtime-package-artifact"); + expect(consumer).toContain("if command -v cygpath >/dev/null 2>&1; then"); + expect(consumer).toContain('runner_temp="$(cygpath -u "$runner_temp")"'); expect(consumer).toContain( - 'tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz" -C "$RUNNER_TEMP"' + 'tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp"' + ); + expect(consumer).not.toContain( + 'tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz"' ); expect(consumer).not.toContain("path: ${{ runner.temp }}/runtime-packages\n"); } diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts index d6882df23f..0969d03ff6 100644 --- a/nodejs/test/runtimeArtifacts.test.ts +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -16,6 +16,7 @@ import { } from "../src/runtimeArtifacts.js"; import { COPILOT_CLI_USE_NPM_PACKAGE, COPILOT_CLI_VERSION } from "../src/cliVersion.js"; import { ensureCopilotPackage } from "../scripts/releaseArtifacts.js"; +import { getLegacyCliPathForTests } from "./e2e/harness/sdkTestContext.js"; describe("defaultRuntimeCacheRoot", () => { it.each([ @@ -285,6 +286,104 @@ describe("release package acquisition", () => { expect(fetcher).not.toHaveBeenCalled(); }); + it("uses an ambient acquired package only for its exact runtime version", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-runtime-environment-")); + const platform = "linux-x64"; + const packageRoot = join(root, platform); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(packageRoot, "app.js"), "legacy CLI"); + writeFileSync(join(prebuilds, "runtime.node"), "runtime"); + const fetcher = vi.fn(() => { + throw new Error("matching ambient runtime resolution must not fetch"); + }); + const environment = { + ...process.env, + COPILOT_SDK_RUNTIME_PACKAGE_DIR: root, + }; + + await expect( + ensureCopilotPackage(COPILOT_CLI_VERSION, { + environment, + fetch: fetcher, + platform, + }) + ).resolves.toBe(packageRoot); + expect(readFileSync(join(packageRoot, "app.js"), "utf8")).toBe("legacy CLI"); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("resolves the legacy E2E CLI from the matching acquired package", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-legacy-runtime-")); + const platform = getRuntimePlatform(); + const packageRoot = join(root, platform); + mkdirSync(join(packageRoot, "prebuilds", platform), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(packageRoot, "app.js"), "legacy CLI"); + writeFileSync(join(packageRoot, "prebuilds", platform, "runtime.node"), "runtime"); + vi.stubEnv("COPILOT_SDK_RUNTIME_PACKAGE_DIR", root); + + try { + await expect(getLegacyCliPathForTests()).resolves.toBe(join(packageRoot, "app.js")); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("ignores an ambient acquired package for a different requested version", async () => { + const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-runtime-version-guard-")); + const acquiredRoot = join(sourceRoot, "acquired"); + const platform = "linux-x64"; + const acquiredPackageRoot = join(acquiredRoot, platform); + const acquiredPrebuilds = join(acquiredPackageRoot, "prebuilds", platform); + mkdirSync(acquiredPrebuilds, { recursive: true }); + writeFileSync( + join(acquiredPackageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(acquiredPrebuilds, "runtime.node"), "acquired runtime"); + + const packageRoot = join(sourceRoot, "package"); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(join(prebuilds, "runtime.node"), "controlled runtime"); + mkdirSync(join(packageRoot, "schemas"), { recursive: true }); + writeFileSync(join(packageRoot, "schemas", "api.schema.json"), "{}"); + + const archivePath = join(sourceRoot, "runtime.tgz"); + await createTar({ cwd: sourceRoot, file: archivePath, gzip: true }, ["package"]); + const archive = readFileSync(archivePath); + const version = "1.2.3-controlled.1"; + const assetName = getRuntimeReleaseAssetName(version, platform); + const checksum = createHash("sha256").update(archive).digest("hex"); + const fetcher = vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/SHA256SUMS.txt") + ? new Response(`${checksum} ${assetName}\n`) + : new Response(archive) + ); + + const resolved = await ensureCopilotPackage(version, { + cacheRoot: join(sourceRoot, "cache"), + environment: { + ...process.env, + COPILOT_SDK_RUNTIME_PACKAGE_DIR: acquiredRoot, + }, + fetch: fetcher, + platform, + }); + + expect(resolved).not.toBe(acquiredPackageRoot); + expect(readFileSync(join(resolved, "schemas", "api.schema.json"), "utf8")).toBe("{}"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it("downloads, verifies, and caches a release package for packaging", async () => { const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-release-source-")); const packageRoot = join(sourceRoot, "package"); From 59bc2e1b50d7fe03097f56ce74f56a8a48d5dffd Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 09:40:05 -0700 Subject: [PATCH 17/23] Shorten runtime SDK job names Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 5c836bc266..6a739dba8d 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -46,7 +46,7 @@ env: jobs: plan: - name: Freeze runtime-backed release identity + name: Plan runs-on: ubuntu-latest environment: cicd permissions: @@ -107,7 +107,7 @@ jobs: } >> "$GITHUB_OUTPUT" acquire-runtime: - name: Acquire exact runtime packages + name: Acquire runtime needs: plan runs-on: ubuntu-latest environment: cicd @@ -152,7 +152,7 @@ jobs: retention-days: 7 test: - name: Runtime-backed Node tests (${{ matrix.os }}) + name: Test (${{ matrix.os }}) needs: [plan, acquire-runtime] permissions: contents: read @@ -209,7 +209,7 @@ jobs: run: npm test package: - name: Build and verify nine SDK packages + name: Build and verify needs: [plan, acquire-runtime, test] runs-on: ubuntu-latest permissions: @@ -276,7 +276,7 @@ jobs: retention-days: 30 publish-internal: - name: Publish and verify SDK internally + name: Publish internally if: | always() && !cancelled() && @@ -352,7 +352,7 @@ jobs: ' "$SDK_VERSION" publish-public: - name: Publish unstable SDK publicly + name: Publish publicly if: inputs.channel == 'unstable' needs: [plan, publish-internal] runs-on: ubuntu-latest From 42550fed616a66c7e3c16c00777b823b1f707983 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 10:58:09 -0700 Subject: [PATCH 18/23] Rename runtime SDK publish mode Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 10 +++++----- docs/developer-docs/unstable-releases.md | 14 +++++++------- nodejs/scripts/runtime-release-identity.ts | 6 +++--- nodejs/test/release-workflows.test.ts | 17 ++++++++++++++++- nodejs/test/runtime-release-identity.test.ts | 17 +++++++++++++---- 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 6a739dba8d..bd469caa44 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -24,13 +24,13 @@ on: required: true type: string mode: - description: "tests-only for canary verification; internal for publication" + description: "tests-only validates canary; publish releases to channel destinations" required: true type: choice options: - tests-only - - internal - default: internal + - publish + default: publish version: description: "Unstable SemVer base for a direct manual run; workflow identity is appended" required: false @@ -280,7 +280,7 @@ jobs: if: | always() && !cancelled() && - inputs.mode == 'internal' && + inputs.mode == 'publish' && needs.plan.result == 'success' && needs.package.result == 'success' needs: [plan, package] @@ -353,7 +353,7 @@ jobs: publish-public: name: Publish publicly - if: inputs.channel == 'unstable' + if: inputs.channel == 'unstable' && inputs.mode == 'publish' needs: [plan, publish-internal] runs-on: ubuntu-latest concurrency: diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 40eef9a0d8..8218e9e073 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -19,16 +19,16 @@ optional internal publication, and public unstable npm publication. The runtime dispatch includes these inputs: -* `channel`: `canary` or `unstable` -* `runtime_version`: Exact runtime package version -* `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -* `runtime_run_id`: Source runtime workflow run ID for provenance -* `mode`: `tests-only` or `internal` for canary; `internal` for unstable +- `channel`: `canary` or `unstable` +- `runtime_version`: Exact runtime package version +- `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +- `runtime_run_id`: Source runtime workflow run ID for provenance +- `mode`: `tests-only` or `publish` for canary; `publish` for unstable Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The optional `version` input is available only for unstable and must be an unstable SemVer base. The workflow appends its run number and SDK SHA so each new -dispatch still creates a unique version. +dispatch still creates a unique version. Unstable runs reject `tests-only`. ## Release gates @@ -52,7 +52,7 @@ or recalculating its identity. ## Publication order -Canary `tests-only` runs stop after package verification. Canary `internal` +Canary `tests-only` runs stop after package verification. Canary `publish` runs publish platform packages before the umbrella package to the Azure `copilot-canary` feed, then perform a clean install and package version check. No canary job has a public npm publication path. diff --git a/nodejs/scripts/runtime-release-identity.ts b/nodejs/scripts/runtime-release-identity.ts index a4a62ecc23..47a3c59568 100644 --- a/nodejs/scripts/runtime-release-identity.ts +++ b/nodejs/scripts/runtime-release-identity.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import * as semver from "semver"; export type RuntimeReleaseChannel = "canary" | "unstable"; -export type RuntimeReleaseMode = "internal" | "tests-only"; +export type RuntimeReleaseMode = "publish" | "tests-only"; export interface RuntimeReleaseInputs { channel: RuntimeReleaseChannel; @@ -38,8 +38,8 @@ export function validateRuntimeReleaseInputs(inputs: RuntimeReleaseInputs): void assert(inputs.channel === "canary" || inputs.channel === "unstable", "Invalid release channel"); assert( inputs.channel === "canary" - ? inputs.mode === "tests-only" || inputs.mode === "internal" - : inputs.mode === "internal", + ? inputs.mode === "tests-only" || inputs.mode === "publish" + : inputs.mode === "publish", "Invalid channel or mode combination" ); assert.match( diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index daf97a4a39..78a9c20557 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -76,6 +76,7 @@ describe("runtime-driven Node SDK entry contract", () => { expect(runtimeSdk).not.toContain("runtime_source:"); expect(runtimeReleaseIdentity).toContain('inputs.channel === "canary"'); expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); + expect(runtimeReleaseIdentity).toContain('inputs.mode === "publish"'); expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); expect(runtimeSdk).toContain("npx tsx scripts/runtime-release-identity.ts"); }); @@ -124,7 +125,8 @@ describe("runtime-driven Node SDK entry contract", () => { describe("runtime-backed Node release implementation", () => { it("enforces the channel, source, and mode matrix", () => { expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); - expect(runtimeReleaseIdentity).toContain('inputs.mode === "internal"'); + expect(runtimeReleaseIdentity).toContain('inputs.mode === "publish"'); + expect(runtimeReleaseIdentity).not.toContain('inputs.mode === "internal"'); expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); expect(runtimeReleaseIdentity).toContain( "Runtime workflow run ID must be a positive canonical integer" @@ -132,6 +134,19 @@ describe("runtime-backed Node release implementation", () => { expect(runtimeReleaseIdentity).toContain("validateRuntimeVersionChannel"); }); + it("maps publish mode to channel-specific destinations", () => { + expect(runtimeSdk).toContain("- tests-only"); + expect(runtimeSdk).toContain("- publish"); + expect(runtimeSdk).not.toMatch(/^\s+- internal\s*$/m); + expect(runtimeSdk).toContain("default: publish"); + expect(internalPublicationJob).toContain("inputs.mode == 'publish'"); + expect(internalPublicationJob).not.toContain("inputs.channel == 'unstable'"); + expect(publicPublicationJob).toContain( + "if: inputs.channel == 'unstable' && inputs.mode == 'publish'" + ); + expect(publicPublicationJob).toContain("needs: [plan, publish-internal]"); + }); + it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { expect(runtimeSdk).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); diff --git a/nodejs/test/runtime-release-identity.test.ts b/nodejs/test/runtime-release-identity.test.ts index 578a8367bb..693fbd26dd 100644 --- a/nodejs/test/runtime-release-identity.test.ts +++ b/nodejs/test/runtime-release-identity.test.ts @@ -6,7 +6,7 @@ import { const inputs: RuntimeReleaseInputs = { channel: "unstable", - mode: "internal", + mode: "publish", runtimeRunId: "100", runtimeSha: "a".repeat(40), runtimeVersion: "1.2.3-unstable.4", @@ -28,7 +28,7 @@ describe("runtime release identity", () => { validateRuntimeReleaseInputs({ ...inputs, channel, - mode: channel === "canary" ? "tests-only" : "internal", + mode: channel === "canary" ? "tests-only" : "publish", runtimeVersion, }) ).not.toThrow(); @@ -46,7 +46,7 @@ describe("runtime release identity", () => { validateRuntimeReleaseInputs({ ...inputs, channel, - mode: channel === "canary" ? "tests-only" : "internal", + mode: channel === "canary" ? "tests-only" : "publish", runtimeVersion, }) ).toThrow(`does not belong to the '${channel}' channel`); @@ -62,6 +62,15 @@ describe("runtime release identity", () => { runtimeVersion: "1.2.3-canary.4", }) ).not.toThrow(); + expect(() => + validateRuntimeReleaseInputs({ + ...inputs, + channel: "canary", + mode: "publish", + runtimeVersion: "1.2.3-canary.4", + }) + ).not.toThrow(); + expect(() => validateRuntimeReleaseInputs(inputs)).not.toThrow(); expect(() => validateRuntimeReleaseInputs({ ...inputs, @@ -93,7 +102,7 @@ describe("runtime release identity", () => { validateRuntimeReleaseInputs({ ...inputs, channel: "canary", - mode: "internal", + mode: "publish", runtimeVersion: "1.2.3-canary.4", versionOverride: "1.2.3-canary.manual", }) From 691ce53b29455b9a4c69e260dcdd0afa05b82692 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 11:43:33 -0700 Subject: [PATCH 19/23] Restore direct unstable SDK publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/publish.yml | 89 ++++++++++++++-- .github/workflows/runtime-sdk.yml | 1 + docs/developer-docs/unstable-releases.md | 98 +++++++++++------- nodejs/scripts/npm-release.js | 6 +- nodejs/scripts/release-manifest.ts | 123 ++++++++++++++-------- nodejs/scripts/unstable-version.ts | 41 ++++++-- nodejs/test/npm-release.test.ts | 2 +- nodejs/test/release-manifest.test.ts | 36 ++++++- nodejs/test/release-workflows.test.ts | 125 ++++++++++++++++++++++- nodejs/test/unstable-version.test.ts | 56 ++++++++-- 10 files changed, 461 insertions(+), 116 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 77d0a3832c..cc50df1957 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,8 +14,9 @@ on: options: - latest - prerelease + - unstable version: - description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." + description: "Optional final version for latest/prerelease, or unstable SemVer base before workflow identity" type: string required: false @@ -23,7 +24,7 @@ permissions: contents: read concurrency: - group: publish + group: ${{ inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }} cancel-in-progress: false jobs: @@ -31,8 +32,11 @@ jobs: version: name: Calculate Version runs-on: ubuntu-latest + permissions: + actions: read + contents: read outputs: - version: ${{ steps.version.outputs.VERSION }} + version: ${{ steps.unstable_version.outputs.VERSION || steps.version.outputs.VERSION }} current: ${{ steps.version.outputs.CURRENT }} current-prerelease: ${{ steps.version.outputs.CURRENT_PRERELEASE }} defaults: @@ -45,15 +49,38 @@ jobs: DIST_TAG: ${{ inputs.dist-tag }} run: | case "$DIST_TAG" in - latest|prerelease) ;; - *) echo "::error::publish.yml only accepts latest or prerelease."; exit 1 ;; + latest|prerelease|unstable) ;; + *) echo "::error::publish.yml only accepts latest, prerelease, or unstable."; exit 1 ;; esac - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }} - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" - run: npm ci --ignore-scripts + - name: Plan unstable version + if: inputs.dist-tag == 'unstable' + id: unstable_version + env: + GH_TOKEN: ${{ github.token }} + SDK_CHANNEL: unstable + SDK_SHA: ${{ github.sha }} + SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + VERSION="$(npx tsx scripts/unstable-version.ts)" + npm exec -- semver "$VERSION" >/dev/null + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Planned unstable version: $VERSION" >> "$GITHUB_STEP_SUMMARY" - name: Get version + if: inputs.dist-tag != 'unstable' id: version run: | CURRENT="$(node scripts/get-version.js current)" @@ -86,7 +113,7 @@ jobs: { echo "::error::Version '$VERSION' is not valid SemVer."; exit 1; } case "$PRERELEASE_NAMESPACE" in canary|unstable) - echo "::error::The '$PRERELEASE_NAMESPACE' prerelease namespace is reserved for runtime-driven SDK releases." + echo "::error::The '$PRERELEASE_NAMESPACE' prerelease namespace is reserved for dedicated SDK release channels." exit 1 ;; esac @@ -98,6 +125,7 @@ jobs: fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Verify version is available on public npm + if: inputs.dist-tag != 'unstable' env: VERSION: ${{ steps.version.outputs.VERSION }} run: | @@ -136,17 +164,24 @@ jobs: exit 1 fi npm run verify:release-packages + - name: Create unstable package manifest + if: inputs.dist-tag == 'unstable' + env: + SDK_VERSION: ${{ needs.version.outputs.version }} + run: npm run release:manifest -- create-package-set package-set-manifest.json . - name: Upload artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: nodejs-package - path: nodejs/github-copilot-sdk-*.tgz + path: | + nodejs/github-copilot-sdk-*.tgz + nodejs/package-set-manifest.json if-no-files-found: error publish-nodejs: name: Publish Node.js SDK needs: [version, package-nodejs] - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable' runs-on: ubuntu-latest permissions: actions: read @@ -157,6 +192,10 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" + - name: Install release dependencies + if: inputs.dist-tag == 'unstable' + working-directory: ./nodejs + run: npm ci --ignore-scripts - name: Update npm for OIDC support run: npm i -g "npm@11.6.3" - name: Download Node.js package @@ -164,12 +203,22 @@ jobs: with: name: nodejs-package path: ./dist + - name: Validate unstable package manifest + if: inputs.dist-tag == 'unstable' + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify-package-set \ + dist/package-set-manifest.json dist - name: Publish tarball to public npm env: DIST_TAG: ${{ github.event.inputs.dist-tag }} VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail + if [ "$DIST_TAG" = "unstable" ]; then + node nodejs/scripts/npm-release.js publish-manifest \ + dist/package-set-manifest.json dist unstable https://registry.npmjs.org public + exit 0 + fi shopt -s nullglob TARBALLS=(./dist/*.tgz) if [ "${#TARBALLS[@]}" -ne 9 ]; then @@ -212,6 +261,10 @@ jobs: needs: [version, publish-nodejs] environment: cicd runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.dist-tag }} + cancel-in-progress: false + queue: max permissions: actions: read contents: read @@ -224,11 +277,20 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" + - name: Install release dependencies + if: inputs.dist-tag == 'unstable' + working-directory: ./nodejs + run: npm ci --ignore-scripts - name: Download Node.js package uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: nodejs-package path: ./dist + - name: Validate unstable package manifest + if: inputs.dist-tag == 'unstable' + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify-package-set \ + dist/package-set-manifest.json dist - name: Azure Login (OIDC -> id-cpd-ci) uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: @@ -255,6 +317,11 @@ jobs: echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." exit 1 fi + if [ "$DIST_TAG" = "unstable" ]; then + node nodejs/scripts/npm-release.js publish-manifest \ + dist/package-set-manifest.json dist unstable "$FEED_URL" azure + exit 0 + fi shopt -s nullglob TARBALLS=(./dist/*.tgz) if [ "${#TARBALLS[@]}" -ne 9 ]; then @@ -294,6 +361,7 @@ jobs: publish-dotnet: name: Publish .NET SDK + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -336,6 +404,7 @@ jobs: publish-rust: name: Publish Rust SDK + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -379,6 +448,7 @@ jobs: publish-python: name: Publish Python SDK + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -416,7 +486,7 @@ jobs: publish-java: name: Publish Java SDK - if: github.ref == 'refs/heads/main' + if: inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' needs: version permissions: contents: write @@ -441,6 +511,7 @@ jobs: if: | always() && github.ref == 'refs/heads/main' && + inputs.dist-tag != 'unstable' && needs.version.result == 'success' && needs.publish-nodejs.result == 'success' && needs.publish-dotnet.result == 'success' && diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index bd469caa44..47e438f768 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -89,6 +89,7 @@ jobs: SDK_CHANNEL: ${{ inputs.channel }} SDK_SHA: ${{ github.sha }} SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_ID: ${{ github.run_id }} WORKFLOW_RUN_NUMBER: ${{ github.run_number }} run: | set -euo pipefail diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 8218e9e073..bf573ff519 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -1,19 +1,26 @@ # Canary and unstable Node SDK releases -The SDK release workflows consume exact runtime platform packages produced by -`github/copilot-agent-runtime`. Canary releases remain internal. Unstable -releases publish the same self-contained Node SDK tarballs internally and then -to public npm. +Canary releases remain an internal runtime-to-SDK channel. Unstable Node SDK +releases can either publish the selected SDK branch with its existing bundled +runtime or package exact runtime inputs supplied by `github/copilot-agent-runtime`. -## Runtime handoff +## Entry points + +Use `.github/workflows/publish.yml` for a direct unstable release of the +selected SDK branch as-is. The workflow packages its selected or bundled +runtime, publishes the nine Node SDK packages to public npm, then mirrors those +packages to the internal Azure feed. Direct unstable releases can run from a +non-main branch. They do not publish .NET, Rust, Python, Java, or Go releases, +and they do not create an SDK GitHub Release. The same workflow remains the +normal stable and prerelease publisher for all SDK languages. The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each handoff includes the exact runtime version, full source SHA, and source workflow run ID. The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This -runtime-driven Node entry is separate from `publish.yml`, which remains the -manual stable and prerelease entry for all SDK languages. `runtime-sdk.yml` +runtime-driven Node entry is separate from the direct unstable path. +`runtime-sdk.yml` owns runtime acquisition, cross-platform tests, packaging, manifest retention, optional internal publication, and public unstable npm publication. @@ -27,7 +34,7 @@ The runtime dispatch includes these inputs: Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The optional `version` input is available only for unstable and must be an unstable -SemVer base. The workflow appends its run number and SDK SHA so each new +SemVer base. The workflow appends its run ID and SDK SHA so each new dispatch still creates a unique version. Unstable runs reject `tests-only`. ## Release gates @@ -37,18 +44,27 @@ GitHub Packages with the job-scoped `GITHUB_TOKEN`. The workflows validate npm integrity, runtime version and SHA metadata, the exact package set, platform metadata, repository metadata, and required runtime files. -The workflows run runtime-backed Node SDK tests on Ubuntu, macOS, and Windows. -They then build and verify eight self-contained +The runtime-driven workflow runs runtime-backed Node SDK tests on Ubuntu, +macOS, and Windows. It then builds and verifies eight self-contained `@github/copilot-sdk-` packages and the `@github/copilot-sdk` umbrella package. The checked-in `COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; runtime npm packages are build inputs rather than published dependencies. -An unstable run freezes a version from the nearest eligible SDK release on the -selected branch's first-parent history, the workflow run number, and the SDK -SHA. The packaging job writes all nine tarballs and `release-manifest.json` to -one retained artifact. Publication jobs use that artifact without rebuilding -or recalculating its identity. +Both unstable entry points use the same version planner. A generated version is +`-unstable..g`, where the target core +comes from the nearest eligible SDK release on the selected branch's +first-parent history. A stable baseline increments the patch; a prerelease +baseline retains its release core. An explicit unstable base uses +`..g`. GitHub workflow run +IDs are repository-wide, so the two entry points cannot collide when their +per-workflow run numbers happen to match. Release eligibility is frozen at the +workflow creation time, so a same-run retry keeps its identity and each new +dispatch receives a new version. + +The runtime-driven packaging job writes all nine tarballs and +`release-manifest.json` to one retained artifact. Publication jobs use that +artifact without rebuilding or recalculating its identity. ## Publication order @@ -57,29 +73,36 @@ runs publish platform packages before the umbrella package to the Azure `copilot-canary` feed, then perform a clean install and package version check. No canary job has a public npm publication path. -Every unstable run publishes the retained platform tarballs and umbrella -tarball to Azure first. A clean internal install must start the exact selected -SDK package version before public publication begins. The strict acquisition -and package validation gates verify the embedded runtime identity. The public -job uses npm trusted publishing from `runtime-sdk.yml` and publishes the same -tarballs under the `unstable` dist-tag, with the umbrella package last. - -The workflow validates all nine retained tarballs against the local -`release-manifest.json` SHA-512 values before publication. A successful -`npm publish` completes a package publication. A recognized immutable-version -conflict means the package was already published and also completes that -package publication; output feeds do not need to expose `dist.integrity`. -Azure authentication allows the workflow to add or advance its tag, but it -refuses to rewind a tag that points to a newer version. Public npm trusted -publishing sets `unstable` during publication. The workflow then verifies all -nine `@unstable` resolutions. It fails rather than attempting a separate -public dist-tag mutation if any resolution differs. +Direct `publish.yml` unstable runs publish the platform packages and umbrella +package to public npm first, then mirror the same Node package set to Azure. + +Runtime-driven unstable runs publish the retained platform tarballs and +umbrella tarball to Azure first. A clean internal install must start the exact +selected SDK package version before public publication begins. The strict +acquisition and package validation gates verify the embedded runtime identity. +The public job uses npm trusted publishing from `runtime-sdk.yml` and publishes +the same tarballs under the `unstable` dist-tag, with the umbrella package last. + +The two entry points share concurrency locks for public npm and internal Azure +publication so they cannot race either set of `unstable` tags. + +Both unstable paths validate all nine retained tarballs against local SHA-512 +manifest values before publication. A successful `npm publish` completes a +package publication. A recognized immutable-version conflict means the package +was already published and also completes that package publication; output +feeds do not need to expose `dist.integrity`. Azure authentication allows the +workflow to add or advance its tag, but it refuses to rewind a tag that points +to a newer version. Public npm trusted publishing sets `unstable` during +publication. The workflow then verifies all nine `@unstable` resolutions. It +fails rather than attempting a separate public dist-tag mutation if any +resolution differs. ## Recovery Use **Re-run failed jobs** on the original workflow run for normal recovery. -The run number, frozen version, and retained artifact remain unchanged. Do not -rerun a successful packaging job merely to recover a publication job. +The workflow run ID and frozen version remain unchanged. Runtime-driven runs +also retain the package artifact. Do not rerun a successful packaging job +merely to recover a publication job. The runtime run ID is retained as provenance only. Re-running the same SDK workflow run retries its frozen SDK version and retained artifact. A new @@ -100,6 +123,7 @@ eight with its workflow token. Confirm npm trusted publisher configuration authorizes both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for `@github/copilot-sdk` and all eight `@github/copilot-sdk-` package -names. The first identity publishes stable and prerelease versions; the second -publishes unstable versions. Do not add an npm token, workflow indirection, or -a separate protected SDK publication environment. +names. The first identity publishes stable, prerelease, and direct unstable +versions; the second publishes runtime-driven unstable versions. Do not add an +npm token, workflow indirection, or a separate protected SDK publication +environment. diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index 3adce9ded9..4eabd70f7f 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -133,7 +133,11 @@ export async function publishTarball(tarball, tag, registry, mode, identity, run function readReleaseManifest(manifestPath, packageDirectory) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.packages)) { + if ( + manifest.schemaVersion !== 1 || + typeof manifest.sdk?.version !== "string" || + !Array.isArray(manifest.packages) + ) { throw new Error("Unsupported release manifest."); } if (manifest.packages.length !== 9) { diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts index 2650c91a62..8c22cb01c7 100644 --- a/nodejs/scripts/release-manifest.ts +++ b/nodejs/scripts/release-manifest.ts @@ -17,9 +17,16 @@ export interface ReleaseManifestPackage { size: number; } -export interface ReleaseManifest { - channel: "canary" | "unstable"; +export interface PackageSetManifest { packages: ReleaseManifestPackage[]; + schemaVersion: 1; + sdk: { + version: string; + }; +} + +export interface ReleaseManifest extends PackageSetManifest { + channel: "canary" | "unstable"; runtime: { repository: "github/copilot-agent-runtime"; runId: string; @@ -27,7 +34,6 @@ export interface ReleaseManifest { source: "github-packages"; version: string; }; - schemaVersion: 1; sdk: { ref: string; repository: "github/copilot-sdk"; @@ -93,13 +99,43 @@ export async function createReleaseManifest( validateFullSha(metadata.runtimeSha, "Runtime SHA"); validateRuntimeVersionChannel(metadata.runtimeVersion, metadata.channel); assert(Number.isFinite(Date.parse(metadata.createdAt)), "Workflow creation time is invalid"); + const packageSet = await createPackageSetManifest(packageDirectory, metadata.sdkVersion); + return { + ...packageSet, + channel: metadata.channel, + sdk: { + ...packageSet.sdk, + sha: metadata.sdkSha, + ref: metadata.sdkRef, + repository: "github/copilot-sdk", + }, + runtime: { + version: metadata.runtimeVersion, + sha: metadata.runtimeSha, + source: "github-packages", + repository: "github/copilot-agent-runtime", + runId: metadata.runtimeRunId, + }, + workflow: { + runId: metadata.workflowRunId, + runNumber: metadata.workflowRunNumber, + createdAt: metadata.createdAt, + }, + }; +} + +export async function createPackageSetManifest( + packageDirectory: string, + sdkVersion: string +): Promise { + assert.equal(semver.valid(sdkVersion), sdkVersion, "Invalid SDK version"); const packages: ReleaseManifestPackage[] = []; for (const archive of globSync("github-copilot-sdk-*.tgz", { cwd: packageDirectory, absolute: true, })) { const packed = await readPackedManifest(archive); - if (packed.version !== metadata.sdkVersion || !expectedPackageNames.has(packed.name)) { + if (packed.version !== sdkVersion || !expectedPackageNames.has(packed.name)) { continue; } const bytes = readFileSync(archive); @@ -118,49 +154,19 @@ export async function createReleaseManifest( ); return { schemaVersion: 1, - channel: metadata.channel, sdk: { - version: metadata.sdkVersion, - sha: metadata.sdkSha, - ref: metadata.sdkRef, - repository: "github/copilot-sdk", - }, - runtime: { - version: metadata.runtimeVersion, - sha: metadata.runtimeSha, - source: "github-packages", - repository: "github/copilot-agent-runtime", - runId: metadata.runtimeRunId, - }, - workflow: { - runId: metadata.workflowRunId, - runNumber: metadata.workflowRunNumber, - createdAt: metadata.createdAt, + version: sdkVersion, }, packages, }; } -export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { +export function verifyPackageSetManifest( + manifest: PackageSetManifest, + packageDirectory: string +): void { assert.equal(manifest.schemaVersion, 1, "Unsupported release manifest schema"); - assert( - manifest.channel === "canary" || manifest.channel === "unstable", - "Invalid release channel" - ); - validateFullSha(manifest.sdk.sha, "SDK SHA"); - validateFullSha(manifest.runtime.sha, "Runtime SHA"); assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); - validateRuntimeVersionChannel(manifest.runtime.version, manifest.channel); - assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); - assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); - assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); - assert( - Number.isFinite(Date.parse(manifest.workflow.createdAt)), - "Invalid workflow creation time" - ); - assert.equal(manifest.sdk.repository, "github/copilot-sdk"); - assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); - assert.equal(manifest.runtime.source, "github-packages", "Invalid runtime package source"); assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); assert.deepEqual( manifest.packages.map(({ name }) => name).sort(), @@ -184,6 +190,27 @@ export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirector } } +export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { + verifyPackageSetManifest(manifest, packageDirectory); + assert( + manifest.channel === "canary" || manifest.channel === "unstable", + "Invalid release channel" + ); + validateFullSha(manifest.sdk.sha, "SDK SHA"); + validateFullSha(manifest.runtime.sha, "Runtime SHA"); + validateRuntimeVersionChannel(manifest.runtime.version, manifest.channel); + assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); + assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); + assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); + assert( + Number.isFinite(Date.parse(manifest.workflow.createdAt)), + "Invalid workflow creation time" + ); + assert.equal(manifest.sdk.repository, "github/copilot-sdk"); + assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); + assert.equal(manifest.runtime.source, "github-packages", "Invalid runtime package source"); +} + function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) { @@ -195,6 +222,15 @@ function requiredEnvironment(name: string): string { async function main(): Promise { const [command, manifestPath = "release-manifest.json", packageDirectory = "."] = process.argv.slice(2); + if (command === "create-package-set") { + const manifest = await createPackageSetManifest( + packageDirectory, + requiredEnvironment("SDK_VERSION") + ); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + verifyPackageSetManifest(manifest, packageDirectory); + return; + } if (command === "create") { const manifest = await createReleaseManifest(packageDirectory, { channel: requiredEnvironment("RELEASE_CHANNEL") as ReleaseManifest["channel"], @@ -217,7 +253,14 @@ async function main(): Promise { verifyReleaseManifest(manifest, packageDirectory); return; } - throw new Error("Usage: release-manifest.ts create|verify [manifest-path] [package-directory]"); + if (command === "verify-package-set") { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as PackageSetManifest; + verifyPackageSetManifest(manifest, packageDirectory); + return; + } + throw new Error( + "Usage: release-manifest.ts create|verify|create-package-set|verify-package-set [manifest-path] [package-directory]" + ); } const scriptPath = process.argv[1] diff --git a/nodejs/scripts/unstable-version.ts b/nodejs/scripts/unstable-version.ts index c65b7e9799..49d7975c6e 100644 --- a/nodejs/scripts/unstable-version.ts +++ b/nodejs/scripts/unstable-version.ts @@ -15,7 +15,7 @@ export interface UnstableVersionOptions { createdAt: string; firstParentTags: string[]; releases: ReleaseRecord[]; - runNumber: string; + runId: string; sdkSha: string; versionOverride?: string; } @@ -46,9 +46,14 @@ export function targetCoreFromBaseline(baseline: string): string { return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; } -function validateReleaseIdentity(createdAt: string, runNumber: string, sdkSha: string): number { - if (!/^[0-9]+$/.test(runNumber)) { - throw new Error(`Invalid workflow run number: ${runNumber}`); +function validateReleaseIdentity( + createdAt: string, + runIdentity: string, + runIdentityLabel: "ID" | "number", + sdkSha: string +): number { + if (!/^[0-9]+$/.test(runIdentity)) { + throw new Error(`Invalid workflow run ${runIdentityLabel}: ${runIdentity}`); } if (!/^[0-9a-f]{40}$/i.test(sdkSha)) { throw new Error(`Invalid full SDK SHA: ${sdkSha}`); @@ -61,7 +66,12 @@ function validateReleaseIdentity(createdAt: string, runNumber: string, sdkSha: s } export function calculateCanaryVersion(options: CanaryVersionOptions): string { - const createdAt = validateReleaseIdentity(options.createdAt, options.runNumber, options.sdkSha); + const createdAt = validateReleaseIdentity( + options.createdAt, + options.runNumber, + "number", + options.sdkSha + ); const baseline = options.releases .filter((release) => { if (release.draft || release.prerelease || release.published_at === null) { @@ -93,7 +103,12 @@ export function calculateCanaryVersion(options: CanaryVersionOptions): string { } export function calculateUnstableVersion(options: UnstableVersionOptions): string { - const createdAt = validateReleaseIdentity(options.createdAt, options.runNumber, options.sdkSha); + const createdAt = validateReleaseIdentity( + options.createdAt, + options.runId, + "ID", + options.sdkSha + ); if (options.versionOverride) { const parsed = semver.parse(options.versionOverride); @@ -106,7 +121,7 @@ export function calculateUnstableVersion(options: UnstableVersionOptions): strin `Explicit unstable SDK version must be valid SemVer with an unstable prerelease: ${options.versionOverride}` ); } - return `${parsed.major}.${parsed.minor}.${parsed.patch}-${parsed.prerelease.join(".")}.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; + return `${parsed.major}.${parsed.minor}.${parsed.patch}-${parsed.prerelease.join(".")}.${options.runId}.g${options.sdkSha.slice(0, 7)}`; } const eligibleTags = new Set( @@ -128,7 +143,7 @@ export function calculateUnstableVersion(options: UnstableVersionOptions): strin ); } - return `${targetCoreFromBaseline(baseline)}-unstable.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; + return `${targetCoreFromBaseline(baseline)}-unstable.${options.runId}.g${options.sdkSha.slice(0, 7)}`; } function getFirstParentTags(sdkSha: string): string[] { @@ -171,15 +186,19 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1 const releases = JSON.parse(readFileSync(releasesPath, "utf8")) as ReleaseRecord[]; const sdkSha = requireEnvironment("SDK_SHA"); const createdAt = requireEnvironment("WORKFLOW_CREATED_AT"); - const runNumber = requireEnvironment("WORKFLOW_RUN_NUMBER"); const version = requireEnvironment("SDK_CHANNEL") === "canary" - ? calculateCanaryVersion({ createdAt, releases, runNumber, sdkSha }) + ? calculateCanaryVersion({ + createdAt, + releases, + runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER"), + sdkSha, + }) : calculateUnstableVersion({ createdAt, firstParentTags: getFirstParentTags(sdkSha), releases, - runNumber, + runId: requireEnvironment("WORKFLOW_RUN_ID"), sdkSha, versionOverride: process.env.SDK_VERSION_OVERRIDE?.trim() || undefined, }); diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 08fda83934..36ec916af0 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -12,7 +12,7 @@ import { } from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; -const version = "1.2.3-unstable.7.gabcdef0"; +const version = "1.2.3-unstable.34640000001.gabcdef0"; const registry = "https://registry.example.test"; const integrity = "sha512-expected"; const identity = { name: packageName, version, integrity }; diff --git a/nodejs/test/release-manifest.test.ts b/nodejs/test/release-manifest.test.ts index ac95d7c3d5..561dc09137 100644 --- a/nodejs/test/release-manifest.test.ts +++ b/nodejs/test/release-manifest.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { c as createTar } from "tar"; import { afterEach, describe, expect, it } from "vitest"; -import { createReleaseManifest, verifyReleaseManifest } from "../scripts/release-manifest.js"; +import { + createPackageSetManifest, + createReleaseManifest, + verifyPackageSetManifest, + verifyReleaseManifest, +} from "../scripts/release-manifest.js"; import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; const roots: string[] = []; @@ -25,10 +30,37 @@ async function packageTarball(root: string, name: string, version: string): Prom } describe("release manifest", () => { + it("freezes and verifies a direct nine-package release", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-package-set-manifest-")); + roots.push(root); + const version = "1.0.13-unstable.34640000001.gabcdef0"; + for (const name of [ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), + ]) { + await packageTarball(root, name, version); + } + + const manifest = await createPackageSetManifest(root, version); + expect(manifest).toMatchObject({ + schemaVersion: 1, + sdk: { version }, + packages: expect.arrayContaining([ + expect.objectContaining({ name: "@github/copilot-sdk" }), + ]), + }); + expect(manifest.packages).toHaveLength(9); + expect(() => verifyPackageSetManifest(manifest, root)).not.toThrow(); + + const damaged = join(root, manifest.packages[0].filename); + writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); + expect(() => verifyPackageSetManifest(manifest, root)).toThrow("Size mismatch"); + }); + it("freezes and verifies the exact nine-package release identity", async () => { const root = mkdtempSync(join(tmpdir(), "copilot-sdk-manifest-")); roots.push(root); - const version = "1.0.13-unstable.8123.gabcdef0"; + const version = "1.0.13-unstable.34640000001.gabcdef0"; for (const name of [ "@github/copilot-sdk", ...RUNTIME_PLATFORMS.map(getRuntimePackageName), diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 78a9c20557..0dd47131a0 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -7,10 +7,47 @@ const workflow = (name: string) => readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); const publish = workflow("publish.yml"); const runtimeSdk = workflow("runtime-sdk.yml"); +const publishVersionJob = publish.slice( + publish.indexOf(" version:"), + publish.indexOf(" package-nodejs:") +); +const directPackageJob = publish.slice( + publish.indexOf(" package-nodejs:"), + publish.indexOf(" publish-nodejs:") +); +const directNodePublicationJob = publish.slice( + publish.indexOf(" publish-nodejs:"), + publish.indexOf(" publish-nodejs-internal:") +); +const directInternalPublicationJob = publish.slice( + publish.indexOf(" publish-nodejs-internal:"), + publish.indexOf(" publish-dotnet:") +); +const dotnetPublicationJob = publish.slice( + publish.indexOf(" publish-dotnet:"), + publish.indexOf(" publish-rust:") +); +const rustPublicationJob = publish.slice( + publish.indexOf(" publish-rust:"), + publish.indexOf(" publish-python:") +); +const pythonPublicationJob = publish.slice( + publish.indexOf(" publish-python:"), + publish.indexOf(" publish-java:") +); +const javaPublicationJob = publish.slice( + publish.indexOf(" publish-java:"), + publish.indexOf(" github-release:") +); +const githubReleaseJob = publish.slice(publish.indexOf(" github-release:")); const runtimeReleaseIdentity = readFileSync( join(repositoryRoot, "nodejs", "scripts", "runtime-release-identity.ts"), "utf8" ); +const unstableVersion = readFileSync( + join(repositoryRoot, "nodejs", "scripts", "unstable-version.ts"), + "utf8" +); const planJob = runtimeSdk.slice( runtimeSdk.indexOf(" plan:"), runtimeSdk.indexOf(" acquire-runtime:") @@ -30,23 +67,101 @@ const internalPublicationJob = runtimeSdk.slice( ); const publicPublicationJob = runtimeSdk.slice(runtimeSdk.indexOf(" publish-public:")); -describe("normal publishing workflow contract", () => { - it("remains the stable and prerelease entry without runtime handoff inputs", () => { +describe("direct publishing workflow contract", () => { + it("supports stable, prerelease, and direct unstable without runtime handoff inputs", () => { expect(publish).toContain("- latest"); expect(publish).toContain("- prerelease"); - expect(publish).not.toContain("- unstable"); + expect(publish).toContain("- unstable"); expect(publish).not.toContain("runtime_version:"); expect(publish).not.toContain("runtime_run_id:"); expect(publish).not.toContain("resume_run_id:"); expect(publish).not.toContain("runtime-backed-node-release.yml"); - expect(publish).toContain("publish.yml only accepts latest or prerelease"); + expect(publish).toContain("publish.yml only accepts latest, prerelease, or unstable"); expect(publish).toMatch(/- name: Validate release channel\s+working-directory: \.\s+env:/); expect(publish).toContain( - "prerelease namespace is reserved for runtime-driven SDK releases" + "prerelease namespace is reserved for dedicated SDK release channels" ); expect(publish).toContain("canary|unstable"); }); + it("uses the shared deterministic planner only for unstable", () => { + expect(publishVersionJob).toContain( + "fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }}" + ); + expect(publishVersionJob).toContain("if: inputs.dist-tag == 'unstable'"); + expect(publishVersionJob).toContain("WORKFLOW_CREATED_AT="); + expect(publishVersionJob).toContain( + 'gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100"' + ); + expect(publishVersionJob).toContain("SDK_SHA: ${{ github.sha }}"); + expect(publishVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(publishVersionJob).not.toContain("WORKFLOW_RUN_NUMBER:"); + expect(publishVersionJob).toContain("SDK_VERSION_OVERRIDE: ${{ inputs.version }}"); + expect(publishVersionJob).toContain("scripts/unstable-version.ts"); + expect(publishVersionJob).toContain("if: inputs.dist-tag != 'unstable'"); + expect(publishVersionJob).toMatch( + /- name: Verify version is available on public npm\s+if: inputs\.dist-tag != 'unstable'/ + ); + expect(publishVersionJob).toContain( + 'VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})"' + ); + expect(publishVersionJob).not.toContain("get-version.js unstable"); + }); + + it("keeps direct unstable Node-only with manifest-safe public-then-internal ordering", () => { + expect(directNodePublicationJob).toContain( + "if: github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable'" + ); + expect(directPackageJob).toContain("create-package-set package-set-manifest.json"); + expect(directPackageJob).toContain("nodejs/package-set-manifest.json"); + for (const job of [directNodePublicationJob, directInternalPublicationJob]) { + expect(job).toContain("verify-package-set"); + expect(job).toContain("publish-manifest"); + expect(job).toContain('if [ "$DIST_TAG" = "unstable" ]; then'); + expect(job).toContain("npm-release.js publish \\"); + } + expect(directNodePublicationJob).toContain("https://registry.npmjs.org public"); + expect(directInternalPublicationJob).toContain('"$FEED_URL" azure'); + expect(directInternalPublicationJob).toContain("needs: [version, publish-nodejs]"); + expect(publish.indexOf(" publish-nodejs:")).toBeLessThan( + publish.indexOf(" publish-nodejs-internal:") + ); + for (const job of [ + dotnetPublicationJob, + rustPublicationJob, + pythonPublicationJob, + javaPublicationJob, + githubReleaseJob, + ]) { + expect(job).toContain("inputs.dist-tag != 'unstable'"); + } + }); + + it("shares the public unstable concurrency lock with the runtime-driven path", () => { + expect(publish).toContain( + "group: ${{ inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }}" + ); + expect(publicPublicationJob).toContain("group: sdk-runtime-public-unstable"); + expect(directInternalPublicationJob).toContain( + "group: sdk-runtime-internal-${{ inputs.dist-tag }}" + ); + expect(internalPublicationJob).toContain( + "group: sdk-runtime-internal-${{ inputs.channel }}" + ); + expect(publish).toContain("cancel-in-progress: false"); + expect(publicPublicationJob).toContain("cancel-in-progress: false"); + expect(directInternalPublicationJob).toContain("queue: max"); + expect(internalPublicationJob).toContain("queue: max"); + }); + + it("uses repository-wide run IDs for unstable while leaving canary on run numbers", () => { + expect(publishVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(planJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(planJob).toContain("WORKFLOW_RUN_NUMBER: ${{ github.run_number }}"); + expect(unstableVersion).toContain('runId: requireEnvironment("WORKFLOW_RUN_ID")'); + expect(unstableVersion).toContain('runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER")'); + }); + it("retains all normal SDK publication paths", () => { for (const job of [ "publish-nodejs:", diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts index 9c9e4505cb..b743bbeece 100644 --- a/nodejs/test/unstable-version.test.ts +++ b/nodejs/test/unstable-version.test.ts @@ -7,6 +7,7 @@ import { const sha = "abcdef0123456789abcdef0123456789abcdef01"; const otherSha = "123456789abcdef0123456789abcdef012345678"; +const runId = "34640000001"; const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ tag_name, published_at, @@ -31,22 +32,43 @@ describe("unstable SDK version planning", () => { release("v1.0.12", "2026-09-05T00:00:00Z"), release("v1.0.11"), ], - runNumber: "8123", + runId, sdkSha: sha, }) - ).toBe("1.0.13-unstable.8123.gabcdef0"); + ).toBe("1.0.13-unstable.34640000001.gabcdef0"); }); - it("is stable across retries and unique across new workflow runs", () => { + it("freezes eligible release history at workflow creation time", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.12", "v1.0.11"], + releases: [ + release("v1.0.12", "2026-09-05T00:00:00Z"), + release("v1.0.11", "2026-09-01T00:00:00Z"), + ], + runId, + sdkSha: sha, + }; + const planned = calculateUnstableVersion(options); + expect(planned).toBe("1.0.12-unstable.34640000001.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + releases: [...options.releases, release("v1.0.13", "2026-09-06T00:00:00Z")], + }) + ).toBe(planned); + }); + + it("is stable across retries and unique across repository-wide workflow run IDs", () => { const options = { createdAt: "2026-09-04T00:00:00Z", firstParentTags: ["v1.0.11"], releases: [release("v1.0.11")], - runNumber: "8123", + runId, sdkSha: sha, }; expect(calculateUnstableVersion(options)).toBe(calculateUnstableVersion(options)); - expect(calculateUnstableVersion({ ...options, runNumber: "8124" })).not.toBe( + expect(calculateUnstableVersion({ ...options, runId: "34640000002" })).not.toBe( calculateUnstableVersion(options) ); expect(calculateUnstableVersion({ ...options, sdkSha: otherSha })).not.toBe( @@ -59,7 +81,7 @@ describe("unstable SDK version planning", () => { createdAt: "2026-09-04T00:00:00Z", firstParentTags: [], releases: [], - runNumber: "8123", + runId, sdkSha: sha, }; expect( @@ -67,25 +89,39 @@ describe("unstable SDK version planning", () => { ...options, versionOverride: "2.0.0-unstable.manual.1", }) - ).toBe("2.0.0-unstable.manual.1.8123.gabcdef0"); + ).toBe("2.0.0-unstable.manual.1.34640000001.gabcdef0"); expect( calculateUnstableVersion({ ...options, - runNumber: "8124", + runId: "34640000002", versionOverride: "2.0.0-unstable.manual.1", }) - ).toBe("2.0.0-unstable.manual.1.8124.gabcdef0"); + ).toBe("2.0.0-unstable.manual.1.34640000002.gabcdef0"); expect( calculateUnstableVersion({ ...options, sdkSha: otherSha, versionOverride: "2.0.0-unstable.manual.1", }) - ).toBe("2.0.0-unstable.manual.1.8123.g1234567"); + ).toBe("2.0.0-unstable.manual.1.34640000001.g1234567"); expect(() => calculateUnstableVersion({ ...options, versionOverride: "2.0.0-preview.1" }) ).toThrow("unstable prerelease"); }); + + it("cannot collide across workflows with coincident per-workflow run numbers", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.11"], + releases: [release("v1.0.11")], + sdkSha: sha, + }; + const direct = calculateUnstableVersion({ ...options, runId }); + const runtimeDriven = calculateUnstableVersion({ ...options, runId: "34640000002" }); + expect(direct).toBe("1.0.12-unstable.34640000001.gabcdef0"); + expect(runtimeDriven).toBe("1.0.12-unstable.34640000002.gabcdef0"); + expect(direct).not.toBe(runtimeDriven); + }); }); describe("canary SDK version planning", () => { From 23d3ea7490427fc51a837339e5f4ebebdcad6d49 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 12:22:41 -0700 Subject: [PATCH 20/23] Trim unstable publishing scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/publish.yml | 62 ++---------------- docs/developer-docs/unstable-releases.md | 18 ++---- nodejs/scripts/npm-release.js | 43 ++++--------- nodejs/scripts/releaseArtifacts.ts | 4 -- nodejs/test/npm-release.test.ts | 81 ++++++++++++------------ nodejs/test/release-workflows.test.ts | 9 +-- nodejs/test/runtimeArtifacts.test.ts | 22 ------- 7 files changed, 66 insertions(+), 173 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc50df1957..df1fa750b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,15 +43,6 @@ jobs: run: working-directory: ./nodejs steps: - - name: Validate release channel - working-directory: . - env: - DIST_TAG: ${{ inputs.dist-tag }} - run: | - case "$DIST_TAG" in - latest|prerelease|unstable) ;; - *) echo "::error::publish.yml only accepts latest, prerelease, or unstable."; exit 1 ;; - esac - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }} @@ -104,19 +95,6 @@ jobs: echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" exit 1 fi - PRERELEASE_NAMESPACE="$(node -e ' - const semver = require("semver"); - const parsed = semver.parse(process.argv[1]); - if (!parsed) process.exit(2); - process.stdout.write(String(parsed.prerelease[0] ?? "")); - ' "$VERSION")" || - { echo "::error::Version '$VERSION' is not valid SemVer."; exit 1; } - case "$PRERELEASE_NAMESPACE" in - canary|unstable) - echo "::error::The '$PRERELEASE_NAMESPACE' prerelease namespace is reserved for dedicated SDK release channels." - exit 1 - ;; - esac fi echo "Using manual version override: $VERSION" >> $GITHUB_STEP_SUMMARY else @@ -180,7 +158,7 @@ jobs: publish-nodejs: name: Publish Node.js SDK - needs: [version, package-nodejs] + needs: package-nodejs if: github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable' runs-on: ubuntu-latest permissions: @@ -203,15 +181,9 @@ jobs: with: name: nodejs-package path: ./dist - - name: Validate unstable package manifest - if: inputs.dist-tag == 'unstable' - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify-package-set \ - dist/package-set-manifest.json dist - name: Publish tarball to public npm env: DIST_TAG: ${{ github.event.inputs.dist-tag }} - VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail if [ "$DIST_TAG" = "unstable" ]; then @@ -232,33 +204,25 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi - INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ - "$PACKAGE_NAME" \ - "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public \ - "$INTEGRITY" + public done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi - INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ - @github/copilot-sdk \ - "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public \ - "$INTEGRITY" + public publish-nodejs-internal: name: Publish Node.js SDK to internal feed - needs: [version, publish-nodejs] + needs: publish-nodejs environment: cicd runs-on: ubuntu-latest concurrency: @@ -286,11 +250,6 @@ jobs: with: name: nodejs-package path: ./dist - - name: Validate unstable package manifest - if: inputs.dist-tag == 'unstable' - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify-package-set \ - dist/package-set-manifest.json dist - name: Azure Login (OIDC -> id-cpd-ci) uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: @@ -310,7 +269,6 @@ jobs: - name: Publish tarball to internal feed env: DIST_TAG: ${{ github.event.inputs.dist-tag }} - VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then @@ -335,29 +293,21 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi - INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ - "$PACKAGE_NAME" \ - "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure \ - "$INTEGRITY" + azure done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi - INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ - @github/copilot-sdk \ - "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure \ - "$INTEGRITY" + azure publish-dotnet: name: Publish .NET SDK diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index bf573ff519..9313097b75 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -14,15 +14,11 @@ non-main branch. They do not publish .NET, Rust, Python, Java, or Go releases, and they do not create an SDK GitHub Release. The same workflow remains the normal stable and prerelease publisher for all SDK languages. -The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each -handoff includes the exact runtime version, full source SHA, and source workflow -run ID. - -The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This -runtime-driven Node entry is separate from the direct unstable path. -`runtime-sdk.yml` -owns runtime acquisition, cross-platform tests, packaging, manifest retention, -optional internal publication, and public unstable npm publication. +The runtime workflow dispatches `.github/workflows/runtime-sdk.yml` at an +explicit SDK ref with the exact runtime version, full source SHA, and source +workflow run ID. This runtime-driven Node entry owns runtime acquisition, +cross-platform tests, packaging, manifest retention, optional internal +publication, and public unstable npm publication. The runtime dispatch includes these inputs: @@ -124,6 +120,4 @@ Confirm npm trusted publisher configuration authorizes both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for `@github/copilot-sdk` and all eight `@github/copilot-sdk-` package names. The first identity publishes stable, prerelease, and direct unstable -versions; the second publishes runtime-driven unstable versions. Do not add an -npm token, workflow indirection, or a separate protected SDK publication -environment. +versions; the second publishes runtime-driven unstable versions. diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index 4eabd70f7f..7b86c6e3ca 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -export const sdkPackageNames = [ +const sdkPackageNames = [ "@github/copilot-sdk", "@github/copilot-sdk-darwin-arm64", "@github/copilot-sdk-darwin-x64", @@ -101,16 +101,7 @@ export async function assertVersionAbsent(packageName, version, registry, runner } } -export async function assertPackageSetVersionAbsent(version, registry, runner = runCommand) { - for (const packageName of sdkPackageNames) { - await assertVersionAbsent(packageName, version, registry, runner); - } -} - -export async function publishTarball(tarball, tag, registry, mode, identity, runner = runCommand) { - if (!identity?.name || !identity?.version || !identity?.integrity) { - throw new Error("Publishing requires an expected package name, version, and integrity."); - } +export async function publishTarball(tarball, tag, registry, mode, runner = runCommand, identity) { 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}`); @@ -122,9 +113,11 @@ export async function publishTarball(tarball, tag, registry, mode, identity, run const output = `${result.stdout}\n${result.stderr}`; if (PUBLIC_CONFLICT.test(output) || (mode === "azure" && AZURE_CONFLICT.test(output))) { - console.log( - `${identity.name}@${identity.version} is already published; treating the immutable-version conflict as success.` - ); + const subject = + identity?.name && identity?.version + ? `${identity.name}@${identity.version}` + : "Version"; + console.log(`${subject} is already published; treating the conflict as success.`); return; } @@ -211,7 +204,7 @@ export async function publishManifest( } } for (const packed of packages) { - await publishTarball(packed.tarball, tag, registry, mode, packed, runner); + await publishTarball(packed.tarball, tag, registry, mode, runner, packed); } for (const packed of packages) { const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); @@ -244,27 +237,13 @@ async function main() { if (command === "preflight" && args.length === 3) { await assertVersionAbsent(...args); console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`); - } else if (command === "preflight-package-set" && args.length === 2) { - await assertPackageSetVersionAbsent(...args); - console.log(`All SDK packages at ${args[0]} are available on ${args[1]}.`); - } else if (command === "publish" && args.length === 7) { - const [tarball, name, version, tag, registry, mode, expectedIntegrity] = args; - const localIntegrity = `sha512-${createHash("sha512") - .update(readFileSync(tarball)) - .digest("base64")}`; - if (expectedIntegrity !== localIntegrity) { - throw new Error(`Expected integrity does not match ${tarball}.`); - } - await publishTarball(tarball, tag, registry, mode, { - name, - version, - integrity: localIntegrity, - }); + } else if (command === "publish" && args.length === 4) { + await publishTarball(...args); } else if (command === "publish-manifest" && args.length === 5) { await publishManifest(...args); } else { throw new Error( - "Usage: npm-release.js preflight | preflight-package-set | publish | publish-manifest " + "Usage: npm-release.js preflight | publish | publish-manifest " ); } } diff --git a/nodejs/scripts/releaseArtifacts.ts b/nodejs/scripts/releaseArtifacts.ts index e9b6c1107f..ef983384ab 100644 --- a/nodejs/scripts/releaseArtifacts.ts +++ b/nodejs/scripts/releaseArtifacts.ts @@ -24,7 +24,6 @@ export interface EnsureCopilotPackageOptions { environment?: NodeJS.ProcessEnv; fetch?: typeof globalThis.fetch; fetchTimeoutMs?: number; - packageDirectory?: string; platform?: string; } @@ -135,9 +134,6 @@ export async function ensureCopilotPackage( ): Promise { const platform = options.platform ?? getRuntimePlatform(); const environment = options.environment ?? process.env; - if (options.packageDirectory) { - return validateLocalPackage(options.packageDirectory, platform)!; - } const workflowPackageDirectory = environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; if (workflowPackageDirectory) { const packageRoot = validateLocalPackage(workflowPackageDirectory, platform, version); diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 36ec916af0..84326fcf21 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -3,19 +3,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { - assertPackageSetVersionAbsent, - assertVersionAbsent, - publishManifest, - publishTarball, - sdkPackageNames, -} from "../scripts/npm-release.js"; +import { assertVersionAbsent, publishManifest, publishTarball } from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; const version = "1.2.3-unstable.34640000001.gabcdef0"; const registry = "https://registry.example.test"; -const integrity = "sha512-expected"; -const identity = { name: packageName, version, integrity }; +const identity = { name: packageName, version }; const result = (status: number, stdout = "", stderr = "") => ({ status, stdout, stderr }); describe("npm release preflight", () => { @@ -28,40 +21,32 @@ describe("npm release preflight", () => { ).resolves.toBeUndefined(); }); - it("rejects an existing package version without reading registry integrity", async () => { - const existing = vi.fn().mockResolvedValue(result(0, JSON.stringify(version))); - await expect(assertVersionAbsent(packageName, version, registry, existing)).rejects.toThrow( - "already exists" - ); - expect(existing.mock.calls[0][1][2]).toBe("version"); - }); - - it("does not treat malformed or transient failures as absence", async () => { - const runner = vi.fn().mockResolvedValue(result(1, "not-json", "npm error code E500")); + it.each([ + ["an existing version", result(0, JSON.stringify(version)), "already exists"], + ["a transient error", result(1, "", "npm error code E500"), "Could not read"], + ["malformed output", result(1, "not-json"), "Could not read"], + [ + "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 read", + ], + ])("fails for %s", async (_name, response, message) => { + const runner = vi.fn().mockResolvedValue(response); await expect(assertVersionAbsent(packageName, version, registry, runner)).rejects.toThrow( - "Could not read" + message ); }); - - it("checks the complete nine-package SDK set", async () => { - const runner = vi - .fn() - .mockResolvedValue(result(1, JSON.stringify({ error: { code: "E404" } }))); - await expect( - assertPackageSetVersionAbsent(version, registry, runner) - ).resolves.toBeUndefined(); - expect(runner).toHaveBeenCalledTimes(9); - expect( - runner.mock.calls.map(([, args]) => args[1].slice(0, args[1].lastIndexOf("@"))) - ).toEqual(sdkPackageNames); - }); }); describe("npm release publishing", () => { it("treats a successful publish as success without registry metadata", async () => { const runner = vi.fn().mockResolvedValue(result(0)); await expect( - publishTarball("package.tgz", "unstable", registry, "public", identity, runner) + publishTarball("package.tgz", "unstable", registry, "public", runner) ).resolves.toBeUndefined(); expect(runner).toHaveBeenCalledTimes(1); }); @@ -69,7 +54,7 @@ describe("npm release publishing", () => { it("accepts recognized immutable-version conflicts without registry integrity", async () => { const runner = vi.fn().mockResolvedValue(result(1, "", "npm error code EPUBLISHCONFLICT")); await expect( - publishTarball("package.tgz", "unstable", registry, "public", identity, runner) + publishTarball("package.tgz", "unstable", registry, "public", runner, identity) ).resolves.toBeUndefined(); runner.mockResolvedValue( @@ -80,15 +65,33 @@ describe("npm release publishing", () => { ) ); await expect( - publishTarball("package.tgz", "canary", registry, "azure", identity, runner) + publishTarball("package.tgz", "canary", registry, "azure", runner, identity) ).resolves.toBeUndefined(); expect(runner).toHaveBeenCalledTimes(2); }); - it("rejects unrecognized publication failures", async () => { - const runner = vi.fn().mockResolvedValue(result(1, "", "npm error E500")); + 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", + ], + ["an unrelated npm failure", "npm error E500", "public"], + ])("rejects %s", async (_name, error, mode) => { + const runner = vi.fn().mockResolvedValue(result(1, "", error)); await expect( - publishTarball("package.tgz", "unstable", registry, "public", identity, runner) + publishTarball("package.tgz", "unstable", registry, mode, runner) ).rejects.toThrow("npm publish failed"); }); diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 0dd47131a0..39bfc50e39 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -76,12 +76,6 @@ describe("direct publishing workflow contract", () => { expect(publish).not.toContain("runtime_run_id:"); expect(publish).not.toContain("resume_run_id:"); expect(publish).not.toContain("runtime-backed-node-release.yml"); - expect(publish).toContain("publish.yml only accepts latest, prerelease, or unstable"); - expect(publish).toMatch(/- name: Validate release channel\s+working-directory: \.\s+env:/); - expect(publish).toContain( - "prerelease namespace is reserved for dedicated SDK release channels" - ); - expect(publish).toContain("canary|unstable"); }); it("uses the shared deterministic planner only for unstable", () => { @@ -115,14 +109,13 @@ describe("direct publishing workflow contract", () => { expect(directPackageJob).toContain("create-package-set package-set-manifest.json"); expect(directPackageJob).toContain("nodejs/package-set-manifest.json"); for (const job of [directNodePublicationJob, directInternalPublicationJob]) { - expect(job).toContain("verify-package-set"); expect(job).toContain("publish-manifest"); expect(job).toContain('if [ "$DIST_TAG" = "unstable" ]; then'); expect(job).toContain("npm-release.js publish \\"); } expect(directNodePublicationJob).toContain("https://registry.npmjs.org public"); expect(directInternalPublicationJob).toContain('"$FEED_URL" azure'); - expect(directInternalPublicationJob).toContain("needs: [version, publish-nodejs]"); + expect(directInternalPublicationJob).toContain("needs: publish-nodejs"); expect(publish.indexOf(" publish-nodejs:")).toBeLessThan( publish.indexOf(" publish-nodejs-internal:") ); diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts index 0969d03ff6..26a9d1be7b 100644 --- a/nodejs/test/runtimeArtifacts.test.ts +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -264,28 +264,6 @@ describe("ensureRuntimeBundle", () => { }); describe("release package acquisition", () => { - it("uses a pre-acquired runtime package directory without network access", async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-runtime-packages-")); - const platform = "linux-x64"; - const packageRoot = join(root, platform); - const prebuilds = join(packageRoot, "prebuilds", platform); - mkdirSync(prebuilds, { recursive: true }); - writeFileSync(join(packageRoot, "package.json"), "{}"); - writeFileSync(join(prebuilds, "runtime.node"), "runtime"); - const fetcher = vi.fn(() => { - throw new Error("local runtime package resolution must not fetch"); - }); - - await expect( - ensureCopilotPackage("1.2.3-unstable.1", { - fetch: fetcher, - packageDirectory: root, - platform, - }) - ).resolves.toBe(packageRoot); - expect(fetcher).not.toHaveBeenCalled(); - }); - it("uses an ambient acquired package only for its exact runtime version", async () => { const root = mkdtempSync(join(tmpdir(), "copilot-runtime-environment-")); const platform = "linux-x64"; From 463c28b9bb2b10b7faf91c7eefbfa22ccbd2c386 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 13:25:47 -0700 Subject: [PATCH 21/23] Simplify unstable release tooling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/runtime-sdk.yml | 1 - nodejs/scripts/npm-release.js | 87 ++----------------- nodejs/scripts/package-set-manifest.js | 65 ++++++++++++++ nodejs/scripts/release-manifest.ts | 51 ++++------- nodejs/scripts/runtime-package-acquisition.ts | 62 ++++++------- nodejs/test/release-workflows.test.ts | 2 +- .../test/runtime-package-acquisition.test.ts | 21 +---- 7 files changed, 114 insertions(+), 175 deletions(-) create mode 100644 nodejs/scripts/package-set-manifest.js diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml index 47e438f768..5c1a37adf4 100644 --- a/.github/workflows/runtime-sdk.yml +++ b/.github/workflows/runtime-sdk.yml @@ -140,7 +140,6 @@ jobs: npm run acquire:runtime-packages -- \ --version "$RUNTIME_VERSION" \ --sha "$RUNTIME_SHA" \ - --registry https://npm.pkg.github.com \ --output "$RUNNER_TEMP/runtime-packages" - name: Archive validated runtime packages run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index 7b86c6e3ca..97a1d1a8fa 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -1,20 +1,8 @@ -import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; - -const sdkPackageNames = [ - "@github/copilot-sdk", - "@github/copilot-sdk-darwin-arm64", - "@github/copilot-sdk-darwin-x64", - "@github/copilot-sdk-linux-arm64", - "@github/copilot-sdk-linux-x64", - "@github/copilot-sdk-linuxmusl-arm64", - "@github/copilot-sdk-linuxmusl-x64", - "@github/copilot-sdk-win32-arm64", - "@github/copilot-sdk-win32-x64", -]; +import { verifyPackageSetManifestFiles } from "./package-set-manifest.js"; 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 = @@ -72,28 +60,6 @@ export async function getRegistryVersion(packageName, version, registry, runner ); } -export async function getRegistryTagVersion(packageName, tag, registry, runner = runCommand) { - const result = await runner("npm", [ - "view", - `${packageName}@${tag}`, - "version", - "--json", - "--registry", - registry, - ]); - const parsed = parseNpmJson(result); - if (result.status === 0 && typeof parsed === "string") { - return parsed; - } - if (result.status !== 0 && parsed?.error?.code === "E404") { - return undefined; - } - const output = `${result.stdout}\n${result.stderr}`.trim(); - throw new Error( - `Could not read ${packageName}@${tag} from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` - ); -} - export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { const existing = await getRegistryVersion(packageName, version, registry, runner); if (existing !== undefined) { @@ -126,50 +92,7 @@ export async function publishTarball(tarball, tag, registry, mode, runner = runC function readReleaseManifest(manifestPath, packageDirectory) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); - if ( - manifest.schemaVersion !== 1 || - typeof manifest.sdk?.version !== "string" || - !Array.isArray(manifest.packages) - ) { - throw new Error("Unsupported release manifest."); - } - if (manifest.packages.length !== 9) { - throw new Error(`Expected nine release packages, found ${manifest.packages.length}.`); - } - const expectedNames = new Set(sdkPackageNames); - const names = new Set(); - for (const packed of manifest.packages) { - if ( - typeof packed.name !== "string" || - typeof packed.filename !== "string" || - typeof packed.integrity !== "string" || - typeof packed.size !== "number" - ) { - throw new Error("Release manifest contains an invalid package entry."); - } - if (names.has(packed.name)) { - throw new Error(`Duplicate package in release manifest: ${packed.name}`); - } - if (!expectedNames.has(packed.name)) { - throw new Error(`Unexpected package in release manifest: ${packed.name}`); - } - names.add(packed.name); - const tarball = resolve(packageDirectory, packed.filename); - if ( - dirname(tarball) !== resolve(packageDirectory) || - basename(tarball) !== packed.filename - ) { - throw new Error(`Unsafe release package filename: ${packed.filename}`); - } - const bytes = readFileSync(tarball); - const localIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; - if (bytes.length !== packed.size || localIntegrity !== packed.integrity) { - throw new Error(`Local release package does not match manifest: ${packed.filename}`); - } - } - if (names.size !== expectedNames.size) { - throw new Error("Release manifest does not contain the exact Node SDK package set."); - } + verifyPackageSetManifestFiles(manifest, packageDirectory); return manifest; } @@ -196,7 +119,7 @@ export async function publishManifest( const semver = await import("semver"); for (const packed of packages) { - const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + const taggedVersion = await getRegistryVersion(packed.name, tag, registry, runner); if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { throw new Error( `${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` @@ -207,7 +130,7 @@ export async function publishManifest( await publishTarball(packed.tarball, tag, registry, mode, runner, packed); } for (const packed of packages) { - const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + const taggedVersion = await getRegistryVersion(packed.name, tag, registry, runner); if (taggedVersion === packed.version) { continue; } diff --git a/nodejs/scripts/package-set-manifest.js b/nodejs/scripts/package-set-manifest.js new file mode 100644 index 0000000000..c2727ca740 --- /dev/null +++ b/nodejs/scripts/package-set-manifest.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; + +export const SDK_PACKAGE_NAMES = [ + "@github/copilot-sdk", + "@github/copilot-sdk-darwin-arm64", + "@github/copilot-sdk-darwin-x64", + "@github/copilot-sdk-linux-arm64", + "@github/copilot-sdk-linux-x64", + "@github/copilot-sdk-linuxmusl-arm64", + "@github/copilot-sdk-linuxmusl-x64", + "@github/copilot-sdk-win32-arm64", + "@github/copilot-sdk-win32-x64", +]; + +export function packageIntegrity(bytes) { + return `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +} + +export function verifyPackageSetManifestFiles(manifest, packageDirectory) { + assert.equal(manifest?.schemaVersion, 1, "Unsupported release manifest schema"); + assert.equal(typeof manifest.sdk?.version, "string", "Invalid SDK version"); + assert(Array.isArray(manifest.packages), "Release manifest packages must be an array"); + assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); + + const packageNames = new Set(); + for (const packed of manifest.packages) { + assert.equal(typeof packed?.name, "string", "Invalid release package name"); + assert.equal(typeof packed.filename, "string", "Invalid release package filename"); + assert.equal(typeof packed.integrity, "string", "Invalid release package integrity"); + assert.equal(typeof packed.size, "number", "Invalid release package size"); + assert( + !packageNames.has(packed.name), + `Duplicate package in release manifest: ${packed.name}` + ); + packageNames.add(packed.name); + + const archive = resolve(packageDirectory, packed.filename); + assert.equal( + dirname(archive), + resolve(packageDirectory), + `Unsafe release filename: ${packed.filename}` + ); + assert.equal( + basename(archive), + packed.filename, + `Unsafe release filename: ${packed.filename}` + ); + const bytes = readFileSync(archive); + assert.equal(statSync(archive).size, packed.size, `Size mismatch for ${packed.filename}`); + assert.equal( + packageIntegrity(bytes), + packed.integrity, + `Integrity mismatch for ${packed.filename}` + ); + } + + assert.deepEqual( + [...packageNames].sort(), + [...SDK_PACKAGE_NAMES].sort(), + "Release manifest package names do not match the expected package set" + ); +} diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts index 8c22cb01c7..ed18e073e2 100644 --- a/nodejs/scripts/release-manifest.ts +++ b/nodejs/scripts/release-manifest.ts @@ -1,14 +1,18 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { globSync } from "glob"; import * as semver from "semver"; import { x as extractTar } from "tar"; -import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; +import { + packageIntegrity, + SDK_PACKAGE_NAMES, + verifyPackageSetManifestFiles, +} from "./package-set-manifest.js"; import { validateRuntimeVersionChannel } from "./runtime-release-identity.js"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; export interface ReleaseManifestPackage { filename: string; @@ -60,14 +64,12 @@ export interface ReleaseManifestMetadata { workflowRunNumber: string; } -const expectedPackageNames = new Set([ - "@github/copilot-sdk", - ...RUNTIME_PLATFORMS.map(getRuntimePackageName), -]); - -function integrity(buffer: Buffer): string { - return `sha512-${createHash("sha512").update(buffer).digest("base64")}`; -} +const expectedPackageNames = new Set(SDK_PACKAGE_NAMES); +assert.deepEqual( + [...expectedPackageNames].sort(), + ["@github/copilot-sdk", ...RUNTIME_PLATFORMS.map(getRuntimePackageName)].sort(), + "Shared package manifest names must match the supported runtime platforms" +); async function readPackedManifest(archive: string): Promise<{ name: string; version: string }> { const root = mkdtempSync(join(tmpdir(), "copilot-sdk-release-manifest-")); @@ -141,7 +143,7 @@ export async function createPackageSetManifest( const bytes = readFileSync(archive); packages.push({ filename: basename(archive), - integrity: integrity(bytes), + integrity: packageIntegrity(bytes), name: packed.name, size: bytes.length, }); @@ -165,29 +167,8 @@ export function verifyPackageSetManifest( manifest: PackageSetManifest, packageDirectory: string ): void { - assert.equal(manifest.schemaVersion, 1, "Unsupported release manifest schema"); + verifyPackageSetManifestFiles(manifest, packageDirectory); assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); - assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); - assert.deepEqual( - manifest.packages.map(({ name }) => name).sort(), - [...expectedPackageNames].sort(), - "Release manifest package names do not match the expected package set" - ); - for (const packed of manifest.packages) { - const archive = resolve(packageDirectory, packed.filename); - assert.equal( - dirname(archive), - resolve(packageDirectory), - `Unsafe release filename: ${packed.filename}` - ); - const bytes = readFileSync(archive); - assert.equal(statSync(archive).size, packed.size, `Size mismatch for ${packed.filename}`); - assert.equal( - integrity(bytes), - packed.integrity, - `Integrity mismatch for ${packed.filename}` - ); - } } export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { diff --git a/nodejs/scripts/runtime-package-acquisition.ts b/nodejs/scripts/runtime-package-acquisition.ts index 1eb27b56e9..ed49cd6081 100644 --- a/nodejs/scripts/runtime-package-acquisition.ts +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; import { x as extractTar } from "tar"; import { RUNTIME_PLATFORMS, validateFile } from "../src/runtimeArtifacts.js"; @@ -28,7 +29,6 @@ interface RuntimePackageManifest { export interface AcquireRuntimePackagesOptions { outputDirectory: string; - registry: string; runtimeSha: string; runtimeVersion: string; } @@ -39,6 +39,8 @@ export type CommandRunner = ( options?: { cwd?: string } ) => Promise; +const GITHUB_PACKAGES_REGISTRY = "https://npm.pkg.github.com"; + export function getSourceRuntimePackageName(platform: string): string { return `@github/copilot-${platform}`; } @@ -136,11 +138,6 @@ export async function acquireRuntimePackages( runner: CommandRunner = runCommand ): Promise { assert.match(options.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); - assert.equal( - options.registry, - "https://npm.pkg.github.com", - "Runtime packages must come from GitHub Packages" - ); assert( options.outputDirectory.trim().length > 0, "Runtime package output directory is required" @@ -165,7 +162,7 @@ export async function acquireRuntimePackages( "dist.integrity", "--json", "--registry", - options.registry, + GITHUB_PACKAGES_REGISTRY, ]); const registryIntegrity = parseJsonOutput( viewResult, @@ -183,7 +180,7 @@ export async function acquireRuntimePackages( "--pack-destination", tarballDirectory, "--registry", - options.registry, + GITHUB_PACKAGES_REGISTRY, ]); const packed = parseJsonOutput<{ filename: string; integrity?: string }[]>( packResult, @@ -235,7 +232,7 @@ export async function acquireRuntimePackages( { runtimeVersion: options.runtimeVersion, runtimeSha: options.runtimeSha, - registry: options.registry, + registry: GITHUB_PACKAGES_REGISTRY, packages: acquired, }, null, @@ -245,37 +242,28 @@ export async function acquireRuntimePackages( } export function parseArguments(args: string[]): AcquireRuntimePackagesOptions { - const optionNames = new Set(["--version", "--sha", "--registry", "--output"]); - const values = new Map(); - if (args.length !== optionNames.size * 2) { - throw new Error( - "Usage: runtime-package-acquisition.ts --version --sha --registry --output " + const { tokens, values } = parseArgs({ + args, + allowPositionals: false, + options: { + output: { type: "string" }, + sha: { type: "string" }, + version: { type: "string" }, + }, + strict: true, + tokens: true, + }); + for (const name of ["version", "sha", "output"] as const) { + const occurrences = tokens.filter( + (token) => token.kind === "option" && token.name === name ); + assert.equal(occurrences.length, 1, `Option --${name} must be provided exactly once`); + assert(values[name]?.trim(), `Option --${name} requires a non-empty value`); } - for (let index = 0; index < args.length; index += 2) { - const key = args[index]; - const value = args[index + 1]; - if (!key || !optionNames.has(key)) { - throw new Error(`Unknown runtime package acquisition option: ${key ?? ""}`); - } - if (values.has(key)) { - throw new Error(`Duplicate runtime package acquisition option: ${key}`); - } - if (!value || value.trim().length === 0 || value.startsWith("--")) { - throw new Error(`Runtime package acquisition option ${key} requires a non-empty value`); - } - values.set(key, value); - } - const requiredValue = (key: string): string => { - const value = values.get(key); - assert(value !== undefined, `Missing runtime package acquisition option: ${key}`); - return value; - }; return { - runtimeVersion: requiredValue("--version"), - runtimeSha: requiredValue("--sha"), - registry: requiredValue("--registry"), - outputDirectory: requiredValue("--output"), + runtimeVersion: values.version!, + runtimeSha: values.sha!, + outputDirectory: values.output!, }; } diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 39bfc50e39..a09869d8d6 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -260,7 +260,7 @@ describe("runtime-backed Node release implementation", () => { expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); expect(acquisitionJob).toContain("packages: read"); expect(acquisitionJob).toContain("NODE_AUTH_TOKEN: ${{ github.token }}"); - expect(acquisitionJob).toContain("--registry https://npm.pkg.github.com"); + expect(acquisitionJob).not.toContain("--registry"); expect(acquisitionJob).not.toContain("azure/login"); expect(acquisitionJob).not.toContain("FEED_URL"); expect(internalPublicationJob).toContain("azure/login"); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts index 3f68b52073..c439791457 100644 --- a/nodejs/test/runtime-package-acquisition.test.ts +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -76,14 +76,11 @@ describe("runtime npm package acquisition", () => { runtimeVersion, "--sha", runtimeSha, - "--registry", - "https://npm.pkg.github.com", "--output", "runtime-packages", ]; expect(parseArguments(valid)).toEqual({ outputDirectory: "runtime-packages", - registry: "https://npm.pkg.github.com", runtimeSha, runtimeVersion, }); @@ -135,7 +132,6 @@ describe("runtime npm package acquisition", () => { await acquireRuntimePackages( { outputDirectory: output, - registry: "https://npm.pkg.github.com", runtimeSha, runtimeVersion, }, @@ -155,7 +151,7 @@ describe("runtime npm package acquisition", () => { } }); - it("requires GitHub Packages and strict registry integrity", async () => { + it("requires strict GitHub Packages registry integrity", async () => { const root = temporaryRoot("copilot-runtime-registry-"); const runner = vi .fn() @@ -164,26 +160,13 @@ describe("runtime npm package acquisition", () => { acquireRuntimePackages( { outputDirectory: join(root, "output"), - registry: "https://pkgs.dev.azure.com/example/npm/registry/", - runtimeSha, - runtimeVersion, - }, - runner - ) - ).rejects.toThrow("must come from GitHub Packages"); - expect(runner).not.toHaveBeenCalled(); - - await expect( - acquireRuntimePackages( - { - outputDirectory: join(root, "output"), - registry: "https://npm.pkg.github.com", runtimeSha, runtimeVersion, }, runner ) ).rejects.toThrow("Invalid registry integrity"); + expect(runner.mock.calls[0][1]).toContain("https://npm.pkg.github.com"); }); it("rejects mismatched source identity metadata", async () => { From dc024f425aa50246d42f0f3b816ad0501882bf55 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 15:19:04 -0700 Subject: [PATCH 22/23] Unify SDK release dispatch Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- .github/workflows/publish.yml | 382 ++++++++++++++++- .github/workflows/runtime-sdk.yml | 388 ----------------- docs/developer-docs/secrets.md | 2 +- docs/developer-docs/unstable-releases.md | 158 +++---- nodejs/scripts/runtime-release-identity.ts | 130 ++++-- nodejs/test/release-workflows.test.ts | 415 ++++++------------- nodejs/test/runtime-release-identity.test.ts | 184 ++++---- 7 files changed, 812 insertions(+), 847 deletions(-) delete mode 100644 .github/workflows/runtime-sdk.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index df1fa750b7..96a337485b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,21 @@ on: - latest - prerelease - unstable + - canary version: - description: "Optional final version for latest/prerelease, or unstable SemVer base before workflow identity" + description: "Optional direct SDK version or unstable SemVer base" + type: string + required: false + mode: + description: "Publish or validate without registry and release mutations" + type: choice + required: true + default: publish + options: + - publish + - dry-run + runtime: + description: 'Automation-only runtime JSON: {"version":"...","sha":"...","run_id":"..."}' type: string required: false @@ -24,13 +37,40 @@ permissions: contents: read concurrency: - group: ${{ inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }} + group: ${{ inputs.mode == 'dry-run' && format('publish-dry-run-{0}', github.run_id) || inputs.runtime != '' && format('publish-runtime-{0}', github.run_id) || inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }} cancel-in-progress: false jobs: + validate-dispatch: + name: Validate dispatch + runs-on: ubuntu-latest + outputs: + kind: ${{ steps.validate.outputs.kind }} + runtime_run_id: ${{ steps.validate.outputs.runtime_run_id }} + runtime_sha: ${{ steps.validate.outputs.runtime_sha }} + runtime_version: ${{ steps.validate.outputs.runtime_version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "22.x" + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Validate release dispatch + id: validate + working-directory: ./nodejs + env: + DIST_TAG: ${{ inputs.dist-tag }} + MODE: ${{ inputs.mode }} + RUNTIME_JSON: ${{ inputs.runtime }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: npx tsx scripts/runtime-release-identity.ts + # Shared job to calculate version once for all publish jobs version: name: Calculate Version + needs: validate-dispatch + if: needs.validate-dispatch.outputs.kind == 'direct' runs-on: ubuntu-latest permissions: actions: read @@ -159,7 +199,7 @@ jobs: publish-nodejs: name: Publish Node.js SDK needs: package-nodejs - if: github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable' + if: inputs.mode == 'publish' && (github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable') runs-on: ubuntu-latest permissions: actions: read @@ -541,3 +581,339 @@ jobs: fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + runtime-plan: + name: Plan runtime release + needs: validate-dispatch + if: needs.validate-dispatch.outputs.kind == 'runtime' + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + outputs: + artifact_name: ${{ steps.plan.outputs.artifact_name }} + sdk_version: ${{ steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Calculate the release identity + id: plan + working-directory: ./nodejs + env: + CHANNEL: ${{ inputs.dist-tag }} + GH_TOKEN: ${{ github.token }} + SDK_CHANNEL: ${{ inputs.dist-tag }} + SDK_SHA: ${{ github.sha }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" + npm exec -- semver "$SDK_VERSION" >/dev/null + ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" + { + echo "artifact_name=$ARTIFACT_NAME" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + + runtime-acquire: + name: Acquire runtime + needs: [validate-dispatch, runtime-plan] + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + packages: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Configure authentication-only GitHub Packages access + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + NODE_AUTH_TOKEN: ${{ github.token }} + RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --output "$RUNNER_TEMP/runtime-packages" + - name: Archive validated runtime packages + run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages + - name: Upload validated runtime packages + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages.tar.gz + if-no-files-found: error + retention-days: 7 + + runtime-test: + name: Test runtime (${{ matrix.os }}) + needs: [validate-dispatch, runtime-plan, runtime-acquire] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - name: Download validated runtime packages + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + run: | + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + runtime-package: + name: Build and verify runtime release + needs: [validate-dispatch, runtime-plan, runtime-acquire, runtime-test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Download validated runtime packages + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + run: | + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: ${{ inputs.dist-tag }} + RUNTIME_RUN_ID: ${{ needs.validate-dispatch.outputs.runtime_run_id }} + RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.runtime-plan.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + runtime-publish-internal: + name: Publish runtime release internally + if: | + always() && + !cancelled() && + inputs.mode == 'publish' && + needs.runtime-plan.result == 'success' && + needs.runtime-package.result == 'success' + needs: [validate-dispatch, runtime-plan, runtime-package] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.dist-tag }} + cancel-in-progress: false + queue: max + environment: cicd + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download retained release + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.dist-tag }}" ] || + { echo "::error::Retained release channel does not match the requested channel."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + 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 exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist "${{ inputs.dist-tag }}" "$FEED_URL" azure + - name: Clean install and package version check + env: + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + run: | + VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.dist-tag }}-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + node -e ' + const expected = process.argv[1]; + const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); + const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); + if (umbrella.version !== expected || platform.version !== expected) { + throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); + } + ' "$SDK_VERSION" + + runtime-publish-public: + name: Publish runtime release publicly + if: inputs.dist-tag == 'unstable' && inputs.mode == 'publish' + needs: [runtime-plan, runtime-publish-internal] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-public-unstable + cancel-in-progress: false + queue: max + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Update npm for trusted publishing + run: npm install --global npm@11.6.3 + - name: Download retained release + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ + dist/release-manifest.json dist + - name: Publish the same tarballs to public npm + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist unstable https://registry.npmjs.org public diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml deleted file mode 100644 index 5c1a37adf4..0000000000 --- a/.github/workflows/runtime-sdk.yml +++ /dev/null @@ -1,388 +0,0 @@ -name: Runtime-driven Node SDK -run-name: "Runtime-driven SDK #${{ github.run_number }} from runtime run ${{ inputs.runtime_run_id }}" - -on: - workflow_dispatch: - inputs: - channel: - description: "Release channel" - required: true - type: choice - options: - - canary - - unstable - runtime_version: - description: "Exact runtime package version" - required: true - type: string - runtime_sha: - description: "Full github/copilot-agent-runtime source SHA" - required: true - type: string - runtime_run_id: - description: "Source runtime workflow run ID for provenance" - required: true - type: string - mode: - description: "tests-only validates canary; publish releases to channel destinations" - required: true - type: choice - options: - - tests-only - - publish - default: publish - version: - description: "Unstable SemVer base for a direct manual run; workflow identity is appended" - required: false - type: string - -permissions: - contents: read - -env: - ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 - FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ - HUSKY: 0 - -jobs: - plan: - name: Plan - runs-on: ubuntu-latest - environment: cicd - permissions: - actions: read - contents: read - outputs: - artifact_name: ${{ steps.plan.outputs.artifact_name }} - sdk_version: ${{ steps.plan.outputs.sdk_version }} - workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Validate runtime release inputs - working-directory: ./nodejs - env: - CHANNEL: ${{ inputs.channel }} - MODE: ${{ inputs.mode }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - VERSION_OVERRIDE: ${{ inputs.version }} - run: npx tsx scripts/runtime-release-identity.ts - - name: Calculate the release identity - id: plan - working-directory: ./nodejs - env: - CHANNEL: ${{ inputs.channel }} - GH_TOKEN: ${{ github.token }} - SDK_CHANNEL: ${{ inputs.channel }} - SDK_SHA: ${{ github.sha }} - SDK_VERSION_OVERRIDE: ${{ inputs.version }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - set -euo pipefail - WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" - gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" - export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" - export WORKFLOW_CREATED_AT - SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" - npm exec -- semver "$SDK_VERSION" >/dev/null - ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" - { - echo "artifact_name=$ARTIFACT_NAME" - echo "sdk_version=$SDK_VERSION" - echo "workflow_created_at=$WORKFLOW_CREATED_AT" - } >> "$GITHUB_OUTPUT" - - acquire-runtime: - name: Acquire runtime - needs: plan - runs-on: ubuntu-latest - environment: cicd - permissions: - contents: read - packages: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Configure authentication-only GitHub Packages access - env: - NODE_AUTH_TOKEN: ${{ github.token }} - run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" - - name: Download and validate all runtime platforms - env: - NODE_AUTH_TOKEN: ${{ github.token }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - run: | - npm run acquire:runtime-packages -- \ - --version "$RUNTIME_VERSION" \ - --sha "$RUNTIME_SHA" \ - --output "$RUNNER_TEMP/runtime-packages" - - name: Archive validated runtime packages - run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages - - name: Upload validated runtime packages - uses: actions/upload-artifact@v7.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-packages.tar.gz - if-no-files-found: error - retention-days: 7 - - test: - name: Test (${{ matrix.os }}) - needs: [plan, acquire-runtime] - permissions: - contents: read - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - environment: cicd - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Install test harness dependencies - working-directory: ./test/harness - run: npm ci --ignore-scripts - - name: Download validated runtime packages - uses: actions/download-artifact@v8.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-package-artifact - - name: Extract validated runtime packages - run: | - runner_temp="$RUNNER_TEMP" - if command -v cygpath >/dev/null 2>&1; then - runner_temp="$(cygpath -u "$runner_temp")" - fi - rm -rf "$runner_temp/runtime-packages" - tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - - name: Select the acquired runtime - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ inputs.runtime_version }} - run: | - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - runtime_path="$(npm run --silent prepare:runtime -- --print-path)" - echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - run: npm run build - - name: Warm up PowerShell - if: runner.os == 'Windows' - run: pwsh.exe -Command "Write-Host 'PowerShell ready'" - - name: Run Node SDK tests - env: - COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: npm test - - package: - name: Build and verify - needs: [plan, acquire-runtime, test] - runs-on: ubuntu-latest - permissions: - contents: read - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - cache: npm - cache-dependency-path: ./nodejs/package-lock.json - node-version: 22 - - run: npm ci --ignore-scripts - - name: Download validated runtime packages - uses: actions/download-artifact@v8.0.0 - with: - name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} - path: ${{ runner.temp }}/runtime-package-artifact - - name: Extract validated runtime packages - run: | - runner_temp="$RUNNER_TEMP" - if command -v cygpath >/dev/null 2>&1; then - runner_temp="$(cygpath -u "$runner_temp")" - fi - rm -rf "$runner_temp/runtime-packages" - tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" - - name: Build and verify exact package set - env: - COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} - run: | - VERSION="$SDK_VERSION" node scripts/set-version.js - node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package - grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts - npm run build - npm run pack:release - npm run verify:release-packages - - name: Create immutable release manifest - env: - RELEASE_CHANNEL: ${{ inputs.channel }} - RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} - RUNTIME_SHA: ${{ inputs.runtime_sha }} - RUNTIME_VERSION: ${{ inputs.runtime_version }} - SDK_REF: ${{ github.ref }} - SDK_SHA: ${{ github.sha }} - SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} - WORKFLOW_CREATED_AT: ${{ needs.plan.outputs.workflow_created_at }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - WORKFLOW_RUN_NUMBER: ${{ github.run_number }} - run: | - npm run release:manifest -- create release-manifest.json . - npm run release:manifest -- verify release-manifest.json . - - uses: actions/upload-artifact@v7.0.0 - with: - name: ${{ needs.plan.outputs.artifact_name }} - path: | - nodejs/release-manifest.json - nodejs/github-copilot-sdk-*.tgz - if-no-files-found: error - retention-days: 30 - - publish-internal: - name: Publish internally - if: | - always() && - !cancelled() && - inputs.mode == 'publish' && - needs.plan.result == 'success' && - needs.package.result == 'success' - needs: [plan, package] - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-internal-${{ inputs.channel }} - cancel-in-progress: false - queue: max - environment: cicd - permissions: - actions: read - contents: read - id-token: write - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Download retained release - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.plan.outputs.artifact_name }} - path: ./dist - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist - [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || - { echo "::error::Retained release belongs to a different workflow run."; exit 1; } - [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || - { echo "::error::Retained release channel does not match the requested channel."; exit 1; } - - name: Azure login - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 - with: - allow-no-subscriptions: true - client-id: ${{ vars.CPD_ID_CLIENT_ID }} - tenant-id: ${{ vars.CPD_ID_TENANT_ID }} - - name: Configure authentication-only Azure npm access - run: | - 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 exact tarballs internally - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure - - name: Clean install and package version check - env: - SDK_VERSION: ${{ needs.plan.outputs.sdk_version }} - run: | - VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" - mkdir -p "$VERIFY_ROOT" - cd "$VERIFY_ROOT" - npm init -y >/dev/null - printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" - npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" - node -e ' - const expected = process.argv[1]; - const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); - const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); - if (umbrella.version !== expected || platform.version !== expected) { - throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); - } - ' "$SDK_VERSION" - - publish-public: - name: Publish publicly - if: inputs.channel == 'unstable' && inputs.mode == 'publish' - needs: [plan, publish-internal] - runs-on: ubuntu-latest - concurrency: - group: sdk-runtime-public-unstable - cancel-in-progress: false - queue: max - permissions: - actions: read - contents: read - id-token: write - steps: - - uses: actions/checkout@v6.0.2 - - uses: actions/setup-node@v6 - with: - node-version: 22 - - run: npm ci --ignore-scripts - working-directory: ./nodejs - - name: Update npm for trusted publishing - run: npm install --global npm@11.6.3 - - name: Download retained release - uses: actions/download-artifact@v8.0.0 - with: - name: ${{ needs.plan.outputs.artifact_name }} - path: ./dist - - name: Validate retained release - run: | - node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ - dist/release-manifest.json dist - - name: Publish the same tarballs to public npm - run: | - node nodejs/scripts/npm-release.js publish-manifest \ - dist/release-manifest.json dist unstable https://registry.npmjs.org public diff --git a/docs/developer-docs/secrets.md b/docs/developer-docs/secrets.md index 8f6904b609..d169dd7374 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -10,7 +10,7 @@ This document covers secrets management for the github/copilot-sdk repository. I These secrets are used by the per-language SDK test workflows and the canary workflow. * **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments. - * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `runtime-sdk.yml` + * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `publish.yml` ## Agentic workflow secrets diff --git a/docs/developer-docs/unstable-releases.md b/docs/developer-docs/unstable-releases.md index 9313097b75..6d877f6a31 100644 --- a/docs/developer-docs/unstable-releases.md +++ b/docs/developer-docs/unstable-releases.md @@ -3,86 +3,104 @@ Canary releases remain an internal runtime-to-SDK channel. Unstable Node SDK releases can either publish the selected SDK branch with its existing bundled runtime or package exact runtime inputs supplied by `github/copilot-agent-runtime`. - -## Entry points - -Use `.github/workflows/publish.yml` for a direct unstable release of the -selected SDK branch as-is. The workflow packages its selected or bundled -runtime, publishes the nine Node SDK packages to public npm, then mirrors those -packages to the internal Azure feed. Direct unstable releases can run from a -non-main branch. They do not publish .NET, Rust, Python, Java, or Go releases, -and they do not create an SDK GitHub Release. The same workflow remains the -normal stable and prerelease publisher for all SDK languages. - -The runtime workflow dispatches `.github/workflows/runtime-sdk.yml` at an -explicit SDK ref with the exact runtime version, full source SHA, and source -workflow run ID. This runtime-driven Node entry owns runtime acquisition, -cross-platform tests, packaging, manifest retention, optional internal -publication, and public unstable npm publication. - -The runtime dispatch includes these inputs: - -- `channel`: `canary` or `unstable` -- `runtime_version`: Exact runtime package version -- `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA -- `runtime_run_id`: Source runtime workflow run ID for provenance -- `mode`: `tests-only` or `publish` for canary; `publish` for unstable - -Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The -optional `version` input is available only for unstable and must be an unstable -SemVer base. The workflow appends its run ID and SDK SHA so each new -dispatch still creates a unique version. Unstable runs reject `tests-only`. +All production release jobs run in `.github/workflows/publish.yml`. + +## Dispatch inputs + +The workflow has one input surface for maintainer and automation dispatches: + +* `dist-tag`: Required release channel: `latest`, `prerelease`, `unstable`, or + `canary`. The default is `prerelease`. +* `version`: Optional direct SDK version. For direct unstable releases, this is + an unstable SemVer base before workflow identity is added. +* `mode`: Required execution mode: `publish` or `dry-run`. The default is + `publish`. +* `runtime`: Optional automation-only JSON object with exactly the string fields + `version`, `sha`, and `run_id`. + +The runtime workflow dispatches `publish.yml` at an explicit SDK ref. For +example: + +```json +{"version":"1.0.83-5.unstable.123.gabcdef0","sha":"abcdef0123456789abcdef0123456789abcdef01","run_id":"34640000001"} +``` + +Runtime JSON is valid only for canary and unstable releases. Canary requires +runtime JSON; direct canary releases are rejected. The workflow also rejects a +direct `version` combined with runtime JSON. Runtime JSON must contain an exact +SemVer for the selected channel, a lowercase 40-character +`github/copilot-agent-runtime` SHA, and a positive canonical decimal workflow +run ID. + +Dry-run mode is valid only for canary and unstable releases. Stable and +prerelease dry-runs are rejected because the existing Java release path does +not have a non-mutating build-only mode. + +For a direct unstable release, select `dist-tag: unstable`, leave `runtime` +empty, and optionally provide `version`. The workflow packages the selected SDK +branch with its selected or bundled runtime. Direct unstable releases can run +from a non-main branch. They do not publish .NET, Rust, Python, Java, or Go +releases, and they do not create an SDK GitHub Release. The same workflow +remains the normal stable and prerelease publisher for all SDK languages. ## Release gates -Both channels acquire all eight `@github/copilot-` packages from -GitHub Packages with the job-scoped `GITHUB_TOKEN`. The workflows validate npm -integrity, runtime version and SHA metadata, the exact package set, platform -metadata, repository metadata, and required runtime files. - -The runtime-driven workflow runs runtime-backed Node SDK tests on Ubuntu, -macOS, and Windows. It then builds and verifies eight self-contained -`@github/copilot-sdk-` packages and the -`@github/copilot-sdk` umbrella package. The checked-in -`COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; runtime npm packages are -build inputs rather than published dependencies. - -Both unstable entry points use the same version planner. A generated version is -`-unstable..g`, where the target core -comes from the nearest eligible SDK release on the selected branch's +Runtime-initiated releases acquire all eight +`@github/copilot-` packages from GitHub Packages with the job-scoped +`GITHUB_TOKEN`. The workflow validates npm integrity, runtime version and SHA +metadata, the exact package set, platform metadata, repository metadata, and +required runtime files. + +The runtime jobs run runtime-backed Node SDK tests on Ubuntu, macOS, and +Windows. They then build and verify eight self-contained +`@github/copilot-sdk-` packages and the `@github/copilot-sdk` umbrella +package. The checked-in `COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; +runtime npm packages are build inputs rather than published dependencies. + +Both unstable dispatch modes use the same version planner. A generated version +is `-unstable..g`, where the target +core comes from the nearest eligible SDK release on the selected branch's first-parent history. A stable baseline increments the patch; a prerelease -baseline retains its release core. An explicit unstable base uses -`..g`. GitHub workflow run -IDs are repository-wide, so the two entry points cannot collide when their -per-workflow run numbers happen to match. Release eligibility is frozen at the -workflow creation time, so a same-run retry keeps its identity and each new +baseline retains its release core. A direct release with an explicit unstable +base uses `..g`. GitHub +workflow run IDs are repository-wide, so the two modes cannot collide when +their per-workflow run numbers happen to match. Release eligibility is frozen +at workflow creation time, so a same-run retry keeps its identity and each new dispatch receives a new version. -The runtime-driven packaging job writes all nine tarballs and +Canary versions use +`X.Y.(Z+1)-canary..g`, based on the newest +stable SDK release published before workflow creation. + +The runtime packaging job writes all nine tarballs and `release-manifest.json` to one retained artifact. Publication jobs use that artifact without rebuilding or recalculating its identity. ## Publication order -Canary `tests-only` runs stop after package verification. Canary `publish` -runs publish platform packages before the umbrella package to the Azure -`copilot-canary` feed, then perform a clean install and package version check. -No canary job has a public npm publication path. +Canary and runtime-backed unstable `dry-run` runs stop after package and local +manifest verification. Direct unstable `dry-run` runs build, pack, and verify +the same nine-package set without registry mutations. Dry-runs do not acquire +publication concurrency locks. + +Canary `publish` runs publish platform packages before the umbrella package to +the Azure `copilot-canary` feed, then perform a clean install and package +version check. No canary job has a public npm publication path. -Direct `publish.yml` unstable runs publish the platform packages and umbrella -package to public npm first, then mirror the same Node package set to Azure. +Direct unstable runs publish the platform packages and umbrella package to +public npm first, then mirror the same Node package set to Azure. -Runtime-driven unstable runs publish the retained platform tarballs and +Runtime-initiated unstable runs publish the retained platform tarballs and umbrella tarball to Azure first. A clean internal install must start the exact -selected SDK package version before public publication begins. The strict -acquisition and package validation gates verify the embedded runtime identity. -The public job uses npm trusted publishing from `runtime-sdk.yml` and publishes -the same tarballs under the `unstable` dist-tag, with the umbrella package last. +selected SDK package version before public publication begins. The public job +runs directly in `publish.yml` so npm trusted publishing sees the configured +workflow identity. It publishes the same tarballs under the `unstable` +dist-tag, with the umbrella package last. -The two entry points share concurrency locks for public npm and internal Azure -publication so they cannot race either set of `unstable` tags. +The two dispatch modes share concurrency locks for public npm and internal +Azure publication so they cannot race either set of `unstable` tags. -Both unstable paths validate all nine retained tarballs against local SHA-512 +Both unstable modes validate all nine retained tarballs against local SHA-512 manifest values before publication. A successful `npm publish` completes a package publication. A recognized immutable-version conflict means the package was already published and also completes that package publication; output @@ -96,15 +114,14 @@ resolution differs. ## Recovery Use **Re-run failed jobs** on the original workflow run for normal recovery. -The workflow run ID and frozen version remain unchanged. Runtime-driven runs +The workflow run ID and frozen version remain unchanged. Runtime-initiated runs also retain the package artifact. Do not rerun a successful packaging job merely to recover a publication job. The runtime run ID is retained as provenance only. Re-running the same SDK workflow run retries its frozen SDK version and retained artifact. A new workflow dispatch creates a new SDK release identity and version, even when it -uses the same runtime run, version, and SHA. This allows any number of SDK -releases to reuse the same immutable runtime packages. +uses the same runtime run, version, and SHA. ## Registry setup @@ -117,7 +134,6 @@ coordinates to GitHub Packages and confirm that this repository can read all eight with its workflow token. Confirm npm trusted publisher configuration authorizes -both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for -`@github/copilot-sdk` and all eight `@github/copilot-sdk-` package -names. The first identity publishes stable, prerelease, and direct unstable -versions; the second publishes runtime-driven unstable versions. +`.github/workflows/publish.yml` for `@github/copilot-sdk` and all eight +`@github/copilot-sdk-` package names. This workflow publishes stable, +prerelease, direct unstable, and runtime-initiated unstable versions. diff --git a/nodejs/scripts/runtime-release-identity.ts b/nodejs/scripts/runtime-release-identity.ts index 47a3c59568..8a2ddd2763 100644 --- a/nodejs/scripts/runtime-release-identity.ts +++ b/nodejs/scripts/runtime-release-identity.ts @@ -1,21 +1,32 @@ import assert from "node:assert/strict"; +import { appendFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import * as semver from "semver"; +export type ReleaseDistTag = "canary" | "latest" | "prerelease" | "unstable"; +export type ReleaseMode = "dry-run" | "publish"; export type RuntimeReleaseChannel = "canary" | "unstable"; -export type RuntimeReleaseMode = "publish" | "tests-only"; -export interface RuntimeReleaseInputs { - channel: RuntimeReleaseChannel; - mode: RuntimeReleaseMode; +export interface ReleaseDispatchInputs { + distTag: ReleaseDistTag; + mode: ReleaseMode; + runtimeJson: string; + version: string; +} + +export interface ReleaseDispatchPlan { + kind: "direct" | "runtime"; runtimeRunId: string; runtimeSha: string; runtimeVersion: string; - versionOverride: string; } -const canonicalNumericIdPattern = /^[1-9][0-9]*$/; +interface RuntimeDescriptor { + run_id: string; + sha: string; + version: string; +} export function validateRuntimeVersionChannel( version: string, @@ -34,30 +45,81 @@ export function validateRuntimeVersionChannel( ); } -export function validateRuntimeReleaseInputs(inputs: RuntimeReleaseInputs): void { - assert(inputs.channel === "canary" || inputs.channel === "unstable", "Invalid release channel"); +function parseRuntimeDescriptor(value: string): RuntimeDescriptor { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Runtime input must be valid JSON."); + } assert( - inputs.channel === "canary" - ? inputs.mode === "tests-only" || inputs.mode === "publish" - : inputs.mode === "publish", - "Invalid channel or mode combination" + typeof parsed === "object" && parsed !== null && !Array.isArray(parsed), + "Runtime input must be a JSON object" ); - assert.match( - inputs.runtimeRunId, - canonicalNumericIdPattern, - "Runtime workflow run ID must be a positive canonical integer" + assert.deepEqual( + Object.keys(parsed).sort(), + ["run_id", "sha", "version"], + "Runtime input must contain exactly version, sha, and run_id" ); - assert.match(inputs.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); - validateRuntimeVersionChannel(inputs.runtimeVersion, inputs.channel); + const runtime = parsed as Partial; + for (const name of ["version", "sha", "run_id"] as const) { + assert.equal(typeof runtime[name], "string", `Runtime ${name} must be a string`); + } + return runtime as RuntimeDescriptor; +} + +export function validateReleaseDispatch(inputs: ReleaseDispatchInputs): ReleaseDispatchPlan { + assert( + inputs.distTag === "latest" || + inputs.distTag === "prerelease" || + inputs.distTag === "unstable" || + inputs.distTag === "canary", + "Invalid release dist-tag" + ); + assert(inputs.mode === "publish" || inputs.mode === "dry-run", "Invalid release mode"); + + if (inputs.runtimeJson === "") { + assert(inputs.distTag !== "canary", "Canary releases require runtime JSON"); + assert( + inputs.mode !== "dry-run" || inputs.distTag === "unstable", + "Dry-run mode is supported only for canary and unstable releases" + ); + return { + kind: "direct", + runtimeRunId: "", + runtimeSha: "", + runtimeVersion: "", + }; + } + assert.equal( - inputs.versionOverride, - inputs.versionOverride.trim(), - "SDK version override must not contain surrounding whitespace" + inputs.runtimeJson, + inputs.runtimeJson.trim(), + "Runtime input must not contain surrounding whitespace" ); assert( - inputs.channel !== "canary" || inputs.versionOverride === "", - "Canary runs do not accept a version override" + inputs.distTag === "canary" || inputs.distTag === "unstable", + "Runtime JSON is supported only for canary and unstable releases" ); + assert.equal( + inputs.version, + "", + "The direct version input cannot be combined with runtime JSON" + ); + const runtime = parseRuntimeDescriptor(inputs.runtimeJson); + assert.match( + runtime.run_id, + /^[1-9][0-9]*$/, + "Runtime run_id must be a positive canonical integer" + ); + assert.match(runtime.sha, /^[0-9a-f]{40}$/, "Runtime sha must be a lowercase full SHA"); + validateRuntimeVersionChannel(runtime.version, inputs.distTag); + return { + kind: "runtime", + runtimeRunId: runtime.run_id, + runtimeSha: runtime.sha, + runtimeVersion: runtime.version, + }; } function requiredEnvironment(name: string): string { @@ -69,14 +131,22 @@ function requiredEnvironment(name: string): string { } function main(): void { - validateRuntimeReleaseInputs({ - channel: requiredEnvironment("CHANNEL") as RuntimeReleaseChannel, - mode: requiredEnvironment("MODE") as RuntimeReleaseMode, - runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), - runtimeSha: requiredEnvironment("RUNTIME_SHA"), - runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), - versionOverride: process.env.VERSION_OVERRIDE ?? "", + const plan = validateReleaseDispatch({ + distTag: requiredEnvironment("DIST_TAG") as ReleaseDistTag, + mode: requiredEnvironment("MODE") as ReleaseMode, + runtimeJson: process.env.RUNTIME_JSON ?? "", + version: process.env.VERSION_OVERRIDE ?? "", }); + appendFileSync( + requiredEnvironment("GITHUB_OUTPUT"), + [ + `kind=${plan.kind}`, + `runtime_run_id=${plan.runtimeRunId}`, + `runtime_sha=${plan.runtimeSha}`, + `runtime_version=${plan.runtimeVersion}`, + "", + ].join("\n") + ); } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index a09869d8d6..71ec0e63ee 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -3,160 +3,119 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const repositoryRoot = join(import.meta.dirname, "..", ".."); -const workflow = (name: string) => - readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); -const publish = workflow("publish.yml"); -const runtimeSdk = workflow("runtime-sdk.yml"); -const publishVersionJob = publish.slice( - publish.indexOf(" version:"), - publish.indexOf(" package-nodejs:") -); -const directPackageJob = publish.slice( - publish.indexOf(" package-nodejs:"), - publish.indexOf(" publish-nodejs:") -); -const directNodePublicationJob = publish.slice( - publish.indexOf(" publish-nodejs:"), - publish.indexOf(" publish-nodejs-internal:") -); -const directInternalPublicationJob = publish.slice( - publish.indexOf(" publish-nodejs-internal:"), - publish.indexOf(" publish-dotnet:") -); -const dotnetPublicationJob = publish.slice( - publish.indexOf(" publish-dotnet:"), - publish.indexOf(" publish-rust:") -); -const rustPublicationJob = publish.slice( - publish.indexOf(" publish-rust:"), - publish.indexOf(" publish-python:") -); -const pythonPublicationJob = publish.slice( - publish.indexOf(" publish-python:"), - publish.indexOf(" publish-java:") -); -const javaPublicationJob = publish.slice( - publish.indexOf(" publish-java:"), - publish.indexOf(" github-release:") -); -const githubReleaseJob = publish.slice(publish.indexOf(" github-release:")); -const runtimeReleaseIdentity = readFileSync( - join(repositoryRoot, "nodejs", "scripts", "runtime-release-identity.ts"), - "utf8" +const workflowPath = join(repositoryRoot, ".github", "workflows", "publish.yml"); +const publish = readFileSync(workflowPath, "utf8"); +const jobStart = (name: string) => publish.indexOf(`\n ${name}:`) + 1; +const job = (name: string, next?: string) => + publish.slice(jobStart(name), next ? jobStart(next) : publish.length); + +const inputSection = publish.slice( + publish.indexOf(" inputs:"), + publish.indexOf("\n\npermissions:") ); +const validateDispatchJob = job("validate-dispatch", "version"); +const directVersionJob = job("version", "package-nodejs"); +const directPackageJob = job("package-nodejs", "publish-nodejs"); +const directPublicJob = job("publish-nodejs", "publish-nodejs-internal"); +const directInternalJob = job("publish-nodejs-internal", "publish-dotnet"); +const dotnetJob = job("publish-dotnet", "publish-rust"); +const rustJob = job("publish-rust", "publish-python"); +const pythonJob = job("publish-python", "publish-java"); +const javaJob = job("publish-java", "github-release"); +const githubReleaseJob = job("github-release", "runtime-plan"); +const runtimePlanJob = job("runtime-plan", "runtime-acquire"); +const runtimeAcquireJob = job("runtime-acquire", "runtime-test"); +const runtimeTestJob = job("runtime-test", "runtime-package"); +const runtimePackageJob = job("runtime-package", "runtime-publish-internal"); +const runtimeInternalJob = job("runtime-publish-internal", "runtime-publish-public"); +const runtimePublicJob = job("runtime-publish-public"); const unstableVersion = readFileSync( join(repositoryRoot, "nodejs", "scripts", "unstable-version.ts"), "utf8" ); -const planJob = runtimeSdk.slice( - runtimeSdk.indexOf(" plan:"), - runtimeSdk.indexOf(" acquire-runtime:") -); -const acquisitionJob = runtimeSdk.slice( - runtimeSdk.indexOf(" acquire-runtime:"), - runtimeSdk.indexOf(" test:") -); -const testJob = runtimeSdk.slice(runtimeSdk.indexOf(" test:"), runtimeSdk.indexOf(" package:")); -const packageJob = runtimeSdk.slice( - runtimeSdk.indexOf(" package:"), - runtimeSdk.indexOf(" publish-internal:") -); -const internalPublicationJob = runtimeSdk.slice( - runtimeSdk.indexOf(" publish-internal:"), - runtimeSdk.indexOf(" publish-public:") -); -const publicPublicationJob = runtimeSdk.slice(runtimeSdk.indexOf(" publish-public:")); - -describe("direct publishing workflow contract", () => { - it("supports stable, prerelease, and direct unstable without runtime handoff inputs", () => { - expect(publish).toContain("- latest"); - expect(publish).toContain("- prerelease"); - expect(publish).toContain("- unstable"); - expect(publish).not.toContain("runtime_version:"); - expect(publish).not.toContain("runtime_run_id:"); - expect(publish).not.toContain("resume_run_id:"); - expect(publish).not.toContain("runtime-backed-node-release.yml"); - }); - it("uses the shared deterministic planner only for unstable", () => { - expect(publishVersionJob).toContain( - "fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }}" - ); - expect(publishVersionJob).toContain("if: inputs.dist-tag == 'unstable'"); - expect(publishVersionJob).toContain("WORKFLOW_CREATED_AT="); - expect(publishVersionJob).toContain( - 'gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100"' - ); - expect(publishVersionJob).toContain("SDK_SHA: ${{ github.sha }}"); - expect(publishVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); - expect(publishVersionJob).not.toContain("WORKFLOW_RUN_NUMBER:"); - expect(publishVersionJob).toContain("SDK_VERSION_OVERRIDE: ${{ inputs.version }}"); - expect(publishVersionJob).toContain("scripts/unstable-version.ts"); - expect(publishVersionJob).toContain("if: inputs.dist-tag != 'unstable'"); - expect(publishVersionJob).toMatch( - /- name: Verify version is available on public npm\s+if: inputs\.dist-tag != 'unstable'/ - ); - expect(publishVersionJob).toContain( - 'VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})"' +describe("unified publishing workflow contract", () => { + it("exposes only the approved four inputs", () => { + expect([...inputSection.matchAll(/^ ([\w-]+):$/gm)].map((match) => match[1])).toEqual([ + "dist-tag", + "version", + "mode", + "runtime", + ]); + for (const distTag of ["latest", "prerelease", "unstable", "canary"]) { + expect(inputSection).toContain(`- ${distTag}`); + } + expect(inputSection).toContain('default: "prerelease"'); + expect(inputSection).toContain("default: publish"); + expect(inputSection).toContain("- dry-run"); + expect(existsSync(join(repositoryRoot, ".github", "workflows", "runtime-sdk.yml"))).toBe( + false ); - expect(publishVersionJob).not.toContain("get-version.js unstable"); }); - it("keeps direct unstable Node-only with manifest-safe public-then-internal ordering", () => { - expect(directNodePublicationJob).toContain( - "if: github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable'" - ); - expect(directPackageJob).toContain("create-package-set package-set-manifest.json"); - expect(directPackageJob).toContain("nodejs/package-set-manifest.json"); - for (const job of [directNodePublicationJob, directInternalPublicationJob]) { - expect(job).toContain("publish-manifest"); - expect(job).toContain('if [ "$DIST_TAG" = "unstable" ]; then'); - expect(job).toContain("npm-release.js publish \\"); - } - expect(directNodePublicationJob).toContain("https://registry.npmjs.org public"); - expect(directInternalPublicationJob).toContain('"$FEED_URL" azure'); - expect(directInternalPublicationJob).toContain("needs: publish-nodejs"); - expect(publish.indexOf(" publish-nodejs:")).toBeLessThan( - publish.indexOf(" publish-nodejs-internal:") - ); - for (const job of [ - dotnetPublicationJob, - rustPublicationJob, - pythonPublicationJob, - javaPublicationJob, - githubReleaseJob, - ]) { - expect(job).toContain("inputs.dist-tag != 'unstable'"); - } + it("parses runtime JSON once and routes only validated outputs", () => { + expect(validateDispatchJob).toContain("npx tsx scripts/runtime-release-identity.ts"); + expect(validateDispatchJob).toContain("RUNTIME_JSON: ${{ inputs.runtime }}"); + expect(validateDispatchJob).toContain( + "runtime_version: ${{ steps.validate.outputs.runtime_version }}" + ); + expect(validateDispatchJob).toContain( + "runtime_sha: ${{ steps.validate.outputs.runtime_sha }}" + ); + expect(validateDispatchJob).toContain( + "runtime_run_id: ${{ steps.validate.outputs.runtime_run_id }}" + ); + expect(publish).not.toContain("fromJSON("); + expect(publish).not.toContain("inputs.runtime_version"); + expect(publish).not.toContain("inputs.runtime_sha"); + expect(publish).not.toContain("inputs.runtime_run_id"); + expect(publish).not.toContain("inputs.channel"); + expect(directVersionJob).toContain("if: needs.validate-dispatch.outputs.kind == 'direct'"); + expect(runtimePlanJob).toContain("if: needs.validate-dispatch.outputs.kind == 'runtime'"); }); - it("shares the public unstable concurrency lock with the runtime-driven path", () => { + it("keeps dry-runs out of publication locks and mutation jobs", () => { expect(publish).toContain( - "group: ${{ inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }}" + "inputs.mode == 'dry-run' && format('publish-dry-run-{0}', github.run_id)" ); - expect(publicPublicationJob).toContain("group: sdk-runtime-public-unstable"); - expect(directInternalPublicationJob).toContain( - "group: sdk-runtime-internal-${{ inputs.dist-tag }}" + expect(directPublicJob).toContain("if: inputs.mode == 'publish'"); + expect(runtimeInternalJob).toContain("inputs.mode == 'publish'"); + expect(runtimePublicJob).toContain("inputs.mode == 'publish'"); + expect(directPackageJob).not.toContain("inputs.mode == 'publish'"); + expect(runtimePackageJob).not.toContain("inputs.mode == 'publish'"); + }); +}); + +describe("direct publishing path", () => { + it("keeps normal version calculation and deterministic direct unstable planning", () => { + expect(directVersionJob).toContain( + "fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }}" ); - expect(internalPublicationJob).toContain( - "group: sdk-runtime-internal-${{ inputs.channel }}" + expect(directVersionJob).toContain("if: inputs.dist-tag == 'unstable'"); + expect(directVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(directVersionJob).toContain("SDK_VERSION_OVERRIDE: ${{ inputs.version }}"); + expect(directVersionJob).toContain("scripts/unstable-version.ts"); + expect(directVersionJob).toContain("if: inputs.dist-tag != 'unstable'"); + expect(directVersionJob).toContain( + 'VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})"' ); - expect(publish).toContain("cancel-in-progress: false"); - expect(publicPublicationJob).toContain("cancel-in-progress: false"); - expect(directInternalPublicationJob).toContain("queue: max"); - expect(internalPublicationJob).toContain("queue: max"); }); - it("uses repository-wide run IDs for unstable while leaving canary on run numbers", () => { - expect(publishVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); - expect(planJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); - expect(planJob).toContain("WORKFLOW_RUN_NUMBER: ${{ github.run_number }}"); - expect(unstableVersion).toContain('runId: requireEnvironment("WORKFLOW_RUN_ID")'); - expect(unstableVersion).toContain('runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER")'); + it("keeps direct unstable Node-only and public-before-internal", () => { + expect(directPublicJob).toContain("inputs.dist-tag == 'unstable'"); + expect(directPackageJob).toContain("create-package-set package-set-manifest.json"); + expect(directPublicJob).toContain("publish-manifest"); + expect(directPublicJob).toContain("https://registry.npmjs.org public"); + expect(directInternalJob).toContain("needs: publish-nodejs"); + expect(directInternalJob).toContain("publish-manifest"); + expect(directInternalJob).toContain('"$FEED_URL" azure'); + for (const nonNodeJob of [dotnetJob, rustJob, pythonJob, javaJob, githubReleaseJob]) { + expect(nonNodeJob).toContain("inputs.dist-tag != 'unstable'"); + } }); - it("retains all normal SDK publication paths", () => { - for (const job of [ + it("retains all stable and prerelease publishers", () => { + for (const jobName of [ "publish-nodejs:", "publish-dotnet:", "publish-rust:", @@ -164,169 +123,67 @@ describe("direct publishing workflow contract", () => { "publish-java:", "github-release:", ]) { - expect(publish).toContain(job); + expect(publish).toContain(jobName); } }); }); -describe("runtime-driven Node SDK entry contract", () => { - it("contains the runtime-backed implementation without a single-caller reusable workflow", () => { - expect( - existsSync( - join(repositoryRoot, ".github", "workflows", "runtime-backed-node-release.yml") - ) - ).toBe(false); - }); - - it("owns both strict runtime handoff matrices", () => { - expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); - expect(runtimeSdk).toContain("runtime_run_id:"); - expect(runtimeSdk).not.toContain("runtime_source:"); - expect(runtimeReleaseIdentity).toContain('inputs.channel === "canary"'); - expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); - expect(runtimeReleaseIdentity).toContain('inputs.mode === "publish"'); - expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); - expect(runtimeSdk).toContain("npx tsx scripts/runtime-release-identity.ts"); - }); - - it("uses the runtime run ID only as provenance", () => { - expect(runtimeSdk).toContain( - 'description: "Source runtime workflow run ID for provenance"' - ); - expect(runtimeSdk).toContain( - 'run-name: "Runtime-driven SDK #${{ github.run_number }} from runtime run ${{ inputs.runtime_run_id }}"' - ); - expect(runtimeSdk).toContain( - 'description: "Unstable SemVer base for a direct manual run; workflow identity is appended"' - ); - expect(runtimeSdk).not.toContain("claim-runtime-dispatch"); - expect(runtimeSdk).not.toContain("sdk-runtime-dispatch-"); - expect(runtimeSdk).not.toContain("runtime-dispatch-ledger"); - expect(runtimeSdk).not.toContain("canonical_run"); - expect(runtimeSdk).not.toContain("CANONICAL_RUN_ID"); - expect(runtimeSdk).not.toContain("gh run watch"); - expect( - existsSync(join(repositoryRoot, "nodejs", "scripts", "runtime-dispatch-ledger.ts")) - ).toBe(false); - expect( - existsSync(join(repositoryRoot, "nodejs", "test", "runtime-dispatch-ledger.test.ts")) - ).toBe(false); - expect(runtimeSdk).toContain("cancel-in-progress: false"); - expect(runtimeSdk.match(/queue: max/g)).toHaveLength(2); - expect(runtimeSdk).not.toContain("resume_run_id"); - }); - - it("plans every invocation before separately serialized publication", () => { - expect(runtimeSdk).toContain("scripts/unstable-version.ts"); - expect(planJob).not.toContain("needs:"); - expect(planJob).not.toContain("if: needs."); - expect(runtimeSdk).toContain("group: sdk-runtime-public-unstable"); - expect(runtimeSdk.indexOf("publish-internal:")).toBeLessThan( - runtimeSdk.indexOf("publish-public:") - ); - expect(runtimeSdk).toContain("needs: [plan, publish-internal]"); - expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); - expect(runtimeSdk).not.toContain("needs.claim-runtime-dispatch"); - }); -}); - -describe("runtime-backed Node release implementation", () => { - it("enforces the channel, source, and mode matrix", () => { - expect(runtimeReleaseIdentity).toContain('inputs.mode === "tests-only"'); - expect(runtimeReleaseIdentity).toContain('inputs.mode === "publish"'); - expect(runtimeReleaseIdentity).not.toContain('inputs.mode === "internal"'); - expect(runtimeReleaseIdentity).toContain("Invalid channel or mode combination"); - expect(runtimeReleaseIdentity).toContain( - "Runtime workflow run ID must be a positive canonical integer" - ); - expect(runtimeReleaseIdentity).toContain("validateRuntimeVersionChannel"); - }); - - it("maps publish mode to channel-specific destinations", () => { - expect(runtimeSdk).toContain("- tests-only"); - expect(runtimeSdk).toContain("- publish"); - expect(runtimeSdk).not.toMatch(/^\s+- internal\s*$/m); - expect(runtimeSdk).toContain("default: publish"); - expect(internalPublicationJob).toContain("inputs.mode == 'publish'"); - expect(internalPublicationJob).not.toContain("inputs.channel == 'unstable'"); - expect(publicPublicationJob).toContain( - "if: inputs.channel == 'unstable' && inputs.mode == 'publish'" - ); - expect(publicPublicationJob).toContain("needs: [plan, publish-internal]"); +describe("runtime-backed publishing path", () => { + it("keeps canary run numbers and unstable repository-wide run IDs", () => { + expect(runtimePlanJob).toContain("SDK_CHANNEL: ${{ inputs.dist-tag }}"); + expect(runtimePlanJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(runtimePlanJob).toContain("WORKFLOW_RUN_NUMBER: ${{ github.run_number }}"); + expect(unstableVersion).toContain('runId: requireEnvironment("WORKFLOW_RUN_ID")'); + expect(unstableVersion).toContain('runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER")'); }); - it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { - expect(runtimeSdk).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); - expect(runtimeSdk).toContain("npm run acquire:runtime-packages"); - expect(acquisitionJob).toContain("packages: read"); - expect(acquisitionJob).toContain("NODE_AUTH_TOKEN: ${{ github.token }}"); - expect(acquisitionJob).not.toContain("--registry"); - expect(acquisitionJob).not.toContain("azure/login"); - expect(acquisitionJob).not.toContain("FEED_URL"); - expect(internalPublicationJob).toContain("azure/login"); - expect(internalPublicationJob).toContain('"$FEED_URL" azure'); - expect(internalPublicationJob).not.toContain("registry.npmjs.org"); - expect(publicPublicationJob).toContain("https://registry.npmjs.org public"); - expect(publicPublicationJob).not.toContain("azure/login"); - expect(publicPublicationJob).not.toContain("FEED_URL"); - expect(runtimeSdk).toContain("npm run verify:release-packages"); - expect(runtimeSdk).toContain("publish-manifest"); - expect(runtimeSdk).not.toContain("preflight-package-set"); - expect(runtimeSdk).not.toContain("for PACKAGE in"); - expect(runtimeSdk).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); - expect(runtimeSdk).not.toContain('"$runtime_path" --version'); - expect(runtimeSdk).not.toContain('"$RUNTIME" --version'); - expect(runtimeSdk).not.toContain("resume_run_id"); - expect(runtimeSdk).toContain("SDK_CHANNEL: ${{ inputs.channel }}"); - expect(runtimeSdk).not.toContain("scripts/get-version.js current"); - expect(runtimeSdk.indexOf("WORKFLOW_CREATED_AT=")).toBeLessThan( - runtimeSdk.indexOf("scripts/unstable-version.ts") + it("acquires validated runtime outputs and tests all runner platforms", () => { + expect(runtimeAcquireJob).toContain("packages: read"); + expect(runtimeAcquireJob).toContain( + "RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }}" ); - expect(runtimeSdk).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); - expect(runtimeSdk.indexOf("npm run verify:release-packages")).toBeLessThan( - runtimeSdk.indexOf("publish-manifest") + expect(runtimeAcquireJob).toContain( + "RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }}" ); + expect(runtimeAcquireJob).toContain("npm run acquire:runtime-packages"); + expect(runtimeAcquireJob).not.toContain("azure/login"); + expect(runtimeTestJob).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(runtimeTestJob).toContain("npm test"); }); - it("preserves runtime package modes across every artifact boundary", () => { - expect(acquisitionJob).toContain( + it("preserves executable modes across runtime package artifact boundaries", () => { + expect(runtimeAcquireJob).toContain( 'tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages' ); - expect(acquisitionJob).toContain("path: ${{ runner.temp }}/runtime-packages.tar.gz"); - expect(acquisitionJob).not.toContain("path: ${{ runner.temp }}/runtime-packages\n"); - - for (const consumer of [testJob, packageJob]) { - expect(consumer).toContain("path: ${{ runner.temp }}/runtime-package-artifact"); + for (const consumer of [runtimeTestJob, runtimePackageJob]) { expect(consumer).toContain("if command -v cygpath >/dev/null 2>&1; then"); expect(consumer).toContain('runner_temp="$(cygpath -u "$runner_temp")"'); expect(consumer).toContain( 'tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp"' ); - expect(consumer).not.toContain( - 'tar -xzf "$RUNNER_TEMP/runtime-package-artifact/runtime-packages.tar.gz"' - ); - expect(consumer).not.toContain("path: ${{ runner.temp }}/runtime-packages\n"); } - expect(testJob.indexOf("Extract validated runtime packages")).toBeLessThan( - testJob.indexOf("Select the acquired runtime") - ); - expect(packageJob.indexOf("Extract validated runtime packages")).toBeLessThan( - packageJob.indexOf("Build and verify exact package set") - ); }); - it("persists and consumes the restored runtime package directory", () => { - expect(testJob).toContain( - 'echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV"' - ); - expect(testJob).toContain('echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV"'); - expect(packageJob).toContain( - "COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages" - ); - expect(packageJob.indexOf("Extract validated runtime packages")).toBeLessThan( - packageJob.indexOf( - "COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages" - ) - ); + it("builds one retained package set and preserves runtime publication order", () => { + expect(runtimePackageJob).toContain("npm run verify:release-packages"); + expect(runtimePackageJob).toContain("release-manifest.json"); + expect(runtimePackageJob).toContain( + "RUNTIME_RUN_ID: ${{ needs.validate-dispatch.outputs.runtime_run_id }}" + ); + expect(runtimeInternalJob).toContain("publish-manifest"); + expect(runtimeInternalJob).toContain('"$FEED_URL" azure'); + expect(runtimeInternalJob).toContain("Clean install and package version check"); + expect(runtimePublicJob).toContain("inputs.dist-tag == 'unstable'"); + expect(runtimePublicJob).toContain("needs: [runtime-plan, runtime-publish-internal]"); + expect(runtimePublicJob).toContain("https://registry.npmjs.org public"); + }); + + it("executes npm publication directly with shared channel locks", () => { + expect(directInternalJob).toContain("group: sdk-runtime-internal-${{ inputs.dist-tag }}"); + expect(runtimeInternalJob).toContain("group: sdk-runtime-internal-${{ inputs.dist-tag }}"); + expect(runtimePublicJob).toContain("group: sdk-runtime-public-unstable"); + expect(runtimePublicJob).toContain("id-token: write"); + expect(runtimePublicJob).toContain("npm install --global npm@11.6.3"); + expect(publish).not.toContain("uses: ./.github/workflows/runtime-sdk.yml"); }); }); diff --git a/nodejs/test/runtime-release-identity.test.ts b/nodejs/test/runtime-release-identity.test.ts index 693fbd26dd..e8467663ed 100644 --- a/nodejs/test/runtime-release-identity.test.ts +++ b/nodejs/test/runtime-release-identity.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from "vitest"; import { - type RuntimeReleaseInputs, - validateRuntimeReleaseInputs, + type ReleaseDispatchInputs, + validateReleaseDispatch, + validateRuntimeVersionChannel, } from "../scripts/runtime-release-identity.js"; -const inputs: RuntimeReleaseInputs = { - channel: "unstable", +const runtime = { + run_id: "34640000001", + sha: "abcdef0123456789abcdef0123456789abcdef01", + version: "1.0.83-5.unstable.123.gabcdef0", +}; +const inputs: ReleaseDispatchInputs = { + distTag: "unstable", mode: "publish", - runtimeRunId: "100", - runtimeSha: "a".repeat(40), - runtimeVersion: "1.2.3-unstable.4", - versionOverride: "", + runtimeJson: "", + version: "", }; -describe("runtime release identity", () => { +describe("runtime version identity", () => { it.each([ ["canary", "1.2.4-canary.7.gdef5678.signed"], ["canary", "1.2.4-canary.8.gdef5678.unsigned"], @@ -21,91 +25,121 @@ describe("runtime release identity", () => { ["unstable", "1.0.83-5.unstable.123.gabcdef0"], ["unstable", "9.9.9-unstable.test"], ["unstable", "1.0.83-5.unstable.123.gabcdef0+build.42"], - ] satisfies [RuntimeReleaseInputs["channel"], string][])( - "accepts a %s runtime version with valid producer suffixes: %s", - (channel, runtimeVersion) => { - expect(() => - validateRuntimeReleaseInputs({ - ...inputs, - channel, - mode: channel === "canary" ? "tests-only" : "publish", - runtimeVersion, - }) - ).not.toThrow(); - } - ); + ] as const)("accepts a %s runtime version: %s", (channel, runtimeVersion) => { + expect(() => validateRuntimeVersionChannel(runtimeVersion, channel)).not.toThrow(); + }); it.each([ ["unstable", "1.2.4-canary.7.gdef5678.signed"], ["canary", "1.0.83-5.unstable.123.gabcdef0"], ["canary", "1.2.4-canaryish.7.gdef5678"], - ] satisfies [RuntimeReleaseInputs["channel"], string][])( - "rejects a runtime version outside the %s channel: %s", - (channel, runtimeVersion) => { - expect(() => - validateRuntimeReleaseInputs({ - ...inputs, - channel, - mode: channel === "canary" ? "tests-only" : "publish", - runtimeVersion, - }) - ).toThrow(`does not belong to the '${channel}' channel`); + ] as const)("rejects a runtime version outside %s: %s", (channel, runtimeVersion) => { + expect(() => validateRuntimeVersionChannel(runtimeVersion, channel)).toThrow( + `does not belong to the '${channel}' channel` + ); + }); + + it("rejects non-canonical runtime versions", () => { + expect(() => validateRuntimeVersionChannel(" 1.2.3-unstable.4", "unstable")).toThrow(); + expect(() => validateRuntimeVersionChannel("1.2.3", "unstable")).toThrow(); + }); +}); + +describe("release dispatch", () => { + it.each(["latest", "prerelease", "unstable"] as const)( + "accepts direct %s publication", + (distTag) => { + expect(validateReleaseDispatch({ ...inputs, distTag })).toEqual({ + kind: "direct", + runtimeRunId: "", + runtimeSha: "", + runtimeVersion: "", + }); } ); - it("enforces the channel and mode matrix", () => { - expect(() => - validateRuntimeReleaseInputs({ - ...inputs, - channel: "canary", - mode: "tests-only", - runtimeVersion: "1.2.3-canary.4", - }) - ).not.toThrow(); - expect(() => - validateRuntimeReleaseInputs({ + it("accepts direct unstable dry-run", () => { + expect( + validateReleaseDispatch({ ...inputs, distTag: "unstable", mode: "dry-run" }).kind + ).toBe("direct"); + }); + + it.each([ + ["canary", "dry-run", "1.0.83-5.canary.123.gabcdef0"], + ["canary", "publish", "1.0.83-5.canary.123.gabcdef0"], + ["unstable", "dry-run", runtime.version], + ["unstable", "publish", runtime.version], + ] as const)("accepts runtime-backed %s %s", (distTag, mode, version) => { + expect( + validateReleaseDispatch({ ...inputs, - channel: "canary", - mode: "publish", - runtimeVersion: "1.2.3-canary.4", + distTag, + mode, + runtimeJson: JSON.stringify({ ...runtime, version }), }) - ).not.toThrow(); - expect(() => validateRuntimeReleaseInputs(inputs)).not.toThrow(); + ).toEqual({ + kind: "runtime", + runtimeRunId: runtime.run_id, + runtimeSha: runtime.sha, + runtimeVersion: version, + }); + }); + + it("rejects canary without runtime JSON", () => { + expect(() => validateReleaseDispatch({ ...inputs, distTag: "canary" })).toThrow( + "Canary releases require runtime JSON" + ); + }); + + it.each(["latest", "prerelease"] as const)("rejects %s dry-run", (distTag) => { + expect(() => validateReleaseDispatch({ ...inputs, distTag, mode: "dry-run" })).toThrow( + "Dry-run mode is supported only for canary and unstable releases" + ); + }); + + it.each(["latest", "prerelease"] as const)("rejects runtime JSON for %s", (distTag) => { expect(() => - validateRuntimeReleaseInputs({ + validateReleaseDispatch({ ...inputs, - mode: "tests-only", + distTag, + runtimeJson: JSON.stringify(runtime), }) - ).toThrow("Invalid channel or mode combination"); + ).toThrow("Runtime JSON is supported only for canary and unstable releases"); + }); + + it("rejects a direct version with runtime JSON", () => { expect(() => - validateRuntimeReleaseInputs({ + validateReleaseDispatch({ ...inputs, - channel: "invalid" as RuntimeReleaseInputs["channel"], + runtimeJson: JSON.stringify(runtime), + version: "2.0.0-unstable.manual", }) - ).toThrow("Invalid release channel"); + ).toThrow("direct version input"); }); - it("rejects non-canonical provenance and identity inputs", () => { - for (const changed of [ - { runtimeRunId: "0" }, - { runtimeRunId: "0100" }, - { runtimeVersion: " 1.2.3-unstable.4" }, - { runtimeSha: "A".repeat(40) }, - { versionOverride: " 1.2.3-unstable.4" }, - ]) { - expect(() => validateRuntimeReleaseInputs({ ...inputs, ...changed })).toThrow(); - } + it("rejects unknown dist-tags and modes", () => { + expect(() => + validateReleaseDispatch({ ...inputs, distTag: "preview" as "unstable" }) + ).toThrow("Invalid release dist-tag"); + expect(() => validateReleaseDispatch({ ...inputs, mode: "test" as "publish" })).toThrow( + "Invalid release mode" + ); }); - it("rejects canary SDK version overrides", () => { - expect(() => - validateRuntimeReleaseInputs({ - ...inputs, - channel: "canary", - mode: "publish", - runtimeVersion: "1.2.3-canary.4", - versionOverride: "1.2.3-canary.manual", - }) - ).toThrow("Canary runs do not accept a version override"); + it.each([ + ["malformed JSON", "{"], + ["whitespace-only input", " "], + ["surrounding whitespace", ` ${JSON.stringify(runtime)}`], + ["null", "null"], + ["array", "[]"], + ["missing key", JSON.stringify({ version: runtime.version, sha: runtime.sha })], + ["extra key", JSON.stringify({ ...runtime, source: "github-packages" })], + ["non-string field", JSON.stringify({ ...runtime, run_id: 123 })], + ["zero run ID", JSON.stringify({ ...runtime, run_id: "0" })], + ["non-canonical run ID", JSON.stringify({ ...runtime, run_id: "0123" })], + ["uppercase SHA", JSON.stringify({ ...runtime, sha: runtime.sha.toUpperCase() })], + ["wrong channel", JSON.stringify({ ...runtime, version: "1.0.83-5.canary.1" })], + ])("rejects %s", (_name, runtimeJson) => { + expect(() => validateReleaseDispatch({ ...inputs, runtimeJson })).toThrow(); }); }); From 15083b8cef7ba5beb5ad61560f0e473217635c2a Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 11 Sep 2026 15:41:25 -0700 Subject: [PATCH 23/23] Make release workflow test cross-platform Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d81fc7cf-d30a-470e-b7bf-42a02f62841d --- nodejs/test/release-workflows.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts index 71ec0e63ee..c1476f7175 100644 --- a/nodejs/test/release-workflows.test.ts +++ b/nodejs/test/release-workflows.test.ts @@ -11,7 +11,7 @@ const job = (name: string, next?: string) => const inputSection = publish.slice( publish.indexOf(" inputs:"), - publish.indexOf("\n\npermissions:") + publish.indexOf("\npermissions:", publish.indexOf(" inputs:")) ); const validateDispatchJob = job("validate-dispatch", "version"); const directVersionJob = job("version", "package-nodejs");