diff --git a/.github/scripts/extract-release-info.mjs b/.github/scripts/extract-release-info.mjs index 5062cb77f..34c41f8c6 100755 --- a/.github/scripts/extract-release-info.mjs +++ b/.github/scripts/extract-release-info.mjs @@ -7,13 +7,21 @@ import { execSync } from "node:child_process" import { appendFileSync } from "node:fs" +import { pathToFileURL } from "node:url" // Configuration const RELEASE_PATTERNS = { - desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, - mobile: /release\(mobile\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i, + desktop: /^release\(desktop\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i, + mobile: /^release\(mobile\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i, } +const RELEASE_PLATFORM_BY_REF = { + main: "desktop", + "mobile-main": "mobile", +} + +const GITHUB_MERGE_SUBJECT_PATTERN = /^Merge pull request #\d+ from /i + const EXIT_CODES = { SUCCESS: 0, GIT_ERROR: 2, @@ -57,7 +65,6 @@ function setGitHubOutput(key, value) { /** * Get the latest commit message - * @returns {string} Latest commit message */ function getLatestCommitMessage() { try { @@ -69,21 +76,45 @@ function getLatestCommitMessage() { } /** - * Extract release information from commit message + * Extract release information from a commit message. + * Prefer the subject. For a standard GitHub merge commit, fall back only to the first non-empty body + * line, where GitHub stores the PR title. When a GitHub ref is available, only the platform released + * from that branch is considered. Other body lines are ignored so stale release commits cannot + * retrigger a release. * @param {string} commitMessage - Git commit message - * @returns {Object|null} Release information or null if no release found + * @param {string|undefined} refName - GitHub ref name + * @returns {{platform: string, version: string, tagName: string}|null} Release information or null */ -function extractReleaseInfo(commitMessage) { - for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) { - const match = commitMessage.match(regex) - if (match) { - const version = match[1] - const tagName = `${platform}/${version}` +export function extractReleaseInfo(commitMessage, refName = process.env.GITHUB_REF_NAME) { + const [commitSubject = "", ...commitBodyLines] = commitMessage.split(/\r?\n/) + const expectedPlatform = refName ? RELEASE_PLATFORM_BY_REF[refName] : undefined - return { - platform, - version, - tagName, + if (refName && !expectedPlatform) { + return null + } + + const platforms = expectedPlatform ? [expectedPlatform] : Object.keys(RELEASE_PATTERNS) + const candidates = [commitSubject.trim()] + + if (GITHUB_MERGE_SUBJECT_PATTERN.test(commitSubject)) { + const pullRequestTitle = commitBodyLines.map((line) => line.trim()).find(Boolean) + if (pullRequestTitle) { + candidates.push(pullRequestTitle) + } + } + + for (const candidate of candidates) { + for (const platform of platforms) { + const match = candidate.match(RELEASE_PATTERNS[platform]) + if (match) { + const version = match[1] + const tagName = `${platform}/${version}` + + return { + platform, + version, + tagName, + } } } } @@ -128,4 +159,6 @@ function main() { } } -main() +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/.github/scripts/extract-release-info.test.ts b/.github/scripts/extract-release-info.test.ts new file mode 100644 index 000000000..6adf7e9fe --- /dev/null +++ b/.github/scripts/extract-release-info.test.ts @@ -0,0 +1,161 @@ +import { execFile } from "node:child_process" +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" + +import { join } from "pathe" +import { describe, expect, it } from "vitest" + +import { extractReleaseInfo } from "./extract-release-info.mjs" + +const execFileAsync = promisify(execFile) + +describe("extractReleaseInfo", () => { + it("recognizes a mobile release from the subject and ignores old desktop markers in the body", () => { + const commitMessage = [ + "release(mobile): Release v0.5.7 (#5061)", + "", + "* release(desktop): release v1.11.0", + "* docs(mobile): restore desktop release inputs", + ].join("\n") + + expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({ + platform: "mobile", + version: "v0.5.7", + tagName: "mobile/v0.5.7", + }) + }) + + it("rejects a release subject on the wrong target branch", () => { + expect(extractReleaseInfo("release(mobile): Release v0.5.7", "main")).toBeNull() + expect(extractReleaseInfo("release(desktop): Release v1.12.0", "mobile-main")).toBeNull() + }) + + it("recognizes the target platform release from a standard merge commit body", () => { + const commitMessage = [ + "Merge pull request #5061 from RSSNext/release/mobile/0.5.7", + "", + "release(mobile): Release v0.5.7", + ].join("\n") + + expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({ + platform: "mobile", + version: "v0.5.7", + tagName: "mobile/v0.5.7", + }) + }) + + it("ignores ordinary commits", () => { + expect(extractReleaseInfo("fix(mobile): restore release metadata", "mobile-main")).toBeNull() + }) + + it("ignores release markers for the wrong platform in a merge commit body", () => { + const commitMessage = [ + "Merge pull request #5061 from RSSNext/release/mobile/0.5.7", + "", + "release(mobile): Release v0.5.7", + ].join("\n") + + expect(extractReleaseInfo(commitMessage, "main")).toBeNull() + }) + + it("ignores stale release markers later in an ordinary commit body", () => { + const commitMessage = [ + "chore(sync): merge mobile-main into dev", + "", + "* release(mobile): Release v0.5.6", + ].join("\n") + + expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull() + }) + + it("does not use a stale body marker when another platform release is the subject", () => { + const commitMessage = [ + "release(desktop): Release v1.6.0", + "", + "* release(mobile): Release v0.4.1", + ].join("\n") + + expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull() + }) + + it("recognizes a desktop release on main", () => { + expect(extractReleaseInfo("release(desktop): Release v1.12.0", "main")).toEqual({ + platform: "desktop", + version: "v1.12.0", + tagName: "desktop/v1.12.0", + }) + }) + + it("falls back to subject-based platform detection without a GitHub ref", () => { + const originalRefName = process.env.GITHUB_REF_NAME + delete process.env.GITHUB_REF_NAME + + try { + expect(extractReleaseInfo("release(mobile): Release v0.5.7")).toEqual({ + platform: "mobile", + version: "v0.5.7", + tagName: "mobile/v0.5.7", + }) + } finally { + if (originalRefName === undefined) { + delete process.env.GITHUB_REF_NAME + } else { + process.env.GITHUB_REF_NAME = originalRefName + } + } + }) + + it("writes the existing GitHub environment and output values from the latest subject", async () => { + const repositoryDir = await mkdtemp(join(tmpdir(), "extract-release-info-")) + + try { + const githubEnvPath = join(repositoryDir, "github-env.txt") + const githubOutputPath = join(repositoryDir, "github-output.txt") + const scriptPath = fileURLToPath(new URL("./extract-release-info.mjs", import.meta.url)) + + await execFileAsync("git", ["init"], { cwd: repositoryDir }) + await execFileAsync("git", ["config", "user.name", "Release Test"], { + cwd: repositoryDir, + }) + await execFileAsync("git", ["config", "user.email", "release-test@example.com"], { + cwd: repositoryDir, + }) + await execFileAsync( + "git", + [ + "commit", + "--allow-empty", + "-m", + "release(mobile): Release v0.5.7 (#5061)", + "-m", + "* release(desktop): release v1.11.0", + ], + { cwd: repositoryDir }, + ) + + await execFileAsync("node", [scriptPath], { + cwd: repositoryDir, + env: { + ...process.env, + GITHUB_ENV: githubEnvPath, + GITHUB_OUTPUT: githubOutputPath, + GITHUB_REF_NAME: "mobile-main", + }, + }) + + const githubEnv = await readFile(githubEnvPath, "utf8") + const githubOutput = await readFile(githubOutputPath, "utf8") + + expect(githubEnv).toContain("tag_version=mobile/v0.5.7") + expect(githubEnv).toContain("platform=mobile") + expect(githubEnv).toContain("version=v0.5.7") + expect(githubOutput).toContain("tag_version=mobile/v0.5.7") + expect(githubOutput).toContain("platform=mobile") + expect(githubOutput).toContain("version=v0.5.7") + } finally { + await rm(repositoryDir, { recursive: true, force: true }) + } + }) +}) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4b7ba8eac..cd46673d0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -52,5 +52,10 @@ jobs: run: | export NODE_OPTIONS="--max_old_space_size=16384" npm exec turbo run format:check typecheck lint + - name: Run release workflow tests + run: >- + pnpm exec vitest run + .github/scripts/extract-release-info.test.ts + .github/scripts/release-workflow-guards.test.ts - name: Run test run: npm exec turbo run test diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 24f1c7c0a..1d307dcf6 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -36,7 +36,6 @@ jobs: - name: Extract release information id: extract_info run: .github/scripts/extract-release-info.mjs - continue-on-error: true - name: Expose release outputs id: release_info