chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Red-green test for detect-py-version-changes.sh using a local fixture HTTP
# server. No network access. Requires python3 >= 3.11 (tomllib).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SCRIPT="${HERE}/../detect-py-version-changes.sh"
TMP="$(mktemp -d)"
SRV_PID=""
STDERR_LOG="$TMP/stderr.log"
SRV_LOG="$TMP/server.log"
cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true; rm -rf "$TMP"; }
trap cleanup EXIT INT TERM
# Preflight: tomllib requires py3.11+
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' \
|| { echo "SKIP/FAIL: need python3 >= 3.11 for tomllib"; exit 1; }
# Fake pyproject with local version 0.2.0
mkdir -p "$TMP/pkg"
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
PORTFILE="$TMP/port"
WWW="$TMP/www"
# Start a fixture server that serves $WWW and writes its bound port to PORTFILE.
# FAIL_500_PATH (optional): when set, requests with that exact path return HTTP 500
# instead of the default SimpleHTTPRequestHandler behavior. Used by Case I (5xx).
start_server() {
rm -f "$PORTFILE" "$SRV_LOG"
PORTFILE="$PORTFILE" WWW="$WWW" FAIL_500_PATH="${FAIL_500_PATH:-}" python3 - 2>"$SRV_LOG" <<'PY' &
import os, http.server, socketserver, functools
www = os.environ["WWW"]
os.makedirs(www, exist_ok=True)
fail_500_path = os.environ.get("FAIL_500_PATH", "")
class H(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=www, **kw)
def do_GET(self):
if fail_500_path and self.path == fail_500_path:
self.send_error(500, "fixture: forced 500")
return
super().do_GET()
httpd = socketserver.TCPServer(("127.0.0.1", 0), H)
with open(os.environ["PORTFILE"], "w") as f:
f.write(str(httpd.server_address[1]))
httpd.serve_forever()
PY
SRV_PID=$!
for _ in $(seq 1 50); do [ -s "$PORTFILE" ] && break; sleep 0.1; done
# Liveness check: even if PORTFILE never landed, surface the server's stderr
# so a crashed fixture python process produces a real error instead of a
# vague timeout.
if ! kill -0 "$SRV_PID" 2>/dev/null; then
echo "FAIL: fixture server process died before binding" >&2
if [ -s "$SRV_LOG" ]; then
echo "--- captured server stderr ---" >&2
cat "$SRV_LOG" >&2
echo "--- end server stderr ---" >&2
fi
exit 1
fi
if [ ! -s "$PORTFILE" ]; then
echo "FAIL: fixture server failed to bind/write PORTFILE within 5s" >&2
if [ -s "$SRV_LOG" ]; then
echo "--- captured server stderr ---" >&2
cat "$SRV_LOG" >&2
echo "--- end server stderr ---" >&2
fi
exit 1
fi
PORT="$(cat "$PORTFILE")"
}
stop_server() { kill "$SRV_PID" 2>/dev/null || true; wait "$SRV_PID" 2>/dev/null || true; SRV_PID=""; unset FAIL_500_PATH; }
serve_published() {
# Realistic PyPI-shaped response: info.version AND a releases dict (single
# released version). Real PyPI always returns `releases`; the bare-info shape
# was a test-only shortcut that the max-over-releases logic doesn't match.
# File list contains one non-yanked dict so the script's yanked filter (which
# excludes empty/all-yanked file lists) still counts this as a live release.
mkdir -p "$WWW/pypi/copilotkit"
printf '{"info":{"version":"%s"},"releases":{"%s":[{"yanked":false}]}}' "$1" "$1" > "$WWW/pypi/copilotkit/json"
}
# Serve a fuller PyPI-shaped response: info.version + a releases dict whose keys
# are the version strings. $1 = info.version, remaining args = release keys.
# Each release key gets a single non-yanked file entry so it counts as live.
serve_published_with_releases() {
mkdir -p "$WWW/pypi/copilotkit"
local info="$1"; shift
local rels="" k
for k in "$@"; do
[ -z "$rels" ] && rels="\"$k\":[{\"yanked\":false}]" || rels="$rels,\"$k\":[{\"yanked\":false}]"
done
printf '{"info":{"version":"%s"},"releases":{%s}}' "$info" "$rels" > "$WWW/pypi/copilotkit/json"
}
# Serve a custom raw JSON body at /pypi/copilotkit/json. Used by Cases G and H
# to inject yanked file lists or omit the `releases` key entirely.
serve_raw_json() {
mkdir -p "$WWW/pypi/copilotkit"
printf '%s' "$1" > "$WWW/pypi/copilotkit/json"
}
run() {
PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1
}
# Run and assert non-zero exit. Captures the script's exit status BEFORE the
# pipeline (a `| tail` rhs would mask the lhs exit code). Returns 0 if the
# script failed as expected, non-zero otherwise. PYPROJECT path overridable
# via $1.
run_expect_fail() {
local pyproj="${1:-$TMP/pkg/pyproject.toml}"
set +e
PYPROJECT_PATH="$pyproj" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" >"$TMP/stdout.log" 2>"$STDERR_LOG"
local ec=$?
set -e
if [ "$ec" -eq 0 ]; then return 1; fi
return 0
}
fail() {
echo "FAIL: $1" >&2
if [ -s "$STDERR_LOG" ]; then
echo "--- captured stderr ---" >&2
cat "$STDERR_LOG" >&2
echo "--- end stderr ---" >&2
fi
exit 1
}
# Case A: published == local (0.2.0) -> should_publish=false (no-op)
# Also assert GITHUB_OUTPUT emission: the script must append should_publish=,
# name=, and version= lines when GITHUB_OUTPUT is set.
rm -rf "$WWW"; serve_published "0.2.0"; start_server
GHO="$TMP/gho.txt"; : > "$GHO"
OUT="$(GITHUB_OUTPUT="$GHO" PYPROJECT_PATH="$TMP/pkg/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
echo "A: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "no-op: got '$OUT'"
grep -Fxq 'should_publish=false' "$GHO" || fail "GITHUB_OUTPUT missing should_publish=false (got: $(cat "$GHO"))"
grep -Fxq 'name=copilotkit' "$GHO" || fail "GITHUB_OUTPUT missing name=copilotkit (got: $(cat "$GHO"))"
grep -Fxq 'version=0.2.0' "$GHO" || fail "GITHUB_OUTPUT missing version=0.2.0 (got: $(cat "$GHO"))"
stop_server
# Case B: published < local (0.1.91 < 0.2.0) -> should_publish=true (exactly one pkg)
rm -rf "$WWW"; serve_published "0.1.91"; start_server
OUT="$(run)"; echo "B: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "bump: got '$OUT'"
stop_server
# Case C: package missing (404) -> should_publish=true (NEW)
rm -rf "$WWW"; mkdir -p "$WWW"; start_server
OUT="$(run)"; echo "C: $OUT"; [ "$OUT" = "true copilotkit 0.2.0" ] || fail "new-pkg: got '$OUT'"
stop_server
# Case D: info.version is LOWER than the true max in releases. PyPI's info.version
# is the LATEST-UPLOADED, not the highest — an out-of-order patch upload to an
# old line can produce this state. The script must compute the max over the
# numeric-parseable releases keys, not trust info.version.
# releases = {0.1.0, 0.2.0}, info.version=0.1.0, local=0.2.0 -> 0.2.0==0.2.0 -> false.
# Explicitly (re)write pyproject so this case doesn't implicitly depend on
# Case A's setup persisting through the prior cases.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; serve_published_with_releases "0.1.0" "0.1.0" "0.2.0"; start_server
OUT="$(run)"; echo "D: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "max-over-releases: got '$OUT'"
stop_server
# Case E: releases contains a non-numeric prerelease key alongside numeric. The
# script must ignore non-numeric published keys (not abort on them) and compare
# against the numeric max. info.version is the prerelease (rc1); local is 0.2.1.
# numeric max published = 0.2.0 < 0.2.1 -> should_publish=true.
rm -rf "$WWW"
mkdir -p "$TMP/pkg2"
cat > "$TMP/pkg2/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.1"
TOML
serve_published_with_releases "0.2.1rc1" "0.2.0" "0.2.1rc1"; start_server
OUT="$(PYPROJECT_PATH="$TMP/pkg2/pyproject.toml" PYPI_BASE_URL="http://127.0.0.1:${PORT}" \
"$SCRIPT" 2>"$STDERR_LOG" | tail -n1)"
echo "E: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "non-numeric-released-ignored: got '$OUT'"
stop_server
# Case F (zero-pad): published has only key "0.2" (live); local pyproject "0.2.0".
# PEP 440 treats 0.2 == 0.2.0, so should_publish must be false. Without
# zero-padding the comparison, (0,2,0) > (0,2) would wrongly yield True and
# trigger a duplicate-version uv publish that PyPI rejects with 400.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; serve_published_with_releases "0.2" "0.2"; start_server
OUT="$(run)"; echo "F: $OUT"; [ "$OUT" = "false copilotkit 0.2.0" ] || fail "zero-pad: got '$OUT'"
stop_server
# Case G (yanked): releases = {0.2.0:[non-yanked], 0.99.0:[yanked]}, local 0.2.1.
# The fully-yanked 0.99.0 must be excluded when computing the published max so
# a yanked bogus high version can't block legitimate 0.2.x bumps. Live max =
# 0.2.0 < 0.2.1 -> should_publish=true.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.1"
TOML
rm -rf "$WWW"
serve_raw_json '{"info":{"version":"0.2.0"},"releases":{"0.2.0":[{"yanked":false}],"0.99.0":[{"yanked":true}]}}'
start_server
OUT="$(run)"; echo "G: $OUT"; [ "$OUT" = "true copilotkit 0.2.1" ] || fail "yanked-excluded: got '$OUT'"
stop_server
# Case H (missing-releases fail-loud): HTTP 200 with body missing the
# `releases` key entirely. This is a malformed/unexpected PyPI response and a
# publish gate must NOT silently treat it as "new package" — only a genuine
# 404 means NEW. Script must exit non-zero.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"
serve_raw_json '{"info":{"version":"0.2.0"}}'
start_server
run_expect_fail || fail "missing-releases must fail loud (script exited 0; stdout=$(cat "$TMP/stdout.log" 2>/dev/null))"
echo "H: non-zero exit as expected"
stop_server
# Case I (5xx coverage): server returns HTTP 500. The script already fails on
# any unexpected non-200/404 status, so this is GREEN immediately — pure
# coverage to lock in the 5xx-fails-loud contract.
cat > "$TMP/pkg/pyproject.toml" <<'TOML'
[tool.poetry]
name = "copilotkit"
version = "0.2.0"
TOML
rm -rf "$WWW"; mkdir -p "$WWW"
FAIL_500_PATH="/pypi/copilotkit/json" start_server
run_expect_fail || fail "5xx must fail loud (script exited 0)"
echo "I: non-zero exit as expected"
stop_server
echo "ALL PASS"
@@ -0,0 +1,227 @@
/**
* CLI wrapper for the post-release #engr Slack notification builder.
*
* Thin glue around the pure buildReleaseNotification() function in
* ./lib/build-release-notification.ts. The truth-table logic lives (and is
* unit-tested) there; this file only:
* 1. reads the release signals from env vars (set by the notify job from
* needs.* outputs/results + workflow inputs),
* 2. resolves the npm-scope package count from release.config.json
* (defensively — a cosmetic count must never suppress a real alert),
* 3. calls the pure builder, and
* 4. writes `message=` and `should_post=` to GITHUB_OUTPUT.
*
* Env vars (all optional; absent → empty string):
* MODE needs.build.outputs.mode ("stable" | "prerelease" | "")
* NPM_RESULT needs.publish.result ("success" | "failure" | "skipped" | ...)
* NPM_VER needs.publish.outputs.version
* BUILD_RESULT needs.build.result (catches npm build-stage failures)
* NPM_INTENDED notify-job event-derived npm release intent ("true" | ...) (gates the npm FAILURE arm)
* PY_PUB needs.build-python.outputs.should_publish ("true" | ...)
* PY_INTENDED notify-job event-derived Python release intent ("true" | ...) (gates the PyPI FAILURE arm)
* PY_RESULT needs.publish-python.result
* PY_BUILD_RESULT needs.build-python.result (catches PyPI build-stage failures)
* PY_VER needs.build-python.outputs.version
* SCOPE needs.build.outputs.scope ("monorepo" | "angular")
* DRY_RUN inputs.dry-run ("true" | "false" | "")
* RUN_URL this workflow run URL
* RELEASE_URL GitHub Release URL (npm release notes)
* NPM_URL scope-correct npm package/org page URL
* PY_URL PyPI project page URL
*
* Usage: pnpm tsx scripts/release/build-release-notification.ts
*/
import fs from "fs";
import path from "path";
import { randomBytes } from "crypto";
import { fileURLToPath } from "url";
import { buildReleaseNotification } from "./lib/build-release-notification.js";
import type {
ReleaseMode,
JobResult,
BuildReleaseNotificationResult,
} from "./lib/build-release-notification.js";
import { getScopeConfig, loadConfig } from "./lib/config.js";
import type { ReleaseScope } from "./lib/config.js";
function env(name: string): string {
return process.env[name] ?? "";
}
const KNOWN_MODES: readonly ReleaseMode[] = ["stable", "prerelease", ""];
const KNOWN_JOB_RESULTS: readonly JobResult[] = [
"success",
"failure",
"cancelled",
"skipped",
"",
];
/**
* Validate a raw GitHub Actions job-result env value against the known
* JobResult set, degrading LOUDLY to "failure" (page-on-uncertainty) on any
* unrecognized value. A mis-wired `needs.<job>.result` env (typo, renamed job,
* an Actions value we don't model) must not be cast through unchecked.
*
* DIRECTION ASYMMETRY (intentional): RESULT values drive FAILURE-gating, and
* for a status notifier whose thesis is "never swallow a real failure" an
* unknown result is anomalous and must err toward PAGING, not silence — so it
* degrades to "failure". This is safe precisely because the failure arms are
* intent-gated (npmIntended/pyIntended): a degraded result only pages on a real
* release attempt, never on a routine non-release merge. By contrast
* resolveModeSafe degrades to "" — MODE drives SUCCESS-gating, where fabricating
* "stable" would falsely claim a publish that didn't happen. The ::warning::
* makes the degradation visible in the run log either way.
*/
export function resolveJobResultSafe(raw: string): JobResult {
if ((KNOWN_JOB_RESULTS as readonly string[]).includes(raw)) {
return raw as JobResult;
}
console.warn(
`::warning::resolveJobResultSafe: unrecognized job result "${raw}" (expected one of: success, failure, cancelled, skipped, or empty) — coercing to "failure" (page-on-uncertainty; the intent gates ensure this only pages on a real release).`,
);
return "failure";
}
/**
* Validate the raw MODE env value against the known ReleaseMode set, degrading
* LOUDLY to "" (treated as "npm lane didn't run" — the neutral, safe default)
* on any unrecognized value. A typo'd MODE must not be cast through unchecked.
*
* DIRECTION ASYMMETRY (intentional, opposite of resolveJobResultSafe): MODE
* drives the npm SUCCESS-gating (success requires mode==="stable"). Degrading a
* typo to a fabricated "stable" would FALSELY claim a publish that may not have
* happened, so MODE degrades to the neutral "" — never inventing a success.
* This does NOT swallow failures: the npm-failure arm keys off the event-derived
* npmIntended + the job RESULTS (gated only by the canary suppression), so a real
* stable failure still pages even with a degraded MODE. RESULT values, by
* contrast, drive FAILURE-gating and so degrade toward "failure"
* (page-on-uncertainty) in resolveJobResultSafe. The ::warning:: surfaces either
* degradation in the run log.
*/
export function resolveModeSafe(raw: string): ReleaseMode {
if ((KNOWN_MODES as readonly string[]).includes(raw)) {
return raw as ReleaseMode;
}
console.warn(
`::warning::resolveModeSafe: unrecognized MODE "${raw}" (expected one of: stable, prerelease, or empty) — coercing to "" (treated as "npm lane did not run").`,
);
return "";
}
/**
* Resolve the npm-scope package count from release.config.json, degrading to 0
* on ANY error (unknown scope, missing/corrupt config, etc.). A cosmetic
* package count must NEVER throw and suppress a real release alert.
*/
export function resolvePackageCountSafe(scope: string): number {
try {
// Any scope defined in release.config.json has a package list; anything
// else (e.g. a python-only run with an empty scope) has no npm packages to
// count. Membership comes from the config itself so a newly added scope
// can never drift out of sync with this notifier.
if (scope in loadConfig().scopes) {
return getScopeConfig(scope as ReleaseScope).packages.length;
}
return 0;
} catch (err) {
// Degrade to 0 — the message simply omits the count rather than crashing a
// status notifier over a cosmetic detail. But surface the error in the run
// log (don't swallow silently): a corrupt/missing release.config.json
// should be visible, and the builder will render "published to npm
// (`latest`)" with no count parenthetical (never "0 packages").
console.warn(
`::warning::resolvePackageCountSafe: failed to resolve npm package count for scope "${scope}" — rendering without a package count. ${
err instanceof Error ? err.message : String(err)
}`,
);
return 0;
}
}
/**
* Serialize the builder result to a GITHUB_OUTPUT file using a per-write RANDOM
* heredoc delimiter (GitHub's documented pattern), so message content can never
* collide with / prematurely terminate the heredoc.
*/
export function writeGithubOutput(
outputPath: string,
result: BuildReleaseNotificationResult,
): void {
const delimiter = `EOF_${randomBytes(8).toString("hex")}`;
fs.appendFileSync(
outputPath,
`message<<${delimiter}\n${result.message}\n${delimiter}\n`,
);
fs.appendFileSync(outputPath, `should_post=${result.shouldPost}\n`);
}
function main(): void {
const scope = env("SCOPE");
const result = buildReleaseNotification({
mode: resolveModeSafe(env("MODE")),
npmResult: resolveJobResultSafe(env("NPM_RESULT")),
npmVer: env("NPM_VER"),
buildResult: resolveJobResultSafe(env("BUILD_RESULT")),
npmIntended: env("NPM_INTENDED"),
pyPub: env("PY_PUB"),
pyIntended: env("PY_INTENDED"),
pyResult: resolveJobResultSafe(env("PY_RESULT")),
pyBuildResult: resolveJobResultSafe(env("PY_BUILD_RESULT")),
pyVer: env("PY_VER"),
scope,
dryRun: env("DRY_RUN") === "true",
packageCount: resolvePackageCountSafe(scope),
runUrl: env("RUN_URL"),
releaseUrl: env("RELEASE_URL"),
npmUrl: env("NPM_URL"),
pyUrl: env("PY_URL"),
});
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
writeGithubOutput(outputPath, result);
} else if (process.env.GITHUB_ACTIONS === "true") {
// A status notifier that cannot write its `should_post`/`message` outputs
// is broken: the Post step gates on those outputs, so silently no-op'ing
// would swallow a real release alert. Fail loud under Actions.
console.error(
"::error::GITHUB_OUTPUT is unset under GitHub Actions — cannot emit should_post/message for the release notification.",
);
process.exit(1);
}
// Console echo (always useful in logs; the sole output channel for an
// explicit local/no-Actions invocation).
console.log(`should_post=${result.shouldPost}`);
if (result.message) {
console.log(`message:\n${result.message}`);
}
}
// Only run when invoked directly as a CLI, not when imported by tests.
// Apply fs.realpathSync to BOTH sides so a symlinked checkout (where the module
// path and argv[1] resolve to the same real file through different symlinks)
// can't make main() silently not run. But realpathSync THROWS (ENOENT) if
// argv[1] doesn't resolve on disk — which would crash before main() and
// swallow a real release alert. So guard it: on a realpath throw, fall back to
// a path.resolve()-normalized compare (no disk resolution) so the normal
// direct-invoke path still runs the notifier. Normalize BOTH sides with
// path.resolve — modulePath is already absolute (fileURLToPath), but argv[1]
// may be relative, so a bare string compare could spuriously fail and silently
// skip main() on a realpath throw.
function isInvokedDirectly(): boolean {
if (process.argv[1] == null) return false;
const modulePath = fileURLToPath(import.meta.url);
try {
return fs.realpathSync(modulePath) === fs.realpathSync(process.argv[1]);
} catch {
return path.resolve(modulePath) === path.resolve(process.argv[1]);
}
}
if (isInvokedDirectly()) {
main();
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Bump package versions for a prerelease (runs in the secrets-free build job).
*
* This is extracted from prerelease.ts so that version bumping happens before
* the build, in a job that has no access to NPM_TOKEN or other publish secrets.
* The publish job then receives pre-built, correctly-versioned artifacts.
*
* Usage: tsx scripts/release/bump-prerelease.ts --scope <scope from release.config.json> [--suffix <label>]
*/
import {
getCurrentVersion,
computePrereleaseVersion,
bumpPackages,
getPackagesForScope,
} from "./lib/versions.js";
import { loadConfig, type ReleaseScope } from "./lib/config.js";
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const suffixIdx = argv.indexOf("--suffix");
const suffix = suffixIdx !== -1 ? argv[suffixIdx + 1] : undefined;
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: bump-prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--suffix <label>]`,
);
process.exit(1);
}
const config = loadConfig();
const distTag = config.prereleaseTag;
const currentVersion = getCurrentVersion(scope);
const prereleaseVersion = computePrereleaseVersion(currentVersion, suffix);
console.log(`Scope: ${scope}`);
console.log(`Current version: ${currentVersion}`);
console.log(`Prerelease version: ${prereleaseVersion}`);
console.log(`Dist tag: ${distTag}`);
// Bump versions in working directory (no commit)
const updated = bumpPackages(scope, prereleaseVersion);
console.log(`\nBumped ${updated.length} packages to ${prereleaseVersion}`);
for (const p of updated) {
console.log(` ${p.name}: ${p.oldVersion} -> ${p.newVersion}`);
}
}
main();
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Detect whether sdk-python/pyproject.toml declares a version newer than what's
# published on PyPI. Emits GitHub Actions outputs: should_publish, name, version.
# Also prints "<should_publish> <name> <version>" to stdout for test consumption.
# PYPI_BASE_URL overridable for tests (default https://pypi.org). Requires py3.11+.
set -euo pipefail
PYPROJECT="${PYPROJECT_PATH:-sdk-python/pyproject.toml}"
PYPI_BASE_URL="${PYPI_BASE_URL:-https://pypi.org}"
# Parse name + version from pyproject (tomllib, py3.11+). Capture explicitly so a
# parse failure aborts (heredoc-fed `read` would otherwise mask it under set -e).
PYOUT="$(python3 - "$PYPROJECT" <<'PY'
import sys, tomllib
with open(sys.argv[1], "rb") as f:
data = tomllib.load(f)
poetry = data.get("tool", {}).get("poetry", {})
project = data.get("project", {})
name = poetry.get("name") or project.get("name")
version = poetry.get("version") or project.get("version")
if not name or not version:
sys.exit("could not read name/version from pyproject")
print(name)
print(version)
PY
)" || { echo "ERROR: failed to parse ${PYPROJECT}" >&2; exit 1; }
NAME="$(printf '%s\n' "$PYOUT" | sed -n 1p)"
VERSION="$(printf '%s\n' "$PYOUT" | sed -n 2p)"
echo "Local: ${NAME}==${VERSION}" >&2
# Fetch published version, distinguishing 404 (new package) from other failures.
# Capture curl stderr so transport errors (DNS/TLS/connection refused) surface to
# the operator instead of being erased into a vague "HTTP 000".
RESP="$(mktemp)"; CURL_ERR="$(mktemp)"; trap 'rm -f "$RESP" "$CURL_ERR"' EXIT
CODE="$(curl -sS --max-time 30 --retry 3 --retry-all-errors --retry-connrefused -o "$RESP" -w '%{http_code}' "${PYPI_BASE_URL}/pypi/${NAME}/json" 2>"$CURL_ERR" || echo "000")"
case "$CODE" in
200)
# Compute the MAX numeric-parseable version from the `releases` dict (the
# complete set of released versions). `info.version` is the LATEST-UPLOADED,
# not necessarily the highest — out-of-order patch uploads to an old line
# can produce info.version < max(releases). Non-numeric keys (prereleases
# like "0.2.0rc1", dev/post tags) are filtered out, not aborted on.
#
# Exclude fully-yanked releases: each release maps to a list of file dicts
# with a "yanked" bool. A version with an empty file list or all files
# yanked is NOT a live release and must be skipped — otherwise a yanked
# bogus high version (e.g. 0.99.0) permanently blocks legitimate bumps.
#
# If a 200 response lacks a "releases" key, FAIL LOUD: that's not a "new
# package" signal (only 404 is), it's malformed/unexpected JSON for a
# publish gate. If no live numeric release exists, treat as NEW.
PUBLISHED="$(python3 - "$RESP" <<'PY'
import sys, json, re
with open(sys.argv[1]) as f:
data = json.load(f)
if not isinstance(data, dict) or "releases" not in data or data.get("releases") is None:
sys.exit("missing or null 'releases' key in PyPI JSON response")
releases = data["releases"]
numeric = []
for k, files in releases.items():
if not re.fullmatch(r"\d+(\.\d+)*", k):
continue
# Live iff at least one non-yanked file exists. Empty list -> excluded.
if isinstance(files, list) and any(not f.get("yanked", False) for f in files):
numeric.append(k)
if not numeric:
print("")
else:
best = max(numeric, key=lambda v: tuple(int(x) for x in v.split(".")))
print(best)
PY
)" || { echo "ERROR: bad JSON from PyPI (or missing releases key)" >&2; exit 1; }
if [ -z "$PUBLISHED" ]; then
echo "Published: ${NAME} has no live numeric releases — treating as NEW" >&2
else
echo "Published: ${NAME}==${PUBLISHED}" >&2
fi ;;
404)
PUBLISHED=""; echo "Not found on PyPI — treating as NEW" >&2 ;;
000)
echo "ERROR: curl transport/connection failure contacting ${PYPI_BASE_URL}/pypi/${NAME}/json (HTTP 000 = no response, not an HTTP status)" >&2
if [ -s "$CURL_ERR" ]; then
echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2
fi
exit 1 ;;
*)
echo "ERROR: unexpected HTTP ${CODE} from ${PYPI_BASE_URL}/pypi/${NAME}/json" >&2
if [ -s "$CURL_ERR" ]; then
echo "--- curl stderr ---" >&2; cat "$CURL_ERR" >&2; echo "--- end curl stderr ---" >&2
fi
exit 1 ;;
esac
if [ -z "$PUBLISHED" ]; then
SHOULD_PUBLISH="true"
else
# Plain X.Y.Z numeric-tuple comparison (no third-party deps). The stable lane
# only ships dotted-numeric LOCAL versions; refuse non-numeric LOCAL loudly.
# PUBLISHED is already guaranteed numeric (filtered above when computing max).
# Zero-pad the shorter tuple before comparing so "0.2" and "0.2.0" compare
# equal (PEP 440). Without padding, (0,2,0) > (0,2) wrongly yields True and
# triggers a duplicate-version `uv publish` that PyPI rejects with 400.
SHOULD_PUBLISH="$(python3 - "$VERSION" "$PUBLISHED" <<'PY'
import sys, re
def parse_local(v):
if not re.fullmatch(r"\d+(\.\d+)*", v):
sys.exit(f"non-numeric local version not supported on stable lane: {v!r}")
return tuple(int(x) for x in v.split("."))
local = parse_local(sys.argv[1])
pub = tuple(int(x) for x in sys.argv[2].split("."))
n = max(len(local), len(pub))
local += (0,) * (n - len(local))
pub += (0,) * (n - len(pub))
print("true" if local > pub else "false")
PY
)" || exit 1
fi
echo "should_publish=${SHOULD_PUBLISH}" >&2
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{ echo "should_publish=${SHOULD_PUBLISH}"; echo "name=${NAME}"; echo "version=${VERSION}"; } >> "$GITHUB_OUTPUT"
fi
echo "${SHOULD_PUBLISH} ${NAME} ${VERSION}"
@@ -0,0 +1,169 @@
/**
* AI-powered release notes generator + Notion draft creator.
*
* 1. Reads the raw changelog from release-notes.md
* 2. Calls Claude API to generate polished release notes
* 3. Creates a Notion page with the draft (for human editing)
* 4. Writes release-notes.md with the AI version
* 5. Outputs the Notion page URL + ID for the workflow
*
* Env vars:
* ANTHROPIC_API_KEY — for AI generation (falls back to raw if missing)
* NOTION_API_KEY — for creating the Notion draft (skipped if missing)
* NOTION_RELEASE_NOTES_PAGE — parent page ID in Notion
*
* Usage: tsx scripts/release/generate-ai-release-notes.ts <version>
*/
import fs from "fs";
import path from "path";
import https from "https";
import { spawnSync } from "child_process";
import { ROOT } from "./lib/config.js";
import { createReleaseDraft } from "./lib/notion.js";
function getRecentCommits(count = 50): string {
const result = spawnSync(
"git",
["log", "--oneline", `-${count}`, "--no-merges"],
{ cwd: ROOT, encoding: "utf8" },
);
return result.stdout.trim();
}
function callAnthropic(apiKey: string, prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
messages: [{ role: "user", content: prompt }],
});
const options = {
hostname: "api.anthropic.com",
path: "/v1/messages",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk: string) => (data += chunk));
res.on("end", () => {
try {
const parsed = JSON.parse(data);
if (parsed.content?.[0]) {
resolve(parsed.content[0].text);
} else {
reject(new Error(`Unexpected API response: ${data}`));
}
} catch (e) {
reject(e);
}
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
async function main() {
const version = process.argv[2];
if (!version) {
console.error("Usage: generate-ai-release-notes.ts <version>");
process.exit(1);
}
const releaseNotesPath = path.join(ROOT, "release-notes.md");
if (!fs.existsSync(releaseNotesPath)) {
console.error("release-notes.md not found. Run prepare-release.ts first.");
process.exit(1);
}
const rawChangelog = fs.readFileSync(releaseNotesPath, "utf8");
let finalNotes = rawChangelog;
// Step 1: AI-enhance the release notes if API key is available
const anthropicKey = process.env.ANTHROPIC_API_KEY;
if (anthropicKey) {
console.log("Generating AI-enhanced release notes...");
const recentCommits = getRecentCommits();
const prompt = `You are writing release notes for CopilotKit v${version}, an open-source AI agent framework for React applications.
Here is the raw changelog extracted from git history:
${rawChangelog}
Here are the recent git commits for additional context:
${recentCommits}
Write polished, user-facing release notes for a GitHub Release. Guidelines:
- Start with a brief (1-2 sentence) summary of the release
- Group changes into clear sections (Features, Fixes, Breaking Changes as applicable)
- Write in a professional but approachable tone
- Focus on what users care about — what changed and why it matters
- Include any migration notes for breaking changes
- Keep it concise — no filler, no marketing speak
- Use markdown formatting
- Do NOT include a title/header — the GitHub Release title will be "v${version}"
Output ONLY the release notes content, nothing else.`;
try {
finalNotes = await callAnthropic(anthropicKey, prompt);
fs.writeFileSync(releaseNotesPath, finalNotes);
console.log("AI-enhanced release notes written to release-notes.md");
} catch (err: any) {
console.error(`AI generation failed: ${err.message}`);
console.log("Falling back to raw changelog.");
}
} else {
console.log(
"No ANTHROPIC_API_KEY found. Using raw changelog as release notes.",
);
}
// Step 2: Create a Notion draft page for human editing
const notionKey = process.env.NOTION_API_KEY;
const notionParent = process.env.NOTION_RELEASE_NOTES_PAGE;
if (notionKey && notionParent) {
console.log("Creating Notion release notes draft...");
try {
const { pageId, url } = await createReleaseDraft(version, finalNotes);
console.log(`Notion draft created: ${url}`);
// Write the Notion reference so the publish workflow can find it
const notionRef = { pageId, url, version };
const refPath = path.join(ROOT, "release-notes-notion.json");
fs.writeFileSync(refPath, JSON.stringify(notionRef, null, 2) + "\n");
// Output for CI
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
fs.appendFileSync(outputPath, `notion_url=${url}\n`);
fs.appendFileSync(outputPath, `notion_page_id=${pageId}\n`);
}
} catch (err: any) {
console.error(`Notion draft creation failed: ${err.message}`);
console.log("Continuing without Notion draft.");
}
} else {
console.log(
"No NOTION_API_KEY/NOTION_RELEASE_NOTES_PAGE found. Skipping Notion draft.",
);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,705 @@
import { describe, it, expect } from "vitest";
import { buildReleaseNotification } from "./build-release-notification.js";
import type { BuildReleaseNotificationInput } from "./build-release-notification.js";
const RUN_URL = "https://github.com/CopilotKit/CopilotKit/actions/runs/123";
const RELEASE_URL =
"https://github.com/CopilotKit/CopilotKit/releases/tag/v1.2.3";
const NPM_URL = "https://www.npmjs.com/org/copilotkit";
const ANGULAR_NPM_URL = "https://www.npmjs.com/package/@copilotkit/angular";
const PY_URL = "https://pypi.org/project/copilotkit/0.9.0/";
// A neutral baseline where nothing has acted. Each test overrides only the
// fields relevant to its truth-table row.
function base(
overrides: Partial<BuildReleaseNotificationInput> = {},
): BuildReleaseNotificationInput {
return {
mode: "",
npmResult: "skipped",
npmVer: "",
buildResult: "skipped",
npmIntended: "false",
pyPub: "false",
pyIntended: "false",
pyResult: "skipped",
pyBuildResult: "skipped",
pyVer: "",
scope: "monorepo",
dryRun: false,
packageCount: 16,
runUrl: RUN_URL,
releaseUrl: RELEASE_URL,
npmUrl: NPM_URL,
pyUrl: PY_URL,
...overrides,
};
}
describe("buildReleaseNotification", () => {
// ---- dry-run --------------------------------------------------------------
it("suppresses dry-run — no post", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
dryRun: true,
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- npm lane: prerelease canary fully suppressed -------------------------
it("suppresses prerelease (canary) success — no post", () => {
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmResult: "success",
npmVer: "1.2.3-canary.1",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("suppresses prerelease (canary) npm failure — no post", () => {
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("suppresses prerelease (canary) build failure — no post (would otherwise fire)", () => {
// Slot-6 strengthening: this row would emit a lane-level failure if the
// prerelease suppression on the npm lane regressed. buildResult=failure
// alone fires the alert UNLESS mode === "prerelease".
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- npm lane: stable success --------------------------------------------
it("stable npm success → npm success line (monorepo, N packages)", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
it("stable npm success with empty releaseUrl → success line, NO broken Release-notes link", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
releaseUrl: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${NPM_URL}|npm>`,
);
// The broken "/releases/tag/" empty link must never appear.
expect(r.message).not.toContain("Release notes");
expect(r.message).not.toContain("<|");
});
it("angular scope uses the angular package npm URL", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "2.0.0",
buildResult: "success",
scope: "angular",
packageCount: 1,
npmUrl: ANGULAR_NPM_URL,
}),
);
expect(r.message).toContain(`<${ANGULAR_NPM_URL}|npm>`);
expect(r.message).toContain("*CopilotKit angular v2.0.0*");
expect(r.message).toContain("(`latest`, 1 package)");
});
// ---- npm lane: failure (lane-level wording) -------------------------------
it("stable npm failure → lane-level red alert (NOT 'npm publish failed')", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
// A post-publish step (tag/release) may have failed while publish itself
// succeeded — never claim "publish failed".
expect(r.message).not.toContain("publish failed");
});
it("stable npm publish step SUCCEEDED (version emitted) but JOB failed at a later step → FAILURE line, NOT success (if/else ordering)", () => {
// The central npm-lane design claim: a populated version does NOT force a
// success line. The publish step can succeed and emit a version while the
// JOB still ends in `failure` (e.g. a later tag/release step broke). The
// success arm requires npmResult === "success"; here npmResult is "failure",
// so the `else if (npmResult === "failure" ...)` branch wins → lane-level
// "release failed". This locks the if/else ORDERING: failure result beats a
// populated version. Wording stays lane-level ("release failed"), never
// "publish failed", precisely because publish itself may have succeeded.
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
// Must NOT render a success line despite the populated version.
expect(r.message).not.toContain("🚀");
expect(r.message).not.toContain("published to npm");
// Lane-level, never step-level.
expect(r.message).not.toContain("publish failed");
});
it("build failure (mode='', npm skipped) → lane-level npm/build red alert", () => {
// A failure in the build job before the publish job ran: npmResult is
// "skipped" and mode is empty, but buildResult is "failure".
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("publish failed");
});
it("npm publish failure with empty mode (npmResult='failure') → lane-level red alert (no mode-coupling swallow)", () => {
// The npm FAILURE arm keys off npmIntended (the notify job's event-derived
// release intent) AND the job result — NOT on mode === "stable" (that would
// swallow a real stable publish failure whose mode output came back empty).
// An empty mode with a real publish failure on a genuine release attempt
// still pages.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("publish failed");
});
// ---- npm lane: EVENT-DERIVED intent gate ---------------------------------
it("npmIntended='true' + buildResult='failure' → npm red ALERT (intent gate open)", () => {
// The notify job determined an npm release was actually attempted
// (release/publish/* merge or a workflow_dispatch). A build-job failure on a
// genuine npm release must page — independent of needs.build outputs.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>`,
);
});
it("npmIntended='false' + buildResult='failure' → NEUTRAL (no npm release attempted)", () => {
// The notify job runs on EVERY merged PR. A routine non-release merge whose
// build job flakes (or a stray failure) carries no npm release intent
// (not a release/publish/* merge, not a dispatch), so the lane stays quiet.
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "false",
npmResult: "skipped",
buildResult: "failure",
scope: "monorepo",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- PyPI lane: independent of MODE --------------------------------------
it("PyPI success → only PyPI line", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "success", pyVer: "0.9.0" }),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
it("PyPI failure → lane-level PyPI red alert", () => {
const r = buildReleaseNotification(
base({ pyIntended: "true", pyPub: "true", pyResult: "failure" }),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("build-python failure during a REAL Python release (pyPub='true', publish-python skipped) → PyPI red alert", () => {
// The previously-silent gap: on a genuine Python release where build-python
// FAILS, publish-python is skipped (its `if` requires
// build-python.result == 'success') → pyResult === 'skipped'. Keying the
// failure lane off pyBuildResult catches this so a real release that broke
// at build still pages, instead of posting nothing.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "true",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("build-python CANCELLED during a real Python release → NEUTRAL (no false red on deliberate cancel)", () => {
// A cancelled build-python reports result 'cancelled' (NOT 'failure'), even
// when it hits timeout-minutes. The failure lane keys off
// pyBuildResult === 'failure', so a cancel stays neutral.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "true",
pyResult: "skipped",
pyBuildResult: "cancelled",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python failure WITHOUT intent (pyIntended='false', pyBuildResult='failure') → NO post (routine PR flake)", () => {
// build-python runs on EVERY merged PR; a transient build-python failure on
// an unrelated PR (no release intent) must NOT page. The pyIntended === 'true'
// gate is what prevents this false-RED.
const r = buildReleaseNotification(
base({
pyIntended: "false",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python without intent (pyIntended='false') → NO post (routine PR, no false red)", () => {
// build-python runs on EVERY merged PR; only its inner steps are
// pyproject-gated. A transient build-python failure on an unrelated
// (docs/npm-only) PR must NOT page a PyPI failure — the notify job's
// event-derived pyIntended is false (no Python release attempted).
const r = buildReleaseNotification(
base({ pyIntended: "false", pyPub: "", pyResult: "skipped" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- PyPI lane: EARLY-INTENT failure gate (closes the should_publish gap) -
it("build-python failure AT/BEFORE detect on a genuine Python release (pyIntended='true', pyPub='' — detect never emitted) → PyPI red ALERT", () => {
// THE CLOSED GAP. should_publish (pyPub) is emitted at the END of the detect
// step. A build-python failure at/before detect (PyPI API outage,
// setup-python failure, malformed pyproject) on a genuine Python release
// never reaches the should_publish echo → pyPub="". Gating the failure arm
// on pyPub silently swallowed this. The notify job's event-derived
// pyIntended (computed independently of the build jobs) catches it.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("routine PR build-python flake (pyIntended='false', pyBuildResult='failure') → NEUTRAL (no false-RED)", () => {
// build-python runs on EVERY merged PR; an npm/docs-only PR's transient
// build-python failure carries no release intent (the notify job's
// pyIntended is false — the merged PR did not change sdk-python/pyproject.toml
// and this is not a python_publish dispatch), so the lane stays quiet.
const r = buildReleaseNotification(
base({
pyIntended: "false",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("python release intended (pyIntended='true', pyBuildResult='failure') → PyPI red ALERT", () => {
// An explicit Python release intent (a python_publish=true dispatch, or a
// merged PR that bumped sdk-python/pyproject.toml). A build failure before
// detect emits no should_publish, but the event-derived pyIntended signal
// still fires the alert.
const r = buildReleaseNotification(
base({
pyIntended: "true",
pyPub: "",
pyResult: "skipped",
pyBuildResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("prerelease + BOTH lanes failing → ONLY the PyPI red line, npm fully suppressed (canary)", () => {
// A mode=prerelease dispatch where the npm lane fails AND the PyPI lane
// fails. The npm failure is canary noise → fully suppressed by
// mode === "prerelease". The PyPI lane is mode-independent, so its real
// failure (pyPub="true", pyResult="failure") still pages. Exactly one red
// line, the PyPI one.
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmIntended: "true",
npmResult: "failure",
buildResult: "failure",
pyIntended: "true",
pyPub: "true",
pyResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
// No npm line: the npm failure marker "*CopilotKit" (the npm line's bold
// prefix) must be absent. NB: the RUN_URL contains "CopilotKit/CopilotKit",
// so assert on the line prefix, not the bare org name.
expect(r.message).not.toContain("*CopilotKit");
expect(r.message).toBe(
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
it("prerelease + python_publish success → npm suppressed, PyPI line still posts", () => {
// A mode=prerelease dispatch that also runs the Python lane must still
// announce the real PyPI publish — the canary suppression is npm-only.
const r = buildReleaseNotification(
base({
mode: "prerelease",
npmResult: "success",
npmVer: "1.2.3-canary.1",
buildResult: "success",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain("🚀");
expect(r.message).not.toContain("canary");
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
// ---- cancelled is NEUTRAL everywhere -------------------------------------
it("npm cancelled (mode=stable) → no line (neutral, no false red)", () => {
const r = buildReleaseNotification(
base({ mode: "stable", npmResult: "cancelled", buildResult: "success" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build cancelled → no line (neutral)", () => {
const r = buildReleaseNotification(
base({ mode: "", npmResult: "skipped", buildResult: "cancelled" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("PyPI cancelled → no line (neutral)", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "cancelled" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("build-python succeeded but publish-python cancelled (should_publish=true) → no line (neutral)", () => {
// Distinct from the row above: here build-python passed (pyBuildResult
// 'success') and the publish job was cancelled. Neither a build failure nor
// a publish failure → neutral, no false red.
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "cancelled", pyBuildResult: "success" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- skipped lanes contribute nothing ------------------------------------
it("python-only run (npm lane skipped) → only PyPI line, NO false red", () => {
const r = buildReleaseNotification(
base({
mode: "",
npmResult: "skipped",
buildResult: "skipped",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain("🔴");
expect(r.message).toBe(
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`,
);
});
it("npm-only run (PyPI lane not acting) → only npm line", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
pyPub: "false",
pyResult: "skipped",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toContain("🚀 *CopilotKit monorepo v1.2.3*");
expect(r.message).not.toContain("🐍");
expect(r.message).not.toContain("🔴");
});
// ---- both lanes ----------------------------------------------------------
it("both lanes succeed → one message with both lines", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
pyPub: "true",
pyResult: "success",
pyVer: "0.9.0",
}),
);
expect(r.shouldPost).toBe(true);
const expected =
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>\n` +
"🐍 *copilotkit (Python SDK) v0.9.0* published to PyPI · " +
`<${PY_URL}|PyPI>`;
expect(r.message).toBe(expected);
});
it("both lanes fail → one message with both lane-level red lines", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmIntended: "true",
npmResult: "failure",
buildResult: "success",
pyIntended: "true",
pyPub: "true",
pyResult: "failure",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit monorepo release failed* · <${RUN_URL}|View run>\n` +
`🔴 *copilotkit (Python SDK) release failed* · <${RUN_URL}|View run>`,
);
});
// ---- nothing acted -------------------------------------------------------
it("nothing acted (npm skipped, PyPI not publishing) → no post, empty message", () => {
const r = buildReleaseNotification(base());
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
// ---- no-false-success guards ---------------------------------------------
it("stable npm success with empty version → no npm success line (no false success)", () => {
// npmResult=success but no version is an anomalous state — do not claim
// success. With buildResult=success there is also no failure to report.
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("PyPI success with empty version → no PyPI success line (no false success)", () => {
const r = buildReleaseNotification(
base({ pyPub: "true", pyResult: "success", pyVer: "" }),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
it("pluralization: monorepo scope with N packages → 'N packages'", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
packageCount: 16,
}),
);
expect(r.message).toContain("(`latest`, 16 packages)");
});
it("packageCount=0 (unknown/degraded) → success line WITHOUT a packages count parenthetical", () => {
// A degraded/missing release.config.json resolves to 0 packages. The
// builder must NOT render the self-contradictory "(`latest`, 0 packages)";
// it omits the count parenthetical entirely → "published to npm (`latest`)".
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
packageCount: 0,
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toContain("published to npm (`latest`)");
expect(r.message).not.toContain("packages");
expect(r.message).not.toContain("0 package");
expect(r.message).toBe(
"🚀 *CopilotKit monorepo v1.2.3* published to npm (`latest`) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
// ---- empty-scope rendering (no doubled words / double spaces) -------------
it("empty scope on the FAILURE path → clean 'CopilotKit release failed' (no 'release release')", () => {
// A stable build failure where scope is empty must not render
// "CopilotKit release release failed".
const r = buildReleaseNotification(
base({
mode: "",
npmIntended: "true",
npmResult: "skipped",
buildResult: "failure",
scope: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).toBe(
`🔴 *CopilotKit release failed* · <${RUN_URL}|View run>`,
);
expect(r.message).not.toContain("release release");
expect(r.message).not.toContain(" ");
});
it("empty scope on the SUCCESS path → clean version line (no double space)", () => {
const r = buildReleaseNotification(
base({
mode: "stable",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
scope: "",
}),
);
expect(r.shouldPost).toBe(true);
expect(r.message).not.toContain(" ");
expect(r.message).toBe(
"🚀 *CopilotKit v1.2.3* published to npm (`latest`, 16 packages) · " +
`<${RELEASE_URL}|Release notes> · <${NPM_URL}|npm>`,
);
});
it("npm success but mode not stable (defensive) → no npm success line", () => {
// mode "" with a success result must not produce an npm success line.
// buildResult=success means no failure either → nothing posts.
const r = buildReleaseNotification(
base({
mode: "",
npmResult: "success",
npmVer: "1.2.3",
buildResult: "success",
}),
);
expect(r.shouldPost).toBe(false);
expect(r.message).toBe("");
});
});
@@ -0,0 +1,303 @@
/**
* Pure message-builder for the post-release #engr Slack notification.
*
* This is the load-bearing truth table for what (if anything) gets posted to
* Slack after the publish-release.yml workflow runs. It is deliberately a PURE
* function of its inputs so the full truth table can be unit-tested without any
* GitHub Actions / network involvement. The thin CLI wrapper
* (scripts/release/build-release-notification.ts) parses env vars, resolves the
* package count from release.config.json, calls this function, and writes the
* result to GITHUB_OUTPUT.
*
* Failure model — TWO INDEPENDENT LANES (npm + PyPI):
*
* - dry-run → no post (entirely suppressed).
*
* - npm lane (canary npm fully suppressed — success AND failure — when
* mode === "prerelease", because npm canaries are noise):
* • SUCCESS line when mode==stable && npmResult==success && npmVer set.
* The "<releaseUrl|Release notes>" link is included only when
* releaseUrl is non-empty. This empty-releaseUrl guard is retained as
* DEFENSE-IN-DEPTH: the empty-releaseUrl-on-SUCCESS state is NOT
* currently reachable — the tag step is `if: success()`, so a tag-step
* failure flips the publish JOB to `failure`, routing to the failure
* arm (npmResult != "success") rather than rendering an empty link. The
* guard exists so that a FUTURE change making the tag step
* continue-on-error (publish success + empty tag output) cannot render a
* broken empty "<|Release notes>" / "/releases/tag/" link. Do NOT
* remove it. The "(`latest`, N packages)" count parenthetical is OMITTED
* when the resolved packageCount is 0 (unknown/degraded config) — never
* print the self-contradictory "0 packages".
* • FAILURE alert (lane-level, NOT step-level) when
* npmIntended && (npmResult==failure || buildResult==failure) &&
* mode != "prerelease". Gated on the event-derived npmIntended (computed
* in the notify job from the github.event payload — a workflow_dispatch
* or a merged release/publish/* PR) and the job results, with the canary
* suppression. NOT additionally gated on mode==stable (which would
* swallow a real stable publish failure whose mode output came back
* empty). See the inline LANE SYMMETRY note. The publish step may have
* succeeded with a LATER tag/release step failing, so the wording is
* "release failed", never "publish failed".
*
* - PyPI lane (INDEPENDENT of MODE — PyPI has no canary concept, so a
* mode=prerelease dispatch that also runs python_publish must still
* announce the real PyPI publish. NB: this mode-independence only matters
* when the python lane was actually dispatched; on a pure npm release the
* lane is skipped and contributes nothing):
* • SUCCESS line when pyPub=="true" && pyResult==success && pyVer set.
* (Success legitimately requires detect to have run and emitted
* should_publish, so pyPub is the correct success gate.)
* • FAILURE alert when pyIntended &&
* (pyResult==failure || pyBuildResult==failure), where pyIntended is the
* notify job's event-derived Python-release intent (a python_publish
* dispatch, OR a merged PR that changed sdk-python/pyproject.toml per
* the GitHub PR changed-files API). Symmetric with the npm lane. The
* pyBuildResult arm closes the gap where build-python FAILS during a
* genuine release → publish-python is skipped (its `if` requires
* build-python.result == 'success') → pyResult is "skipped", so a bare
* pyResult check would post nothing. CRITICAL — gate on the
* build-job-INDEPENDENT intent, not pyPub: should_publish (pyPub) is
* emitted only at the END of the detect step, so a build-python failure
* AT/BEFORE detect on a genuine release (PyPI API outage, setup-python
* failure, malformed pyproject) never emits it → pyPub="" → a pyPub-gated
* failure arm SILENTLY SWALLOWS the alert. pyIntended is computed in the
* notify job itself from the github.event payload + the PR changed-files
* API, so it does NOT depend on the build jobs running at all. pyIntended
* also keeps routine non-Python PRs quiet: build-python runs on EVERY
* merged PR, but a docs/npm-only PR neither changed pyproject.toml nor is
* a python_publish dispatch, so a transient build flake stays neutral. A
* CANCELLED build reports "cancelled" (NOT "failure") and stays neutral
* — no false-RED on a deliberate cancel.
*
* - cancelled is NEUTRAL everywhere — never a failure line. (GitHub has no
* timeout-specific result; a job hitting timeout-minutes reports
* "cancelled", which correctly stays neutral.) This matches the
* showcase-notify convention and avoids false-red pages on deliberate
* cancels (e.g. concurrency cancel-in-progress).
*
* - a skipped lane contributes NOTHING (no false red).
* - shouldPost is true iff ≥1 line (success OR failure) was emitted; an empty
* message never posts.
*
* See build-release-notification.test.ts for the exhaustive truth table.
*/
export type ReleaseMode = "stable" | "prerelease" | "";
/**
* GitHub Actions `result` values for a needed job. These are the ONLY values
* GitHub emits: success | failure | cancelled | skipped (plus "" when unset).
* There is no timeout-specific result — a job that hits timeout-minutes
* reports "cancelled". We only treat "success" and "failure" as actionable;
* "skipped"/"cancelled"/"" are neutral.
*/
export type JobResult = "success" | "failure" | "skipped" | "cancelled" | "";
export interface BuildReleaseNotificationInput {
/** needs.build.outputs.mode — "stable" | "prerelease" | "" (empty when npm lane didn't run). */
mode: ReleaseMode;
/** needs.publish.result — the npm publish job result. */
npmResult: JobResult;
/** needs.publish.outputs.version — the published npm version (empty unless stable success). */
npmVer: string;
/** needs.build.result — the npm build job result (catches build-stage failures). */
buildResult: JobResult;
/**
* NPM_INTENDED — "true" when the notify job determined an npm release was
* actually attempted, computed in the notify job itself from the
* github.event payload (a workflow_dispatch, or a merged release/publish/*
* PR) — independent of whether/how the build jobs ran. The npm FAILURE arm
* gates on this so a build-job failure on a genuine npm release always pages,
* even if needs.build emitted no usable outputs.
*/
npmIntended: string;
/** needs.build-python.outputs.should_publish — "true" when the PyPI lane acted. */
pyPub: string;
/**
* PY_INTENDED — "true" when the notify job determined a Python release was
* actually intended, computed in the notify job itself: a workflow_dispatch
* with python_publish=true, OR a merged PR that changed
* sdk-python/pyproject.toml (determined via the GitHub PR changed-files API,
* not the build job's outputs). This is the build-job-INDEPENDENT Python
* release-intent signal. should_publish (pyPub) is emitted only at the END of
* build-python's `detect` step, so a build-python failure at/before detect on
* a genuine release never emits should_publish — gating the failure arm on
* pyIntended (not pyPub) closes that silent-swallow gap.
*/
pyIntended: string;
/** needs.publish-python.result — the PyPI publish job result. */
pyResult: JobResult;
/**
* needs.build-python.result — the PyPI build job result. Lets the failure
* lane catch a build-stage failure that skips publish-python (whose `if`
* requires build-python.result == 'success', so pyResult becomes "skipped").
*/
pyBuildResult: JobResult;
/** needs.build-python.outputs.version — the published PyPI version. */
pyVer: string;
/** needs.build.outputs.scope — "monorepo" | "angular". */
scope: string;
/** inputs.dry-run — true on a dry-run dispatch. */
dryRun: boolean;
/** Number of packages in the npm scope (from release.config.json). */
packageCount: number;
/** URL to this workflow run (for failure "View run" links). */
runUrl: string;
/** URL to the GitHub Release for the npm release (for "Release notes" link). May be empty. */
releaseUrl: string;
/** URL to the npm package/org page (for the "npm" link). Scope-correct. */
npmUrl: string;
/** URL to the PyPI project page (for the "PyPI" link). */
pyUrl: string;
}
export interface BuildReleaseNotificationResult {
/** The combined Slack message (mrkdwn). Empty when shouldPost is false. */
message: string;
/** True iff there is ≥1 success line OR ≥1 failure line. */
shouldPost: boolean;
}
function pluralizePackages(count: number): string {
return count === 1 ? "1 package" : `${count} packages`;
}
/**
* Build the #engr Slack message for a release run. Pure function: same
* inputs always produce the same output.
*/
export function buildReleaseNotification(
input: BuildReleaseNotificationInput,
): BuildReleaseNotificationResult {
const empty: BuildReleaseNotificationResult = {
message: "",
shouldPost: false,
};
// Dry-run never posts (no real publish happened on any lane).
if (input.dryRun) {
return empty;
}
// Both failure lanes gate on an event-derived intent signal computed in the
// notify job (NOT on the build jobs' outputs/results). npmIntended is "true"
// when an npm release was actually attempted; pyIntended is "true" when a
// Python release was actually intended. See the per-lane notes below.
const npmIntended = input.npmIntended === "true";
const pyIntended = input.pyIntended === "true";
const lines: string[] = [];
// Render the scope cleanly when empty: a bare " " or doubled "release"
// word must never appear. With scope present we get "CopilotKit <scope> …";
// with scope empty we collapse to "CopilotKit …" (no extra word/space).
const scopeSegment = input.scope ? `${input.scope} ` : "";
// --- npm lane -----------------------------------------------------------
// Canary (prerelease) npm runs are fully suppressed — success AND failure —
// because npm canaries are noise. The PyPI lane below is NOT gated on this.
if (input.mode !== "prerelease") {
if (
input.mode === "stable" &&
input.npmResult === "success" &&
input.npmVer
) {
// Build the success line, including the "Release notes" link ONLY when
// releaseUrl is non-empty. This guard is retained as DEFENSE-IN-DEPTH (see
// header): the empty-releaseUrl-on-SUCCESS state is NOT currently
// reachable — the tag step is `if: success()`, so a tag-step failure flips
// the publish JOB to `failure` and routes to the failure arm rather than
// this success arm. The guard protects against a FUTURE change making the
// tag step continue-on-error (publish success + empty tag output), which
// would otherwise render a broken empty "<|Release notes>" /
// "/releases/tag/" link. Do NOT remove it.
const releaseNotes = input.releaseUrl
? `<${input.releaseUrl}|Release notes> · `
: "";
// Omit the "(`latest`, N packages)" count parenthetical entirely when
// the package count is unknown/degraded (0) — never print "0 packages".
const countSuffix =
input.packageCount > 0
? ` (\`latest\`, ${pluralizePackages(input.packageCount)})`
: " (`latest`)";
lines.push(
`🚀 *CopilotKit ${scopeSegment}v${input.npmVer}* published to npm` +
`${countSuffix} · ` +
`${releaseNotes}<${input.npmUrl}|npm>`,
);
} else if (
npmIntended &&
(input.npmResult === "failure" || input.buildResult === "failure")
) {
// Lane-level wording: the publish step may have succeeded while a later
// tag/release step failed, so never say "npm publish failed".
//
// The npm FAILURE arm gates on npmIntended AND the job results, with the
// enclosing canary suppression (mode !== "prerelease"). It is NOT gated on
// mode === "stable" (that would swallow a real stable publish failure
// whose mode output came back empty).
//
// LANE SYMMETRY (now both lanes are intent-gated from the notify job's
// event-derived signals): npmIntended is computed in the notify job from
// the github.event payload (a workflow_dispatch, or a merged
// release/publish/* PR) — NOT inferred from the `build` job's
// branch-gating invariant. So a build-job failure that emits no usable
// outputs still pages on a genuine npm release, and a routine non-release
// merge's build flake stays quiet. The PyPI lane below is symmetric: it
// gates on pyIntended, likewise event-derived in the notify job.
lines.push(
`🔴 *CopilotKit ${scopeSegment}release failed* · <${input.runUrl}|View run>`,
);
}
// cancelled / skipped on the npm lane are NEUTRAL → no line.
}
// --- PyPI lane ----------------------------------------------------------
// INDEPENDENT of MODE — PyPI has no canary concept, so the prerelease path
// never suppresses a real PyPI publish.
// pyIntended is the notify job's event-derived Python-release intent signal,
// computed independently of the build jobs (a python_publish dispatch, or a
// merged PR that changed sdk-python/pyproject.toml per the GitHub PR
// changed-files API). The SUCCESS arm still requires pyPub (should_publish)
// because a legitimate success necessarily means detect ran and emitted it;
// only the FAILURE arm must gate on the build-job-independent intent so a
// build-python failure AT/BEFORE detect (which never reaches the
// should_publish echo) still pages.
if (input.pyPub === "true" && input.pyResult === "success" && input.pyVer) {
lines.push(
`🐍 *copilotkit (Python SDK) v${input.pyVer}* published to PyPI · ` +
`<${input.pyUrl}|PyPI>`,
);
} else if (
pyIntended &&
(input.pyResult === "failure" || input.pyBuildResult === "failure")
) {
// Fire on a real Python release attempt when EITHER the publish job failed
// OR the build job failed. Keying off pyBuildResult catches the gap where
// build-python FAILS → publish-python is skipped (its `if` requires
// build-python.result == 'success') → pyResult is "skipped" and the bare
// pyResult check would post nothing.
//
// The gate is pyIntended (the notify job's event-derived Python-release
// intent), NOT pyPub: pyPub (should_publish) is emitted only at the END of
// the detect step, so a build-python failure AT/BEFORE detect on a genuine
// release (PyPI API outage, setup-python failure, malformed pyproject) never
// emits it → pyPub="" → the old pyPub gate silently swallowed the alert.
// pyIntended is computed in the notify job from the github.event payload +
// the PR changed-files API, so it is present regardless of how the build
// jobs ran. pyIntended also keeps routine non-Python PRs quiet: build-python
// runs on EVERY merged PR, but a docs/npm-only PR neither changed
// sdk-python/pyproject.toml nor is a python_publish dispatch, so a transient
// build flake there stays neutral. Use pyBuildResult === "failure" (NOT
// "skipped"): a CANCELLED build reports "cancelled" and stays NEUTRAL, so a
// deliberate cancel never false-REDs.
lines.push(
`🔴 *copilotkit (Python SDK) release failed* · <${input.runUrl}|View run>`,
);
}
if (lines.length === 0) {
return empty;
}
return { message: lines.join("\n"), shouldPost: true };
}
@@ -0,0 +1,432 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { fileURLToPath } from "url";
import { execFileSync } from "child_process";
import {
writeGithubOutput,
resolvePackageCountSafe,
resolveModeSafe,
resolveJobResultSafe,
} from "../build-release-notification.js";
import * as config from "./config.js";
const WRAPPER = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../build-release-notification.ts",
);
// Resolve the local tsx binary so the subprocess never hits npx's network /
// registry path (which is flaky in CI). Walk up from this file to the repo
// root's node_modules/.bin/tsx.
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
const TSX_BIN = path.join(REPO_ROOT, "node_modules", ".bin", "tsx");
/**
* Run the wrapper CLI as a subprocess; returns { code, stdout, stderr }.
*
* Builds a CLEAN minimal env (only PATH + the caller's overrides) rather than
* spreading the runner's process.env. This matters because the suite itself may
* run under GitHub Actions with a real GITHUB_OUTPUT / GITHUB_ACTIONS set —
* spreading those in would pollute the fail-loud "GITHUB_OUTPUT unset" test and
* the DRY_RUN coercion cases.
*/
function runWrapper(env: Record<string, string | undefined>): {
code: number;
stdout: string;
stderr: string;
} {
// Strip undefined values so an explicit `KEY: undefined` truly unsets it
// (rather than passing the string "undefined").
const cleanEnv: Record<string, string> = { PATH: process.env.PATH ?? "" };
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) cleanEnv[k] = v;
}
try {
const stdout = execFileSync(TSX_BIN, [WRAPPER], {
env: cleanEnv,
stdio: "pipe",
encoding: "utf8",
});
return { code: 0, stdout, stderr: "" };
} catch (e: unknown) {
const err = e as { status?: number; stdout?: string; stderr?: string };
return {
code: err.status ?? 1,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
};
}
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "release-notify-wrapper-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("writeGithubOutput", () => {
it("round-trips a multi-line message through the GITHUB_OUTPUT heredoc", () => {
const outputPath = path.join(tmpDir, "out.txt");
fs.writeFileSync(outputPath, "");
const message = "line one\nline two · <https://x|y>";
writeGithubOutput(outputPath, { message, shouldPost: true });
const raw = fs.readFileSync(outputPath, "utf8");
// Parse the heredoc the way GitHub Actions does: message<<DELIM ... DELIM.
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
expect(m![2]).toBe(message);
expect(raw).toContain("should_post=true");
});
it("uses a per-write RANDOM delimiter (not a fixed sentinel)", () => {
const a = path.join(tmpDir, "a.txt");
const b = path.join(tmpDir, "b.txt");
fs.writeFileSync(a, "");
fs.writeFileSync(b, "");
writeGithubOutput(a, { message: "x", shouldPost: true });
writeGithubOutput(b, { message: "x", shouldPost: true });
const delimA = fs.readFileSync(a, "utf8").match(/^message<<(\S+)/m)?.[1];
const delimB = fs.readFileSync(b, "utf8").match(/^message<<(\S+)/m)?.[1];
expect(delimA).toBeTruthy();
expect(delimB).toBeTruthy();
// No fixed sentinel, and two separate writes must differ.
expect(delimA).not.toBe("__RELEASE_NOTIFY_EOF__");
expect(delimA).not.toBe(delimB);
});
it("does not corrupt output when the message itself contains a heredoc-like token", () => {
const outputPath = path.join(tmpDir, "out.txt");
fs.writeFileSync(outputPath, "");
// A pathological message containing the legacy fixed delimiter must not
// prematurely terminate the heredoc.
const message = "__RELEASE_NOTIFY_EOF__\nstill the message";
writeGithubOutput(outputPath, { message, shouldPost: true });
const raw = fs.readFileSync(outputPath, "utf8");
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
expect(m![2]).toBe(message);
});
});
describe("resolvePackageCountSafe", () => {
it("returns 0 for an unknown scope (early return, no config lookup)", () => {
// An unknown scope is NOT a known npm scope, so it hits the early `return 0`
// BEFORE getScopeConfig is ever called — this exercises the not-a-known-scope
// branch, NOT the try/catch error-swallow path (see the throw test below).
expect(() => resolvePackageCountSafe("does-not-exist")).not.toThrow();
expect(resolvePackageCountSafe("does-not-exist")).toBe(0);
});
it("returns 0 for an empty scope (python-only run)", () => {
expect(resolvePackageCountSafe("")).toBe(0);
});
it("returns the real package count for a known scope (angular)", () => {
// angular has exactly one package in release.config.json.
expect(resolvePackageCountSafe("angular")).toBe(1);
});
it("returns the real package count for the monorepo scope (drift guard)", () => {
// Pins the actual count from release.config.json (16). If the package set
// drifts, this catches the staleness of the hardcoded "16 packages"
// assertions in build-release-notification.test.ts.
expect(resolvePackageCountSafe("monorepo")).toBe(16);
});
it("returns the real package count for the channels scope (channels + channels-ui, drift guard)", () => {
expect(resolvePackageCountSafe("channels")).toBe(2);
});
it("returns the real package count for the channels-slack scope (drift guard)", () => {
expect(resolvePackageCountSafe("channels-slack")).toBe(1);
});
it("resolves a positive count for EVERY scope in release.config.json (anti-drift)", () => {
// Membership is read from the config at runtime, so a newly added scope
// can never silently render without a package count. If this fails for a
// future scope, that scope's package list is empty or the wrapper has
// drifted from release.config.json.
for (const [scope, cfg] of Object.entries(config.loadConfig().scopes)) {
expect(resolvePackageCountSafe(scope)).toBe(cfg.packages.length);
expect(resolvePackageCountSafe(scope)).toBeGreaterThan(0);
}
});
it("swallows a getScopeConfig throw on a KNOWN scope → returns 0 AND emits ::warning::", () => {
// Drive the catch branch (not the early return): stub getScopeConfig to
// throw for a KNOWN scope (monorepo), simulating a corrupt/missing
// release.config.json. The safe wrapper must degrade to 0 and surface the
// failure as a ::warning:: rather than crash the notifier.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const scopeSpy = vi
.spyOn(config, "getScopeConfig")
.mockImplementation(() => {
throw new Error("simulated corrupt release.config.json");
});
expect(() => resolvePackageCountSafe("monorepo")).not.toThrow();
expect(resolvePackageCountSafe("monorepo")).toBe(0);
expect(scopeSpy).toHaveBeenCalledWith("monorepo");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("resolveModeSafe", () => {
it.each(["stable", "prerelease", ""] as const)(
'passes through the known mode "%s" unchanged',
(mode: string) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(resolveModeSafe(mode)).toBe(mode);
expect(warnSpy).not.toHaveBeenCalled();
},
);
it('coerces an unknown MODE (typo) to "" AND emits ::warning:: (degrade loud, no crash)', () => {
// A typo'd MODE must NOT be cast through unchecked. The safe resolver
// degrades to "" (neutral "npm lane did not run") and surfaces a
// ::warning:: so the degradation isn't silent. "" is the safe default: the
// npm-failure arm keys off job RESULTS (gated only by canary suppression),
// so a real failure still pages — only a stable SUCCESS would degrade, and
// that degradation is now visible in the run log.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => resolveModeSafe("stabel")).not.toThrow();
expect(resolveModeSafe("stabel")).toBe("");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("resolveJobResultSafe", () => {
it.each(["success", "failure", "cancelled", "skipped", ""] as const)(
'passes through the known job result "%s" unchanged (no warning)',
(result: string) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(resolveJobResultSafe(result)).toBe(result);
expect(warnSpy).not.toHaveBeenCalled();
},
);
it('coerces an unknown job result to "failure" AND emits ::warning:: (page-on-uncertainty, no crash)', () => {
// A mis-wired needs.<job>.result (typo, renamed job, an Actions value we
// don't model) must NOT be cast through unchecked. RESULT values drive
// FAILURE-gating, so for a notifier whose thesis is "never swallow a real
// failure" an unknown result is anomalous and degrades toward "failure"
// (page-on-uncertainty), not silence. The intent gates (npmIntended/
// pyIntended) ensure this only pages on a real release. A ::warning:: makes
// the degradation visible in the run log.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => resolveJobResultSafe("succeeded")).not.toThrow();
expect(resolveJobResultSafe("succeeded")).toBe("failure");
expect(warnSpy).toHaveBeenCalled();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("::warning::")),
).toBe(true);
});
});
describe("wrapper CLI fail-loud (subprocess)", () => {
it("fails loud (non-zero + ::error::) when running under Actions with GITHUB_OUTPUT unset", () => {
// GITHUB_ACTIONS=true signals an Actions context; with no GITHUB_OUTPUT a
// status notifier that cannot write its output must fail visibly.
const { code, stderr } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: undefined,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
});
expect(code).not.toBe(0);
expect(stderr).toContain("::error::");
}, 30000);
it("writes output and exits 0 when GITHUB_OUTPUT is set", () => {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "monorepo",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=true");
expect(raw).toMatch(/^message<<\S+/m);
}, 30000);
});
describe("wrapper CLI DRY_RUN string coercion (subprocess)", () => {
// DRY_RUN is the inputs.dry-run boolean stringified by Actions. It gates EVERY
// production notification, yet the env→boolean coercion is only exercisable at
// this string layer. Only the exact string "true" suppresses the post.
function postFor(dryRun: string): { code: number; raw: string } {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "monorepo",
DRY_RUN: dryRun,
});
return { code, raw: fs.readFileSync(out, "utf8") };
}
it('DRY_RUN="true" → should_post=false (suppressed)', () => {
const { code, raw } = postFor("true");
expect(code).toBe(0);
expect(raw).toContain("should_post=false");
}, 30000);
it('DRY_RUN="false" → posts on an otherwise-successful stable run', () => {
const { code, raw } = postFor("false");
expect(code).toBe(0);
expect(raw).toContain("should_post=true");
}, 30000);
it('DRY_RUN="" (empty) → posts on an otherwise-successful stable run', () => {
const { code, raw } = postFor("");
expect(code).toBe(0);
expect(raw).toContain("should_post=true");
}, 30000);
});
describe("wrapper CLI end-to-end message rendering (subprocess)", () => {
it("mixed lane: npm success + PyPI failure → one 🚀 line and one 🔴 line in one message", () => {
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
NPM_INTENDED: "true",
SCOPE: "monorepo",
PY_INTENDED: "true",
PY_PUB: "true",
PY_RESULT: "failure",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const m = fs
.readFileSync(out, "utf8")
.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("🚀");
expect(message).toContain("🔴");
expect(message).toContain("(Python SDK) release failed");
// Exactly two lines (one per lane).
expect(message.split("\n")).toHaveLength(2);
}, 30000);
it("PyPI build failure during a real release (PY_BUILD_RESULT=failure, publish skipped) → 🔴 PyPI alert", () => {
// End-to-end wiring of the PY_BUILD_RESULT env: build-python failed, so
// publish-python was skipped (PY_RESULT=skipped). The notifier must still
// emit the PyPI failure line via the pyBuildResult arm.
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
PY_INTENDED: "true",
PY_PUB: "true",
PY_RESULT: "skipped",
PY_BUILD_RESULT: "failure",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=true");
const m = raw.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("🔴");
expect(message).toContain("(Python SDK) release failed");
}, 30000);
it("build-skipped routine merge (BUILD_RESULT=skipped, MODE='', SCOPE='', NPM_INTENDED='false', PY_INTENDED='false') → should_post=false (no false red)", () => {
// The dominant real-world case: the notify job runs on EVERY merged PR, but
// the build job is `skipped` on a non-release merge (no release/publish/*
// ref) → MODE/SCOPE come back empty and no Python intent signal is set.
// The notifier must stay completely silent — neither a success nor a
// failure line — so a routine docs/feature merge never pages #engr.
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "",
SCOPE: "",
BUILD_RESULT: "skipped",
NPM_RESULT: "skipped",
NPM_INTENDED: "false",
PY_PUB: "",
PY_INTENDED: "false",
PY_RESULT: "skipped",
PY_BUILD_RESULT: "skipped",
RUN_URL: "https://github.com/CopilotKit/CopilotKit/actions/runs/123",
});
expect(code).toBe(0);
const raw = fs.readFileSync(out, "utf8");
expect(raw).toContain("should_post=false");
}, 30000);
it("packageCount=0 (unknown scope) → success line WITHOUT a packages count", () => {
// An empty/unknown SCOPE resolves to 0 packages; the rendered npm line must
// omit the count parenthetical, never print "0 packages".
const out = path.join(tmpDir, "gho.txt");
fs.writeFileSync(out, "");
const { code } = runWrapper({
GITHUB_ACTIONS: "true",
GITHUB_OUTPUT: out,
MODE: "stable",
NPM_RESULT: "success",
NPM_VER: "1.2.3",
BUILD_RESULT: "success",
SCOPE: "",
NPM_URL: "https://www.npmjs.com/org/copilotkit",
});
expect(code).toBe(0);
const m = fs
.readFileSync(out, "utf8")
.match(/^message<<(\S+)\n([\s\S]*?)\n\1\n/m);
expect(m).not.toBeNull();
const message = m![2];
expect(message).toContain("published to npm (`latest`)");
expect(message).not.toContain("packages");
expect(message).not.toContain("0 package");
}, 30000);
});
+66
View File
@@ -0,0 +1,66 @@
import { spawnSync } from "child_process";
import { ROOT } from "./config.js";
export function getLastReleaseTag(): string | null {
const result = spawnSync(
"git",
["tag", "--list", "v*", "--sort=-v:refname"],
{ cwd: ROOT, encoding: "utf8" },
);
const tags = result.stdout.trim().split("\n").filter(Boolean);
for (const tag of tags) {
if (/^v\d+\.\d+\.\d+$/.test(tag)) {
return tag;
}
}
return null;
}
export interface Commit {
hash: string;
subject: string;
}
export function getCommitsSinceLastRelease(): Commit[] {
const lastTag = getLastReleaseTag();
const range = lastTag ? `${lastTag}..HEAD` : "HEAD";
const result = spawnSync(
"git",
["log", range, "--oneline", "--no-merges", "--format=%H %s"],
{ cwd: ROOT, encoding: "utf8" },
);
return result.stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(" ");
return {
hash: line.slice(0, spaceIdx),
subject: line.slice(spaceIdx + 1),
};
});
}
export interface ChangesSummary {
lastTag: string | null;
commitCount: number;
commits: Commit[];
oneline: string;
}
export function getChangesSummary(): ChangesSummary {
const lastTag = getLastReleaseTag();
const commits = getCommitsSinceLastRelease();
return {
lastTag,
commitCount: commits.length,
commits,
oneline: commits.map((c) => `- ${c.subject}`).join("\n"),
};
}
+46
View File
@@ -0,0 +1,46 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
export const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
export type ReleaseScope =
| "monorepo"
| "angular"
| "channels"
| "channels-discord"
| "channels-intelligence"
| "channels-slack"
| "channels-teams"
| "channels-telegram"
| "channels-whatsapp";
export interface ScopeConfig {
packages: string[];
versionSource: string;
sharedVersion: boolean;
}
export interface ReleaseConfig {
prereleaseTag: string;
scopes: Record<ReleaseScope, ScopeConfig>;
}
export function loadConfig(): ReleaseConfig {
const configPath = path.join(ROOT, "release.config.json");
return JSON.parse(fs.readFileSync(configPath, "utf8"));
}
export function getScopeConfig(scope: ReleaseScope): ScopeConfig {
const config = loadConfig();
const scopeConfig = config.scopes[scope];
if (!scopeConfig) {
throw new Error(
`Unknown scope: ${scope}. Valid scopes: ${Object.keys(config.scopes).join(", ")}`,
);
}
return scopeConfig;
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import { emitGithubOutputs } from "./github-output.js";
let tmpDir: string;
let outputFile: string;
const originalGithubOutput = process.env.GITHUB_OUTPUT;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-output-"));
outputFile = path.join(tmpDir, "output");
fs.writeFileSync(outputFile, "");
process.env.GITHUB_OUTPUT = outputFile;
});
afterEach(() => {
// Restore spies first so they cannot leak into the env/fs cleanup below.
vi.restoreAllMocks();
if (originalGithubOutput === undefined) {
delete process.env.GITHUB_OUTPUT;
} else {
process.env.GITHUB_OUTPUT = originalGithubOutput;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("emitGithubOutputs", () => {
it("appends key=value lines to the GITHUB_OUTPUT file", () => {
emitGithubOutputs({ version: "1.2.3-canary.42", scope: "monorepo" });
expect(fs.readFileSync(outputFile, "utf8")).toBe(
"version=1.2.3-canary.42\nscope=monorepo\n",
);
});
it("appends without truncating prior outputs", () => {
fs.writeFileSync(outputFile, "earlier=value\n");
emitGithubOutputs({ version: "1.2.3" });
expect(fs.readFileSync(outputFile, "utf8")).toBe(
"earlier=value\nversion=1.2.3\n",
);
});
it("is a no-op when GITHUB_OUTPUT is unset", () => {
delete process.env.GITHUB_OUTPUT;
const appendSpy = vi.spyOn(fs, "appendFileSync");
expect(() => emitGithubOutputs({ version: "1.2.3" })).not.toThrow();
expect(appendSpy).not.toHaveBeenCalled();
});
it("throws when a value contains a newline, naming the offending key", () => {
expect(() =>
emitGithubOutputs({ version: "1.2.3\nmalicious=evil" }),
).toThrow(/version/);
});
it("throws when a value contains a carriage return", () => {
expect(() => emitGithubOutputs({ version: "1.2.3\r" })).toThrow(/version/);
});
it("throws when a key contains a newline", () => {
expect(() => emitGithubOutputs({ "bad\nkey": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key contains '='", () => {
expect(() => emitGithubOutputs({ "bad=key": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key contains a space", () => {
expect(() => emitGithubOutputs({ "bad key": "value" })).toThrow(
/alphanumeric/,
);
});
it("throws when a key is empty", () => {
expect(() => emitGithubOutputs({ "": "value" })).toThrow(/alphanumeric/);
});
it("accepts a value containing '=' and writes it verbatim", () => {
emitGithubOutputs({ note: "a=b" });
expect(fs.readFileSync(outputFile, "utf8")).toBe("note=a=b\n");
});
});
+36
View File
@@ -0,0 +1,36 @@
import fs from "fs";
/**
* Append step outputs to the file GitHub Actions exposes via GITHUB_OUTPUT.
*
* The publish-release workflow's "Verify publish step emitted version" guard
* and the downstream summary/tag steps read `steps.publish.outputs.version`
* (and `scope`), so every publish script must emit these after publishing.
* No-op outside CI (GITHUB_OUTPUT unset), e.g. when running locally.
*
* Keys must match GitHub Actions' safe output-name shape
* (`/^[A-Za-z_][A-Za-z0-9_-]*$/`); values must be single-line (no newline or
* carriage return), since GITHUB_OUTPUT's `key=value` form splits on the first
* `=` and cannot carry newlines (multi-line values would need the heredoc
* form, which this helper deliberately does not support).
*/
export function emitGithubOutputs(outputs: Record<string, string>): void {
for (const [key, value] of Object.entries(outputs)) {
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
throw new Error(
`emitGithubOutputs: key ${JSON.stringify(key)} is not a valid GitHub Actions output name; keys must be alphanumeric/underscore/dash and start with a letter or underscore.`,
);
}
if (/[\n\r]/.test(value)) {
throw new Error(
`emitGithubOutputs: value for key ${JSON.stringify(key)} contains a newline or carriage return; GITHUB_OUTPUT's key=value form cannot carry newlines (multi-line values would need the heredoc form, which this helper deliberately does not support).`,
);
}
}
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
const lines = Object.entries(outputs)
.map(([key, value]) => `${key}=${value}\n`)
.join("");
fs.appendFileSync(outputPath, lines);
}
+196
View File
@@ -0,0 +1,196 @@
import https from "https";
/**
* Notion API helper for release notes management.
*
* Creates a draft release notes page under the configured parent page.
* On merge, reads the (potentially edited) page content back for the
* GitHub Release body.
*
* Required env vars:
* NOTION_API_KEY — Notion internal integration token
* NOTION_RELEASE_NOTES_PAGE — Parent page ID for release note drafts
*/
const NOTION_API_VERSION = "2022-06-28";
function notionRequest(
apiKey: string,
method: string,
path: string,
body?: unknown,
): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
hostname: "api.notion.com",
path,
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Notion-Version": NOTION_API_VERSION,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk: string) => (data += chunk));
res.on("end", () => {
const parsed = JSON.parse(data);
if (res.statusCode && res.statusCode >= 400) {
reject(
new Error(
`Notion API ${res.statusCode}: ${parsed.message || data}`,
),
);
} else {
resolve(parsed);
}
});
});
req.on("error", reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
/** Convert a markdown string into Notion block children (simplified). */
function markdownToBlocks(markdown: string): any[] {
const blocks: any[] = [];
const lines = markdown.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("### ")) {
blocks.push({
object: "block",
type: "heading_3",
heading_3: {
rich_text: [{ type: "text", text: { content: line.slice(4) } }],
},
});
} else if (line.startsWith("## ")) {
blocks.push({
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: line.slice(3) } }],
},
});
} else if (line.startsWith("- ")) {
blocks.push({
object: "block",
type: "bulleted_list_item",
bulleted_list_item: {
rich_text: [{ type: "text", text: { content: line.slice(2) } }],
},
});
} else if (line.trim() === "") {
continue;
} else {
blocks.push({
object: "block",
type: "paragraph",
paragraph: {
rich_text: [{ type: "text", text: { content: line } }],
},
});
}
}
return blocks;
}
/** Convert Notion blocks back to markdown. */
function blocksToMarkdown(blocks: any[]): string {
const lines: string[] = [];
for (const block of blocks) {
const richText =
block[block.type]?.rich_text
?.map((t: any) => t.plain_text || "")
.join("") || "";
switch (block.type) {
case "heading_1":
lines.push(`# ${richText}`);
break;
case "heading_2":
lines.push(`## ${richText}`);
break;
case "heading_3":
lines.push(`### ${richText}`);
break;
case "bulleted_list_item":
lines.push(`- ${richText}`);
break;
case "numbered_list_item":
lines.push(`1. ${richText}`);
break;
case "paragraph":
lines.push(richText || "");
break;
case "divider":
lines.push("---");
break;
default:
if (richText) lines.push(richText);
}
}
return lines.join("\n");
}
/**
* Create a Notion page with release notes content.
* Returns { pageId, url }.
*/
export async function createReleaseDraft(
version: string,
markdownContent: string,
): Promise<{ pageId: string; url: string }> {
const apiKey = process.env.NOTION_API_KEY;
const parentPageId = process.env.NOTION_RELEASE_NOTES_PAGE;
if (!apiKey || !parentPageId) {
throw new Error("NOTION_API_KEY and NOTION_RELEASE_NOTES_PAGE must be set");
}
const blocks = markdownToBlocks(markdownContent);
const page = await notionRequest(apiKey, "POST", "/v1/pages", {
parent: { page_id: parentPageId },
properties: {
title: {
title: [{ text: { content: `v${version} Release Notes (Draft)` } }],
},
},
children: blocks,
});
return {
pageId: page.id,
url: page.url,
};
}
/**
* Read a Notion page's content back as markdown.
* Used on merge to get the (potentially human-edited) release notes.
*/
export async function readReleaseDraft(pageId: string): Promise<string> {
const apiKey = process.env.NOTION_API_KEY;
if (!apiKey) {
throw new Error("NOTION_API_KEY must be set");
}
const response = await notionRequest(
apiKey,
"GET",
`/v1/blocks/${pageId}/children?page_size=100`,
);
return blocksToMarkdown(response.results);
}
+230
View File
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import {
parseSemver,
computeNextStableVersion,
computePrereleaseVersion,
bumpPackages,
} from "./versions.js";
let tmpDir: string;
// Mock config — ROOT points to a temp dir for bumpPackages tests
vi.mock("./config.js", async () => {
return {
get ROOT() {
return tmpDir || "/mock";
},
loadConfig: () => ({
prereleaseTag: "canary",
scopes: {
monorepo: {
packages: ["@copilotkit/shared", "@copilotkit/react-core"],
versionSource: "@copilotkit/react-core",
sharedVersion: true,
},
angular: {
packages: ["@copilotkit/angular"],
versionSource: "@copilotkit/angular",
sharedVersion: false,
},
},
}),
getScopeConfig: (scope: string) => {
const scopes: Record<string, any> = {
monorepo: {
packages: ["@copilotkit/shared", "@copilotkit/react-core"],
versionSource: "@copilotkit/react-core",
sharedVersion: true,
},
angular: {
packages: ["@copilotkit/angular"],
versionSource: "@copilotkit/angular",
sharedVersion: false,
},
};
return scopes[scope];
},
};
});
describe("parseSemver", () => {
it("parses a stable version", () => {
expect(parseSemver("1.55.2")).toEqual({
major: 1,
minor: 55,
patch: 2,
prerelease: null,
});
});
it("parses a prerelease version", () => {
expect(parseSemver("1.55.2-canary.1744382400")).toEqual({
major: 1,
minor: 55,
patch: 2,
prerelease: "canary.1744382400",
});
});
it("parses a version with text prerelease", () => {
expect(parseSemver("2.0.0-canary.fix-user-issue")).toEqual({
major: 2,
minor: 0,
patch: 0,
prerelease: "canary.fix-user-issue",
});
});
it("throws on invalid version", () => {
expect(() => parseSemver("not-a-version")).toThrow("Invalid semver");
});
it("throws on empty string", () => {
expect(() => parseSemver("")).toThrow("Invalid semver");
});
});
describe("computeNextStableVersion", () => {
it("bumps patch", () => {
expect(computeNextStableVersion("1.55.2", "patch")).toBe("1.55.3");
});
it("bumps minor", () => {
expect(computeNextStableVersion("1.55.2", "minor")).toBe("1.56.0");
});
it("bumps major", () => {
expect(computeNextStableVersion("1.55.2", "major")).toBe("2.0.0");
});
it("strips prerelease suffix on any bump", () => {
expect(computeNextStableVersion("1.56.0-canary.123", "patch")).toBe(
"1.56.0",
);
});
it("strips prerelease suffix regardless of bump level", () => {
expect(computeNextStableVersion("2.0.0-canary.123", "major")).toBe("2.0.0");
});
it("handles 0.x versions", () => {
expect(computeNextStableVersion("0.1.0", "patch")).toBe("0.1.1");
expect(computeNextStableVersion("0.1.0", "minor")).toBe("0.2.0");
expect(computeNextStableVersion("0.1.0", "major")).toBe("1.0.0");
});
});
describe("computePrereleaseVersion", () => {
it("appends canary tag with timestamp when no suffix given", () => {
const result = computePrereleaseVersion("1.55.2");
expect(result).toMatch(/^1\.55\.2-canary\.\d+$/);
});
it("appends canary tag with custom suffix", () => {
expect(computePrereleaseVersion("1.55.2", "fix-user-issue")).toBe(
"1.55.2-canary.fix-user-issue",
);
});
it("uses the base version as-is (no bump)", () => {
expect(computePrereleaseVersion("1.55.2", "test")).toBe(
"1.55.2-canary.test",
);
expect(computePrereleaseVersion("2.0.0", "test")).toBe("2.0.0-canary.test");
});
it("strips existing prerelease before appending", () => {
expect(computePrereleaseVersion("1.55.2-canary.old", "new")).toBe(
"1.55.2-canary.new",
);
});
});
describe("bumpPackages", () => {
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "release-test-"));
const packagesDir = path.join(tmpDir, "packages");
// Create shared package
const sharedDir = path.join(packagesDir, "shared");
fs.mkdirSync(sharedDir, { recursive: true });
fs.writeFileSync(
path.join(sharedDir, "package.json"),
JSON.stringify({
name: "@copilotkit/shared",
version: "1.55.2",
}),
);
// Create react-core with workspace:* dep on shared
const reactCoreDir = path.join(packagesDir, "react-core");
fs.mkdirSync(reactCoreDir, { recursive: true });
fs.writeFileSync(
path.join(reactCoreDir, "package.json"),
JSON.stringify({
name: "@copilotkit/react-core",
version: "1.55.2",
dependencies: {
"@copilotkit/shared": "workspace:*",
},
}),
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("bumps version fields", () => {
const result = bumpPackages("monorepo", "1.55.3");
expect(result).toHaveLength(2);
expect(result[0].newVersion).toBe("1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/shared/package.json"),
"utf8",
),
);
expect(pkg.version).toBe("1.55.3");
});
it("preserves workspace:* protocol in dependencies", () => {
bumpPackages("monorepo", "1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
"utf8",
),
);
expect(pkg.dependencies["@copilotkit/shared"]).toBe("workspace:*");
});
it("updates exact version deps (not workspace protocol)", () => {
// Write react-core with an exact version dep instead of workspace:*
fs.writeFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
JSON.stringify({
name: "@copilotkit/react-core",
version: "1.55.2",
dependencies: {
"@copilotkit/shared": "1.55.2",
},
}),
);
bumpPackages("monorepo", "1.55.3");
const pkg = JSON.parse(
fs.readFileSync(
path.join(tmpDir, "packages/react-core/package.json"),
"utf8",
),
);
expect(pkg.dependencies["@copilotkit/shared"]).toBe("1.55.3");
});
});
+158
View File
@@ -0,0 +1,158 @@
import fs from "fs";
import path from "path";
import {
loadConfig,
getScopeConfig,
ROOT,
type ReleaseScope,
} from "./config.js";
export type BumpLevel = "patch" | "minor" | "major";
interface SemVer {
major: number;
minor: number;
patch: number;
prerelease: string | null;
}
export interface PublishablePackage {
name: string;
dir: string;
pkgJsonPath: string;
pkg: Record<string, any>;
}
/** Find a package directory by its npm name. */
function findPackageDir(packageName: string): string {
const packagesDir = path.join(ROOT, "packages");
for (const dir of fs.readdirSync(packagesDir)) {
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
if (!fs.existsSync(pkgJsonPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
if (pkg.name === packageName) return path.join(packagesDir, dir);
}
throw new Error(`Package not found: ${packageName}`);
}
/** Get the current version for a scope (reads from the scope's versionSource package). */
export function getCurrentVersion(scope: ReleaseScope): string {
const scopeConfig = getScopeConfig(scope);
const dir = findPackageDir(scopeConfig.versionSource);
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
);
return pkg.version;
}
export function parseSemver(version: string): SemVer {
const match = version.match(
/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.\-]+))?(?:\+(.+))?$/,
);
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4] || null,
};
}
export function computeNextStableVersion(
currentVersion: string,
bumpLevel: BumpLevel,
): string {
const v = parseSemver(currentVersion);
if (v.prerelease) {
return `${v.major}.${v.minor}.${v.patch}`;
}
switch (bumpLevel) {
case "major":
return `${v.major + 1}.0.0`;
case "minor":
return `${v.major}.${v.minor + 1}.0`;
case "patch":
return `${v.major}.${v.minor}.${v.patch + 1}`;
}
}
export function computePrereleaseVersion(
currentVersion: string,
suffix?: string,
): string {
const v = parseSemver(currentVersion);
const config = loadConfig();
const tag = config.prereleaseTag;
const id = suffix || String(Math.floor(Date.now() / 1000));
return `${v.major}.${v.minor}.${v.patch}-${tag}.${id}`;
}
/** Get all publishable packages for a given scope. */
export function getPackagesForScope(scope: ReleaseScope): PublishablePackage[] {
const scopeConfig = getScopeConfig(scope);
const packageNames = new Set(scopeConfig.packages);
const packagesDir = path.join(ROOT, "packages");
const results: PublishablePackage[] = [];
for (const dir of fs.readdirSync(packagesDir)) {
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
if (!fs.existsSync(pkgJsonPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
if (packageNames.has(pkg.name)) {
results.push({
name: pkg.name,
dir: path.join(packagesDir, dir),
pkgJsonPath,
pkg,
});
}
}
return results;
}
/** Bump all packages in a scope to a new version. For sharedVersion scopes, also updates internal deps. */
export function bumpPackages(
scope: ReleaseScope,
newVersion: string,
): { name: string; oldVersion: string; newVersion: string }[] {
const scopeConfig = getScopeConfig(scope);
const packages = getPackagesForScope(scope);
const scopeNames = new Set(scopeConfig.packages);
const updated: { name: string; oldVersion: string; newVersion: string }[] =
[];
for (const p of packages) {
const pkg = JSON.parse(fs.readFileSync(p.pkgJsonPath, "utf8"));
const oldVersion = pkg.version;
pkg.version = newVersion;
// For shared-version scopes, update internal dependency references —
// but only if they use exact versions, not workspace:* protocol
if (scopeConfig.sharedVersion) {
for (const depField of [
"dependencies",
"peerDependencies",
"devDependencies",
] as const) {
if (!pkg[depField]) continue;
for (const depName of Object.keys(pkg[depField])) {
const depValue = pkg[depField][depName];
if (scopeNames.has(depName) && !depValue.startsWith("workspace:")) {
pkg[depField][depName] = newVersion;
}
}
}
}
fs.writeFileSync(p.pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
updated.push({ name: p.name, oldVersion, newVersion });
}
return updated;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Prepare a release: bump versions, generate raw release notes.
* Runs inside the "create release PR" workflow.
*
* Usage: tsx scripts/release/prepare-release.ts --bump <patch|minor|major> --scope <scope from release.config.json> [--dry-run]
*/
import fs from "fs";
import path from "path";
import {
getCurrentVersion,
computeNextStableVersion,
bumpPackages,
getPackagesForScope,
type BumpLevel,
} from "./lib/versions.js";
import {
getChangesSummary,
type ChangesSummary,
type Commit,
} from "./lib/changes.js";
import { ROOT, loadConfig, type ReleaseScope } from "./lib/config.js";
function generateRawReleaseNotes(
version: string,
scope: ReleaseScope,
summary: ChangesSummary,
): string {
const lines: string[] = [];
const label = scope === "monorepo" ? "" : ` (${scope})`;
lines.push(`## v${version}${label}`, "");
if (summary.commits.length === 0) {
lines.push("No changes since last release.");
return lines.join("\n");
}
const features: Commit[] = [];
const fixes: Commit[] = [];
const other: Commit[] = [];
for (const c of summary.commits) {
if (/^feat[:(]/.test(c.subject)) features.push(c);
else if (/^fix[:(]/.test(c.subject)) fixes.push(c);
else other.push(c);
}
if (features.length > 0) {
lines.push("### Features", "");
for (const c of features)
lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
if (fixes.length > 0) {
lines.push("### Fixes", "");
for (const c of fixes) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
if (other.length > 0) {
lines.push("### Other Changes", "");
for (const c of other) lines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
lines.push("");
}
return lines.join("\n");
}
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const bumpIdx = argv.indexOf("--bump");
const bumpLevel = (
bumpIdx !== -1 ? argv[bumpIdx + 1] : null
) as BumpLevel | null;
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!bumpLevel || !["patch", "minor", "major"].includes(bumpLevel)) {
console.error(
"Usage: prepare-release.ts --bump <patch|minor|major> --scope <scope from release.config.json>",
);
process.exit(1);
}
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Invalid scope: ${scope}. Valid scopes: ${VALID_SCOPES.join(", ")}`,
);
process.exit(1);
}
const currentVersion = getCurrentVersion(scope);
const nextVersion = computeNextStableVersion(currentVersion, bumpLevel);
console.log(`Scope: ${scope}`);
console.log(`Current version: ${currentVersion}`);
console.log(`Bump level: ${bumpLevel}`);
console.log(`Next version: ${nextVersion}`);
const summary = getChangesSummary();
console.log(
`\nCommits since ${summary.lastTag || "beginning"}: ${summary.commitCount}`,
);
if (dryRun) {
console.log("\n[DRY RUN] Would bump these packages:");
for (const p of getPackagesForScope(scope)) {
console.log(` ${p.name}: ${p.pkg.version} -> ${nextVersion}`);
}
console.log("\n[DRY RUN] Exiting.");
return;
}
const updated = bumpPackages(scope, nextVersion);
console.log(`\nBumped ${updated.length} packages to ${nextVersion}`);
const rawNotes = generateRawReleaseNotes(nextVersion, scope, summary);
const releaseNotesPath = path.join(ROOT, "release-notes.md");
fs.writeFileSync(releaseNotesPath, rawNotes);
console.log("Raw release notes written to release-notes.md");
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath) {
fs.appendFileSync(outputPath, `version=${nextVersion}\n`);
fs.appendFileSync(outputPath, `scope=${scope}\n`);
}
console.log(`\nRelease prepared: v${nextVersion} (${scope})`);
}
main();
+122
View File
@@ -0,0 +1,122 @@
/**
* Publish a prerelease to npm (publish-only, no build/test/bump).
*
* Version bumping is handled by bump-prerelease.ts in the secrets-free CI
* build job. Build and test also run there. This script receives pre-built,
* correctly-versioned artifacts and only performs the npm publish step.
*
* Always publishes with the "canary" dist-tag.
*
* Usage: tsx scripts/release/prerelease.ts --scope <scope from release.config.json> [--dry-run]
*/
import { spawnSync } from "child_process";
import { getPackagesForScope } from "./lib/versions.js";
import { ROOT, loadConfig } from "./lib/config.js";
import type { ReleaseScope } from "./lib/config.js";
import { emitGithubOutputs } from "./lib/github-output.js";
function run(cmd: string, args: string[], opts?: { cwd?: string }) {
const result = spawnSync(cmd, args, {
cwd: opts?.cwd ?? ROOT,
stdio: "inherit",
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}
return result;
}
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
function main() {
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--dry-run]`,
);
process.exit(1);
}
const config = loadConfig();
const distTag = config.prereleaseTag;
// Read the version from package.json — already bumped by bump-prerelease.ts
// in the CI build job.
const packages = getPackagesForScope(scope);
if (packages.length === 0) {
console.error(
`No packages found for scope "${scope}" — refusing to emit a version for a publish that did nothing.`,
);
process.exit(1);
}
const publishVersion = packages[0].pkg.version;
if (!publishVersion) {
console.error(
`Package ${packages[0].name} has no version field; refusing to publish.`,
);
process.exit(1);
}
console.log(`Scope: ${scope}`);
console.log(`Publishing version: ${publishVersion}`);
console.log(`Dist tag: ${distTag}`);
if (dryRun) {
console.log("\n[DRY RUN] Would publish these packages:");
for (const p of packages) {
console.log(` ${p.name}@${p.pkg.version}`);
}
// Emitting in dry-run is safe — the publish workflow gates both the
// publish step and the verify guard on `inputs.dry-run != true`, so this
// only serves local/e2e verification of the output contract.
emitGithubOutputs({ version: publishVersion, scope });
console.log("\n[DRY RUN] Exiting.");
return;
}
// NOTE: Version bumping is handled by bump-prerelease.ts in the CI build
// job (no secrets). Build and test also run there.
// The publish job receives pre-built artifacts via download-artifact.
// We intentionally do NOT rebuild/retest here to keep NPM_TOKEN out
// of the build process tree.
// Publish each package via pnpm pack + npx npm@11 (OIDC-aware)
console.log("\nPublishing packages...");
for (const p of packages) {
console.log(
` Publishing ${p.name}@${p.pkg.version} with tag ${distTag}...`,
);
run("pnpm", ["pack"], { cwd: p.dir });
const tarball = `${p.name.replace("@", "").replace("/", "-")}-${p.pkg.version}.tgz`;
run(
"npx",
[
"--yes",
"npm@11.15.0",
"publish",
tarball,
"--tag",
distTag,
"--access",
"public",
],
{ cwd: p.dir },
);
}
// The workflow's "Verify publish step emitted version" guard and the
// prerelease summary read these from steps.publish.outputs.
emitGithubOutputs({ version: publishVersion, scope });
console.log(`\nPrerelease published: ${publishVersion} (tag: ${distTag})`);
}
main();
+216
View File
@@ -0,0 +1,216 @@
/**
* Publish a stable release (runs after merge of a release PR).
*
* 1. Reads the scope and current version from package.json (already bumped by the release PR)
* 2. Optionally reads the Notion draft for the final release notes
* 3. Publishes pre-built packages to npm with "latest" tag
* 4. Outputs the version for downstream steps (git tag, GitHub Release)
*
* NOTE: Build is handled by the separate CI build job (no publish secrets).
* This script receives pre-built artifacts and only performs the publish step.
*
* Env vars:
* NOTION_API_KEY — for reading edited release notes from Notion (optional)
* GITHUB_OUTPUT — CI output file
*
* Auth: Uses npm OIDC trusted publishers (id-token: write) via npx npm@11.
* No NPM_TOKEN needed — NODE_AUTH_TOKEN must be empty to avoid blocking OIDC.
*
* Usage: tsx scripts/release/publish-release.ts --scope <scope from release.config.json>
*/
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
import {
getCurrentVersion,
getPackagesForScope,
parseSemver,
} from "./lib/versions.js";
import { readReleaseDraft } from "./lib/notion.js";
import {
ROOT,
getScopeConfig,
loadConfig,
type ReleaseScope,
} from "./lib/config.js";
import { emitGithubOutputs } from "./lib/github-output.js";
function run(cmd: string, args: string[], opts?: { cwd?: string }) {
const result = spawnSync(cmd, args, {
cwd: opts?.cwd ?? ROOT,
stdio: "inherit",
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
}
return result;
}
function getPublishedVersion(packageName: string): string | null {
const result = spawnSync("npm", ["view", packageName, "version"], {
encoding: "utf8",
timeout: 15000,
});
if (result.status === 0) {
return result.stdout.trim() || null;
}
// E404 means package doesn't exist on registry — genuinely not published
if (
result.stderr?.includes("E404") ||
result.stderr?.includes("is not in this registry")
) {
return null;
}
// Any other error (network, auth, rate limit) should stop the release
throw new Error(
`npm registry check failed for ${packageName}: ${result.stderr?.trim() || "unknown error"}`,
);
}
function isGreaterVersion(next: string, current: string): boolean {
const a = parseSemver(next);
const b = parseSemver(current);
if (a.major !== b.major) return a.major > b.major;
if (a.minor !== b.minor) return a.minor > b.minor;
return a.patch > b.patch;
}
// Valid scopes come from release.config.json — the single source of truth.
const VALID_SCOPES = Object.keys(loadConfig().scopes);
async function main() {
const argv = process.argv.slice(2);
const scopeIdx = argv.indexOf("--scope");
const scope = (
scopeIdx !== -1 ? argv[scopeIdx + 1] : null
) as ReleaseScope | null;
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: publish-release.ts --scope <${VALID_SCOPES.join("|")}>`,
);
process.exit(1);
}
const version = getCurrentVersion(scope);
const scopeConfig = getScopeConfig(scope);
// Resolve packages before any version/registry safety checks so that a
// misconfigured scope fails with a clear "no packages" error instead of a
// misleading "not greater than published" one.
const packages = getPackagesForScope(scope);
if (packages.length === 0) {
console.error(
`No packages found for scope "${scope}" — refusing to emit a version for a publish that did nothing.`,
);
process.exit(1);
}
console.log(`Scope: ${scope}`);
console.log(`Publishing version: ${version}`);
// Safety check: only allow clean semver (no prerelease suffixes like -canary.123)
const v = parseSemver(version);
if (v.prerelease) {
console.error(
`Refusing to publish: ${version} contains a prerelease suffix. ` +
`Stable releases must be clean semver (e.g. 1.55.3).`,
);
process.exit(1);
}
// Safety check: refuse to publish if version isn't greater than what's on npm
let published: string | null;
try {
published = getPublishedVersion(scopeConfig.versionSource);
} catch (err: any) {
console.error(
`Registry check failed — aborting release to avoid publishing over an existing version.`,
);
console.error(err.message);
process.exit(1);
}
if (published) {
console.log(`Currently published version: ${published}`);
if (!isGreaterVersion(version, published)) {
console.error(
`Refusing to publish: ${version} is not greater than the currently published ${published}.`,
);
process.exit(1);
}
}
// Try to read edited release notes from Notion
const notionRefPath = path.join(ROOT, "release-notes-notion.json");
const releaseNotesPath = path.join(ROOT, "release-notes.md");
if (fs.existsSync(notionRefPath)) {
try {
const ref = JSON.parse(fs.readFileSync(notionRefPath, "utf8"));
if (ref.pageId && process.env.NOTION_API_KEY) {
console.log("Reading edited release notes from Notion...");
const notionContent = await readReleaseDraft(ref.pageId);
if (notionContent.trim()) {
fs.writeFileSync(releaseNotesPath, notionContent);
console.log("Release notes updated from Notion draft.");
}
}
} catch (err: any) {
console.error(`Failed to read Notion draft: ${err.message}`);
console.log("Using release notes from the PR branch.");
}
}
// NOTE: Build is handled by the CI build job (no secrets).
// The publish job receives pre-built artifacts via download-artifact.
// We intentionally do NOT rebuild here to keep NPM_TOKEN out of the
// build process tree.
// Publish each package in scope.
// Uses pnpm pack (workspace-aware) + npx npm@11 publish (OIDC-aware).
// npm 11 uses GitHub Actions OIDC tokens for auth when id-token: write
// is granted, eliminating the need for long-lived NPM_TOKEN secrets.
// Skips packages already published at this version (idempotent retries).
console.log("\nPublishing packages...");
let skipped = 0;
for (const p of packages) {
const pubVersion = getPublishedVersion(p.name);
if (pubVersion === version) {
console.log(` Skipping ${p.name}@${version} (already published)`);
skipped++;
continue;
}
console.log(` Publishing ${p.name}@${version}...`);
run("pnpm", ["pack"], { cwd: p.dir });
const tarball = `${p.name.replace("@", "").replace("/", "-")}-${version}.tgz`;
run(
"npx",
[
"--yes",
"npm@11.15.0",
"publish",
tarball,
"--tag",
"latest",
"--access",
"public",
],
{ cwd: p.dir },
);
}
if (skipped > 0) {
console.log(`\n${skipped} package(s) skipped (already at ${version}).`);
}
// Output version for downstream steps
emitGithubOutputs({ version, scope });
console.log(`\nRelease published: ${version} (${scope})`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env bash
# scripts/release/verify-release-scope-dropdowns.sh
#
# Verifies that the hand-maintained `workflow_dispatch` `scope` choice
# dropdowns in the release workflows match the authoritative set of release
# scopes declared in release.config.json (`.scopes` keys, at the REPO ROOT —
# the TS side loads the same file via scripts/release/lib/config.ts).
#
# Why this matters: the release workflows expose a `scope` input as a
# `type: choice` with a hard-coded `options:` list. That list is supposed to
# be "regenerated from release.config.json", but nothing enforced it — so as
# packages were enrolled/renamed in release.config.json the dropdowns could
# drift (newly-enrolled packages wouldn't be canary-selectable; stale scopes
# would linger). This guard fails CI whenever a dropdown diverges from the
# config.
#
# Three files are checked:
# .github/workflows/publish-release.yml — canary/prerelease + stable-retry `scope` input
# .github/workflows/stable-release.yml — create-pr `scope` input (release / create-pr)
# .github/workflows/canary.yml — one-click canary orchestrator `scope` input
#
# Sentinel exception: none of the workflows uses a non-scope sentinel option
# (no `all` / `canary` pseudo-scope — an empty/omitted scope is handled
# outside the options list). If a sentinel is ever introduced, add it to
# SENTINELS below so it is excluded from the equality check.
#
# Secondary projection guarded here: publish-release.yml's `notify` job has a
# `Resolve npm URL for scope` step whose `case "$SCOPE"` maps a dispatch
# scope to a per-scope npm URL. Unlike ag-ui's per-scope ecosystem case
# (which required full coverage), CopilotKit's case uses a `*)` catch-all,
# so full per-scope coverage is NOT required. We instead verify the WEAKER
# but still useful invariant: every EXPLICITLY-named arm (i.e. every arm
# other than the `*)` catch-all) MUST be a valid scope from
# release.config.json. This catches stale or renamed scopes lingering as
# dead arms after a release.config.json rename/removal. See check_notify_case.
set -euo pipefail
# Make sort/comm/diff/string comparison byte-deterministic across environments (macOS vs CI).
export LC_ALL=C
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CONFIG="$REPO_ROOT/release.config.json"
PUBLISH_WF="$REPO_ROOT/.github/workflows/publish-release.yml"
STABLE_WF="$REPO_ROOT/.github/workflows/stable-release.yml"
CANARY_WF="$REPO_ROOT/.github/workflows/canary.yml"
# Documented non-scope sentinel options to ignore (none today). Space-separated.
SENTINELS=""
for f in "$CONFIG" "$PUBLISH_WF" "$STABLE_WF" "$CANARY_WF"; do
if [ ! -f "$f" ]; then
echo "ERROR: $f not found" >&2
exit 1
fi
done
# Authoritative scope set from release.config.json.
CONFIG_SCOPES=$(jq -r '.scopes | keys[]' "$CONFIG" | sort -u)
if [ -z "$CONFIG_SCOPES" ]; then
echo "ERROR: no scopes found under '.scopes' in $CONFIG — config corrupt or schema changed." >&2
exit 1
fi
# Extract the `options:` list belonging to the `scope:` input from a workflow.
# Uses yq when available (the CI path on ubuntu-latest), otherwise a robust awk
# pass (the local-dev fallback):
# - find the `scope:` input key (an `inputs:` child, indented 6 spaces),
# - within that block find its `options:` line,
# - collect the `- value` list items until indentation drops back out.
#
# yq path: the `on` key is quoted as .["on"] so it is read as the literal map
# key and never YAML-1.1-boolean-coerced (`on`/`off`/`yes`/`no` → true/false).
# The result is emitted on stdout; callers MUST treat zero options as a PARSER
# failure (loud), distinct from a real drift mismatch — see check_workflow.
extract_scope_options() {
local file="$1"
if command -v yq >/dev/null 2>&1; then
yq -r '.["on"].workflow_dispatch.inputs.scope.options[]' "$file" | sort -u
return
fi
awk '
# Match the scope input key: " scope:" (6-space indent under inputs:).
/^ scope:[[:space:]]*$/ { in_scope = 1; next }
# Next sibling input ends the scope block. Match keys with OR without an
# inline value (the ` scope:` opener is consumed by the rule above).
in_scope && /^ [a-zA-Z0-9_-]+:/ { in_scope = 0 }
in_scope && /^ options:[[:space:]]*$/ { in_opts = 1; next }
in_opts {
# Skip blank lines and full-line YAML comments so readability whitespace
# or `# comment` lines between options do not silently terminate the
# collection (truncating the option set).
if ($0 ~ /^[[:space:]]*(#|$)/) next
# An options list item: " - value"
if (match($0, /^[[:space:]]*-[[:space:]]+/)) {
val = $0
sub(/^[[:space:]]*-[[:space:]]+/, "", val)
sub(/[[:space:]]*#.*$/, "", val) # inline YAML comment
gsub(/^["'"'"']|["'"'"']$/, "", val) # surrounding quotes
sub(/[[:space:]]+$/, "", val)
if (val != "") print val
next
}
# Any non-list-item line ends the options block.
in_opts = 0
in_scope = 0
}
' "$file" | sort -u
}
# Strip documented sentinels from an option set before comparing.
strip_sentinels() {
local opts="$1"
if [ -z "$SENTINELS" ]; then
printf '%s\n' "$opts"
return
fi
local filtered="$opts"
for s in $SENTINELS; do
# Fixed-string match: sentinels containing regex metacharacters must not over-match.
filtered=$(printf '%s\n' "$filtered" | grep -Fvx -- "$s" || true)
done
printf '%s\n' "$filtered"
}
check_workflow() {
local name="$1" file="$2"
local opts
opts=$(extract_scope_options "$file")
opts=$(strip_sentinels "$opts")
# Zero options means the PARSER could not locate the scope options block (a
# yq/awk extraction failure or a structural change to the workflow), NOT that
# the dropdown drifted. Fail LOUD and distinctly so this is never mistaken for
# a real drift mismatch (which prints a diff below).
if [ -z "$opts" ]; then
echo "ERROR: parser could not find scope options in $file ($name)." >&2
echo " Extracted ZERO options via $(command -v yq >/dev/null 2>&1 && echo yq || echo 'awk fallback')." >&2
echo " This is a PARSER failure (not a drift mismatch): the 'scope' input's" >&2
echo " 'options:' list could not be located. Check the workflow structure or" >&2
echo " the extractor in this script." >&2
return 1
fi
if [ "$opts" = "$CONFIG_SCOPES" ]; then
echo "OK: $name scope dropdown matches release.config.json scopes"
return 0
fi
echo "ERROR: $name scope dropdown is out of sync with release.config.json." >&2
echo "" >&2
echo "--- diff (release.config.json scopes vs $name options) ---" >&2
diff <(printf '%s\n' "$CONFIG_SCOPES") <(printf '%s\n' "$opts") >&2 || true
echo "" >&2
echo "Fix: update the 'scope' input 'options:' list in $file to exactly match" >&2
echo "the keys of '.scopes' in release.config.json" >&2
echo "(plus any documented sentinel listed in SENTINELS within this script)." >&2
return 1
}
# Verify the notify-job `Resolve npm URL for scope` step in publish-release.yml.
# That step's `case "$SCOPE"` maps a dispatch scope to a per-scope npm URL.
# CopilotKit's case uses a `*)` catch-all (unlike ag-ui's per-scope ecosystem
# case which required full coverage), so we cannot assert full coverage here.
# What we CAN — and do — assert is the weaker but still useful invariant:
# every EXPLICITLY-named arm (every arm whose pattern is not `*`) MUST be a
# valid scope from release.config.json. This catches stale/renamed scopes
# lingering as dead arms after a config rename or removal.
check_notify_case() {
local file="$1"
# Guard against silent parser degradation: this awk only recognizes the
# literal form `case "$SCOPE" in` and only the FIRST such block. If the
# workflow is refactored to e.g. `case "${SCOPE}" in`, or if a second block
# is added, the strict parser would silently validate nothing. Cross-check
# a loose grep against the strict form and fail loudly on mismatch.
#
# Both regexes are anchored to the start of the line (allowing only leading
# whitespace) so that prose comments mentioning the words case/SCOPE/in
# cannot trip the guard. Comments start with `#`, so `case` is never their
# first non-whitespace token.
local loose_count strict_count
loose_count=$(grep -cE '^[[:space:]]*case[[:space:]].*SCOPE.*[[:space:]]in([[:space:]]|$)' "$file" || true)
# Default to 0 on a hard grep failure: `grep -c ... || true` yields "0" on
# no-match (grep prints 0, exits 1) but EMPTY if grep itself fails (e.g.
# unreadable file), which would make the later integer `-ne` test die
# cryptically. Same below.
loose_count=${loose_count:-0}
# shellcheck disable=SC2016 # literal $SCOPE is intentional — we are matching shell source text, not expanding
strict_count=$(grep -cE '^[[:space:]]*case[[:space:]]+"\$SCOPE"[[:space:]]+in' "$file" || true)
strict_count=${strict_count:-0}
if [ "$loose_count" -ne "$strict_count" ]; then
echo "ERROR: $file has $loose_count case-on-SCOPE statement(s) but only $strict_count match the strict 'case \"\$SCOPE\" in' form this parser understands." >&2
echo "Update check_notify_case() in $0 to handle the new form." >&2
return 1
fi
if [ "$strict_count" -gt 1 ]; then
echo "ERROR: $file has $strict_count 'case \"\$SCOPE\" in' blocks; this parser validates only the first." >&2
echo "Update check_notify_case() in $0 to validate every block." >&2
return 1
fi
if [ "$strict_count" -eq 0 ]; then
echo "ERROR: no 'case \"\$SCOPE\" in' block found in $file." >&2
echo "The notify-job npm-url step was removed or restructured; update or remove check_notify_case() in $0 accordingly." >&2
return 1
fi
# Pull the explicit case-arm patterns from the FIRST `case "$SCOPE" in ...
# esac` block in the file. The notify job's `Resolve npm URL for scope` step
# is the only `case "$SCOPE"` in publish-release.yml; if more are added,
# this single-block parser will need updating.
local actual_explicit
actual_explicit=$(awk '
/^[[:space:]]*case[[:space:]]+"\$SCOPE"[[:space:]]+in/ { in_case = 1; next }
in_case && /^[[:space:]]*esac[[:space:]]*$/ { in_case = 0; exit }
# Comment lines inside the case body are never arms — skip them BEFORE the
# arm matcher (a comment like `# maps angular)` ends in ")" and would
# otherwise be misparsed as a stale arm).
in_case && /^[[:space:]]*#/ { next }
# Arm-shaped lines only: the entire line before the closing ")" must be
# pattern characters — extended to allow space and surrounding quotes so
# we accept legal forms like `"angular")` and `channels | channels-slack)`. We use a
# negated class that still excludes `=`, `$`, `(`, `:`, `/` (and `)` since
# `)` is the terminator) so body lines like `FOO=$(cmd)` or
# `path: /tmp/foo)` cannot match.
in_case && /^[[:space:]]*[^=$(:\/)]+\)[[:space:]]*$/ {
line = $0
sub(/[[:space:]]*\)[[:space:]]*$/, "", line) # drop trailing ")"
sub(/^[[:space:]]+/, "", line) # drop leading indent
# Skip the catch-all arm.
if (line == "*") next
# Alternation: a|b|c — split on `|` and emit each pattern. Strip
# surrounding whitespace and surrounding quotes (single or double) from
# each alternative so `"angular"` and `channels | channels-slack` both yield the
# bare scope name. The '"'"' pattern is how a single quote is embedded
# inside a single-quoted shell heredoc (see line ~95 above).
n = split(line, arr, "|")
for (i = 1; i <= n; i++) {
p = arr[i]
gsub(/^[[:space:]]+|[[:space:]]+$/, "", p)
gsub(/^["'"'"']|["'"'"']$/, "", p)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", p)
if (p != "" && p != "*") print p
}
}
' "$file" | sort -u)
# If there are no explicit arms (the case is `*)` only), nothing to check.
if [ -z "$actual_explicit" ]; then
echo "OK: publish-release.yml notify-job npm-url case has only a catch-all (nothing to validate)"
return 0
fi
# Every explicit arm must be a valid config scope.
local stale
stale=$(comm -23 <(printf '%s\n' "$actual_explicit") <(printf '%s\n' "$CONFIG_SCOPES"))
if [ -n "$stale" ]; then
echo "ERROR: publish-release.yml notify-job npm-url case names scope(s) not in release.config.json:" >&2
printf '%s\n' "$stale" | sed 's/^/ /' >&2
echo "" >&2
echo "Fix: remove or rename the stale arm(s) in the 'Resolve npm URL for scope' step" >&2
echo "so every explicitly-named case arm matches a key under '.scopes' in" >&2
echo "release.config.json. (The '*)' catch-all handles scopes without a custom URL," >&2
echo "so full per-scope coverage is intentionally NOT required.)" >&2
return 1
fi
echo "OK: publish-release.yml notify-job npm-url case explicit arms are all valid scopes"
return 0
}
rc=0
check_workflow "publish-release.yml" "$PUBLISH_WF" || rc=1
check_workflow "stable-release.yml" "$STABLE_WF" || rc=1
check_workflow "canary.yml" "$CANARY_WF" || rc=1
check_notify_case "$PUBLISH_WF" || rc=1
if [ "$rc" -ne 0 ]; then
exit 1
fi
echo "OK: all release scope dropdowns match release.config.json; notify-job npm-url case explicit arms are all valid scopes"
exit 0
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["scripts/release/lib/**/*.{test,spec}.ts"],
reporters: [["default", { summary: false }]],
silent: true,
},
});