From 3fa8c80aeefb4de65ceba8b64f617f35b5a52a38 Mon Sep 17 00:00:00 2001 From: nirooxx Date: Wed, 17 Jun 2026 21:50:13 +0200 Subject: [PATCH 1/2] chore: add TypeScript migration foundation --- src/__tests__/config.test.ts | 224 ++++++++++++++++++++++++++++++ src/__tests__/utils/env.test.ts | 103 ++++++++++++++ src/__tests__/utils/log.test.ts | 98 +++++++++++++ src/__tests__/utils/retry.test.ts | 88 ++++++++++++ src/config.ts | 92 ++++++++++++ src/main.ts | 33 +++++ src/utils/env.ts | 54 +++++++ src/utils/files.ts | 41 ++++++ src/utils/log.ts | 27 ++++ src/utils/retry.ts | 37 +++++ 10 files changed, 797 insertions(+) create mode 100644 src/__tests__/config.test.ts create mode 100644 src/__tests__/utils/env.test.ts create mode 100644 src/__tests__/utils/log.test.ts create mode 100644 src/__tests__/utils/retry.test.ts create mode 100644 src/config.ts create mode 100644 src/main.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/files.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/retry.ts diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..22107cc --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "@jest/globals"; +import { readActionConfig } from "../config.js"; + +const BASE_ENV = { + "INPUT_GITHUB-TOKEN": "ghp_test_token", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_API_URL: "https://api.github.com", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_ACTOR: "octocat", + GITHUB_WORKSPACE: "/github/workspace", +}; + +describe("readActionConfig", () => { + describe("defaults", () => { + test("applies all default values when no optional inputs are set", () => { + const config = readActionConfig(BASE_ENV); + expect(config.dryRun).toBe(false); + expect(config.releaseDraft).toBe(false); + expect(config.releasePrerelease).toBe(false); + expect(config.releaseTitlePrefix).toBe(""); + expect(config.tagTemplate).toBe("v"); + expect(config.changelogFilePath).toBe("CHANGELOG.md"); + expect(config.versionOverride).toBeUndefined(); + }); + + test("reads GitHub token from INPUT_GITHUB-TOKEN", () => { + const config = readActionConfig(BASE_ENV); + expect(config.githubToken).toBe("ghp_test_token"); + }); + + test("falls back to GITHUB_TOKEN when INPUT_GITHUB-TOKEN is not set", () => { + const env = { ...BASE_ENV, GITHUB_TOKEN: "fallback_token" }; + delete (env as Record)["INPUT_GITHUB-TOKEN"]; + const config = readActionConfig(env); + expect(config.githubToken).toBe("fallback_token"); + }); + + test("throws when neither token env var is set", () => { + const env: Record = { ...BASE_ENV }; + delete (env as Record)["INPUT_GITHUB-TOKEN"]; + expect(() => readActionConfig(env)).toThrow( + "GITHUB_TOKEN is required but not set.", + ); + }); + + test("throws when INPUT_GITHUB-TOKEN is empty and GITHUB_TOKEN is not set", () => { + const env = { ...BASE_ENV, "INPUT_GITHUB-TOKEN": "" }; + expect(() => readActionConfig(env)).toThrow( + "GITHUB_TOKEN is required but not set.", + ); + }); + }); + + describe("ciWorkflows parsing", () => { + test("defaults to auto when INPUT_CI-WORKFLOWS is not set", () => { + const config = readActionConfig(BASE_ENV); + expect(config.ciWorkflows).toEqual({ mode: "auto" }); + }); + + test("returns auto for empty string", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "", + }); + expect(config.ciWorkflows).toEqual({ mode: "auto" }); + }); + + test("returns auto for explicit 'auto' value", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "auto", + }); + expect(config.ciWorkflows).toEqual({ mode: "auto" }); + }); + + test("returns disabled for 'none'", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "none", + }); + expect(config.ciWorkflows).toEqual({ mode: "disabled" }); + }); + + test("returns disabled for 'false'", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "false", + }); + expect(config.ciWorkflows).toEqual({ mode: "disabled" }); + }); + + test("returns explicit list for comma-separated values", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "ci.yml,test.yml", + }); + expect(config.ciWorkflows).toEqual({ + mode: "explicit", + workflows: ["ci.yml", "test.yml"], + }); + }); + + test("trims whitespace around workflow names", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": " ci.yml , test.yml ", + }); + expect(config.ciWorkflows).toEqual({ + mode: "explicit", + workflows: ["ci.yml", "test.yml"], + }); + }); + + test("filters empty entries from comma-separated list", () => { + const config = readActionConfig({ + ...BASE_ENV, + "INPUT_CI-WORKFLOWS": "ci.yml,,test.yml", + }); + expect(config.ciWorkflows).toEqual({ + mode: "explicit", + workflows: ["ci.yml", "test.yml"], + }); + }); + }); + + describe("boolean parsing", () => { + test("only exact 'true' enables dryRun", () => { + expect( + readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "true" }).dryRun, + ).toBe(true); + expect( + readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "TRUE" }).dryRun, + ).toBe(false); + expect( + readActionConfig({ ...BASE_ENV, "INPUT_DRY-RUN": "1" }).dryRun, + ).toBe(false); + }); + + test("only exact 'true' enables releaseDraft", () => { + expect( + readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-DRAFT": "true" }) + .releaseDraft, + ).toBe(true); + expect( + readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-DRAFT": "TRUE" }) + .releaseDraft, + ).toBe(false); + }); + + test("only exact 'true' enables releasePrerelease", () => { + expect( + readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-PRERELEASE": "true" }) + .releasePrerelease, + ).toBe(true); + expect( + readActionConfig({ ...BASE_ENV, "INPUT_RELEASE-PRERELEASE": "1" }) + .releasePrerelease, + ).toBe(false); + }); + }); + + describe("GitHub Enterprise support", () => { + test("preserves custom server and API URLs", () => { + const config = readActionConfig({ + ...BASE_ENV, + GITHUB_SERVER_URL: "https://ghe.example.com", + GITHUB_API_URL: "https://ghe.example.com/api/v3", + }); + expect(config.githubServerUrl).toBe("https://ghe.example.com"); + expect(config.githubApiUrl).toBe("https://ghe.example.com/api/v3"); + }); + + test("throws when GITHUB_SERVER_URL is missing", () => { + const env: Record = { ...BASE_ENV }; + delete (env as Record).GITHUB_SERVER_URL; + expect(() => readActionConfig(env)).toThrow( + "GITHUB_SERVER_URL is required but not set.", + ); + }); + + test("throws when GITHUB_API_URL is missing", () => { + const env: Record = { ...BASE_ENV }; + delete (env as Record).GITHUB_API_URL; + expect(() => readActionConfig(env)).toThrow( + "GITHUB_API_URL is required but not set.", + ); + }); + + test("preserves GITHUB_REPOSITORY", () => { + const config = readActionConfig({ + ...BASE_ENV, + GITHUB_REPOSITORY: "myorg/myrepo", + }); + expect(config.githubRepository).toBe("myorg/myrepo"); + }); + }); + + describe("optional context fields", () => { + test("versionOverride is set when INPUT_VERSION is provided", () => { + const config = readActionConfig({ + ...BASE_ENV, + INPUT_VERSION: "2.3.4", + }); + expect(config.versionOverride).toBe("2.3.4"); + }); + + test("githubRefName and githubBaseRef are undefined when not set", () => { + const config = readActionConfig(BASE_ENV); + expect(config.githubRefName).toBeUndefined(); + expect(config.githubBaseRef).toBeUndefined(); + }); + + test("githubWorkflowRef is set when present", () => { + const config = readActionConfig({ + ...BASE_ENV, + GITHUB_WORKFLOW_REF: + "owner/repo/.github/workflows/release.yml@refs/heads/main", + }); + expect(config.githubWorkflowRef).toBe( + "owner/repo/.github/workflows/release.yml@refs/heads/main", + ); + }); + }); +}); diff --git a/src/__tests__/utils/env.test.ts b/src/__tests__/utils/env.test.ts new file mode 100644 index 0000000..bbc5ce2 --- /dev/null +++ b/src/__tests__/utils/env.test.ts @@ -0,0 +1,103 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { + exportEnv, + getBooleanEnv, + getEnv, + getRequiredEnv, +} from "../../utils/env.js"; + +describe("getEnv", () => { + test("returns value when set", () => { + const env = { MY_VAR: "hello" }; + expect(getEnv("MY_VAR", env)).toBe("hello"); + }); + + test("returns undefined when not set", () => { + expect(getEnv("MISSING", {})).toBeUndefined(); + }); + + test("returns undefined for empty string", () => { + const env = { MY_VAR: "" }; + expect(getEnv("MY_VAR", env)).toBeUndefined(); + }); +}); + +describe("getRequiredEnv", () => { + test("returns value when set", () => { + const env = { MY_VAR: "value" }; + expect(getRequiredEnv("MY_VAR", env)).toBe("value"); + }); + + test("throws when not set", () => { + expect(() => getRequiredEnv("MISSING", {})).toThrow( + "MISSING is required but not set.", + ); + }); + + test("throws when empty string", () => { + expect(() => getRequiredEnv("MY_VAR", { MY_VAR: "" })).toThrow( + "MY_VAR is required but not set.", + ); + }); +}); + +describe("getBooleanEnv", () => { + test("returns true only for exact string 'true'", () => { + expect(getBooleanEnv("FLAG", false, { FLAG: "true" })).toBe(true); + }); + + test("returns false for 'TRUE' (case-sensitive)", () => { + expect(getBooleanEnv("FLAG", false, { FLAG: "TRUE" })).toBe(false); + }); + + test("returns false for '1'", () => { + expect(getBooleanEnv("FLAG", false, { FLAG: "1" })).toBe(false); + }); + + test("returns false for 'yes'", () => { + expect(getBooleanEnv("FLAG", false, { FLAG: "yes" })).toBe(false); + }); + + test("uses default when not set", () => { + expect(getBooleanEnv("FLAG", true, {})).toBe(true); + expect(getBooleanEnv("FLAG", false, {})).toBe(false); + }); + + test("uses default when empty string", () => { + expect(getBooleanEnv("FLAG", true, { FLAG: "" })).toBe(true); + }); +}); + +describe("exportEnv", () => { + let tempDir: string; + let githubEnvFile: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-test-")); + githubEnvFile = path.join(tempDir, "github.env"); + fs.writeFileSync(githubEnvFile, "", "utf8"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("appends NAME=value to GITHUB_ENV file", () => { + exportEnv("MY_VAR", "hello", { GITHUB_ENV: githubEnvFile }); + expect(fs.readFileSync(githubEnvFile, "utf8")).toBe("MY_VAR=hello\n"); + }); + + test("appends multiple entries", () => { + const env = { GITHUB_ENV: githubEnvFile }; + exportEnv("FIRST", "a", env); + exportEnv("SECOND", "b", env); + expect(fs.readFileSync(githubEnvFile, "utf8")).toBe("FIRST=a\nSECOND=b\n"); + }); + + test("does nothing when GITHUB_ENV is not set", () => { + expect(() => exportEnv("MY_VAR", "val", {})).not.toThrow(); + }); +}); diff --git a/src/__tests__/utils/log.test.ts b/src/__tests__/utils/log.test.ts new file mode 100644 index 0000000..bd9eb45 --- /dev/null +++ b/src/__tests__/utils/log.test.ts @@ -0,0 +1,98 @@ +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from "@jest/globals"; +import { addMask, error, info, notice, warning } from "../../utils/log.js"; + +describe("log", () => { + let stdoutOutput: string[]; + let stderrOutput: string[]; + + beforeEach(() => { + stdoutOutput = []; + stderrOutput = []; + jest + .spyOn(process.stdout, "write") + .mockImplementation((chunk: unknown): boolean => { + stdoutOutput.push(String(chunk)); + return true; + }); + jest + .spyOn(process.stderr, "write") + .mockImplementation((chunk: unknown): boolean => { + stderrOutput.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("info", () => { + test("writes message to stdout with newline", () => { + info("hello world"); + expect(stdoutOutput).toEqual(["hello world\n"]); + }); + }); + + describe("notice", () => { + test("wraps message with ::notice:: prefix", () => { + notice("something happened"); + expect(stdoutOutput).toEqual(["::notice::something happened\n"]); + }); + + test("escapes percent signs", () => { + notice("50% done"); + expect(stdoutOutput).toEqual(["::notice::50%25 done\n"]); + }); + + test("escapes newlines", () => { + notice("line1\nline2"); + expect(stdoutOutput).toEqual(["::notice::line1%0Aline2\n"]); + }); + + test("escapes carriage returns", () => { + notice("line1\rline2"); + expect(stdoutOutput).toEqual(["::notice::line1%0Dline2\n"]); + }); + }); + + describe("warning", () => { + test("wraps message with ::warning:: prefix on stdout", () => { + warning("be careful"); + expect(stdoutOutput).toEqual(["::warning::be careful\n"]); + }); + }); + + describe("error", () => { + test("writes ::error:: prefix to stderr", () => { + error("something failed"); + expect(stderrOutput).toEqual(["::error::something failed\n"]); + expect(stdoutOutput).toHaveLength(0); + }); + + test("escapes newlines in error message", () => { + error("line1\nline2"); + expect(stderrOutput).toEqual(["::error::line1%0Aline2\n"]); + }); + }); + + describe("addMask", () => { + test("emits ::add-mask:: and returns the value", () => { + const result = addMask("secret-token"); + expect(result).toBe("secret-token"); + expect(stdoutOutput).toEqual(["::add-mask::secret-token\n"]); + }); + + test("does not emit anything for empty string", () => { + const result = addMask(""); + expect(result).toBe(""); + expect(stdoutOutput).toHaveLength(0); + }); + }); +}); diff --git a/src/__tests__/utils/retry.test.ts b/src/__tests__/utils/retry.test.ts new file mode 100644 index 0000000..0a1a100 --- /dev/null +++ b/src/__tests__/utils/retry.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "@jest/globals"; +import { retryUntil } from "../../utils/retry.js"; + +const noSleep = (): Promise => Promise.resolve(); + +describe("retryUntil", () => { + test("returns immediately when operation succeeds on first attempt", async () => { + const result = await retryUntil( + () => Promise.resolve("value" as string | undefined), + { + maxAttempts: 3, + intervalMs: 0, + description: "test", + sleep: noSleep, + }, + ); + expect(result).toBe("value"); + }); + + test("retries and returns when operation eventually succeeds", async () => { + let calls = 0; + const result = await retryUntil( + () => { + calls += 1; + const value: string | undefined = calls >= 3 ? "done" : undefined; + return Promise.resolve(value); + }, + { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep }, + ); + expect(result).toBe("done"); + expect(calls).toBe(3); + }); + + test("throws after maxAttempts when operation never succeeds", async () => { + await expect( + retryUntil(() => Promise.resolve(undefined), { + maxAttempts: 3, + intervalMs: 0, + description: "workflow run", + sleep: noSleep, + }), + ).rejects.toThrow( + "Timed out while waiting for workflow run after 3 attempts.", + ); + }); + + test("treats null as not-yet-found", async () => { + let calls = 0; + const result = await retryUntil( + () => { + calls += 1; + const value: number | null = calls >= 2 ? 42 : null; + return Promise.resolve(value); + }, + { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep }, + ); + expect(result).toBe(42); + }); + + test("treats false as not-yet-found", async () => { + let calls = 0; + const result = await retryUntil( + () => { + calls += 1; + const value: string | false = calls >= 2 ? "found" : false; + return Promise.resolve(value); + }, + { maxAttempts: 5, intervalMs: 0, description: "test", sleep: noSleep }, + ); + expect(result).toBe("found"); + }); + + test("calls onRetry on each failed attempt except the last", async () => { + const retryCalls: number[] = []; + await expect( + retryUntil(() => Promise.resolve(undefined), { + maxAttempts: 3, + intervalMs: 0, + description: "x", + sleep: noSleep, + onRetry: (attempt) => { + retryCalls.push(attempt); + }, + }), + ).rejects.toThrow(); + expect(retryCalls).toEqual([1, 2]); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2e915df --- /dev/null +++ b/src/config.ts @@ -0,0 +1,92 @@ +import { + getBooleanEnv, + getEnv, + getRequiredEnv, + type Env, +} from "./utils/env.js"; + +export type CiWorkflowsConfig = + | { + mode: "auto"; + } + | { + mode: "disabled"; + } + | { + mode: "explicit"; + workflows: string[]; + }; + +export type ActionConfig = { + githubToken: string; + dryRun: boolean; + releaseDraft: boolean; + releasePrerelease: boolean; + releaseTitlePrefix: string; + tagTemplate: string; + changelogFilePath: string; + versionOverride?: string; + ciWorkflows: CiWorkflowsConfig; + githubServerUrl: string; + githubApiUrl: string; + githubRepository: string; + githubActor: string; + githubWorkspace: string; + githubRefName?: string; + githubBaseRef?: string; + githubWorkflowRef?: string; +}; + +function parseCiWorkflows(value: string | undefined): CiWorkflowsConfig { + const normalized = value?.trim(); + + if (normalized === undefined || normalized === "" || normalized === "auto") { + return { mode: "auto" }; + } + + if (normalized === "none" || normalized === "false") { + return { mode: "disabled" }; + } + + const workflows = normalized + .split(",") + .map((workflow) => workflow.trim()) + .filter((workflow) => workflow.length > 0); + + if (workflows.length === 0) { + return { mode: "auto" }; + } + + return { + mode: "explicit", + workflows, + }; +} + +export function readActionConfig(env: Env = process.env): ActionConfig { + const githubToken = + getEnv("INPUT_GITHUB-TOKEN", env) ?? getRequiredEnv("GITHUB_TOKEN", env); + + const versionOverride = getEnv("INPUT_VERSION", env); + + return { + githubToken, + dryRun: getBooleanEnv("INPUT_DRY-RUN", false, env), + releaseDraft: getBooleanEnv("INPUT_RELEASE-DRAFT", false, env), + releasePrerelease: getBooleanEnv("INPUT_RELEASE-PRERELEASE", false, env), + releaseTitlePrefix: getEnv("INPUT_RELEASE-TITLE-PREFIX", env) ?? "", + tagTemplate: getEnv("INPUT_TAG-TEMPLATE", env) ?? "v", + changelogFilePath: + getEnv("INPUT_CHANGELOG-FILE-PATH", env) ?? "CHANGELOG.md", + versionOverride, + ciWorkflows: parseCiWorkflows(getEnv("INPUT_CI-WORKFLOWS", env)), + githubServerUrl: getRequiredEnv("GITHUB_SERVER_URL", env), + githubApiUrl: getRequiredEnv("GITHUB_API_URL", env), + githubRepository: getRequiredEnv("GITHUB_REPOSITORY", env), + githubActor: getRequiredEnv("GITHUB_ACTOR", env), + githubWorkspace: getRequiredEnv("GITHUB_WORKSPACE", env), + githubRefName: getEnv("GITHUB_REF_NAME", env), + githubBaseRef: getEnv("GITHUB_BASE_REF", env), + githubWorkflowRef: getEnv("GITHUB_WORKFLOW_REF", env), + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..9f8e1bd --- /dev/null +++ b/src/main.ts @@ -0,0 +1,33 @@ +import { readActionConfig } from "./config.js"; +import { addMask, error, info } from "./utils/log.js"; + +export async function main(): Promise { + info("GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1"); + + const config = readActionConfig(); + addMask(config.githubToken); + + info("Starting GitHub Release Action TypeScript runtime."); + info(`CI workflow mode: ${config.ciWorkflows.mode}`); + info( + "TypeScript pipeline foundation is available. Runtime swap is intentionally blocked until pipeline parity is complete.", + ); + + // Pipeline not yet wired — keeps the async signature that will hold awaits in later slices. + await Promise.resolve(); + throw new Error( + "TypeScript pipeline is not wired yet. Do not switch Dockerfile to src/main.ts before migration is complete.", + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((caughtError: unknown) => { + const message = + caughtError instanceof Error + ? caughtError.message + : "An unknown error occurred."; + + error(message); + process.exit(1); + }); +} diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..8a48333 --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,54 @@ +import * as fs from "node:fs"; + +export type Env = NodeJS.ProcessEnv; + +export function getEnv( + name: string, + env: Env = process.env, +): string | undefined { + const value = env[name]; + + if (value === undefined || value === "") { + return undefined; + } + + return value; +} + +export function getRequiredEnv(name: string, env: Env = process.env): string { + const value = getEnv(name, env); + + if (value === undefined) { + throw new Error(`${name} is required but not set.`); + } + + return value; +} + +export function getBooleanEnv( + name: string, + defaultValue: boolean, + env: Env = process.env, +): boolean { + const value = getEnv(name, env); + + if (value === undefined) { + return defaultValue; + } + + return value === "true"; +} + +export function exportEnv( + name: string, + value: string, + env: Env = process.env, +): void { + const githubEnv = getEnv("GITHUB_ENV", env); + + if (githubEnv === undefined) { + return; + } + + fs.appendFileSync(githubEnv, `${name}=${value}\n`, "utf8"); +} diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..f75023b --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,41 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export function fileExists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +export function readTextFile(filePath: string): string { + return fs.readFileSync(filePath, "utf8"); +} + +export function readTextFileIfExists(filePath: string): string | undefined { + if (!fileExists(filePath)) { + return undefined; + } + + return readTextFile(filePath); +} + +export function writeTextFile(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, "utf8"); +} + +export function appendTextFile(filePath: string, content: string): void { + fs.appendFileSync(filePath, content, "utf8"); +} + +export function ensureTextFile(filePath: string, defaultContent: string): void { + if (!fileExists(filePath)) { + writeTextFile(filePath, defaultContent); + } +} + +export function createTempDirectory(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function removeDirectory(filePath: string): void { + fs.rmSync(filePath, { recursive: true, force: true }); +} diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..bd30a83 --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,27 @@ +function escapeCommandValue(value: string): string { + return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +export function info(message: string): void { + process.stdout.write(`${message}\n`); +} + +export function notice(message: string): void { + info(`::notice::${escapeCommandValue(message)}`); +} + +export function warning(message: string): void { + info(`::warning::${escapeCommandValue(message)}`); +} + +export function error(message: string): void { + process.stderr.write(`::error::${escapeCommandValue(message)}\n`); +} + +export function addMask(value: string): string { + if (value !== "") { + info(`::add-mask::${escapeCommandValue(value)}`); + } + + return value; +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..614c023 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,37 @@ +export type RetryOptions = { + maxAttempts: number; + intervalMs: number; + description: string; + sleep?: (milliseconds: number) => Promise; + onRetry?: (attempt: number) => void; +}; + +export async function sleep(milliseconds: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +export async function retryUntil( + operation: (attempt: number) => Promise, + options: RetryOptions, +): Promise { + const sleepFn = options.sleep ?? sleep; + + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + const result = await operation(attempt); + + if (result !== undefined && result !== null && result !== false) { + return result; + } + + if (attempt < options.maxAttempts) { + options.onRetry?.(attempt); + await sleepFn(options.intervalMs); + } + } + + throw new Error( + `Timed out while waiting for ${options.description} after ${options.maxAttempts} attempts.`, + ); +} From 92cdabe939c40a140ada7c46bb543c0af24cbc6e Mon Sep 17 00:00:00 2001 From: nirooxx Date: Thu, 18 Jun 2026 15:08:52 +0200 Subject: [PATCH 2/2] refactor: migrate release action pipeline and switch Docker action runtime to TypeScript --- .claude/settings.json | 46 ++ .github/workflows/main.yml | 16 + .gitignore | 10 + Dockerfile | 5 +- action.yml | 4 + src/__tests__/git/git.test.ts | 199 +++++++ src/__tests__/main.test.ts | 32 + src/__tests__/mocks/git-port.ts | 38 ++ src/__tests__/mocks/github-client.ts | 28 + src/__tests__/release/actionState.test.ts | 140 +++++ src/__tests__/release/collectCommits.test.ts | 373 ++++++++++++ .../release/createChangelogPr.test.ts | 551 ++++++++++++++++++ src/__tests__/release/createRelease.test.ts | 155 +++++ src/__tests__/release/pipeline.test.ts | 234 ++++++++ .../release/renderReleaseNotes.test.ts | 76 +++ src/__tests__/release/setupRelease.test.ts | 311 ++++++++++ src/__tests__/release/updateChangelog.test.ts | 284 +++++++++ src/__tests__/utils/repository.test.ts | 20 + src/git/git.ts | 294 ++++++++++ src/github/client.ts | 345 +++++++++++ src/main.ts | 20 +- src/release/actionState.ts | 48 ++ src/release/collectCommits.ts | 297 ++++++++++ src/release/createChangelogPr.ts | 413 +++++++++++++ src/release/createRelease.ts | 40 ++ src/release/pipeline.ts | 70 +++ src/release/renderReleaseNotes.ts | 22 + src/release/setupRelease.ts | 147 +++++ src/release/updateChangelog.ts | 211 +++++++ src/utils/repository.ts | 18 + 30 files changed, 4434 insertions(+), 13 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/__tests__/git/git.test.ts create mode 100644 src/__tests__/main.test.ts create mode 100644 src/__tests__/mocks/git-port.ts create mode 100644 src/__tests__/mocks/github-client.ts create mode 100644 src/__tests__/release/actionState.test.ts create mode 100644 src/__tests__/release/collectCommits.test.ts create mode 100644 src/__tests__/release/createChangelogPr.test.ts create mode 100644 src/__tests__/release/createRelease.test.ts create mode 100644 src/__tests__/release/pipeline.test.ts create mode 100644 src/__tests__/release/renderReleaseNotes.test.ts create mode 100644 src/__tests__/release/setupRelease.test.ts create mode 100644 src/__tests__/release/updateChangelog.test.ts create mode 100644 src/__tests__/utils/repository.test.ts create mode 100644 src/git/git.ts create mode 100644 src/github/client.ts create mode 100644 src/release/actionState.ts create mode 100644 src/release/collectCommits.ts create mode 100644 src/release/createChangelogPr.ts create mode 100644 src/release/createRelease.ts create mode 100644 src/release/pipeline.ts create mode 100644 src/release/renderReleaseNotes.ts create mode 100644 src/release/setupRelease.ts create mode 100644 src/release/updateChangelog.ts create mode 100644 src/utils/repository.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..316558d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,46 @@ +{ + "permissions": { + "allow": [ + "Bash(pwd)", + "Bash(ls*)", + "Bash(dir*)", + "Bash(Get-ChildItem*)", + "Bash(Get-Content*)", + "Bash(Select-String*)", + "Bash(Test-Path*)", + "Bash(npm install*)", + "Bash(npm ci*)", + "Bash(npm run *)", + "Bash(npm test*)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(Start-Sleep -Seconds 5)", + "Bash(powershell -Command \"docker ps\")" + ], + "deny": [ + "Bash(git commit*)", + "Bash(git push*)", + "Bash(git tag*)", + "Bash(git reset --hard*)", + "Bash(rm -rf*)", + "Bash(Remove-Item * -Recurse*)", + "Bash(del *)", + "Bash(rmdir *)" + ], + "ask": [ + "Bash(git add*)", + "Bash(git restore*)", + "Bash(git checkout*)", + "Bash(git stash*)", + "Bash(mkdir*)", + "Bash(New-Item*)", + "Bash(Copy-Item*)", + "Bash(Move-Item*)" + ], + "defaultMode": "default" + } +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e47c6bb..af7c112 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -67,3 +67,19 @@ jobs: uses: ./ with: dry-run: true + + docker-smoke: + name: Docker Smoke Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build --no-cache -t github-release-action:test . + + - name: Run smoke test + run: | + output="$(docker run --rm github-release-action:test --smoke-test)" + echo "$output" + echo "$output" | grep -qF "GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1" + echo "$output" | grep -qF "TypeScript Docker runtime smoke test passed." diff --git a/.gitignore b/.gitignore index d07b5c1..13de11b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,13 @@ yarn-error.log # GitHub Actions cache .github/workflows/node_modules/ + +# Claude Code local state (worktrees, shell snapshots, plans, session data) +.claude/worktrees/ +.claude/shell-snapshots/ +.claude/plans/ +.claude/projects/ +.claude/todos/ +.claude/history/ +.claude/*.local.json +.claude/settings.local.json diff --git a/Dockerfile b/Dockerfile index a6bc066..c294ae2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ COPY . . # Compile TypeScript files RUN npm run build +RUN test -f /app/dist/src/main.js # Remove unnecessary files after build RUN npm prune --production @@ -38,5 +39,5 @@ RUN apk add --no-cache git jq curl RUN chmod +x /app/scripts/*.sh RUN chmod +x /app/dist/src/release.js -# Set the entrypoint script -ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"] +# Set the entrypoint to the compiled TypeScript runtime +ENTRYPOINT ["node", "/app/dist/src/main.js"] diff --git a/action.yml b/action.yml index 67e1200..f4cb37f 100644 --- a/action.yml +++ b/action.yml @@ -49,6 +49,10 @@ inputs: required: false default: "auto" +outputs: + release-url: + description: "URL of the created GitHub release." + runs: using: "docker" image: "Dockerfile" diff --git a/src/__tests__/git/git.test.ts b/src/__tests__/git/git.test.ts new file mode 100644 index 0000000..526287f --- /dev/null +++ b/src/__tests__/git/git.test.ts @@ -0,0 +1,199 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { + add, + branchExistsRemote, + checkoutExistingBranch, + cloneWorkspace, + commit, + execGit, + hasUnstagedChanges, + requireGit, + revParse, + tagExists, + tagList, +} from "../../git/git.js"; + +describe("git", () => { + let repoDir: string; + + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-test-")); + requireGit(["init", "-b", "main"], { cwd: repoDir }); + requireGit(["config", "user.name", "test-actor"], { cwd: repoDir }); + requireGit( + ["config", "user.email", "test-actor@users.noreply.github.com"], + { cwd: repoDir }, + ); + fs.writeFileSync(path.join(repoDir, "file.txt"), "hello\n", "utf8"); + add("file.txt", { cwd: repoDir }); + commit("initial commit", { cwd: repoDir }); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + describe("requireGit", () => { + test("throws an error including command, exit code, and stderr on failure", () => { + expect(() => + requireGit(["rev-parse", "--verify", "refs/heads/does-not-exist"], { + cwd: repoDir, + }), + ).toThrow(/exit code/); + }); + + test("returns trimmed stdout on success", () => { + const output = requireGit(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: repoDir, + }); + expect(output).toBe("main"); + }); + }); + + describe("execGit", () => { + test("never throws, returns exitCode for failures", () => { + const result = execGit(["rev-parse", "--verify", "refs/heads/missing"], { + cwd: repoDir, + }); + expect(result.exitCode).not.toBe(0); + }); + }); + + describe("tagExists", () => { + test("returns false for a tag that does not exist", () => { + expect(tagExists("v9.9.9", { cwd: repoDir })).toBe(false); + }); + + test("returns true for a tag that exists", () => { + requireGit(["tag", "v1.0.0"], { cwd: repoDir }); + expect(tagExists("v1.0.0", { cwd: repoDir })).toBe(true); + }); + + test("supports tag names containing slashes", () => { + requireGit(["tag", "v/1.2.3"], { cwd: repoDir }); + expect(tagExists("v/1.2.3", { cwd: repoDir })).toBe(true); + expect(tagList({ cwd: repoDir })).toContain("v/1.2.3"); + }); + }); + + describe("revParse", () => { + test("returns undefined for a missing ref instead of throwing", () => { + expect(revParse("refs/heads/missing", { cwd: repoDir })).toBeUndefined(); + }); + + test("returns the resolved sha for an existing ref", () => { + const sha = revParse("HEAD", { cwd: repoDir }); + expect(sha).toMatch(/^[0-9a-f]{40}$/); + }); + }); + + describe("commit", () => { + test("does not throw when there is nothing to commit", () => { + expect(() => + commit("empty commit attempt", { cwd: repoDir }), + ).not.toThrow(); + }); + }); + + describe("hasUnstagedChanges", () => { + test("returns false when file is unchanged", () => { + expect(hasUnstagedChanges("file.txt", { cwd: repoDir })).toBe(false); + }); + + test("returns true when file has unstaged modifications", () => { + fs.writeFileSync(path.join(repoDir, "file.txt"), "changed\n", "utf8"); + expect(hasUnstagedChanges("file.txt", { cwd: repoDir })).toBe(true); + }); + }); + + describe("branchExistsRemote", () => { + test("returns false when there is no remote configured", () => { + expect(branchExistsRemote("some-branch", { cwd: repoDir })).toBe(false); + }); + }); + + describe("checkoutExistingBranch", () => { + test("checks out a branch that exists on the remote but has no local ref", () => { + requireGit(["checkout", "-b", "feature-branch"], { cwd: repoDir }); + fs.writeFileSync( + path.join(repoDir, "file.txt"), + "from feature\n", + "utf8", + ); + add("file.txt", { cwd: repoDir }); + commit("feature commit", { cwd: repoDir }); + requireGit(["checkout", "main"], { cwd: repoDir }); + + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-local-")); + fs.rmSync(localDir, { recursive: true, force: true }); + requireGit(["clone", repoDir, localDir]); + + expect( + requireGit(["branch", "--list", "feature-branch"], { cwd: localDir }), + ).toBe(""); + + checkoutExistingBranch("feature-branch", { cwd: localDir }); + + expect( + requireGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: localDir }), + ).toBe("feature-branch"); + expect( + fs.readFileSync(path.join(localDir, "file.txt"), "utf8").trim(), + ).toBe("from feature"); + + fs.rmSync(localDir, { recursive: true, force: true }); + }); + + test("resets an existing local branch to match the remote", () => { + requireGit(["checkout", "-b", "feature-branch"], { cwd: repoDir }); + requireGit(["checkout", "main"], { cwd: repoDir }); + + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-local-")); + fs.rmSync(localDir, { recursive: true, force: true }); + requireGit(["clone", repoDir, localDir]); + requireGit( + ["checkout", "-b", "feature-branch", "origin/feature-branch"], + { + cwd: localDir, + }, + ); + fs.writeFileSync( + path.join(localDir, "file.txt"), + "local divergent change\n", + "utf8", + ); + add("file.txt", { cwd: localDir }); + commit("local-only commit", { cwd: localDir }); + requireGit(["checkout", "main"], { cwd: localDir }); + + checkoutExistingBranch("feature-branch", { cwd: localDir }); + + const localSha = requireGit(["rev-parse", "feature-branch"], { + cwd: localDir, + }); + const remoteSha = requireGit(["rev-parse", "main"], { cwd: repoDir }); + expect(localSha).toBe(remoteSha); + + fs.rmSync(localDir, { recursive: true, force: true }); + }); + }); + + describe("cloneWorkspace", () => { + test("copies workspace contents to a new directory", () => { + const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "git-clone-")); + fs.rmSync(targetDir, { recursive: true, force: true }); + + cloneWorkspace(repoDir, targetDir); + + expect(fs.existsSync(path.join(targetDir, "file.txt"))).toBe(true); + expect(fs.readFileSync(path.join(targetDir, "file.txt"), "utf8")).toBe( + "hello\n", + ); + + fs.rmSync(targetDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts new file mode 100644 index 0000000..529bd4f --- /dev/null +++ b/src/__tests__/main.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, jest, test } from "@jest/globals"; + +describe("main", () => { + test("--smoke-test prints the runtime marker and success message, then returns without reading GitHub env vars", async () => { + const originalArgv = process.argv; + process.argv = ["node", "main.js", "--smoke-test"]; + + const writeSpy = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + try { + const { main } = await import("../main.js"); + await expect(main()).resolves.toBeUndefined(); + + const loggedLines = writeSpy.mock.calls.map((call) => String(call[0])); + expect( + loggedLines.some((line) => + line.includes("GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1"), + ), + ).toBe(true); + expect( + loggedLines.some((line) => + line.includes("TypeScript Docker runtime smoke test passed."), + ), + ).toBe(true); + } finally { + writeSpy.mockRestore(); + process.argv = originalArgv; + } + }); +}); diff --git a/src/__tests__/mocks/git-port.ts b/src/__tests__/mocks/git-port.ts new file mode 100644 index 0000000..3e6d915 --- /dev/null +++ b/src/__tests__/mocks/git-port.ts @@ -0,0 +1,38 @@ +import type { GitPort } from "../../git/git.js"; + +function notConfigured(methodName: string): never { + throw new Error( + `FakeGitPort.${methodName} was called without a configured implementation.`, + ); +} + +export function createFakeGitPort(overrides: Partial = {}): GitPort { + const base: GitPort = { + configSafeDirectory: () => notConfigured("configSafeDirectory"), + configUser: () => notConfigured("configUser"), + configGitHttpAuth: () => notConfigured("configGitHttpAuth"), + fetchBranches: () => notConfigured("fetchBranches"), + fetchTags: () => notConfigured("fetchTags"), + fetchBranchesAndTags: () => notConfigured("fetchBranchesAndTags"), + fetchTargetBranch: () => notConfigured("fetchTargetBranch"), + tagList: () => notConfigured("tagList"), + listTagsSortedByVersionDescending: () => + notConfigured("listTagsSortedByVersionDescending"), + revParse: () => notConfigured("revParse"), + tagExists: () => notConfigured("tagExists"), + getHeadSha: () => notConfigured("getHeadSha"), + gitLog: () => notConfigured("gitLog"), + hasDiffAgainstRef: () => notConfigured("hasDiffAgainstRef"), + hasUnstagedChanges: () => notConfigured("hasUnstagedChanges"), + pullTargetBranch: () => notConfigured("pullTargetBranch"), + checkoutBranchFromTarget: () => notConfigured("checkoutBranchFromTarget"), + checkoutExistingBranch: () => notConfigured("checkoutExistingBranch"), + branchExistsRemote: () => notConfigured("branchExistsRemote"), + add: () => notConfigured("add"), + commit: () => notConfigured("commit"), + pushBranch: () => notConfigured("pushBranch"), + cloneWorkspace: () => notConfigured("cloneWorkspace"), + }; + + return { ...base, ...overrides }; +} diff --git a/src/__tests__/mocks/github-client.ts b/src/__tests__/mocks/github-client.ts new file mode 100644 index 0000000..92c985e --- /dev/null +++ b/src/__tests__/mocks/github-client.ts @@ -0,0 +1,28 @@ +import type { GitHubClient } from "../../github/client.js"; + +function notConfigured(methodName: string): never { + throw new Error( + `FakeGitHubClient.${methodName} was called without a configured implementation.`, + ); +} + +export function createFakeGitHubClient( + overrides: Partial = {}, +): GitHubClient { + const base: GitHubClient = { + getReleaseByTag: () => notConfigured("getReleaseByTag"), + createRelease: () => notConfigured("createRelease"), + getCommit: () => notConfigured("getCommit"), + listPullRequestsAssociatedWithCommit: () => + notConfigured("listPullRequestsAssociatedWithCommit"), + createPullRequest: () => notConfigured("createPullRequest"), + findOpenPullRequestByHead: () => notConfigured("findOpenPullRequestByHead"), + createWorkflowDispatch: () => notConfigured("createWorkflowDispatch"), + listWorkflowRuns: () => notConfigured("listWorkflowRuns"), + getWorkflowRun: () => notConfigured("getWorkflowRun"), + listJobsForWorkflowRun: () => notConfigured("listJobsForWorkflowRun"), + createCheckRun: () => notConfigured("createCheckRun"), + }; + + return { ...base, ...overrides }; +} diff --git a/src/__tests__/release/actionState.test.ts b/src/__tests__/release/actionState.test.ts new file mode 100644 index 0000000..a683332 --- /dev/null +++ b/src/__tests__/release/actionState.test.ts @@ -0,0 +1,140 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import { + exportChangelogState, + exportInputState, + exportPrState, + exportSetupState, +} from "../../release/actionState.js"; +import type { ChangelogPrResult } from "../../release/createChangelogPr.js"; +import type { ReleaseSetup } from "../../release/setupRelease.js"; + +function buildConfig(overrides: Partial = {}): ActionConfig { + return { + githubToken: "super-secret-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "auto" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "owner/repo", + githubActor: "octocat", + githubWorkspace: "/workspace", + ...overrides, + }; +} + +function buildSetup(overrides: Partial = {}): ReleaseSetup { + return { + version: "1.2.3", + tag: "v1.2.3", + releaseTitle: "v1.2.3", + tagExists: false, + releaseExists: false, + targetBranch: "main", + ...overrides, + }; +} + +describe("actionState", () => { + let githubEnvPath: string; + let originalGithubEnv: string | undefined; + + beforeEach(() => { + originalGithubEnv = process.env.GITHUB_ENV; + githubEnvPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "github-env-")), + "env.txt", + ); + fs.writeFileSync(githubEnvPath, "", "utf8"); + process.env.GITHUB_ENV = githubEnvPath; + }); + + afterEach(() => { + fs.rmSync(path.dirname(githubEnvPath), { recursive: true, force: true }); + + if (originalGithubEnv === undefined) { + delete process.env.GITHUB_ENV; + } else { + process.env.GITHUB_ENV = originalGithubEnv; + } + }); + + test("exportInputState writes all expected input variables and never GITHUB_TOKEN", () => { + exportInputState( + buildConfig({ + dryRun: true, + ciWorkflows: { mode: "explicit", workflows: ["b.yml", "a.yml"] }, + versionOverride: "9.9.9", + }), + ); + + const content = fs.readFileSync(githubEnvPath, "utf8"); + + expect(content).toContain("DRY_RUN=true\n"); + expect(content).toContain("CHANGELOG_FILE_PATH=CHANGELOG.md\n"); + expect(content).toContain("TAG_TEMPLATE=v\n"); + expect(content).toContain("RELEASE_DRAFT=false\n"); + expect(content).toContain("RELEASE_PRERELEASE=false\n"); + expect(content).toContain("RELEASE_TITLE_PREFIX=\n"); + expect(content).toContain("VERSION_OVERRIDE=9.9.9\n"); + expect(content).toContain("CI_WORKFLOWS=b.yml,a.yml\n"); + expect(content).not.toContain("super-secret-token"); + expect(content).not.toContain("GITHUB_TOKEN"); + }); + + test("exportInputState serializes auto and disabled CI workflow modes", () => { + exportInputState(buildConfig({ ciWorkflows: { mode: "auto" } })); + exportInputState(buildConfig({ ciWorkflows: { mode: "disabled" } })); + + const content = fs.readFileSync(githubEnvPath, "utf8"); + expect(content).toContain("CI_WORKFLOWS=auto\n"); + expect(content).toContain("CI_WORKFLOWS=none\n"); + }); + + test("exportSetupState writes setup variables, including LATEST_TAG only when present", () => { + exportSetupState(buildSetup()); + + let content = fs.readFileSync(githubEnvPath, "utf8"); + expect(content).toContain("VERSION=1.2.3\n"); + expect(content).toContain("TAG=v1.2.3\n"); + expect(content).toContain("RELEASE_TITLE=v1.2.3\n"); + expect(content).toContain("TAG_EXISTS=false\n"); + expect(content).toContain("RELEASE_EXISTS=false\n"); + expect(content).toContain("TARGET_BRANCH=main\n"); + expect(content).not.toContain("LATEST_TAG="); + + fs.writeFileSync(githubEnvPath, "", "utf8"); + exportSetupState(buildSetup({ latestTag: "v1.0.0" })); + content = fs.readFileSync(githubEnvPath, "utf8"); + expect(content).toContain("LATEST_TAG=v1.0.0\n"); + }); + + test("exportChangelogState writes CHANGELOG_UPDATED", () => { + exportChangelogState(true); + expect(fs.readFileSync(githubEnvPath, "utf8")).toContain( + "CHANGELOG_UPDATED=true\n", + ); + }); + + test("exportPrState writes PR_URL and CHANGELOG_PR_HEAD_SHA", () => { + const result: ChangelogPrResult = { + prUrl: "https://github.com/owner/repo/pull/5", + headSha: "abc123", + }; + + exportPrState(result); + + const content = fs.readFileSync(githubEnvPath, "utf8"); + expect(content).toContain("PR_URL=https://github.com/owner/repo/pull/5\n"); + expect(content).toContain("CHANGELOG_PR_HEAD_SHA=abc123\n"); + }); +}); diff --git a/src/__tests__/release/collectCommits.test.ts b/src/__tests__/release/collectCommits.test.ts new file mode 100644 index 0000000..8e1a9f2 --- /dev/null +++ b/src/__tests__/release/collectCommits.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import { collectCommits } from "../../release/collectCommits.js"; +import type { ReleaseSetup } from "../../release/setupRelease.js"; +import { createFakeGitPort } from "../mocks/git-port.js"; +import { createFakeGitHubClient } from "../mocks/github-client.js"; + +const FIELD_SEPARATOR = "\x1f"; + +function buildConfig(overrides: Partial = {}): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "auto" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "open-resource-discovery/github-release", + githubActor: "octocat", + githubWorkspace: "/workspace", + ...overrides, + }; +} + +function buildSetup(overrides: Partial = {}): ReleaseSetup { + return { + version: "1.1.0", + tag: "v1.1.0", + releaseTitle: "v1.1.0", + tagExists: false, + releaseExists: false, + targetBranch: "main", + ...overrides, + }; +} + +function commitLine( + sha: string, + shortSha: string, + authorName: string, + authorEmail: string, + subject: string, +): string { + return [sha, shortSha, authorName, authorEmail, subject].join( + FIELD_SEPARATOR, + ); +} + +describe("collectCommits", () => { + test("falls back to 'No changes since last release' when there are no commits", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => ["v1.0.0"], + gitLog: () => "", + }); + const client = createFakeGitHubClient(); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual(["* No changes since last release."]); + }); + + test("computes range against the previous tag and includes a Full Changelog link", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true, tag: "v1.1.0" }); + let receivedRange = ""; + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => ["v1.0.0"], + gitLog: (range) => { + receivedRange = range; + return ""; + }, + }); + const client = createFakeGitHubClient(); + + const result = await collectCommits(config, setup, git, client); + + expect(receivedRange).toBe("v1.0.0..v1.1.0"); + expect(result.fullChangelogLine).toBe( + "**Full Changelog**: [v1.0.0...v1.1.0](https://github.com/open-resource-discovery/github-release/compare/v1.0.0...v1.1.0)", + ); + }); + + test("supports tags containing a slash in range computation and compare URL", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true, tag: "v/1.1.0" }); + let receivedRange = ""; + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => ["v/1.0.0"], + gitLog: (range) => { + receivedRange = range; + return ""; + }, + }); + const client = createFakeGitHubClient(); + + const result = await collectCommits(config, setup, git, client); + + expect(receivedRange).toBe("v/1.0.0..v/1.1.0"); + expect(result.fullChangelogLine).toContain("compare/v/1.0.0...v/1.1.0"); + }); + + test("prefers the PR link over the raw commit link", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine("sha1", "abc1234", "Alice", "alice@example.com", "Fix bug"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({ login: "alice" }), + listPullRequestsAssociatedWithCommit: () => + Promise.resolve([ + { + number: 42, + title: "Fix the bug", + html_url: "https://github.com/owner/repo/pull/42", + user: { login: "alice" }, + }, + ]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Fix the bug by @alice in [#42](https://github.com/owner/repo/pull/42)", + ]); + }); + + test("falls back to the commit link when no PR is associated and none is parseable", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine("sha1", "abc1234", "Bob", "bob@example.com", "Quick fix"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({}), + listPullRequestsAssociatedWithCommit: () => Promise.resolve([]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Quick fix by Bob in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)", + ]); + }); + + test("the same GitHub login can appear on multiple separate lines (no cross-line dedup)", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + [ + commitLine( + "sha-one", + "1111111", + "Alice", + "alice-work@example.com", + "First change", + ), + commitLine( + "sha-two", + "2222222", + "Alice", + "alice-private@example.com", + "Second change", + ), + ].join("\n"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({ login: "alice" }), + listPullRequestsAssociatedWithCommit: () => Promise.resolve([]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* First change by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)", + "* Second change by @alice in [2222222](https://github.com/open-resource-discovery/github-release/commit/sha-two)", + ]); + }); + + test("deduplicates PRs by PR number", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + [ + commitLine( + "sha1", + "abc1111", + "Alice", + "alice@example.com", + "Commit A", + ), + commitLine( + "sha2", + "abc2222", + "Alice", + "alice@example.com", + "Commit B", + ), + ].join("\n"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({ login: "alice" }), + listPullRequestsAssociatedWithCommit: () => + Promise.resolve([ + { + number: 7, + title: "Shared PR", + html_url: "https://github.com/owner/repo/pull/7", + user: { login: "alice" }, + }, + ]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Shared PR by @alice in [#7](https://github.com/owner/repo/pull/7)", + ]); + }); + + test("does not mention bot contributors but still lists their commits by author name", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine( + "sha-bot", + "3333333", + "dependabot[bot]", + "dependabot[bot]@users.noreply.github.com", + "Dependency update", + ), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({ login: "dependabot" }), + listPullRequestsAssociatedWithCommit: () => Promise.resolve([]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Dependency update by dependabot[bot] in [3333333](https://github.com/open-resource-discovery/github-release/commit/sha-bot)", + ]); + }); + + test("full changelog link is omitted when there is no previous tag", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => "", + }); + const client = createFakeGitHubClient(); + + const result = await collectCommits(config, setup, git, client); + + expect(result.fullChangelogLine).toBeUndefined(); + }); + + test("getCommit rejection still produces a commit line, falling back without a login", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine("sha1", "abc1234", "Carol", "carol@example.com", "Fix it"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.reject(new Error("API unavailable")), + listPullRequestsAssociatedWithCommit: () => Promise.resolve([]), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Fix it by Carol in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)", + ]); + }); + + test("PR lookup rejection falls back to the PR number parsed from the commit subject", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine( + "sha1", + "abc1234", + "Dana", + "dana@example.com", + "Add feature (#99)", + ), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.resolve({ login: "dana" }), + listPullRequestsAssociatedWithCommit: () => + Promise.reject(new Error("API unavailable")), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Add feature (#99) by @dana in [#99](https://github.com/open-resource-discovery/github-release/pull/99)", + ]); + }); + + test("both getCommit and PR lookup rejecting still produces a commit-link fallback", async () => { + const config = buildConfig(); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => undefined, + tagList: () => [], + gitLog: () => + commitLine("sha1", "abc1234", "Eve", "eve@example.com", "Plain fix"), + }); + const client = createFakeGitHubClient({ + getCommit: () => Promise.reject(new Error("down")), + listPullRequestsAssociatedWithCommit: () => + Promise.reject(new Error("down")), + }); + + const result = await collectCommits(config, setup, git, client); + + expect(result.commitLogLines).toEqual([ + "* Plain fix by Eve in [abc1234](https://github.com/open-resource-discovery/github-release/commit/sha1)", + ]); + }); + + test("dry-run skips fetchBranchesAndTags", async () => { + let fetchCalled = false; + const config = buildConfig({ dryRun: true }); + const setup = buildSetup({ tagExists: true }); + const git = createFakeGitPort({ + fetchBranchesAndTags: () => { + fetchCalled = true; + }, + tagList: () => [], + gitLog: () => "", + }); + const client = createFakeGitHubClient(); + + await collectCommits(config, setup, git, client); + + expect(fetchCalled).toBe(false); + }); +}); diff --git a/src/__tests__/release/createChangelogPr.test.ts b/src/__tests__/release/createChangelogPr.test.ts new file mode 100644 index 0000000..68e0a91 --- /dev/null +++ b/src/__tests__/release/createChangelogPr.test.ts @@ -0,0 +1,551 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { afterEach, describe, expect, jest, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import { GitHubApiError } from "../../github/client.js"; +import { createChangelogPr } from "../../release/createChangelogPr.js"; +import type { ReleaseSetup } from "../../release/setupRelease.js"; +import type { ChangelogResult } from "../../release/updateChangelog.js"; +import type { GitPort } from "../../git/git.js"; +import { createFakeGitPort } from "../mocks/git-port.js"; +import { createFakeGitHubClient } from "../mocks/github-client.js"; + +function buildConfig(overrides: Partial = {}): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "disabled" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "owner/repo", + githubActor: "octocat", + githubWorkspace: "/workspace", + ...overrides, + }; +} + +function buildSetup(overrides: Partial = {}): ReleaseSetup { + return { + version: "1.2.3", + tag: "v1.2.3", + releaseTitle: "v1.2.3", + tagExists: false, + releaseExists: false, + targetBranch: "main", + ...overrides, + }; +} + +function buildChangelog( + overrides: Partial = {}, +): ChangelogResult { + return { + updated: true, + changelogFileContent: "## [unreleased]\n", + releaseBody: "Release body", + ...overrides, + }; +} + +function baseGitPort( + overrides: Parameters[0] = {}, +): GitPort { + return createFakeGitPort({ + cloneWorkspace: () => undefined, + fetchTargetBranch: () => undefined, + branchExistsRemote: () => false, + checkoutBranchFromTarget: () => undefined, + checkoutExistingBranch: () => undefined, + add: () => undefined, + commit: () => undefined, + getHeadSha: () => "local-head-sha", + pushBranch: () => undefined, + ...overrides, + }); +} + +const SUCCESSFUL_RUN = { + id: 1, + head_sha: "remote-head-sha", + head_branch: "release-changelog-update/1.2.3", + status: "completed", + conclusion: "success", + created_at: "2026-01-01T00:00:00Z", + html_url: "https://github.com/owner/repo/actions/runs/1", +}; + +describe("createChangelogPr", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + test("uses branch name format release-changelog-update/", async () => { + let receivedBranchName = ""; + const git = baseGitPort({ + checkoutBranchFromTarget: (branchName) => { + receivedBranchName = branchName; + }, + }); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + }); + + await createChangelogPr( + buildConfig(), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(receivedBranchName).toBe("release-changelog-update/1.2.3"); + }); + + test("sends the correct PR creation payload", async () => { + let captured: unknown; + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: (input) => { + captured = input; + return Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }); + }, + }); + + await createChangelogPr( + buildConfig(), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(captured).toEqual({ + owner: "owner", + repo: "repo", + title: "chore: update changelog for version 1.2.3", + head: "release-changelog-update/1.2.3", + base: "main", + body: "This PR updates the changelog for the new version 1.2.3. Please review and merge it to proceed with the release process.", + }); + }); + + test("recovers from a 422 already-exists response by looking up the open PR", async () => { + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.reject(new GitHubApiError("Validation failed", 422)), + findOpenPullRequestByHead: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/9", + head: { sha: "existing-head-sha" }, + }), + }); + + const result = await createChangelogPr( + buildConfig(), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(result.prUrl).toBe("https://github.com/owner/repo/pull/9"); + expect(result.headSha).toBe("existing-head-sha"); + }); + + test("throws clearly on a non-422 PR creation failure", async () => { + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.reject(new GitHubApiError("Internal error", 500)), + }); + + await expect( + createChangelogPr( + buildConfig(), + buildSetup(), + buildChangelog(), + git, + client, + ), + ).rejects.toThrow("Internal error"); + }); + + test("captures head SHA from the local commit and falls back when API omits it", async () => { + const git = baseGitPort({ getHeadSha: () => "captured-before-push" }); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "" }, + }), + }); + + const result = await createChangelogPr( + buildConfig(), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(result.headSha).toBe("captured-before-push"); + }); + + test("auto discovery finds workflow_dispatch workflows and skips the release workflow itself", async () => { + const git = baseGitPort({ + cloneWorkspace: (_source, target) => { + const workflowsDir = path.join(target, ".github", "workflows"); + fs.mkdirSync(workflowsDir, { recursive: true }); + fs.writeFileSync( + path.join(workflowsDir, "release.yml"), + "on:\n workflow_dispatch:\n", + "utf8", + ); + fs.writeFileSync( + path.join(workflowsDir, "ci.yml"), + "on:\n workflow_dispatch:\n", + "utf8", + ); + fs.writeFileSync( + path.join(workflowsDir, "push-only.yml"), + "on:\n push:\n", + "utf8", + ); + }, + }); + + const dispatchedWorkflows: string[] = []; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: (_owner, _repo, workflowFileName) => { + dispatchedWorkflows.push(workflowFileName); + return Promise.resolve(); + }, + listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "success" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Dummy CI Check", conclusion: "success", html_url: null }, + ]), + createCheckRun: () => Promise.resolve(), + }); + + const config = buildConfig({ + ciWorkflows: { mode: "auto" }, + githubWorkflowRef: + "owner/repo/.github/workflows/release.yml@refs/heads/main", + }); + + await createChangelogPr( + config, + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(dispatchedWorkflows).toEqual(["ci.yml"]); + }); + + test("explicit workflow list bypasses discovery", async () => { + const git = baseGitPort(); + const dispatchedWorkflows: string[] = []; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: (_owner, _repo, workflowFileName) => { + dispatchedWorkflows.push(workflowFileName); + return Promise.resolve(); + }, + listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "success" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Explicit Check", conclusion: "success", html_url: null }, + ]), + createCheckRun: () => Promise.resolve(), + }); + + const config = buildConfig({ + ciWorkflows: { mode: "explicit", workflows: ["explicit.yml"] }, + }); + + await createChangelogPr( + config, + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(dispatchedWorkflows).toEqual(["explicit.yml"]); + }); + + test("disabled mode dispatches nothing", async () => { + const git = baseGitPort(); + let dispatchCalled = false; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: () => { + dispatchCalled = true; + return Promise.resolve(); + }, + }); + + await createChangelogPr( + buildConfig({ ciWorkflows: { mode: "disabled" } }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(dispatchCalled).toBe(false); + }); + + test("dispatch call includes the branch ref", async () => { + const git = baseGitPort(); + let receivedRef = ""; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: (_owner, _repo, _workflowFileName, ref) => { + receivedRef = ref; + return Promise.resolve(); + }, + listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "success" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Check", conclusion: "success", html_url: null }, + ]), + createCheckRun: () => Promise.resolve(), + }); + + await createChangelogPr( + buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(receivedRef).toBe("release-changelog-update/1.2.3"); + }); + + test("run lookup falls back to branch+created_at when head_sha does not match", async () => { + const git = baseGitPort(); + const createCheckRunCalls: { name: string; conclusion: string }[] = []; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: () => Promise.resolve(), + listWorkflowRuns: () => + Promise.resolve([ + { + id: 2, + head_sha: "some-other-sha", + head_branch: "release-changelog-update/1.2.3", + status: "completed", + conclusion: "success", + created_at: "2099-01-01T00:00:00Z", + html_url: "https://github.com/owner/repo/actions/runs/2", + }, + ]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "success" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Check", conclusion: "success", html_url: null }, + ]), + createCheckRun: (_owner, _repo, input) => { + createCheckRunCalls.push({ + name: input.name, + conclusion: input.conclusion, + }); + return Promise.resolve(); + }, + }); + + await createChangelogPr( + buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(createCheckRunCalls).toEqual([ + { name: "Check", conclusion: "success" }, + ]); + }); + + test("check run is created with the exact job name and the changelog PR head SHA", async () => { + const git = baseGitPort(); + const createCheckRunCalls: { name: string; head_sha: string }[] = []; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: () => Promise.resolve(), + listWorkflowRuns: () => Promise.resolve([SUCCESSFUL_RUN]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "success" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Dummy CI Check", conclusion: "success", html_url: null }, + ]), + createCheckRun: (_owner, _repo, input) => { + createCheckRunCalls.push({ + name: input.name, + head_sha: input.head_sha, + }); + return Promise.resolve(); + }, + }); + + await createChangelogPr( + buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(createCheckRunCalls).toEqual([ + { name: "Dummy CI Check", head_sha: "remote-head-sha" }, + ]); + }); + + test("a failing job still creates a check run and fails the overall call", async () => { + const git = baseGitPort(); + const createCheckRunCalls: { name: string; conclusion: string }[] = []; + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: () => Promise.resolve(), + listWorkflowRuns: () => + Promise.resolve([{ ...SUCCESSFUL_RUN, conclusion: "failure" }]), + getWorkflowRun: () => + Promise.resolve({ status: "completed", conclusion: "failure" }), + listJobsForWorkflowRun: () => + Promise.resolve([ + { name: "Failing Check", conclusion: "failure", html_url: null }, + ]), + createCheckRun: (_owner, _repo, input) => { + createCheckRunCalls.push({ + name: input.name, + conclusion: input.conclusion, + }); + return Promise.resolve(); + }, + }); + + await expect( + createChangelogPr( + buildConfig({ + ciWorkflows: { mode: "explicit", workflows: ["x.yml"] }, + }), + buildSetup(), + buildChangelog(), + git, + client, + ), + ).rejects.toThrow( + "Dispatched CI job 'Failing Check' finished with conclusion 'failure'.", + ); + + expect(createCheckRunCalls).toEqual([ + { name: "Failing Check", conclusion: "failure" }, + ]); + }); + + test("no workflow run found after exhausting retries throws clearly", async () => { + jest.useFakeTimers(); + + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/1", + head: { sha: "remote-head-sha" }, + }), + createWorkflowDispatch: () => Promise.resolve(), + listWorkflowRuns: () => Promise.resolve([]), + }); + + const resultPromise = createChangelogPr( + buildConfig({ ciWorkflows: { mode: "explicit", workflows: ["x.yml"] } }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + const expectation = expect(resultPromise).rejects.toThrow( + /Timed out while waiting for dispatched workflow run/, + ); + + await jest.advanceTimersByTimeAsync(60 * 5000 + 1000); + await expectation; + }, 20000); + + test("dry-run PR URL uses the configured GitHub Enterprise server URL, not github.com", async () => { + const git = baseGitPort(); + const client = createFakeGitHubClient(); + + const result = await createChangelogPr( + buildConfig({ + dryRun: true, + githubServerUrl: "https://github.example-corp.com", + }), + buildSetup(), + buildChangelog(), + git, + client, + ); + + expect(result.prUrl).toBe( + "https://github.example-corp.com/owner/repo/pull/dry-run-placeholder", + ); + }); +}); diff --git a/src/__tests__/release/createRelease.test.ts b/src/__tests__/release/createRelease.test.ts new file mode 100644 index 0000000..f7fc0a5 --- /dev/null +++ b/src/__tests__/release/createRelease.test.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import { createReleaseForTag } from "../../release/createRelease.js"; +import type { ReleaseSetup } from "../../release/setupRelease.js"; +import { createFakeGitHubClient } from "../mocks/github-client.js"; + +function buildConfig(overrides: Partial = {}): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "auto" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "open-resource-discovery/github-release", + githubActor: "octocat", + githubWorkspace: "/workspace", + ...overrides, + }; +} + +function buildSetup(overrides: Partial = {}): ReleaseSetup { + return { + version: "1.2.3", + tag: "v1.2.3", + releaseTitle: "v1.2.3", + tagExists: false, + releaseExists: false, + targetBranch: "main", + ...overrides, + }; +} + +describe("createReleaseForTag", () => { + const originalGithubOutput = process.env.GITHUB_OUTPUT; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-release-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + + if (originalGithubOutput === undefined) { + delete process.env.GITHUB_OUTPUT; + } else { + process.env.GITHUB_OUTPUT = originalGithubOutput; + } + }); + + test("requires a non-empty tag", async () => { + const client = createFakeGitHubClient(); + + await expect( + createReleaseForTag( + buildConfig(), + buildSetup({ tag: "" }), + "body", + client, + ), + ).rejects.toThrow("TAG is required but not set."); + }); + + test("sends the correct create-release payload", async () => { + let captured: unknown; + const client = createFakeGitHubClient({ + createRelease: (input) => { + captured = input; + return Promise.resolve({ + html_url: + "https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3", + }); + }, + }); + + await createReleaseForTag( + buildConfig(), + buildSetup(), + "Release body", + client, + ); + + expect(captured).toEqual({ + owner: "open-resource-discovery", + repo: "github-release", + tag_name: "v1.2.3", + target_commitish: "main", + name: "v1.2.3", + body: "Release body", + draft: false, + prerelease: false, + }); + }); + + test("respects draft and prerelease flags", async () => { + let captured: { draft: boolean; prerelease: boolean } | undefined; + const client = createFakeGitHubClient({ + createRelease: (input) => { + captured = { draft: input.draft, prerelease: input.prerelease }; + return Promise.resolve({ html_url: "https://example.com/release" }); + }, + }); + + await createReleaseForTag( + buildConfig({ releaseDraft: true, releasePrerelease: true }), + buildSetup(), + "body", + client, + ); + + expect(captured).toEqual({ draft: true, prerelease: true }); + }); + + test("writes release-url to GITHUB_OUTPUT", async () => { + const githubOutput = path.join(tempDir, "github-output.txt"); + fs.writeFileSync(githubOutput, "", "utf8"); + process.env.GITHUB_OUTPUT = githubOutput; + + const client = createFakeGitHubClient({ + createRelease: () => + Promise.resolve({ html_url: "https://example.com/releases/v1.2.3" }), + }); + + const url = await createReleaseForTag( + buildConfig(), + buildSetup(), + "body", + client, + ); + + expect(url).toBe("https://example.com/releases/v1.2.3"); + expect(fs.readFileSync(githubOutput, "utf8")).toBe( + "release-url=https://example.com/releases/v1.2.3\n", + ); + }); + + test("bubbles up API failures clearly", async () => { + const client = createFakeGitHubClient({ + createRelease: () => Promise.reject(new Error("GitHub API error")), + }); + + await expect( + createReleaseForTag(buildConfig(), buildSetup(), "body", client), + ).rejects.toThrow("GitHub API error"); + }); +}); diff --git a/src/__tests__/release/pipeline.test.ts b/src/__tests__/release/pipeline.test.ts new file mode 100644 index 0000000..bf4c4e8 --- /dev/null +++ b/src/__tests__/release/pipeline.test.ts @@ -0,0 +1,234 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import type { GitPort } from "../../git/git.js"; +import { runPipeline } from "../../release/pipeline.js"; +import { createFakeGitPort } from "../mocks/git-port.js"; +import { createFakeGitHubClient } from "../mocks/github-client.js"; + +function buildConfig( + workspaceDir: string, + overrides: Partial = {}, +): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "disabled" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "owner/repo", + githubActor: "octocat", + githubWorkspace: workspaceDir, + ...overrides, + }; +} + +function baseGitPort( + overrides: Parameters[0] = {}, +): GitPort { + return createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + tagList: () => [], + gitLog: () => "", + fetchBranches: () => undefined, + hasDiffAgainstRef: () => false, + hasUnstagedChanges: () => false, + cloneWorkspace: () => undefined, + fetchTargetBranch: () => undefined, + branchExistsRemote: () => false, + checkoutBranchFromTarget: () => undefined, + add: () => undefined, + commit: () => undefined, + getHeadSha: () => "head-sha", + pushBranch: () => undefined, + ...overrides, + }); +} + +describe("runPipeline", () => { + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipeline-")); + fs.writeFileSync( + path.join(workspaceDir, "package.json"), + JSON.stringify({ version: "1.3.0" }), + "utf8", + ); + }); + + afterEach(() => { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + }); + + test("changelog updated: creates PR, throws with PR URL, never creates a release", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "New stuff", ""].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + let createReleaseCalled = false; + + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: () => + Promise.resolve({ + html_url: "https://github.com/owner/repo/pull/5", + head: { sha: "remote-sha" }, + }), + createRelease: () => { + createReleaseCalled = true; + return Promise.resolve({ html_url: "https://example.com" }); + }, + }); + + await expect(runPipeline(config, { git, client })).rejects.toThrow( + /Please review and merge the changelog PR.*pull\/5/, + ); + + expect(createReleaseCalled).toBe(false); + }); + + test("dry-run with an updated changelog resolves successfully without mutating anything remote", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "New stuff", ""].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir, { dryRun: true }); + let pushBranchCalled = false; + let createPullRequestCalled = false; + let createReleaseCalled = false; + + const git = baseGitPort({ + pushBranch: () => { + pushBranchCalled = true; + }, + }); + const client = createFakeGitHubClient({ + createPullRequest: () => { + createPullRequestCalled = true; + return Promise.reject(new Error("should not be called")); + }, + createRelease: () => { + createReleaseCalled = true; + return Promise.resolve({ html_url: "https://example.com" }); + }, + }); + + await expect(runPipeline(config, { git, client })).resolves.toBeUndefined(); + + expect(pushBranchCalled).toBe(false); + expect(createPullRequestCalled).toBe(false); + expect(createReleaseCalled).toBe(false); + }); + + test("changelog unchanged and release does not exist: creates the release", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## [[1.3.0](https://github.com/owner/repo/releases/tag/v1.3.0)] - 2026-01-01", + "", + "Already released", + "", + "## [unreleased]", + "", + ].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + let createPullRequestCalled = false; + let createReleaseUrl: string | undefined; + + const git = baseGitPort(); + const client = createFakeGitHubClient({ + createPullRequest: () => { + createPullRequestCalled = true; + return Promise.reject(new Error("should not be called")); + }, + createRelease: () => { + createReleaseUrl = "https://github.com/owner/repo/releases/tag/v1.3.0"; + return Promise.resolve({ html_url: createReleaseUrl }); + }, + }); + + await runPipeline(config, { git, client }); + + expect(createPullRequestCalled).toBe(false); + expect(createReleaseUrl).toBe( + "https://github.com/owner/repo/releases/tag/v1.3.0", + ); + }); + + test("release already exists: throws early, never collects commits or creates a PR/release", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", ""].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => true, + listTagsSortedByVersionDescending: () => [], + // tagList/gitLog intentionally NOT configured — if collectCommits ran, + // the fake's "not configured" guard would throw a different error. + }); + const client = createFakeGitHubClient({ + getReleaseByTag: () => Promise.resolve({ id: 1 }), + }); + + await expect(runPipeline(config, { git, client })).rejects.toThrow( + "Release for tag v1.3.0 already exists.", + ); + }); + + test("dry-run still throws when a release already exists (no dry-run exception)", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", ""].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir, { dryRun: true }); + + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + tagExists: () => true, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient({ + getReleaseByTag: () => Promise.resolve({ id: 1 }), + }); + + await expect(runPipeline(config, { git, client })).rejects.toThrow( + "Release for tag v1.3.0 already exists.", + ); + }); +}); diff --git a/src/__tests__/release/renderReleaseNotes.test.ts b/src/__tests__/release/renderReleaseNotes.test.ts new file mode 100644 index 0000000..a009c76 --- /dev/null +++ b/src/__tests__/release/renderReleaseNotes.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "@jest/globals"; +import { + FALLBACK_DESCRIPTION, + renderReleaseBody, +} from "../../release/renderReleaseNotes.js"; + +describe("renderReleaseBody", () => { + test("renders the normal case with a description and commit list", () => { + const body = renderReleaseBody("Some description.", [ + "* First change by @alice in [#1](https://example.com/pull/1)", + ]); + + expect(body).toBe( + [ + "Some description.", + "", + "------", + "", + "## What's Changed", + "* First change by @alice in [#1](https://example.com/pull/1)", + ].join("\n"), + ); + }); + + test("uses the fallback description constant when given an empty description", () => { + const body = renderReleaseBody(FALLBACK_DESCRIPTION, [ + "* No changes since last release.", + ]); + + expect(body).toContain("This release includes the changes below."); + }); + + test("includes the Full Changelog link when provided", () => { + const body = renderReleaseBody( + "Description", + ["* Change"], + "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)", + ); + + expect(body).toContain( + "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)", + ); + expect( + body.endsWith( + "**Full Changelog**: [v1.0.0...v1.1.0](https://example.com/compare/v1.0.0...v1.1.0)", + ), + ).toBe(true); + }); + + test("omits the Full Changelog section entirely when not provided", () => { + const body = renderReleaseBody("Description", ["* Change"]); + + expect(body).not.toContain("Full Changelog"); + }); + + test("heading is exactly '## What's Changed'", () => { + const body = renderReleaseBody("Description", ["* Change"]); + + expect(body).toMatch(/^## What's Changed$/m); + }); + + test("never renders the legacy '## What's Changed (commits)' heading", () => { + const body = renderReleaseBody("Description", ["* Change"]); + + expect(body).not.toContain("## What's Changed (commits)"); + }); + + test("never renders an HTML contributor table", () => { + const body = renderReleaseBody("Description", [ + "* Change by @alice in [#1](https://example.com/pull/1)", + ]); + + expect(body).not.toContain(" = {}): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "auto" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "open-resource-discovery/github-release", + githubActor: "octocat", + githubWorkspace: "", + ...overrides, + }; +} + +describe("setupRelease", () => { + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-release-")); + }); + + afterEach(() => { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + }); + + test("version override wins over package.json", async () => { + fs.writeFileSync( + path.join(workspaceDir, "package.json"), + JSON.stringify({ version: "0.0.1" }), + "utf8", + ); + + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "9.9.9", + }); + + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient(); + + const setup = await setupRelease(config, git, client); + + expect(setup.version).toBe("9.9.9"); + expect(setup.tag).toBe("v9.9.9"); + }); + + test("falls back to package.json version when no override exists", async () => { + fs.writeFileSync( + path.join(workspaceDir, "package.json"), + JSON.stringify({ version: "1.4.0" }), + "utf8", + ); + + const config = buildConfig({ githubWorkspace: workspaceDir }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient(); + + const setup = await setupRelease(config, git, client); + + expect(setup.version).toBe("1.4.0"); + }); + + test("throws a clear error when no version can be resolved", async () => { + const config = buildConfig({ githubWorkspace: workspaceDir }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + }); + const client = createFakeGitHubClient(); + + await expect(setupRelease(config, git, client)).rejects.toThrow( + 'Mandatory "version" parameter has not been specified.', + ); + }); + + test("supports tag templates containing a slash", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + tagTemplate: "v/", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient(); + + const setup = await setupRelease(config, git, client); + + expect(setup.tag).toBe("v/1.2.3"); + }); + + test("detects an existing tag", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: (tag) => tag === "v1.2.3", + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient({ + getReleaseByTag: () => Promise.resolve(undefined), + }); + + const setup = await setupRelease(config, git, client); + + expect(setup.tagExists).toBe(true); + }); + + test("detects an existing release when the tag and release both exist", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: (tag) => tag === "v1.2.3", + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient({ + getReleaseByTag: () => Promise.resolve({ id: 42 }), + }); + + const setup = await setupRelease(config, git, client); + + expect(setup.releaseExists).toBe(true); + }); + + test("does not call the release API when the tag does not exist", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + let callCount = 0; + const client = createFakeGitHubClient({ + getReleaseByTag: () => { + callCount += 1; + return Promise.resolve(undefined); + }, + }); + + const setup = await setupRelease(config, git, client); + + expect(setup.releaseExists).toBe(false); + expect(callCount).toBe(0); + }); + + test("detects the latest tag matching the tag template", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => ["v1.1.0", "v1.0.0"], + }); + const client = createFakeGitHubClient(); + + const setup = await setupRelease(config, git, client); + + expect(setup.latestTag).toBe("v1.1.0"); + }); + + test("target branch prefers githubBaseRef over githubRefName over main", async () => { + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient(); + + const withBaseRef = await setupRelease( + buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.0.0", + githubBaseRef: "base-branch", + githubRefName: "ref-branch", + }), + git, + client, + ); + expect(withBaseRef.targetBranch).toBe("base-branch"); + + const withRefNameOnly = await setupRelease( + buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.0.0", + githubRefName: "ref-branch", + }), + git, + client, + ); + expect(withRefNameOnly.targetBranch).toBe("ref-branch"); + + const fallback = await setupRelease( + buildConfig({ githubWorkspace: workspaceDir, versionOverride: "1.0.0" }), + git, + client, + ); + expect(fallback.targetBranch).toBe("main"); + }); + + test("dry-run skips fetchBranchesAndTags", async () => { + let fetchCalled = false; + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.0.0", + dryRun: true, + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => { + fetchCalled = true; + }, + tagExists: () => false, + listTagsSortedByVersionDescending: () => [], + }); + const client = createFakeGitHubClient(); + + await setupRelease(config, git, client); + + expect(fetchCalled).toBe(false); + }); + + test("respects GitHub Enterprise API URL when checking release existence", async () => { + const config = buildConfig({ + githubWorkspace: workspaceDir, + versionOverride: "1.2.3", + githubServerUrl: "https://ghe.example.com", + githubApiUrl: "https://ghe.example.com/api/v3", + }); + const git = createFakeGitPort({ + configSafeDirectory: () => undefined, + configUser: () => undefined, + configGitHttpAuth: () => undefined, + fetchBranchesAndTags: () => undefined, + tagExists: () => true, + listTagsSortedByVersionDescending: () => [], + }); + let receivedApiCall = false; + const client = createFakeGitHubClient({ + getReleaseByTag: () => { + receivedApiCall = true; + return Promise.resolve(undefined); + }, + }); + + await setupRelease(config, git, client); + + expect(receivedApiCall).toBe(true); + }); +}); diff --git a/src/__tests__/release/updateChangelog.test.ts b/src/__tests__/release/updateChangelog.test.ts new file mode 100644 index 0000000..4416600 --- /dev/null +++ b/src/__tests__/release/updateChangelog.test.ts @@ -0,0 +1,284 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import type { ActionConfig } from "../../config.js"; +import type { GitPort } from "../../git/git.js"; +import type { CollectedReleaseData } from "../../release/collectCommits.js"; +import type { ReleaseSetup } from "../../release/setupRelease.js"; +import { updateChangelog } from "../../release/updateChangelog.js"; +import { createFakeGitPort } from "../mocks/git-port.js"; + +function buildConfig( + workspaceDir: string, + overrides: Partial = {}, +): ActionConfig { + return { + githubToken: "test-token", + dryRun: false, + releaseDraft: false, + releasePrerelease: false, + releaseTitlePrefix: "", + tagTemplate: "v", + changelogFilePath: "CHANGELOG.md", + versionOverride: undefined, + ciWorkflows: { mode: "auto" }, + githubServerUrl: "https://github.com", + githubApiUrl: "https://api.github.com", + githubRepository: "open-resource-discovery/github-release", + githubActor: "octocat", + githubWorkspace: workspaceDir, + ...overrides, + }; +} + +function buildSetup(overrides: Partial = {}): ReleaseSetup { + return { + version: "1.2.3", + tag: "v1.2.3", + releaseTitle: "v1.2.3", + tagExists: false, + releaseExists: false, + targetBranch: "main", + ...overrides, + }; +} + +function buildCollected( + overrides: Partial = {}, +): CollectedReleaseData { + return { + commitLogLines: [ + "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)", + ], + ...overrides, + }; +} + +function defaultGitPort( + diffAgainstRefResult = false, + hasUnstaged = false, +): GitPort { + return createFakeGitPort({ + fetchBranches: () => undefined, + hasDiffAgainstRef: () => diffAgainstRefResult, + hasUnstagedChanges: () => hasUnstaged, + pullTargetBranch: () => undefined, + add: () => undefined, + commit: () => undefined, + }); +} + +describe("updateChangelog", () => { + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "update-changelog-")); + }); + + afterEach(() => { + fs.rmSync(workspaceDir, { recursive: true, force: true }); + }); + + test("existing version path: extracts description, returns updated=false", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## [[1.2.3](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3)] - 2026-01-01", + "### Added", + "", + "- Existing changelog entry", + "", + "## [unreleased]", + "", + ].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + const setup = buildSetup(); + const collected = buildCollected(); + const git = defaultGitPort(); + + const result = await updateChangelog(config, setup, collected, git); + + expect(result.updated).toBe(false); + expect(result.changelogFileContent).toBeUndefined(); + expect(result.releaseBody).toContain("### Added"); + expect(result.releaseBody).toContain("- Existing changelog entry"); + expect(result.releaseBody).toContain("------"); + expect(result.releaseBody).toMatch(/^## What's Changed$/m); + expect(result.releaseBody).toContain( + "* Existing changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)", + ); + }); + + test("new version path: returns updated=true with new changelog content", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## [unreleased]", + "", + "### Added", + "", + "- New changelog entry", + "", + "## [[1.2.2](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.2)] - 2025-12-01", + "", + "- Old entry", + "", + ].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + const setup = buildSetup(); + const collected = buildCollected({ + commitLogLines: [ + "* New changelog entry by @alice in [1111111](https://github.com/open-resource-discovery/github-release/commit/sha-one)", + ], + }); + const git = defaultGitPort(); + + const result = await updateChangelog(config, setup, collected, git); + + expect(result.updated).toBe(true); + expect(result.changelogFileContent).toBeDefined(); + expect(result.changelogFileContent).toContain("## [unreleased]"); + expect(result.changelogFileContent).toContain( + "## [[1.2.3](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.3)]", + ); + expect(result.changelogFileContent).toContain( + "## [[1.2.2](https://github.com/open-resource-discovery/github-release/releases/tag/v1.2.2)]", + ); + expect(result.changelogFileContent).toContain("- Old entry"); + + expect(result.releaseBody).toContain("### Added"); + expect(result.releaseBody).toContain("- New changelog entry"); + expect(result.releaseBody).toMatch(/^## What's Changed$/m); + expect(result.releaseBody).not.toContain("## What's Changed (commits)"); + }); + + test("falls back to default description when section body is blank", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + [ + "# Changelog", + "", + "## [unreleased]", + "", + "## [[1.0.0](url)] - 2025-01-01", + "", + ].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + const setup = buildSetup({ version: "1.2.3" }); + const collected = buildCollected(); + const git = defaultGitPort(); + + const result = await updateChangelog(config, setup, collected, git); + + expect(result.releaseBody).toContain( + "This release includes the changes below.", + ); + }); + + test("never renders the legacy '(commits)' heading or HTML tables", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "Some description", ""].join( + "\n", + ), + "utf8", + ); + + const config = buildConfig(workspaceDir); + const setup = buildSetup(); + const collected = buildCollected(); + const git = defaultGitPort(); + + const result = await updateChangelog(config, setup, collected, git); + + expect(result.releaseBody).not.toContain("## What's Changed (commits)"); + expect(result.releaseBody).not.toContain(" { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"), + "utf8", + ); + + const config = buildConfig(workspaceDir); + const setup = buildSetup(); + const collected = buildCollected({ + fullChangelogLine: + "**Full Changelog**: [v1.2.2...v1.2.3](https://github.com/owner/repo/compare/v1.2.2...v1.2.3)", + }); + const git = defaultGitPort(); + + const result = await updateChangelog(config, setup, collected, git); + + expect(result.releaseBody).toContain( + "**Full Changelog**: [v1.2.2...v1.2.3](https://github.com/owner/repo/compare/v1.2.2...v1.2.3)", + ); + }); + + test("pulls latest target branch when changelog is outdated and not dry-run", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"), + "utf8", + ); + + let pullCalled = false; + const config = buildConfig(workspaceDir); + const setup = buildSetup(); + const collected = buildCollected(); + const git = createFakeGitPort({ + fetchBranches: () => undefined, + hasDiffAgainstRef: () => true, + hasUnstagedChanges: () => false, + pullTargetBranch: () => { + pullCalled = true; + }, + }); + + await updateChangelog(config, setup, collected, git); + + expect(pullCalled).toBe(true); + }); + + test("dry-run skips pulling even when changelog is outdated", async () => { + fs.writeFileSync( + path.join(workspaceDir, "CHANGELOG.md"), + ["# Changelog", "", "## [unreleased]", "", "Description", ""].join("\n"), + "utf8", + ); + + let pullCalled = false; + const config = buildConfig(workspaceDir, { dryRun: true }); + const setup = buildSetup(); + const collected = buildCollected(); + const git = createFakeGitPort({ + fetchBranches: () => undefined, + hasDiffAgainstRef: () => true, + hasUnstagedChanges: () => false, + pullTargetBranch: () => { + pullCalled = true; + }, + }); + + await updateChangelog(config, setup, collected, git); + + expect(pullCalled).toBe(false); + }); +}); diff --git a/src/__tests__/utils/repository.test.ts b/src/__tests__/utils/repository.test.ts new file mode 100644 index 0000000..f34bf43 --- /dev/null +++ b/src/__tests__/utils/repository.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "@jest/globals"; +import { parseRepositoryCoordinates } from "../../utils/repository.js"; + +describe("parseRepositoryCoordinates", () => { + test("splits a valid owner/repo string", () => { + expect(parseRepositoryCoordinates("octocat/hello-world")).toEqual({ + owner: "octocat", + repo: "hello-world", + }); + }); + + test.each(["", "owner", "owner/", "/repo", "owner/repo/extra"])( + "throws for malformed value %p", + (value) => { + expect(() => parseRepositoryCoordinates(value)).toThrow( + /GITHUB_REPOSITORY must be in the form "owner\/repo"/, + ); + }, + ); +}); diff --git a/src/git/git.ts b/src/git/git.ts new file mode 100644 index 0000000..32eb3eb --- /dev/null +++ b/src/git/git.ts @@ -0,0 +1,294 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; + +export type GitCommandResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +export type GitOptions = { + cwd?: string; +}; + +export function execGit( + args: string[], + options: GitOptions = {}, +): GitCommandResult { + const result = spawnSync("git", args, { + cwd: options.cwd, + encoding: "utf8", + }); + + if (result.error) { + throw new Error( + `Failed to execute "git ${args.join(" ")}": ${result.error.message}`, + ); + } + + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.status ?? 1, + }; +} + +export function requireGit(args: string[], options: GitOptions = {}): string { + const result = execGit(args, options); + + if (result.exitCode !== 0) { + throw new Error( + [ + `git ${args.join(" ")} failed with exit code ${result.exitCode}.`, + `stdout: ${result.stdout.trim()}`, + `stderr: ${result.stderr.trim()}`, + ].join("\n"), + ); + } + + return result.stdout.trim(); +} + +export interface GitPort { + configSafeDirectory(workspace: string): void; + configUser(actor: string): void; + configGitHttpAuth(githubServerUrl: string, token: string): void; + fetchBranches(options?: GitOptions): void; + fetchTags(options?: GitOptions): void; + fetchBranchesAndTags(options?: GitOptions): void; + fetchTargetBranch(targetBranch: string, options?: GitOptions): void; + tagList(options?: GitOptions): string[]; + listTagsSortedByVersionDescending(options?: GitOptions): string[]; + revParse(ref: string, options?: GitOptions): string | undefined; + tagExists(tag: string, options?: GitOptions): boolean; + getHeadSha(options?: GitOptions): string; + gitLog( + range: string, + format: string, + maxCount: number, + options?: GitOptions, + ): string; + hasDiffAgainstRef( + ref: string, + filePath: string, + options?: GitOptions, + ): boolean; + hasUnstagedChanges(filePath: string, options?: GitOptions): boolean; + pullTargetBranch(targetBranch: string, options?: GitOptions): void; + checkoutBranchFromTarget( + branchName: string, + targetBranch: string, + options?: GitOptions, + ): void; + checkoutExistingBranch(branchName: string, options?: GitOptions): void; + branchExistsRemote(branchName: string, options?: GitOptions): boolean; + add(filePath: string, options?: GitOptions): void; + commit(message: string, options?: GitOptions): void; + pushBranch(branchName: string, options?: GitOptions): void; + cloneWorkspace(source: string, target: string): void; +} + +export function configSafeDirectory(workspace: string): void { + requireGit(["config", "--global", "--add", "safe.directory", workspace]); +} + +export function configUser(actor: string): void { + requireGit(["config", "--global", "user.name", actor]); + requireGit([ + "config", + "--global", + "user.email", + `${actor}@users.noreply.github.com`, + ]); +} + +export function configGitHttpAuth( + githubServerUrl: string, + token: string, +): void { + const host = githubServerUrl.replace(/^https?:\/\//, ""); + requireGit([ + "config", + "--global", + `url.https://x-access-token:${token}@${host}/.insteadOf`, + `https://${host}/`, + ]); +} + +export function fetchBranches(options: GitOptions = {}): void { + requireGit( + ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"], + options, + ); +} + +export function fetchTags(options: GitOptions = {}): void { + requireGit( + ["fetch", "--prune", "--prune-tags", "origin", "+refs/tags/*:refs/tags/*"], + options, + ); +} + +export function fetchBranchesAndTags(options: GitOptions = {}): void { + fetchBranches(options); + fetchTags(options); +} + +export function fetchTargetBranch( + targetBranch: string, + options: GitOptions = {}, +): void { + requireGit(["fetch", "origin", targetBranch], options); +} + +export function tagList(options: GitOptions = {}): string[] { + const output = requireGit(["tag", "--list"], options); + return output.split("\n").filter((line) => line.length > 0); +} + +export function listTagsSortedByVersionDescending( + options: GitOptions = {}, +): string[] { + const output = requireGit( + ["tag", "--list", "--sort=-version:refname"], + options, + ); + return output.split("\n").filter((line) => line.length > 0); +} + +export function revParse( + ref: string, + options: GitOptions = {}, +): string | undefined { + const result = execGit(["rev-parse", "--verify", ref], options); + + if (result.exitCode !== 0) { + return undefined; + } + + return result.stdout.trim(); +} + +export function tagExists(tag: string, options: GitOptions = {}): boolean { + return revParse(`refs/tags/${tag}`, options) !== undefined; +} + +export function getHeadSha(options: GitOptions = {}): string { + return requireGit(["rev-parse", "HEAD"], options); +} + +export function gitLog( + range: string, + format: string, + maxCount: number, + options: GitOptions = {}, +): string { + const result = execGit( + ["log", range, `--max-count=${maxCount}`, `--pretty=format:${format}`], + options, + ); + + if (result.exitCode !== 0) { + throw new Error( + [ + `git log ${range} failed with exit code ${result.exitCode}.`, + `stderr: ${result.stderr.trim()}`, + ].join("\n"), + ); + } + + return result.stdout; +} + +export function hasDiffAgainstRef( + ref: string, + filePath: string, + options: GitOptions = {}, +): boolean { + const result = execGit(["diff", "--quiet", ref, "--", filePath], options); + return result.exitCode !== 0; +} + +export function hasUnstagedChanges( + filePath: string, + options: GitOptions = {}, +): boolean { + const result = execGit(["diff", "--quiet", "--", filePath], options); + return result.exitCode !== 0; +} + +export function pullTargetBranch( + targetBranch: string, + options: GitOptions = {}, +): void { + requireGit(["pull", "origin", targetBranch], options); +} + +export function checkoutBranchFromTarget( + branchName: string, + targetBranch: string, + options: GitOptions = {}, +): void { + requireGit(["checkout", "-b", branchName, `origin/${targetBranch}`], options); +} + +export function checkoutExistingBranch( + branchName: string, + options: GitOptions = {}, +): void { + // The local clone may not have a ref for this branch at all (e.g. a fresh + // workspace copy that only ever fetched the target branch), so fetch it + // explicitly before checking it out instead of assuming a local/remote- + // tracking ref already exists. + requireGit( + ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], + options, + ); + // `checkout -B` creates the local branch if missing, or resets it to match + // origin if it already exists — avoids rebase conflicts in the ephemeral + // temp-dir clone this is always run against. + requireGit(["checkout", "-B", branchName, `origin/${branchName}`], options); +} + +export function branchExistsRemote( + branchName: string, + options: GitOptions = {}, +): boolean { + const result = execGit( + ["ls-remote", "--exit-code", "--heads", "origin", branchName], + options, + ); + return result.exitCode === 0; +} + +export function add(filePath: string, options: GitOptions = {}): void { + requireGit(["add", "--", filePath], options); +} + +export function commit(message: string, options: GitOptions = {}): void { + const result = execGit(["commit", "-m", message], options); + + if (result.exitCode !== 0) { + const combinedOutput = `${result.stdout}\n${result.stderr}`; + + if (/nothing to commit/i.test(combinedOutput)) { + return; + } + + throw new Error( + [ + `git commit failed with exit code ${result.exitCode}.`, + `stdout: ${result.stdout.trim()}`, + `stderr: ${result.stderr.trim()}`, + ].join("\n"), + ); + } +} + +export function pushBranch(branchName: string, options: GitOptions = {}): void { + requireGit(["push", "origin", branchName], options); +} + +export function cloneWorkspace(source: string, target: string): void { + fs.cpSync(source, target, { recursive: true }); +} diff --git a/src/github/client.ts b/src/github/client.ts new file mode 100644 index 0000000..c50c486 --- /dev/null +++ b/src/github/client.ts @@ -0,0 +1,345 @@ +import { getOctokit } from "@actions/github"; + +export type ReleaseInfo = { id: number }; + +export type CreateReleaseInput = { + owner: string; + repo: string; + tag_name: string; + target_commitish: string; + name: string; + body: string; + draft: boolean; + prerelease: boolean; +}; + +export type CreateReleaseResult = { html_url: string }; + +export type PullRequestSummary = { + number: number; + title: string; + html_url: string; + user: { login: string } | null; +}; + +export type CreatePullRequestInput = { + owner: string; + repo: string; + title: string; + head: string; + base: string; + body: string; +}; + +export type PullRequestRef = { + html_url: string; + head: { sha: string }; +}; + +export type WorkflowRun = { + id: number; + head_sha: string; + head_branch: string | null; + status: string; + conclusion: string | null; + created_at: string; + html_url: string; +}; + +export type WorkflowJob = { + name: string; + conclusion: string | null; + html_url: string | null; +}; + +export type CheckRunConclusion = + | "success" + | "failure" + | "neutral" + | "cancelled" + | "skipped" + | "timed_out" + | "action_required"; + +export type CreateCheckRunInput = { + name: string; + head_sha: string; + conclusion: CheckRunConclusion; + details_url?: string; + summary: string; +}; + +export class GitHubApiError extends Error { + public readonly status?: number; + + public constructor(message: string, status?: number) { + super(message); + this.name = "GitHubApiError"; + this.status = status; + } +} + +export interface GitHubClient { + getReleaseByTag( + owner: string, + repo: string, + tag: string, + ): Promise; + createRelease(input: CreateReleaseInput): Promise; + getCommit( + owner: string, + repo: string, + sha: string, + ): Promise<{ login?: string }>; + listPullRequestsAssociatedWithCommit( + owner: string, + repo: string, + sha: string, + ): Promise; + createPullRequest(input: CreatePullRequestInput): Promise; + findOpenPullRequestByHead( + owner: string, + repo: string, + headOwnerColonBranch: string, + base: string, + ): Promise; + createWorkflowDispatch( + owner: string, + repo: string, + workflowFileName: string, + ref: string, + ): Promise; + listWorkflowRuns( + owner: string, + repo: string, + workflowFileName: string, + params: { branch: string; event: string; perPage: number }, + ): Promise; + getWorkflowRun( + owner: string, + repo: string, + runId: number, + ): Promise<{ status: string; conclusion: string | null }>; + listJobsForWorkflowRun( + owner: string, + repo: string, + runId: number, + ): Promise; + createCheckRun( + owner: string, + repo: string, + input: CreateCheckRunInput, + ): Promise; +} + +function getErrorStatus(error: unknown): number | undefined { + if (typeof error === "object" && error !== null && "status" in error) { + const status = error.status; + + if (typeof status === "number") { + return status; + } + } + + return undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown GitHub API error."; +} + +export type GitHubClientConfig = { + githubToken: string; + githubApiUrl: string; +}; + +export function createGitHubClient(config: GitHubClientConfig): GitHubClient { + const octokit = getOctokit(config.githubToken, { + baseUrl: config.githubApiUrl, + }); + + return { + async getReleaseByTag(owner, repo, tag): Promise { + try { + const response = await octokit.rest.repos.getReleaseByTag({ + owner, + repo, + tag, + }); + return { id: response.data.id }; + } catch (error: unknown) { + if (getErrorStatus(error) === 404) { + return undefined; + } + throw error; + } + }, + + async createRelease(input): Promise { + const response = await octokit.rest.repos.createRelease(input); + const htmlUrl: string | undefined = response.data.html_url; + + if (htmlUrl === undefined || htmlUrl === "") { + throw new GitHubApiError("Release response is missing html_url."); + } + + return { html_url: htmlUrl }; + }, + + async getCommit(owner, repo, sha): Promise<{ login?: string }> { + const response = await octokit.rest.repos.getCommit({ + owner, + repo, + ref: sha, + }); + return { login: response.data.author?.login ?? undefined }; + }, + + async listPullRequestsAssociatedWithCommit( + owner, + repo, + sha, + ): Promise { + const response = + await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: sha, + }); + + return response.data.map((pullRequest) => ({ + number: pullRequest.number, + title: pullRequest.title, + html_url: pullRequest.html_url, + user: pullRequest.user ? { login: pullRequest.user.login } : null, + })); + }, + + async createPullRequest(input): Promise { + try { + const response = await octokit.rest.pulls.create(input); + return { + html_url: response.data.html_url, + head: { sha: response.data.head.sha }, + }; + } catch (error: unknown) { + throw new GitHubApiError(getErrorMessage(error), getErrorStatus(error)); + } + }, + + async findOpenPullRequestByHead( + owner, + repo, + headOwnerColonBranch, + base, + ): Promise { + const response = await octokit.rest.pulls.list({ + owner, + repo, + head: headOwnerColonBranch, + base, + state: "open", + per_page: 1, + }); + + const pullRequest = response.data[0]; + + if (!pullRequest) { + return undefined; + } + + return { + html_url: pullRequest.html_url, + head: { sha: pullRequest.head.sha }, + }; + }, + + async createWorkflowDispatch( + owner, + repo, + workflowFileName, + ref, + ): Promise { + await octokit.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: workflowFileName, + ref, + }); + }, + + async listWorkflowRuns( + owner, + repo, + workflowFileName, + params, + ): Promise { + const response = await octokit.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflowFileName, + branch: params.branch, + event: params.event, + per_page: params.perPage, + }); + + return response.data.workflow_runs.map((run) => ({ + id: run.id, + head_sha: run.head_sha, + head_branch: run.head_branch, + status: run.status ?? "", + conclusion: run.conclusion, + created_at: run.created_at, + html_url: run.html_url, + })); + }, + + async getWorkflowRun( + owner, + repo, + runId, + ): Promise<{ status: string; conclusion: string | null }> { + const response = await octokit.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runId, + }); + + return { + status: response.data.status ?? "", + conclusion: response.data.conclusion, + }; + }, + + async listJobsForWorkflowRun(owner, repo, runId): Promise { + const response = await octokit.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: runId, + per_page: 100, + }); + + return response.data.jobs.map((job) => ({ + name: job.name, + conclusion: job.conclusion, + html_url: job.html_url, + })); + }, + + async createCheckRun(owner, repo, input): Promise { + await octokit.rest.checks.create({ + owner, + repo, + name: input.name, + head_sha: input.head_sha, + status: "completed", + conclusion: input.conclusion, + details_url: input.details_url, + output: { + title: input.name, + summary: input.summary, + }, + }); + }, + }; +} diff --git a/src/main.ts b/src/main.ts index 9f8e1bd..9e4c318 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,21 @@ import { readActionConfig } from "./config.js"; +import { exportInputState } from "./release/actionState.js"; +import { runPipeline } from "./release/pipeline.js"; import { addMask, error, info } from "./utils/log.js"; export async function main(): Promise { info("GITHUB_RELEASE_ACTION_RUNTIME=typescript-v1"); + if (process.argv.slice(2).includes("--smoke-test")) { + info("TypeScript Docker runtime smoke test passed."); + return; + } + const config = readActionConfig(); addMask(config.githubToken); + exportInputState(config); - info("Starting GitHub Release Action TypeScript runtime."); - info(`CI workflow mode: ${config.ciWorkflows.mode}`); - info( - "TypeScript pipeline foundation is available. Runtime swap is intentionally blocked until pipeline parity is complete.", - ); - - // Pipeline not yet wired — keeps the async signature that will hold awaits in later slices. - await Promise.resolve(); - throw new Error( - "TypeScript pipeline is not wired yet. Do not switch Dockerfile to src/main.ts before migration is complete.", - ); + await runPipeline(config); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/src/release/actionState.ts b/src/release/actionState.ts new file mode 100644 index 0000000..5599e59 --- /dev/null +++ b/src/release/actionState.ts @@ -0,0 +1,48 @@ +import type { ActionConfig, CiWorkflowsConfig } from "../config.js"; +import { exportEnv } from "../utils/env.js"; +import type { ChangelogPrResult } from "./createChangelogPr.js"; +import type { ReleaseSetup } from "./setupRelease.js"; + +function serializeCiWorkflows(config: CiWorkflowsConfig): string { + switch (config.mode) { + case "auto": + return "auto"; + case "disabled": + return "none"; + case "explicit": + return config.workflows.join(","); + } +} + +export function exportInputState(config: ActionConfig): void { + exportEnv("DRY_RUN", String(config.dryRun)); + exportEnv("CHANGELOG_FILE_PATH", config.changelogFilePath); + exportEnv("TAG_TEMPLATE", config.tagTemplate); + exportEnv("RELEASE_DRAFT", String(config.releaseDraft)); + exportEnv("RELEASE_PRERELEASE", String(config.releasePrerelease)); + exportEnv("RELEASE_TITLE_PREFIX", config.releaseTitlePrefix); + exportEnv("VERSION_OVERRIDE", config.versionOverride ?? ""); + exportEnv("CI_WORKFLOWS", serializeCiWorkflows(config.ciWorkflows)); +} + +export function exportSetupState(setup: ReleaseSetup): void { + exportEnv("VERSION", setup.version); + exportEnv("TAG", setup.tag); + exportEnv("RELEASE_TITLE", setup.releaseTitle); + exportEnv("TAG_EXISTS", String(setup.tagExists)); + exportEnv("RELEASE_EXISTS", String(setup.releaseExists)); + exportEnv("TARGET_BRANCH", setup.targetBranch); + + if (setup.latestTag !== undefined) { + exportEnv("LATEST_TAG", setup.latestTag); + } +} + +export function exportChangelogState(updated: boolean): void { + exportEnv("CHANGELOG_UPDATED", String(updated)); +} + +export function exportPrState(result: ChangelogPrResult): void { + exportEnv("PR_URL", result.prUrl); + exportEnv("CHANGELOG_PR_HEAD_SHA", result.headSha); +} diff --git a/src/release/collectCommits.ts b/src/release/collectCommits.ts new file mode 100644 index 0000000..d5fc29d --- /dev/null +++ b/src/release/collectCommits.ts @@ -0,0 +1,297 @@ +import type { ActionConfig } from "../config.js"; +import type { GitOptions, GitPort } from "../git/git.js"; +import type { GitHubClient, PullRequestSummary } from "../github/client.js"; +import { warning } from "../utils/log.js"; +import { parseRepositoryCoordinates } from "../utils/repository.js"; +import type { ReleaseSetup } from "./setupRelease.js"; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +export type CollectedReleaseData = { + commitLogLines: string[]; + fullChangelogLine?: string; +}; + +const FIELD_SEPARATOR = "\x1f"; +const MAX_COMMITS = 30; + +type SemverTuple = [number, number, number]; + +function extractSemver(tag: string): SemverTuple | undefined { + const match = /^[^0-9]*(\d+)\.(\d+)\.(\d+)/.exec(tag); + + if (!match) { + return undefined; + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareSemver(a: SemverTuple, b: SemverTuple): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +function findPrevAndNextSemverTags( + existingTags: string[], + targetTag: string, +): { prevSemverTag?: string; nextSemverTag?: string } { + const entries: { tag: string; semver: SemverTuple }[] = []; + + for (const tag of existingTags) { + const semver = extractSemver(tag); + if (semver) { + entries.push({ tag, semver }); + } + } + + if (!existingTags.includes(targetTag)) { + const semver = extractSemver(targetTag); + if (semver) { + entries.push({ tag: targetTag, semver }); + } + } + + entries.sort((a, b) => compareSemver(a.semver, b.semver)); + + let prevSemverTag: string | undefined; + let nextSemverTag: string | undefined; + let found = false; + + for (const entry of entries) { + if (entry.tag === targetTag) { + found = true; + continue; + } + + if (!found) { + prevSemverTag = entry.tag; + } else if (nextSemverTag === undefined) { + nextSemverTag = entry.tag; + break; + } + } + + return { prevSemverTag, nextSemverTag }; +} + +function computeCommitRange( + setup: ReleaseSetup, + prevSemverTag: string | undefined, + nextSemverTag: string | undefined, +): string { + if (setup.tagExists) { + return prevSemverTag ? `${prevSemverTag}..${setup.tag}` : setup.tag; + } + + if (prevSemverTag && nextSemverTag) { + return `${prevSemverTag}..${nextSemverTag}`; + } + + if (prevSemverTag) { + return `${prevSemverTag}..HEAD`; + } + + return "HEAD"; +} + +type ParsedCommit = { + sha: string; + shortSha: string; + authorName: string; + authorEmail: string; + subject: string; +}; + +function parseCommitLines(rawLog: string): ParsedCommit[] { + const commits: ParsedCommit[] = []; + + for (const line of rawLog.split("\n")) { + if (line.length === 0) { + continue; + } + + const [sha, shortSha, authorName, authorEmail, ...subjectParts] = + line.split(FIELD_SEPARATOR); + + if ( + sha === undefined || + sha === "" || + shortSha === undefined || + authorName === undefined || + authorEmail === undefined + ) { + continue; + } + + commits.push({ + sha, + shortSha, + authorName, + authorEmail, + subject: subjectParts.join(FIELD_SEPARATOR), + }); + } + + return commits; +} + +type ParsedPrReference = { + prNumber?: string; + prTitle?: string; + isMergeCommit: boolean; +}; + +function parsePrReferenceFromSubject(subject: string): ParsedPrReference { + const mergeMatch = /[Mm]erge pull request #(\d+)/.exec(subject); + + if (mergeMatch) { + return { prNumber: mergeMatch[1], isMergeCommit: true }; + } + + const inlineMatch = /\(#(\d+)\)/.exec(subject); + + if (inlineMatch) { + return { prNumber: inlineMatch[1], isMergeCommit: false }; + } + + return { isMergeCommit: false }; +} + +async function buildCommitLine( + commit: ParsedCommit, + config: ActionConfig, + client: GitHubClient, + seenPrNumbers: Set, +): Promise { + const { owner, repo } = parseRepositoryCoordinates(config.githubRepository); + const commitUrl = `${config.githubServerUrl}/${config.githubRepository}/commit/${commit.sha}`; + + let commitLogin: string | undefined; + + try { + const commitInfo = await client.getCommit(owner, repo, commit.sha); + commitLogin = commitInfo.login; + } catch (error: unknown) { + warning( + `Failed to resolve GitHub user for commit ${commit.sha}: ${getErrorMessage(error)}`, + ); + } + + let pullRequests: PullRequestSummary[] = []; + + try { + pullRequests = await client.listPullRequestsAssociatedWithCommit( + owner, + repo, + commit.sha, + ); + } catch (error: unknown) { + warning( + `Failed to resolve pull request for commit ${commit.sha}: ${getErrorMessage(error)}`, + ); + } + + const firstPullRequest = pullRequests[0]; + + let prNumber: string | undefined; + let prTitle: string | undefined; + let prUrl: string | undefined; + let prUserLogin: string | undefined; + + if (firstPullRequest) { + prNumber = String(firstPullRequest.number); + prTitle = firstPullRequest.title; + prUrl = firstPullRequest.html_url; + prUserLogin = firstPullRequest.user?.login; + } else { + const parsedPr = parsePrReferenceFromSubject(commit.subject); + + if (parsedPr.prNumber) { + prNumber = parsedPr.prNumber; + prUrl = `${config.githubServerUrl}/${config.githubRepository}/pull/${parsedPr.prNumber}`; + prTitle = parsedPr.isMergeCommit + ? `Pull request #${parsedPr.prNumber}` + : commit.subject; + } + } + + const resolvedLogin = + commitLogin !== undefined && commitLogin !== "" ? commitLogin : prUserLogin; + const isBot = + commit.authorEmail.includes("[bot]") || + (resolvedLogin !== undefined && resolvedLogin.includes("[bot]")); + const hasEligibleLogin = + resolvedLogin !== undefined && resolvedLogin !== "" && !isBot; + + if (prNumber !== undefined && prUrl !== undefined) { + if (seenPrNumbers.has(prNumber)) { + return undefined; + } + seenPrNumbers.add(prNumber); + + const title = + prTitle !== undefined && prTitle !== "" ? prTitle : commit.subject; + + return hasEligibleLogin + ? `* ${title} by @${resolvedLogin} in [#${prNumber}](${prUrl})` + : `* ${title} in [#${prNumber}](${prUrl})`; + } + + return hasEligibleLogin + ? `* ${commit.subject} by @${resolvedLogin} in [${commit.shortSha}](${commitUrl})` + : `* ${commit.subject} by ${commit.authorName} in [${commit.shortSha}](${commitUrl})`; +} + +export async function collectCommits( + config: ActionConfig, + setup: ReleaseSetup, + git: GitPort, + client: GitHubClient, +): Promise { + const gitOptions: GitOptions = { cwd: config.githubWorkspace }; + + if (!config.dryRun) { + git.fetchBranchesAndTags(gitOptions); + } + + const existingTags = git.tagList(gitOptions); + const { prevSemverTag, nextSemverTag } = findPrevAndNextSemverTags( + existingTags, + setup.tag, + ); + const commitRange = computeCommitRange(setup, prevSemverTag, nextSemverTag); + + const fullChangelogLine = prevSemverTag + ? `**Full Changelog**: [${prevSemverTag}...${setup.tag}](${config.githubServerUrl}/${config.githubRepository}/compare/${prevSemverTag}...${setup.tag})` + : undefined; + + const rawLog = git.gitLog( + commitRange, + `%H${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%s`, + MAX_COMMITS, + gitOptions, + ); + const commits = parseCommitLines(rawLog); + + if (commits.length === 0) { + return { + commitLogLines: ["* No changes since last release."], + fullChangelogLine, + }; + } + + const seenPrNumbers = new Set(); + const commitLogLines: string[] = []; + + for (const commit of commits) { + const line = await buildCommitLine(commit, config, client, seenPrNumbers); + + if (line !== undefined) { + commitLogLines.push(line); + } + } + + return { commitLogLines, fullChangelogLine }; +} diff --git a/src/release/createChangelogPr.ts b/src/release/createChangelogPr.ts new file mode 100644 index 0000000..a20f57d --- /dev/null +++ b/src/release/createChangelogPr.ts @@ -0,0 +1,413 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ActionConfig } from "../config.js"; +import type { GitOptions, GitPort } from "../git/git.js"; +import { + GitHubApiError, + type CheckRunConclusion, + type GitHubClient, +} from "../github/client.js"; +import { + createTempDirectory, + removeDirectory, + writeTextFile, +} from "../utils/files.js"; +import { info, warning } from "../utils/log.js"; +import { parseRepositoryCoordinates } from "../utils/repository.js"; +import { retryUntil } from "../utils/retry.js"; +import type { ReleaseSetup } from "./setupRelease.js"; +import type { ChangelogResult } from "./updateChangelog.js"; + +export type ChangelogPrResult = { + prUrl: string; + headSha: string; +}; + +const RUN_LOOKUP_MAX_ATTEMPTS = 60; +const RUN_LOOKUP_INTERVAL_MS = 5000; +const RUN_WAIT_MAX_ATTEMPTS = 120; +const RUN_WAIT_INTERVAL_MS = 10000; + +const VALID_CHECK_CONCLUSIONS: ReadonlySet = new Set([ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", +]); + +function mapJobConclusionToCheckConclusion( + conclusion: string | null, +): CheckRunConclusion { + if (conclusion !== null && VALID_CHECK_CONCLUSIONS.has(conclusion)) { + return conclusion as CheckRunConclusion; + } + + return "failure"; +} + +function getCurrentReleaseWorkflowPath( + githubWorkflowRef: string | undefined, +): string | undefined { + if (githubWorkflowRef === undefined) { + return undefined; + } + + const match = /^[^/]*\/[^/]*\/(\.github\/workflows\/[^@]*)@/.exec( + githubWorkflowRef, + ); + + return match?.[1]; +} + +function workflowSupportsAutoDispatch(content: string): boolean { + const nonCommentContent = content + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + + return /(^|[^_a-zA-Z0-9-])workflow_dispatch([^_a-zA-Z0-9-]|$)/m.test( + nonCommentContent, + ); +} + +function resolveCiWorkflows( + config: ActionConfig, + workflowsDir: string, +): string[] { + if (config.ciWorkflows.mode === "disabled") { + return []; + } + + if (config.ciWorkflows.mode === "explicit") { + return [ + ...new Set( + config.ciWorkflows.workflows.map((workflow) => path.basename(workflow)), + ), + ].sort(); + } + + const currentReleaseWorkflowPath = getCurrentReleaseWorkflowPath( + config.githubWorkflowRef, + ); + const discovered: string[] = []; + + if (fs.existsSync(workflowsDir)) { + for (const entry of fs.readdirSync(workflowsDir)) { + if (!entry.endsWith(".yml") && !entry.endsWith(".yaml")) { + continue; + } + + const relativePath = `.github/workflows/${entry}`; + + if ( + currentReleaseWorkflowPath !== undefined && + relativePath === currentReleaseWorkflowPath + ) { + info(`Skipping release workflow itself: ${relativePath}`); + continue; + } + + const content = fs.readFileSync(path.join(workflowsDir, entry), "utf8"); + + if (workflowSupportsAutoDispatch(content)) { + discovered.push(entry); + } + } + } + + return [...new Set(discovered)].sort(); +} + +function findDispatchedWorkflowRunId( + client: GitHubClient, + owner: string, + repo: string, + workflowFile: string, + branchRef: string, + headSha: string, + dispatchStartedAt: string, +): Promise { + return retryUntil( + async () => { + const runs = await client.listWorkflowRuns(owner, repo, workflowFile, { + branch: branchRef, + event: "workflow_dispatch", + perPage: 20, + }); + + const matchBySha = runs.find((run) => run.head_sha === headSha); + + if (matchBySha) { + return matchBySha.id; + } + + const matchByBranchAndTime = runs.find( + (run) => + run.head_branch === branchRef && run.created_at >= dispatchStartedAt, + ); + + return matchByBranchAndTime?.id; + }, + { + maxAttempts: RUN_LOOKUP_MAX_ATTEMPTS, + intervalMs: RUN_LOOKUP_INTERVAL_MS, + description: `dispatched workflow run for '${workflowFile}' on branch '${branchRef}' (SHA '${headSha}')`, + }, + ); +} + +async function waitForWorkflowRunCompletion( + client: GitHubClient, + owner: string, + repo: string, + runId: number, +): Promise { + await retryUntil( + async () => { + const run = await client.getWorkflowRun(owner, repo, runId); + info( + `Workflow run ${runId} status: ${run.status} conclusion: ${ + run.conclusion ?? "none" + }`, + ); + return run.status === "completed" ? true : undefined; + }, + { + maxAttempts: RUN_WAIT_MAX_ATTEMPTS, + intervalMs: RUN_WAIT_INTERVAL_MS, + description: `workflow run ${runId} to complete`, + }, + ); +} + +async function mirrorWorkflowJobsAsCheckRuns( + client: GitHubClient, + owner: string, + repo: string, + runId: number, + headSha: string, +): Promise { + const jobs = await client.listJobsForWorkflowRun(owner, repo, runId); + + if (jobs.length === 0) { + throw new Error( + `Workflow run ${runId} has no jobs. Cannot mirror required checks.`, + ); + } + + for (const job of jobs) { + const checkConclusion = mapJobConclusionToCheckConclusion(job.conclusion); + + await client.createCheckRun(owner, repo, { + name: job.name, + head_sha: headSha, + conclusion: checkConclusion, + details_url: job.html_url ?? undefined, + summary: `Mirrored result from dispatched workflow run ${runId}.`, + }); + + info( + `Created check run '${job.name}' with conclusion '${checkConclusion}' for ${headSha}.`, + ); + + if ( + checkConclusion !== "success" && + checkConclusion !== "neutral" && + checkConclusion !== "skipped" + ) { + throw new Error( + `Dispatched CI job '${job.name}' finished with conclusion '${ + job.conclusion ?? "failure" + }'.`, + ); + } + } +} + +async function dispatchConfiguredCiWorkflows( + config: ActionConfig, + client: GitHubClient, + owner: string, + repo: string, + branchName: string, + headSha: string, + tempDir: string, +): Promise { + const workflows = resolveCiWorkflows( + config, + path.join(tempDir, ".github", "workflows"), + ); + + if (workflows.length === 0) { + info("No CI workflows configured or discovered for dispatch."); + return; + } + + info(`Dispatching CI workflows for branch: ${branchName}`); + info( + `Mirroring dispatched CI jobs as check runs for PR head SHA: ${headSha}`, + ); + + for (const workflowFile of workflows) { + info(`Dispatching workflow: ${workflowFile}`); + const dispatchStartedAt = new Date().toISOString(); + + await client.createWorkflowDispatch(owner, repo, workflowFile, branchName); + info(`Workflow dispatched successfully: ${workflowFile}`); + + const runId = await findDispatchedWorkflowRunId( + client, + owner, + repo, + workflowFile, + branchName, + headSha, + dispatchStartedAt, + ); + info(`Found dispatched workflow run: ${runId}`); + + await waitForWorkflowRunCompletion(client, owner, repo, runId); + await mirrorWorkflowJobsAsCheckRuns(client, owner, repo, runId, headSha); + } +} + +export async function createChangelogPr( + config: ActionConfig, + setup: ReleaseSetup, + changelog: ChangelogResult, + git: GitPort, + client: GitHubClient, +): Promise { + const branchName = `release-changelog-update/${setup.version}`; + const tempDir = createTempDirectory("github-release-changelog-pr-"); + const tempOptions: GitOptions = { cwd: tempDir }; + const { owner, repo } = parseRepositoryCoordinates(config.githubRepository); + + try { + info(`Cloning workspace to temporary directory: ${tempDir}`); + git.cloneWorkspace(config.githubWorkspace, tempDir); + + git.fetchTargetBranch(setup.targetBranch, tempOptions); + + if (git.branchExistsRemote(branchName, tempOptions)) { + git.checkoutExistingBranch(branchName, tempOptions); + } else { + info(`Creating new branch: ${branchName}`); + + if (config.dryRun) { + info(`Dry-Run: Skipping 'git checkout -b ${branchName}'.`); + } else { + git.checkoutBranchFromTarget( + branchName, + setup.targetBranch, + tempOptions, + ); + } + } + + if (config.dryRun) { + info("Dry-Run: Skipping 'git add' and 'git commit'."); + } else { + if (changelog.changelogFileContent !== undefined) { + writeTextFile( + path.join(tempDir, config.changelogFilePath), + changelog.changelogFileContent, + ); + } + + git.add(config.changelogFilePath, tempOptions); + git.commit( + `chore: update changelog for version ${setup.version}`, + tempOptions, + ); + } + + const branchHeadSha = git.getHeadSha(tempOptions); + info(`Local branch HEAD SHA (before push): ${branchHeadSha}`); + + let prUrl: string; + let headSha: string; + + if (config.dryRun) { + info(`Dry-Run: Skipping 'git push origin ${branchName}'.`); + info("Dry-Run: Skipping PR creation."); + prUrl = `${config.githubServerUrl}/${config.githubRepository}/pull/dry-run-placeholder`; + headSha = branchHeadSha; + } else { + git.pushBranch(branchName, tempOptions); + + const prTitle = `chore: update changelog for version ${setup.version}`; + const prBody = `This PR updates the changelog for the new version ${setup.version}. Please review and merge it to proceed with the release process.`; + + try { + const created = await client.createPullRequest({ + owner, + repo, + title: prTitle, + head: branchName, + base: setup.targetBranch, + body: prBody, + }); + prUrl = created.html_url; + headSha = created.head.sha !== "" ? created.head.sha : branchHeadSha; + + if (created.head.sha === "") { + warning( + "PR head SHA not returned by API — falling back to local git SHA", + ); + } + } catch (error: unknown) { + if (error instanceof GitHubApiError && error.status === 422) { + const existing = await client.findOpenPullRequestByHead( + owner, + repo, + `${owner}:${branchName}`, + setup.targetBranch, + ); + + if (!existing) { + throw new Error( + `PR already exists for branch '${branchName}' but could not be found via the API.`, + { cause: error }, + ); + } + + info( + `PR already exists: ${existing.html_url} (head SHA: ${existing.head.sha})`, + ); + prUrl = existing.html_url; + headSha = + existing.head.sha !== "" ? existing.head.sha : branchHeadSha; + } else { + throw error; + } + } + } + + info(`Changelog PR head SHA: ${headSha}`); + + if (config.dryRun) { + info("Dry-Run: Skipping CI workflow dispatch."); + } else { + await dispatchConfiguredCiWorkflows( + config, + client, + owner, + repo, + branchName, + headSha, + tempDir, + ); + } + + info("A pull request has been created for the changelog update."); + info(`PR URL: ${prUrl}`); + + return { prUrl, headSha }; + } finally { + removeDirectory(tempDir); + } +} diff --git a/src/release/createRelease.ts b/src/release/createRelease.ts new file mode 100644 index 0000000..9662c56 --- /dev/null +++ b/src/release/createRelease.ts @@ -0,0 +1,40 @@ +import * as fs from "node:fs"; +import type { ActionConfig } from "../config.js"; +import type { GitHubClient } from "../github/client.js"; +import { info } from "../utils/log.js"; +import { parseRepositoryCoordinates } from "../utils/repository.js"; +import type { ReleaseSetup } from "./setupRelease.js"; + +export async function createReleaseForTag( + config: ActionConfig, + setup: ReleaseSetup, + releaseBody: string, + client: GitHubClient, +): Promise { + if (setup.tag === "") { + throw new Error("TAG is required but not set."); + } + + const { owner, repo } = parseRepositoryCoordinates(config.githubRepository); + + info(`Creating release for tag: ${setup.tag} in ${owner}/${repo}`); + + const release = await client.createRelease({ + owner, + repo, + tag_name: setup.tag, + target_commitish: setup.targetBranch, + name: setup.releaseTitle, + body: releaseBody, + draft: config.releaseDraft, + prerelease: config.releasePrerelease, + }); + + const githubOutput = process.env.GITHUB_OUTPUT; + + if (githubOutput !== undefined && githubOutput !== "") { + fs.appendFileSync(githubOutput, `release-url=${release.html_url}\n`); + } + + return release.html_url; +} diff --git a/src/release/pipeline.ts b/src/release/pipeline.ts new file mode 100644 index 0000000..483db15 --- /dev/null +++ b/src/release/pipeline.ts @@ -0,0 +1,70 @@ +import type { ActionConfig } from "../config.js"; +import * as realGit from "../git/git.js"; +import type { GitPort } from "../git/git.js"; +import { createGitHubClient, type GitHubClient } from "../github/client.js"; +import { info } from "../utils/log.js"; +import { + exportChangelogState, + exportPrState, + exportSetupState, +} from "./actionState.js"; +import { collectCommits } from "./collectCommits.js"; +import { createChangelogPr } from "./createChangelogPr.js"; +import { createReleaseForTag } from "./createRelease.js"; +import { setupRelease } from "./setupRelease.js"; +import { updateChangelog } from "./updateChangelog.js"; + +export type PipelineDependencies = { + git?: GitPort; + client?: GitHubClient; +}; + +export async function runPipeline( + config: ActionConfig, + deps: PipelineDependencies = {}, +): Promise { + info("Starting GitHub Release Action TypeScript pipeline."); + + const git = deps.git ?? realGit; + const client = + deps.client ?? + createGitHubClient({ + githubToken: config.githubToken, + githubApiUrl: config.githubApiUrl, + }); + + const setup = await setupRelease(config, git, client); + exportSetupState(setup); + + if (setup.releaseExists) { + throw new Error(`Release for tag ${setup.tag} already exists.`); + } + + const collected = await collectCommits(config, setup, git, client); + const changelogResult = await updateChangelog(config, setup, collected, git); + exportChangelogState(changelogResult.updated); + + if (changelogResult.updated) { + const prResult = await createChangelogPr( + config, + setup, + changelogResult, + git, + client, + ); + exportPrState(prResult); + + if (config.dryRun) { + info("Dry-Run: Skipping release process. No PR was actually created."); + return; + } + + throw new Error( + `Please review and merge the changelog PR before re-running the workflow: ${prResult.prUrl}`, + ); + } + + await createReleaseForTag(config, setup, changelogResult.releaseBody, client); + + info("GitHub Release Action TypeScript pipeline completed."); +} diff --git a/src/release/renderReleaseNotes.ts b/src/release/renderReleaseNotes.ts new file mode 100644 index 0000000..825cadb --- /dev/null +++ b/src/release/renderReleaseNotes.ts @@ -0,0 +1,22 @@ +export const FALLBACK_DESCRIPTION = "This release includes the changes below."; + +export function renderReleaseBody( + description: string, + commitLogLines: string[], + fullChangelogLine?: string, +): string { + const lines = [ + description, + "", + "------", + "", + "## What's Changed", + commitLogLines.join("\n"), + ]; + + if (fullChangelogLine !== undefined && fullChangelogLine !== "") { + lines.push("", fullChangelogLine); + } + + return lines.join("\n"); +} diff --git a/src/release/setupRelease.ts b/src/release/setupRelease.ts new file mode 100644 index 0000000..4a3ebd5 --- /dev/null +++ b/src/release/setupRelease.ts @@ -0,0 +1,147 @@ +import * as path from "node:path"; +import type { ActionConfig } from "../config.js"; +import type { GitOptions, GitPort } from "../git/git.js"; +import type { GitHubClient } from "../github/client.js"; +import { ensureTextFile, fileExists, readTextFile } from "../utils/files.js"; +import { info } from "../utils/log.js"; +import { parseRepositoryCoordinates } from "../utils/repository.js"; + +export type ReleaseSetup = { + version: string; + tag: string; + releaseTitle: string; + tagExists: boolean; + releaseExists: boolean; + latestTag?: string; + targetBranch: string; +}; + +const DEFAULT_CHANGELOG_CONTENT = + "## [unreleased]\n\n### Added\n- Placeholder changelog\n"; + +function ensureChangelogExists(config: ActionConfig): void { + const changelogPath = path.join( + config.githubWorkspace, + config.changelogFilePath, + ); + + if (fileExists(changelogPath)) { + return; + } + + info(`File not found: ${config.changelogFilePath}`); + info("Creating a default changelog file..."); + + if (config.dryRun) { + info("Dry-Run: Skipping file creation."); + return; + } + + ensureTextFile(changelogPath, DEFAULT_CHANGELOG_CONTENT); +} + +function resolveVersion(config: ActionConfig): string { + if (config.versionOverride !== undefined) { + info(`Using custom version override: ${config.versionOverride}`); + return config.versionOverride; + } + + const packageJsonPath = path.join(config.githubWorkspace, "package.json"); + + if (fileExists(packageJsonPath)) { + const parsed: unknown = JSON.parse(readTextFile(packageJsonPath)); + + if ( + typeof parsed === "object" && + parsed !== null && + "version" in parsed && + typeof parsed.version === "string" && + (parsed as { version: string }).version !== "" + ) { + return (parsed as { version: string }).version; + } + } + + throw new Error( + 'Mandatory "version" parameter has not been specified. Please check GitHub Action configuration.', + ); +} + +function resolveTag(tagTemplate: string, version: string): string { + return tagTemplate.replace("", version); +} + +function resolveReleaseTitle(config: ActionConfig, version: string): string { + if (config.releaseTitlePrefix !== "") { + return `${config.releaseTitlePrefix} v${version}`; + } + + return `v${version}`; +} + +function resolveLatestTag( + git: GitPort, + tagTemplate: string, + options: GitOptions, +): string | undefined { + const pattern = tagTemplate.replace("", ""); + const patternRegex = new RegExp(pattern); + const sortedTags = git.listTagsSortedByVersionDescending(options); + + return sortedTags.find((tag) => patternRegex.test(tag)); +} + +function resolveTargetBranch(config: ActionConfig): string { + return config.githubBaseRef ?? config.githubRefName ?? "main"; +} + +export async function setupRelease( + config: ActionConfig, + git: GitPort, + client: GitHubClient, +): Promise { + git.configSafeDirectory(config.githubWorkspace); + git.configUser(config.githubActor); + + if (config.githubToken !== "" && config.githubServerUrl !== "") { + git.configGitHttpAuth(config.githubServerUrl, config.githubToken); + } + + ensureChangelogExists(config); + + const version = resolveVersion(config); + const tag = resolveTag(config.tagTemplate, version); + const releaseTitle = resolveReleaseTitle(config, version); + + info(`Version set to: ${version} (${tag})`); + + const gitOptions: GitOptions = { cwd: config.githubWorkspace }; + + if (!config.dryRun) { + git.fetchBranchesAndTags(gitOptions); + } + + const tagExists = + git.tagExists(tag, gitOptions) || git.tagExists(`ms/${tag}`, gitOptions); + + let releaseExists = false; + + if (tagExists) { + const { owner, repo } = parseRepositoryCoordinates(config.githubRepository); + const release = await client.getReleaseByTag(owner, repo, tag); + releaseExists = release !== undefined; + } + + const latestTag = resolveLatestTag(git, config.tagTemplate, gitOptions); + const targetBranch = resolveTargetBranch(config); + + return { + version, + tag, + releaseTitle, + tagExists, + releaseExists, + latestTag, + targetBranch, + }; +} diff --git a/src/release/updateChangelog.ts b/src/release/updateChangelog.ts new file mode 100644 index 0000000..7fb5f46 --- /dev/null +++ b/src/release/updateChangelog.ts @@ -0,0 +1,211 @@ +import * as path from "node:path"; +import type { ActionConfig } from "../config.js"; +import type { GitOptions, GitPort } from "../git/git.js"; +import { readTextFile } from "../utils/files.js"; +import { info } from "../utils/log.js"; +import type { CollectedReleaseData } from "./collectCommits.js"; +import { + FALLBACK_DESCRIPTION, + renderReleaseBody, +} from "./renderReleaseNotes.js"; +import type { ReleaseSetup } from "./setupRelease.js"; + +export type ChangelogResult = { + updated: boolean; + changelogFileContent?: string; + releaseBody: string; +}; + +function buildVersionHeadingRegexes(version: string): RegExp[] { + return [ + new RegExp(`^## \\[\\[${version}\\]\\]`), + new RegExp(`^## \\[\\[${version}\\]\\(.*\\)\\]`), + new RegExp(`^## \\[${version}\\]`), + ]; +} + +function isBlank(value: string): boolean { + return value.trim() === ""; +} + +function findNextHeadingIndex(lines: string[], fromIndex: number): number { + for (let i = fromIndex; i < lines.length; i += 1) { + if (/^## \[/.test(lines[i])) { + return i; + } + } + + return -1; +} + +function extractExistingVersionDescription( + lines: string[], + version: string, +): string | undefined { + const headingRegexes = buildVersionHeadingRegexes(version); + const startIndex = lines.findIndex((line) => + headingRegexes.some((regex) => regex.test(line)), + ); + + if (startIndex === -1) { + return undefined; + } + + const nextHeadingIndex = findNextHeadingIndex(lines, startIndex + 1); + const sliceEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex; + + return lines.slice(startIndex + 1, sliceEnd).join("\n"); +} + +function splitUnreleasedSection(lines: string[]): { + header: string; + unreleasedBody: string; + rest: string; +} { + const unreleasedIndex = lines.findIndex((line) => + /^## \[unreleased\]/.test(line), + ); + + if (unreleasedIndex === -1) { + return { header: lines.join("\n"), unreleasedBody: "", rest: "" }; + } + + const header = lines.slice(0, unreleasedIndex).join("\n"); + const nextHeadingIndex = findNextHeadingIndex(lines, unreleasedIndex + 1); + + if (nextHeadingIndex === -1) { + return { + header, + unreleasedBody: lines.slice(unreleasedIndex + 1).join("\n"), + rest: "", + }; + } + + return { + header, + unreleasedBody: lines + .slice(unreleasedIndex + 1, nextHeadingIndex) + .join("\n"), + rest: lines.slice(nextHeadingIndex).join("\n"), + }; +} + +function todayDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function syncChangelogWithRemote( + config: ActionConfig, + setup: ReleaseSetup, + git: GitPort, + options: GitOptions, +): void { + git.fetchBranches(options); + + const isOutdated = git.hasDiffAgainstRef( + `origin/${setup.targetBranch}`, + config.changelogFilePath, + options, + ); + + if (isOutdated) { + info("Local CHANGELOG.md is outdated."); + + if (config.dryRun) { + info(`Dry-Run: Skipping 'git pull origin ${setup.targetBranch}'.`); + } else { + info("Pulling latest changes..."); + git.pullTargetBranch(setup.targetBranch, options); + } + } else { + info("CHANGELOG.md is up to date."); + } + + const hasLocalChanges = git.hasUnstagedChanges( + config.changelogFilePath, + options, + ); + + if (!hasLocalChanges) { + info(`No changes in ${config.changelogFilePath}`); + return; + } + + info("Saving changes before switching branches..."); + + if (config.dryRun) { + info("Dry-Run: Skipping 'git add' and 'git commit'."); + return; + } + + git.add(config.changelogFilePath, options); + git.commit("chore: save changelog changes before branch switch", options); +} + +export function updateChangelog( + config: ActionConfig, + setup: ReleaseSetup, + collected: CollectedReleaseData, + git: GitPort, +): Promise { + const gitOptions: GitOptions = { cwd: config.githubWorkspace }; + syncChangelogWithRemote(config, setup, git, gitOptions); + + const changelogPath = path.join( + config.githubWorkspace, + config.changelogFilePath, + ); + const lines = readTextFile(changelogPath).split("\n"); + + const existingDescription = extractExistingVersionDescription( + lines, + setup.version, + ); + + if (existingDescription !== undefined) { + info( + `Version ${setup.version} already exists in ${config.changelogFilePath}. Extracting description.`, + ); + + const description = isBlank(existingDescription) + ? FALLBACK_DESCRIPTION + : existingDescription; + const releaseBody = renderReleaseBody( + description, + collected.commitLogLines, + collected.fullChangelogLine, + ); + + return Promise.resolve({ updated: false, releaseBody }); + } + + info( + `Version ${setup.version} not found in ${config.changelogFilePath}. Updating changelog...`, + ); + + const { header, unreleasedBody, rest } = splitUnreleasedSection(lines); + const description = isBlank(unreleasedBody) + ? FALLBACK_DESCRIPTION + : unreleasedBody; + + const versionLink = `${config.githubServerUrl}/${config.githubRepository}/releases/tag/${setup.tag}`; + + const changelogFileContent = [ + header, + "", + "## [unreleased]", + "", + `## [[${setup.version}](${versionLink})] - ${todayDate()}`, + description, + "", + rest, + ].join("\n"); + + const releaseBody = renderReleaseBody( + description, + collected.commitLogLines, + collected.fullChangelogLine, + ); + + return Promise.resolve({ updated: true, changelogFileContent, releaseBody }); +} diff --git a/src/utils/repository.ts b/src/utils/repository.ts new file mode 100644 index 0000000..6a80ebe --- /dev/null +++ b/src/utils/repository.ts @@ -0,0 +1,18 @@ +export type RepositoryCoordinates = { + owner: string; + repo: string; +}; + +export function parseRepositoryCoordinates( + githubRepository: string, +): RepositoryCoordinates { + const parts = githubRepository.split("/"); + + if (parts.length !== 2 || parts[0] === "" || parts[1] === "") { + throw new Error( + `GITHUB_REPOSITORY must be in the form "owner/repo", got: "${githubRepository}".`, + ); + } + + return { owner: parts[0], repo: parts[1] }; +}