diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb412e6db6..96a337485b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,8 +15,21 @@ on: - latest - prerelease - unstable + - canary version: - description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." + description: "Optional direct SDK version or unstable SemVer base" + type: string + required: false + mode: + description: "Publish or validate without registry and release mutations" + type: choice + required: true + default: publish + options: + - publish + - dry-run + runtime: + description: 'Automation-only runtime JSON: {"version":"...","sha":"...","run_id":"..."}' type: string required: false @@ -24,16 +37,46 @@ permissions: contents: read concurrency: - group: publish + group: ${{ inputs.mode == 'dry-run' && format('publish-dry-run-{0}', github.run_id) || inputs.runtime != '' && format('publish-runtime-{0}', github.run_id) || inputs.dist-tag == 'unstable' && 'sdk-runtime-public-unstable' || 'publish' }} cancel-in-progress: false jobs: + validate-dispatch: + name: Validate dispatch + runs-on: ubuntu-latest + outputs: + kind: ${{ steps.validate.outputs.kind }} + runtime_run_id: ${{ steps.validate.outputs.runtime_run_id }} + runtime_sha: ${{ steps.validate.outputs.runtime_sha }} + runtime_version: ${{ steps.validate.outputs.runtime_version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "22.x" + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Validate release dispatch + id: validate + working-directory: ./nodejs + env: + DIST_TAG: ${{ inputs.dist-tag }} + MODE: ${{ inputs.mode }} + RUNTIME_JSON: ${{ inputs.runtime }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: npx tsx scripts/runtime-release-identity.ts + # Shared job to calculate version once for all publish jobs version: name: Calculate Version + needs: validate-dispatch + if: needs.validate-dispatch.outputs.kind == 'direct' runs-on: ubuntu-latest + permissions: + actions: read + contents: read outputs: - version: ${{ steps.version.outputs.VERSION }} + version: ${{ steps.unstable_version.outputs.VERSION || steps.version.outputs.VERSION }} current: ${{ steps.version.outputs.CURRENT }} current-prerelease: ${{ steps.version.outputs.CURRENT_PRERELEASE }} defaults: @@ -41,11 +84,34 @@ jobs: working-directory: ./nodejs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }} - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" - run: npm ci --ignore-scripts + - name: Plan unstable version + if: inputs.dist-tag == 'unstable' + id: unstable_version + env: + GH_TOKEN: ${{ github.token }} + SDK_CHANNEL: unstable + SDK_SHA: ${{ github.sha }} + SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + VERSION="$(npx tsx scripts/unstable-version.ts)" + npm exec -- semver "$VERSION" >/dev/null + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "Planned unstable version: $VERSION" >> "$GITHUB_STEP_SUMMARY" - name: Get version + if: inputs.dist-tag != 'unstable' id: version run: | CURRENT="$(node scripts/get-version.js current)" @@ -66,7 +132,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 @@ -77,6 +143,7 @@ jobs: fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Verify version is available on public npm + if: inputs.dist-tag != 'unstable' env: VERSION: ${{ steps.version.outputs.VERSION }} run: | @@ -115,17 +182,24 @@ jobs: exit 1 fi npm run verify:release-packages + - name: Create unstable package manifest + if: inputs.dist-tag == 'unstable' + env: + SDK_VERSION: ${{ needs.version.outputs.version }} + run: npm run release:manifest -- create-package-set package-set-manifest.json . - name: Upload artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: nodejs-package - path: nodejs/github-copilot-sdk-*.tgz + path: | + nodejs/github-copilot-sdk-*.tgz + nodejs/package-set-manifest.json if-no-files-found: error publish-nodejs: name: Publish Node.js SDK needs: package-nodejs - if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' + if: inputs.mode == 'publish' && (github.ref == 'refs/heads/main' || inputs.dist-tag == 'unstable') runs-on: ubuntu-latest permissions: actions: read @@ -136,6 +210,10 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" + - name: Install release dependencies + if: inputs.dist-tag == 'unstable' + working-directory: ./nodejs + run: npm ci --ignore-scripts - name: Update npm for OIDC support run: npm i -g "npm@11.6.3" - name: Download Node.js package @@ -148,6 +226,11 @@ jobs: DIST_TAG: ${{ github.event.inputs.dist-tag }} run: | set -euo pipefail + if [ "$DIST_TAG" = "unstable" ]; then + node nodejs/scripts/npm-release.js publish-manifest \ + dist/package-set-manifest.json dist unstable https://registry.npmjs.org public + exit 0 + fi shopt -s nullglob TARBALLS=(./dist/*.tgz) if [ "${#TARBALLS[@]}" -ne 9 ]; then @@ -182,6 +265,10 @@ jobs: needs: publish-nodejs environment: cicd runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.dist-tag }} + cancel-in-progress: false + queue: max permissions: actions: read contents: read @@ -194,6 +281,10 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: "22.x" + - name: Install release dependencies + if: inputs.dist-tag == 'unstable' + working-directory: ./nodejs + run: npm ci --ignore-scripts - name: Download Node.js package uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: @@ -224,6 +315,11 @@ jobs: echo "::error::FEED_URL ('$FEED_URL') is not the expected internal feed. Refusing to publish." exit 1 fi + if [ "$DIST_TAG" = "unstable" ]; then + node nodejs/scripts/npm-release.js publish-manifest \ + dist/package-set-manifest.json dist unstable "$FEED_URL" azure + exit 0 + fi shopt -s nullglob TARBALLS=(./dist/*.tgz) if [ "${#TARBALLS[@]}" -ne 9 ]; then @@ -255,7 +351,7 @@ jobs: publish-dotnet: name: Publish .NET SDK - if: github.event.inputs.dist-tag != 'unstable' + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -298,7 +394,7 @@ jobs: publish-rust: name: Publish Rust SDK - if: github.event.inputs.dist-tag != 'unstable' + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -342,7 +438,7 @@ jobs: publish-python: name: Publish Python SDK - if: github.event.inputs.dist-tag != 'unstable' + if: inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -380,7 +476,7 @@ jobs: publish-java: name: Publish Java SDK - if: github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' + if: inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' needs: version permissions: contents: write @@ -405,7 +501,7 @@ jobs: if: | always() && github.ref == 'refs/heads/main' && - github.event.inputs.dist-tag != 'unstable' && + inputs.dist-tag != 'unstable' && needs.version.result == 'success' && needs.publish-nodejs.result == 'success' && needs.publish-dotnet.result == 'success' && @@ -485,3 +581,339 @@ jobs: fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + runtime-plan: + name: Plan runtime release + needs: validate-dispatch + if: needs.validate-dispatch.outputs.kind == 'runtime' + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + outputs: + artifact_name: ${{ steps.plan.outputs.artifact_name }} + sdk_version: ${{ steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Calculate the release identity + id: plan + working-directory: ./nodejs + env: + CHANNEL: ${{ inputs.dist-tag }} + GH_TOKEN: ${{ github.token }} + SDK_CHANNEL: ${{ inputs.dist-tag }} + SDK_SHA: ${{ github.sha }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" + npm exec -- semver "$SDK_VERSION" >/dev/null + ARTIFACT_NAME="nodejs-${CHANNEL}-${SDK_VERSION}" + { + echo "artifact_name=$ARTIFACT_NAME" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + + runtime-acquire: + name: Acquire runtime + needs: [validate-dispatch, runtime-plan] + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + packages: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Configure authentication-only GitHub Packages access + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + NODE_AUTH_TOKEN: ${{ github.token }} + RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --output "$RUNNER_TEMP/runtime-packages" + - name: Archive validated runtime packages + run: tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages + - name: Upload validated runtime packages + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages.tar.gz + if-no-files-found: error + retention-days: 7 + + runtime-test: + name: Test runtime (${{ matrix.os }}) + needs: [validate-dispatch, runtime-plan, runtime-acquire] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - name: Download validated runtime packages + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + run: | + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + echo "COPILOT_SDK_RUNTIME_PACKAGE_DIR=$COPILOT_SDK_RUNTIME_PACKAGE_DIR" >> "$GITHUB_ENV" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + runtime-package: + name: Build and verify runtime release + needs: [validate-dispatch, runtime-plan, runtime-acquire, runtime-test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Download validated runtime packages + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: runtime-${{ inputs.dist-tag }}-${{ needs.validate-dispatch.outputs.runtime_version }}-${{ needs.validate-dispatch.outputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-package-artifact + - name: Extract validated runtime packages + run: | + runner_temp="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + runner_temp="$(cygpath -u "$runner_temp")" + fi + rm -rf "$runner_temp/runtime-packages" + tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp" + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + run: | + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: ${{ inputs.dist-tag }} + RUNTIME_RUN_ID: ${{ needs.validate-dispatch.outputs.runtime_run_id }} + RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }} + RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.runtime-plan.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + runtime-publish-internal: + name: Publish runtime release internally + if: | + always() && + !cancelled() && + inputs.mode == 'publish' && + needs.runtime-plan.result == 'success' && + needs.runtime-package.result == 'success' + needs: [validate-dispatch, runtime-plan, runtime-package] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.dist-tag }} + cancel-in-progress: false + queue: max + environment: cicd + permissions: + actions: read + contents: read + id-token: write + env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download retained release + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.dist-tag }}" ] || + { echo "::error::Retained release channel does not match the requested channel."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + - name: Publish exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist "${{ inputs.dist-tag }}" "$FEED_URL" azure + - name: Clean install and package version check + env: + SDK_VERSION: ${{ needs.runtime-plan.outputs.sdk_version }} + run: | + VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.dist-tag }}-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + node -e ' + const expected = process.argv[1]; + const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); + const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); + if (umbrella.version !== expected || platform.version !== expected) { + throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); + } + ' "$SDK_VERSION" + + runtime-publish-public: + name: Publish runtime release publicly + if: inputs.dist-tag == 'unstable' && inputs.mode == 'publish' + needs: [runtime-plan, runtime-publish-internal] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-public-unstable + cancel-in-progress: false + queue: max + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Update npm for trusted publishing + run: npm install --global npm@11.6.3 + - name: Download retained release + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: ${{ needs.runtime-plan.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify \ + dist/release-manifest.json dist + - name: Publish the same tarballs to public npm + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist unstable https://registry.npmjs.org public diff --git a/.github/workflows/sdk-canary.yml b/.github/workflows/sdk-canary.yml deleted file mode 100644 index ac57ca6c99..0000000000 --- a/.github/workflows/sdk-canary.yml +++ /dev/null @@ -1,428 +0,0 @@ -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 - -on: - workflow_dispatch: - inputs: - runtime_version: - description: "Exact github/copilot-cli release (public) or @github/copilot package version (internal)" - required: true - type: string - runtime_source: - description: "Where to install the runtime from" - required: true - type: choice - options: - - public - - internal - default: public - mode: - description: "publish (tests must pass), publish-force (publish even if tests fail), or tests-only (run gate, never publish)" - required: false - type: choice - default: publish - options: - - publish - - publish-force - - tests-only - repository_dispatch: - types: [runtime-canary] - -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 }} - cancel-in-progress: false - -jobs: - resolve: - name: "Resolve runtime 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 }} - 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 - id: normalize - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.runtime_version }} - INPUT_SOURCE: ${{ inputs.runtime_source }} - INPUT_MODE: ${{ inputs.mode }} - PAYLOAD_VERSION: ${{ github.event.client_payload.runtime_version }} - PAYLOAD_SOURCE: ${{ github.event.client_payload.runtime_source }} - PAYLOAD_MODE: ${{ github.event.client_payload.mode }} - 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 - case "$MODE" in - publish|publish-force|tests-only) ;; - *) echo "::error::Invalid publish mode '$MODE'. Expected one of: publish, publish-force, tests-only."; 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 }})" - needs: resolve - if: github.event.repository.fork == false - 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 }} - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - cache: "npm" - cache-dependency-path: "./nodejs/package-lock.json" - node-version: 22 - - - 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' - 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' - run: | - set -euo pipefail - TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" - echo "::add-mask::$TOKEN" - # Derive the protocol-relative auth scopes from FEED_URL so the feed - # name lives in exactly one place (the workflow-level env). - FEED_AUTH_REGISTRY="${FEED_URL#https:}" - FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" - NPMRC="$(printf '%s\n' \ - "@github:registry=${FEED_URL}" \ - "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ - "${FEED_AUTH_BASE}:_authToken=${TOKEN}")" - printf '%s\n' "$NPMRC" > .npmrc - echo "Wrote scoped @github registry .npmrc to ./nodejs" - - - name: Override runtime version - run: | - set -euo pipefail - if [ "$RUNTIME_SOURCE" = "internal" ]; then - echo "Installing internal @github/copilot@${RUNTIME_VERSION}" - npm install "@github/copilot@${RUNTIME_VERSION}" --save-exact --ignore-scripts - node scripts/set-cli-version.js "$RUNTIME_VERSION" --npm-package - else - echo "Pinning github/copilot-cli release ${RUNTIME_VERSION}" - node scripts/set-cli-version.js "$RUNTIME_VERSION" - npm install --ignore-scripts - fi - - - name: Verify release runtime - 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" - echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" - - - name: Build SDK - 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 - 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 - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - env: - RUNTIME_VERSION: ${{ needs.resolve.outputs.RUNTIME_VERSION }} - RUNTIME_SOURCE: ${{ needs.resolve.outputs.RUNTIME_SOURCE }} - defaults: - run: - shell: bash - working-directory: ./nodejs - steps: - - name: Warn — publishing despite failed e2e gate (publish-force) - # always() so this audit is never skipped by prior-step status; it fires - # specifically when publish proceeded on a non-green gate via publish-force. - # Runs at the workspace root because it executes before checkout, so the - # job's default working-directory (./nodejs) does not exist yet. - if: always() && needs.test.result != 'success' && needs.resolve.outputs.PUBLISH_MODE == 'publish-force' - working-directory: ${{ github.workspace }} - run: | - echo "::warning title=e2e gate bypassed::Publishing SDK canary despite a non-passing e2e gate (test job result: ${{ needs.test.result }}) via publish-force. Triggered by '${{ github.actor }}' through '${{ github.event_name }}'. The e2e signal was bypassed; build + feed-only guards still apply." - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 - with: - 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 - env: - RUN_NUMBER: ${{ github.run_number }} - 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 - env: - SDK_VERSION: ${{ steps.sdkver.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) - 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) - 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 - 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 - env: - SDK_VERSION: ${{ steps.sdkver.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" diff --git a/docs/developer-docs/secrets.md b/docs/developer-docs/secrets.md index 573f4f22e1..d169dd7374 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -10,7 +10,7 @@ This document covers secrets management for the github/copilot-sdk repository. I These secrets are used by the per-language SDK test workflows and the canary workflow. * **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments. - * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `sdk-canary.yml` + * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `publish.yml` ## Agentic workflow secrets @@ -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 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 new file mode 100644 index 0000000000..6d877f6a31 --- /dev/null +++ b/docs/developer-docs/unstable-releases.md @@ -0,0 +1,139 @@ +# Canary and unstable Node SDK releases + +Canary releases remain an internal runtime-to-SDK channel. Unstable Node SDK +releases can either publish the selected SDK branch with its existing bundled +runtime or package exact runtime inputs supplied by `github/copilot-agent-runtime`. +All production release jobs run in `.github/workflows/publish.yml`. + +## Dispatch inputs + +The workflow has one input surface for maintainer and automation dispatches: + +* `dist-tag`: Required release channel: `latest`, `prerelease`, `unstable`, or + `canary`. The default is `prerelease`. +* `version`: Optional direct SDK version. For direct unstable releases, this is + an unstable SemVer base before workflow identity is added. +* `mode`: Required execution mode: `publish` or `dry-run`. The default is + `publish`. +* `runtime`: Optional automation-only JSON object with exactly the string fields + `version`, `sha`, and `run_id`. + +The runtime workflow dispatches `publish.yml` at an explicit SDK ref. For +example: + +```json +{"version":"1.0.83-5.unstable.123.gabcdef0","sha":"abcdef0123456789abcdef0123456789abcdef01","run_id":"34640000001"} +``` + +Runtime JSON is valid only for canary and unstable releases. Canary requires +runtime JSON; direct canary releases are rejected. The workflow also rejects a +direct `version` combined with runtime JSON. Runtime JSON must contain an exact +SemVer for the selected channel, a lowercase 40-character +`github/copilot-agent-runtime` SHA, and a positive canonical decimal workflow +run ID. + +Dry-run mode is valid only for canary and unstable releases. Stable and +prerelease dry-runs are rejected because the existing Java release path does +not have a non-mutating build-only mode. + +For a direct unstable release, select `dist-tag: unstable`, leave `runtime` +empty, and optionally provide `version`. The workflow packages the selected SDK +branch with its selected or bundled runtime. Direct unstable releases can run +from a non-main branch. They do not publish .NET, Rust, Python, Java, or Go +releases, and they do not create an SDK GitHub Release. The same workflow +remains the normal stable and prerelease publisher for all SDK languages. + +## Release gates + +Runtime-initiated releases acquire all eight +`@github/copilot-` packages from GitHub Packages with the job-scoped +`GITHUB_TOKEN`. The workflow validates npm integrity, runtime version and SHA +metadata, the exact package set, platform metadata, repository metadata, and +required runtime files. + +The runtime jobs run runtime-backed Node SDK tests on Ubuntu, macOS, and +Windows. They then build and verify eight self-contained +`@github/copilot-sdk-` packages and the `@github/copilot-sdk` umbrella +package. The checked-in `COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; +runtime npm packages are build inputs rather than published dependencies. + +Both unstable dispatch modes use the same version planner. A generated version +is `-unstable..g`, where the target +core comes from the nearest eligible SDK release on the selected branch's +first-parent history. A stable baseline increments the patch; a prerelease +baseline retains its release core. A direct release with an explicit unstable +base uses `..g`. GitHub +workflow run IDs are repository-wide, so the two modes cannot collide when +their per-workflow run numbers happen to match. Release eligibility is frozen +at workflow creation time, so a same-run retry keeps its identity and each new +dispatch receives a new version. + +Canary versions use +`X.Y.(Z+1)-canary..g`, based on the newest +stable SDK release published before workflow creation. + +The runtime packaging job writes all nine tarballs and +`release-manifest.json` to one retained artifact. Publication jobs use that +artifact without rebuilding or recalculating its identity. + +## Publication order + +Canary and runtime-backed unstable `dry-run` runs stop after package and local +manifest verification. Direct unstable `dry-run` runs build, pack, and verify +the same nine-package set without registry mutations. Dry-runs do not acquire +publication concurrency locks. + +Canary `publish` runs publish platform packages before the umbrella package to +the Azure `copilot-canary` feed, then perform a clean install and package +version check. No canary job has a public npm publication path. + +Direct unstable runs publish the platform packages and umbrella package to +public npm first, then mirror the same Node package set to Azure. + +Runtime-initiated unstable runs publish the retained platform tarballs and +umbrella tarball to Azure first. A clean internal install must start the exact +selected SDK package version before public publication begins. The public job +runs directly in `publish.yml` so npm trusted publishing sees the configured +workflow identity. It publishes the same tarballs under the `unstable` +dist-tag, with the umbrella package last. + +The two dispatch modes share concurrency locks for public npm and internal +Azure publication so they cannot race either set of `unstable` tags. + +Both unstable modes validate all nine retained tarballs against local SHA-512 +manifest values before publication. A successful `npm publish` completes a +package publication. A recognized immutable-version conflict means the package +was already published and also completes that package publication; output +feeds do not need to expose `dist.integrity`. Azure authentication allows the +workflow to add or advance its tag, but it refuses to rewind a tag that points +to a newer version. Public npm trusted publishing sets `unstable` during +publication. The workflow then verifies all nine `@unstable` resolutions. It +fails rather than attempting a separate public dist-tag mutation if any +resolution differs. + +## Recovery + +Use **Re-run failed jobs** on the original workflow run for normal recovery. +The workflow run ID and frozen version remain unchanged. Runtime-initiated runs +also retain the package artifact. Do not rerun a successful packaging job +merely to recover a publication job. + +The runtime run ID is retained as provenance only. Re-running the same SDK +workflow run retries its frozen SDK version and retained artifact. A new +workflow dispatch creates a new SDK release identity and version, even when it +uses the same runtime run, version, and SHA. + +## 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 runtime dispatch, publish the eight signed runtime package +coordinates to GitHub Packages and confirm that this repository can read all +eight with its workflow token. + +Confirm npm trusted publisher configuration authorizes +`.github/workflows/publish.yml` for `@github/copilot-sdk` and all eight +`@github/copilot-sdk-` package names. This workflow publishes stable, +prerelease, direct unstable, and runtime-initiated unstable versions. diff --git a/nodejs/README.md b/nodejs/README.md index 7effb81e95..2fb98ce972 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -21,6 +21,10 @@ release's `SHA256SUMS.txt`. `npm run pack:release` builds the main package and all platform packages. Set `COPILOT_CLI_DOWNLOAD_BASE_URL` to use a release mirror while packaging. +Release workflows instead set `COPILOT_SDK_RUNTIME_PACKAGE_DIR` to a directory +containing validated runtime npm package roots named for all eight platforms. +This keeps `COPILOT_CLI_USE_NPM_PACKAGE` false and embeds those runtime files in +the self-contained SDK platform packages. ## Installation diff --git a/nodejs/package.json b/nodejs/package.json index 4918bc7274..211938123b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -36,8 +36,10 @@ "auth:refresh": "node ../scripts/npm-auth-refresh.mjs --run", "clean": "rimraf --glob dist *.tgz", "build": "tsx esbuild-copilotsdk-nodejs.ts", + "acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts", "pack:release": "tsx scripts/package-sdk.ts", "verify:release-packages": "tsx scripts/verify-release-packages.ts", + "release:manifest": "tsx scripts/release-manifest.ts", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index fe750bada0..97a1d1a8fa 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -1,13 +1,15 @@ import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; - +import { verifyPackageSetManifestFiles } from "./package-set-manifest.js"; const PUBLIC_CONFLICT = /^(?:npm (?:error|ERR!) code EPUBLISHCONFLICT|npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:You )?cannot publish over (?:the )?previously published versions(?:: [^\r\n]+)?\.?)\r?$/im; const AZURE_CONFLICT = /^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,11 +23,22 @@ 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 getRegistryVersion(packageName, version, registry, runner = runCommand) { const result = await runner("npm", [ "view", `${packageName}@${version}`, @@ -34,52 +47,126 @@ export async function assertVersionAbsent(packageName, version, registry, runner "--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 confirm that ${packageName}@${version} is absent from public npm (npm exited ${result.status}).${output ? `\n${output}` : ""}` + `Could not read ${packageName}@${version} 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 getRegistryVersion(packageName, version, registry, runner); + if (existing !== undefined) { + throw new Error(`${packageName}@${version} already exists on ${registry}.`); + } +} + +export async function publishTarball(tarball, tag, registry, mode, runner = runCommand, identity) { const args = ["publish", tarball, "--tag", tag, "--registry", registry]; if (mode === "public") args.push("--access", "public"); if (mode !== "public" && mode !== "azure") throw new Error(`Unknown publish mode: ${mode}`); const result = await runner("npm", args, { stream: true }); - if (result.status === 0) return; + 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." - ); + const subject = + identity?.name && identity?.version + ? `${identity.name}@${identity.version}` + : "Version"; + console.log(`${subject} is already published; treating the conflict as success.`); return; } throw new Error(`npm publish failed with exit code ${result.status}.`); } +function readReleaseManifest(manifestPath, packageDirectory) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + verifyPackageSetManifestFiles(manifest, packageDirectory); + 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 semver = await import("semver"); + for (const packed of packages) { + const taggedVersion = await getRegistryVersion(packed.name, tag, registry, runner); + if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { + throw new Error( + `${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` + ); + } + } + for (const packed of packages) { + await publishTarball(packed.tarball, tag, registry, mode, runner, packed); + } + for (const packed of packages) { + const taggedVersion = await getRegistryVersion(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.`); + console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`); } else if (command === "publish" && args.length === 4) { await publishTarball(...args); + } else if (command === "publish-manifest" && args.length === 5) { + await publishManifest(...args); } else { throw new Error( - "Usage: npm-release.js preflight | publish " + "Usage: npm-release.js preflight | publish | publish-manifest " ); } } diff --git a/nodejs/scripts/package-set-manifest.js b/nodejs/scripts/package-set-manifest.js new file mode 100644 index 0000000000..c2727ca740 --- /dev/null +++ b/nodejs/scripts/package-set-manifest.js @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; + +export const SDK_PACKAGE_NAMES = [ + "@github/copilot-sdk", + "@github/copilot-sdk-darwin-arm64", + "@github/copilot-sdk-darwin-x64", + "@github/copilot-sdk-linux-arm64", + "@github/copilot-sdk-linux-x64", + "@github/copilot-sdk-linuxmusl-arm64", + "@github/copilot-sdk-linuxmusl-x64", + "@github/copilot-sdk-win32-arm64", + "@github/copilot-sdk-win32-x64", +]; + +export function packageIntegrity(bytes) { + return `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +} + +export function verifyPackageSetManifestFiles(manifest, packageDirectory) { + assert.equal(manifest?.schemaVersion, 1, "Unsupported release manifest schema"); + assert.equal(typeof manifest.sdk?.version, "string", "Invalid SDK version"); + assert(Array.isArray(manifest.packages), "Release manifest packages must be an array"); + assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); + + const packageNames = new Set(); + for (const packed of manifest.packages) { + assert.equal(typeof packed?.name, "string", "Invalid release package name"); + assert.equal(typeof packed.filename, "string", "Invalid release package filename"); + assert.equal(typeof packed.integrity, "string", "Invalid release package integrity"); + assert.equal(typeof packed.size, "number", "Invalid release package size"); + assert( + !packageNames.has(packed.name), + `Duplicate package in release manifest: ${packed.name}` + ); + packageNames.add(packed.name); + + const archive = resolve(packageDirectory, packed.filename); + assert.equal( + dirname(archive), + resolve(packageDirectory), + `Unsafe release filename: ${packed.filename}` + ); + assert.equal( + basename(archive), + packed.filename, + `Unsafe release filename: ${packed.filename}` + ); + const bytes = readFileSync(archive); + assert.equal(statSync(archive).size, packed.size, `Size mismatch for ${packed.filename}`); + assert.equal( + packageIntegrity(bytes), + packed.integrity, + `Integrity mismatch for ${packed.filename}` + ); + } + + assert.deepEqual( + [...packageNames].sort(), + [...SDK_PACKAGE_NAMES].sort(), + "Release manifest package names do not match the expected package set" + ); +} diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts new file mode 100644 index 0000000000..ed18e073e2 --- /dev/null +++ b/nodejs/scripts/release-manifest.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; +import * as semver from "semver"; +import { x as extractTar } from "tar"; +import { + packageIntegrity, + SDK_PACKAGE_NAMES, + verifyPackageSetManifestFiles, +} from "./package-set-manifest.js"; +import { validateRuntimeVersionChannel } from "./runtime-release-identity.js"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +export interface ReleaseManifestPackage { + filename: string; + integrity: string; + name: string; + size: number; +} + +export interface PackageSetManifest { + packages: ReleaseManifestPackage[]; + schemaVersion: 1; + sdk: { + version: string; + }; +} + +export interface ReleaseManifest extends PackageSetManifest { + channel: "canary" | "unstable"; + runtime: { + repository: "github/copilot-agent-runtime"; + runId: string; + sha: string; + source: "github-packages"; + version: string; + }; + 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; + runtimeRunId: string; + runtimeVersion: string; + sdkRef: string; + sdkSha: string; + sdkVersion: string; + workflowRunId: string; + workflowRunNumber: string; +} + +const expectedPackageNames = new Set(SDK_PACKAGE_NAMES); +assert.deepEqual( + [...expectedPackageNames].sort(), + ["@github/copilot-sdk", ...RUNTIME_PLATFORMS.map(getRuntimePackageName)].sort(), + "Shared package manifest names must match the supported runtime platforms" +); + +async function readPackedManifest(archive: string): Promise<{ name: string; version: string }> { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-release-manifest-")); + 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"); + validateRuntimeVersionChannel(metadata.runtimeVersion, metadata.channel); + assert(Number.isFinite(Date.parse(metadata.createdAt)), "Workflow creation time is invalid"); + const packageSet = await createPackageSetManifest(packageDirectory, metadata.sdkVersion); + return { + ...packageSet, + channel: metadata.channel, + sdk: { + ...packageSet.sdk, + sha: metadata.sdkSha, + ref: metadata.sdkRef, + repository: "github/copilot-sdk", + }, + runtime: { + version: metadata.runtimeVersion, + sha: metadata.runtimeSha, + source: "github-packages", + repository: "github/copilot-agent-runtime", + runId: metadata.runtimeRunId, + }, + workflow: { + runId: metadata.workflowRunId, + runNumber: metadata.workflowRunNumber, + createdAt: metadata.createdAt, + }, + }; +} + +export async function createPackageSetManifest( + packageDirectory: string, + sdkVersion: string +): Promise { + assert.equal(semver.valid(sdkVersion), sdkVersion, "Invalid SDK version"); + const packages: ReleaseManifestPackage[] = []; + for (const archive of globSync("github-copilot-sdk-*.tgz", { + cwd: packageDirectory, + absolute: true, + })) { + const packed = await readPackedManifest(archive); + if (packed.version !== sdkVersion || !expectedPackageNames.has(packed.name)) { + continue; + } + const bytes = readFileSync(archive); + packages.push({ + filename: basename(archive), + integrity: packageIntegrity(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, + sdk: { + version: sdkVersion, + }, + packages, + }; +} + +export function verifyPackageSetManifest( + manifest: PackageSetManifest, + packageDirectory: string +): void { + verifyPackageSetManifestFiles(manifest, packageDirectory); + assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); +} + +export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { + verifyPackageSetManifest(manifest, packageDirectory); + assert( + manifest.channel === "canary" || manifest.channel === "unstable", + "Invalid release channel" + ); + validateFullSha(manifest.sdk.sha, "SDK SHA"); + validateFullSha(manifest.runtime.sha, "Runtime SHA"); + validateRuntimeVersionChannel(manifest.runtime.version, manifest.channel); + assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); + assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); + assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); + assert( + Number.isFinite(Date.parse(manifest.workflow.createdAt)), + "Invalid workflow creation time" + ); + assert.equal(manifest.sdk.repository, "github/copilot-sdk"); + assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); + assert.equal(manifest.runtime.source, "github-packages", "Invalid runtime package source"); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + 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-package-set") { + const manifest = await createPackageSetManifest( + packageDirectory, + requiredEnvironment("SDK_VERSION") + ); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + verifyPackageSetManifest(manifest, packageDirectory); + return; + } + if (command === "create") { + const manifest = await createReleaseManifest(packageDirectory, { + channel: requiredEnvironment("RELEASE_CHANNEL") as ReleaseManifest["channel"], + createdAt: requiredEnvironment("WORKFLOW_CREATED_AT"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + 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; + } + if (command === "verify-package-set") { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as PackageSetManifest; + verifyPackageSetManifest(manifest, packageDirectory); + return; + } + throw new Error( + "Usage: release-manifest.ts create|verify|create-package-set|verify-package-set [manifest-path] [package-directory]" + ); +} + +const scriptPath = process.argv[1] + ? 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..ef983384ab 100644 --- a/nodejs/scripts/releaseArtifacts.ts +++ b/nodejs/scripts/releaseArtifacts.ts @@ -1,5 +1,13 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { dirname, join } from "node:path"; import { x as extractTar } from "tar"; import { @@ -23,6 +31,24 @@ const packageDownloads = new Map>(); const checksumDownloads = new Map>>(); const DEFAULT_FETCH_TIMEOUT_MS = 60_000; +function validateLocalPackage( + packageDirectory: string, + platform: string, + expectedVersion?: string +): string | undefined { + const packageRoot = join(packageDirectory, platform); + const manifestPath = join(packageRoot, "package.json"); + validateFile(manifestPath, `${platform} runtime package manifest`); + if (expectedVersion !== undefined) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }; + if (manifest.version !== expectedVersion) { + return undefined; + } + } + validateFile(join(packageRoot, "prebuilds", platform, "runtime.node"), "Copilot runtime.node"); + return packageRoot; +} + async function fetchWithRetry( fetcher: typeof globalThis.fetch, url: string, @@ -107,6 +133,14 @@ export async function ensureCopilotPackage( options: EnsureCopilotPackageOptions = {} ): Promise { const platform = options.platform ?? getRuntimePlatform(); + const environment = options.environment ?? process.env; + const workflowPackageDirectory = environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; + if (workflowPackageDirectory) { + const packageRoot = validateLocalPackage(workflowPackageDirectory, platform, version); + if (packageRoot) { + return packageRoot; + } + } // lgtm[js/trivial-conditional] This generated constant is true for internal canary builds. if (version === COPILOT_CLI_VERSION && COPILOT_CLI_USE_NPM_PACKAGE) { const packageName = `@github/copilot-${platform}`; @@ -130,7 +164,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..ed49cd6081 --- /dev/null +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -0,0 +1,275 @@ +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 { parseArgs } from "node:util"; +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; + runtimeSha: string; + runtimeVersion: string; +} + +export type CommandRunner = ( + command: string, + args: string[], + options?: { cwd?: string } +) => Promise; + +const GITHUB_PACKAGES_REGISTRY = "https://npm.pkg.github.com"; + +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( + 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 }); + 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", + GITHUB_PACKAGES_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", + GITHUB_PACKAGES_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: GITHUB_PACKAGES_REGISTRY, + packages: acquired, + }, + null, + 2 + )}\n` + ); +} + +export function parseArguments(args: string[]): AcquireRuntimePackagesOptions { + const { tokens, values } = parseArgs({ + args, + allowPositionals: false, + options: { + output: { type: "string" }, + sha: { type: "string" }, + version: { type: "string" }, + }, + strict: true, + tokens: true, + }); + for (const name of ["version", "sha", "output"] as const) { + const occurrences = tokens.filter( + (token) => token.kind === "option" && token.name === name + ); + assert.equal(occurrences.length, 1, `Option --${name} must be provided exactly once`); + assert(values[name]?.trim(), `Option --${name} requires a non-empty value`); + } + return { + runtimeVersion: values.version!, + runtimeSha: values.sha!, + outputDirectory: values.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/runtime-release-identity.ts b/nodejs/scripts/runtime-release-identity.ts new file mode 100644 index 0000000000..8a2ddd2763 --- /dev/null +++ b/nodejs/scripts/runtime-release-identity.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { appendFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as semver from "semver"; + +export type ReleaseDistTag = "canary" | "latest" | "prerelease" | "unstable"; +export type ReleaseMode = "dry-run" | "publish"; +export type RuntimeReleaseChannel = "canary" | "unstable"; + +export interface ReleaseDispatchInputs { + distTag: ReleaseDistTag; + mode: ReleaseMode; + runtimeJson: string; + version: string; +} + +export interface ReleaseDispatchPlan { + kind: "direct" | "runtime"; + runtimeRunId: string; + runtimeSha: string; + runtimeVersion: string; +} + +interface RuntimeDescriptor { + run_id: string; + sha: string; + version: string; +} + +export function validateRuntimeVersionChannel( + version: string, + channel: RuntimeReleaseChannel +): void { + assert(channel === "canary" || channel === "unstable", "Invalid channel"); + const parsed = semver.parse(version); + assert(parsed, "Runtime version must be exact SemVer"); + const canonicalVersion = `${parsed.version}${ + parsed.build.length > 0 ? `+${parsed.build.join(".")}` : "" + }`; + assert.equal(version, canonicalVersion, "Runtime version must be exact SemVer"); + assert( + parsed.prerelease.some((identifier) => identifier === channel), + `Runtime version '${version}' does not belong to the '${channel}' channel` + ); +} + +function parseRuntimeDescriptor(value: string): RuntimeDescriptor { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Runtime input must be valid JSON."); + } + assert( + typeof parsed === "object" && parsed !== null && !Array.isArray(parsed), + "Runtime input must be a JSON object" + ); + assert.deepEqual( + Object.keys(parsed).sort(), + ["run_id", "sha", "version"], + "Runtime input must contain exactly version, sha, and run_id" + ); + const runtime = parsed as Partial; + for (const name of ["version", "sha", "run_id"] as const) { + assert.equal(typeof runtime[name], "string", `Runtime ${name} must be a string`); + } + return runtime as RuntimeDescriptor; +} + +export function validateReleaseDispatch(inputs: ReleaseDispatchInputs): ReleaseDispatchPlan { + assert( + inputs.distTag === "latest" || + inputs.distTag === "prerelease" || + inputs.distTag === "unstable" || + inputs.distTag === "canary", + "Invalid release dist-tag" + ); + assert(inputs.mode === "publish" || inputs.mode === "dry-run", "Invalid release mode"); + + if (inputs.runtimeJson === "") { + assert(inputs.distTag !== "canary", "Canary releases require runtime JSON"); + assert( + inputs.mode !== "dry-run" || inputs.distTag === "unstable", + "Dry-run mode is supported only for canary and unstable releases" + ); + return { + kind: "direct", + runtimeRunId: "", + runtimeSha: "", + runtimeVersion: "", + }; + } + + assert.equal( + inputs.runtimeJson, + inputs.runtimeJson.trim(), + "Runtime input must not contain surrounding whitespace" + ); + assert( + inputs.distTag === "canary" || inputs.distTag === "unstable", + "Runtime JSON is supported only for canary and unstable releases" + ); + assert.equal( + inputs.version, + "", + "The direct version input cannot be combined with runtime JSON" + ); + const runtime = parseRuntimeDescriptor(inputs.runtimeJson); + assert.match( + runtime.run_id, + /^[1-9][0-9]*$/, + "Runtime run_id must be a positive canonical integer" + ); + assert.match(runtime.sha, /^[0-9a-f]{40}$/, "Runtime sha must be a lowercase full SHA"); + validateRuntimeVersionChannel(runtime.version, inputs.distTag); + return { + kind: "runtime", + runtimeRunId: runtime.run_id, + runtimeSha: runtime.sha, + runtimeVersion: runtime.version, + }; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + throw new Error(`${name} is required.`); + } + return value; +} + +function main(): void { + const plan = validateReleaseDispatch({ + distTag: requiredEnvironment("DIST_TAG") as ReleaseDistTag, + mode: requiredEnvironment("MODE") as ReleaseMode, + runtimeJson: process.env.RUNTIME_JSON ?? "", + version: process.env.VERSION_OVERRIDE ?? "", + }); + appendFileSync( + requiredEnvironment("GITHUB_OUTPUT"), + [ + `kind=${plan.kind}`, + `runtime_run_id=${plan.runtimeRunId}`, + `runtime_sha=${plan.runtimeSha}`, + `runtime_version=${plan.runtimeVersion}`, + "", + ].join("\n") + ); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + try { + main(); + } 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..49d7975c6e --- /dev/null +++ b/nodejs/scripts/unstable-version.ts @@ -0,0 +1,206 @@ +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; + prerelease?: boolean; + published_at: string | null; + tag_name: string; +} + +export interface UnstableVersionOptions { + createdAt: string; + firstParentTags: string[]; + releases: ReleaseRecord[]; + runId: string; + sdkSha: string; + 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; + } + 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}`; +} + +function validateReleaseIdentity( + createdAt: string, + runIdentity: string, + runIdentityLabel: "ID" | "number", + sdkSha: string +): number { + if (!/^[0-9]+$/.test(runIdentity)) { + throw new Error(`Invalid workflow run ${runIdentityLabel}: ${runIdentity}`); + } + if (!/^[0-9a-f]{40}$/i.test(sdkSha)) { + throw new Error(`Invalid full SDK SHA: ${sdkSha}`); + } + const createdAtTime = Date.parse(createdAt); + if (!Number.isFinite(createdAtTime)) { + throw new Error(`Invalid workflow creation time: ${createdAt}`); + } + return createdAtTime; +} + +export function calculateCanaryVersion(options: CanaryVersionOptions): string { + const createdAt = validateReleaseIdentity( + options.createdAt, + options.runNumber, + "number", + options.sdkSha + ); + const baseline = options.releases + .filter((release) => { + if (release.draft || release.prerelease || release.published_at === null) { + 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.runId, + "ID", + options.sdkSha + ); + + 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 `${parsed.major}.${parsed.minor}.${parsed.patch}-${parsed.prerelease.join(".")}.${options.runId}.g${options.sdkSha.slice(0, 7)}`; + } + + 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.runId}.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 createdAt = requireEnvironment("WORKFLOW_CREATED_AT"); + const version = + requireEnvironment("SDK_CHANNEL") === "canary" + ? calculateCanaryVersion({ + createdAt, + releases, + runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER"), + sdkSha, + }) + : calculateUnstableVersion({ + createdAt, + firstParentTags: getFirstParentTags(sdkSha), + releases, + runId: requireEnvironment("WORKFLOW_RUN_ID"), + 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..84326fcf21 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -1,13 +1,18 @@ +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 { assertVersionAbsent, publishManifest, publishTarball } from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; -const version = "1.2.3"; +const version = "1.2.3-unstable.34640000001.gabcdef0"; const registry = "https://registry.example.test"; +const identity = { name: packageName, version }; 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" } }))); @@ -18,8 +23,8 @@ describe("npm release preflight", () => { 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 transient error", result(1, "", "npm error code E500"), "Could not read"], + ["malformed output", result(1, "not-json"), "Could not read"], [ "a non-404 error containing E404 and 404 text", result( @@ -27,7 +32,7 @@ describe("npm release preflight", () => { 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", + "Could not read", ], ])("fails for %s", async (_name, response, message) => { const runner = vi.fn().mockResolvedValue(response); @@ -38,28 +43,31 @@ describe("npm release preflight", () => { }); describe("npm release publishing", () => { - it("succeeds after a normal publish", async () => { + it("treats a successful publish as success without registry metadata", async () => { const runner = vi.fn().mockResolvedValue(result(0)); await expect( - publishTarball("package.tgz", "latest", registry, "public", runner) + publishTarball("package.tgz", "unstable", registry, "public", runner) ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(1); }); - 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("accepts recognized immutable-version conflicts without registry integrity", async () => { + const runner = vi.fn().mockResolvedValue(result(1, "", "npm error code EPUBLISHCONFLICT")); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) + publishTarball("package.tgz", "unstable", registry, "public", runner, identity) ).resolves.toBeUndefined(); + + runner.mockResolvedValue( + result( + 1, + "", + "npm error 403 https://pkgs.dev.azure.com/example - The feed 'copilot-canary' already contains file 'package.tgz' in package '@github/copilot-sdk'." + ) + ); + await expect( + publishTarball("package.tgz", "canary", registry, "azure", runner, identity) + ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledTimes(2); }); it.each([ @@ -79,10 +87,129 @@ describe("npm release publishing", () => { "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) => { + ["an unrelated npm failure", "npm error E500", "public"], + ])("rejects %s", async (_name, error, mode) => { const runner = vi.fn().mockResolvedValue(result(1, "", error)); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) + publishTarball("package.tgz", "unstable", registry, mode, runner) ).rejects.toThrow("npm publish failed"); }); + + it("validates all packages, publishes platforms before the umbrella, and tags last", async () => { + const directory = mkdtempSync(join(tmpdir(), "copilot-sdk-npm-release-")); + mkdirSync(directory, { recursive: true }); + const packages = [ + "@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") { + return result(0, JSON.stringify(version)); + } + 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(calls.some((args) => args.includes("dist.integrity"))).toBe(false); + + const staleTagRunner = vi.fn(async (_command: string, args: string[]) => { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + 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 azureConflictRunner = vi.fn(async (_command: string, args: string[]) => + args[0] === "view" + ? result(0, JSON.stringify(version)) + : result( + 1, + "", + "npm error 403 https://pkgs.dev.azure.com/example - The feed 'copilot-canary' already contains file 'package.tgz' in package '@github/copilot-sdk'." + ) + ); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "azure", + azureConflictRunner + ) + ).resolves.toBeUndefined(); + expect( + azureConflictRunner.mock.calls.some(([, args]) => args.includes("dist.integrity")) + ).toBe(false); + const missingTagRunner = vi.fn(async (_command: string, args: string[]) => { + return args[0] === "view" + ? result(1, JSON.stringify({ error: { code: "E404" } })) + : result(0); + }); + 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..561dc09137 --- /dev/null +++ b/nodejs/test/release-manifest.test.ts @@ -0,0 +1,112 @@ +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 { + createPackageSetManifest, + createReleaseManifest, + verifyPackageSetManifest, + 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 a direct nine-package release", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-package-set-manifest-")); + roots.push(root); + const version = "1.0.13-unstable.34640000001.gabcdef0"; + for (const name of [ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), + ]) { + await packageTarball(root, name, version); + } + + const manifest = await createPackageSetManifest(root, version); + expect(manifest).toMatchObject({ + schemaVersion: 1, + sdk: { version }, + packages: expect.arrayContaining([ + expect.objectContaining({ name: "@github/copilot-sdk" }), + ]), + }); + expect(manifest.packages).toHaveLength(9); + expect(() => verifyPackageSetManifest(manifest, root)).not.toThrow(); + + const damaged = join(root, manifest.packages[0].filename); + writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); + expect(() => verifyPackageSetManifest(manifest, root)).toThrow("Size mismatch"); + }); + + it("freezes and verifies the exact nine-package release identity", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-manifest-")); + roots.push(root); + const version = "1.0.13-unstable.34640000001.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, + runtimeVersion: "1.0.83-5.unstable.123.g1234567+build.42", + sdkRef: "feature/unstable", + sdkSha, + sdkVersion: version, + workflowRunId: "812300", + workflowRunNumber: "8123", + }); + + expect(manifest.packages).toHaveLength(9); + expect(manifest.runtime.runId).toBe("9001"); + expect(manifest.runtime.source).toBe("github-packages"); + expect(() => verifyReleaseManifest(manifest, root)).not.toThrow(); + + const mismatched = structuredClone(manifest); + mismatched.runtime.version = "1.0.83-5.canary.123.g1234567.unsigned"; + expect(() => verifyReleaseManifest(mismatched, root)).toThrow( + "does not belong to the 'unstable' channel" + ); + await expect( + createReleaseManifest(root, { + channel: "canary", + createdAt: "2026-09-04T00:00:00Z", + runtimeRunId: "9001", + runtimeSha, + runtimeVersion: "1.0.83-5.unstable.123.g1234567", + sdkRef: "feature/unstable", + sdkSha, + sdkVersion: version, + workflowRunId: "812300", + workflowRunNumber: "8123", + }) + ).rejects.toThrow("does not belong to the 'canary' channel"); + + const damaged = join(root, manifest.packages[0].filename); + writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); + expect(() => verifyReleaseManifest(manifest, root)).toThrow("Size mismatch"); + }); +}); diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts new file mode 100644 index 0000000000..c1476f7175 --- /dev/null +++ b/nodejs/test/release-workflows.test.ts @@ -0,0 +1,189 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = join(import.meta.dirname, "..", ".."); +const workflowPath = join(repositoryRoot, ".github", "workflows", "publish.yml"); +const publish = readFileSync(workflowPath, "utf8"); +const jobStart = (name: string) => publish.indexOf(`\n ${name}:`) + 1; +const job = (name: string, next?: string) => + publish.slice(jobStart(name), next ? jobStart(next) : publish.length); + +const inputSection = publish.slice( + publish.indexOf(" inputs:"), + publish.indexOf("\npermissions:", publish.indexOf(" inputs:")) +); +const validateDispatchJob = job("validate-dispatch", "version"); +const directVersionJob = job("version", "package-nodejs"); +const directPackageJob = job("package-nodejs", "publish-nodejs"); +const directPublicJob = job("publish-nodejs", "publish-nodejs-internal"); +const directInternalJob = job("publish-nodejs-internal", "publish-dotnet"); +const dotnetJob = job("publish-dotnet", "publish-rust"); +const rustJob = job("publish-rust", "publish-python"); +const pythonJob = job("publish-python", "publish-java"); +const javaJob = job("publish-java", "github-release"); +const githubReleaseJob = job("github-release", "runtime-plan"); +const runtimePlanJob = job("runtime-plan", "runtime-acquire"); +const runtimeAcquireJob = job("runtime-acquire", "runtime-test"); +const runtimeTestJob = job("runtime-test", "runtime-package"); +const runtimePackageJob = job("runtime-package", "runtime-publish-internal"); +const runtimeInternalJob = job("runtime-publish-internal", "runtime-publish-public"); +const runtimePublicJob = job("runtime-publish-public"); +const unstableVersion = readFileSync( + join(repositoryRoot, "nodejs", "scripts", "unstable-version.ts"), + "utf8" +); + +describe("unified publishing workflow contract", () => { + it("exposes only the approved four inputs", () => { + expect([...inputSection.matchAll(/^ ([\w-]+):$/gm)].map((match) => match[1])).toEqual([ + "dist-tag", + "version", + "mode", + "runtime", + ]); + for (const distTag of ["latest", "prerelease", "unstable", "canary"]) { + expect(inputSection).toContain(`- ${distTag}`); + } + expect(inputSection).toContain('default: "prerelease"'); + expect(inputSection).toContain("default: publish"); + expect(inputSection).toContain("- dry-run"); + expect(existsSync(join(repositoryRoot, ".github", "workflows", "runtime-sdk.yml"))).toBe( + false + ); + }); + + it("parses runtime JSON once and routes only validated outputs", () => { + expect(validateDispatchJob).toContain("npx tsx scripts/runtime-release-identity.ts"); + expect(validateDispatchJob).toContain("RUNTIME_JSON: ${{ inputs.runtime }}"); + expect(validateDispatchJob).toContain( + "runtime_version: ${{ steps.validate.outputs.runtime_version }}" + ); + expect(validateDispatchJob).toContain( + "runtime_sha: ${{ steps.validate.outputs.runtime_sha }}" + ); + expect(validateDispatchJob).toContain( + "runtime_run_id: ${{ steps.validate.outputs.runtime_run_id }}" + ); + expect(publish).not.toContain("fromJSON("); + expect(publish).not.toContain("inputs.runtime_version"); + expect(publish).not.toContain("inputs.runtime_sha"); + expect(publish).not.toContain("inputs.runtime_run_id"); + expect(publish).not.toContain("inputs.channel"); + expect(directVersionJob).toContain("if: needs.validate-dispatch.outputs.kind == 'direct'"); + expect(runtimePlanJob).toContain("if: needs.validate-dispatch.outputs.kind == 'runtime'"); + }); + + it("keeps dry-runs out of publication locks and mutation jobs", () => { + expect(publish).toContain( + "inputs.mode == 'dry-run' && format('publish-dry-run-{0}', github.run_id)" + ); + expect(directPublicJob).toContain("if: inputs.mode == 'publish'"); + expect(runtimeInternalJob).toContain("inputs.mode == 'publish'"); + expect(runtimePublicJob).toContain("inputs.mode == 'publish'"); + expect(directPackageJob).not.toContain("inputs.mode == 'publish'"); + expect(runtimePackageJob).not.toContain("inputs.mode == 'publish'"); + }); +}); + +describe("direct publishing path", () => { + it("keeps normal version calculation and deterministic direct unstable planning", () => { + expect(directVersionJob).toContain( + "fetch-depth: ${{ inputs.dist-tag == 'unstable' && '0' || '1' }}" + ); + expect(directVersionJob).toContain("if: inputs.dist-tag == 'unstable'"); + expect(directVersionJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(directVersionJob).toContain("SDK_VERSION_OVERRIDE: ${{ inputs.version }}"); + expect(directVersionJob).toContain("scripts/unstable-version.ts"); + expect(directVersionJob).toContain("if: inputs.dist-tag != 'unstable'"); + expect(directVersionJob).toContain( + 'VERSION="$(node scripts/get-version.js ${{ github.event.inputs.dist-tag }})"' + ); + }); + + it("keeps direct unstable Node-only and public-before-internal", () => { + expect(directPublicJob).toContain("inputs.dist-tag == 'unstable'"); + expect(directPackageJob).toContain("create-package-set package-set-manifest.json"); + expect(directPublicJob).toContain("publish-manifest"); + expect(directPublicJob).toContain("https://registry.npmjs.org public"); + expect(directInternalJob).toContain("needs: publish-nodejs"); + expect(directInternalJob).toContain("publish-manifest"); + expect(directInternalJob).toContain('"$FEED_URL" azure'); + for (const nonNodeJob of [dotnetJob, rustJob, pythonJob, javaJob, githubReleaseJob]) { + expect(nonNodeJob).toContain("inputs.dist-tag != 'unstable'"); + } + }); + + it("retains all stable and prerelease publishers", () => { + for (const jobName of [ + "publish-nodejs:", + "publish-dotnet:", + "publish-rust:", + "publish-python:", + "publish-java:", + "github-release:", + ]) { + expect(publish).toContain(jobName); + } + }); +}); + +describe("runtime-backed publishing path", () => { + it("keeps canary run numbers and unstable repository-wide run IDs", () => { + expect(runtimePlanJob).toContain("SDK_CHANNEL: ${{ inputs.dist-tag }}"); + expect(runtimePlanJob).toContain("WORKFLOW_RUN_ID: ${{ github.run_id }}"); + expect(runtimePlanJob).toContain("WORKFLOW_RUN_NUMBER: ${{ github.run_number }}"); + expect(unstableVersion).toContain('runId: requireEnvironment("WORKFLOW_RUN_ID")'); + expect(unstableVersion).toContain('runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER")'); + }); + + it("acquires validated runtime outputs and tests all runner platforms", () => { + expect(runtimeAcquireJob).toContain("packages: read"); + expect(runtimeAcquireJob).toContain( + "RUNTIME_SHA: ${{ needs.validate-dispatch.outputs.runtime_sha }}" + ); + expect(runtimeAcquireJob).toContain( + "RUNTIME_VERSION: ${{ needs.validate-dispatch.outputs.runtime_version }}" + ); + expect(runtimeAcquireJob).toContain("npm run acquire:runtime-packages"); + expect(runtimeAcquireJob).not.toContain("azure/login"); + expect(runtimeTestJob).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(runtimeTestJob).toContain("npm test"); + }); + + it("preserves executable modes across runtime package artifact boundaries", () => { + expect(runtimeAcquireJob).toContain( + 'tar -czf "$RUNNER_TEMP/runtime-packages.tar.gz" -C "$RUNNER_TEMP" runtime-packages' + ); + for (const consumer of [runtimeTestJob, runtimePackageJob]) { + expect(consumer).toContain("if command -v cygpath >/dev/null 2>&1; then"); + expect(consumer).toContain('runner_temp="$(cygpath -u "$runner_temp")"'); + expect(consumer).toContain( + 'tar -xzf "$runner_temp/runtime-package-artifact/runtime-packages.tar.gz" -C "$runner_temp"' + ); + } + }); + + it("builds one retained package set and preserves runtime publication order", () => { + expect(runtimePackageJob).toContain("npm run verify:release-packages"); + expect(runtimePackageJob).toContain("release-manifest.json"); + expect(runtimePackageJob).toContain( + "RUNTIME_RUN_ID: ${{ needs.validate-dispatch.outputs.runtime_run_id }}" + ); + expect(runtimeInternalJob).toContain("publish-manifest"); + expect(runtimeInternalJob).toContain('"$FEED_URL" azure'); + expect(runtimeInternalJob).toContain("Clean install and package version check"); + expect(runtimePublicJob).toContain("inputs.dist-tag == 'unstable'"); + expect(runtimePublicJob).toContain("needs: [runtime-plan, runtime-publish-internal]"); + expect(runtimePublicJob).toContain("https://registry.npmjs.org public"); + }); + + it("executes npm publication directly with shared channel locks", () => { + expect(directInternalJob).toContain("group: sdk-runtime-internal-${{ inputs.dist-tag }}"); + expect(runtimeInternalJob).toContain("group: sdk-runtime-internal-${{ inputs.dist-tag }}"); + expect(runtimePublicJob).toContain("group: sdk-runtime-public-unstable"); + expect(runtimePublicJob).toContain("id-token: write"); + expect(runtimePublicJob).toContain("npm install --global npm@11.6.3"); + expect(publish).not.toContain("uses: ./.github/workflows/runtime-sdk.yml"); + }); +}); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts new file mode 100644 index 0000000000..c439791457 --- /dev/null +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -0,0 +1,185 @@ +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, + parseArguments, + 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("requires exactly one non-empty value for every CLI option", () => { + const valid = [ + "--version", + runtimeVersion, + "--sha", + runtimeSha, + "--output", + "runtime-packages", + ]; + expect(parseArguments(valid)).toEqual({ + outputDirectory: "runtime-packages", + 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"); + 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, + 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("requires strict GitHub Packages registry integrity", async () => { + const root = temporaryRoot("copilot-runtime-registry-"); + const runner = vi + .fn() + .mockResolvedValue({ status: 0, stdout: JSON.stringify("sha1-invalid"), stderr: "" }); + await expect( + acquireRuntimePackages( + { + outputDirectory: join(root, "output"), + runtimeSha, + runtimeVersion, + }, + runner + ) + ).rejects.toThrow("Invalid registry integrity"); + expect(runner.mock.calls[0][1]).toContain("https://npm.pkg.github.com"); + }); + + it("rejects mismatched source identity metadata", async () => { + 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/runtime-release-identity.test.ts b/nodejs/test/runtime-release-identity.test.ts new file mode 100644 index 0000000000..e8467663ed --- /dev/null +++ b/nodejs/test/runtime-release-identity.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + type ReleaseDispatchInputs, + validateReleaseDispatch, + validateRuntimeVersionChannel, +} from "../scripts/runtime-release-identity.js"; + +const runtime = { + run_id: "34640000001", + sha: "abcdef0123456789abcdef0123456789abcdef01", + version: "1.0.83-5.unstable.123.gabcdef0", +}; +const inputs: ReleaseDispatchInputs = { + distTag: "unstable", + mode: "publish", + runtimeJson: "", + version: "", +}; + +describe("runtime version identity", () => { + it.each([ + ["canary", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.2.4-canary.8.gdef5678.unsigned"], + ["canary", "9.9.9-canary.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0"], + ["unstable", "9.9.9-unstable.test"], + ["unstable", "1.0.83-5.unstable.123.gabcdef0+build.42"], + ] as const)("accepts a %s runtime version: %s", (channel, runtimeVersion) => { + expect(() => validateRuntimeVersionChannel(runtimeVersion, channel)).not.toThrow(); + }); + + it.each([ + ["unstable", "1.2.4-canary.7.gdef5678.signed"], + ["canary", "1.0.83-5.unstable.123.gabcdef0"], + ["canary", "1.2.4-canaryish.7.gdef5678"], + ] as const)("rejects a runtime version outside %s: %s", (channel, runtimeVersion) => { + expect(() => validateRuntimeVersionChannel(runtimeVersion, channel)).toThrow( + `does not belong to the '${channel}' channel` + ); + }); + + it("rejects non-canonical runtime versions", () => { + expect(() => validateRuntimeVersionChannel(" 1.2.3-unstable.4", "unstable")).toThrow(); + expect(() => validateRuntimeVersionChannel("1.2.3", "unstable")).toThrow(); + }); +}); + +describe("release dispatch", () => { + it.each(["latest", "prerelease", "unstable"] as const)( + "accepts direct %s publication", + (distTag) => { + expect(validateReleaseDispatch({ ...inputs, distTag })).toEqual({ + kind: "direct", + runtimeRunId: "", + runtimeSha: "", + runtimeVersion: "", + }); + } + ); + + it("accepts direct unstable dry-run", () => { + expect( + validateReleaseDispatch({ ...inputs, distTag: "unstable", mode: "dry-run" }).kind + ).toBe("direct"); + }); + + it.each([ + ["canary", "dry-run", "1.0.83-5.canary.123.gabcdef0"], + ["canary", "publish", "1.0.83-5.canary.123.gabcdef0"], + ["unstable", "dry-run", runtime.version], + ["unstable", "publish", runtime.version], + ] as const)("accepts runtime-backed %s %s", (distTag, mode, version) => { + expect( + validateReleaseDispatch({ + ...inputs, + distTag, + mode, + runtimeJson: JSON.stringify({ ...runtime, version }), + }) + ).toEqual({ + kind: "runtime", + runtimeRunId: runtime.run_id, + runtimeSha: runtime.sha, + runtimeVersion: version, + }); + }); + + it("rejects canary without runtime JSON", () => { + expect(() => validateReleaseDispatch({ ...inputs, distTag: "canary" })).toThrow( + "Canary releases require runtime JSON" + ); + }); + + it.each(["latest", "prerelease"] as const)("rejects %s dry-run", (distTag) => { + expect(() => validateReleaseDispatch({ ...inputs, distTag, mode: "dry-run" })).toThrow( + "Dry-run mode is supported only for canary and unstable releases" + ); + }); + + it.each(["latest", "prerelease"] as const)("rejects runtime JSON for %s", (distTag) => { + expect(() => + validateReleaseDispatch({ + ...inputs, + distTag, + runtimeJson: JSON.stringify(runtime), + }) + ).toThrow("Runtime JSON is supported only for canary and unstable releases"); + }); + + it("rejects a direct version with runtime JSON", () => { + expect(() => + validateReleaseDispatch({ + ...inputs, + runtimeJson: JSON.stringify(runtime), + version: "2.0.0-unstable.manual", + }) + ).toThrow("direct version input"); + }); + + it("rejects unknown dist-tags and modes", () => { + expect(() => + validateReleaseDispatch({ ...inputs, distTag: "preview" as "unstable" }) + ).toThrow("Invalid release dist-tag"); + expect(() => validateReleaseDispatch({ ...inputs, mode: "test" as "publish" })).toThrow( + "Invalid release mode" + ); + }); + + it.each([ + ["malformed JSON", "{"], + ["whitespace-only input", " "], + ["surrounding whitespace", ` ${JSON.stringify(runtime)}`], + ["null", "null"], + ["array", "[]"], + ["missing key", JSON.stringify({ version: runtime.version, sha: runtime.sha })], + ["extra key", JSON.stringify({ ...runtime, source: "github-packages" })], + ["non-string field", JSON.stringify({ ...runtime, run_id: 123 })], + ["zero run ID", JSON.stringify({ ...runtime, run_id: "0" })], + ["non-canonical run ID", JSON.stringify({ ...runtime, run_id: "0123" })], + ["uppercase SHA", JSON.stringify({ ...runtime, sha: runtime.sha.toUpperCase() })], + ["wrong channel", JSON.stringify({ ...runtime, version: "1.0.83-5.canary.1" })], + ])("rejects %s", (_name, runtimeJson) => { + expect(() => validateReleaseDispatch({ ...inputs, runtimeJson })).toThrow(); + }); +}); diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts index ffd22f21ef..26a9d1be7b 100644 --- a/nodejs/test/runtimeArtifacts.test.ts +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -16,6 +16,7 @@ import { } from "../src/runtimeArtifacts.js"; import { COPILOT_CLI_USE_NPM_PACKAGE, COPILOT_CLI_VERSION } from "../src/cliVersion.js"; import { ensureCopilotPackage } from "../scripts/releaseArtifacts.js"; +import { getLegacyCliPathForTests } from "./e2e/harness/sdkTestContext.js"; describe("defaultRuntimeCacheRoot", () => { it.each([ @@ -76,12 +77,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 +264,104 @@ describe("ensureRuntimeBundle", () => { }); describe("release package acquisition", () => { + it("uses an ambient acquired package only for its exact runtime version", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-runtime-environment-")); + const platform = "linux-x64"; + const packageRoot = join(root, platform); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(packageRoot, "app.js"), "legacy CLI"); + writeFileSync(join(prebuilds, "runtime.node"), "runtime"); + const fetcher = vi.fn(() => { + throw new Error("matching ambient runtime resolution must not fetch"); + }); + const environment = { + ...process.env, + COPILOT_SDK_RUNTIME_PACKAGE_DIR: root, + }; + + await expect( + ensureCopilotPackage(COPILOT_CLI_VERSION, { + environment, + fetch: fetcher, + platform, + }) + ).resolves.toBe(packageRoot); + expect(readFileSync(join(packageRoot, "app.js"), "utf8")).toBe("legacy CLI"); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("resolves the legacy E2E CLI from the matching acquired package", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-legacy-runtime-")); + const platform = getRuntimePlatform(); + const packageRoot = join(root, platform); + mkdirSync(join(packageRoot, "prebuilds", platform), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(packageRoot, "app.js"), "legacy CLI"); + writeFileSync(join(packageRoot, "prebuilds", platform, "runtime.node"), "runtime"); + vi.stubEnv("COPILOT_SDK_RUNTIME_PACKAGE_DIR", root); + + try { + await expect(getLegacyCliPathForTests()).resolves.toBe(join(packageRoot, "app.js")); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("ignores an ambient acquired package for a different requested version", async () => { + const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-runtime-version-guard-")); + const acquiredRoot = join(sourceRoot, "acquired"); + const platform = "linux-x64"; + const acquiredPackageRoot = join(acquiredRoot, platform); + const acquiredPrebuilds = join(acquiredPackageRoot, "prebuilds", platform); + mkdirSync(acquiredPrebuilds, { recursive: true }); + writeFileSync( + join(acquiredPackageRoot, "package.json"), + JSON.stringify({ version: COPILOT_CLI_VERSION }) + ); + writeFileSync(join(acquiredPrebuilds, "runtime.node"), "acquired runtime"); + + const packageRoot = join(sourceRoot, "package"); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(join(prebuilds, "runtime.node"), "controlled runtime"); + mkdirSync(join(packageRoot, "schemas"), { recursive: true }); + writeFileSync(join(packageRoot, "schemas", "api.schema.json"), "{}"); + + const archivePath = join(sourceRoot, "runtime.tgz"); + await createTar({ cwd: sourceRoot, file: archivePath, gzip: true }, ["package"]); + const archive = readFileSync(archivePath); + const version = "1.2.3-controlled.1"; + const assetName = getRuntimeReleaseAssetName(version, platform); + const checksum = createHash("sha256").update(archive).digest("hex"); + const fetcher = vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/SHA256SUMS.txt") + ? new Response(`${checksum} ${assetName}\n`) + : new Response(archive) + ); + + const resolved = await ensureCopilotPackage(version, { + cacheRoot: join(sourceRoot, "cache"), + environment: { + ...process.env, + COPILOT_SDK_RUNTIME_PACKAGE_DIR: acquiredRoot, + }, + fetch: fetcher, + platform, + }); + + expect(resolved).not.toBe(acquiredPackageRoot); + expect(readFileSync(join(resolved, "schemas", "api.schema.json"), "utf8")).toBe("{}"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it("downloads, verifies, and caches a release package for packaging", async () => { const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-release-source-")); const packageRoot = join(sourceRoot, "package"); diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts new file mode 100644 index 0000000000..b743bbeece --- /dev/null +++ b/nodejs/test/unstable-version.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + calculateCanaryVersion, + calculateUnstableVersion, + targetCoreFromBaseline, +} from "../scripts/unstable-version.js"; + +const sha = "abcdef0123456789abcdef0123456789abcdef01"; +const otherSha = "123456789abcdef0123456789abcdef012345678"; +const runId = "34640000001"; +const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ + tag_name, + published_at, +}); + +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"), + ], + runId, + sdkSha: sha, + }) + ).toBe("1.0.13-unstable.34640000001.gabcdef0"); + }); + + it("freezes eligible release history at workflow creation time", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.12", "v1.0.11"], + releases: [ + release("v1.0.12", "2026-09-05T00:00:00Z"), + release("v1.0.11", "2026-09-01T00:00:00Z"), + ], + runId, + sdkSha: sha, + }; + const planned = calculateUnstableVersion(options); + expect(planned).toBe("1.0.12-unstable.34640000001.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + releases: [...options.releases, release("v1.0.13", "2026-09-06T00:00:00Z")], + }) + ).toBe(planned); + }); + + it("is stable across retries and unique across repository-wide workflow run IDs", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.11"], + releases: [release("v1.0.11")], + runId, + sdkSha: sha, + }; + expect(calculateUnstableVersion(options)).toBe(calculateUnstableVersion(options)); + expect(calculateUnstableVersion({ ...options, runId: "34640000002" })).not.toBe( + calculateUnstableVersion(options) + ); + expect(calculateUnstableVersion({ ...options, sdkSha: otherSha })).not.toBe( + calculateUnstableVersion(options) + ); + }); + + it("appends workflow identity to explicit unstable SemVer bases", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: [], + releases: [], + runId, + sdkSha: sha, + }; + expect( + calculateUnstableVersion({ + ...options, + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1.34640000001.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + runId: "34640000002", + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1.34640000002.gabcdef0"); + expect( + calculateUnstableVersion({ + ...options, + sdkSha: otherSha, + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1.34640000001.g1234567"); + expect(() => + calculateUnstableVersion({ ...options, versionOverride: "2.0.0-preview.1" }) + ).toThrow("unstable prerelease"); + }); + + it("cannot collide across workflows with coincident per-workflow run numbers", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.11"], + releases: [release("v1.0.11")], + sdkSha: sha, + }; + const direct = calculateUnstableVersion({ ...options, runId }); + const runtimeDriven = calculateUnstableVersion({ ...options, runId: "34640000002" }); + expect(direct).toBe("1.0.12-unstable.34640000001.gabcdef0"); + expect(runtimeDriven).toBe("1.0.12-unstable.34640000002.gabcdef0"); + expect(direct).not.toBe(runtimeDriven); + }); +}); + +describe("canary SDK version planning", () => { + it("is stable across retries and unique across new workflow runs", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + releases: [release("v1.0.11")], + runNumber: "8123", + sdkSha: sha, + }; + expect(calculateCanaryVersion(options)).toBe(calculateCanaryVersion(options)); + expect(calculateCanaryVersion({ ...options, runNumber: "8124" })).not.toBe( + calculateCanaryVersion(options) + ); + expect(calculateCanaryVersion({ ...options, sdkSha: otherSha })).not.toBe( + calculateCanaryVersion(options) + ); + }); + + it("freezes the stable baseline at workflow creation time", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + 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); + }); +});