diff --git a/.github/scripts/collect-deployment-decision-context.mjs b/.github/scripts/collect-release-scope-context.mjs similarity index 90% rename from .github/scripts/collect-deployment-decision-context.mjs rename to .github/scripts/collect-release-scope-context.mjs index 4978f93475..bc106cef4d 100644 --- a/.github/scripts/collect-deployment-decision-context.mjs +++ b/.github/scripts/collect-release-scope-context.mjs @@ -15,15 +15,20 @@ function truncate(value, limit = MAX_BODY_LENGTH) { return text.length <= limit ? text : `${text.slice(0, limit)}\n...[truncated]`; } -export function assertMergedDevelopPullRequest(pullRequest) { - if (!pullRequest?.merged_at || !pullRequest?.merge_commit_sha) { - throw new Error(`PR #${pullRequest?.number ?? "?"} is not merged`); +export function assertReleasePullRequest(pullRequest) { + if (pullRequest?.base?.ref !== "main") { + throw new Error( + `PR #${pullRequest?.number ?? "?"} targets ${pullRequest?.base?.ref ?? ""}, not main`, + ); } - if (pullRequest.base?.ref !== "develop") { + if (pullRequest.head?.ref !== "develop") { throw new Error( - `PR #${pullRequest.number} targets ${pullRequest.base?.ref ?? ""}, not develop`, + `PR #${pullRequest.number} ships ${pullRequest.head?.ref ?? ""}, not develop`, ); } + if (!pullRequest.head?.sha) { + throw new Error(`PR #${pullRequest.number} has no head SHA`); + } } export function extractClosingIssueNumbers(body) { @@ -326,14 +331,14 @@ function gitCommitExists(commitSha) { async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; - const targetPullRequestNumber = Number(process.env.TARGET_PR_NUMBER); - const outputPath = process.env.DEPLOYMENT_CONTEXT_PATH; + const targetPullRequestNumber = Number(process.env.RELEASE_PR_NUMBER); + const outputPath = process.env.RELEASE_SCOPE_CONTEXT_PATH; const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; const graphqlUrl = process.env.GITHUB_GRAPHQL_URL ?? "https://api.github.com/graphql"; if (!token || !repository || !targetPullRequestNumber || !outputPath) { throw new Error( - "GITHUB_TOKEN, GITHUB_REPOSITORY, TARGET_PR_NUMBER, and DEPLOYMENT_CONTEXT_PATH are required", + "GITHUB_TOKEN, GITHUB_REPOSITORY, RELEASE_PR_NUMBER, and RELEASE_SCOPE_CONTEXT_PATH are required", ); } @@ -341,7 +346,8 @@ async function main() { `${apiUrl}/repos/${repository}/pulls/${targetPullRequestNumber}`, token, ); - assertMergedDevelopPullRequest(targetPullRequest); + assertReleasePullRequest(targetPullRequest); + const releaseHeadSha = targetPullRequest.head.sha; const workflowRunsQuery = new URLSearchParams({ status: "success", per_page: "100" }); const workflowRuns = await requestJson( @@ -353,7 +359,7 @@ async function main() { apiUrl, repository, successfulRuns, - targetPullRequest.merge_commit_sha, + releaseHeadSha, token, ); const covered = relevantDistribution?.relation === "covered"; @@ -367,12 +373,12 @@ async function main() { : selectMergedPullRequestsByAncestry( allPullRequests, baselineSha, - targetPullRequest.merge_commit_sha, + releaseHeadSha, gitIsAncestor, ); if (selectedPullRequests.length > MAX_PULL_REQUESTS) { throw new Error( - `${selectedPullRequests.length} PRs accumulated since the baseline; refusing a truncated decision`, + `${selectedPullRequests.length} PRs accumulated since the baseline; refusing a truncated scope`, ); } @@ -382,13 +388,14 @@ async function main() { normalizedPullRequests.push(normalizePullRequest(pullRequest, issues)); } - const decisionContext = { + const scopeContext = { repository, - targetPullRequest: normalizePullRequest( - targetPullRequest, - await loadClosingIssues(graphqlUrl, repository, targetPullRequest, token), - ), - targetCoveredBySuccessfulDistribution: covered, + releasePullRequest: { + number: targetPullRequest.number, + headSha: releaseHeadSha, + body: truncate(targetPullRequest.body), + }, + alreadyDistributed: covered, baselineDistribution: relevantDistribution ? { id: relevantDistribution.run.id, @@ -405,7 +412,7 @@ async function main() { }; await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(decisionContext, null, 2)}\n`, "utf8"); + await fs.writeFile(outputPath, `${JSON.stringify(scopeContext, null, 2)}\n`, "utf8"); } const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; diff --git a/.github/scripts/collect-deployment-decision-context.test.mjs b/.github/scripts/collect-release-scope-context.test.mjs similarity index 85% rename from .github/scripts/collect-deployment-decision-context.test.mjs rename to .github/scripts/collect-release-scope-context.test.mjs index 1d9fb40b3d..a3088f905a 100644 --- a/.github/scripts/collect-deployment-decision-context.test.mjs +++ b/.github/scripts/collect-release-scope-context.test.mjs @@ -2,31 +2,38 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - assertMergedDevelopPullRequest, + assertReleasePullRequest, distributionRelationForCompareStatus, extractClosingIssueNumbers, isTargetCoveredByCompareStatus, selectMergedPullRequestsByAncestry, sortDistributionRuns, sourceShaForDistributionRun, -} from "./collect-deployment-decision-context.mjs"; +} from "./collect-release-scope-context.mjs"; -test("accepts only merged PRs targeting develop", () => { +test("accepts only develop -> main release PRs, merged or not", () => { assert.doesNotThrow(() => - assertMergedDevelopPullRequest({ - number: 726, - merged_at: "2026-08-04T01:00:00Z", - merge_commit_sha: "abc", - base: { ref: "develop" }, + assertReleasePullRequest({ + number: 1025, + base: { ref: "main" }, + head: { ref: "develop", sha: "release-head" }, }), ); assert.throws( () => - assertMergedDevelopPullRequest({ - number: 726, - merged_at: "2026-08-04T01:00:00Z", - merge_commit_sha: "abc", + assertReleasePullRequest({ + number: 1026, + base: { ref: "develop" }, + head: { ref: "fix/940", sha: "abc" }, + }), + /not main/, + ); + assert.throws( + () => + assertReleasePullRequest({ + number: 1027, base: { ref: "main" }, + head: { ref: "hotfix/urgent", sha: "abc" }, }), /not develop/, ); diff --git a/.github/scripts/evaluate-deployment-decision.mjs b/.github/scripts/evaluate-deployment-decision.mjs deleted file mode 100644 index 4684ed624e..0000000000 --- a/.github/scripts/evaluate-deployment-decision.mjs +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env node - -import { execFileSync } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const DEPLOYMENT_CONTROL_PATHS = [ - /^\.github\/workflows\/(?:release-distribution|deployment-decision)\.ya?ml$/, - /^\.github\/scripts\/(?:collect-deployment-decision-context|evaluate-deployment-decision)\.mjs$/, - /^\.github\/scripts\/render-distribution-release-notes\.sh$/, -]; -const HIGH_RISK_PATHS = [ - /(^|\/)(auth|login|onboarding|signup|signing|keystore)(\/|\.|$)/i, - /(^|\/)(data|network|api)(\/|$)/i, - /(^|\/)(build\.gradle(?:\.kts)?|settings\.gradle(?:\.kts)?|gradle\.properties)$/, - /^gradle\/libs\.versions\.toml$/, -]; -const HIGH_RISK_TEXT = - /로그인|회원가입|인증|온보딩|데이터\s*(?:손실|삭제)|API\s*계약|서명|배포|release|signing|authentication|data[ -]?loss/i; -const RUNTIME_NEUTRAL_PATHS = [ - /^\.github\/(?:workflows|scripts)\//, - /(^|\/)README(?:\.[^/]*)?$/i, - /(^|\/)docs?\//i, - /\.md$/i, - /(^|\/)(test|tests|androidTest)\//, - /(?:Test|Tests)\.(?:kt|java)$/, -]; - -function unique(values) { - return [...new Set(values)]; -} - -function parseTitle(title) { - const match = /^([a-z]+)(?:\(([^)]+)\))?:/i.exec(String(title ?? "")); - return { - type: match?.[1]?.toLowerCase() ?? "", - scope: match?.[2]?.toLowerCase() ?? "", - }; -} - -export function extractQaPoints(body) { - const points = []; - let capturing = false; - for (const line of String(body ?? "").split(/\r?\n/)) { - const heading = /^#{1,6}\s+(.+?)\s*$/.exec(line); - if (heading) { - if (capturing) { - break; - } - capturing = /qa\s*포인트/i.test(heading[1]); - continue; - } - if (!capturing) { - continue; - } - const point = line - .replace(/^\s*[-*]\s*/, "") - .replace(/^\s*\d+\.\s*/, "") - .trim(); - if (point && point.toLowerCase() !== "no response") { - points.push(point); - } - } - return unique(points); -} - -function issueMap(context) { - const issues = new Map(); - const pullRequests = [context.targetPullRequest, ...(context.pendingPullRequests ?? [])]; - for (const pullRequest of pullRequests) { - for (const issue of pullRequest?.closingIssues ?? []) { - issues.set(issue.number, issue); - } - } - return issues; -} - -function defaultQaPoints(issues, pullRequests) { - if (issues.size > 0) { - return [...issues.values()].map( - (issue) => - `#${issue.number} 관련 동작을 재현하고 수정 후 기대 결과가 충족되는지 확인`, - ); - } - return pullRequests.map( - (pullRequest) => - `PR #${pullRequest.number}의 변경 흐름을 실행하고 기존 동작이 회귀하지 않는지 확인`, - ); -} - -function isHighRiskFile(file) { - return HIGH_RISK_PATHS.some((pattern) => pattern.test(file)); -} - -function isDeploymentControlFile(file) { - return DEPLOYMENT_CONTROL_PATHS.some((pattern) => pattern.test(file)); -} - -function isRuntimeNeutralFile(file) { - return RUNTIME_NEUTRAL_PATHS.some((pattern) => pattern.test(file)); -} - -export function evaluateDeploymentDecision(context, changedFiles) { - const pullRequests = context.pendingPullRequests ?? []; - const issues = issueMap(context); - const includedIssues = [...issues.keys()].sort((left, right) => left - right); - const explicitQaPoints = unique( - pullRequests.flatMap((pullRequest) => extractQaPoints(pullRequest.body)), - ); - const qaPoints = - explicitQaPoints.length > 0 - ? explicitQaPoints - : defaultQaPoints(issues, pullRequests.length > 0 ? pullRequests : [context.targetPullRequest]); - - if (context.targetCoveredBySuccessfulDistribution) { - return { - decision: "hold", - risk: "low", - reason: "대상 머지 커밋이 이미 성공한 배포에 포함되어 추가 배포가 필요하지 않습니다.", - includedIssues, - qaPoints, - }; - } - - if (!context.baselineDistribution) { - return { - decision: "deploy", - risk: "normal", - reason: "성공한 배포 기준점이 없어 현재 상태를 첫 QA 기준 빌드로 배포해야 합니다.", - includedIssues, - qaPoints, - }; - } - - const deploymentControlFiles = changedFiles.filter(isDeploymentControlFile); - const highRiskPullRequests = pullRequests.filter((pullRequest) => - HIGH_RISK_TEXT.test(`${pullRequest.title}\n${pullRequest.body ?? ""}`), - ); - if (deploymentControlFiles.length > 0) { - const evidence = [ - ...deploymentControlFiles.slice(0, 3), - ...highRiskPullRequests.slice(0, 3).map((pullRequest) => `PR #${pullRequest.number}`), - ]; - return { - decision: "deploy", - risk: "high", - reason: `고위험 변경이 포함됐습니다: ${unique(evidence).join(", ")}. 다른 변경을 기다리지 않고 QA 배포합니다.`, - includedIssues, - qaPoints, - }; - } - - const runtimeNeutral = changedFiles.length > 0 && changedFiles.every(isRuntimeNeutralFile); - if (runtimeNeutral) { - return { - decision: "hold", - risk: "low", - reason: "문서·테스트·일반 CI 등 앱 런타임에 영향을 주지 않는 변경만 있어 다음 사용자 노출 변경과 함께 배포합니다.", - includedIssues, - qaPoints, - }; - } - - const highRiskFiles = changedFiles.filter( - (file) => isHighRiskFile(file) && !isRuntimeNeutralFile(file), - ); - if (highRiskFiles.length > 0 || highRiskPullRequests.length > 0) { - const evidence = [ - ...highRiskFiles.slice(0, 3), - ...highRiskPullRequests.slice(0, 3).map((pullRequest) => `PR #${pullRequest.number}`), - ]; - return { - decision: "deploy", - risk: "high", - reason: `고위험 변경이 포함됐습니다: ${unique(evidence).join(", ")}. 다른 변경을 기다리지 않고 QA 배포합니다.`, - includedIssues, - qaPoints, - }; - } - - const parsedTitles = pullRequests.map((pullRequest) => ({ - pullRequest, - ...parseTitle(pullRequest.title), - })); - const visibleFixes = parsedTitles.filter(({ type }) => type === "fix"); - if (visibleFixes.length > 0) { - return { - decision: "deploy", - risk: "normal", - reason: `테스터 확인이 필요한 결함 수정이 머지됐습니다: ${visibleFixes.map(({ pullRequest }) => `PR #${pullRequest.number}`).join(", ")}.`, - includedIssues, - qaPoints, - }; - } - - const completedFeatures = parsedTitles.filter(({ type }) => type === "feat"); - if (completedFeatures.length > 0) { - return { - decision: "deploy", - risk: "normal", - reason: `새 사용자 흐름을 검증할 기능 변경이 완료됐습니다: ${completedFeatures.map(({ pullRequest }) => `PR #${pullRequest.number}`).join(", ")}.`, - includedIssues, - qaPoints, - }; - } - - if (includedIssues.length >= 3) { - return { - decision: "deploy", - risk: "normal", - reason: `마지막 배포 이후 독립 이슈 ${includedIssues.length}건이 누적돼 회귀 원인 분리 전에 배포합니다.`, - includedIssues, - qaPoints, - }; - } - - if (explicitQaPoints.length >= 6) { - return { - decision: "deploy", - risk: "normal", - reason: `명시된 QA 포인트가 ${explicitQaPoints.length}개로 배포 경계에 도달했습니다.`, - includedIssues, - qaPoints, - }; - } - - const scopes = unique(parsedTitles.map(({ scope }) => scope).filter(Boolean)); - if (pullRequests.length >= 5 || scopes.length >= 3) { - return { - decision: "deploy", - risk: "normal", - reason: `누적 PR ${pullRequests.length}건·영향 스코프 ${scopes.length}개로 더 쌓이면 회귀 원인 분리가 어려워집니다.`, - includedIssues, - qaPoints, - }; - } - - return { - decision: "hold", - risk: "low", - reason: `현재 누적 변경은 PR ${pullRequests.length}건·이슈 ${includedIssues.length}건으로 즉시 QA가 필요한 위험 또는 묶음 크기에 도달하지 않았습니다.`, - includedIssues, - qaPoints, - }; -} - -function changedFilesFor(context) { - const baseSha = context.baselineDistribution?.headSha; - const headSha = context.targetPullRequest?.mergeCommitSha; - if (!baseSha || !headSha || context.targetCoveredBySuccessfulDistribution) { - return []; - } - const output = execFileSync("git", ["diff", "--name-only", `${baseSha}..${headSha}`], { - encoding: "utf8", - }); - return unique(output.split(/\r?\n/).filter(Boolean)); -} - -async function main() { - const [contextPath, outputPath] = process.argv.slice(2); - if (!contextPath || !outputPath) { - throw new Error("context path and decision output path are required"); - } - const context = JSON.parse(await fs.readFile(contextPath, "utf8")); - const decision = evaluateDeploymentDecision(context, changedFilesFor(context)); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(decision, null, 2)}\n`, "utf8"); -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; -if (import.meta.url === invokedPath) { - main().catch((error) => { - console.error(error instanceof Error ? error.stack : error); - process.exitCode = 1; - }); -} diff --git a/.github/scripts/evaluate-deployment-decision.test.mjs b/.github/scripts/evaluate-deployment-decision.test.mjs deleted file mode 100644 index 1c058646b9..0000000000 --- a/.github/scripts/evaluate-deployment-decision.test.mjs +++ /dev/null @@ -1,123 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - evaluateDeploymentDecision, - extractQaPoints, -} from "./evaluate-deployment-decision.mjs"; - -function context(overrides = {}) { - const issue = { number: 726, title: "배포 판단", body: "", labels: [] }; - return { - targetCoveredBySuccessfulDistribution: false, - baselineDistribution: { headSha: "base" }, - targetPullRequest: { - number: 800, - title: "refactor(core): 내부 정리", - body: "", - mergeCommitSha: "head", - closingIssues: [issue], - }, - pendingPullRequests: [ - { - number: 800, - title: "refactor(core): 내부 정리", - body: "", - closingIssues: [issue], - }, - ], - ...overrides, - }; -} - -test("holds when the target is already distributed", () => { - const result = evaluateDeploymentDecision( - context({ targetCoveredBySuccessfulDistribution: true }), - ["feature/home/HomeScreen.kt"], - ); - assert.equal(result.decision, "hold"); - assert.match(result.reason, /이미 성공한/); -}); - -test("deploys a high-risk release path change immediately", () => { - const result = evaluateDeploymentDecision(context(), [".github/workflows/release-distribution.yml"]); - assert.equal(result.decision, "deploy"); - assert.equal(result.risk, "high"); -}); - -test("holds an unrelated CI workflow-only fix", () => { - const pendingPullRequest = { - number: 802, - title: "fix(ci): Review Assign 자동 assignee 제거", - body: "앱과 서버 동작 변경 없음", - closingIssues: [{ number: 801, title: "자동 assignee 제거", body: "", labels: ["bug"] }], - }; - const result = evaluateDeploymentDecision( - context({ pendingPullRequests: [pendingPullRequest] }), - [".github/workflows/PRassign.yml"], - ); - - assert.equal(result.decision, "hold"); - assert.equal(result.risk, "low"); - assert.match(result.reason, /일반 CI/); -}); - -test("holds a data-layer test-only change", () => { - const result = evaluateDeploymentDecision(context(), [ - "feature/afternote/data/src/test/java/ReceiverMapperTest.kt", - ]); - - assert.equal(result.decision, "hold"); - assert.equal(result.risk, "low"); - assert.match(result.reason, /런타임/); -}); - -test("deploys a user-visible bug fix", () => { - const pendingPullRequest = { - number: 801, - title: "fix(home): 새로고침 실패 복구", - body: "", - closingIssues: [{ number: 704, title: "새로고침 실패", body: "", labels: ["bug"] }], - }; - const result = evaluateDeploymentDecision( - context({ pendingPullRequests: [pendingPullRequest] }), - ["feature/home/HomeScreen.kt"], - ); - assert.equal(result.decision, "deploy"); - assert.match(result.reason, /결함 수정/); -}); - -test("holds documentation-only changes", () => { - const result = evaluateDeploymentDecision(context(), ["README.md", "docs/release.md"]); - assert.equal(result.decision, "hold"); - assert.match(result.reason, /런타임/); -}); - -test("deploys when three issues accumulate", () => { - const pendingPullRequests = [1, 2, 3].map((number) => ({ - number: 810 + number, - title: `refactor(scope${number}): 정리`, - body: "", - closingIssues: [ - { - number: number === 1 ? 726 : 720 + number, - title: "정리", - body: "", - labels: [], - }, - ], - })); - const result = evaluateDeploymentDecision( - context({ pendingPullRequests }), - ["feature/record/RecordState.kt"], - ); - assert.equal(result.decision, "deploy"); - assert.match(result.reason, /독립 이슈 3건/); -}); - -test("reads explicit QA points from a PR body", () => { - assert.deepEqual( - extractQaPoints("## QA 포인트\n- 저장 후 목록에 표시되는지 확인\n- 재진입 시 값이 유지되는지 확인\n\n## 기타\n- 제외"), - ["저장 후 목록에 표시되는지 확인", "재진입 시 값이 유지되는지 확인"], - ); -}); diff --git a/.github/scripts/render-release-scope.mjs b/.github/scripts/render-release-scope.mjs new file mode 100644 index 0000000000..f0ed45e858 --- /dev/null +++ b/.github/scripts/render-release-scope.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ISSUE_HEADING = "포함 이슈"; +const QA_HEADING = "QA 포인트"; +const HEADING_PATTERN = /^#{1,6}\s+(.+?)\s*$/; + +function unique(values) { + return [...new Set(values)]; +} + +export function extractQaPoints(body) { + const points = []; + let capturing = false; + for (const line of String(body ?? "").split(/\r?\n/)) { + const heading = HEADING_PATTERN.exec(line); + if (heading) { + if (capturing) { + break; + } + capturing = /qa\s*포인트/i.test(heading[1]); + continue; + } + if (!capturing) { + continue; + } + const point = line + .replace(/^\s*[-*]\s*/, "") + .replace(/^\s*\d+\.\s*/, "") + .trim(); + if (point && point.toLowerCase() !== "no response") { + points.push(point); + } + } + return unique(points); +} + +export function summarizeReleaseScope(context) { + const pullRequests = context.pendingPullRequests ?? []; + const issues = new Map(); + for (const pullRequest of pullRequests) { + for (const issue of pullRequest.closingIssues ?? []) { + issues.set(issue.number, issue); + } + } + const includedIssues = [...issues.keys()].sort((left, right) => left - right); + return { + alreadyDistributed: context.alreadyDistributed === true, + pullRequestCount: pullRequests.length, + includedIssues, + issueTitles: Object.fromEntries( + [...issues.entries()].map(([number, issue]) => [number, issue.title]), + ), + qaPointsDraft: unique( + pullRequests.flatMap((pullRequest) => extractQaPoints(pullRequest.body)), + ), + baseline: context.baselineDistribution ?? null, + }; +} + +export function renderIssueSection(includedIssues) { + if (includedIssues.length === 0) { + return "- 연결된 이슈 없음 — 배포 전에 직접 채워 주세요."; + } + return includedIssues.map((number) => `- #${number}`).join("\n"); +} + +export function renderQaDraftSection(scope) { + if (scope.qaPointsDraft.length > 0) { + return scope.qaPointsDraft.map((point) => `- ${point}`).join("\n"); + } + return "- 테스터가 실행할 동작과 기대 결과를 직접 채워 주세요."; +} + +function findSection(lines, heading) { + let start = -1; + for (const [index, line] of lines.entries()) { + const match = HEADING_PATTERN.exec(line); + if (!match) { + continue; + } + if (start === -1) { + if (match[1] === heading) { + start = index; + } + continue; + } + return { start, end: index }; + } + return start === -1 ? null : { start, end: lines.length }; +} + +function sectionHasContent(lines, section) { + return lines + .slice(section.start + 1, section.end) + .some((line) => line.trim().length > 0); +} + +/** + * `## 포함 이슈` 는 항상 산출값으로 덮어쓰고, `## QA 포인트` 는 비어 있을 때만 초안을 채운다. + * 사람이 다시 쓴 QA 문구와 나머지 섹션은 건드리지 않는다. + */ +export function applyReleaseScopeToBody(body, { issueSection, qaSection }) { + let lines = String(body ?? "").split(/\r?\n/); + + const replaceSection = (heading, content) => { + const section = findSection(lines, heading); + if (!section) { + const tail = lines.length > 0 && lines.at(-1).trim() === "" ? [] : [""]; + lines = [...lines, ...tail, `## ${heading}`, "", ...content.split("\n"), ""]; + return; + } + lines = [ + ...lines.slice(0, section.start + 1), + "", + ...content.split("\n"), + "", + ...lines.slice(section.end), + ]; + }; + + replaceSection(ISSUE_HEADING, issueSection); + + const qa = findSection(lines, QA_HEADING); + if (!qa || !sectionHasContent(lines, qa)) { + replaceSection(QA_HEADING, qaSection); + } + + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; +} + +async function main() { + const [contextPath, outputPath] = process.argv.slice(2); + if (!contextPath || !outputPath) { + throw new Error("context path and scope output path are required"); + } + const context = JSON.parse(await fs.readFile(contextPath, "utf8")); + const scope = summarizeReleaseScope(context); + const result = { + ...scope, + body: applyReleaseScopeToBody(context.releasePullRequest?.body, { + issueSection: renderIssueSection(scope.includedIssues), + qaSection: renderQaDraftSection(scope), + }), + }; + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/render-release-scope.test.mjs b/.github/scripts/render-release-scope.test.mjs new file mode 100644 index 0000000000..a38518e634 --- /dev/null +++ b/.github/scripts/render-release-scope.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyReleaseScopeToBody, + extractQaPoints, + renderIssueSection, + summarizeReleaseScope, +} from "./render-release-scope.mjs"; + +function context(overrides = {}) { + return { + alreadyDistributed: false, + baselineDistribution: { id: 1, headSha: "base", url: "https://example.test/1" }, + releasePullRequest: { number: 1025, headSha: "head", body: "" }, + pendingPullRequests: [ + { + number: 1002, + title: "fix(home): 주간 요약 그리드", + body: "## QA 포인트\n- 홈에서 주간 요약이 실제 기록 수를 보인다", + closingIssues: [{ number: 562, title: "주간 요약", body: "", labels: [] }], + }, + { + number: 1001, + title: "refactor(core): 인증 예외 정리", + body: "", + closingIssues: [{ number: 934, title: "예외 정리", body: "", labels: [] }], + }, + ], + ...overrides, + }; +} + +test("collects closing issues across pending PRs in ascending order", () => { + const scope = summarizeReleaseScope(context()); + assert.deepEqual(scope.includedIssues, [562, 934]); + assert.equal(scope.pullRequestCount, 2); +}); + +test("carries QA points written in the constituent PR bodies", () => { + const scope = summarizeReleaseScope(context()); + assert.deepEqual(scope.qaPointsDraft, ["홈에서 주간 요약이 실제 기록 수를 보인다"]); +}); + +test("stops QA point capture at the next heading", () => { + assert.deepEqual( + extractQaPoints("## QA 포인트\n- 첫 항목\n\n## 검증\n- 빌드 통과"), + ["첫 항목"], + ); +}); + +test("renders issue numbers in the release note format", () => { + assert.equal(renderIssueSection([562, 934]), "- #562\n- #934"); + assert.match(renderIssueSection([]), /직접 채워/); +}); + +test("replaces the included-issue section without touching other sections", () => { + const body = [ + "## 변경 요약", + "", + "develop 누적 반영.", + "", + "## 포함 이슈", + "", + "- #111", + "", + "## 검증", + "", + "| 태스크 | 결과 |", + ].join("\n"); + + const updated = applyReleaseScopeToBody(body, { + issueSection: "- #562\n- #934", + qaSection: "- 초안", + }); + + assert.match(updated, /## 포함 이슈\n\n- #562\n- #934\n/); + assert.doesNotMatch(updated, /#111/); + assert.match(updated, /## 변경 요약\n\ndevelop 누적 반영\./); + assert.match(updated, /## 검증\n\n\| 태스크 \| 결과 \|/); +}); + +test("keeps QA points a human already wrote", () => { + const body = ["## 포함 이슈", "", "- #1", "", "## QA 포인트", "", "- 사람이 쓴 문장"].join("\n"); + + const updated = applyReleaseScopeToBody(body, { + issueSection: "- #562", + qaSection: "- 자동 초안", + }); + + assert.match(updated, /- 사람이 쓴 문장/); + assert.doesNotMatch(updated, /자동 초안/); +}); + +test("fills an empty QA section so the distribution gate cannot fail on it", () => { + const body = ["## 포함 이슈", "", "- #1", "", "## QA 포인트", ""].join("\n"); + + const updated = applyReleaseScopeToBody(body, { + issueSection: "- #562", + qaSection: "- 자동 초안", + }); + + assert.match(updated, /## QA 포인트\n\n- 자동 초안/); +}); + +test("appends both sections when the body has neither", () => { + const updated = applyReleaseScopeToBody("## 변경 요약\n\n릴리스 후보.", { + issueSection: "- #562", + qaSection: "- 자동 초안", + }); + + assert.match(updated, /## 포함 이슈\n\n- #562/); + assert.match(updated, /## QA 포인트\n\n- 자동 초안/); + assert.match(updated, /## 변경 요약\n\n릴리스 후보\./); +}); + +test("leaves no heading inside the rendered sections", () => { + const updated = applyReleaseScopeToBody("", { + issueSection: "- #562\n- #934", + qaSection: "- 자동 초안", + }); + const lines = updated.split("\n"); + const issueStart = lines.indexOf("## 포함 이슈"); + const qaStart = lines.indexOf("## QA 포인트"); + + assert.ok(issueStart >= 0 && qaStart > issueStart); + assert.deepEqual( + lines.slice(issueStart + 1, qaStart).filter((line) => line.startsWith("#")), + [], + ); +}); diff --git a/.github/workflows/deployment-decision.yml b/.github/workflows/deployment-decision.yml deleted file mode 100644 index 126bbacd8f..0000000000 --- a/.github/workflows/deployment-decision.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Evaluate QA Distribution Candidate - -on: - pull_request: - types: [closed] - branches: [develop] - workflow_dispatch: - inputs: - pull_request_number: - description: "다시 판단할 develop 머지 PR 번호" - required: true - type: number - -concurrency: - group: qa-distribution-decision-${{ github.event.pull_request.base.ref || 'develop' }} - cancel-in-progress: false - -permissions: - actions: read - contents: read - issues: write - pull-requests: write - -jobs: - evaluate: - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - name: Clone merged revision - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.merge_commit_sha || 'develop' }} - fetch-depth: 0 - persist-credentials: false - - - name: Collect pending deployment context - env: - GITHUB_TOKEN: ${{ github.token }} - TARGET_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} - DEPLOYMENT_CONTEXT_PATH: ${{ runner.temp }}/afternote-deployment-decision/context.json - run: node .github/scripts/collect-deployment-decision-context.mjs - - - name: Evaluate deployment boundary - env: - DEPLOYMENT_CONTEXT_PATH: ${{ runner.temp }}/afternote-deployment-decision/context.json - DEPLOYMENT_DECISION_PATH: ${{ runner.temp }}/afternote-deployment-decision/decision.json - run: | - node .github/scripts/evaluate-deployment-decision.mjs \ - "$DEPLOYMENT_CONTEXT_PATH" \ - "$DEPLOYMENT_DECISION_PATH" - - - name: Publish decision on the merged PR - uses: actions/github-script@v9 - env: - DEPLOYMENT_DECISION_PATH: ${{ runner.temp }}/afternote-deployment-decision/decision.json - TARGET_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} - with: - github-token: ${{ github.token }} - script: | - const fs = require('node:fs'); - const marker = ''; - const result = JSON.parse(fs.readFileSync(process.env.DEPLOYMENT_DECISION_PATH, 'utf8')); - const clean = (value) => String(value).replaceAll('@', '@\u200b'); - const title = result.decision === 'deploy' ? '🚀 QA 배포 권장' : '⏸️ QA 배포 보류'; - const issues = result.includedIssues.map((number) => `#${number}`).join(', '); - const qaPoints = result.qaPoints.map((point) => `- ${clean(point)}`).join('\n'); - const body = [ - marker, - `## ${title}`, - '', - `- 위험도: \`${result.risk}\``, - `- 포함 이슈: ${issues || '연결 이슈 없음'}`, - `- 판단 근거: ${clean(result.reason)}`, - '', - '### QA 포인트', - qaPoints, - '', - '> 이 판단은 배포를 자동 실행하지 않습니다. `deploy`일 때 Release Distribution을 수동 실행하세요.', - ].join('\n'); - - const issueNumber = Number(process.env.TARGET_PR_NUMBER); - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - } - await core.summary.addRaw(body).write(); diff --git a/.github/workflows/release-scope.yml b/.github/workflows/release-scope.yml new file mode 100644 index 0000000000..f7bb1f08ac --- /dev/null +++ b/.github/workflows/release-scope.yml @@ -0,0 +1,96 @@ +# 릴리스 PR(develop -> main)의 배포 범위를 산출해 PR 본문에 채운다. +# +# `## 포함 이슈`·`## QA 포인트` 두 섹션은 main push 시 +# render-distribution-release-notes.sh 가 그대로 Firebase 릴리스 노트로 렌더하고, +# 하나라도 비면 배포가 중단된다. 그 입력을 사람이 옮겨 적지 않도록 여기서 채운다. +name: Collect Release Scope + +on: + pull_request: + types: [opened, reopened, synchronize] + branches: [main] + workflow_dispatch: + inputs: + pull_request_number: + description: "범위를 다시 산출할 릴리스 PR 번호" + required: true + type: number + +concurrency: + group: release-scope-${{ github.event.pull_request.number || inputs.pull_request_number }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + collect: + runs-on: ubuntu-latest + steps: + - name: Clone release candidate + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || 'develop' }} + fetch-depth: 0 + persist-credentials: false + + - name: Collect release scope context + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} + RELEASE_SCOPE_CONTEXT_PATH: ${{ runner.temp }}/afternote-release-scope/context.json + run: node .github/scripts/collect-release-scope-context.mjs + + - name: Render release scope + env: + RELEASE_SCOPE_CONTEXT_PATH: ${{ runner.temp }}/afternote-release-scope/context.json + RELEASE_SCOPE_PATH: ${{ runner.temp }}/afternote-release-scope/scope.json + run: | + node .github/scripts/render-release-scope.mjs \ + "$RELEASE_SCOPE_CONTEXT_PATH" \ + "$RELEASE_SCOPE_PATH" + + - name: Fill release sections on the PR body + uses: actions/github-script@v9 + env: + RELEASE_SCOPE_PATH: ${{ runner.temp }}/afternote-release-scope/scope.json + RELEASE_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} + with: + github-token: ${{ github.token }} + script: | + const fs = require('node:fs'); + const scope = JSON.parse(fs.readFileSync(process.env.RELEASE_SCOPE_PATH, 'utf8')); + const pullNumber = Number(process.env.RELEASE_PR_NUMBER); + + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + }); + + if ((pullRequest.body ?? '') !== scope.body) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + body: scope.body, + }); + } + + const summary = [ + `## 릴리스 범위 — PR #${pullNumber}`, + '', + `- 누적 PR: ${scope.pullRequestCount}건`, + `- 포함 이슈: ${scope.includedIssues.length}건`, + `- 기준 배포: ${scope.baseline ? `[run ${scope.baseline.id}](${scope.baseline.url})` : '없음 (첫 배포)'}`, + scope.alreadyDistributed + ? '- ⚠️ 이 head 는 이미 배포된 지점이라 새로 나갈 변경이 없다.' + : '', + '', + scope.qaPointsDraft.length > 0 + ? `구성 PR 본문에서 QA 포인트 ${scope.qaPointsDraft.length}건을 초안으로 넣었다. 테스터가 실행할 문장으로 다시 쓰는 것을 권한다.` + : '구성 PR 본문에 QA 포인트가 없어 자리만 만들어 뒀다. 배포 전에 직접 채워야 한다.', + ].filter(Boolean).join('\n'); + await core.summary.addRaw(summary).write(); diff --git a/README.md b/README.md index 10afb5ed58..0dfec5770a 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,15 @@ keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-sh - 수정 결과를 테스터가 확인해야 하는 결함이 머지되면, 묶음 크기와 관계없이 확인 가능한 빌드를 배포한다. - 현재 묶음의 모든 QA 포인트가 통과한 뒤 `develop`을 `main`으로 승격한다. -### 머지별 자동 판단 +### 릴리스 PR 범위 자동 산출 -`develop` 대상 PR이 머지되면 [`deployment-decision.yml`](.github/workflows/deployment-decision.yml)이 마지막 성공 QA 배포 이후의 누적 PR·연결 이슈·실제 diff를 읽는다. 고위험 경로, 버그·기능 PR, 누적 이슈 수, 명시 QA 포인트 수, 영향 스코프 수를 위 기준으로 판정해 `QA 배포 권장` 또는 `QA 배포 보류`, 포함 이슈, QA 포인트를 머지된 PR에 코멘트한다. 판단만 자동화하며 APK 업로드는 실행하지 않는다. +배포 시점은 위 기준에 따라 사람이 정한다 — `develop` → `main` 릴리스 PR을 여는 것이 곧 배포 결정이다. -별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Evaluate QA Distribution Candidate**에서 이미 머지된 PR 번호를 입력해 같은 규칙으로 수동 재검증할 수도 있다. +릴리스 PR이 열리거나 head가 갱신되면 [`release-scope.yml`](.github/workflows/release-scope.yml)이 마지막 성공 배포 이후 `develop`에 머지된 PR과 그 연결 이슈를 모아 PR 본문의 `## 포함 이슈`를 채운다. head가 움직일 때마다 다시 채우므로 머지 직전에 목록을 손으로 대조할 필요가 없다. + +`## QA 포인트`는 비어 있을 때만 구성 PR 본문에서 모은 초안으로 채우고, 사람이 쓴 문장이 있으면 건드리지 않는다. 두 섹션은 main push 시 그대로 릴리스 노트가 되므로 배포 전에 테스터가 실행할 문장으로 다듬는다. + +별도 API나 유료 AI를 호출하지 않으며 기존 GitHub Actions 실행량만 사용한다. Actions의 **Collect Release Scope**에서 릴리스 PR 번호를 입력해 다시 산출할 수도 있다. ### QA 배포 — `develop` → Firebase App Distribution (수동, 기본 경로)