#!/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, 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 MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6) 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 getAllTags() { try { return git("tag", "--list", "v*.*.*", "--sort=-v:refname") .split("\n") .map((line) => line.trim()) .filter(Boolean) } catch { return [] } } function getPreviousTag(tag) { const tags = getAllTags() const index = tags.indexOf(tag) return index >= 0 && index + 1 < tags.length ? tags[index + 1] : null } function getTagPublishedAt(tag) { try { return git("log", "-1", "--format=%aI", tag) } catch { return null } } function toManifestCommits(commits) { return commits.map((commit) => ({ sha: commit.sha, shortSha: commit.shortSha, subject: commit.subject, author: commit.author, type: commit.type, })) } function writeManifest(manifest) { mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8") } function getLatestTag() { return getAllTags()[0] ?? 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 parseCommitLog(raw) } function getRecentCommits(limit = MANIFEST_COMMIT_LIMIT) { const raw = git( "log", "-n", String(limit), "HEAD", "--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e", ) if (!raw) return [] return parseCommitLog(raw) } function parseCommitLog(raw) { 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) { const deployTag = latestTag ?? `v${baseFromTag}` const deployVersion = baseFromTag const displayCommits = getRecentCommits() const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, "release_notes.md"), "", "utf8") writeManifest({ version: deployVersion, tag: deployTag, publishedAt, releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "", commits: toManifestCommits(displayCommits), }) writeGithubOutput({ should_release: "false", version: deployVersion, tag: deployTag, 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: toManifestCommits(getRecentCommits()), } mkdirSync(outputDir, { recursive: true }) writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8") writeManifest(manifest) writeGithubOutput({ should_release: "true", version, tag, bump, release_url: releaseUrl, }) process.stdout.write(`${tag}\n`) } main()