From 724da4ea813c15bddce2bf881dfca98291bb8122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 29 Aug 2026 12:02:36 +0900 Subject: [PATCH] =?UTF-8?q?chore(ci):=20=EC=A3=BD=EC=9D=80=20stack-refresh?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EB=A5=BC=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-branch 엔드포인트는 네이티브 스택 멤버를 403 으로 거절한다. 스택 밑단(base=develop)도 스택 멤버라는 사실만으로 막히므로, 이 워크플로의 유일한 기능인 다단 base 전파는 어떤 root 로도 도달하지 못한다. 남은 수동 체인은 분기라 선형 가드가 따로 막는다. 실행 이력 3건 전부 실패·취소로 성공 실적이 없다. 대체 자동화는 두지 않는다. 밑단이 머지되면 GitHub 이 위 칸을 서버에서 자동 rebase 하므로 체인 전파 목적 자체가 플랫폼 기능에 흡수됐고, 남는 '밑단이 develop 에 뒤처짐' 은 base 병합 시점이 곧 판단이라 사람 몫이다. 로컬 병합 + 일반 push 로 SHA 를 지키며 갱신하는 경로를 README 에 남긴다. --- .github/scripts/refresh-pr-stack.mjs | 232 ------------------ .github/scripts/refresh-pr-stack.test.mjs | 136 ---------- .../remote-dev-workflows-policy.test.mjs | 11 - .github/workflows/stack-refresh.yml | 58 ----- README.md | 1 + 5 files changed, 1 insertion(+), 437 deletions(-) delete mode 100644 .github/scripts/refresh-pr-stack.mjs delete mode 100644 .github/scripts/refresh-pr-stack.test.mjs delete mode 100644 .github/workflows/stack-refresh.yml 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 상태다. ## 커밋