Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m36s
Docker images / frontend-image (push) Successful in 2m25s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
284 lines
7.2 KiB
JavaScript
284 lines
7.2 KiB
JavaScript
#!/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()
|