Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/auto-release-on-main.yml
Original file line number Diff line number Diff line change
@@ -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}"
214 changes: 214 additions & 0 deletions ServerDeck/scripts/auto-release-on-main.mjs
Original file line number Diff line number Diff line change
@@ -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();
30 changes: 18 additions & 12 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Loading