diff --git a/.github/scripts/classify-documentation-changes.mjs b/.github/scripts/classify-documentation-changes.mjs new file mode 100644 index 0000000000..677de82a7d --- /dev/null +++ b/.github/scripts/classify-documentation-changes.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export function isDocumentationPath(filename) { + return ( + filename === "README.md" || + (typeof filename === "string" && filename.startsWith("docs/") && filename.length > "docs/".length) + ); +} + +function requireChangedFileCount(value) { + const serialized = String(value ?? ""); + if (!/^[1-9][0-9]*$/.test(serialized)) { + throw new Error(`changed_files must be a positive integer: ${serialized || ""}`); + } + + const count = Number(serialized); + if (!Number.isSafeInteger(count)) { + throw new Error(`changed_files exceeds the safe integer range: ${serialized}`); + } + return count; +} + +export function flattenPaginatedFiles(pages) { + if (!Array.isArray(pages) || pages.length === 0) { + throw new Error("paginated pull request files must contain at least one page"); + } + + const files = []; + for (const page of pages) { + if (!Array.isArray(page)) { + throw new Error("each paginated pull request files response must be an array"); + } + files.push(...page); + } + + if (files.length === 0) { + throw new Error("pull request files response must not be empty"); + } + return files; +} + +function documentationStateOf(file, index) { + if (file === null || typeof file !== "object" || Array.isArray(file)) { + throw new Error(`pull request file at index ${index} is not an object`); + } + if (typeof file.filename !== "string" || file.filename.length === 0) { + throw new Error(`pull request file at index ${index} has no valid filename`); + } + + const paths = [file.filename]; + if (file.status === "renamed") { + if (typeof file.previous_filename !== "string" || file.previous_filename.length === 0) { + throw new Error(`renamed pull request file at index ${index} has no valid previous_filename`); + } + paths.push(file.previous_filename); + } else if (Object.hasOwn(file, "previous_filename")) { + if (typeof file.previous_filename !== "string" || file.previous_filename.length === 0) { + throw new Error(`pull request file at index ${index} has an invalid previous_filename`); + } + paths.push(file.previous_filename); + } + + return paths.every(isDocumentationPath); +} + +export function classifyDocumentationChanges(files, expectedChangedFileCount) { + const expectedCount = requireChangedFileCount(expectedChangedFileCount); + if (!Array.isArray(files) || files.length === 0) { + throw new Error("pull request files must be a non-empty array"); + } + if (files.length !== expectedCount) { + throw new Error(`pull request file count mismatch: expected ${expectedCount}, received ${files.length}`); + } + + return files.every(documentationStateOf); +} + +export function classifyPaginatedDocumentationChanges(pages, expectedChangedFileCount) { + return classifyDocumentationChanges(flattenPaginatedFiles(pages), expectedChangedFileCount); +} + +async function main() { + const expectedChangedFileCount = process.argv[2]; + let input = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + input += chunk; + } + const pages = JSON.parse(input); + const docsOnly = classifyPaginatedDocumentationChanges(pages, expectedChangedFileCount); + process.stdout.write(`${docsOnly}\n`); +} + +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.message : error); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/classify-documentation-changes.test.mjs b/.github/scripts/classify-documentation-changes.test.mjs new file mode 100644 index 0000000000..a611cb6d3b --- /dev/null +++ b/.github/scripts/classify-documentation-changes.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + classifyDocumentationChanges, + classifyPaginatedDocumentationChanges, + isDocumentationPath, +} from "./classify-documentation-changes.mjs"; + +const scriptPath = fileURLToPath(new URL("./classify-documentation-changes.mjs", import.meta.url)); + +test("allows only the root README and files below docs", () => { + assert.equal(isDocumentationPath("README.md"), true); + assert.equal(isDocumentationPath("docs/qa/status.md"), true); + assert.equal(isDocumentationPath("docs/nested/README.md"), true); + assert.equal(isDocumentationPath("readme.md"), false); + assert.equal(isDocumentationPath("docs"), false); + assert.equal(isDocumentationPath("feature/docs/status.md"), false); + + assert.equal( + classifyDocumentationChanges( + [{ filename: "README.md" }, { filename: "docs/qa/status.md" }], + 2, + ), + true, + ); + assert.equal( + classifyDocumentationChanges( + [{ filename: "docs/qa/status.md" }, { filename: "app/build.gradle.kts" }], + 2, + ), + false, + ); +}); + +test("renames are documentation-only only when both paths are allowed", () => { + assert.equal( + classifyDocumentationChanges( + [ + { + filename: "docs/qa/new-name.md", + previous_filename: "docs/qa/old-name.md", + status: "renamed", + }, + ], + 1, + ), + true, + ); + assert.equal( + classifyDocumentationChanges( + [ + { + filename: "docs/qa/moved.md", + previous_filename: "app/src/main/java/Old.kt", + status: "renamed", + }, + ], + 1, + ), + false, + ); + assert.throws( + () => classifyDocumentationChanges([{ filename: "docs/qa/moved.md", status: "renamed" }], 1), + /previous_filename/, + ); +}); + +test("pagination, malformed objects, empty responses, and count mismatches fail closed", () => { + assert.equal( + classifyPaginatedDocumentationChanges( + [[{ filename: "README.md" }], [{ filename: "docs/release/distribution.md" }]], + "2", + ), + true, + ); + assert.throws(() => classifyPaginatedDocumentationChanges([], 1), /at least one page/); + assert.throws(() => classifyPaginatedDocumentationChanges([[]], 1), /must not be empty/); + assert.throws(() => classifyPaginatedDocumentationChanges([[{ filename: "README.md" }]], 2), /count mismatch/); + assert.throws(() => classifyPaginatedDocumentationChanges([[{}]], 1), /valid filename/); + assert.throws(() => classifyPaginatedDocumentationChanges([{ filename: "README.md" }], 1), /must be an array/); +}); + +test("the CLI consumes gh api slurped pages and prints only the classification", () => { + const success = spawnSync(process.execPath, [scriptPath, "2"], { + input: JSON.stringify([[{ filename: "README.md" }, { filename: "docs/qa/status.md" }]]), + encoding: "utf8", + }); + assert.equal(success.status, 0); + assert.equal(success.stdout, "true\n"); + assert.equal(success.stderr, ""); + + const nonDocumentation = spawnSync(process.execPath, [scriptPath, "1"], { + input: JSON.stringify([[{ filename: ".github/workflows/pr-validation.yml" }]]), + encoding: "utf8", + }); + assert.equal(nonDocumentation.status, 0); + assert.equal(nonDocumentation.stdout, "false\n"); + + const malformed = spawnSync(process.execPath, [scriptPath, "2"], { + input: JSON.stringify([[{ filename: "README.md" }]]), + encoding: "utf8", + }); + assert.notEqual(malformed.status, 0); + assert.equal(malformed.stdout, ""); + assert.match(malformed.stderr, /count mismatch/); +}); diff --git a/.github/scripts/codeql-path-scope-policy.test.mjs b/.github/scripts/codeql-path-scope-policy.test.mjs new file mode 100644 index 0000000000..09c11eb41a --- /dev/null +++ b/.github/scripts/codeql-path-scope-policy.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const workflowUrl = new URL("../workflows/codeql.yml", import.meta.url); + +function jobBlock(source, jobName, nextJobName) { + const start = source.indexOf(` ${jobName}:`); + assert.notEqual(start, -1, `${jobName} job is missing`); + const end = nextJobName === undefined ? source.length : source.indexOf(` ${nextJobName}:`, start + 1); + assert.notEqual(end, -1, `${nextJobName} job is missing`); + return source.slice(start, end); +} + +test("CodeQL keeps its pull request trigger unfiltered so required checks cannot stay pending", async () => { + const source = await readFile(workflowUrl, "utf8"); + const trigger = /^on:\n\s{2}pull_request:\n([\s\S]*?)^\s{2}push:/m.exec(source)?.[1] ?? ""; + + assert.doesNotMatch(trigger, /paths(?:-ignore)?:/); + assert.match(source, /^\s{2}workflow_dispatch:\n\s{4}inputs:\n\s{6}pull_request_number:/m); +}); + +test("CodeQL classifies every PR file and defaults to full analysis", async () => { + const source = await readFile(workflowUrl, "utf8"); + const classifier = jobBlock(source, "classify-changes", "analyze-actions"); + + assert.match(classifier, /gh api --paginate --slurp/); + assert.match(classifier, /classify-documentation-changes\.mjs/); + assert.match(classifier, /docs_only: \$\{\{ steps\.path-scope\.outputs\.docs_only \|\| 'false' \}\}/); + assert.match(classifier, /if \[ "\$GITHUB_EVENT_NAME" = "workflow_dispatch" \]; then/); + assert.match(classifier, /if \[ "\$GITHUB_SHA" != "\$head_sha" \]; then/); + assert.match(classifier, /permissions:\n\s+contents: read\n\s+pull-requests: read/); + assert.doesNotMatch(classifier, /security-events: write/); + assert.match(classifier, /persist-credentials: false/); + assert.match(classifier, /env -u GH_TOKEN -u GITHUB_TOKEN/); +}); + +test("CodeQL preserves both required context names and skips only after a successful docs-only classification", async () => { + const source = await readFile(workflowUrl, "utf8"); + const actions = jobBlock(source, "analyze-actions", "analyze-java-kotlin"); + const kotlin = jobBlock(source, "analyze-java-kotlin"); + const failClosed = /if: \$\{\{ !cancelled\(\) && \(needs\.classify-changes\.result != 'success' \|\| needs\.classify-changes\.outputs\.docs_only != 'true'\) \}\}/; + + assert.match(actions, /^\s{4}name: Analyze \(actions\)$/m); + assert.match(kotlin, /^\s{4}name: Analyze \(java-kotlin\)$/m); + assert.match(actions, /^\s{4}needs: classify-changes$/m); + assert.match(kotlin, /^\s{4}needs: classify-changes$/m); + assert.match(actions, failClosed); + assert.match(kotlin, failClosed); + assert.match(actions, /security-events: write/); + assert.match(kotlin, /security-events: write/); + assert.doesNotMatch(source, /^\s{4}strategy:/m); +}); diff --git a/.github/scripts/pr-gate-policy.test.mjs b/.github/scripts/pr-gate-policy.test.mjs index def29b98a0..f4e8ac04a5 100644 --- a/.github/scripts/pr-gate-policy.test.mjs +++ b/.github/scripts/pr-gate-policy.test.mjs @@ -5,6 +5,7 @@ import test from "node:test"; const workflowDirectory = new URL("../workflows/", import.meta.url); const ENTRY_WORKFLOW = "pr-validation.yml"; const VALIDATION_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml", "repository-quality.yml"]; +const HEAVY_VALIDATION_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml"]; // Repository ruleset 20911039 의 required context 와 함께 바꿔야 하는 외부 계약이다. const REQUIRED_VALIDATION_CONTEXTS = [ "Repository Quality / Repository Quality", @@ -105,13 +106,72 @@ test("token-authored commits preserve the pull request context on manual dispatc ); }); -test("the entry point keeps no pull_request branch filter", async () => { +test("the entry point keeps no pull_request branch or path filter", async () => { // #683: base 변경(feat/* → develop)은 기본 activity type 에 없어 재트리거되지 // 않는다. 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다. + // required workflow 자체의 paths 필터는 제외된 PR 에 check 를 만들지 않아 ruleset 을 + // 영구 pending 으로 남길 수 있으므로 변경 파일 분류는 job 안에서 한다. const entry = await readWorkflow(ENTRY_WORKFLOW); const trigger = /^on:\n\s{2}pull_request:\n([\s\S]*?)^\S/m.exec(entry)?.[1] ?? ""; assert.doesNotMatch(trigger, /branches/); + assert.doesNotMatch(trigger, /^\s+paths(?:-ignore)?:/m); +}); + +test("documentation fast path runs full validation unless repository quality succeeds with an exact true output", async () => { + const entry = await readWorkflow(ENTRY_WORKFLOW); + const failClosedInput = + "run_validation: ${{ needs.repository-quality.result != 'success' || " + + "needs.repository-quality.outputs.docs_only != 'true' }}"; + + assert.equal((entry.match(/^ {4}needs: repository-quality$/gm) ?? []).length, HEAVY_VALIDATION_WORKFLOWS.length); + assert.equal( + (entry.match(/^ {4}if: \$\{\{ !cancelled\(\) \}\}$/gm) ?? []).length, + HEAVY_VALIDATION_WORKFLOWS.length, + "quality failures must fan out to full validation without reviving a cancelled stale run", + ); + assert.equal( + entry.split(failClosedInput).length - 1, + HEAVY_VALIDATION_WORKFLOWS.length, + "missing, false, or failed classification must run every heavy validation workflow", + ); +}); + +test("heavy reusable workflows default to full validation and preserve every required job context", async () => { + for (const name of HEAVY_VALIDATION_WORKFLOWS) { + const source = await readWorkflow(name); + const jobs = jobNames(source); + + assert.match( + source, + /^ {6}run_validation:\n(?: {8}.+\n)*? {8}default: true\n {8}type: boolean$/m, + `${name} must default callers such as develop validation to the full suite`, + ); + assert.equal( + (source.match(/^ {4}if: inputs\.run_validation$/gm) ?? []).length, + jobs.length, + `${name} must skip work at the existing required job boundary`, + ); + } +}); + +test("repository quality owns fail-closed paginated classification and pull request metadata gates", async () => { + const repositoryQuality = await readWorkflow("repository-quality.yml"); + const unitTest = await readWorkflow("unit-test.yml"); + + assert.match(repositoryQuality, /^ {4}outputs:\n {6}docs_only:\n(?: {8}.+\n)*? {8}value: \$\{\{ jobs\.repository-quality\.outputs\.docs_only \}\}$/m); + assert.match(repositoryQuality, /^ {4}outputs:\n {6}docs_only: \$\{\{ steps\.classify-documentation-changes\.outputs\.docs_only \}\}$/m); + assert.match(repositoryQuality, /gh api --paginate --slurp/); + assert.match(repositoryQuality, /classify-documentation-changes\.mjs "\$CHANGED_FILES"/); + assert.match(repositoryQuality, /if \[ "\$GITHUB_EVENT_NAME" = "workflow_dispatch" \]; then/); + assert.match(repositoryQuality, /if \[ "\$GITHUB_SHA" != "\$head_sha" \]; then/); + assert.match(repositoryQuality, /persist-credentials: false/); + assert.match(repositoryQuality, /env -u GH_TOKEN -u GITHUB_TOKEN/); + assert.match( + repositoryQuality, + /- name: Validate structured QA metadata\n\s+if: github\.event_name == 'pull_request'/, + ); + assert.doesNotMatch(unitTest, /Validate structured QA metadata/); }); test("repository quality still runs on develop and main pushes", async () => { diff --git a/.github/scripts/qa-metadata.mjs b/.github/scripts/qa-metadata.mjs index 8ed4888e30..b9fc6764b0 100644 --- a/.github/scripts/qa-metadata.mjs +++ b/.github/scripts/qa-metadata.mjs @@ -1,4 +1,4 @@ -// PR 본문의 `QA Metadata` 섹션을 파싱·검증한다. validate-pr-qa-metadata.mjs 가 Unit Test 에서 +// PR 본문의 `QA Metadata` 섹션을 파싱·검증한다. validate-pr-qa-metadata.mjs 가 Repository Quality 에서 // 이 함수들로 게이트를 건다 (#809). // // 원래 qa-semantic-audit.mjs 의 앞부분이었다. 그 스크립트는 deployment-decision.yml 의 판정 위에 @@ -227,4 +227,3 @@ export function inspectQaMetadata(body, options = {}) { errors: errors.map((error) => `PR #${pullRequestNumber}: ${error}`), }; } - diff --git a/.github/scripts/refresh-pr-stack.mjs b/.github/scripts/refresh-pr-stack.mjs index abce7211ce..f09245a551 100644 --- a/.github/scripts/refresh-pr-stack.mjs +++ b/.github/scripts/refresh-pr-stack.mjs @@ -135,10 +135,13 @@ export async function dispatchRequiredChecks(api, repository, results, { dryRun } for (const result of results) { for (const workflow of REQUIRED_WORKFLOWS) { - const body = { ref: result.branch }; - if (workflow === "pr-validation.yml" || workflow === "merge-order-guard.yml") { - body.inputs = { pull_request_number: String(result.number) }; - } + // 세 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, diff --git a/.github/scripts/refresh-pr-stack.test.mjs b/.github/scripts/refresh-pr-stack.test.mjs index f0cd272ec4..8a659b6fb0 100644 --- a/.github/scripts/refresh-pr-stack.test.mjs +++ b/.github/scripts/refresh-pr-stack.test.mjs @@ -116,14 +116,12 @@ test("필수 workflow 세 개를 각 PR 현재 branch 에 dispatch 한다", asyn ]); assert.equal(api.calls.length, 6); - assert.deepEqual(api.calls[0].body, { - ref: "feat/a", - inputs: { pull_request_number: "10" }, - }); - assert.deepEqual(api.calls[2].body, { - ref: "feat/a", - inputs: { pull_request_number: "10" }, - }); + 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 () => { diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7d8d53bc55..39d2f68ccc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,35 +7,130 @@ on: schedule: - cron: '17 19 * * 2' workflow_dispatch: + inputs: + pull_request_number: + description: "현재 ref 와 일치해야 하는 열린 PR 번호. 비우면 전체 분석" + required: false + default: 0 + type: number concurrency: - group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pull_request_number || github.ref }} cancel-in-progress: true permissions: contents: read - security-events: write jobs: - analyze: - name: Analyze (${{ matrix.language }}) + # required workflow 자체에 paths-ignore 를 걸면 docs-only PR 의 CodeQL context 가 + # Pending 으로 남는다. 작은 선행 job 이 전체 변경 경로를 확인하고, 아래 required job 을 + # job-level if 로 skipped/success 처리한다. 분류 실패·빈 출력은 전체 분석으로 닫힌다. + classify-changes: + name: Classify documentation changes + if: github.event_name == 'pull_request' || inputs.pull_request_number > 0 + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + contents: read + pull-requests: read + outputs: + docs_only: ${{ steps.path-scope.outputs.docs_only || 'false' }} + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve documentation-only path scope + id: path-scope + env: + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run: | + set -euo pipefail + + if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::유효한 pull_request_number 가 필요합니다" + exit 1 + fi + + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + pull_request=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + state=$(jq -r '.state' <<< "$pull_request") + head_sha=$(jq -r '.head.sha' <<< "$pull_request") + head_repository=$(jq -r '.head.repo.full_name' <<< "$pull_request") + if [ "$state" != "open" ] || [ "$head_repository" != "$GITHUB_REPOSITORY" ]; then + echo "::error::PR #$PR_NUMBER 는 이 저장소의 열린 PR이 아닙니다" + exit 1 + fi + if [ "$GITHUB_SHA" != "$head_sha" ]; then + echo "::error::dispatch ref $GITHUB_SHA 가 PR #$PR_NUMBER head $head_sha 와 다릅니다" + exit 1 + fi + CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") + elif [ -z "$CHANGED_FILES" ]; then + CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.changed_files') + fi + + if ! [[ "$CHANGED_FILES" =~ ^[0-9]+$ ]]; then + echo "::error::PR changed_files 값이 유효하지 않습니다: $CHANGED_FILES" + exit 1 + fi + + files_json=$(gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100") + docs_only=$(env -u GH_TOKEN -u GITHUB_TOKEN \ + node .github/scripts/classify-documentation-changes.mjs \ + "$CHANGED_FILES" <<< "$files_json") + printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT" + + analyze-actions: + name: Analyze (actions) + needs: classify-changes + if: ${{ !cancelled() && (needs.classify-changes.result != 'success' || needs.classify-changes.outputs.docs_only != 'true') }} runs-on: ubuntu-latest timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - language: actions - build_mode: none - database_language: actions - source_extensions: .yml,.yaml - required_path_fragment: .github/workflows/ - - language: java-kotlin - build_mode: manual - database_language: java - source_extensions: .kt - required_path_fragment: app/src/main/ + permissions: + contents: read + security-events: write + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + languages: actions + build-mode: none + - name: Analyze and upload CodeQL results + id: analyze + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + category: /language:actions + + - name: Verify analyzed sources in the CodeQL database + env: + CODEQL_BUILD_MODE: none + CODEQL_DATABASE_LANGUAGE: actions + CODEQL_DATABASE_LOCATIONS: ${{ steps.analyze.outputs.db-locations }} + CODEQL_DISPLAY_LANGUAGE: actions + CODEQL_REQUIRED_PATH_FRAGMENT: .github/workflows/ + CODEQL_SOURCE_EXTENSIONS: .yml,.yaml + run: node .github/scripts/verify-codeql-database.mjs + + analyze-java-kotlin: + name: Analyze (java-kotlin) + needs: classify-changes + if: ${{ !cancelled() && (needs.classify-changes.result != 'success' || needs.classify-changes.outputs.docs_only != 'true') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + security-events: write steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,18 +138,14 @@ jobs: persist-credentials: false - name: Set up JDK 21 - if: matrix.build_mode == 'manual' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: '21' # 읽기 전용이다 — push 에서도 쓰지 않는다. 이 job 은 --build-cache 를 쓸 수 없어 - # (아래 주석) build-cache-1 이 빈 Gradle User Home 을 저장하게 되는데, Actions 캐시는 - # 같은 키를 덮어쓰지 못하므로 그것이 먼저 저장되면 build-cache-warm.yml 이 채운 - # 캐시가 올라갈 자리를 잃는다. develop push 의 캐시 writer 는 그 워크플로 하나다 (#996). + # build-cache-warm.yml 이 develop 에 채운 캐시 키를 빈 엔트리로 선점해서는 안 된다. - name: Set up Gradle and validate wrapper - if: matrix.build_mode == 'manual' uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-provider: basic @@ -62,29 +153,18 @@ jobs: validate-wrappers: true - name: Set up CI-only app configuration - if: matrix.build_mode == 'manual' uses: ./.github/actions/setup-ci-config - name: Initialize CodeQL uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build_mode }} + languages: java-kotlin + build-mode: manual # Java/Kotlin extraction observes compiler invocations, so a clean manual build is the # contract. compileDebugSources covers every Android leaf; the two pure JVM leaves need classes. - # - # 추출에 필요한 것은 컴파일러 호출이지 APK 가 아니다. assembleDebug 는 그 뒤로 - # dex 병합(R8)까지 끌고 갔고, 그 :app:mergeExtDexDebug 가 GitHub 러너(7GB)에서 - # 힙을 넘겨 CodeQL 을 산발적으로 실패시켰다 — 코드 결함과 구분되지 않는 빨간불이었다. - # compileDebugSources 는 같은 114개 컴파일 task 를 그대로 돌리면서 dex·packaging 만 - # 뺀다 (#1023). - # - # 이 워크플로는 #996 의 --build-cache 예외이기도 하다. compile task 가 FROM-CACHE 로 - # 넘어가면 컴파일러가 아예 실행되지 않아 추출기가 소스를 담지 못하고, 아래 verify - # 스텝이 빈 database 로 실패한다. lint·unit-test·screenshot 에는 이 제약이 없다. + # FROM-CACHE 로 넘어가면 컴파일러가 실행되지 않아 추출기가 소스를 담지 못한다. - name: Compile Android and pure JVM Kotlin sources - if: matrix.build_mode == 'manual' run: >- ./gradlew compileDebugSources :feature:onboarding:domain:classes @@ -97,14 +177,14 @@ jobs: id: analyze uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: - category: /language:${{ matrix.language }} + category: /language:java-kotlin - name: Verify analyzed sources in the CodeQL database env: - CODEQL_BUILD_MODE: ${{ matrix.build_mode }} - CODEQL_DATABASE_LANGUAGE: ${{ matrix.database_language }} + CODEQL_BUILD_MODE: manual + CODEQL_DATABASE_LANGUAGE: java CODEQL_DATABASE_LOCATIONS: ${{ steps.analyze.outputs.db-locations }} - CODEQL_DISPLAY_LANGUAGE: ${{ matrix.language }} - CODEQL_REQUIRED_PATH_FRAGMENT: ${{ matrix.required_path_fragment }} - CODEQL_SOURCE_EXTENSIONS: ${{ matrix.source_extensions }} + CODEQL_DISPLAY_LANGUAGE: java-kotlin + CODEQL_REQUIRED_PATH_FRAGMENT: app/src/main/ + CODEQL_SOURCE_EXTENSIONS: .kt run: node .github/scripts/verify-codeql-database.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 401b41c3da..416ce80dfa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,11 @@ on: required: false default: 0 type: number + run_validation: + description: "false 이면 required check 이름을 유지한 채 무거운 검증을 건너뜀" + required: false + default: true + type: boolean permissions: contents: read @@ -20,6 +25,7 @@ permissions: jobs: ktlint: name: Check Code Quality (Ktlint) + if: inputs.run_validation runs-on: ubuntu-latest steps: - name: Clone repo @@ -60,6 +66,7 @@ jobs: android-lint: name: Check Project Issues (Android Lint) + if: inputs.run_validation runs-on: ubuntu-latest steps: - name: Clone repo diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 55ce26319c..6a2971cc73 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -1,8 +1,9 @@ name: PR Validation -# PR 검증의 유일한 진입점이다. static analysis · unit test · screenshot · -# repository quality 를 reusable workflow 로 한 번씩 부르고, 각 job check 를 ruleset 에서 -# 직접 required 로 요구한다. 모든 검증 뒤 집계용 runner 를 한 번 더 기다리지 않는다. +# PR 검증의 유일한 진입점이다. repository quality 가 변경 파일을 완전하게 분류한 뒤 +# static analysis · unit test · screenshot 을 reusable workflow 로 한 번씩 부른다. +# 각 job check 를 ruleset 에서 직접 required 로 요구하므로 문서 전용 PR 도 같은 이름의 +# check 를 만들되, 무거운 job 내부는 즉시 건너뛴다. 모든 검증 뒤 집계용 runner 는 없다. # required context 이름은 pr-gate-policy.test.mjs 에 고정해 workflow 이름 변경과 ruleset # 변경이 함께 검토되도록 한다. # @@ -31,26 +32,35 @@ permissions: pull-requests: write jobs: + repository-quality: + name: Repository Quality + uses: ./.github/workflows/repository-quality.yml + with: + pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number }} + static-analysis: name: Static Analysis + needs: repository-quality + if: ${{ !cancelled() }} uses: ./.github/workflows/lint.yml with: pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.docs_only != 'true' }} unit-test: name: Unit Test + needs: repository-quality + if: ${{ !cancelled() }} uses: ./.github/workflows/unit-test.yml with: pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.docs_only != 'true' }} screenshot: name: Screenshot + needs: repository-quality + if: ${{ !cancelled() }} uses: ./.github/workflows/screenshot.yml with: pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number }} - - repository-quality: - name: Repository Quality - uses: ./.github/workflows/repository-quality.yml - with: - pull_request_number: ${{ inputs.pull_request_number || github.event.pull_request.number }} + run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.docs_only != 'true' }} diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index c803198489..d5a9cd63b7 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -8,6 +8,10 @@ on: required: false default: 0 type: number + outputs: + docs_only: + description: "README.md 와 docs/ 아래 파일만 바뀐 PR인지 여부" + value: ${{ jobs.repository-quality.outputs.docs_only }} push: branches: [develop, main] @@ -25,11 +29,14 @@ jobs: name: Repository Quality runs-on: ubuntu-latest timeout-minutes: 3 + outputs: + docs_only: ${{ steps.classify-documentation-changes.outputs.docs_only }} steps: # GitHub 의 server-side paths 평가는 유한한 diff 앞부분만 보므로, 그 경계를 넘는 PR 을 # 그대로 허용하면 뒤쪽 dependency 파일이 graph/review workflow 를 우회할 수 있다. # 공식 문서의 상충하는 300/3,000 한도 중 보수적인 300을 fail-closed 경계로 쓴다. - name: Guard server-side path filter boundary + id: changed-files if: inputs.pull_request_number > 0 env: CHANGED_FILES: ${{ github.event.pull_request.changed_files }} @@ -39,7 +46,21 @@ jobs: run: | set -euo pipefail - if [ -z "$CHANGED_FILES" ]; then + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + pull_request=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + state=$(jq -r '.state' <<< "$pull_request") + head_sha=$(jq -r '.head.sha' <<< "$pull_request") + head_repository=$(jq -r '.head.repo.full_name' <<< "$pull_request") + if [ "$state" != "open" ] || [ "$head_repository" != "$GITHUB_REPOSITORY" ]; then + echo "::error::PR #$PR_NUMBER 는 이 저장소의 열린 PR이 아닙니다" + exit 1 + fi + if [ "$GITHUB_SHA" != "$head_sha" ]; then + echo "::error::dispatch ref $GITHUB_SHA 가 PR #$PR_NUMBER head $head_sha 와 다릅니다" + exit 1 + fi + CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") + elif [ -z "$CHANGED_FILES" ]; then CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.changed_files') fi @@ -53,8 +74,33 @@ jobs: exit 1 fi + printf 'count=%s\n' "$CHANGED_FILES" >> "$GITHUB_OUTPUT" + - name: Clone repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # 최상위 paths 필터는 required check 자체를 만들지 않아 영구 pending 이 될 수 있다. + # 모든 파일 페이지와 GitHub 의 changed_files 총수를 대조한 뒤에만 무거운 Android + # required job 을 건너뛴다. API 응답 누락·형식 오류·rename 경계는 모두 실패로 닫힌다. + - name: Classify documentation-only changes + id: classify-documentation-changes + if: inputs.pull_request_number > 0 + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.count }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pull_request_number }} + run: | + set -euo pipefail + + docs_only=$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \ + | env -u GH_TOKEN -u GITHUB_TOKEN \ + node .github/scripts/classify-documentation-changes.mjs "$CHANGED_FILES" + ) + printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT" - name: Require linked Issue if: github.event_name == 'pull_request' @@ -62,6 +108,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/scripts/validate-pr-issue-link.mjs + - name: Validate structured QA metadata + if: github.event_name == 'pull_request' + run: node .github/scripts/validate-pr-qa-metadata.mjs "$GITHUB_EVENT_PATH" + - name: Install actionlint env: ACTIONLINT_VERSION: 1.7.12 diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index 02a8c63e51..5493edd526 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -12,6 +12,11 @@ on: required: false default: 0 type: number + run_validation: + description: "false 이면 required check 이름을 유지한 채 무거운 검증을 건너뜀" + required: false + default: true + type: boolean permissions: contents: read @@ -20,6 +25,7 @@ permissions: jobs: screenshot-test: name: Validate Compose Preview Screenshots + if: inputs.run_validation runs-on: ubuntu-latest # baseline 을 가진 모듈의 정본. 종전에는 실행 태스크·아티팩트 경로·실패 안내가 각자 # 목록을 들고 있어, 모듈을 하나 늘리면 세 곳을 모두 고쳐야 했다 — 하나라도 빠지면 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 7946985734..c13edca6ab 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -1,8 +1,6 @@ name: Unit Test -# 모든 PR 에서 unit test 실행: -# - validate-pr-qa-metadata.mjs : PR 구조화 QA 원천 누락·generic 문구 차단. -# 게이트 도입 전 생성 PR 은 섹션이 없으면 건너뛴다(스크립트의 QA_METADATA_GATE_CUTOFF) — 리베이스 소급 차단 방지. +# 코드가 바뀐 모든 PR 과 develop 에서 unit test 실행: # - node --test .github/scripts/*.test.mjs : 배포 판단 스크립트 회귀 가드. # - :koverXmlReportCi / :koverHtmlReportCi : Android debug + 순수 JVM 테스트와 aggregate coverage # - :konsist:test : 순수 JVM 모듈(testDebugUnitTest 미보유)이라 별도 명시. 레이어 의존 아키텍처 가드. @@ -23,6 +21,11 @@ on: required: false default: 0 type: number + run_validation: + description: "false 이면 required check 이름을 유지한 채 무거운 검증을 건너뜀" + required: false + default: true + type: boolean permissions: contents: read @@ -31,6 +34,7 @@ permissions: jobs: unit-test: name: Run Unit Tests + if: inputs.run_validation runs-on: ubuntu-latest steps: - name: Clone repo @@ -88,9 +92,6 @@ jobs: "+refs/heads/${BASE_REF}:refs/remotes/origin/kover-base" \ "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/kover-head" - - name: Validate structured QA metadata - run: node .github/scripts/validate-pr-qa-metadata.mjs "$GITHUB_EVENT_PATH" - - name: Run deployment script tests run: node --test .github/scripts/*.test.mjs