diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5e1d277259..206971a552 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,6 @@ on: options: - latest - prerelease - - unstable version: description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." type: string @@ -40,6 +39,15 @@ jobs: run: working-directory: ./nodejs steps: + - name: Validate release channel + working-directory: . + env: + DIST_TAG: ${{ inputs.dist-tag }} + run: | + case "$DIST_TAG" in + latest|prerelease) ;; + *) echo "::error::publish.yml only accepts latest or prerelease."; exit 1 ;; + esac - uses: actions/checkout@v6.0.2 - uses: actions/setup-node@v6 with: @@ -66,9 +74,22 @@ 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 + PRERELEASE_NAMESPACE="$(node -e ' + const semver = require("semver"); + const parsed = semver.parse(process.argv[1]); + if (!parsed) process.exit(2); + process.stdout.write(String(parsed.prerelease[0] ?? "")); + ' "$VERSION")" || + { echo "::error::Version '$VERSION' is not valid SemVer."; exit 1; } + case "$PRERELEASE_NAMESPACE" in + canary|unstable) + echo "::error::The '$PRERELEASE_NAMESPACE' prerelease namespace is reserved for runtime-driven SDK releases." + exit 1 + ;; + esac fi echo "Using manual version override: $VERSION" >> $GITHUB_STEP_SUMMARY else @@ -124,8 +145,8 @@ jobs: publish-nodejs: name: Publish Node.js SDK - needs: package-nodejs - if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' + needs: [version, package-nodejs] + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: actions: read @@ -146,6 +167,7 @@ jobs: - name: Publish tarball to public npm env: DIST_TAG: ${{ github.event.inputs.dist-tag }} + VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail shopt -s nullglob @@ -161,25 +183,33 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ + "$PACKAGE_NAME" \ + "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public + public \ + "$INTEGRITY" done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ + @github/copilot-sdk \ + "$VERSION" \ "$DIST_TAG" \ https://registry.npmjs.org \ - public + public \ + "$INTEGRITY" publish-nodejs-internal: name: Publish Node.js SDK to internal feed - needs: publish-nodejs + needs: [version, publish-nodejs] environment: cicd runs-on: ubuntu-latest permissions: @@ -218,6 +248,7 @@ jobs: - name: Publish tarball to internal feed env: DIST_TAG: ${{ github.event.inputs.dist-tag }} + VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail if [ "$FEED_URL" != "https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/" ]; then @@ -237,25 +268,32 @@ jobs: MAIN_TARBALL="$TARBALL" continue fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$TARBALL" \ + "$PACKAGE_NAME" \ + "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure + azure \ + "$INTEGRITY" done if [ -z "$MAIN_TARBALL" ]; then echo "::error::Main @github/copilot-sdk tarball not found." exit 1 fi + INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$MAIN_TARBALL" | openssl base64 -A)" node nodejs/scripts/npm-release.js publish \ "$MAIN_TARBALL" \ + @github/copilot-sdk \ + "$VERSION" \ "$DIST_TAG" \ "$FEED_URL" \ - azure + azure \ + "$INTEGRITY" publish-dotnet: name: Publish .NET SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -298,7 +336,6 @@ jobs: publish-rust: name: Publish Rust SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -342,7 +379,6 @@ jobs: publish-python: name: Publish Python SDK - if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest permissions: @@ -380,7 +416,7 @@ jobs: publish-java: name: Publish Java SDK - if: github.event.inputs.dist-tag != 'unstable' && github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' needs: version permissions: contents: write @@ -405,7 +441,6 @@ jobs: if: | always() && github.ref == 'refs/heads/main' && - github.event.inputs.dist-tag != 'unstable' && needs.version.result == 'success' && needs.publish-nodejs.result == 'success' && needs.publish-dotnet.result == 'success' && diff --git a/.github/workflows/runtime-backed-node-release.yml b/.github/workflows/runtime-backed-node-release.yml new file mode 100644 index 0000000000..c6749406a5 --- /dev/null +++ b/.github/workflows/runtime-backed-node-release.yml @@ -0,0 +1,373 @@ +name: Runtime-backed Node SDK release + +on: + workflow_call: + inputs: + artifact_name: + required: false + type: string + default: "" + channel: + required: true + type: string + mode: + required: true + type: string + runtime_run_id: + required: true + type: string + runtime_sha: + required: true + type: string + runtime_source: + required: true + type: string + runtime_version: + required: true + type: string + sdk_ref: + required: true + type: string + sdk_sha: + required: true + type: string + sdk_version: + required: false + type: string + default: "" + outputs: + artifact_name: + value: ${{ jobs.boundary.outputs.artifact_name }} + sdk_version: + value: ${{ jobs.boundary.outputs.sdk_version }} + secrets: + COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY: + required: true + +env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + HUSKY: 0 + +jobs: + boundary: + name: Validate shared release boundary + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + artifact_name: ${{ steps.validate.outputs.artifact_name }} + sdk_version: ${{ steps.validate.outputs.sdk_version }} + workflow_created_at: ${{ steps.validate.outputs.workflow_created_at }} + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Enforce channel, source, mode, and identity + id: validate + env: + ARTIFACT_NAME: ${{ inputs.artifact_name }} + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + MODE: ${{ inputs.mode }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ inputs.sdk_ref }} + SDK_SHA: ${{ inputs.sdk_sha }} + SDK_VERSION: ${{ inputs.sdk_version }} + run: | + set -euo pipefail + case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in + canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; + *) echo "::error::Invalid runtime-backed release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; + esac + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + [[ "$SDK_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::sdk_sha must be a lowercase full SHA."; exit 1; } + [ -n "$SDK_REF" ] || { echo "::error::sdk_ref is required."; exit 1; } + if [ "$CHANNEL" = "canary" ]; then + PUBLIC_LATEST="$(node scripts/get-version.js current)" + BASE="$(node -e ' + const semver = require("semver"); + const parsed = semver.parse(process.argv[1]); + if (!parsed) process.exit(1); + process.stdout.write(`${parsed.major}.${parsed.minor}.${parsed.patch}`); + ' "$PUBLIC_LATEST")" || + { echo "::error::Current public SDK version is not valid SemVer: $PUBLIC_LATEST"; exit 1; } + IFS=. read -r MAJOR MINOR PATCH <<< "$BASE" + SDK_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-canary.${GITHUB_RUN_NUMBER}.g${SDK_SHA:0:7}" + else + [ -n "$SDK_VERSION" ] || { echo "::error::Unstable sdk_version is required."; exit 1; } + [[ "$SDK_VERSION" =~ -unstable\. ]] || + { echo "::error::Unstable sdk_version must use the unstable prerelease identifier."; exit 1; } + fi + npm exec -- semver "$SDK_VERSION" >/dev/null + EXPECTED_ARTIFACT="nodejs-${CHANNEL}-${SDK_VERSION}" + if [ -n "$ARTIFACT_NAME" ] && [ "$ARTIFACT_NAME" != "$EXPECTED_ARTIFACT" ]; then + echo "::error::artifact_name must be $EXPECTED_ARTIFACT." + exit 1 + fi + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + { + echo "artifact_name=$EXPECTED_ARTIFACT" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + + acquire-runtime: + name: Acquire exact runtime packages + needs: boundary + runs-on: ubuntu-latest + environment: cicd + permissions: + contents: read + id-token: write + packages: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Azure login + if: inputs.runtime_source == 'azure' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + if: inputs.runtime_source == 'azure' + run: | + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + - name: Configure authentication-only GitHub Packages access + if: inputs.runtime_source == 'github-packages' + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" > "$HOME/.npmrc" + - name: Download and validate all runtime platforms + env: + REGISTRY: ${{ inputs.runtime_source == 'azure' && env.FEED_URL || 'https://npm.pkg.github.com' }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + npm run acquire:runtime-packages -- \ + --version "$RUNTIME_VERSION" \ + --sha "$RUNTIME_SHA" \ + --registry "$REGISTRY" \ + --output "$RUNNER_TEMP/runtime-packages" + - uses: actions/upload-artifact@v7.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + if-no-files-found: error + retention-days: 7 + + test: + name: Runtime-backed Node tests (${{ matrix.os }}) + needs: [boundary, acquire-runtime] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + environment: cicd + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - name: Install test harness dependencies + working-directory: ./test/harness + run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Select the acquired runtime + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + runtime_path="$(npm run --silent prepare:runtime -- --print-path)" + echo "COPILOT_CLI_PATH=$runtime_path" >> "$GITHUB_ENV" + - run: npm run build + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node SDK tests + env: + COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + run: npm test + + package: + name: Build and verify nine SDK packages + needs: [boundary, acquire-runtime, test] + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: ./nodejs + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@v8.0.0 + with: + name: runtime-${{ inputs.channel }}-${{ inputs.runtime_version }}-${{ inputs.runtime_sha }} + path: ${{ runner.temp }}/runtime-packages + - name: Build and verify exact package set + env: + COPILOT_SDK_RUNTIME_PACKAGE_DIR: ${{ runner.temp }}/runtime-packages + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + run: | + VERSION="$SDK_VERSION" node scripts/set-version.js + node scripts/set-cli-version.js "$RUNTIME_VERSION" --local-package + grep -F "COPILOT_CLI_USE_NPM_PACKAGE = false" src/cliVersion.ts + npm run build + npm run pack:release + npm run verify:release-packages + - name: Create immutable release manifest + env: + RELEASE_CHANNEL: ${{ inputs.channel }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ inputs.sdk_ref }} + SDK_SHA: ${{ inputs.sdk_sha }} + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + WORKFLOW_CREATED_AT: ${{ needs.boundary.outputs.workflow_created_at }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + npm run release:manifest -- create release-manifest.json . + npm run release:manifest -- verify release-manifest.json . + - uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ needs.boundary.outputs.artifact_name }} + path: | + nodejs/release-manifest.json + nodejs/github-copilot-sdk-*.tgz + if-no-files-found: error + retention-days: 30 + + publish-internal: + name: Publish and verify SDK internally + if: | + always() && + !cancelled() && + inputs.mode == 'internal' && + needs.boundary.result == 'success' && + needs.package.result == 'success' + needs: [boundary, package] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-internal-${{ inputs.channel }} + cancel-in-progress: false + environment: cicd + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Download retained release + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.boundary.outputs.artifact_name }} + path: ./dist + - name: Validate retained release + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/release-manifest.ts verify dist/release-manifest.json dist + [ "$(jq -r .workflow.runId dist/release-manifest.json)" = "${{ github.run_id }}" ] || + { echo "::error::Retained release belongs to a different workflow run."; exit 1; } + [ "$(jq -r .channel dist/release-manifest.json)" = "${{ inputs.channel }}" ] || + { echo "::error::Retained release channel does not match the requested channel."; exit 1; } + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Configure authentication-only Azure npm access + run: | + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + - name: Publish exact tarballs internally + run: | + node nodejs/scripts/npm-release.js publish-manifest \ + dist/release-manifest.json dist "${{ inputs.channel }}" "$FEED_URL" azure + - name: Clean install and package version check + env: + SDK_VERSION: ${{ needs.boundary.outputs.sdk_version }} + run: | + VERIFY_ROOT="$RUNNER_TEMP/sdk-${{ inputs.channel }}-verification" + mkdir -p "$VERIFY_ROOT" + cd "$VERIFY_ROOT" + npm init -y >/dev/null + printf '%s\n' "@github:registry=${FEED_URL}" >> "$HOME/.npmrc" + npm install --ignore-scripts "@github/copilot-sdk@${SDK_VERSION}" + node -e ' + const expected = process.argv[1]; + const umbrella = require("./node_modules/@github/copilot-sdk/package.json"); + const platform = require("./node_modules/@github/copilot-sdk-linux-x64/package.json"); + if (umbrella.version !== expected || platform.version !== expected) { + throw new Error(`Installed SDK package version mismatch: ${umbrella.version}/${platform.version}, expected ${expected}`); + } + ' "$SDK_VERSION" diff --git a/.github/workflows/runtime-sdk.yml b/.github/workflows/runtime-sdk.yml new file mode 100644 index 0000000000..b0a8ac4fa5 --- /dev/null +++ b/.github/workflows/runtime-sdk.yml @@ -0,0 +1,395 @@ +name: Runtime-driven Node SDK +run-name: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} + +on: + workflow_dispatch: + inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - canary + - unstable + runtime_version: + description: "Exact runtime package version" + required: true + type: string + runtime_sha: + description: "Full github/copilot-agent-runtime source SHA" + required: true + type: string + runtime_source: + description: "Runtime package registry" + required: true + type: choice + options: + - azure + - github-packages + runtime_run_id: + description: "Source runtime workflow run ID and idempotency key" + required: true + type: string + mode: + description: "tests-only for canary verification; internal for publication" + required: true + type: choice + options: + - tests-only + - internal + default: internal + version: + description: "Unstable SDK version override for a direct manual run" + required: false + type: string + +permissions: + contents: read + +jobs: + claim-runtime-dispatch: + name: Claim runtime dispatch + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + cancel-in-progress: false + permissions: + actions: read + contents: read + outputs: + canonical_run_id: ${{ steps.existing.outputs.canonical_run_id || steps.created.outputs.canonical_run_id }} + role: ${{ steps.existing.outputs.role || steps.created.outputs.role }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Validate entry boundary + env: + CHANNEL: ${{ inputs.channel }} + MODE: ${{ inputs.mode }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + case "$CHANNEL:$RUNTIME_SOURCE:$MODE" in + canary:azure:tests-only|canary:azure:internal|unstable:github-packages:internal) ;; + *) echo "::error::Invalid runtime-driven release matrix: $CHANNEL/$RUNTIME_SOURCE/$MODE."; exit 1 ;; + esac + [[ "$RUNTIME_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || + { echo "::error::runtime_version must be exact SemVer."; exit 1; } + [[ "$RUNTIME_SHA" =~ ^[0-9a-f]{40}$ ]] || + { echo "::error::runtime_sha must be a lowercase full SHA."; exit 1; } + [[ "$RUNTIME_RUN_ID" =~ ^[0-9]+$ ]] || + { echo "::error::runtime_run_id must be numeric."; exit 1; } + if [ "$CHANNEL" = "canary" ] && [ -n "$VERSION" ]; then + echo "::error::Canary runs do not accept a version override." + exit 1 + fi + - name: Find the canonical dispatch marker + id: lookup + env: + GH_TOKEN: ${{ github.token }} + MARKER_NAME: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + RUN_TITLE: Runtime-driven SDK from runtime run ${{ inputs.runtime_run_id }} + run: | + set -euo pipefail + for ATTEMPT in 1 2 3 4 5 6; do + gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$MARKER_NAME&per_page=100" \ + > "$RUNNER_TEMP/artifacts.json" + MATCHES="$(jq --arg name "$MARKER_NAME" \ + '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ + "$RUNNER_TEMP/artifacts.json")" + if [ "$MATCHES" -gt 1 ]; then + echo "::error::More than one unexpired $MARKER_NAME artifact exists." + exit 1 + fi + if [ "$MATCHES" -eq 1 ]; then + jq --arg name "$MARKER_NAME" \ + '.artifacts[] | select(.name == $name and .expired == false)' \ + "$RUNNER_TEMP/artifacts.json" > "$RUNNER_TEMP/artifact.json" + { + echo "found=true" + echo "artifact_id=$(jq -r .id "$RUNNER_TEMP/artifact.json")" + echo "artifact_run_id=$(jq -r .workflow_run.id "$RUNNER_TEMP/artifact.json")" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + gh api "/repos/$GITHUB_REPOSITORY/actions/workflows/runtime-sdk.yml/runs?event=workflow_dispatch&per_page=100" \ + > "$RUNNER_TEMP/runs.json" + EARLIER="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ + '[.workflow_runs[] | select(.display_title == $title and .id < $current)] | length' \ + "$RUNNER_TEMP/runs.json")" + if [ "$EARLIER" -eq 0 ]; then + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$ATTEMPT" -lt 6 ]; then + echo "An earlier matching run is visible; waiting for its marker (attempt $ATTEMPT/6)." + sleep 10 + fi + done + + ACTIVE="$(jq --arg title "$RUN_TITLE" --argjson current "$GITHUB_RUN_ID" \ + '[.workflow_runs[] | select( + .display_title == $title and + .id < $current and + .status != "completed" + )] | length' "$RUNNER_TEMP/runs.json")" + if [ "$ACTIVE" -gt 0 ]; then + echo "::error::An earlier matching run is still initializing without a visible marker. Retry this run later." + exit 1 + fi + echo "Earlier matching runs completed before claiming; none could have started release work." + echo "found=false" >> "$GITHUB_OUTPUT" + - name: Download the existing marker + if: steps.lookup.outputs.found == 'true' + env: + ARTIFACT_ID: ${{ steps.lookup.outputs.artifact_id }} + ARTIFACT_RUN_ID: ${{ steps.lookup.outputs.artifact_run_id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/marker" + gh api "/repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" > "$RUNNER_TEMP/marker.zip" + unzip -q "$RUNNER_TEMP/marker.zip" -d "$RUNNER_TEMP/marker" + gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$ARTIFACT_RUN_ID" > "$RUNNER_TEMP/run.json" + - name: Validate the existing marker and API provenance + if: steps.lookup.outputs.found == 'true' + id: existing + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_RUN_ID: ${{ github.run_id }} + MODE: ${{ inputs.mode }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts validate \ + "$RUNNER_TEMP/marker/marker.json" "$RUNNER_TEMP/artifact.json" "$RUNNER_TEMP/run.json" + - name: Mirror the canonical run + if: steps.existing.outputs.role == 'duplicate' + env: + CANONICAL_RUN_ID: ${{ steps.existing.outputs.canonical_run_id }} + GH_TOKEN: ${{ github.token }} + run: | + set +e + gh run watch "$CANONICAL_RUN_ID" --exit-status + RESULT=$? + set -e + if [ "$RESULT" -ne 0 ]; then + echo "::error::Canonical SDK run $CANONICAL_RUN_ID failed or was canceled. Re-run that original run; this duplicate will not mint another SDK version." + exit "$RESULT" + fi + echo "Canonical SDK run $CANONICAL_RUN_ID succeeded; this duplicate is complete." + - name: Create the canonical marker + if: steps.lookup.outputs.found == 'false' + id: created + env: + CHANNEL: ${{ inputs.channel }} + CURRENT_RUN_ID: ${{ github.run_id }} + MODE: ${{ inputs.mode }} + RUNTIME_RUN_ID: ${{ inputs.runtime_run_id }} + RUNTIME_SHA: ${{ inputs.runtime_sha }} + RUNTIME_SOURCE: ${{ inputs.runtime_source }} + RUNTIME_VERSION: ${{ inputs.runtime_version }} + SDK_REF: ${{ github.ref }} + SDK_SHA: ${{ github.sha }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + mkdir -p "$RUNNER_TEMP/new-marker" + node nodejs/node_modules/.bin/tsx nodejs/scripts/runtime-dispatch-ledger.ts create \ + "$RUNNER_TEMP/new-marker/marker.json" + { + echo "role=owner" + echo "canonical_run_id=$GITHUB_RUN_ID" + } >> "$GITHUB_OUTPUT" + - name: Persist the canonical marker + if: steps.lookup.outputs.found == 'false' + uses: actions/upload-artifact@v7.0.0 + with: + name: sdk-runtime-dispatch-${{ inputs.runtime_run_id }} + path: ${{ runner.temp }}/new-marker/marker.json + retention-days: 90 + + plan: + name: Freeze runtime-backed release identity + if: needs.claim-runtime-dispatch.outputs.role == 'owner' + needs: claim-runtime-dispatch + runs-on: ubuntu-latest + environment: cicd + permissions: + actions: read + contents: read + id-token: write + outputs: + artifact_name: ${{ steps.plan.outputs.artifact_name }} + sdk_version: ${{ steps.plan.outputs.sdk_version }} + workflow_created_at: ${{ steps.plan.outputs.workflow_created_at }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: ./nodejs/package-lock.json + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Calculate the release identity + id: plan + working-directory: ./nodejs + env: + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + SDK_SHA: ${{ github.sha }} + SDK_VERSION_OVERRIDE: ${{ inputs.version }} + WORKFLOW_RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + WORKFLOW_CREATED_AT="$(gh api "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --jq .created_at)" + SDK_VERSION="" + ARTIFACT_NAME="" + if [ "$CHANNEL" = "unstable" ]; then + gh api --paginate "/repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -s 'add' > "$RUNNER_TEMP/sdk-releases.json" + export SDK_RELEASES_FILE="$RUNNER_TEMP/sdk-releases.json" + export WORKFLOW_CREATED_AT + SDK_VERSION="$(npx tsx scripts/unstable-version.ts)" + ARTIFACT_NAME="nodejs-unstable-$SDK_VERSION" + fi + { + echo "artifact_name=$ARTIFACT_NAME" + echo "sdk_version=$SDK_VERSION" + echo "workflow_created_at=$WORKFLOW_CREATED_AT" + } >> "$GITHUB_OUTPUT" + - name: Reject an explicit version already present publicly + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + working-directory: ./nodejs + env: + SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} + run: | + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" https://registry.npmjs.org + done + - name: Azure login for explicit-version preflight + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + allow-no-subscriptions: true + client-id: ${{ vars.CPD_ID_CLIENT_ID }} + tenant-id: ${{ vars.CPD_ID_TENANT_ID }} + - name: Reject an explicit version already present internally + if: needs.claim-runtime-dispatch.outputs.role == 'owner' && inputs.channel == 'unstable' && inputs.version != '' + working-directory: ./nodejs + env: + ADO_RESOURCE: 499b84ac-1321-427f-aa17-267ca6975798 + FEED_URL: https://pkgs.dev.azure.com/devdiv/_packaging/copilot-canary/npm/registry/ + SDK_VERSION: ${{ steps.plan.outputs.sdk_version }} + run: | + set -euo pipefail + TOKEN="$(az account get-access-token --resource "$ADO_RESOURCE" --query accessToken -o tsv)" + echo "::add-mask::$TOKEN" + FEED_AUTH_REGISTRY="${FEED_URL#https:}" + FEED_AUTH_BASE="${FEED_AUTH_REGISTRY%registry/}" + printf '%s\n' \ + "${FEED_AUTH_REGISTRY}:_authToken=${TOKEN}" \ + "${FEED_AUTH_BASE}:_authToken=${TOKEN}" > "$HOME/.npmrc" + for PACKAGE in \ + @github/copilot-sdk \ + @github/copilot-sdk-darwin-arm64 \ + @github/copilot-sdk-darwin-x64 \ + @github/copilot-sdk-linux-arm64 \ + @github/copilot-sdk-linux-x64 \ + @github/copilot-sdk-linuxmusl-arm64 \ + @github/copilot-sdk-linuxmusl-x64 \ + @github/copilot-sdk-win32-arm64 \ + @github/copilot-sdk-win32-x64; do + node scripts/npm-release.js preflight "$PACKAGE" "$SDK_VERSION" "$FEED_URL" + done + + runtime-backed-release: + name: Run runtime-backed SDK pipeline + if: needs.claim-runtime-dispatch.outputs.role == 'owner' + needs: [claim-runtime-dispatch, plan] + uses: ./.github/workflows/runtime-backed-node-release.yml + permissions: + actions: read + contents: read + id-token: write + packages: read + with: + artifact_name: ${{ needs.plan.outputs.artifact_name }} + channel: ${{ inputs.channel }} + mode: ${{ inputs.mode }} + runtime_run_id: ${{ inputs.runtime_run_id }} + runtime_sha: ${{ inputs.runtime_sha }} + runtime_source: ${{ inputs.runtime_source }} + runtime_version: ${{ inputs.runtime_version }} + sdk_ref: ${{ github.ref }} + sdk_sha: ${{ github.sha }} + sdk_version: ${{ needs.plan.outputs.sdk_version }} + secrets: inherit + + publish-public: + name: Publish unstable SDK publicly + if: inputs.channel == 'unstable' && needs.claim-runtime-dispatch.outputs.role == 'owner' + needs: [claim-runtime-dispatch, plan, runtime-backed-release] + runs-on: ubuntu-latest + concurrency: + group: sdk-runtime-public-unstable + cancel-in-progress: false + permissions: + actions: read + contents: read + id-token: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npm ci --ignore-scripts + working-directory: ./nodejs + - name: Update npm for trusted publishing + run: npm install --global npm@11.6.3 + - name: Download retained release + uses: actions/download-artifact@v8.0.0 + with: + name: ${{ needs.runtime-backed-release.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 7425f8e720..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@v6.0.2 - - - uses: actions/setup-node@v6 - 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@v6.0.2 - - - uses: actions/setup-node@v6 - 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..8f6904b609 100644 --- a/docs/developer-docs/secrets.md +++ b/docs/developer-docs/secrets.md @@ -10,7 +10,7 @@ This document covers secrets management for the github/copilot-sdk repository. I These secrets are used by the per-language SDK test workflows and the canary workflow. * **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments. - * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `sdk-canary.yml` + * Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `runtime-sdk.yml` ## Agentic workflow secrets @@ -61,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..f44ee9412e --- /dev/null +++ b/docs/developer-docs/unstable-releases.md @@ -0,0 +1,108 @@ +# Canary and unstable Node SDK releases + +The SDK release workflows consume exact runtime platform packages produced by +`github/copilot-agent-runtime`. Canary releases remain internal. Unstable +releases publish the same self-contained Node SDK tarballs internally and then +to public npm. + +## Runtime handoff + +The runtime workflow dispatches an SDK workflow at an explicit SDK ref. Each +handoff includes the exact runtime version, full source SHA, and source workflow +run ID. + +The runtime workflow dispatches `.github/workflows/runtime-sdk.yml`. This +runtime-driven Node entry is separate from `publish.yml`, which remains the +manual stable and prerelease entry for all SDK languages. `runtime-sdk.yml` +invokes `runtime-backed-node-release.yml` for runtime acquisition, +cross-platform tests, packaging, manifest retention, and optional internal +publication. It alone contains public unstable npm publication. + +The runtime dispatch includes these inputs: + +- `channel`: `canary` or `unstable` +- `runtime_version`: Exact runtime package version +- `runtime_sha`: Lowercase, 40-character `github/copilot-agent-runtime` SHA +- `runtime_source`: `azure` for canary or `github-packages` for unstable +- `runtime_run_id`: Source runtime workflow run ID and receiver idempotency key +- `mode`: `tests-only` or `internal` for canary; `internal` for unstable + +Maintainers can dispatch `runtime-sdk.yml` directly with the same inputs. The +optional `version` input is available only for unstable and must be an unstable +SemVer. Do not reuse an explicit version after an artifact has been built. + +## Release gates + +Both channels acquire all eight `@github/copilot-` packages with an +explicit registry argument. The workflows validate npm integrity, runtime +version and SHA metadata, platform metadata, repository metadata, and required +runtime files. Authentication configuration does not map the entire `@github` +scope to GitHub Packages. + +The workflows run runtime-backed Node SDK tests on Ubuntu, macOS, and Windows. +They then build and verify eight self-contained +`@github/copilot-sdk-` packages and the +`@github/copilot-sdk` umbrella package. The checked-in +`COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`; runtime npm packages are +build inputs rather than published dependencies. + +An unstable run freezes a version from the nearest eligible SDK release on the +selected branch's first-parent history, the workflow run number, and the SDK +SHA. The packaging job writes all nine tarballs and `release-manifest.json` to +one retained artifact. Publication jobs use that artifact without rebuilding +or recalculating its identity. + +## Publication order + +Canary `tests-only` runs stop after package verification. Canary `internal` +runs publish platform packages before the umbrella package to the Azure +`copilot-canary` feed, then perform a clean install and package version check. +No canary job has a public npm publication path. + +Every unstable run publishes the retained platform tarballs and umbrella +tarball to Azure first. A clean internal install must start the exact selected +SDK package version before public publication begins. The strict acquisition +and package validation gates verify the embedded runtime identity. The public +job uses npm trusted publishing from `runtime-sdk.yml` and publishes the same +tarballs under the `unstable` dist-tag, with the umbrella package last. + +Before either publication, the workflow checks all nine package coordinates. +An existing package counts as complete only when registry integrity matches +the retained manifest. A mismatch fails the release. After all package +contents are present, the workflow updates the channel dist-tag. +Azure authentication allows the workflow to add or advance its tag, but it +refuses to rewind a tag that points to a newer version. Public npm trusted +publishing sets `unstable` as each missing package is published. The workflow +then verifies all nine `@unstable` resolutions. It fails rather than attempting +a separate public dist-tag mutation if any resolution differs. + +## Recovery + +Use **Re-run failed jobs** on the original workflow run for normal recovery. +The run number, frozen version, and retained artifact remain unchanged. Do not +rerun a successful packaging job merely to recover a publication job. + +Each `runtime_run_id` is serialized and claimed by a 90-day marker artifact. +The marker records the canonical SDK run and complete runtime/input +provenance, but the runtime run ID is not part of the immutable release +identity. Exact duplicate dispatches wait for and mirror the canonical run. +If that run fails or is canceled, rerun the original run rather than +dispatching another release. + +## Registry setup + +The Azure `copilot-canary` feed continues to use the `cicd` environment and +Azure workload identity. GitHub Packages acquisition uses the workflow +`GITHUB_TOKEN` with `packages: read`. + +Before enabling unstable dispatch, publish the eight signed runtime package +coordinates once, set each GitHub Package to public visibility, and confirm +that this repository can read all eight with its workflow token. Public +visibility does not remove GitHub Packages npm authentication. + +Confirm npm trusted publisher configuration authorizes +both `.github/workflows/publish.yml` and `.github/workflows/runtime-sdk.yml` for +`@github/copilot-sdk` and all eight `@github/copilot-sdk-` package +names. The first identity publishes stable and prerelease versions; the second +publishes unstable versions. Do not add an npm token, workflow indirection, or +a separate protected SDK publication environment. diff --git a/nodejs/README.md b/nodejs/README.md index e3d76ba6e4..e72ec5c790 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -21,6 +21,10 @@ release's `SHA256SUMS.txt`. `npm run pack:release` builds the main package and all platform packages. Set `COPILOT_CLI_DOWNLOAD_BASE_URL` to use a release mirror while packaging. +Release workflows instead set `COPILOT_SDK_RUNTIME_PACKAGE_DIR` to a directory +containing validated runtime npm package roots named for all eight platforms. +This keeps `COPILOT_CLI_USE_NPM_PACKAGE` false and embeds those runtime files in +the self-contained SDK platform packages. ## Installation diff --git a/nodejs/package.json b/nodejs/package.json index 783c4d5390..ffeb0a3790 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -35,8 +35,10 @@ "scripts": { "clean": "rimraf --glob dist *.tgz", "build": "tsx esbuild-copilotsdk-nodejs.ts", + "acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts", "pack:release": "tsx scripts/package-sdk.ts", "verify:release-packages": "tsx scripts/verify-release-packages.ts", + "release:manifest": "tsx scripts/release-manifest.ts", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/nodejs/scripts/npm-release.js b/nodejs/scripts/npm-release.js index fe750bada0..a2d1e91104 100644 --- a/nodejs/scripts/npm-release.js +++ b/nodejs/scripts/npm-release.js @@ -1,13 +1,11 @@ +import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -const PUBLIC_CONFLICT = - /^(?:npm (?:error|ERR!) code EPUBLISHCONFLICT|npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:You )?cannot publish over (?:the )?previously published versions(?:: [^\r\n]+)?\.?)\r?$/im; -const AZURE_CONFLICT = - /^npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:The feed '[^'\r\n]+' )?already contains file '[^'\r\n]+\.tgz' in package '[^'\r\n]+'\.?\r?$/im; - export function runCommand(command, args, { stream = false } = {}) { - return new Promise((resolve, reject) => { + return new Promise((resolveResult, reject) => { const child = spawn(command, args, { shell: false }); let stdout = ""; let stderr = ""; @@ -21,65 +19,289 @@ export function runCommand(command, args, { stream = false } = {}) { if (stream) process.stderr.write(chunk); }); child.on("error", reject); - child.on("close", (status) => resolve({ status: status ?? 1, stdout, stderr })); + child.on("close", (status) => resolveResult({ status: status ?? 1, stdout, stderr })); }); } -export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { +function parseNpmJson(result) { + for (const output of [result.stdout, result.stderr]) { + try { + return JSON.parse(output); + } catch { + // The caller reports the complete npm output if neither stream is JSON. + } + } + return undefined; +} + +export async function getRegistryIntegrity(packageName, version, registry, runner = runCommand) { const result = await runner("npm", [ "view", `${packageName}@${version}`, - "version", + "dist.integrity", "--json", "--registry", registry, ]); - - if (result.status === 0) { - throw new Error(`${packageName}@${version} already exists on public npm.`); + const parsed = parseNpmJson(result); + if (result.status === 0 && typeof parsed === "string") { + return parsed; } - - try { - if (JSON.parse(result.stdout)?.error?.code === "E404") return; - } catch { - // The failure below includes npm's output for diagnosis. + if (result.status !== 0 && parsed?.error?.code === "E404") { + return undefined; } + const output = `${result.stdout}\n${result.stderr}`.trim(); + throw new Error( + `Could not read ${packageName}@${version} integrity from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` + ); +} +export async function getRegistryTagVersion(packageName, tag, registry, runner = runCommand) { + const result = await runner("npm", [ + "view", + `${packageName}@${tag}`, + "version", + "--json", + "--registry", + registry, + ]); + const parsed = parseNpmJson(result); + if (result.status === 0 && typeof parsed === "string") { + return parsed; + } + if (result.status !== 0 && parsed?.error?.code === "E404") { + return undefined; + } const output = `${result.stdout}\n${result.stderr}`.trim(); throw new Error( - `Could not confirm that ${packageName}@${version} is absent from public npm (npm exited ${result.status}).${output ? `\n${output}` : ""}` + `Could not read ${packageName}@${tag} from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}` ); } -export async function publishTarball(tarball, tag, registry, mode, runner = runCommand) { +export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) { + const existing = await getRegistryIntegrity(packageName, version, registry, runner); + if (existing !== undefined) { + throw new Error(`${packageName}@${version} already exists on ${registry}.`); + } +} + +export async function assertPublishedIntegrity( + packageName, + version, + expectedIntegrity, + registry, + runner = runCommand +) { + const existing = await getRegistryIntegrity(packageName, version, registry, runner); + if (existing === undefined) { + return "missing"; + } + if (existing !== expectedIntegrity) { + throw new Error( + `${packageName}@${version} on ${registry} has integrity ${existing}, expected ${expectedIntegrity}.` + ); + } + return "matching"; +} + +export async function publishTarball(tarball, tag, registry, mode, identity, runner = runCommand) { + if (!identity?.name || !identity?.version || !identity?.integrity) { + throw new Error("Publishing requires an expected package name, version, and integrity."); + } const args = ["publish", tarball, "--tag", tag, "--registry", registry]; if (mode === "public") args.push("--access", "public"); if (mode !== "public" && mode !== "azure") throw new Error(`Unknown publish mode: ${mode}`); const result = await runner("npm", args, { stream: true }); - if (result.status === 0) return; - - const output = `${result.stdout}\n${result.stderr}`; - if (PUBLIC_CONFLICT.test(output) || (mode === "azure" && AZURE_CONFLICT.test(output))) { - console.log( - "Version already published; treating the immutable-version conflict as success." + if (result.status !== 0) { + const state = await assertPublishedIntegrity( + identity.name, + identity.version, + identity.integrity, + registry, + runner ); + if (state !== "matching") { + throw new Error(`npm publish failed with exit code ${result.status}.`); + } + console.log(`${identity.name}@${identity.version} already exists with matching integrity.`); return; } + const state = await assertPublishedIntegrity( + identity.name, + identity.version, + identity.integrity, + registry, + runner + ); + if (state !== "matching") { + throw new Error( + `${identity.name}@${identity.version} was not readable with matching integrity after publication.` + ); + } +} - throw new Error(`npm publish failed with exit code ${result.status}.`); +function readReleaseManifest(manifestPath, packageDirectory) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.packages)) { + throw new Error("Unsupported release manifest."); + } + if (manifest.packages.length !== 9) { + throw new Error(`Expected nine release packages, found ${manifest.packages.length}.`); + } + const expectedNames = new Set([ + "@github/copilot-sdk", + "@github/copilot-sdk-darwin-arm64", + "@github/copilot-sdk-darwin-x64", + "@github/copilot-sdk-linux-arm64", + "@github/copilot-sdk-linux-x64", + "@github/copilot-sdk-linuxmusl-arm64", + "@github/copilot-sdk-linuxmusl-x64", + "@github/copilot-sdk-win32-arm64", + "@github/copilot-sdk-win32-x64", + ]); + const names = new Set(); + for (const packed of manifest.packages) { + if ( + typeof packed.name !== "string" || + typeof packed.filename !== "string" || + typeof packed.integrity !== "string" || + typeof packed.size !== "number" + ) { + throw new Error("Release manifest contains an invalid package entry."); + } + if (names.has(packed.name)) { + throw new Error(`Duplicate package in release manifest: ${packed.name}`); + } + if (!expectedNames.has(packed.name)) { + throw new Error(`Unexpected package in release manifest: ${packed.name}`); + } + names.add(packed.name); + const tarball = resolve(packageDirectory, packed.filename); + if ( + dirname(tarball) !== resolve(packageDirectory) || + basename(tarball) !== packed.filename + ) { + throw new Error(`Unsafe release package filename: ${packed.filename}`); + } + const bytes = readFileSync(tarball); + const localIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if (bytes.length !== packed.size || localIntegrity !== packed.integrity) { + throw new Error(`Local release package does not match manifest: ${packed.filename}`); + } + } + if (names.size !== expectedNames.size) { + throw new Error("Release manifest does not contain the exact Node SDK package set."); + } + return manifest; +} + +export async function publishManifest( + manifestPath, + packageDirectory, + tag, + registry, + mode, + runner = runCommand +) { + const manifest = readReleaseManifest(manifestPath, packageDirectory); + const packages = manifest.packages + .map((packed) => ({ + ...packed, + version: manifest.sdk.version, + tarball: resolve(packageDirectory, packed.filename), + })) + .sort((left, right) => { + if (left.name === "@github/copilot-sdk") return 1; + if (right.name === "@github/copilot-sdk") return -1; + return left.name.localeCompare(right.name); + }); + + const states = new Map(); + for (const packed of packages) { + states.set( + packed.name, + await assertPublishedIntegrity( + packed.name, + packed.version, + packed.integrity, + registry, + runner + ) + ); + } + const semver = await import("semver"); + for (const packed of packages) { + const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { + throw new Error( + `${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` + ); + } + if ( + mode === "public" && + states.get(packed.name) === "matching" && + taggedVersion !== packed.version + ) { + throw new Error( + `${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.` + ); + } + } + for (const packed of packages) { + if (states.get(packed.name) === "missing") { + await publishTarball(packed.tarball, tag, registry, mode, packed, runner); + } + } + for (const packed of packages) { + const taggedVersion = await getRegistryTagVersion(packed.name, tag, registry, runner); + if (taggedVersion === packed.version) { + continue; + } + if (mode === "public") { + throw new Error( + `${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.` + ); + } + if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) { + throw new Error( + `${packed.name}@${tag} advanced to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.` + ); + } + const result = await runner( + "npm", + ["dist-tag", "add", `${packed.name}@${packed.version}`, tag, "--registry", registry], + { stream: true } + ); + if (result.status !== 0) { + throw new Error(`Failed to set ${packed.name}@${packed.version} dist-tag ${tag}.`); + } + } } async function main() { const [command, ...args] = process.argv.slice(2); if (command === "preflight" && args.length === 3) { await assertVersionAbsent(...args); - console.log(`${args[0]}@${args[1]} is available on public npm.`); - } else if (command === "publish" && args.length === 4) { - await publishTarball(...args); + console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`); + } else if (command === "publish" && args.length === 7) { + const [tarball, name, version, tag, registry, mode, expectedIntegrity] = args; + const localIntegrity = `sha512-${createHash("sha512") + .update(readFileSync(tarball)) + .digest("base64")}`; + if (expectedIntegrity !== localIntegrity) { + throw new Error(`Expected integrity does not match ${tarball}.`); + } + await publishTarball(tarball, tag, registry, mode, { + name, + version, + integrity: localIntegrity, + }); + } else if (command === "publish-manifest" && args.length === 5) { + await publishManifest(...args); } else { throw new Error( - "Usage: npm-release.js preflight | publish " + "Usage: npm-release.js preflight | publish | publish-manifest " ); } } diff --git a/nodejs/scripts/release-manifest.ts b/nodejs/scripts/release-manifest.ts new file mode 100644 index 0000000000..9181033afa --- /dev/null +++ b/nodejs/scripts/release-manifest.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; +import * as semver from "semver"; +import { x as extractTar } from "tar"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +export interface ReleaseManifestPackage { + filename: string; + integrity: string; + name: string; + size: number; +} + +export interface ReleaseManifest { + channel: "canary" | "unstable"; + packages: ReleaseManifestPackage[]; + runtime: { + repository: "github/copilot-agent-runtime"; + runId: string; + sha: string; + source: "azure" | "github-packages"; + version: string; + }; + schemaVersion: 1; + sdk: { + ref: string; + repository: "github/copilot-sdk"; + sha: string; + version: string; + }; + workflow: { + createdAt: string; + runId: string; + runNumber: string; + }; +} + +export interface ReleaseManifestMetadata { + channel: ReleaseManifest["channel"]; + createdAt: string; + runtimeSha: string; + runtimeSource: ReleaseManifest["runtime"]["source"]; + runtimeRunId: string; + runtimeVersion: string; + sdkRef: string; + sdkSha: string; + sdkVersion: string; + workflowRunId: string; + workflowRunNumber: string; +} + +const expectedPackageNames = new Set([ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), +]); + +function integrity(buffer: Buffer): string { + return `sha512-${createHash("sha512").update(buffer).digest("base64")}`; +} + +async function readPackedManifest(archive: string): Promise<{ name: string; version: string }> { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-release-manifest-")); + try { + await extractTar({ + cwd: root, + file: archive, + strict: true, + filter: (entryPath) => entryPath === "package/package.json", + }); + return JSON.parse(readFileSync(join(root, "package", "package.json"), "utf8")) as { + name: string; + version: string; + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function validateFullSha(value: string, label: string): void { + assert.match(value, /^[0-9a-f]{40}$/i, `${label} must be a full 40-character SHA`); +} + +export async function createReleaseManifest( + packageDirectory: string, + metadata: ReleaseManifestMetadata +): Promise { + validateFullSha(metadata.sdkSha, "SDK SHA"); + validateFullSha(metadata.runtimeSha, "Runtime SHA"); + assert(Number.isFinite(Date.parse(metadata.createdAt)), "Workflow creation time is invalid"); + const packages: ReleaseManifestPackage[] = []; + for (const archive of globSync("github-copilot-sdk-*.tgz", { + cwd: packageDirectory, + absolute: true, + })) { + const packed = await readPackedManifest(archive); + if (packed.version !== metadata.sdkVersion || !expectedPackageNames.has(packed.name)) { + continue; + } + const bytes = readFileSync(archive); + packages.push({ + filename: basename(archive), + integrity: integrity(bytes), + name: packed.name, + size: bytes.length, + }); + } + packages.sort((left, right) => left.name.localeCompare(right.name)); + assert.deepEqual( + packages.map(({ name }) => name), + [...expectedPackageNames].sort(), + "Release artifact must contain exactly the nine expected Node packages" + ); + return { + schemaVersion: 1, + channel: metadata.channel, + sdk: { + version: metadata.sdkVersion, + sha: metadata.sdkSha, + ref: metadata.sdkRef, + repository: "github/copilot-sdk", + }, + runtime: { + version: metadata.runtimeVersion, + sha: metadata.runtimeSha, + source: metadata.runtimeSource, + repository: "github/copilot-agent-runtime", + runId: metadata.runtimeRunId, + }, + workflow: { + runId: metadata.workflowRunId, + runNumber: metadata.workflowRunNumber, + createdAt: metadata.createdAt, + }, + packages, + }; +} + +export function verifyReleaseManifest(manifest: ReleaseManifest, packageDirectory: string): void { + assert.equal(manifest.schemaVersion, 1, "Unsupported release manifest schema"); + assert( + manifest.channel === "canary" || manifest.channel === "unstable", + "Invalid release channel" + ); + validateFullSha(manifest.sdk.sha, "SDK SHA"); + validateFullSha(manifest.runtime.sha, "Runtime SHA"); + assert(semver.valid(manifest.sdk.version), "Invalid SDK version"); + assert(semver.valid(manifest.runtime.version), "Invalid runtime version"); + assert.match(manifest.workflow.runId, /^[0-9]+$/, "Invalid SDK workflow run ID"); + assert.match(manifest.workflow.runNumber, /^[0-9]+$/, "Invalid SDK workflow run number"); + assert.match(manifest.runtime.runId, /^[0-9]+$/, "Invalid runtime workflow run ID"); + assert( + Number.isFinite(Date.parse(manifest.workflow.createdAt)), + "Invalid workflow creation time" + ); + assert.equal(manifest.sdk.repository, "github/copilot-sdk"); + assert.equal(manifest.runtime.repository, "github/copilot-agent-runtime"); + assert.equal( + manifest.runtime.source, + manifest.channel === "canary" ? "azure" : "github-packages", + "Runtime source does not match the release channel" + ); + assert.equal(manifest.packages.length, 9, "Release manifest must contain nine packages"); + assert.deepEqual( + manifest.packages.map(({ name }) => name).sort(), + [...expectedPackageNames].sort(), + "Release manifest package names do not match the expected package set" + ); + for (const packed of manifest.packages) { + const archive = resolve(packageDirectory, packed.filename); + assert.equal( + dirname(archive), + resolve(packageDirectory), + `Unsafe release filename: ${packed.filename}` + ); + const bytes = readFileSync(archive); + assert.equal(statSync(archive).size, packed.size, `Size mismatch for ${packed.filename}`); + assert.equal( + integrity(bytes), + packed.integrity, + `Integrity mismatch for ${packed.filename}` + ); + } +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +async function main(): Promise { + const [command, manifestPath = "release-manifest.json", packageDirectory = "."] = + process.argv.slice(2); + if (command === "create") { + const manifest = await createReleaseManifest(packageDirectory, { + channel: requiredEnvironment("RELEASE_CHANNEL") as ReleaseManifest["channel"], + createdAt: requiredEnvironment("WORKFLOW_CREATED_AT"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + runtimeSource: requiredEnvironment( + "RUNTIME_SOURCE" + ) as ReleaseManifest["runtime"]["source"], + runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), + runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), + sdkRef: requiredEnvironment("SDK_REF"), + sdkSha: requiredEnvironment("SDK_SHA"), + sdkVersion: requiredEnvironment("SDK_VERSION"), + workflowRunId: requiredEnvironment("WORKFLOW_RUN_ID"), + workflowRunNumber: requiredEnvironment("WORKFLOW_RUN_NUMBER"), + }); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + verifyReleaseManifest(manifest, packageDirectory); + return; + } + if (command === "verify") { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ReleaseManifest; + verifyReleaseManifest(manifest, packageDirectory); + return; + } + throw new Error("Usage: release-manifest.ts create|verify [manifest-path] [package-directory]"); +} + +const scriptPath = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (scriptPath) { + main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/nodejs/scripts/releaseArtifacts.ts b/nodejs/scripts/releaseArtifacts.ts index 2731d878c5..cf493f47cb 100644 --- a/nodejs/scripts/releaseArtifacts.ts +++ b/nodejs/scripts/releaseArtifacts.ts @@ -16,6 +16,7 @@ export interface EnsureCopilotPackageOptions { environment?: NodeJS.ProcessEnv; fetch?: typeof globalThis.fetch; fetchTimeoutMs?: number; + packageDirectory?: string; platform?: string; } @@ -107,6 +108,18 @@ export async function ensureCopilotPackage( options: EnsureCopilotPackageOptions = {} ): Promise { const platform = options.platform ?? getRuntimePlatform(); + const environment = options.environment ?? process.env; + const packageDirectory = + options.packageDirectory ?? environment.COPILOT_SDK_RUNTIME_PACKAGE_DIR; + if (packageDirectory) { + const packageRoot = join(packageDirectory, platform); + validateFile(join(packageRoot, "package.json"), `${platform} runtime package manifest`); + validateFile( + join(packageRoot, "prebuilds", platform, "runtime.node"), + "Copilot runtime.node" + ); + return packageRoot; + } // lgtm[js/trivial-conditional] This generated constant is true for internal canary builds. if (version === COPILOT_CLI_VERSION && COPILOT_CLI_USE_NPM_PACKAGE) { const packageName = `@github/copilot-${platform}`; @@ -130,7 +143,7 @@ export async function ensureCopilotPackage( } const baseUrl = ( - (options.environment ?? process.env).COPILOT_CLI_DOWNLOAD_BASE_URL ?? + environment.COPILOT_CLI_DOWNLOAD_BASE_URL ?? "https://github.com/github/copilot-cli/releases/download" ).replace(/\/+$/, ""); const fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; diff --git a/nodejs/scripts/runtime-dispatch-ledger.ts b/nodejs/scripts/runtime-dispatch-ledger.ts new file mode 100644 index 0000000000..aecab955d2 --- /dev/null +++ b/nodejs/scripts/runtime-dispatch-ledger.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface RuntimeDispatchMarker { + canonicalRunId: string; + channel: "canary" | "unstable"; + createdAt: string; + mode: "internal" | "tests-only"; + runtime: { + repository: "github/copilot-agent-runtime"; + runId: string; + sha: string; + source: "azure" | "github-packages"; + version: string; + }; + schemaVersion: 1; + sdk: { + ref: string; + repository: "github/copilot-sdk"; + versionOverride: string; + sha: string; + }; + workflow: ".github/workflows/runtime-sdk.yml"; +} + +interface ArtifactApiResponse { + expired: boolean; + workflow_run?: { id?: number }; +} + +interface WorkflowRunApiResponse { + event: string; + head_branch: string; + head_sha: string; + id: number; + name: string; + path: string; + repository: { full_name: string }; +} + +export interface ExpectedDispatch { + channel: RuntimeDispatchMarker["channel"]; + currentRunId: string; + mode: RuntimeDispatchMarker["mode"]; + runtimeRunId: string; + runtimeSha: string; + runtimeSource: RuntimeDispatchMarker["runtime"]["source"]; + runtimeVersion: string; + sdkRef: string; + sdkSha: string; + versionOverride: string; +} + +export type DispatchRole = "duplicate" | "owner"; + +const workflowPath = ".github/workflows/runtime-sdk.yml"; +const workflowName = "Runtime-driven Node SDK"; + +function validateInputs(expected: ExpectedDispatch): void { + assert.match(expected.currentRunId, /^[0-9]+$/, "Current workflow run ID must be numeric"); + assert.match(expected.runtimeRunId, /^[0-9]+$/, "Runtime workflow run ID must be numeric"); + assert.match(expected.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + assert.match(expected.sdkSha, /^[0-9a-f]{40}$/, "SDK SHA must be lowercase full SHA"); + assert(expected.sdkRef.length > 0, "SDK ref is required"); + assert( + expected.channel === "canary" + ? expected.runtimeSource === "azure" && + (expected.mode === "tests-only" || expected.mode === "internal") + : expected.runtimeSource === "github-packages" && expected.mode === "internal", + "Invalid channel, runtime source, or mode combination" + ); +} + +export function createRuntimeDispatchMarker(expected: ExpectedDispatch): RuntimeDispatchMarker { + validateInputs(expected); + return { + schemaVersion: 1, + canonicalRunId: expected.currentRunId, + channel: expected.channel, + mode: expected.mode, + runtime: { + repository: "github/copilot-agent-runtime", + runId: expected.runtimeRunId, + sha: expected.runtimeSha, + source: expected.runtimeSource, + version: expected.runtimeVersion, + }, + sdk: { + repository: "github/copilot-sdk", + ref: expected.sdkRef, + sha: expected.sdkSha, + versionOverride: expected.versionOverride, + }, + workflow: workflowPath, + createdAt: new Date().toISOString(), + }; +} + +export function validateRuntimeDispatchMarker( + marker: RuntimeDispatchMarker, + artifact: ArtifactApiResponse, + workflowRun: WorkflowRunApiResponse, + expected: ExpectedDispatch +): DispatchRole { + validateInputs(expected); + assert.equal(marker.schemaVersion, 1, "Unsupported dispatch marker schema"); + assert.match(marker.canonicalRunId, /^[0-9]+$/, "Canonical workflow run ID must be numeric"); + assert.equal(artifact.expired, false, "Dispatch marker artifact is expired"); + assert.equal( + String(artifact.workflow_run?.id), + marker.canonicalRunId, + "Artifact workflow run ID does not match its marker" + ); + assert.equal(String(workflowRun.id), marker.canonicalRunId, "Workflow run provenance mismatch"); + assert.equal(workflowRun.repository.full_name, "github/copilot-sdk"); + assert.equal(workflowRun.path, workflowPath); + assert.equal(workflowRun.name, workflowName); + assert.equal(workflowRun.event, "workflow_dispatch"); + assert.equal(workflowRun.head_sha, marker.sdk.sha); + assert.equal(workflowRun.head_branch, marker.sdk.ref.replace(/^refs\/(heads|tags)\//, "")); + assert.deepEqual( + { + channel: marker.channel, + mode: marker.mode, + runtime: marker.runtime, + sdk: marker.sdk, + workflow: marker.workflow, + }, + { + channel: expected.channel, + mode: expected.mode, + runtime: { + repository: "github/copilot-agent-runtime", + runId: expected.runtimeRunId, + sha: expected.runtimeSha, + source: expected.runtimeSource, + version: expected.runtimeVersion, + }, + sdk: { + repository: "github/copilot-sdk", + ref: expected.sdkRef, + sha: expected.sdkSha, + versionOverride: expected.versionOverride, + }, + workflow: workflowPath, + }, + "runtime_run_id is already claimed by a different release tuple" + ); + + if (marker.canonicalRunId === expected.currentRunId) { + return "owner"; + } + return "duplicate"; +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +function expectedFromEnvironment(): ExpectedDispatch { + return { + channel: requiredEnvironment("CHANNEL") as ExpectedDispatch["channel"], + currentRunId: requiredEnvironment("CURRENT_RUN_ID"), + mode: requiredEnvironment("MODE") as ExpectedDispatch["mode"], + runtimeRunId: requiredEnvironment("RUNTIME_RUN_ID"), + runtimeSha: requiredEnvironment("RUNTIME_SHA"), + runtimeSource: requiredEnvironment("RUNTIME_SOURCE") as ExpectedDispatch["runtimeSource"], + runtimeVersion: requiredEnvironment("RUNTIME_VERSION"), + sdkRef: requiredEnvironment("SDK_REF"), + sdkSha: requiredEnvironment("SDK_SHA"), + versionOverride: process.env.VERSION_OVERRIDE?.trim() ?? "", + }; +} + +function main(): void { + const [command, markerPath, artifactPath, runPath] = process.argv.slice(2); + const expected = expectedFromEnvironment(); + if (command === "create" && markerPath) { + writeFileSync( + markerPath, + `${JSON.stringify(createRuntimeDispatchMarker(expected), null, 2)}\n` + ); + return; + } + if (command === "validate" && markerPath && artifactPath && runPath) { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as RuntimeDispatchMarker; + const artifact = JSON.parse(readFileSync(artifactPath, "utf8")) as ArtifactApiResponse; + const run = JSON.parse(readFileSync(runPath, "utf8")) as WorkflowRunApiResponse; + const role = validateRuntimeDispatchMarker(marker, artifact, run, expected); + if (process.env.GITHUB_OUTPUT) { + writeFileSync( + process.env.GITHUB_OUTPUT, + `role=${role}\ncanonical_run_id=${marker.canonicalRunId}\n`, + { + flag: "a", + } + ); + } else { + console.log(role); + } + return; + } + throw new Error( + "Usage: runtime-dispatch-ledger.ts create | validate " + ); +} + +const scriptPath = process.argv[1] + ? fileURLToPath(import.meta.url) === resolve(process.argv[1]) + : false; +if (scriptPath) { + try { + main(); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/nodejs/scripts/runtime-package-acquisition.ts b/nodejs/scripts/runtime-package-acquisition.ts new file mode 100644 index 0000000000..5521f54a46 --- /dev/null +++ b/nodejs/scripts/runtime-package-acquisition.ts @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { x as extractTar } from "tar"; +import { RUNTIME_PLATFORMS, validateFile } from "../src/runtimeArtifacts.js"; + +interface CommandResult { + status: number; + stderr: string; + stdout: string; +} + +interface RuntimePackageManifest { + copilotRuntime?: { + sourceRepository?: string; + sourceSha?: string; + }; + cpu?: string[]; + libc?: string[]; + name?: string; + os?: string[]; + repository?: string | { url?: string }; + version?: string; +} + +export interface AcquireRuntimePackagesOptions { + outputDirectory: string; + registry: string; + runtimeSha: string; + runtimeVersion: string; +} + +export type CommandRunner = ( + command: string, + args: string[], + options?: { cwd?: string } +) => Promise; + +export function getSourceRuntimePackageName(platform: string): string { + return `@github/copilot-${platform}`; +} + +export function runCommand( + command: string, + args: string[], + options: { cwd?: string } = {} +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + shell: false, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (status) => resolveResult({ status: status ?? 1, stdout, stderr })); + }); +} + +function parseJsonOutput(result: CommandResult, description: string): T { + if (result.status !== 0) { + throw new Error( + `${description} failed with exit code ${result.status}: ${result.stderr || result.stdout}` + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error(`${description} returned invalid JSON: ${result.stdout}`); + } +} + +function validatePlatformMetadata(manifest: RuntimePackageManifest, platform: string): void { + const [osName, cpu] = platform.replace("linuxmusl", "linux").split("-"); + assert.deepEqual(manifest.os, [osName], `Invalid os metadata for ${platform}`); + assert.deepEqual(manifest.cpu, [cpu], `Invalid cpu metadata for ${platform}`); + if (platform.startsWith("linux")) { + assert.deepEqual( + manifest.libc, + [platform.startsWith("linuxmusl") ? "musl" : "glibc"], + `Invalid libc metadata for ${platform}` + ); + } else { + assert.equal(manifest.libc, undefined, `Unexpected libc metadata for ${platform}`); + } +} + +function repositoryUrl(repository: RuntimePackageManifest["repository"]): string { + return typeof repository === "string" ? repository : (repository?.url ?? ""); +} + +export function validateRuntimePackageRoot( + packageRoot: string, + platform: string, + runtimeVersion: string, + runtimeSha: string +): void { + const manifestPath = join(packageRoot, "package.json"); + validateFile(manifestPath, `${platform} runtime package manifest`); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RuntimePackageManifest; + assert.equal(manifest.name, getSourceRuntimePackageName(platform)); + assert.equal(manifest.version, runtimeVersion); + assert.equal(manifest.copilotRuntime?.sourceRepository, "github/copilot-agent-runtime"); + assert.equal(manifest.copilotRuntime?.sourceSha, runtimeSha.toLowerCase()); + assert( + repositoryUrl(manifest.repository).includes("github/copilot-agent-runtime"), + `${manifest.name} does not link to github/copilot-agent-runtime` + ); + validatePlatformMetadata(manifest, platform); + + const windows = platform.startsWith("win32"); + for (const requiredPath of [ + "LICENSE.md", + windows ? "copilot.exe" : "copilot", + join("prebuilds", platform, windows ? "copilot-runtime.exe" : "copilot-runtime"), + join("prebuilds", platform, "runtime.node"), + join("copilot-sdk", "extension.js"), + join("preloads", "extension_bootstrap.mjs"), + join("sdk", "index.js"), + ]) { + validateFile(join(packageRoot, requiredPath), `${manifest.name} ${requiredPath}`); + } +} + +function sha512Integrity(path: string): string { + return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`; +} + +export async function acquireRuntimePackages( + options: AcquireRuntimePackagesOptions, + runner: CommandRunner = runCommand +): Promise { + assert.match(options.runtimeSha, /^[0-9a-f]{40}$/, "Runtime SHA must be lowercase full SHA"); + assert.match(options.registry, /^https:\/\//, "Runtime registry must use HTTPS"); + const outputDirectory = resolve(options.outputDirectory); + const tarballDirectory = join(outputDirectory, "tarballs"); + mkdirSync(tarballDirectory, { recursive: true }); + const acquired: { + filename: string; + integrity: string; + name: string; + platform: string; + version: string; + }[] = []; + + for (const platform of RUNTIME_PLATFORMS) { + const packageName = getSourceRuntimePackageName(platform); + const spec = `${packageName}@${options.runtimeVersion}`; + const viewResult = await runner("npm", [ + "view", + spec, + "dist.integrity", + "--json", + "--registry", + options.registry, + ]); + const registryIntegrity = parseJsonOutput( + viewResult, + `Reading registry integrity for ${spec}` + ); + assert.match( + registryIntegrity, + /^sha512-[A-Za-z0-9+/]+={0,2}$/, + `Invalid registry integrity for ${spec}` + ); + const packResult = await runner("npm", [ + "pack", + spec, + "--json", + "--pack-destination", + tarballDirectory, + "--registry", + options.registry, + ]); + const packed = parseJsonOutput<{ filename: string; integrity?: string }[]>( + packResult, + `Downloading ${spec}` + ); + assert.equal(packed.length, 1, `npm pack returned an unexpected result for ${spec}`); + const tarball = join(tarballDirectory, basename(packed[0].filename)); + validateFile(tarball, `${spec} tarball`); + assert.equal(sha512Integrity(tarball), registryIntegrity, `Integrity mismatch for ${spec}`); + if (packed[0].integrity) { + assert.equal( + packed[0].integrity, + registryIntegrity, + `npm pack integrity mismatch for ${spec}` + ); + } + + const extractionRoot = join(outputDirectory, `.extract-${platform}`); + const packageRoot = join(extractionRoot, "package"); + rmSync(extractionRoot, { recursive: true, force: true }); + mkdirSync(extractionRoot, { recursive: true }); + try { + await extractTar({ cwd: extractionRoot, file: tarball, strict: true }); + validateRuntimePackageRoot( + packageRoot, + platform, + options.runtimeVersion, + options.runtimeSha + ); + const destination = join(outputDirectory, platform); + rmSync(destination, { recursive: true, force: true }); + renameSync(packageRoot, destination); + } finally { + rmSync(extractionRoot, { recursive: true, force: true }); + } + acquired.push({ + filename: basename(tarball), + integrity: registryIntegrity, + name: packageName, + platform, + version: options.runtimeVersion, + }); + } + + assert.equal(acquired.length, 8); + writeFileSync( + join(outputDirectory, "runtime-packages.json"), + `${JSON.stringify( + { + runtimeVersion: options.runtimeVersion, + runtimeSha: options.runtimeSha, + registry: options.registry, + packages: acquired, + }, + null, + 2 + )}\n` + ); +} + +function parseArguments(args: string[]): AcquireRuntimePackagesOptions { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if (!key?.startsWith("--") || !value) { + throw new Error( + "Usage: runtime-package-acquisition.ts --version --sha --registry --output " + ); + } + values.set(key, value); + } + return { + runtimeVersion: values.get("--version") ?? "", + runtimeSha: values.get("--sha") ?? "", + registry: values.get("--registry") ?? "", + outputDirectory: values.get("--output") ?? "", + }; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + acquireRuntimePackages(parseArguments(process.argv.slice(2))).catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/nodejs/scripts/set-cli-version.js b/nodejs/scripts/set-cli-version.js index ea45f90ada..e94d04bea6 100644 --- a/nodejs/scripts/set-cli-version.js +++ b/nodejs/scripts/set-cli-version.js @@ -4,9 +4,9 @@ import { fileURLToPath } from "node:url"; const [version, mode] = process.argv.slice(2); if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z._-]+)?$/.test(version)) { - throw new Error("Usage: set-cli-version.js [--npm-package]"); + throw new Error("Usage: set-cli-version.js [--npm-package|--local-package]"); } -if (mode !== undefined && mode !== "--npm-package") { +if (mode !== undefined && mode !== "--npm-package" && mode !== "--local-package") { throw new Error(`Unknown option: ${mode}`); } @@ -30,7 +30,7 @@ const cliAssets = [ "copilot-win32-x64.zip", ]; const useNpmPackage = mode === "--npm-package"; -if (!useNpmPackage) { +if (mode === undefined) { const checksumsUrl = `https://github.com/github/copilot-cli/releases/download/v${version}/SHA256SUMS.txt`; const response = await fetch(checksumsUrl); if (!response.ok) { diff --git a/nodejs/scripts/unstable-version.ts b/nodejs/scripts/unstable-version.ts new file mode 100644 index 0000000000..c8905ebef8 --- /dev/null +++ b/nodejs/scripts/unstable-version.ts @@ -0,0 +1,137 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as semver from "semver"; + +export interface ReleaseRecord { + draft?: boolean; + published_at: string | null; + tag_name: string; +} + +export interface UnstableVersionOptions { + createdAt: string; + firstParentTags: string[]; + releases: ReleaseRecord[]; + runNumber: string; + sdkSha: string; + versionOverride?: string; +} + +function canonicalVersion(tag: string): string | undefined { + if (!tag.startsWith("v")) { + return undefined; + } + const version = tag.slice(1); + return semver.valid(version) === version ? version : undefined; +} + +export function targetCoreFromBaseline(baseline: string): string { + const parsed = semver.parse(baseline); + if (!parsed) { + throw new Error(`Invalid SDK release baseline: ${baseline}`); + } + if (parsed.prerelease.length > 0) { + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; + } + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +export function calculateUnstableVersion(options: UnstableVersionOptions): string { + if (!/^[0-9]+$/.test(options.runNumber)) { + throw new Error(`Invalid workflow run number: ${options.runNumber}`); + } + if (!/^[0-9a-f]{40}$/i.test(options.sdkSha)) { + throw new Error(`Invalid full SDK SHA: ${options.sdkSha}`); + } + const createdAt = Date.parse(options.createdAt); + if (!Number.isFinite(createdAt)) { + throw new Error(`Invalid workflow creation time: ${options.createdAt}`); + } + + if (options.versionOverride) { + const parsed = semver.parse(options.versionOverride); + if ( + !parsed || + semver.valid(options.versionOverride) !== options.versionOverride || + parsed.prerelease[0] !== "unstable" + ) { + throw new Error( + `Explicit unstable SDK version must be valid SemVer with an unstable prerelease: ${options.versionOverride}` + ); + } + return options.versionOverride; + } + + const eligibleTags = new Set( + options.releases + .filter( + (release) => + !release.draft && + release.published_at !== null && + Date.parse(release.published_at) <= createdAt && + canonicalVersion(release.tag_name) !== undefined + ) + .map((release) => release.tag_name) + ); + const baselineTag = options.firstParentTags.find((tag) => eligibleTags.has(tag)); + const baseline = baselineTag ? canonicalVersion(baselineTag) : undefined; + if (!baseline) { + throw new Error( + "No eligible SDK release tag was found on the selected SDK branch's first-parent history." + ); + } + + return `${targetCoreFromBaseline(baseline)}-unstable.${options.runNumber}.g${options.sdkSha.slice(0, 7)}`; +} + +function getFirstParentTags(sdkSha: string): string[] { + const commits = execFileSync("git", ["rev-list", "--first-parent", sdkSha], { + encoding: "utf8", + }) + .trim() + .split(/\r?\n/) + .filter(Boolean); + const position = new Map(commits.map((commit, index) => [commit, index])); + return execFileSync("git", ["tag", "--list", "v*"], { encoding: "utf8" }) + .trim() + .split(/\r?\n/) + .filter((tag) => canonicalVersion(tag) !== undefined) + .map((tag) => ({ + tag, + commit: execFileSync("git", ["rev-parse", `${tag}^{commit}`], { + encoding: "utf8", + }).trim(), + })) + .filter(({ commit }) => position.has(commit)) + .sort( + (left, right) => + (position.get(left.commit) ?? Number.MAX_SAFE_INTEGER) - + (position.get(right.commit) ?? Number.MAX_SAFE_INTEGER) + ) + .map(({ tag }) => tag); +} + +function requireEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const releasesPath = requireEnvironment("SDK_RELEASES_FILE"); + const releases = JSON.parse(readFileSync(releasesPath, "utf8")) as ReleaseRecord[]; + const sdkSha = requireEnvironment("SDK_SHA"); + const version = calculateUnstableVersion({ + createdAt: requireEnvironment("WORKFLOW_CREATED_AT"), + firstParentTags: getFirstParentTags(sdkSha), + releases, + runNumber: requireEnvironment("WORKFLOW_RUN_NUMBER"), + sdkSha, + versionOverride: process.env.SDK_VERSION_OVERRIDE?.trim() || undefined, + }); + process.stdout.write(`${version}\n`); +} diff --git a/nodejs/test/npm-release.test.ts b/nodejs/test/npm-release.test.ts index 26caf7deaa..06d431d7f6 100644 --- a/nodejs/test/npm-release.test.ts +++ b/nodejs/test/npm-release.test.ts @@ -1,13 +1,24 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { assertVersionAbsent, publishTarball } from "../scripts/npm-release.js"; +import { + assertPublishedIntegrity, + assertVersionAbsent, + publishManifest, + publishTarball, +} from "../scripts/npm-release.js"; const packageName = "@github/copilot-sdk"; -const version = "1.2.3"; +const version = "1.2.3-unstable.7.gabcdef0"; const registry = "https://registry.example.test"; +const integrity = "sha512-expected"; +const identity = { name: packageName, version, integrity }; const result = (status: number, stdout = "", stderr = "") => ({ status, stdout, stderr }); describe("npm release preflight", () => { - it("succeeds only for a structured E404 response", async () => { + it("recognizes only a structured E404 as absent", async () => { const runner = vi .fn() .mockResolvedValue(result(1, JSON.stringify({ error: { code: "E404" } }))); @@ -16,73 +27,176 @@ describe("npm release preflight", () => { ).resolves.toBeUndefined(); }); - it.each([ - ["an existing version", result(0, JSON.stringify(version)), "already exists"], - ["a transient error", result(1, "", "npm error code E500"), "Could not confirm"], - ["malformed output", result(1, "not-json"), "Could not confirm"], - [ - "a non-404 error containing E404 and 404 text", - result( - 1, - JSON.stringify({ error: { code: "E500", summary: "version 1.2.3-E404.404" } }), - "npm error code E500 for 1.2.3-E404.404" - ), - "Could not confirm", - ], - ])("fails for %s", async (_name, response, message) => { - const runner = vi.fn().mockResolvedValue(response); + it("accepts an existing package only when integrity matches", async () => { + const matching = vi.fn().mockResolvedValue(result(0, JSON.stringify(integrity))); + await expect( + assertPublishedIntegrity(packageName, version, integrity, registry, matching) + ).resolves.toBe("matching"); + + const conflicting = vi + .fn() + .mockResolvedValue(result(0, JSON.stringify("sha512-conflicting"))); + await expect( + assertPublishedIntegrity(packageName, version, integrity, registry, conflicting) + ).rejects.toThrow("has integrity sha512-conflicting"); + }); + + it("does not treat malformed or transient failures as absence", async () => { + const runner = vi.fn().mockResolvedValue(result(1, "not-json", "npm error code E500")); await expect(assertVersionAbsent(packageName, version, registry, runner)).rejects.toThrow( - message + "Could not read" ); }); }); describe("npm release publishing", () => { - it("succeeds after a normal publish", async () => { - const runner = vi.fn().mockResolvedValue(result(0)); + it("verifies registry integrity after a normal publish", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(0)) + .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); await expect( - publishTarball("package.tgz", "latest", registry, "public", runner) + publishTarball("package.tgz", "unstable", registry, "public", identity, runner) ).resolves.toBeUndefined(); }); - it.each([ - ["npm error code EPUBLISHCONFLICT", "public"], - [ - "npm error 403 403 Forbidden - PUT https://registry.npmjs.org/package - You cannot publish over the previously published versions: 1.2.3.", - "public", - ], - [ - "npm error 403 403 Forbidden - The feed 'copilot-canary' already contains file 'copilot-sdk-0.0.0-29613896246.tgz' in package '@github/copilot-sdk 0.0.0-29613896246'.", - "azure", - ], - ])("recovers the immutable conflict: %s", async (error, mode) => { - const runner = vi.fn().mockResolvedValue(result(1, "", error)); + it("recovers a publication conflict only when registry integrity matches", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) + .mockResolvedValueOnce(result(0, JSON.stringify(integrity))); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) + publishTarball("package.tgz", "unstable", registry, "public", identity, runner) ).resolves.toBeUndefined(); }); - it.each([ - ["a generic Azure 403", "403 Forbidden", "azure"], - [ - "an Azure non-tarball conflict", - "npm error 403 already contains file 'package.json' in package '@github/copilot-sdk/1.2.3'", - "azure", - ], - [ - "an embedded public phrase", - "npm error network timeout while parsing 'cannot publish over the previously published versions'", - "public", - ], - [ - "an embedded Azure phrase", - "npm error network timeout while parsing \"already contains file 'package.tgz' in package '@github/copilot-sdk/1.2.3'\"", - "azure", - ], - ])("fails for %s", async (_name, error, mode) => { - const runner = vi.fn().mockResolvedValue(result(1, "", error)); + it("fails a publication conflict with different content", async () => { + const runner = vi + .fn() + .mockResolvedValueOnce(result(1, "", "EPUBLISHCONFLICT")) + .mockResolvedValueOnce(result(0, JSON.stringify("sha512-other"))); await expect( - publishTarball("package.tgz", "latest", registry, mode, runner) - ).rejects.toThrow("npm publish failed"); + publishTarball("package.tgz", "unstable", registry, "public", identity, runner) + ).rejects.toThrow("sha512-other"); + }); + + it("preflights all packages, publishes platforms before the umbrella, and tags last", async () => { + const directory = mkdtempSync(join(tmpdir(), "copilot-sdk-npm-release-")); + mkdirSync(directory, { recursive: true }); + const packages = [ + "@github/copilot-sdk", + ...[ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "linuxmusl-arm64", + "linuxmusl-x64", + "win32-arm64", + "win32-x64", + ].map((platform) => `@github/copilot-sdk-${platform}`), + ].map((name, index) => { + const filename = `package-${index}.tgz`; + const bytes = Buffer.from(name); + writeFileSync(join(directory, filename), bytes); + return { + filename, + integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`, + name, + size: bytes.length, + }; + }); + const manifestPath = join(directory, "release-manifest.json"); + writeFileSync( + manifestPath, + JSON.stringify({ schemaVersion: 1, sdk: { version }, packages }) + ); + const calls: string[][] = []; + const runner = vi.fn(async (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "view") { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name); + if (args[2] === "version") { + return result(0, JSON.stringify(version)); + } + return result( + calls + .filter((call) => call[0] === "publish") + .some((call) => call[1].includes(packed!.filename)) + ? 0 + : 1, + calls + .filter((call) => call[0] === "publish") + .some((call) => call[1].includes(packed!.filename)) + ? JSON.stringify(packed!.integrity) + : JSON.stringify({ error: { code: "E404" } }) + ); + } + return result(0); + }); + + try { + await publishManifest(manifestPath, directory, "unstable", registry, "public", runner); + const publishCalls = calls.filter((args) => args[0] === "publish"); + expect(publishCalls).toHaveLength(9); + expect(publishCalls.at(-1)?.[1]).toContain("package-0.tgz"); + expect(calls.filter((args) => args[0] === "dist-tag")).toHaveLength(0); + expect( + Math.max( + ...calls.map((args, index) => + args[0] === "view" && args[2] === "version" ? index : -1 + ) + ) + ).toBeGreaterThan(calls.map((args) => args[0]).lastIndexOf("publish")); + + const staleTagRunner = vi.fn(async (_command: string, args: string[]) => { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name)!; + return result( + 0, + JSON.stringify(args[2] === "version" ? "9.0.0-unstable.1" : packed.integrity) + ); + }); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "public", + staleTagRunner + ) + ).rejects.toThrow("refusing to rewind"); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "azure", + staleTagRunner + ) + ).rejects.toThrow("refusing to rewind"); + const missingTagRunner = vi.fn(async (_command: string, args: string[]) => { + const name = args[1].slice(0, args[1].lastIndexOf("@")); + const packed = packages.find((candidate) => candidate.name === name)!; + return args[2] === "version" + ? result(1, JSON.stringify({ error: { code: "E404" } })) + : result(0, JSON.stringify(packed.integrity)); + }); + await expect( + publishManifest( + manifestPath, + directory, + "unstable", + registry, + "public", + missingTagRunner + ) + ).rejects.toThrow("Public trusted publishing cannot repair dist-tags"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); }); diff --git a/nodejs/test/release-manifest.test.ts b/nodejs/test/release-manifest.test.ts new file mode 100644 index 0000000000..5c7e1648bd --- /dev/null +++ b/nodejs/test/release-manifest.test.ts @@ -0,0 +1,60 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { c as createTar } from "tar"; +import { afterEach, describe, expect, it } from "vitest"; +import { createReleaseManifest, verifyReleaseManifest } from "../scripts/release-manifest.js"; +import { getRuntimePackageName, RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +const roots: string[] = []; +const sdkSha = "abcdef0123456789abcdef0123456789abcdef01"; +const runtimeSha = "123456789abcdef0123456789abcdef012345678"; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function packageTarball(root: string, name: string, version: string): Promise { + const packageRoot = join(root, "staging", name.replaceAll("/", "-")); + mkdirSync(join(packageRoot, "package"), { recursive: true }); + writeFileSync(join(packageRoot, "package", "package.json"), JSON.stringify({ name, version })); + const filename = `${name.replace("@github/", "github-").replaceAll("/", "-")}-${version}.tgz`; + await createTar({ cwd: packageRoot, file: join(root, filename), gzip: true }, ["package"]); +} + +describe("release manifest", () => { + it("freezes and verifies the exact nine-package release identity", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-sdk-manifest-")); + roots.push(root); + const version = "1.0.13-unstable.8123.gabcdef0"; + for (const name of [ + "@github/copilot-sdk", + ...RUNTIME_PLATFORMS.map(getRuntimePackageName), + ]) { + await packageTarball(root, name, version); + } + const manifest = await createReleaseManifest(root, { + channel: "unstable", + createdAt: "2026-09-04T00:00:00Z", + runtimeRunId: "9001", + runtimeSha, + runtimeSource: "github-packages", + runtimeVersion: "1.0.83-5.unstable.123.g1234567", + sdkRef: "feature/unstable", + sdkSha, + sdkVersion: version, + workflowRunId: "812300", + workflowRunNumber: "8123", + }); + + expect(manifest.packages).toHaveLength(9); + expect(manifest.runtime.runId).toBe("9001"); + expect(() => verifyReleaseManifest(manifest, root)).not.toThrow(); + + const damaged = join(root, manifest.packages[0].filename); + writeFileSync(damaged, Buffer.concat([readFileSync(damaged), Buffer.from("tampered")])); + expect(() => verifyReleaseManifest(manifest, root)).toThrow("Size mismatch"); + }); +}); diff --git a/nodejs/test/release-workflows.test.ts b/nodejs/test/release-workflows.test.ts new file mode 100644 index 0000000000..74a9d1ee48 --- /dev/null +++ b/nodejs/test/release-workflows.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = join(import.meta.dirname, "..", ".."); +const workflow = (name: string) => + readFileSync(join(repositoryRoot, ".github", "workflows", name), "utf8"); +const publish = workflow("publish.yml"); +const runtimeSdk = workflow("runtime-sdk.yml"); +const shared = workflow("runtime-backed-node-release.yml"); + +describe("normal publishing workflow contract", () => { + it("remains the stable and prerelease entry without runtime handoff inputs", () => { + expect(publish).toContain("- latest"); + expect(publish).toContain("- prerelease"); + expect(publish).not.toContain("- unstable"); + expect(publish).not.toContain("runtime_version:"); + expect(publish).not.toContain("runtime_run_id:"); + expect(publish).not.toContain("resume_run_id:"); + expect(publish).not.toContain("runtime-backed-node-release.yml"); + expect(publish).toContain("publish.yml only accepts latest or prerelease"); + expect(publish).toMatch(/- name: Validate release channel\s+working-directory: \.\s+env:/); + expect(publish).toContain( + "prerelease namespace is reserved for runtime-driven SDK releases" + ); + expect(publish).toContain("canary|unstable"); + }); + + it("retains all normal SDK publication paths", () => { + for (const job of [ + "publish-nodejs:", + "publish-dotnet:", + "publish-rust:", + "publish-python:", + "publish-java:", + "github-release:", + ]) { + expect(publish).toContain(job); + } + }); +}); + +describe("runtime-driven Node SDK entry contract", () => { + it("owns both strict runtime handoff matrices", () => { + expect(runtimeSdk).toContain("name: Runtime-driven Node SDK"); + expect(runtimeSdk).toContain("canary:azure:tests-only"); + expect(runtimeSdk).toContain("canary:azure:internal"); + expect(runtimeSdk).toContain("unstable:github-packages:internal"); + expect(runtimeSdk).toContain("runtime_run_id:"); + expect(runtimeSdk).toContain("runtime_source:"); + }); + + it("serializes and durably claims each runtime run", () => { + expect(runtimeSdk).toContain("group: sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); + expect(runtimeSdk).toContain("cancel-in-progress: false"); + expect(runtimeSdk).toContain("sdk-runtime-dispatch-${{ inputs.runtime_run_id }}"); + expect(runtimeSdk).toContain("More than one unexpired"); + expect(runtimeSdk).toContain("for ATTEMPT in 1 2 3 4 5 6"); + expect(runtimeSdk).toContain("actions/workflows/runtime-sdk.yml/runs"); + expect(runtimeSdk).toContain('if [ "$EARLIER" -eq 0 ]; then'); + expect(runtimeSdk).not.toContain('GITHUB_RUN_ATTEMPT" -gt 1'); + expect(runtimeSdk).toContain("runtime-dispatch-ledger.ts validate"); + expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); + expect(runtimeSdk).toContain("retention-days: 90"); + expect(runtimeSdk).not.toContain("resume_run_id"); + }); + + it("delegates preparation before its separately serialized public publication", () => { + expect(runtimeSdk).toContain("uses: ./.github/workflows/runtime-backed-node-release.yml"); + expect(runtimeSdk).toContain("scripts/unstable-version.ts"); + expect(runtimeSdk).toContain("group: sdk-runtime-public-unstable"); + expect(runtimeSdk.indexOf("runtime-backed-release:")).toBeLessThan( + runtimeSdk.indexOf("publish-public:") + ); + expect(runtimeSdk).toContain("dist/release-manifest.json dist unstable"); + }); + + it("requires duplicates and failures to use the canonical workflow run", () => { + expect(runtimeSdk).toContain('gh run watch "$CANONICAL_RUN_ID" --exit-status'); + expect(runtimeSdk).toContain("Re-run that original run"); + expect(runtimeSdk).not.toContain("run-id:"); + }); +}); + +describe("shared runtime-backed Node pipeline", () => { + it("enforces the channel, source, and mode matrix again", () => { + expect(shared).toContain("canary:azure:tests-only"); + expect(shared).toContain("canary:azure:internal"); + expect(shared).toContain("unstable:github-packages:internal"); + expect(shared).not.toContain("registry.npmjs.org"); + }); + + it("owns acquisition, cross-platform tests, packaging, and internal verification", () => { + expect(shared).toContain("os: [ubuntu-latest, macos-latest, windows-latest]"); + expect(shared).toContain("npm run acquire:runtime-packages"); + expect(shared).toContain("npm run verify:release-packages"); + expect(shared).toContain("publish-manifest"); + expect(shared).toContain("group: sdk-runtime-internal-${{ inputs.channel }}"); + expect(shared).not.toContain('"$runtime_path" --version'); + expect(shared).not.toContain('"$RUNTIME" --version'); + expect(shared).not.toContain("resume_run_id"); + expect(shared).toContain("const parsed = semver.parse(process.argv[1])"); + expect(shared).toContain("parsed.major}.${parsed.minor}.${parsed.patch"); + expect(shared).not.toContain('BASE="${PUBLIC_LATEST%%-*}"'); + expect(shared.indexOf("npm run verify:release-packages")).toBeLessThan( + shared.indexOf("publish-manifest") + ); + }); +}); diff --git a/nodejs/test/runtime-dispatch-ledger.test.ts b/nodejs/test/runtime-dispatch-ledger.test.ts new file mode 100644 index 0000000000..c26357e445 --- /dev/null +++ b/nodejs/test/runtime-dispatch-ledger.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + createRuntimeDispatchMarker, + type ExpectedDispatch, + validateRuntimeDispatchMarker, +} from "../scripts/runtime-dispatch-ledger.js"; + +const expected: ExpectedDispatch = { + channel: "unstable", + currentRunId: "200", + mode: "internal", + runtimeRunId: "100", + runtimeSha: "a".repeat(40), + runtimeSource: "github-packages", + runtimeVersion: "1.2.3-unstable.4", + sdkRef: "refs/heads/main", + sdkSha: "b".repeat(40), + versionOverride: "", +}; + +function provenance(canonicalRunId: string) { + return { + artifact: { expired: false, workflow_run: { id: Number(canonicalRunId) } }, + run: { + event: "workflow_dispatch", + head_branch: "main", + head_sha: expected.sdkSha, + id: Number(canonicalRunId), + name: "Runtime-driven Node SDK", + path: ".github/workflows/runtime-sdk.yml", + repository: { full_name: "github/copilot-sdk" }, + }, + }; +} + +describe("runtime dispatch ledger", () => { + it("creates a canonical marker without adding the runtime run to release identity", () => { + const marker = createRuntimeDispatchMarker(expected); + expect(marker.canonicalRunId).toBe("200"); + expect(marker.runtime.runId).toBe("100"); + expect(marker).not.toHaveProperty("sdk.version"); + }); + + it("retains ownership for a rerun of the canonical workflow run", () => { + const marker = createRuntimeDispatchMarker(expected); + const api = provenance("200"); + expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( + "owner" + ); + }); + + it("recognizes an exact duplicate", () => { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + expect(validateRuntimeDispatchMarker(marker, api.artifact, api.run, expected)).toBe( + "duplicate" + ); + }); + + it("rejects marker tuple collisions and forged API provenance", () => { + const marker = createRuntimeDispatchMarker({ ...expected, currentRunId: "199" }); + const api = provenance("199"); + expect(() => + validateRuntimeDispatchMarker(marker, api.artifact, api.run, { + ...expected, + runtimeSha: "c".repeat(40), + }) + ).toThrow(/already claimed/); + expect(() => + validateRuntimeDispatchMarker( + marker, + { ...api.artifact, workflow_run: { id: 198 } }, + api.run, + expected + ) + ).toThrow(/Artifact workflow run ID/); + expect(() => + validateRuntimeDispatchMarker( + marker, + api.artifact, + { ...api.run, path: ".github/workflows/publish.yml" }, + expected + ) + ).toThrow(); + }); +}); diff --git a/nodejs/test/runtime-package-acquisition.test.ts b/nodejs/test/runtime-package-acquisition.test.ts new file mode 100644 index 0000000000..d2a08a8f49 --- /dev/null +++ b/nodejs/test/runtime-package-acquisition.test.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { c as createTar } from "tar"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + acquireRuntimePackages, + getSourceRuntimePackageName, + validateRuntimePackageRoot, +} from "../scripts/runtime-package-acquisition.js"; +import { RUNTIME_PLATFORMS } from "../src/runtimeArtifacts.js"; + +const roots: string[] = []; +const runtimeVersion = "1.0.83-5.unstable.123.gabcdef0"; +const runtimeSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function temporaryRoot(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function createRuntimePackage(root: string, platform: string): Promise { + const packageRoot = join(root, platform, "package"); + const windows = platform.startsWith("win32"); + const [osName, cpu] = platform.replace("linuxmusl", "linux").split("-"); + mkdirSync(join(packageRoot, "prebuilds", platform), { recursive: true }); + mkdirSync(join(packageRoot, "copilot-sdk"), { recursive: true }); + mkdirSync(join(packageRoot, "preloads"), { recursive: true }); + mkdirSync(join(packageRoot, "sdk"), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ + name: getSourceRuntimePackageName(platform), + version: runtimeVersion, + repository: "https://github.com/github/copilot-agent-runtime.git", + os: [osName], + cpu: [cpu], + ...(platform.startsWith("linux") + ? { libc: [platform.startsWith("linuxmusl") ? "musl" : "glibc"] } + : {}), + copilotRuntime: { + sourceRepository: "github/copilot-agent-runtime", + sourceSha: runtimeSha, + }, + }) + ); + for (const path of [ + "LICENSE.md", + windows ? "copilot.exe" : "copilot", + join("prebuilds", platform, windows ? "copilot-runtime.exe" : "copilot-runtime"), + join("prebuilds", platform, "runtime.node"), + join("copilot-sdk", "extension.js"), + join("preloads", "extension_bootstrap.mjs"), + join("sdk", "index.js"), + ]) { + writeFileSync(join(packageRoot, path), path); + } + const archive = join(root, `${platform}.tgz`); + await createTar({ cwd: join(root, platform), file: archive, gzip: true }, ["package"]); + return archive; +} + +describe("runtime npm package acquisition", () => { + it("downloads and validates all eight exact runtime platform packages", async () => { + const root = temporaryRoot("copilot-runtime-acquisition-"); + const output = join(root, "output"); + const archives = new Map(); + for (const platform of RUNTIME_PLATFORMS) { + const path = await createRuntimePackage(root, platform); + archives.set(platform, { + path, + integrity: `sha512-${createHash("sha512") + .update(readFileSync(path)) + .digest("base64")}`, + }); + } + const runner = vi.fn(async (_command: string, args: string[]) => { + const spec = args[1]; + const platform = RUNTIME_PLATFORMS.find((candidate) => + spec.startsWith(`${getSourceRuntimePackageName(candidate)}@`) + ); + expect(platform).toBeDefined(); + const archive = archives.get(platform!)!; + if (args[0] === "view") { + return { status: 0, stdout: JSON.stringify(archive.integrity), stderr: "" }; + } + const destination = args[args.indexOf("--pack-destination") + 1]; + const filename = basename(archive.path); + mkdirSync(destination, { recursive: true }); + copyFileSync(archive.path, join(destination, filename)); + return { + status: 0, + stdout: JSON.stringify([{ filename, integrity: archive.integrity }]), + stderr: "", + }; + }); + + await acquireRuntimePackages( + { + outputDirectory: output, + registry: "https://npm.pkg.github.com", + runtimeSha, + runtimeVersion, + }, + runner + ); + + expect(runner).toHaveBeenCalledTimes(16); + const acquisition = JSON.parse(readFileSync(join(output, "runtime-packages.json"), "utf8")); + expect(acquisition.packages).toHaveLength(8); + for (const platform of RUNTIME_PLATFORMS) { + validateRuntimePackageRoot( + join(output, platform), + platform, + runtimeVersion, + runtimeSha + ); + } + }); + + it("rejects mismatched source identity metadata", async () => { + const root = temporaryRoot("copilot-runtime-identity-"); + await createRuntimePackage(root, "linux-x64"); + const packageRoot = join(root, "linux-x64", "package"); + const manifestPath = join(packageRoot, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.copilotRuntime.sourceSha = "0".repeat(40); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + expect(() => + validateRuntimePackageRoot(packageRoot, "linux-x64", runtimeVersion, runtimeSha) + ).toThrow(); + }); +}); diff --git a/nodejs/test/runtimeArtifacts.test.ts b/nodejs/test/runtimeArtifacts.test.ts index ffd22f21ef..d6882df23f 100644 --- a/nodejs/test/runtimeArtifacts.test.ts +++ b/nodejs/test/runtimeArtifacts.test.ts @@ -76,12 +76,34 @@ describe("release runtime selection", () => { expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8"))).toMatchObject({ copilotCliVersion: "9.9.9-canary.test", }); + expect(existsSync(join(root, "copilot-cli.json"))).toBe(false); expect(readFileSync(join(root, "src", "cliVersion.ts"), "utf8")).toContain( "COPILOT_CLI_USE_NPM_PACKAGE = true" ); }); + it("can pin a pre-acquired package while preserving embedded runtime packaging", () => { + const root = mkdtempSync(join(tmpdir(), "copilot-local-package-version-")); + mkdirSync(join(root, "scripts"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}\n"); + writeFileSync( + join(root, "scripts", "set-cli-version.js"), + readFileSync(join(import.meta.dirname, "../scripts/set-cli-version.js")) + ); + + const result = spawnSync( + process.execPath, + [join(root, "scripts", "set-cli-version.js"), "9.9.9-unstable.test", "--local-package"], + { encoding: "utf8" } + ); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(join(root, "src", "cliVersion.ts"), "utf8")).toContain( + "COPILOT_CLI_USE_NPM_PACKAGE = false" + ); + }); + it.each([ ["darwin", "arm64", false, "darwin-arm64"], ["darwin", "x64", false, "darwin-x64"], @@ -241,6 +263,28 @@ describe("ensureRuntimeBundle", () => { }); describe("release package acquisition", () => { + it("uses a pre-acquired runtime package directory without network access", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-runtime-packages-")); + const platform = "linux-x64"; + const packageRoot = join(root, platform); + const prebuilds = join(packageRoot, "prebuilds", platform); + mkdirSync(prebuilds, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), "{}"); + writeFileSync(join(prebuilds, "runtime.node"), "runtime"); + const fetcher = vi.fn(() => { + throw new Error("local runtime package resolution must not fetch"); + }); + + await expect( + ensureCopilotPackage("1.2.3-unstable.1", { + fetch: fetcher, + packageDirectory: root, + platform, + }) + ).resolves.toBe(packageRoot); + expect(fetcher).not.toHaveBeenCalled(); + }); + it("downloads, verifies, and caches a release package for packaging", async () => { const sourceRoot = mkdtempSync(join(tmpdir(), "copilot-release-source-")); const packageRoot = join(sourceRoot, "package"); diff --git a/nodejs/test/unstable-version.test.ts b/nodejs/test/unstable-version.test.ts new file mode 100644 index 0000000000..d23f963c4a --- /dev/null +++ b/nodejs/test/unstable-version.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { calculateUnstableVersion, targetCoreFromBaseline } from "../scripts/unstable-version.js"; + +const sha = "abcdef0123456789abcdef0123456789abcdef01"; +const release = (tag_name: string, published_at = "2026-09-01T00:00:00Z") => ({ + tag_name, + published_at, +}); + +describe("unstable SDK version planning", () => { + it("increments a stable baseline patch", () => { + expect(targetCoreFromBaseline("1.0.11")).toBe("1.0.12"); + }); + + it("uses a prerelease baseline's release core", () => { + expect(targetCoreFromBaseline("1.0.13-preview.4")).toBe("1.0.13"); + }); + + it("selects the nearest eligible release on first-parent history", () => { + expect( + calculateUnstableVersion({ + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.13-preview.4", "v1.0.12", "v1.0.11"], + releases: [ + release("v1.0.13-preview.4"), + release("v1.0.12", "2026-09-05T00:00:00Z"), + release("v1.0.11"), + ], + runNumber: "8123", + sdkSha: sha, + }) + ).toBe("1.0.13-unstable.8123.gabcdef0"); + }); + + it("is stable across retries and unique across new workflow runs", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: ["v1.0.11"], + releases: [release("v1.0.11")], + runNumber: "8123", + sdkSha: sha, + }; + expect(calculateUnstableVersion(options)).toBe(calculateUnstableVersion(options)); + expect(calculateUnstableVersion({ ...options, runNumber: "8124" })).not.toBe( + calculateUnstableVersion(options) + ); + }); + + it("accepts only explicit unstable SemVer overrides", () => { + const options = { + createdAt: "2026-09-04T00:00:00Z", + firstParentTags: [], + releases: [], + runNumber: "8123", + sdkSha: sha, + }; + expect( + calculateUnstableVersion({ + ...options, + versionOverride: "2.0.0-unstable.manual.1", + }) + ).toBe("2.0.0-unstable.manual.1"); + expect(() => + calculateUnstableVersion({ ...options, versionOverride: "2.0.0-preview.1" }) + ).toThrow("unstable prerelease"); + }); +});