#!/usr/bin/env node import { execFileSync } from "node:child_process" import { mkdirSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(__dirname, "..", "..") const baseVersion = process.env.BASE_VERSION ?? "1.0.0" const outputDir = process.env.RELEASE_OUTPUT_DIR ?? join(repoRoot, ".ci", "release") const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? "" const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "") const CONVENTIONAL_RE = /^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i const MINOR_TYPES = new Set(["feat"]) const PATCH_TYPES = new Set([ "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", ]) function git(...args) { return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim() } function parseSemver(version) { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) if (!match) { throw new Error(`Invalid semver: ${version}`) } return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), } } function formatSemver(parts) { return `${parts.major}.${parts.minor}.${parts.patch}` } function bumpVersion(version, bump) { const parts = parseSemver(version) if (bump === "minor") { parts.minor += 1 parts.patch = 0 return formatSemver(parts) } parts.patch += 1 return formatSemver(parts) } function parseCommit(subject, body = "") { const match = CONVENTIONAL_RE.exec(subject.trim()) const breaking = /BREAKING CHANGE/i.test(body) || (match?.[1] && subject.includes(`${match[1]}!:`)) if (!match) { return { type: "other", scope: null, description: subject.trim(), bump: "patch" } } const type = match[1].toLowerCase() const scope = match[2]?.slice(1, -1) ?? null const description = match[3].trim() let bump = "patch" if (breaking || MINOR_TYPES.has(type)) { bump = "minor" } else if (PATCH_TYPES.has(type)) { bump = "patch" } return { type, scope, description, bump } } function getLatestTag() { try { const tags = git("tag", "--list", "v*.*.*", "--sort=-v:refname") const first = tags.split("\n").map((line) => line.trim()).find(Boolean) return first ?? null } catch { return null } } function getCommitsSince(tag) { const range = tag ? `${tag}..HEAD` : "HEAD" const raw = git( "log", range, "--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e", ) if (!raw) return [] return raw .split("\x1e") .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { const [sha, shortSha, author, subject, body] = entry.split("\x1f") const parsed = parseCommit(subject, body) return { sha, shortSha, author, subject, type: parsed.type, scope: parsed.scope, bump: parsed.bump, } }) } function groupCommits(commits) { const groups = new Map() for (const commit of commits) { const key = commit.type if (!groups.has(key)) groups.set(key, []) groups.get(key).push(commit) } return groups } function renderReleaseNotes({ tag, version, commits }) { const lines = [`# ${tag}`, "", `Версия **${version}**.`, ""] const groups = groupCommits(commits) const order = ["feat", "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", "other"] for (const type of order) { const items = groups.get(type) if (!items?.length) continue lines.push(`## ${type}`, "") for (const item of items) { lines.push(`- ${item.subject} (\`${item.shortSha}\`, ${item.author})`) } lines.push("") } return `${lines.join("\n").trim()}\n` } function buildReleaseUrl(tag) { if (!repository) return "" const [owner, repo] = repository.split("/") if (!owner || !repo) return "" return `${serverUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}` } function writeGithubOutput(values) { const outputFile = process.env.GITHUB_OUTPUT if (!outputFile) return const lines = Object.entries(values) .map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "%0A")}`) .join("\n") writeFileSync(outputFile, `${lines}\n`, { encoding: "utf8", flag: "a" }) } function main() { const latestTag = getLatestTag() const baseFromTag = latestTag?.replace(/^v/, "") ?? baseVersion const commits = getCommitsSince(latestTag) if (commits.length === 0) { mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, "release_notes.md"), "", "utf8") writeFileSync( join(outputDir, "release-manifest.json"), `${JSON.stringify( { version: baseFromTag, tag: latestTag ?? `v${baseFromTag}`, publishedAt: null, releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "", commits: [], }, null, 2, )}\n`, "utf8", ) writeGithubOutput({ should_release: "false", version: baseFromTag, tag: latestTag ?? `v${baseFromTag}`, bump: "none", release_url: latestTag ? buildReleaseUrl(latestTag) : "", }) return } const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch" const version = bumpVersion(baseFromTag, bump) const tag = `v${version}` const publishedAt = new Date().toISOString() const releaseUrl = buildReleaseUrl(tag) const releaseNotes = renderReleaseNotes({ tag, version, commits }) const manifest = { version, tag, publishedAt, releaseUrl, commits: commits.map((commit) => ({ sha: commit.sha, shortSha: commit.shortSha, subject: commit.subject, author: commit.author, type: commit.type, })), } mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8") writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8") writeGithubOutput({ should_release: "true", version, tag, bump, release_url: releaseUrl, }) process.stdout.write(`${tag}\n`) } main()