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
99 changes: 99 additions & 0 deletions .github/scripts/prepare-release-notes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from "node:fs";
import { pathToFileURL } from "node:url";

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function extractChangelogSection(changelog, version) {
const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\](?: \\- .*)?$`, "m");
const match = heading.exec(changelog);

if (!match) {
throw new Error(`CHANGELOG.md does not contain a section for ${version}.`);
}

const sectionStart = match.index + match[0].length;
const remaining = changelog.slice(sectionStart);
const nextVersion = remaining.search(/^## \[/m);
const section = (nextVersion === -1 ? remaining : remaining.slice(0, nextVersion)).trim();

if (!section) {
throw new Error(`The CHANGELOG.md section for ${version} is empty.`);
}

return section;
}

export function normalizeIssueReferences(value) {
const references = [];
const seen = new Set();

for (const match of value.matchAll(/#?(\d+)/g)) {
const reference = `#${match[1]}`;
if (!seen.has(reference)) {
seen.add(reference);
references.push(reference);
}
}

return references;
}

export function buildReleaseNotes({ changelog, generatedBody, relatedIssues, version }) {
const parts = [`## Changelog\n\n${extractChangelogSection(changelog, version)}`];
const issues = normalizeIssueReferences(relatedIssues);

if (issues.length > 0) {
parts.push(`## Related Issues\n\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
}

if (generatedBody.trim()) {
parts.push(generatedBody.trim());
}

return `${parts.join("\n\n")}\n`;
}

function parseArguments(argv) {
const values = new Map();

for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];

if (!key?.startsWith("--") || value === undefined) {
throw new Error(`Invalid argument near ${key ?? "the end of the command"}.`);
}

values.set(key.slice(2), value);
}

return values;
}

function run() {
const argumentsMap = parseArguments(process.argv.slice(2));
const version = argumentsMap.get("version");
const changelogPath = argumentsMap.get("changelog");
const generatedPath = argumentsMap.get("generated");
const outputPath = argumentsMap.get("output");

if (!version || !changelogPath || !generatedPath || !outputPath) {
throw new Error("Required arguments: --version, --changelog, --generated, and --output.");
}

const generated = JSON.parse(fs.readFileSync(generatedPath, "utf8"));
const notes = buildReleaseNotes({
changelog: fs.readFileSync(changelogPath, "utf8"),
generatedBody: typeof generated.body === "string" ? generated.body : "",
relatedIssues: argumentsMap.get("issues") ?? "",
version,
});

fs.writeFileSync(outputPath, notes, "utf8");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run();
}
167 changes: 167 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
name: Release

on:
workflow_dispatch:
inputs:
related_issues:
description: "Related issue numbers, for example: #12, #34"
required: false
type: string

permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: Build and publish prerelease
if: github.ref_type == 'branch' && startsWith(github.ref_name, 'release/')
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
CI: true
GH_TOKEN: ${{ github.token }}
NEXT_TELEMETRY_DISABLED: 1

steps:
- name: Check out selected release branch
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Validate release branch and metadata
id: release
shell: bash
run: |
set -euo pipefail

version="$(node -p "require('./package.json').version")"
branch="release/${version}"
tag="v${version}"

if [[ "${version}" != *-* ]]; then
echo "Version ${version} is not a prerelease version." >&2
exit 1
fi

if [[ "${GITHUB_REF_NAME}" != "${branch}" ]]; then
echo "Selected branch ${GITHUB_REF_NAME} must match ${branch}." >&2
exit 1
fi

if ! grep -Fq "## [${version}] - " CHANGELOG.md; then
echo "CHANGELOG.md does not contain a dated ${version} section." >&2
exit 1
fi

if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
echo "Tag ${tag} already exists." >&2
exit 1
fi

echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"

- name: Install dependencies
run: npm ci

- name: Check formatting
run: npx prettier --check .

- name: Run ESLint
run: npm run lint

- name: Check TypeScript
run: npx tsc --noEmit --incremental false

- name: Run tests
run: npm test

- name: Build production application
run: npm run build

- name: Package release assets
env:
VERSION: ${{ steps.release.outputs.version }}
shell: bash
run: |
set -euo pipefail

bundle="terminal-blog-${VERSION}-linux-x64-standalone"
source="terminal-blog-${VERSION}-source"

mkdir -p "dist/${bundle}/.next" \
"dist/${bundle}/articles" \
"dist/${bundle}/draft" \
"dist/${bundle}/access" \
"dist/${bundle}/data"
cp -a .next/standalone/. "dist/${bundle}/"
cp -a .next/static "dist/${bundle}/.next/static"
cp -a public config "dist/${bundle}/"
cp README.md README.en.md CHANGELOG.md LICENSE .env.example "dist/${bundle}/"

tar -C dist -czf "dist/${bundle}.tar.gz" "${bundle}"
rm -rf "dist/${bundle}"

git archive --format=zip --prefix="${source}/" -o "dist/${source}.zip" HEAD
(
cd dist
sha256sum *.tar.gz *.zip > SHA256SUMS
)

- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
with:
name: terminal-blog-${{ steps.release.outputs.version }}
path: dist/
if-no-files-found: error
retention-days: 30

- name: Generate release notes
env:
RELATED_ISSUES: ${{ inputs.related_issues }}
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
shell: bash
run: |
set -euo pipefail

gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f "tag_name=${TAG}" \
-f "target_commitish=${GITHUB_SHA}" \
> generated-release-notes.json

node .github/scripts/prepare-release-notes.mjs \
--version "${VERSION}" \
--changelog CHANGELOG.md \
--generated generated-release-notes.json \
--issues "${RELATED_ISSUES}" \
--output release-notes.md

cat release-notes.md >> "${GITHUB_STEP_SUMMARY}"

- name: Create GitHub prerelease
env:
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
shell: bash
run: |
set -euo pipefail

gh release create "${TAG}" dist/* \
--repo "${GITHUB_REPOSITORY}" \
--target "${GITHUB_SHA}" \
--title "Terminal Blog ${TAG}" \
--notes-file release-notes.md \
--prerelease
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Short `config` virtual path for editing site configuration from the terminal.
- Configurable first-visit cookie and local-storage consent prompt with persistent `y`, `n`, and `Ctrl+C` handling.
- Live title-template updates for opened articles, with the configured site description as the default article name.
- Manual GitHub Actions prerelease workflow with release-branch validation, build artifacts, SHA-256 checksums, related Issue references, and generated Pull Request and contributor notes.
- Production Docker image and Compose deployment with persistent content and database volumes.

### Security
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ chore/<topic> 工具链和维护工作
2. 准备发布时,从最新 `main` 创建 `release/<version>` 分支,例如 `release/0.1.0-beta.1`。
3. 发布分支只接受版本号、CHANGELOG、发布说明和必要的发布修复。通用代码修复必须先同步到 `main`,再合入发布分支。
4. 每个版本都要更新 `CHANGELOG.md`,版本号遵循 Semantic Versioning;预发布版本使用 `-alpha.N`、`-beta.N` 或 `-rc.N`。
5. 在 GitHub Actions 中手动运行 `Release` 工作流,并在分支选择器中选择对应的 `release/<version>`。工作流会拒绝从 `main`、特性分支或版本号不匹配的分支发布。
6. 可在 `related_issues` 输入框填写 `#12, #34` 等 Issue 编号。版本 Changelog 会完整保留,GitHub 自动生成的 Pull Request 和贡献者信息会追加到发布说明。
7. 工作流通过 Prettier、ESLint、TypeScript、Vitest 和生产构建后,创建 `v<version>` 标签和 GitHub pre-release,并上传源码 ZIP、Linux x64 standalone 包和 SHA-256 校验文件。
8. 发布失败后的通用修复仍应先提交到 `main`,再同步到版本分支并重新运行工作流。已经存在的版本标签不会被覆盖。

### 4. 架构约束

Expand Down Expand Up @@ -183,6 +187,10 @@ Use this order for releases:
2. When preparing a release, create `release/<version>` from the latest `main`, for example `release/0.1.0-beta.1`.
3. Release branches accept only version metadata, CHANGELOG updates, release notes, and necessary release fixes. General code fixes must land in `main` first and then be synchronized to the release branch.
4. Update `CHANGELOG.md` for every release. Versions follow Semantic Versioning, with `-alpha.N`, `-beta.N`, or `-rc.N` suffixes for prereleases.
5. Manually run the `Release` workflow in GitHub Actions and choose the matching `release/<version>` in the branch selector. The workflow rejects `main`, feature branches, and release branches whose name does not match `package.json`.
6. Optionally enter issue numbers such as `#12, #34` in `related_issues`. The version's Changelog is preserved, and GitHub-generated Pull Request and contributor details are appended to the release notes.
7. After Prettier, ESLint, TypeScript, Vitest, and the production build pass, the workflow creates the `v<version>` tag and GitHub pre-release, then uploads a source ZIP, Linux x64 standalone bundle, and SHA-256 checksums.
8. General fixes for a failed release still land in `main` first and are then synchronized to the release branch before rerunning the workflow. Existing version tags are never overwritten.

### 4. Architecture rules

Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the complete policy. The essentia
1. Create a focused `feat/`, `fix/`, `docs/`, or `refactor/` branch from the latest `main`.
2. Implement and verify the change on that branch, using Conventional Commits.
3. Open a Pull Request from the feature branch to `main`; maintainers review and merge it manually.
4. When releasing, create `release/<version>` from the latest `main`; synchronize release fixes back to `main` first.
4. When releasing, create `release/<version>` from the latest `main`, select it in GitHub Actions, and manually run the `Release` workflow; synchronize release fixes back to `main` first.

Do not commit articles, drafts, databases, local environment variables, or Agent instruction files. Do not disclose exploit details in public issues; use Private vulnerability reporting from the repository Security page.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ npm run build
1. 从最新 `main` 创建单一职责的 `feat/`、`fix/`、`docs/` 或 `refactor/` 特性分支。
2. 在特性分支完成实现和检查,提交使用 Conventional Commits。
3. 特性分支提交 Pull Request 到 `main`,由维护者 Code Review 并手动合并。
4. 发布时从最新 `main` 创建 `release/<version>` 分支;发布修复先同步回 `main`。
4. 发布时从最新 `main` 创建 `release/<version>` 分支,再在 GitHub Actions 中选择该分支手动运行 `Release` 工作流;发布修复先同步回 `main`。

请不要提交文章、草稿、数据库、本地环境变量或 Agent 指令文件。安全漏洞不要公开提交利用细节,请使用 GitHub Security 页面中的 Private vulnerability reporting。

Expand Down
60 changes: 60 additions & 0 deletions tests/release-notes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";

import {
buildReleaseNotes,
extractChangelogSection,
normalizeIssueReferences,
} from "../.github/scripts/prepare-release-notes.mjs";

const changelog = `# Changelog

## [Unreleased]

## [0.2.0] - 2026-08-17

### Added

- A newer feature.

## [0.1.0-beta.1] - 2026-08-16

### Added

- Initial beta.

### Security

- Hardened requests.

[0.1.0-beta.1]: https://example.com
`;

describe("release notes", () => {
it("extracts only the requested changelog version", () => {
expect(extractChangelogSection(changelog, "0.1.0-beta.1")).toBe(
"### Added\n\n- Initial beta.\n\n### Security\n\n- Hardened requests.\n\n[0.1.0-beta.1]: https://example.com",
);
});

it("normalizes and deduplicates related issue numbers", () => {
expect(normalizeIssueReferences("#12, 34 #12")).toEqual(["#12", "#34"]);
});

it("keeps changelog, issues, pull requests, and contributors", () => {
const notes = buildReleaseNotes({
changelog,
generatedBody: "## What's Changed\n\n- Fix terminal title by @bao-cn in #7",
relatedIssues: "12, #34",
version: "0.1.0-beta.1",
});

expect(notes).toContain("## Changelog");
expect(notes).toContain("- Initial beta.");
expect(notes).toContain("## Related Issues\n\n- #12\n- #34");
expect(notes).toContain("@bao-cn in #7");
});

it("rejects a missing version section", () => {
expect(() => extractChangelogSection(changelog, "9.9.9")).toThrow(/does not contain/);
});
});
Loading