Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions .github/scripts/pr-gate-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ 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 VALIDATION_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml", "repository-quality.yml", "dependency-review.yml"];
const HEAVY_VALIDATION_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml"];
// repository-quality 의 분류 결과를 받아 범위를 줄이는 lane. 분류 실패는 전부 실행으로 닫힌다.
const IMPACT_SCOPED_WORKFLOWS = [...HEAVY_VALIDATION_WORKFLOWS, "dependency-review.yml"];
// PR 번호로 PR 을 다시 조회하는 lane. dependency-review 는 이벤트의 PR 을 쓰므로 번호를 받지 않는다.
const PR_NUMBER_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml", "repository-quality.yml"];
// Repository ruleset 20911039 의 required context 와 함께 바꿔야 하는 외부 계약이다.
const REQUIRED_VALIDATION_CONTEXTS = [
"Dependency Review / Review dependency changes",
"Repository Quality / Repository Quality",
"Screenshot / Validate Compose Preview Screenshots",
"Static Analysis / Check Code Quality (Ktlint)",
Expand Down Expand Up @@ -130,7 +135,7 @@ test("token-authored commits preserve the pull request context on manual dispatc
/pull_request_number: \$\{\{ fromJSON\(inputs\.pull_request_number \|\| '0'\) \|\| github\.event\.pull_request\.number \|\| 0 \}\}/g,
) ?? []
).length,
VALIDATION_WORKFLOWS.length,
PR_NUMBER_WORKFLOWS.length,
);
assert.equal(
(entry.match(/pull_request_number: \$\{\{ inputs\.pull_request_number/g) ?? []).length,
Expand Down Expand Up @@ -158,7 +163,7 @@ test("merge group validation falls back to the full suite without a pull request

assert.equal(
(entry.match(/\|\| github\.event\.pull_request\.number \|\| 0 \}\}/g) ?? []).length,
VALIDATION_WORKFLOWS.length,
PR_NUMBER_WORKFLOWS.length,
);
for (const gate of ["Require linked Issue", "Require module owner", "Validate CI Test Plan", "Reject test-only production declarations"]) {
assert.match(
Expand Down Expand Up @@ -194,13 +199,19 @@ test("the entry point keeps no pull_request branch or path filter", async () =>
test("impact outputs scope each heavy lane and classification failure runs full validation", async () => {
const entry = await readWorkflow(ENTRY_WORKFLOW);

assert.equal((entry.match(/^ {4}needs: repository-quality$/gm) ?? []).length, HEAVY_VALIDATION_WORKFLOWS.length);
assert.equal((entry.match(/^ {4}needs: repository-quality$/gm) ?? []).length, IMPACT_SCOPED_WORKFLOWS.length);
assert.equal(
(entry.match(/^ {4}if: \$\{\{ !cancelled\(\) \}\}$/gm) ?? []).length,
HEAVY_VALIDATION_WORKFLOWS.length,
IMPACT_SCOPED_WORKFLOWS.length,
"quality failures must fan out to full validation without reviving a cancelled stale run",
);
for (const output of ["ktlint_required", "android_lint_required", "unit_test_required", "screenshot_required"]) {
for (const output of [
"ktlint_required",
"android_lint_required",
"unit_test_required",
"screenshot_required",
"dependency_review_required",
]) {
assert.match(
entry,
new RegExp(`needs\\.repository-quality\\.result != 'success' \\|\\| needs\\.repository-quality\\.outputs\\.${output} != 'false'`),
Expand Down Expand Up @@ -237,6 +248,38 @@ test("heavy reusable workflows default to full validation and preserve every req
assert.match(lint, /^ {4}if: inputs\.run_android_lint$/m);
});

test("dependency review keeps one required context and skips only its check step", async () => {
// #2258: job 자체를 if 로 건너뛰면 분류 job 실패와 «검사 불필요» 가 같은 skipped 로 보인다.
// job 은 늘 돌고, 검사 스텝만 PR 이벤트 + 의존성 입력 변경일 때 실행한다. 그 밖의 이벤트에서
// 의존성 입력이 바뀌었으면 merge group 만 건너뛰고 나머지는 실패한다.
const source = await readWorkflow("dependency-review.yml");
const jobs = jobNames(source);

assert.deepEqual(jobs, ["dependency-review"]);
assert.doesNotMatch(source, /^ {4}if:/m);
assert.match(source, /^ {6}run_review:\n(?: {8}.+\n)*? {8}default: true\n {8}type: boolean$/m);
assert.match(
source,
/- name: Fail on newly introduced high-severity vulnerabilities\n\s+if: \$\{\{ inputs\.run_review && github\.event_name == 'pull_request' \}\}\n\s+uses: actions\/dependency-review-action@/,
);
assert.match(source, /- name: Skip unchanged dependency graph inputs\n\s+if: \$\{\{ !inputs\.run_review \}\}/);
assert.match(
source,
/- name: Skip merge group revalidation\n\s+if: \$\{\{ inputs\.run_review && github\.event_name == 'merge_group' \}\}/,
);
// bridge dispatch 커밋은 PR 스냅샷이 없다. 건너뛰면 이전 head 의 실패를 검사 없는 초록이 덮는다.
const rejectStart = source.indexOf("- name: Reject dependency changes without a pull request snapshot\n");
assert.ok(rejectStart >= 0, "dispatch rejection step is missing");
const rejectEnd = source.indexOf("\n - name:", rejectStart + 1);
const rejectStep = source.slice(rejectStart, rejectEnd < 0 ? undefined : rejectEnd);
assert.ok(
rejectStep.includes(
"if: ${{ inputs.run_review && github.event_name != 'pull_request' && github.event_name != 'merge_group' }}\n",
),
);
assert.match(rejectStep, /^ {10}exit 1$/m);
});

test("repository quality owns fail-closed paginated impact classification and PR gates", async () => {
const repositoryQuality = await readWorkflow("repository-quality.yml");
const unitTest = await readWorkflow("unit-test.yml");
Expand Down
55 changes: 55 additions & 0 deletions .github/scripts/resolve-pr-impact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const IMPACT_POLICY_PATHS = new Set([
".github/workflows/unit-test.yml",
".github/workflows/screenshot.yml",
".github/workflows/codeql.yml",
".github/workflows/dependency-review.yml",
// 기대 실패 목록(xfail)은 검증 정책 그 자체다 — 목록·메커니즘 변경은 모든 lane 으로
// fail-closed 해, 잘못 지운 항목이 그 자리에서 red 를 만들게 한다.
".github/ci-expected-failures.json",
Expand All @@ -35,6 +36,50 @@ const IMPACT_POLICY_PATHS = new Set([
// 아키텍처 가드 전용 순수 JVM 모듈. src/main 이 없어 프로덕션 소스 분기를 탈 수 없고,
// kover 도 안 붙어 coverageModules 로도 안 잡힌다 — 소스셋 판정에서 따로 건져야 한다.
const KONSIST_PROJECT_PATH = ":konsist";
// Dependency Review 를 깨우는 의존성 그래프 입력. 예전엔 dependency-review.yml 의 paths
// 필터였는데, 그러면 소스만 고친 PR 에 check 가 생기지 않아 required 로 걸 수 없었다 (#2258).
// 같은 glob 문법을 그대로 두고 job 안 분류로 옮긴다. 목록은 supply-chain-policy.test.mjs 가 고정한다.
export const DEPENDENCY_GRAPH_INPUT_PATTERNS = Object.freeze([
"**/*.gradle",
"**/*.gradle.kts",
"**/*.lockfile",
"**/build-logic/**",
"**/buildSrc/**",
"**/gradle.properties",
"**/gradlew",
"**/gradlew.bat",
"gradle/**",
".github/workflows/dependency-review.yml",
".github/workflows/dependency-submission.yml",
".github/workflows/dependency-submission-trusted.yml",
".github/workflows/dependency-submission-upload.yml",
]);

function globToRegExp(pattern) {
let source = "";
for (let index = 0; index < pattern.length; ) {
if (pattern.startsWith("**/", index)) {
source += "(?:.*/)?";
index += 3;
} else if (pattern.startsWith("**", index)) {
source += ".*";
index += 2;
} else if (pattern[index] === "*") {
source += "[^/]*";
index += 1;
} else {
source += pattern[index].replace(/[.+?^${}()|[\]\\]/g, "\\$&");
index += 1;
}
}
return new RegExp(`^${source}$`);
}

const DEPENDENCY_GRAPH_INPUT_MATCHERS = DEPENDENCY_GRAPH_INPUT_PATTERNS.map(globToRegExp);

export function isDependencyGraphInput(filePath) {
return DEPENDENCY_GRAPH_INPUT_MATCHERS.some((matcher) => matcher.test(filePath));
}

function normalizePath(value) {
return String(value ?? "")
Expand Down Expand Up @@ -172,6 +217,7 @@ export function resolvePrImpact(changedFiles, modules, dependencies) {
let runNodeTests = false;
let codeqlActions = false;
let codeqlJavaKotlin = false;
let dependencyReview = false;
let forceFull = false;
let repositoryQualityFixtures = false;

Expand Down Expand Up @@ -214,6 +260,9 @@ export function resolvePrImpact(changedFiles, modules, dependencies) {
if (filePath.startsWith(".github/workflows/") || filePath.startsWith(".github/actions/")) {
codeqlActions = true;
}
if (isDependencyGraphInput(filePath)) {
dependencyReview = true;
}
if (filePath === ".editorconfig") {
globalKtlintChange = true;
}
Expand Down Expand Up @@ -286,6 +335,10 @@ export function resolvePrImpact(changedFiles, modules, dependencies) {
runKonsist = true;
compileAndroidTest = true;
}
if (forceFull) {
// 분류기 자신이나 처음 보는 경로가 바뀌면 의존성 입력이 없다고 단정하지 않는다.
dependencyReview = true;
}

const productionAffected = reverseDependencyClosure(productionSeeds, reverseDependencies);
if (productionAffected.size > 0) {
Expand Down Expand Up @@ -353,6 +406,7 @@ export function resolvePrImpact(changedFiles, modules, dependencies) {
screenshotTasks,
codeqlActions,
codeqlJavaKotlin,
dependencyReview,
repositoryQualityFull: forceFull,
repositoryQualityFixtures,
};
Expand Down Expand Up @@ -430,6 +484,7 @@ export function githubOutputLines(impact) {
screenshot_tasks: impact.screenshotTasks.join(" "),
codeql_actions: impact.codeqlActions,
codeql_java_kotlin: impact.codeqlJavaKotlin,
dependency_review_required: impact.dependencyReview,
repository_quality_full: impact.repositoryQualityFull,
repository_quality_fixtures: impact.repositoryQualityFixtures,
};
Expand Down
25 changes: 25 additions & 0 deletions .github/scripts/resolve-pr-impact.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,28 @@ test("GitHub outputs preserve empty scopes and booleans", () => {
assert.match(output, /^unit_test_tasks=$/m);
assert.match(output, /^codeql_actions=false$/m);
});

test("dependency review runs only for dependency graph inputs and fails closed", () => {
// #2258: Dependency Review 는 required 라 매 PR 에 check 가 생긴다. 검사 자체는 의존성
// 입력이 바뀐 PR 에서만 하고, 분류가 확신할 수 없는 변경은 검사 쪽으로 닫는다.
const sourceOnly = resolvePrImpact(["feature/home/presentation/src/main/kotlin/Home.kt"], modules, dependencies);
assert.equal(sourceOnly.dependencyReview, false);
assert.match(githubOutputLines(sourceOnly).join("\n"), /^dependency_review_required=false$/m);

for (const changed of [
["gradle/libs.versions.toml"],
["feature/home/presentation/build.gradle.kts"],
["build-logic/convention/src/main/kotlin/Convention.kt"],
[".github/workflows/dependency-submission.yml"],
[".github/workflows/dependency-review.yml"],
[".github/workflows/pr-validation.yml"],
[".github/scripts/resolve-pr-impact.mjs"],
["unexpected/runtime-config.toml"],
]) {
const impact = resolvePrImpact(changed, modules, dependencies);
assert.equal(impact.dependencyReview, true, changed[0]);
assert.match(githubOutputLines(impact).join("\n"), /^dependency_review_required=true$/m);
}

assert.equal(resolvePrImpact(["README.md", "docs/qa/plan.md"], modules, dependencies).dependencyReview, false);
});
65 changes: 50 additions & 15 deletions .github/scripts/supply-chain-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';

import { inspectModules } from './resolve-pr-impact.mjs';
import {
DEPENDENCY_GRAPH_INPUT_PATTERNS,
inspectModules,
isDependencyGraphInput,
} from './resolve-pr-impact.mjs';

const workflowDirectory = new URL('../workflows/', import.meta.url);
const repoRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url)));
Expand Down Expand Up @@ -122,18 +126,6 @@ function checkoutStepBlocks(source) {
return blocks;
}

function eventPathFilters(source, eventName) {
const match = new RegExp(`^ ${eventName}:\\n paths:\\n((?: - '[^']+'\\n)+)`, 'm').exec(
source,
);
assert.ok(match, `${eventName} must declare a paths filter`);
return match[1].trim().split('\n').map((line) => {
const path = /^- '([^']+)'$/.exec(line.trim());
assert.ok(path, `invalid dependency path filter: ${line}`);
return path[1];
});
}

function workflowName(source) {
const match = /^name:\s*(.+)$/m.exec(source);
assert.ok(match, 'workflow must declare a top-level name');
Expand Down Expand Up @@ -488,7 +480,7 @@ test('manual dependency baseline is hard-wired to the checked-out main SHA', asy
assert.doesNotMatch(manualJob, /inputs\./);
});

test('all pull request heads produce a baseline while dependency review keeps its path filter', async () => {
test('all pull request heads produce a baseline while dependency review classifies inputs in the job', async () => {
const submission = await readFile(
new URL('../workflows/dependency-submission.yml', import.meta.url),
'utf8',
Expand All @@ -504,7 +496,12 @@ test('all pull request heads produce a baseline while dependency review keeps it
assert.doesNotMatch(submission, /^\s+(?:paths(?:-ignore)?|branches(?:-ignore)?|types):/m);
assert.doesNotMatch(submission, /^\s+if:/m);
assert.doesNotMatch(submission, /pull_request_target:|workflow_dispatch:/);
assert.deepEqual(eventPathFilters(review, 'pull_request'), dependencyPathFilters);
// #2258: paths 필터는 소스만 고친 PR 에 check 를 만들지 않아 required 로 걸 수 없다.
// 같은 경로 목록을 repository-quality 의 분류로 옮기고, review 는 PR Validation 이 매번 부른다.
assert.match(review, /^on:\n workflow_call:\n/m);
assert.doesNotMatch(review, /^\s+(?:paths(?:-ignore)?|branches(?:-ignore)?):/m);
assert.doesNotMatch(review, /^ (?:pull_request|merge_group|push):/m);
assert.deepEqual([...DEPENDENCY_GRAPH_INPUT_PATTERNS], dependencyPathFilters);
assert.doesNotMatch(submission, /Detect dependency graph input changes/);
assert.doesNotMatch(review, /Detect dependency graph input changes/);
});
Expand Down Expand Up @@ -607,6 +604,44 @@ test('only the release pull request is exempt from the path-filter boundary', as
assert.doesNotMatch(exemption[1], /exit\s+1/);
});

test('dependency graph input classification matches the former paths filter semantics', () => {
for (const filePath of [
'build.gradle.kts',
'settings.gradle.kts',
'app/build.gradle.kts',
'legacy/build.gradle',
'gradle.properties',
'feature/home/gradle.properties',
'gradlew',
'gradlew.bat',
'gradle/libs.versions.toml',
'gradle/wrapper/gradle-wrapper.properties',
'build-logic/convention/src/main/kotlin/AndroidLibrary.kt',
'tools/buildSrc/src/main/kotlin/Plugin.kt',
'app/dependencies.lockfile',
'.github/workflows/dependency-review.yml',
'.github/workflows/dependency-submission.yml',
'.github/workflows/dependency-submission-trusted.yml',
'.github/workflows/dependency-submission-upload.yml',
]) {
assert.equal(isDependencyGraphInput(filePath), true, filePath);
}
for (const filePath of [
'README.md',
'docs/gradle.md',
'app/src/main/kotlin/MainActivity.kt',
'app/src/main/res/values/strings.xml',
'feature/gradle/Screen.kt',
'build-logic',
'gradle',
'gradlew.sh',
'app/build.gradle.kts.orig',
'.github/workflows/pr-validation.yml',
]) {
assert.equal(isDependencyGraphInput(filePath), false, filePath);
}
});

test('dependency review blocks high severity changes without enforcing a license allowlist', async () => {
const source = await readFile(new URL('../workflows/dependency-review.yml', import.meta.url), 'utf8');

Expand Down
Loading
Loading