chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user