Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
454 changes: 443 additions & 11 deletions .github/workflows/publish.yml

Large diffs are not rendered by default.

428 changes: 0 additions & 428 deletions .github/workflows/sdk-canary.yml

This file was deleted.

4 changes: 3 additions & 1 deletion docs/developer-docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This document covers secrets management for the github/copilot-sdk repository. I
These secrets are used by the per-language SDK test workflows and the canary workflow.

* **`COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY`**: HMAC key used to authenticate with the Copilot Developer CLI integration endpoint during tests. Injected as `COPILOT_HMAC_KEY` in test environments.
* Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `sdk-canary.yml`
* Workflows: `nodejs-sdk-tests.yml`, `python-sdk-tests.yml`, `go-sdk-tests.yml`, `dotnet-sdk-tests.yml`, `rust-sdk-tests.yml`, `publish.yml`

## Agentic workflow secrets

Expand Down Expand Up @@ -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

Expand Down
139 changes: 139 additions & 0 deletions docs/developer-docs/unstable-releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Canary and unstable Node SDK releases

Canary releases remain an internal runtime-to-SDK channel. Unstable Node SDK
releases can either publish the selected SDK branch with its existing bundled
runtime or package exact runtime inputs supplied by `github/copilot-agent-runtime`.
All production release jobs run in `.github/workflows/publish.yml`.

## Dispatch inputs

The workflow has one input surface for maintainer and automation dispatches:

* `dist-tag`: Required release channel: `latest`, `prerelease`, `unstable`, or
`canary`. The default is `prerelease`.
* `version`: Optional direct SDK version. For direct unstable releases, this is
an unstable SemVer base before workflow identity is added.
* `mode`: Required execution mode: `publish` or `dry-run`. The default is
`publish`.
* `runtime`: Optional automation-only JSON object with exactly the string fields
`version`, `sha`, and `run_id`.

The runtime workflow dispatches `publish.yml` at an explicit SDK ref. For
example:

```json
{"version":"1.0.83-5.unstable.123.gabcdef0","sha":"abcdef0123456789abcdef0123456789abcdef01","run_id":"34640000001"}
```

Runtime JSON is valid only for canary and unstable releases. Canary requires
runtime JSON; direct canary releases are rejected. The workflow also rejects a
direct `version` combined with runtime JSON. Runtime JSON must contain an exact
SemVer for the selected channel, a lowercase 40-character
`github/copilot-agent-runtime` SHA, and a positive canonical decimal workflow
run ID.

Dry-run mode is valid only for canary and unstable releases. Stable and
prerelease dry-runs are rejected because the existing Java release path does
not have a non-mutating build-only mode.

For a direct unstable release, select `dist-tag: unstable`, leave `runtime`
empty, and optionally provide `version`. The workflow packages the selected SDK
branch with its selected or bundled runtime. Direct unstable releases can run
from a non-main branch. They do not publish .NET, Rust, Python, Java, or Go
releases, and they do not create an SDK GitHub Release. The same workflow
remains the normal stable and prerelease publisher for all SDK languages.

## Release gates

Runtime-initiated releases acquire all eight
`@github/copilot-<platform>` packages from GitHub Packages with the job-scoped
`GITHUB_TOKEN`. The workflow validates npm integrity, runtime version and SHA
metadata, the exact package set, platform metadata, repository metadata, and
required runtime files.

The runtime jobs run runtime-backed Node SDK tests on Ubuntu, macOS, and
Windows. They then build and verify eight self-contained
`@github/copilot-sdk-<platform>` packages and the `@github/copilot-sdk` umbrella
package. The checked-in `COPILOT_CLI_USE_NPM_PACKAGE` value remains `false`;
runtime npm packages are build inputs rather than published dependencies.

Both unstable dispatch modes use the same version planner. A generated version
is `<target-core>-unstable.<workflow-run-id>.g<sdk-sha-7>`, where the target
core comes from the nearest eligible SDK release on the selected branch's
first-parent history. A stable baseline increments the patch; a prerelease
baseline retains its release core. A direct release with an explicit unstable
base uses `<explicit-unstable-base>.<workflow-run-id>.g<sdk-sha-7>`. GitHub
workflow run IDs are repository-wide, so the two modes cannot collide when
their per-workflow run numbers happen to match. Release eligibility is frozen
at workflow creation time, so a same-run retry keeps its identity and each new
dispatch receives a new version.

Canary versions use
`X.Y.(Z+1)-canary.<workflow-run-number>.g<sdk-sha-7>`, based on the newest
stable SDK release published before workflow creation.

The runtime packaging job writes all nine tarballs and
`release-manifest.json` to one retained artifact. Publication jobs use that
artifact without rebuilding or recalculating its identity.

## Publication order

Canary and runtime-backed unstable `dry-run` runs stop after package and local
manifest verification. Direct unstable `dry-run` runs build, pack, and verify
the same nine-package set without registry mutations. Dry-runs do not acquire
publication concurrency locks.

Canary `publish` runs publish platform packages before the umbrella package to
the Azure `copilot-canary` feed, then perform a clean install and package
version check. No canary job has a public npm publication path.

Direct unstable runs publish the platform packages and umbrella package to
public npm first, then mirror the same Node package set to Azure.

Runtime-initiated unstable runs publish the retained platform tarballs and
umbrella tarball to Azure first. A clean internal install must start the exact
selected SDK package version before public publication begins. The public job
runs directly in `publish.yml` so npm trusted publishing sees the configured
workflow identity. It publishes the same tarballs under the `unstable`
dist-tag, with the umbrella package last.

The two dispatch modes share concurrency locks for public npm and internal
Azure publication so they cannot race either set of `unstable` tags.

Both unstable modes validate all nine retained tarballs against local SHA-512
manifest values before publication. A successful `npm publish` completes a
package publication. A recognized immutable-version conflict means the package
was already published and also completes that package publication; output
feeds do not need to expose `dist.integrity`. Azure authentication allows the
workflow to add or advance its tag, but it refuses to rewind a tag that points
to a newer version. Public npm trusted publishing sets `unstable` during
publication. The workflow then verifies all nine `@unstable` resolutions. It
fails rather than attempting a separate public dist-tag mutation if any
resolution differs.

## Recovery

Use **Re-run failed jobs** on the original workflow run for normal recovery.
The workflow run ID and frozen version remain unchanged. Runtime-initiated runs
also retain the package artifact. Do not rerun a successful packaging job
merely to recover a publication job.

The runtime run ID is retained as provenance only. Re-running the same SDK
workflow run retries its frozen SDK version and retained artifact. A new
workflow dispatch creates a new SDK release identity and version, even when it
uses the same runtime run, version, and SHA.

## Registry setup

The Azure `copilot-canary` feed continues to use the `cicd` environment and
Azure workload identity. GitHub Packages acquisition uses the workflow
`GITHUB_TOKEN` with `packages: read`.

Before enabling runtime dispatch, publish the eight signed runtime package
coordinates to GitHub Packages and confirm that this repository can read all
eight with its workflow token.

Confirm npm trusted publisher configuration authorizes
`.github/workflows/publish.yml` for `@github/copilot-sdk` and all eight
`@github/copilot-sdk-<platform>` package names. This workflow publishes stable,
prerelease, direct unstable, and runtime-initiated unstable versions.
4 changes: 4 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
"auth:refresh": "node ../scripts/npm-auth-refresh.mjs --run",
"clean": "rimraf --glob dist *.tgz",
"build": "tsx esbuild-copilotsdk-nodejs.ts",
"acquire:runtime-packages": "tsx scripts/runtime-package-acquisition.ts",
"pack:release": "tsx scripts/package-sdk.ts",
"verify:release-packages": "tsx scripts/verify-release-packages.ts",
"release:manifest": "tsx scripts/release-manifest.ts",
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
129 changes: 108 additions & 21 deletions nodejs/scripts/npm-release.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

import { verifyPackageSetManifestFiles } from "./package-set-manifest.js";
const PUBLIC_CONFLICT =
/^(?:npm (?:error|ERR!) code EPUBLISHCONFLICT|npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:You )?cannot publish over (?:the )?previously published versions(?:: [^\r\n]+)?\.?)\r?$/im;
const AZURE_CONFLICT =
/^npm (?:error|ERR!) (?:403 [^\r\n]* - )?(?:The feed '[^'\r\n]+' )?already contains file '[^'\r\n]+\.tgz' in package '[^'\r\n]+'\.?\r?$/im;

export function runCommand(command, args, { stream = false } = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolveResult, reject) => {
const child = spawn(command, args, { shell: false });
let stdout = "";
let stderr = "";
Expand All @@ -21,11 +23,22 @@ export function runCommand(command, args, { stream = false } = {}) {
if (stream) process.stderr.write(chunk);
});
child.on("error", reject);
child.on("close", (status) => resolve({ status: status ?? 1, stdout, stderr }));
child.on("close", (status) => resolveResult({ status: status ?? 1, stdout, stderr }));
});
}

export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) {
function parseNpmJson(result) {
for (const output of [result.stdout, result.stderr]) {
try {
return JSON.parse(output);
} catch {
// The caller reports the complete npm output if neither stream is JSON.
}
}
return undefined;
}

export async function getRegistryVersion(packageName, version, registry, runner = runCommand) {
const result = await runner("npm", [
"view",
`${packageName}@${version}`,
Expand All @@ -34,52 +47,126 @@ export async function assertVersionAbsent(packageName, version, registry, runner
"--registry",
registry,
]);

if (result.status === 0) {
throw new Error(`${packageName}@${version} already exists on public npm.`);
const parsed = parseNpmJson(result);
if (result.status === 0 && typeof parsed === "string") {
return parsed;
}

try {
if (JSON.parse(result.stdout)?.error?.code === "E404") return;
} catch {
// The failure below includes npm's output for diagnosis.
if (result.status !== 0 && parsed?.error?.code === "E404") {
return undefined;
}

const output = `${result.stdout}\n${result.stderr}`.trim();
throw new Error(
`Could not confirm that ${packageName}@${version} is absent from public npm (npm exited ${result.status}).${output ? `\n${output}` : ""}`
`Could not read ${packageName}@${version} from ${registry} (npm exited ${result.status}).${output ? `\n${output}` : ""}`
);
}

export async function publishTarball(tarball, tag, registry, mode, runner = runCommand) {
export async function assertVersionAbsent(packageName, version, registry, runner = runCommand) {
const existing = await getRegistryVersion(packageName, version, registry, runner);
if (existing !== undefined) {
throw new Error(`${packageName}@${version} already exists on ${registry}.`);
}
}

export async function publishTarball(tarball, tag, registry, mode, runner = runCommand, identity) {
const args = ["publish", tarball, "--tag", tag, "--registry", registry];
if (mode === "public") args.push("--access", "public");
if (mode !== "public" && mode !== "azure") throw new Error(`Unknown publish mode: ${mode}`);

const result = await runner("npm", args, { stream: true });
if (result.status === 0) return;
if (result.status === 0) {
return;
}

const output = `${result.stdout}\n${result.stderr}`;
if (PUBLIC_CONFLICT.test(output) || (mode === "azure" && AZURE_CONFLICT.test(output))) {
console.log(
"Version already published; treating the immutable-version conflict as success."
);
const subject =
identity?.name && identity?.version
? `${identity.name}@${identity.version}`
: "Version";
console.log(`${subject} is already published; treating the conflict as success.`);
return;
}

throw new Error(`npm publish failed with exit code ${result.status}.`);
}

function readReleaseManifest(manifestPath, packageDirectory) {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
verifyPackageSetManifestFiles(manifest, packageDirectory);
return manifest;
}

export async function publishManifest(
manifestPath,
packageDirectory,
tag,
registry,
mode,
runner = runCommand
) {
const manifest = readReleaseManifest(manifestPath, packageDirectory);
const packages = manifest.packages
.map((packed) => ({
...packed,
version: manifest.sdk.version,
tarball: resolve(packageDirectory, packed.filename),
}))
.sort((left, right) => {
if (left.name === "@github/copilot-sdk") return 1;
if (right.name === "@github/copilot-sdk") return -1;
return left.name.localeCompare(right.name);
});

const semver = await import("semver");
for (const packed of packages) {
const taggedVersion = await getRegistryVersion(packed.name, tag, registry, runner);
if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) {
throw new Error(
`${packed.name}@${tag} already points to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.`
);
}
}
for (const packed of packages) {
await publishTarball(packed.tarball, tag, registry, mode, runner, packed);
}
for (const packed of packages) {
const taggedVersion = await getRegistryVersion(packed.name, tag, registry, runner);
if (taggedVersion === packed.version) {
continue;
}
if (mode === "public") {
throw new Error(
`${packed.name}@${tag} resolves to ${taggedVersion ?? "no version"}, expected ${packed.version}. Public trusted publishing cannot repair dist-tags.`
);
}
if (taggedVersion !== undefined && semver.gt(taggedVersion, packed.version)) {
throw new Error(
`${packed.name}@${tag} advanced to newer version ${taggedVersion}; refusing to rewind it to ${packed.version}.`
);
}
const result = await runner(
"npm",
["dist-tag", "add", `${packed.name}@${packed.version}`, tag, "--registry", registry],
{ stream: true }
);
if (result.status !== 0) {
throw new Error(`Failed to set ${packed.name}@${packed.version} dist-tag ${tag}.`);
}
}
}

async function main() {
const [command, ...args] = process.argv.slice(2);
if (command === "preflight" && args.length === 3) {
await assertVersionAbsent(...args);
console.log(`${args[0]}@${args[1]} is available on public npm.`);
console.log(`${args[0]}@${args[1]} is available on ${args[2]}.`);
} else if (command === "publish" && args.length === 4) {
await publishTarball(...args);
} else if (command === "publish-manifest" && args.length === 5) {
await publishManifest(...args);
} else {
throw new Error(
"Usage: npm-release.js preflight <package> <version> <registry> | publish <tarball> <tag> <registry> <public|azure>"
"Usage: npm-release.js preflight <package> <version> <registry> | publish <tarball> <tag> <registry> <public|azure> | publish-manifest <manifest> <package-directory> <tag> <registry> <public|azure>"
);
}
}
Expand Down
Loading
Loading