From 71d2ff9a0318560b4b071004f4f9a1c086afee8d Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 14:08:27 -0700 Subject: [PATCH 01/11] 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 | 566 ++++++++---------- 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, 2303 insertions(+), 415 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 5e1d277259..03a7d3b13a 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 7425f8e720..67f35b9d33 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,382 +49,312 @@ 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) - env: - RUNTIME_VERSION: ${{ steps.normalize.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 - - test: - name: "E2E tests (${{ matrix.os }})" + [[ "$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 - if: github.event.repository.fork == false + runs-on: ubuntu-latest environment: cicd - 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 }} + 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" + cache: npm + cache-dependency-path: ./nodejs/package-lock.json node-version: 22 - - - name: Install SDK dependencies - 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' + - run: npm ci --ignore-scripts + - 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 - - # 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' + 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 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}" \ + printf '%s\n' \ "${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 + "${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: | - 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 + 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 - - name: Verify release runtime + 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@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: 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@v6.0.2 - - uses: actions/setup-node@v6 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 - - - name: Azure Login (OIDC -> id-cpd-ci) + 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@v6.0.2 + - uses: actions/setup-node@v6 + 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 e3d76ba6e4..e72ec5c790 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 783c4d5390..ffeb0a3790 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -35,8 +35,10 @@ "scripts": { "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 646cf4b161a1d62cde8c759add829783437d9289 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 14:28:11 -0700 Subject: [PATCH 02/11] 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 | 246 +---------- docs/developer-docs/unstable-releases.md | 6 + nodejs/test/release-workflows.test.ts | 56 ++- 5 files changed, 464 insertions(+), 479 deletions(-) create mode 100644 .github/workflows/runtime-backed-node-release.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03a7d3b13a..7328559063 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 67f35b9d33..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,236 +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@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: 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@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: 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@v6.0.2 - - uses: actions/setup-node@v6 - 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 1f4e948d55ef3e7d5f97e39075e34dd03cd5cc91 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 15:00:37 -0700 Subject: [PATCH 03/11] 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 7328559063..86b9109552 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@v6.0.2 - uses: actions/setup-node@v6 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 ad0db2bea4b6f5cd041bfa7be03f439517580e71 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:00:37 -0700 Subject: [PATCH 04/11] 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 86b9109552..74d22e23f1 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 58ddbce0008466e8586ff9e35dd6fcc4d4e1c831 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:36:05 -0700 Subject: [PATCH 05/11] 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 4a4e87265f35256052b76a2b1f75a4ef8f2b2431 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Fri, 4 Sep 2026 16:47:53 -0700 Subject: [PATCH 06/11] 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 74d22e23f1..206971a552 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 ded4abffc1ba8a2ebd2c74ffca0075919d90be5b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 07:59:52 -0700 Subject: [PATCH 07/11] 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 ac53583ec27e9ae8c90f9a1d5957196c9b167bc7 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 08:26:11 -0700 Subject: [PATCH 08/11] 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 effe4ef11a033117e3f6d37b5582df5b3bf70593 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 08:52:47 -0700 Subject: [PATCH 09/11] 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 ff0548112122541ca75899dc9cbdee792a18c4c0 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 09:12:19 -0700 Subject: [PATCH 10/11] 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 a7d0a79b8ccd4291bce5fcf12515f02a6bf18b3b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 8 Sep 2026 11:07:42 -0700 Subject: [PATCH 11/11] 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); + }); +});