diff --git a/.github/scripts/base-freshness-policy.test.mjs b/.github/scripts/base-freshness-policy.test.mjs deleted file mode 100644 index 6104b4d9d1..0000000000 --- a/.github/scripts/base-freshness-policy.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const workflowDirectory = new URL("../workflows/", import.meta.url); -const readWorkflow = (name) => readFile(new URL(name, workflowDirectory), "utf8"); - -test("untrusted pull request and stack-base signals have no write permission", async () => { - const probe = await readWorkflow("base-freshness-probe.yml"); - - assert.match(probe, /^name: Base Freshness Probe$/m); - assert.match(probe, /^\s{2}pull_request:\n\s{4}types: \[opened, reopened, synchronize, edited\]$/m); - assert.match(probe, /^\s{2}merge_group:\n\s{4}types: \[checks_requested\]$/m); - assert.match(probe, /^\s{2}push:\s*$/m); - assert.match(probe, /^\s{2}workflow_dispatch:\n\s{4}inputs:\n\s{6}pull_request_number:/m); - assert.match(probe, /^permissions: \{\}$/m); - assert.doesNotMatch(probe, /pull_request_target:/); - assert.doesNotMatch(probe, /statuses: write/); - assert.match(probe, /name: Base Freshness/); - assert.match(probe, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(probe, /persist-credentials: false/); - assert.match(probe, /BASE_FRESHNESS_MODE: merge-group/); -}); - -test("only the trusted publisher receives the narrow status permission", async () => { - const publisher = await readWorkflow("base-freshness.yml"); - - assert.match(publisher, /^name: Base Freshness Publisher$/m); - assert.match(publisher, /^\s{2}workflow_run:\n\s{4}workflows: \["Base Freshness Probe"\]/m); - assert.match(publisher, /^\s{2}push:\n\s{4}branches: \[develop, main\]$/m); - assert.match(publisher, /^permissions: \{\}$/m); - assert.match(publisher, /^ group: base-freshness-publisher$/m); - assert.match(publisher, /^ cancel-in-progress: false$/m); - assert.match(publisher, /^\s{6}statuses: write$/m); - assert.match(publisher, /^\s{6}pull-requests: read$/m); - assert.doesNotMatch(publisher, /contents: write/); - assert.doesNotMatch(publisher, /pull-requests: write/); - assert.match(publisher, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(publisher, /persist-credentials: false/); - assert.match(publisher, /node --test \.github\/scripts\/evaluate-base-freshness\.test\.mjs/); - assert.match(publisher, /node \.github\/scripts\/evaluate-base-freshness\.mjs/); -}); - -test("the gate writes one stable required context from live ancestry comparisons", async () => { - const script = await readFile(new URL("evaluate-base-freshness.mjs", import.meta.url), "utf8"); - - assert.match(script, /STATUS_CONTEXT = "Base Freshness"/); - assert.match(script, /compare\//); - assert.match(script, /state: fresh \? "success" : "failure"/); - assert.match(script, /state: "pending"/); - assert.match(script, /api\.getBranchHead\(current\.baseRef\)/); - assert.match(script, /verifiedBaseSha !== baseSha/); - assert.match(script, /finalBaseSha !== baseSha/); - assert.match(script, /finalCurrent\.headSha !== current\.headSha/); - assert.match(script, /const pullRequests = await api\.listPullRequests\(\)/); - assert.match(script, /sweeps all open PRs/); - assert.match(script, /new AggregateError/); - assert.doesNotMatch(script, /pull_request_target/); -}); diff --git a/.github/scripts/evaluate-base-freshness.mjs b/.github/scripts/evaluate-base-freshness.mjs deleted file mode 100644 index 3c5916fe76..0000000000 --- a/.github/scripts/evaluate-base-freshness.mjs +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env node - -import fs from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -export const STATUS_CONTEXT = "Base Freshness"; -const SHA_PATTERN = /^[0-9a-f]{40}$/i; -const ZERO_SHA = "0".repeat(40); - -function requireString(value, name) { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`${name} must be a non-empty string`); - } - return value; -} - -export function requireSha(value, name = "sha") { - const sha = requireString(value, name).toLowerCase(); - if (!SHA_PATTERN.test(sha)) { - throw new Error(`${name} must be a full 40-character commit SHA: ${value}`); - } - return sha; -} - -export function branchFromRef(ref) { - const prefix = "refs/heads/"; - if (typeof ref !== "string" || !ref.startsWith(prefix) || ref.length === prefix.length) { - throw new Error(`expected a branch ref, received: ${ref ?? ""}`); - } - return ref.slice(prefix.length); -} - -export function freshnessFromComparisonStatus(status) { - switch (status) { - case "ahead": - case "identical": - return true; - case "behind": - case "diverged": - return false; - default: - throw new Error(`unexpected GitHub comparison status: ${status ?? ""}`); - } -} - -export function statusDescription({ baseRef, baseSha, fresh }) { - const shortSha = requireSha(baseSha, "base sha").slice(0, 12); - const description = fresh - ? `Up to date with ${baseRef}@${shortSha}` - : `Missing ${baseRef}@${shortSha}; merge or rebase the current base`; - if (description.length > 140) { - return fresh ? `Up to date with base@${shortSha}` : `Missing base@${shortSha}; merge or rebase`; - } - return description; -} - -class GitHubApi { - constructor({ apiUrl, repository, token }) { - this.apiUrl = requireString(apiUrl, "GITHUB_API_URL").replace(/\/$/, ""); - this.repository = requireString(repository, "GITHUB_REPOSITORY"); - this.token = requireString(token, "GITHUB_TOKEN"); - } - - async request(endpoint, { method = "GET", body, query } = {}) { - const url = new URL(`${this.apiUrl}/repos/${this.repository}/${endpoint}`); - for (const [key, value] of Object.entries(query ?? {})) { - if (value !== undefined && value !== null && value !== "") { - url.searchParams.set(key, String(value)); - } - } - const response = await fetch(url, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${this.token}`, - "x-github-api-version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`${method} ${url.pathname} failed (${response.status}): ${text.slice(0, 500)}`); - } - return text.length === 0 ? null : JSON.parse(text); - } - - async paginate(endpoint, query = {}) { - const values = []; - for (let page = 1; ; page += 1) { - const batch = await this.request(endpoint, { - query: { ...query, page, per_page: 100 }, - }); - if (!Array.isArray(batch)) { - throw new Error(`paginated endpoint ${endpoint} did not return an array`); - } - values.push(...batch); - if (batch.length < 100) { - return values; - } - } - } - - getPullRequest(number) { - return this.request(`pulls/${number}`); - } - - listPullRequests(query) { - return this.paginate("pulls", { state: "open", ...query }); - } - - async getBranchHead(branch) { - const ref = await this.request(`git/ref/heads/${encodeURIComponent(branch)}`); - return requireSha(ref?.object?.sha, `head of ${branch}`); - } - - compare(baseSha, headSha) { - return this.request(`compare/${requireSha(baseSha, "base sha")}...${requireSha(headSha, "head sha")}`); - } - - setStatus(sha, { state, description, targetUrl }) { - if (!["error", "failure", "pending", "success"].includes(state)) { - throw new Error(`invalid commit status state: ${state}`); - } - return this.request(`statuses/${requireSha(sha, "status sha")}`, { - method: "POST", - body: { - state, - context: STATUS_CONTEXT, - description, - target_url: targetUrl, - }, - }); - } -} - -function validatePullRequest(pullRequest, repository) { - const number = Number(pullRequest?.number); - if (!Number.isSafeInteger(number) || number < 1) { - throw new Error("pull request response has no valid number"); - } - if (pullRequest.state !== "open") { - return { kind: "closed", number }; - } - if (pullRequest.base?.repo?.full_name !== repository) { - throw new Error(`PR #${number} targets an unexpected repository: ${pullRequest.base?.repo?.full_name}`); - } - return { - kind: "open", - number, - baseRef: requireString(pullRequest.base?.ref, `PR #${number} base ref`), - headSha: requireSha(pullRequest.head?.sha, `PR #${number} head sha`), - }; -} - -export async function evaluatePullRequestFreshness(api, { - number, - repository, - targetUrl, - expectedHeadSha, - maxAttempts = 3, -}) { - const expected = expectedHeadSha ? requireSha(expectedHeadSha, "expected PR head sha") : null; - let lastCurrent; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const current = validatePullRequest(await api.getPullRequest(number), repository); - if (current.kind === "closed") { - return current; - } - lastCurrent = current; - if (expected !== null && current.headSha !== expected) { - return { kind: "obsolete", number: current.number, currentHeadSha: current.headSha }; - } - - const baseSha = await api.getBranchHead(current.baseRef); - await api.setStatus(current.headSha, { - state: "pending", - description: `Checking current ${current.baseRef}@${baseSha.slice(0, 12)}`, - targetUrl, - }); - const comparison = await api.compare(baseSha, current.headSha); - const [verifiedPullRequest, verifiedBaseSha] = await Promise.all([ - api.getPullRequest(number), - api.getBranchHead(current.baseRef), - ]); - const verified = validatePullRequest(verifiedPullRequest, repository); - if (verified.kind === "closed") { - return verified; - } - if ( - verified.headSha !== current.headSha || - verified.baseRef !== current.baseRef || - verifiedBaseSha !== baseSha - ) { - continue; - } - - const fresh = freshnessFromComparisonStatus(comparison?.status); - await api.setStatus(current.headSha, { - state: fresh ? "success" : "failure", - description: statusDescription({ baseRef: current.baseRef, baseSha, fresh }), - targetUrl, - }); - - // Status 기록과 live ref 조회는 GitHub API에서 원자적이지 않다. 기록 직후에도 - // 같은 base/head인지 확인하고, 사이에 움직였다면 pending부터 다시 평가해 - // 방금 쓴 오래된 결론을 그대로 남기지 않는다. 별도 publisher 실행끼리는 - // workflow concurrency로 직렬화한다. - const [finalPullRequest, finalBaseSha] = await Promise.all([ - api.getPullRequest(number), - api.getBranchHead(current.baseRef), - ]); - const finalCurrent = validatePullRequest(finalPullRequest, repository); - if (finalCurrent.kind === "closed") { - return finalCurrent; - } - if ( - finalCurrent.headSha !== current.headSha || - finalCurrent.baseRef !== current.baseRef || - finalBaseSha !== baseSha - ) { - continue; - } - return { - kind: fresh ? "fresh" : "stale", - number: current.number, - baseRef: current.baseRef, - baseSha, - headSha: current.headSha, - }; - } - - if (lastCurrent) { - await api.setStatus(lastCurrent.headSha, { - state: "error", - description: "Base or PR head kept changing; rerun freshness evaluation", - targetUrl, - }); - } - throw new Error(`PR #${number} changed during every freshness evaluation attempt`); -} - -async function evaluatePullRequests(api, pullRequests, context) { - const results = []; - const errors = []; - for (const pullRequest of pullRequests) { - const number = Number(pullRequest?.number ?? pullRequest); - try { - results.push(await evaluatePullRequestFreshness(api, { ...context, number })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(new Error(`PR #${number} freshness evaluation failed: ${message}`, { cause: error })); - } - } - if (errors.length > 0) { - throw new AggregateError( - errors, - `${errors.length} of ${pullRequests.length} pull request freshness evaluations failed`, - ); - } - return results; -} - -export async function publishFromEvent(api, event, eventName, targetUrl) { - if (eventName === "push") { - // A deleted branch push can replace another pending run in the shared concurrency - // group, so it must still perform the same complete live sweep. - if (event.deleted !== true && event.after !== ZERO_SHA) { - branchFromRef(event.ref); - } - } else if (eventName === "workflow_run") { - const workflowRun = event.workflow_run; - if (workflowRun?.name !== "Base Freshness Probe") { - throw new Error(`unexpected source workflow: ${workflowRun?.name ?? ""}`); - } - } else { - throw new Error(`unsupported publisher event: ${eventName}`); - } - - // GitHub concurrency keeps at most one pending run per group. A newer unrelated - // signal can therefore replace a pending base-push signal. Every surviving run - // sweeps all open PRs from live API state so coalescing cannot leave a stale green. - const pullRequests = await api.listPullRequests(); - return evaluatePullRequests(api, pullRequests, { - repository: api.repository, - targetUrl, - }); -} - -export async function evaluateMergeGroupFreshness(api, mergeGroup, { maxAttempts = 3 } = {}) { - const baseRef = branchFromRef(requireString(mergeGroup?.base_ref, "merge_group base_ref")); - const headSha = requireSha(mergeGroup?.head_sha, "merge_group head_sha"); - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const baseSha = await api.getBranchHead(baseRef); - const comparison = await api.compare(baseSha, headSha); - if ((await api.getBranchHead(baseRef)) !== baseSha) { - continue; - } - const fresh = freshnessFromComparisonStatus(comparison?.status); - if (!fresh) { - throw new Error( - `merge group ${headSha} does not contain current ${baseRef}@${baseSha}; GitHub must rebuild the group`, - ); - } - return { baseRef, baseSha, headSha }; - } - throw new Error(`base ${baseRef} changed during every merge-group freshness evaluation attempt`); -} - -async function appendSummary(results) { - if (!process.env.GITHUB_STEP_SUMMARY || results.length === 0) { - return; - } - const lines = ["### Base Freshness", ""]; - for (const result of results) { - if (result.number) { - lines.push(`- PR #${result.number}: ${result.kind}`); - } else { - lines.push(`- merge group: fresh against \`${result.baseRef}@${result.baseSha.slice(0, 12)}\``); - } - } - lines.push(""); - await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`, "utf8"); -} - -async function main() { - const eventPath = requireString(process.env.GITHUB_EVENT_PATH, "GITHUB_EVENT_PATH"); - const event = JSON.parse(await fs.readFile(eventPath, "utf8")); - const api = new GitHubApi({ - apiUrl: process.env.GITHUB_API_URL, - repository: process.env.GITHUB_REPOSITORY, - token: process.env.GITHUB_TOKEN, - }); - const mode = process.env.BASE_FRESHNESS_MODE ?? "publish"; - let results; - if (mode === "merge-group") { - results = [await evaluateMergeGroupFreshness(api, event.merge_group)]; - } else if (mode === "publish") { - const targetUrl = `${requireString(process.env.GITHUB_SERVER_URL, "GITHUB_SERVER_URL")}/${api.repository}/actions/runs/${requireString(process.env.GITHUB_RUN_ID, "GITHUB_RUN_ID")}`; - results = await publishFromEvent(api, event, process.env.GITHUB_EVENT_NAME, targetUrl); - } else { - throw new Error(`unsupported BASE_FRESHNESS_MODE: ${mode}`); - } - await appendSummary(results); - for (const result of results) { - console.log(JSON.stringify(result)); - } -} - -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-base-freshness.test.mjs b/.github/scripts/evaluate-base-freshness.test.mjs deleted file mode 100644 index d914a636c9..0000000000 --- a/.github/scripts/evaluate-base-freshness.test.mjs +++ /dev/null @@ -1,281 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - branchFromRef, - evaluateMergeGroupFreshness, - evaluatePullRequestFreshness, - freshnessFromComparisonStatus, - publishFromEvent, - STATUS_CONTEXT, - statusDescription, -} from "./evaluate-base-freshness.mjs"; - -const BASE_A = "a".repeat(40); -const BASE_B = "b".repeat(40); -const HEAD_A = "c".repeat(40); -const HEAD_B = "d".repeat(40); - -function pullRequest({ number = 17, baseRef = "develop", headSha = HEAD_A, state = "open" } = {}) { - return { - number, - state, - base: { ref: baseRef, repo: { full_name: "Afternote/Afternote-FE" } }, - head: { sha: headSha }, - }; -} - -function fakeApi({ pulls, baseShas, comparisonStatuses, listedPulls = [], pullErrors = new Set() }) { - const statuses = []; - const pullQueue = [...pulls]; - const baseQueue = [...baseShas]; - const comparisonQueue = [...comparisonStatuses]; - return { - statuses, - repository: "Afternote/Afternote-FE", - async listPullRequests() { - return listedPulls; - }, - async getPullRequest(number) { - if (pullErrors.has(number)) { - throw new Error(`simulated PR #${number} API failure`); - } - assert.ok(pullQueue.length > 0, "unexpected pull request lookup"); - return pullQueue.shift(); - }, - async getBranchHead() { - assert.ok(baseQueue.length > 0, "unexpected branch lookup"); - return baseQueue.shift(); - }, - async compare() { - assert.ok(comparisonQueue.length > 0, "unexpected comparison lookup"); - return { status: comparisonQueue.shift() }; - }, - async setStatus(sha, status) { - statuses.push({ sha, ...status }); - }, - }; -} - -test("base refs and GitHub comparison states are interpreted fail closed", () => { - assert.equal(branchFromRef("refs/heads/develop"), "develop"); - assert.equal(branchFromRef("refs/heads/feat/stack"), "feat/stack"); - assert.throws(() => branchFromRef("refs/tags/v1"), /expected a branch ref/); - assert.equal(freshnessFromComparisonStatus("ahead"), true); - assert.equal(freshnessFromComparisonStatus("identical"), true); - assert.equal(freshnessFromComparisonStatus("behind"), false); - assert.equal(freshnessFromComparisonStatus("diverged"), false); - assert.throws(() => freshnessFromComparisonStatus("unknown"), /unexpected GitHub comparison status/); -}); - -test("a PR head containing the live base receives pending then success", async () => { - const api = fakeApi({ - pulls: [pullRequest(), pullRequest(), pullRequest()], - baseShas: [BASE_A, BASE_A, BASE_A], - comparisonStatuses: ["ahead"], - }); - - const result = await evaluatePullRequestFreshness(api, { - number: 17, - repository: "Afternote/Afternote-FE", - targetUrl: "https://github.example/run/1", - expectedHeadSha: HEAD_A, - }); - - assert.equal(result.kind, "fresh"); - assert.deepEqual(api.statuses.map(({ state }) => state), ["pending", "success"]); - assert.equal(api.statuses[1].sha, HEAD_A); - assert.match(api.statuses[1].description, /Up to date with develop@aaaaaaaaaaaa/); -}); - -test("a diverged PR is a real failing status instead of a stale log message", async () => { - const api = fakeApi({ - pulls: [pullRequest(), pullRequest(), pullRequest()], - baseShas: [BASE_A, BASE_A, BASE_A], - comparisonStatuses: ["diverged"], - }); - - const result = await evaluatePullRequestFreshness(api, { - number: 17, - repository: "Afternote/Afternote-FE", - targetUrl: "https://github.example/run/2", - }); - - assert.equal(result.kind, "stale"); - assert.deepEqual(api.statuses.map(({ state }) => state), ["pending", "failure"]); - assert.match(api.statuses[1].description, /merge or rebase/); -}); - -test("an obsolete workflow run cannot overwrite the current PR head", async () => { - const api = fakeApi({ - pulls: [pullRequest({ headSha: HEAD_B })], - baseShas: [], - comparisonStatuses: [], - }); - - const result = await evaluatePullRequestFreshness(api, { - number: 17, - repository: "Afternote/Afternote-FE", - targetUrl: "https://github.example/run/3", - expectedHeadSha: HEAD_A, - }); - - assert.equal(result.kind, "obsolete"); - assert.deepEqual(api.statuses, []); -}); - -test("base movement during comparison retries against the new live base", async () => { - const api = fakeApi({ - pulls: [pullRequest(), pullRequest(), pullRequest(), pullRequest(), pullRequest()], - baseShas: [BASE_A, BASE_B, BASE_B, BASE_B, BASE_B], - comparisonStatuses: ["ahead", "diverged"], - }); - - const result = await evaluatePullRequestFreshness(api, { - number: 17, - repository: "Afternote/Afternote-FE", - targetUrl: "https://github.example/run/4", - }); - - assert.equal(result.kind, "stale"); - assert.deepEqual(api.statuses.map(({ state }) => state), ["pending", "pending", "failure"]); - assert.match(api.statuses.at(-1).description, /bbbbbbbbbbbb/); -}); - -test("base movement immediately after a final write replaces the obsolete success", async () => { - const api = fakeApi({ - pulls: [ - pullRequest(), - pullRequest(), - pullRequest(), - pullRequest(), - pullRequest(), - pullRequest(), - ], - baseShas: [BASE_A, BASE_A, BASE_B, BASE_B, BASE_B, BASE_B], - comparisonStatuses: ["ahead", "diverged"], - }); - - const result = await evaluatePullRequestFreshness(api, { - number: 17, - repository: "Afternote/Afternote-FE", - targetUrl: "https://github.example/run/5", - }); - - assert.equal(result.kind, "stale"); - assert.deepEqual( - api.statuses.map(({ state }) => state), - ["pending", "success", "pending", "failure"], - ); - assert.match(api.statuses.at(-1).description, /bbbbbbbbbbbb/); -}); - -test("every coalesced publisher event sweeps all open pull requests", async () => { - const api = fakeApi({ - listedPulls: [pullRequest({ number: 17 }), pullRequest({ number: 18, headSha: HEAD_B })], - pulls: [ - pullRequest({ number: 17 }), - pullRequest({ number: 17 }), - pullRequest({ number: 17 }), - pullRequest({ number: 18, headSha: HEAD_B }), - pullRequest({ number: 18, headSha: HEAD_B }), - pullRequest({ number: 18, headSha: HEAD_B }), - ], - baseShas: [BASE_A, BASE_A, BASE_A, BASE_B, BASE_B, BASE_B], - comparisonStatuses: ["ahead", "diverged"], - }); - - const results = await publishFromEvent( - api, - { - workflow_run: { - name: "Base Freshness Probe", - event: "pull_request", - pull_requests: [{ number: 17 }], - }, - }, - "workflow_run", - "https://github.example/run/6", - ); - - assert.deepEqual( - results.map(({ number, kind }) => ({ number, kind })), - [ - { number: 17, kind: "fresh" }, - { number: 18, kind: "stale" }, - ], - ); - assert.deepEqual( - api.statuses.filter(({ state }) => state !== "pending").map(({ sha, state }) => ({ sha, state })), - [ - { sha: HEAD_A, state: "success" }, - { sha: HEAD_B, state: "failure" }, - ], - ); -}); - -test("one pull request API failure does not block later freshness updates", async () => { - const api = fakeApi({ - listedPulls: [pullRequest({ number: 17 }), pullRequest({ number: 18, headSha: HEAD_B })], - pullErrors: new Set([17]), - pulls: [ - pullRequest({ number: 18, headSha: HEAD_B }), - pullRequest({ number: 18, headSha: HEAD_B }), - pullRequest({ number: 18, headSha: HEAD_B }), - ], - baseShas: [BASE_B, BASE_B, BASE_B], - comparisonStatuses: ["diverged"], - }); - - await assert.rejects( - publishFromEvent( - api, - { workflow_run: { name: "Base Freshness Probe", event: "pull_request" } }, - "workflow_run", - "https://github.example/run/7", - ), - /1 of 2 pull request freshness evaluations failed/, - ); - assert.deepEqual( - api.statuses.map(({ sha, state }) => ({ sha, state })), - [ - { sha: HEAD_B, state: "pending" }, - { sha: HEAD_B, state: "failure" }, - ], - ); -}); - -test("merge groups must contain the current base and retry when that base moves", async () => { - const api = fakeApi({ - pulls: [], - baseShas: [BASE_A, BASE_B, BASE_B, BASE_B], - comparisonStatuses: ["ahead", "ahead"], - }); - - const result = await evaluateMergeGroupFreshness(api, { - base_ref: "refs/heads/develop", - head_sha: HEAD_A, - }); - - assert.deepEqual(result, { baseRef: "develop", baseSha: BASE_B, headSha: HEAD_A }); - const staleApi = fakeApi({ - pulls: [], - baseShas: [BASE_A, BASE_A], - comparisonStatuses: ["behind"], - }); - await assert.rejects( - evaluateMergeGroupFreshness(staleApi, { - base_ref: "refs/heads/develop", - head_sha: HEAD_A, - }), - /does not contain current develop/, - ); -}); - -test("the required status context and descriptions stay stable", () => { - assert.equal(STATUS_CONTEXT, "Base Freshness"); - assert.equal( - statusDescription({ baseRef: "develop", baseSha: BASE_A, fresh: false }), - "Missing develop@aaaaaaaaaaaa; merge or rebase the current base", - ); -}); diff --git a/.github/scripts/remote-dev-workflows-policy.test.mjs b/.github/scripts/remote-dev-workflows-policy.test.mjs index 6fe850fed7..fbe7b95f27 100644 --- a/.github/scripts/remote-dev-workflows-policy.test.mjs +++ b/.github/scripts/remote-dev-workflows-policy.test.mjs @@ -63,14 +63,12 @@ test("privileged baseline apply is a workflow-run bridge restricted to PNG basel for (const workflow of [ "pr-validation.yml", "codeql.yml", - "base-freshness-probe.yml", "merge-order-guard.yml", ]) { assert.ok(source.includes(`workflow_id: '${workflow}'`), `${workflow} is not redispatched`); } assert.match(source, /if: steps\.commit\.outputs\.changed == 'true'/); assert.match(source, /workflow_id: 'codeql\.yml',[\s\S]*inputs: \{ pull_request_number: process\.env\.TARGET_PR \}/); - assert.match(source, /workflow_id: 'base-freshness-probe\.yml',[\s\S]*inputs: \{ pull_request_number: process\.env\.TARGET_PR \}/); }); test("screenshot workflow fallbacks cover every baseline module", async () => { diff --git a/.github/workflows/base-freshness-probe.yml b/.github/workflows/base-freshness-probe.yml deleted file mode 100644 index 87d867ee54..0000000000 --- a/.github/workflows/base-freshness-probe.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Base Freshness Probe - -# pull_request code에는 쓰기 토큰을 주지 않는다. 이 읽기 전용 실행의 workflow_run을 -# default branch의 Base Freshness Publisher가 받아 live base ancestry를 commit status로 -# 기록한다. push 신호는 스택 PR의 부모 브랜치가 전진한 경우도 같은 방식으로 갱신한다. -on: - pull_request: - types: [opened, reopened, synchronize, edited] - merge_group: - types: [checks_requested] - push: - # GITHUB_TOKEN이 PR head에 commit을 만들면 pull_request synchronize가 발생하지 - # 않으므로, 해당 automation bridge가 같은 head ref로 이 신호를 명시적으로 보낸다. - workflow_dispatch: - inputs: - pull_request_number: - description: "현재 ref와 일치해야 하는 열린 PR 번호" - required: true - type: number - -concurrency: - group: base-freshness-probe-${{ github.event.pull_request.number || inputs.pull_request_number || github.event.merge_group.head_ref || github.ref }} - cancel-in-progress: true - -permissions: {} - -jobs: - signal: - if: github.event_name != 'merge_group' - name: Schedule Base Freshness Evaluation - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Hand off to the trusted publisher - run: echo "The default-branch workflow_run publisher evaluates current base ancestry." - - merge-group: - if: github.event_name == 'merge_group' - name: Base Freshness - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Clone trusted freshness policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Verify merge group contains the live base - env: - BASE_FRESHNESS_MODE: merge-group - GITHUB_TOKEN: ${{ github.token }} - run: node .github/scripts/evaluate-base-freshness.mjs diff --git a/.github/workflows/base-freshness.yml b/.github/workflows/base-freshness.yml deleted file mode 100644 index d2e8cea73c..0000000000 --- a/.github/workflows/base-freshness.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Base Freshness Publisher - -# workflow_run은 default branch의 검토된 정의로 실행된다. PR head의 코드는 checkout하거나 -# 실행하지 않고 GitHub API의 live base/head 관계만 판정한다. develop/main push는 지연을 -# 줄이기 위해 trusted branch에서 바로 처리하고, 스택 base push는 probe bridge가 처리한다. -on: - workflow_run: - workflows: ["Base Freshness Probe"] - types: [completed] - push: - branches: [develop, main] - -concurrency: - # 모든 이벤트를 한 trusted publisher로 직렬화한다. PR 신호와 base-push 신호가 - # 동시에 같은 head의 status를 쓰면 오래된 base 기준 success가 최신 failure를 - # 나중에 덮을 수 있다. Actions는 같은 group의 pending run을 하나만 보존하므로, - # 각 실행은 event 대상만이 아니라 모든 열린 PR의 live 관계를 다시 판정한다. - group: base-freshness-publisher - cancel-in-progress: false - -permissions: {} - -jobs: - publish: - name: Publish live base status - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: read - statuses: write - steps: - - name: Clone trusted freshness policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Test freshness policy - run: node --test .github/scripts/evaluate-base-freshness.test.mjs - - - name: Publish Base Freshness status - env: - BASE_FRESHNESS_MODE: publish - GITHUB_TOKEN: ${{ github.token }} - run: node .github/scripts/evaluate-base-freshness.mjs diff --git a/.github/workflows/screenshot-baseline-apply.yml b/.github/workflows/screenshot-baseline-apply.yml index b38a36c353..da2f965d85 100644 --- a/.github/workflows/screenshot-baseline-apply.yml +++ b/.github/workflows/screenshot-baseline-apply.yml @@ -245,11 +245,6 @@ jobs: workflow_id: 'codeql.yml', inputs: { pull_request_number: process.env.TARGET_PR }, }); - await github.rest.actions.createWorkflowDispatch({ - ...common, - workflow_id: 'base-freshness-probe.yml', - inputs: { pull_request_number: process.env.TARGET_PR }, - }); await github.rest.actions.createWorkflowDispatch({ ...common, workflow_id: 'merge-order-guard.yml', diff --git a/README.md b/README.md index 80050b2074..d976f89099 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ PR Validation은 rename의 이전·현재 경로를 포함한 전체 변경 파 Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확한 `develop` 기준선과 변경 모듈의 line·branch 비율을 비교해 후퇴를 먼저 warning으로 수집하며, 정책 파일의 mode를 별도 검토로 `enforce`로 바꿀 수 있다. -[`Base Freshness`](.github/workflows/base-freshness.yml)는 현재 base가 PR head의 조상인지 GitHub API로 다시 확인하고, base가 전진하면 같은 head의 과거 green 상태를 실패로 갱신한다. publisher 실행을 직렬화하고 status 기록 뒤에도 live base/head를 재확인하지만 API 조회와 ref 갱신 자체는 원자적이지 않다. 따라서 병합 후 활성 ruleset에 정확한 `Base Freshness` context를 required로 등록하고 `Require branches to be up to date before merging`도 함께 활성화해야 이동 순간까지 GitHub가 원자적으로 막는다. +`develop` 병합은 merge queue를 통과한다. 큐가 현재 base 위에 merge group을 만들어 required check를 다시 실행하므로, 낡은 base에서 green을 받은 PR이 그대로 들어갈 수 없다. merge group에는 pull request가 없어 변경 범위를 좁히지 못하므로 검증은 전량으로 돈다 — PR 단계에서 건너뛴 lane도 큐에서는 실행된다. base 최신성을 별도 status로 감시하고 `Require branches to be up to date before merging`으로 강제하던 방식은 큐가 대체했다. `CI Test Plan`의 `none`은 두 필수 Managed Device check를 에뮬레이터 없이 성공 처리한다. `selected`는 선언한 `path`, fully-qualified `Class#method`, `api30` 또는 `api34`만 실행하고 JUnit XML의 실제 성공 결과까지 확인한다. `full`은 테스트 하네스·Gradle·릴리스 경계 변경에서 전체 API 30 회귀와 API 34 접근성 smoke를 실행한다.