diff --git a/.github/workflows/auto-release-on-main.yml b/.github/workflows/auto-release-on-main.yml new file mode 100644 index 0000000..fdfaf55 --- /dev/null +++ b/.github/workflows/auto-release-on-main.yml @@ -0,0 +1,54 @@ +name: Auto Release On Main Merge + +on: + pull_request_target: + types: + - closed + +permissions: + contents: write + +jobs: + auto-release: + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Prepare next release version + working-directory: ServerDeck + run: node ./scripts/auto-release-on-main.mjs + + - name: Read version + id: version + working-directory: ServerDeck + run: | + echo "version=$(node -e \"console.log(require('./package.json').version)\")" >> "$GITHUB_OUTPUT" + + - name: Commit, tag, and push + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git add ServerDeck/package.json \ + ServerDeck/package-lock.json \ + ServerDeck/src-tauri/Cargo.toml \ + ServerDeck/src-tauri/tauri.conf.json \ + docs/changelog.md + git commit -m "chore(release): v${VERSION}" + git tag "v${VERSION}" + git push origin main + git push origin "v${VERSION}" diff --git a/ServerDeck/scripts/auto-release-on-main.mjs b/ServerDeck/scripts/auto-release-on-main.mjs new file mode 100644 index 0000000..ac37ba7 --- /dev/null +++ b/ServerDeck/scripts/auto-release-on-main.mjs @@ -0,0 +1,214 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const execFile = promisify(execFileCallback); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(scriptDir, ".."); +const repoRoot = path.resolve(appRoot, ".."); +const packageJsonPath = path.join(appRoot, "package.json"); +const packageLockPath = path.join(appRoot, "package-lock.json"); +const tauriConfigPath = path.join(appRoot, "src-tauri", "tauri.conf.json"); +const cargoTomlPath = path.join(appRoot, "src-tauri", "Cargo.toml"); +const changelogPath = path.join(repoRoot, "docs", "changelog.md"); + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function bumpPatchVersion(version) { + const match = String(version || "").trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + + if (!match) { + throw new Error(`Unsupported version format: ${version}`); + } + + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number(patch) + 1}`; +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +async function writeJson(filePath, data) { + await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +async function getLatestTag() { + try { + const { stdout } = await execFile("git", ["describe", "--tags", "--abbrev=0", "--match", "v*"], { + cwd: repoRoot + }); + return stdout.trim() || null; + } catch { + return null; + } +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +async function getCommitSubjects(previousTag) { + const range = previousTag ? `${previousTag}..HEAD` : "HEAD"; + const { stdout } = await execFile("git", ["log", range, "--no-merges", "--pretty=format:%s"], { + cwd: repoRoot + }); + + const subjects = stdout + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + .filter((item) => !item.startsWith("chore(release):")); + + if (subjects.length > 0) { + return subjects; + } + + const fallback = await execFile("git", ["log", "-1", "--pretty=format:%s"], { cwd: repoRoot }); + return fallback.stdout.trim() ? [fallback.stdout.trim()] : []; +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function normalizeCommitMessage(subject) { + const stripped = subject + .replace(/^[a-z]+(?:\([^)]+\))?!?:\s*/i, "") + .replace(/^merge pull request\s+#\d+\s+from\s+/i, "") + .trim(); + + if (!stripped) { + return "Maintenance updates"; + } + + return stripped.charAt(0).toUpperCase() + stripped.slice(1); +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function categorizeCommits(subjects) { + const categories = { + added: [], + changed: [], + fixed: [] + }; + + for (const subject of subjects) { + const normalized = normalizeCommitMessage(subject); + const lower = subject.toLowerCase(); + + if (lower.startsWith("feat:") || lower.startsWith("feat(")) { + categories.added.push(normalized); + continue; + } + + if (lower.startsWith("fix:") || lower.startsWith("fix(") || lower.startsWith("bugfix:") || lower.startsWith("bugfix(")) { + categories.fixed.push(normalized); + continue; + } + + categories.changed.push(normalized); + } + + categories.added = [...new Set(categories.added)]; + categories.changed = [...new Set(categories.changed)]; + categories.fixed = [...new Set(categories.fixed)]; + return categories; +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function formatSectionItems(items) { + if (items.length === 0) { + return "- None"; + } + + return items.map((item) => `- ${item}`).join("\n"); +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function buildChangelogEntry(newVersion, previousVersion, categories) { + return [ + `## v${newVersion}`, + "", + `Compared with \`v${previousVersion}\`.`, + "", + "### Added", + "", + formatSectionItems(categories.added), + "", + "### Changed", + "", + formatSectionItems(categories.changed), + "", + "### Fixed", + "", + formatSectionItems(categories.fixed), + "" + ].join("\n"); +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +function updateCargoTomlVersion(content, version) { + const nextContent = content.replace(/^version = ".*"$/m, `version = "${version}"`); + + if (nextContent === content) { + throw new Error("Failed to update version in Cargo.toml"); + } + + return nextContent; +} + +// author: BrianXiong +// time: 2026/04/15/11:40:00 +async function main() { + const packageJson = await readJson(packageJsonPath); + const packageLock = await readJson(packageLockPath); + const tauriConfig = await readJson(tauriConfigPath); + const cargoToml = await readFile(cargoTomlPath, "utf8"); + const changelog = await readFile(changelogPath, "utf8"); + const currentVersion = packageJson.version; + const newVersion = bumpPatchVersion(currentVersion); + const latestTag = await getLatestTag(); + const commitSubjects = await getCommitSubjects(latestTag); + const categories = categorizeCommits(commitSubjects); + const changelogEntry = buildChangelogEntry(newVersion, currentVersion, categories); + + packageJson.version = newVersion; + packageLock.version = newVersion; + if (packageLock.packages?.[""]) { + packageLock.packages[""].version = newVersion; + } + tauriConfig.version = newVersion; + + const nextCargoToml = updateCargoTomlVersion(cargoToml, newVersion); + const changelogLines = changelog.split(/\r?\n/); + const insertionIndex = changelogLines.findIndex((line) => line.startsWith("## ")); + const nextChangelog = insertionIndex === -1 + ? `${changelog.trim()}\n\n${changelogEntry}` + : [ + ...changelogLines.slice(0, insertionIndex), + changelogEntry.trimEnd(), + "", + ...changelogLines.slice(insertionIndex) + ].join("\n"); + + await Promise.all([ + writeJson(packageJsonPath, packageJson), + writeJson(packageLockPath, packageLock), + writeJson(tauriConfigPath, tauriConfig), + writeFile(cargoTomlPath, nextCargoToml, "utf8"), + writeFile(changelogPath, `${nextChangelog.trimEnd()}\n`, "utf8") + ]); + + console.log(`Released version prepared: ${currentVersion} -> ${newVersion}`); +} + +await main(); diff --git a/docs/release-process.md b/docs/release-process.md index d1799ed..b41982c 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -30,22 +30,19 @@ Do not fill release notes with release mechanics like version bumping, tag creat ## 2. Merge to `main` -Release changes should land in `main` through a pull request. +All changes should still land in `main` through a pull request. -## 3. Create and push a tag +After a pull request is merged into `main`, the workflow at `.github/workflows/auto-release-on-main.yml` will automatically: -From a clean `main` branch: +- bump the patch version +- sync version files +- prepend release notes to `docs/changelog.md` +- commit the release change back to `main` +- create and push the matching `v*` tag -```bash -git checkout main -git pull --ff-only -git tag v0.1.1 -git push origin v0.1.1 -``` +## 3. GitHub Actions builds the release -## 4. GitHub Actions builds the release - -The workflow at `.github/workflows/release.yml` runs on tags matching `v*`. +Once the auto-release workflow pushes the new tag, `.github/workflows/release.yml` runs on tags matching `v*`. It will: @@ -65,6 +62,15 @@ Before the updater can work, configure these GitHub repository secrets: The matching updater public key is embedded in `ServerDeck/src-tauri/tauri.conf.json`. +## 4. Notes about automatic release behavior + +- The current auto-release flow bumps the patch version on every merged pull request into `main`. +- Release notes are generated from commit subjects since the previous tag. +- Conventional commit prefixes are mapped as follows: + - `feat:` -> `Added` + - `fix:` -> `Fixed` + - everything else -> `Changed` + ## 5. Client update behavior The desktop app uses Tauri updater metadata on startup. If a signed newer release is available, the `Update` button appears. Clicking it opens an in-app update dialog where the user can download the update in the background and then restart the app to switch to the new version.