From 0c109d7f94d3f2cd204b54fb7500a67c97ca2d6e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 21:42:40 +0800 Subject: [PATCH 1/2] feat(release): add manual prerelease workflow --- .github/scripts/prepare-release-notes.mjs | 99 +++++++++++++ .github/workflows/release.yml | 167 ++++++++++++++++++++++ CHANGELOG.md | 1 + tests/release-notes.test.mjs | 60 ++++++++ 4 files changed, 327 insertions(+) create mode 100644 .github/scripts/prepare-release-notes.mjs create mode 100644 .github/workflows/release.yml create mode 100644 tests/release-notes.test.mjs diff --git a/.github/scripts/prepare-release-notes.mjs b/.github/scripts/prepare-release-notes.mjs new file mode 100644 index 0000000..2972765 --- /dev/null +++ b/.github/scripts/prepare-release-notes.mjs @@ -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(); +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..29f015d --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 221ab9c..19cdbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/release-notes.test.mjs b/tests/release-notes.test.mjs new file mode 100644 index 0000000..f61b908 --- /dev/null +++ b/tests/release-notes.test.mjs @@ -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/); + }); +}); From 087809a8526b0a2bcd5ab57009cace092492b766 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 21:42:40 +0800 Subject: [PATCH 2/2] docs(release): document manual publishing flow --- CONTRIBUTING.md | 8 ++++++++ README.en.md | 2 +- README.md | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce381b4..80ca6af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,10 @@ chore/ 工具链和维护工作 2. 准备发布时,从最新 `main` 创建 `release/` 分支,例如 `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/`。工作流会拒绝从 `main`、特性分支或版本号不匹配的分支发布。 +6. 可在 `related_issues` 输入框填写 `#12, #34` 等 Issue 编号。版本 Changelog 会完整保留,GitHub 自动生成的 Pull Request 和贡献者信息会追加到发布说明。 +7. 工作流通过 Prettier、ESLint、TypeScript、Vitest 和生产构建后,创建 `v` 标签和 GitHub pre-release,并上传源码 ZIP、Linux x64 standalone 包和 SHA-256 校验文件。 +8. 发布失败后的通用修复仍应先提交到 `main`,再同步到版本分支并重新运行工作流。已经存在的版本标签不会被覆盖。 ### 4. 架构约束 @@ -183,6 +187,10 @@ Use this order for releases: 2. When preparing a release, create `release/` 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/` 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` 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 diff --git a/README.en.md b/README.en.md index 100d8c7..fffbc90 100644 --- a/README.en.md +++ b/README.en.md @@ -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/` from the latest `main`; synchronize release fixes back to `main` first. +4. When releasing, create `release/` 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. diff --git a/README.md b/README.md index 8712fd4..7cd34db 100644 --- a/README.md +++ b/README.md @@ -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/` 分支;发布修复先同步回 `main`。 +4. 发布时从最新 `main` 创建 `release/` 分支,再在 GitHub Actions 中选择该分支手动运行 `Release` 工作流;发布修复先同步回 `main`。 请不要提交文章、草稿、数据库、本地环境变量或 Agent 指令文件。安全漏洞不要公开提交利用细节,请使用 GitHub Security 页面中的 Private vulnerability reporting。