diff --git a/.github/scripts/refresh-pr-stack.mjs b/.github/scripts/refresh-pr-stack.mjs deleted file mode 100644 index f09245a551..0000000000 --- a/.github/scripts/refresh-pr-stack.mjs +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env node - -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -const PAGE_SIZE = 100; -const UPDATE_ATTEMPTS = 30; -const UPDATE_DELAY_MS = 2000; -const REQUIRED_WORKFLOWS = ["pr-validation.yml", "codeql.yml", "merge-order-guard.yml"]; - -function requirePositiveInteger(value, name) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error(`${name} must be a positive integer`); - } - return parsed; -} - -function assertSameRepositoryPullRequest(pullRequest, repository) { - if (pullRequest.state !== "open") { - throw new Error(`PR #${pullRequest.number} is not open`); - } - if (pullRequest.head?.repo?.full_name !== repository) { - throw new Error(`PR #${pullRequest.number} is a fork PR`); - } - if (!pullRequest.head.ref || !pullRequest.base?.ref || !/^[0-9a-f]{40}$/.test(pullRequest.head.sha ?? "")) { - throw new Error(`PR #${pullRequest.number} has incomplete branch metadata`); - } -} - -/** - * root 의 head branch 를 base 로 삼는 열린 PR 을 한 단계씩 따라간다. - * 분기 스택은 자동으로 어느 자식을 먼저 갱신할지 결정할 수 없으므로 쓰기 전에 거절한다. - */ -export function buildLinearStack(pullRequests, rootNumber, repository, maxDepth) { - const root = pullRequests.find((pullRequest) => pullRequest.number === rootNumber); - if (!root) { - throw new Error(`Open PR #${rootNumber} was not found`); - } - - const stack = []; - const seen = new Set(); - let current = root; - for (;;) { - assertSameRepositoryPullRequest(current, repository); - if (seen.has(current.number)) { - throw new Error(`Cycle detected at PR #${current.number}`); - } - seen.add(current.number); - stack.push(current); - if (stack.length > maxDepth) { - throw new Error(`Stack exceeds max depth ${maxDepth}`); - } - - const children = pullRequests.filter( - (pullRequest) => pullRequest.number !== current.number && pullRequest.base?.ref === current.head.ref, - ); - if (children.length > 1) { - throw new Error( - `PR #${current.number} has multiple open children: ${children.map((child) => `#${child.number}`).join(", ")}`, - ); - } - if (children.length === 0) { - return stack; - } - current = children[0]; - } -} - -function encodeRef(ref) { - return encodeURIComponent(ref); -} - -const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); - -async function waitForUpdatedHead( - api, - repository, - pullRequest, - { attempts = UPDATE_ATTEMPTS, delayMs = UPDATE_DELAY_MS, wait = sleep } = {}, -) { - for (let attempt = 1; attempt <= attempts; attempt += 1) { - await wait(delayMs); - const latest = await api(`/repos/${repository}/pulls/${pullRequest.number}`); - if (latest.head.sha !== pullRequest.head.sha) { - return latest.head.sha; - } - } - throw new Error(`Timed out waiting for PR #${pullRequest.number} branch update`); -} - -/** 부모부터 update-branch 를 호출한다. expected_head_sha 가 동시 push 를 fail-closed 로 막는다. */ -export async function refreshStack(api, repository, stack, options = {}) { - const results = []; - const dryRun = options.dryRun ?? false; - - for (const discovered of stack) { - const current = await api(`/repos/${repository}/pulls/${discovered.number}`); - assertSameRepositoryPullRequest(current, repository); - if ( - current.head.sha !== discovered.head.sha || - current.head.ref !== discovered.head.ref || - current.base.ref !== discovered.base.ref - ) { - throw new Error(`PR #${current.number} moved after stack discovery; rerun the workflow`); - } - - const comparison = await api( - `/repos/${repository}/compare/${encodeRef(current.base.ref)}...${encodeRef(current.head.ref)}`, - ); - if (comparison.behind_by === 0) { - results.push({ number: current.number, branch: current.head.ref, sha: current.head.sha, updated: false }); - continue; - } - if (dryRun) { - results.push({ number: current.number, branch: current.head.ref, sha: current.head.sha, updated: true }); - continue; - } - - await api(`/repos/${repository}/pulls/${current.number}/update-branch`, { - method: "PUT", - body: { expected_head_sha: current.head.sha }, - }); - const sha = await waitForUpdatedHead(api, repository, current, options); - results.push({ number: current.number, branch: current.head.ref, sha, updated: true }); - } - - return results; -} - -/** GITHUB_TOKEN push 는 pull_request 를 재발행하지 않으므로 새 SHA 에 필수 workflow 를 수동 부착한다. */ -export async function dispatchRequiredChecks(api, repository, results, { dryRun = false } = {}) { - if (dryRun) { - return; - } - for (const result of results) { - for (const workflow of REQUIRED_WORKFLOWS) { - // 세 workflow 모두 docs-only 판정과 PR 컨텍스트 검증에 번호를 사용한다. - // ref 만 넘기면 GITHUB_TOKEN 으로 갱신된 SHA 의 workflow_dispatch 에는 - // pull_request payload 가 없어 무거운 검증을 안전하게 생략할 수 없다. - const body = { - ref: result.branch, - inputs: { pull_request_number: String(result.number) }, - }; - await api(`/repos/${repository}/actions/workflows/${workflow}/dispatches`, { - method: "POST", - body, - }); - } - } -} - -export function renderSummary(stack, results, dryRun) { - const byNumber = new Map(results.map((result) => [result.number, result])); - const lines = [ - `## PR stack refresh${dryRun ? " — dry run" : ""}`, - "", - ...stack.map((pullRequest, index) => { - const result = byNumber.get(pullRequest.number); - const state = result?.updated ? (dryRun ? "would update" : "updated") : "already current"; - return `${index + 1}. #${pullRequest.number} \`${pullRequest.base.ref}\` → \`${pullRequest.head.ref}\` — ${state}`; - }), - ]; - if (!dryRun) { - lines.push("", "각 PR 현재 head 에 필수 validation, CodeQL, merge-order guard 를 요청했습니다."); - } - return lines.join("\n"); -} - -function createApi(token) { - return async function api(apiPath, { method = "GET", body } = {}) { - const response = await fetch(`https://api.github.com${apiPath}`, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "x-github-api-version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (!response.ok) { - const detail = await response.text(); - throw new Error(`GitHub API ${method} ${apiPath} failed: ${response.status} ${detail}`); - } - if (response.status === 204) { - return null; - } - return response.json(); - }; -} - -async function listOpenPullRequests(api, repository) { - const pullRequests = []; - for (let page = 1; ; page += 1) { - const batch = await api(`/repos/${repository}/pulls?state=open&per_page=${PAGE_SIZE}&page=${page}`); - pullRequests.push(...batch); - if (batch.length < PAGE_SIZE) { - return pullRequests; - } - } -} - -async function main() { - const token = process.env.GITHUB_TOKEN; - const repository = process.env.GITHUB_REPOSITORY; - if (!token || !repository) { - throw new Error("GITHUB_TOKEN and GITHUB_REPOSITORY are required"); - } - const rootNumber = requirePositiveInteger(process.env.ROOT_PULL_REQUEST, "ROOT_PULL_REQUEST"); - const maxDepth = requirePositiveInteger(process.env.MAX_DEPTH ?? "5", "MAX_DEPTH"); - const dryRun = process.env.DRY_RUN === "true"; - const api = createApi(token); - const pullRequests = await listOpenPullRequests(api, repository); - const stack = buildLinearStack(pullRequests, rootNumber, repository, maxDepth); - const results = await refreshStack(api, repository, stack, { dryRun }); - await dispatchRequiredChecks(api, repository, results, { dryRun }); - - const summary = renderSummary(stack, results, dryRun); - console.log(summary); - if (process.env.GITHUB_STEP_SUMMARY) { - const { appendFile } = await import("node:fs/promises"); - await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); - } -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main().catch((error) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/.github/scripts/refresh-pr-stack.test.mjs b/.github/scripts/refresh-pr-stack.test.mjs deleted file mode 100644 index 8a659b6fb0..0000000000 --- a/.github/scripts/refresh-pr-stack.test.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - buildLinearStack, - dispatchRequiredChecks, - refreshStack, - renderSummary, -} from "./refresh-pr-stack.mjs"; - -const repository = "Afternote/Afternote-FE"; - -function pullRequest(number, base, head, sha = String(number).padStart(40, "a")) { - return { - number, - state: "open", - base: { ref: base }, - head: { ref: head, sha, repo: { full_name: repository } }, - }; -} - -function fakeApi(handler) { - const calls = []; - const api = async (apiPath, options = {}) => { - calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); - return handler(apiPath, options, calls); - }; - api.calls = calls; - return api; -} - -test("루트에서 base 관계를 따라 선형 스택을 만든다", () => { - const pullRequests = [ - pullRequest(10, "develop", "feat/a"), - pullRequest(11, "feat/a", "feat/b"), - pullRequest(12, "feat/b", "feat/c"), - pullRequest(99, "develop", "unrelated"), - ]; - - assert.deepEqual(buildLinearStack(pullRequests, 10, repository, 5).map((item) => item.number), [10, 11, 12]); -}); - -test("분기 스택은 어떤 자식을 먼저 갱신할지 추측하지 않는다", () => { - const pullRequests = [ - pullRequest(10, "develop", "feat/a"), - pullRequest(11, "feat/a", "feat/b"), - pullRequest(12, "feat/a", "feat/c"), - ]; - - assert.throws(() => buildLinearStack(pullRequests, 10, repository, 5), /multiple open children/); -}); - -test("fork, 닫힌 PR, 최대 깊이 초과를 거절한다", () => { - const fork = pullRequest(10, "develop", "feat/a"); - fork.head.repo.full_name = "someone/fork"; - assert.throws(() => buildLinearStack([fork], 10, repository, 5), /fork PR/); - - const closed = { ...pullRequest(10, "develop", "feat/a"), state: "closed" }; - assert.throws(() => buildLinearStack([closed], 10, repository, 5), /not open/); - - assert.throws( - () => buildLinearStack([ - pullRequest(10, "develop", "feat/a"), - pullRequest(11, "feat/a", "feat/b"), - ], 10, repository, 1), - /exceeds max depth/, - ); -}); - -test("이미 최신인 PR 은 update-branch 를 호출하지 않는다", async () => { - const pr = pullRequest(10, "develop", "feat/a"); - const api = fakeApi((apiPath) => { - if (apiPath.includes("/compare/")) return { behind_by: 0 }; - return pr; - }); - - const result = await refreshStack(api, repository, [pr]); - - assert.equal(result[0].updated, false); - assert.equal(api.calls.some((call) => call.method === "PUT"), false); -}); - -test("뒤처진 PR 을 expected head 로 갱신하고 새 SHA 를 기다린다", async () => { - const pr = pullRequest(10, "develop", "feat/a"); - const newSha = "b".repeat(40); - let pullReads = 0; - const api = fakeApi((apiPath, options) => { - if (apiPath.includes("/compare/")) return { behind_by: 2 }; - if (options.method === "PUT") return { message: "Updating pull request branch." }; - pullReads += 1; - return pullReads >= 2 ? { ...pr, head: { ...pr.head, sha: newSha } } : pr; - }); - - const result = await refreshStack(api, repository, [pr], { delayMs: 0, wait: async () => {} }); - - const update = api.calls.find((call) => call.method === "PUT"); - assert.deepEqual(update.body, { expected_head_sha: pr.head.sha }); - assert.equal(result[0].sha, newSha); - assert.equal(result[0].updated, true); -}); - -test("발견 뒤 HEAD 가 움직이면 업데이트 전에 실패한다", async () => { - const pr = pullRequest(10, "develop", "feat/a"); - const moved = { ...pr, head: { ...pr.head, sha: "c".repeat(40) } }; - const api = fakeApi(() => moved); - - await assert.rejects(refreshStack(api, repository, [pr]), /moved after stack discovery/); - assert.equal(api.calls.some((call) => call.method === "PUT"), false); -}); - -test("필수 workflow 세 개를 각 PR 현재 branch 에 dispatch 한다", async () => { - const api = fakeApi(() => null); - await dispatchRequiredChecks(api, repository, [ - { number: 10, branch: "feat/a", sha: "a".repeat(40), updated: true }, - { number: 11, branch: "feat/b", sha: "b".repeat(40), updated: false }, - ]); - - assert.equal(api.calls.length, 6); - for (const call of api.calls.slice(0, 3)) { - assert.deepEqual(call.body, { - ref: "feat/a", - inputs: { pull_request_number: "10" }, - }); - } -}); - -test("dry run 은 update 와 workflow dispatch 를 쓰지 않는다", async () => { - const pr = pullRequest(10, "develop", "feat/a"); - const api = fakeApi((apiPath) => (apiPath.includes("/compare/") ? { behind_by: 1 } : pr)); - const result = await refreshStack(api, repository, [pr], { dryRun: true }); - await dispatchRequiredChecks(api, repository, result, { dryRun: true }); - - assert.equal(result[0].updated, true); - assert.equal(api.calls.some((call) => call.method !== "GET"), false); - assert.match(renderSummary([pr], result, true), /would update/); -}); diff --git a/.github/scripts/remote-dev-workflows-policy.test.mjs b/.github/scripts/remote-dev-workflows-policy.test.mjs index c681887217..6fe850fed7 100644 --- a/.github/scripts/remote-dev-workflows-policy.test.mjs +++ b/.github/scripts/remote-dev-workflows-policy.test.mjs @@ -402,17 +402,6 @@ test("screenshot failure comment rechecks the live baseline label before writing assert.match(source.slice(liveLabels, listComments), /core\.info\([\s\S]*return;/); }); -test("stack refresh only executes trusted default-branch code", async () => { - const source = await readWorkflow("stack-refresh.yml"); - - assert.match(source, /^\s{2}workflow_dispatch:/m); - assert.match(source, /github\.ref_name == github\.event\.repository\.default_branch/); - assert.match(source, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(source, /persist-credentials: false/); - assert.match(source, /node --test \.github\/scripts\/refresh-pr-stack\.test\.mjs/); - assert.match(source, /MAX_DEPTH: \$\{\{ inputs\.max_depth \}\}/); -}); - test("required checks expose manual dispatch for token-authored commits", async () => { const validation = await readWorkflow("pr-validation.yml"); const guard = await readWorkflow("merge-order-guard.yml"); diff --git a/.github/workflows/stack-refresh.yml b/.github/workflows/stack-refresh.yml deleted file mode 100644 index a3db70790a..0000000000 --- a/.github/workflows/stack-refresh.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Refresh PR Stack - -# 지정한 root PR 부터 선형 child 관계를 먼저 검증한 뒤 부모→자식 순으로 base 를 merge 한다. -# default branch 코드만 실행해 write token 이 PR 코드에 노출되지 않게 한다. -on: - workflow_dispatch: - inputs: - root_pull_request: - description: "스택의 첫 열린 PR 번호" - required: true - type: number - max_depth: - description: "한 번에 처리할 최대 PR 수" - required: true - default: "5" - type: choice - options: - - "3" - - "5" - - "10" - dry_run: - description: "쓰기 없이 발견·갱신 계획만 출력" - required: false - default: false - type: boolean - -permissions: - actions: write - contents: write - pull-requests: write - -concurrency: - group: refresh-pr-stack - cancel-in-progress: false - -jobs: - refresh: - if: github.ref_name == github.event.repository.default_branch - name: Refresh linear pull request stack - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Clone trusted default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Run stack refresh tests - run: node --test .github/scripts/refresh-pr-stack.test.mjs - - - name: Refresh stack and dispatch checks - env: - DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} - GITHUB_TOKEN: ${{ github.token }} - MAX_DEPTH: ${{ inputs.max_depth }} - ROOT_PULL_REQUEST: ${{ inputs.root_pull_request }} - run: node .github/scripts/refresh-pr-stack.mjs diff --git a/README.md b/README.md index c3c007af66..80050b2074 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ keytool -exportcert -alias afternote-debug-shared -keystore ~/afternote-debug-sh - 기본·통합 브랜치는 `develop`, Firebase 일반 배포 기준 브랜치는 `main`이다. 일반 변경은 `develop`을 향하고, 릴리스 PR만 `develop`에서 `main`으로 올린다. - 작업 브랜치는 변경 성격을 나타내는 lowercase prefix와 `/`를 사용한다. 예: `feat/123`, `fix/123-login`, `docs/readme-refresh`. 언더스코어 사용 규칙은 없다. - 서로 의존하는 변경은 부모 작업 브랜치를 base로 둔 스택 PR을 사용할 수 있다. 부모가 머지되면 base를 `develop`로 이관하고 [머지 순서 가드](.github/workflows/merge-order-guard.yml)를 통과시킨다. +- 스택 PR의 base 갱신은 `PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`로 하지 못한다. 네이티브 스택 멤버는 base가 `develop`인 밑단까지 포함해 이 엔드포인트가 `403 Updating a stacked PR's branch via this endpoint is not supported`로 거절한다. base를 로컬에서 머지하고 평범하게 push하면 통하며, 이 경로는 기존 커밋 SHA를 유지해 리뷰어의 파일 조회 상태와 이미 받은 CI 결과를 살린다. `rebase` 뒤 강제 push는 SHA를 전부 갈아치우므로 둘 다 잃는다. - `develop`·`main`의 삭제와 강제 push는 금지된다. 머지 조건의 최종 정본은 GitHub의 [활성 ruleset](https://github.com/Afternote/Afternote-FE/rules)과 PR의 Required 상태다. ## 커밋