chore: import upstream snapshot with attribution
This commit is contained in:
+253
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
Executable
+123
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user