From 2b300fdd3d38aaf437c3b1d549c1ba1e900e69ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:20:13 +0000 Subject: [PATCH 1/5] Initial plan From 37aedfe24cac8e930c0185b7213a6619e8d20b78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:32:15 +0000 Subject: [PATCH 2/5] Add changed-files input to filter CodeQL matrix by touched languages Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- README.md | 42 +++++++++++++++++++++++++ action.yml | 4 +++ entrypoint.sh | 2 +- main.py | 68 ++++++++++++++++++++++++++++++++++++++++ test_main.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 test_main.py diff --git a/README.md b/README.md index 6082fd8..6e5b377 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,48 @@ Set the `standard-language-names` input to `'true'` to have this action emit the This defaults to `'false'` to preserve backward compatibility, since switching category names for an existing CodeQL setup starts a new analysis history for that language and disassociates previous findings until they age out. +### Filtering by Changed Files + +By default, the matrix includes every CodeQL-supported language detected in the repository, even if a given pull request doesn't touch any files in that language. To scan only the languages actually touched by a pull request, pass the list of changed files to the `changed-files` input. It accepts a comma, space, or newline separated list of file paths, or a JSON array (the output formats used by most "changed files" actions). + +When `changed-files` is provided, the matrix is narrowed down to the intersection of: the languages detected in the repository, minus any `exclude`d languages, and further limited to only those with at least one changed file matching a known extension for that language. When `changed-files` is omitted (the default), the action's behavior is unchanged. + +Example, using `git diff` to compute the changed files for a pull request: + +``` yaml + create-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + if: github.event_name == 'pull_request' + run: | + echo "files=$(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}"...HEAD | tr '\n' ',')" >> "$GITHUB_OUTPUT" + + - name: Get languages from repo + id: set-matrix + uses: advanced-security/set-codeql-language-matrix@v1 + with: + access-token: ${{ secrets.GITHUB_TOKEN }} + endpoint: ${{ github.event.repository.languages_url }} + changed-files: ${{ steps.changed-files.outputs.files }} +``` + +You can also use a dedicated action such as [`tj-actions/changed-files`](https://github.com/tj-actions/changed-files) to compute the `changed-files` input, including its JSON output format. + +#### ⚠️ Impact on required status checks and rulesets + +If your repository requires Code Scanning results before merge (via a branch protection rule or a repository/organization ruleset), skipping a language's analysis on a pull request means that language's expected category is never uploaded for that PR, so the required check for it will never be satisfied and the PR will be blocked. Only enable `changed-files` filtering if you don't enforce required Code Scanning results per-language, or if you have a way to satisfy those checks for the languages you skip. + +[`advanced-security/monorepo-code-scanning-action`](https://github.com/advanced-security/monorepo-code-scanning-action) has a [`republish-sarif`](https://github.com/advanced-security/monorepo-code-scanning-action#republish) action built for exactly this problem: it republishes each skipped language's most recent SARIF results from the base branch onto the pull request (and back onto the base branch on merge), so required checks stay satisfied without re-running the full scan. That action's `republish-sarif` step relies on its own project-based configuration (matching each CodeQL language/project to the paths it covers) and runs as a `github-script` step with access to the workflow's `github`/`context` objects, so it isn't something this action can invoke internally. Instead, pair the two actions in your workflow: use this action's `changed-files` input (or its own `changes` action) to build the matrix of languages to scan, then add a `republish-sarif` step after your analyze job to cover the languages that were skipped. + ### Actions support The GitHub API for [List repository languages](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repository-languages) does not by default include "YAML"/"GitHub Actions". This is particularly useful if your repository contains GitHub Actions workflows that you want to include in CodeQL analysis. diff --git a/action.yml b/action.yml index 9f45ee8..4c33693 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,9 @@ inputs: description: 'Set to "true" to map aliased languages to the standard combined CodeQL language names used by github/codeql-action (e.g. "javascript-typescript" instead of "javascript", "java-kotlin" instead of "java", "c-cpp" instead of "cpp"). This avoids CodeQL treating these languages as separate entries in the code scanning tools page. Defaults to "false" to preserve backward compatibility with existing workflows.' required: false default: 'false' + changed-files: + description: 'A list of changed files (comma, newline or space separated, or a JSON array) used to narrow the matrix down to only the languages touched by those files. Typically populated from `git diff --name-only` or a changed-files action in a pull_request workflow. When omitted, all detected languages are included, matching the action''s default behavior.' + required: false outputs: matrix: description: 'Matrix definition including language and build-mode configurations' @@ -33,4 +36,5 @@ runs: - ${{ inputs.exclude }} - ${{ inputs.build-mode-manual-override }} - ${{ inputs.standard-language-names }} + - ${{ inputs.changed-files }} diff --git a/entrypoint.sh b/entrypoint.sh index b144939..fb0c8fb 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,4 @@ #!/bin/sh -l # kick off the command -python /main.py $1 $2 "$3" "$4" "$5" \ No newline at end of file +python /main.py $1 $2 "$3" "$4" "$5" "$6" \ No newline at end of file diff --git a/main.py b/main.py index 7bcaf7a..c7eb5b2 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,7 @@ exclude = sys.argv[3] if len(sys.argv) > 3 else "" build_mode_manual_override = sys.argv[4] if len(sys.argv) > 4 else "" standard_language_names = sys.argv[5] if len(sys.argv) > 5 else "" +changed_files_input = sys.argv[6] if len(sys.argv) > 6 else "" # Opt-in: use the standard combined CodeQL language names (e.g. "javascript-typescript") # as used by github/codeql-action, instead of the legacy single names (e.g. "javascript"). # Defaults to False to preserve backward compatibility with existing workflows. @@ -15,6 +16,30 @@ codeql_languages = ["actions", "cpp", "c-cpp", "csharp", "go", "java", "java-kotlin", "javascript", "javascript-typescript", "python", "ruby", "rust", "typescript", "kotlin", "swift"] +# File extensions used to detect each CodeQL language in a list of changed files. +# Only used when the `changed-files` input is provided, to narrow the matrix down +# to languages actually touched by a pull request. +# Note: "javascript" and "java" include their combined-language extensions too +# (.ts/.tsx and .kt/.kts respectively), since in legacy (non-standard-language-names) +# mode those slugs are the combined bucket that typescript/kotlin map into. +language_extensions = { + "actions": [".yml", ".yaml"], + "cpp": [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".inc", ".ino"], + "c-cpp": [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".inc", ".ino"], + "csharp": [".cs"], + "go": [".go"], + "java": [".java", ".kt", ".kts"], + "java-kotlin": [".java", ".kt", ".kts"], + "javascript": [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], + "javascript-typescript": [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"], + "python": [".py"], + "ruby": [".rb"], + "rust": [".rs"], + "typescript": [".ts", ".tsx", ".mts", ".cts"], + "kotlin": [".kt", ".kts"], + "swift": [".swift"], +} + # Connect to the languages API and return languages def get_languages(): @@ -66,6 +91,47 @@ def exclude_languages(language_list): print("languages={}".format(output)) return output +# Parse the changed-files input into a list of file paths. +# Accepts a JSON array (e.g. from tj-actions/changed-files with json output), +# or a comma/newline/space separated list of paths (e.g. `git diff --name-only`). +# Returns None when no changed files were provided, so callers can distinguish +# "no filtering requested" from "filtering requested, but nothing matched". +def parse_changed_files(raw): + raw = (raw or "").strip() + if not raw: + return None + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(f).strip() for f in parsed if str(f).strip()] + except ValueError: + pass + return [f.strip() for f in raw.replace(",", "\n").split() if f.strip()] + +# Narrow a list of CodeQL languages down to only those with a changed file +# matching one of their known extensions. If changed_files is None (the +# `changed-files` input was not supplied), the language list is returned +# unmodified to preserve the action's existing default behavior. +def filter_by_changed_files(language_list, changed_files): + if changed_files is None: + return language_list + + normalized_files = [f.replace("\\", "/").lower() for f in changed_files] + filtered = [] + for language in language_list: + extensions = language_extensions.get(language, []) + for file_path in normalized_files: + if not any(file_path.endswith(ext) for ext in extensions): + continue + if language == "actions" and "/.github/workflows/" not in "/" + file_path: + continue + filtered.append(language) + break + + print("Changed files:", changed_files) + print("Languages after changed-files filter:", filtered) + return filtered + # Determine build mode for each language def get_build_mode(language, original_languages=None): # Languages that should use manual build mode by default @@ -123,6 +189,8 @@ def main(): languages = get_languages() language_list, language_mapping = build_languages_list(languages) filtered_languages = exclude_languages(language_list) + changed_files = parse_changed_files(changed_files_input) + filtered_languages = filter_by_changed_files(filtered_languages, changed_files) matrix = build_matrix(filtered_languages, language_mapping) set_action_output("matrix", json.dumps(matrix)) # Keep the old output for backward compatibility diff --git a/test_main.py b/test_main.py new file mode 100644 index 0000000..cbb614b --- /dev/null +++ b/test_main.py @@ -0,0 +1,86 @@ +import importlib +import json +import sys +import unittest + + +def load_main(args): + """Import (or re-import) main.py as if it were invoked with the given CLI args.""" + sys.argv = ["main.py"] + args + if "main" in sys.modules: + return importlib.reload(sys.modules["main"]) + return importlib.import_module("main") + + +class ParseChangedFilesTests(unittest.TestCase): + def setUp(self): + self.main = load_main(["token", "http://example.invalid/languages"]) + + def test_empty_input_returns_none(self): + self.assertIsNone(self.main.parse_changed_files("")) + self.assertIsNone(self.main.parse_changed_files(" ")) + self.assertIsNone(self.main.parse_changed_files(None)) + + def test_comma_separated_list(self): + self.assertEqual( + self.main.parse_changed_files("src/app.py, src/index.js"), + ["src/app.py", "src/index.js"], + ) + + def test_newline_and_space_separated_list(self): + self.assertEqual( + self.main.parse_changed_files("src/app.py\nsrc/index.js cmd/main.go"), + ["src/app.py", "src/index.js", "cmd/main.go"], + ) + + def test_json_array(self): + self.assertEqual( + self.main.parse_changed_files(json.dumps(["a.py", "b.go"])), + ["a.py", "b.go"], + ) + + +class FilterByChangedFilesTests(unittest.TestCase): + def setUp(self): + # standard-language-names=true + self.main = load_main(["token", "http://example.invalid/languages", "", "", "true"]) + + def test_none_changed_files_returns_input_unmodified(self): + languages = ["python", "go"] + self.assertEqual(self.main.filter_by_changed_files(languages, None), languages) + + def test_filters_to_matching_language_only(self): + languages = ["python", "javascript-typescript", "go"] + result = self.main.filter_by_changed_files(languages, ["src/app.py", "README.md"]) + self.assertEqual(result, ["python"]) + + def test_actions_requires_workflows_directory(self): + self.assertEqual( + self.main.filter_by_changed_files(["actions"], ["config/settings.yaml"]), [] + ) + self.assertEqual( + self.main.filter_by_changed_files(["actions"], [".github/workflows/ci.yml"]), + ["actions"], + ) + + def test_case_insensitive_matching(self): + self.assertEqual(self.main.filter_by_changed_files(["go"], ["cmd/MAIN.GO"]), ["go"]) + + def test_legacy_javascript_matches_typescript_files(self): + # With standard-language-names off, "javascript" is the combined js/ts bucket. + main = load_main(["token", "http://example.invalid/languages"]) + self.assertEqual(main.filter_by_changed_files(["javascript"], ["src/app.ts"]), ["javascript"]) + + def test_legacy_java_matches_kotlin_files(self): + # With standard-language-names off, "java" is the combined java/kotlin bucket. + main = load_main(["token", "http://example.invalid/languages"]) + self.assertEqual(main.filter_by_changed_files(["java"], ["src/Main.kt"]), ["java"]) + + def test_no_matching_language_returns_empty_list(self): + self.assertEqual( + self.main.filter_by_changed_files(["python", "go"], ["docs/readme.md"]), [] + ) + + +if __name__ == "__main__": + unittest.main() From ce155469938978913de6c586f0f2c95c9dbfcf2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:35:25 +0000 Subject: [PATCH 3/5] Harden README example and document test running; verify with code_review/codeql_checker Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e5b377..768cd4f 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,10 @@ Example, using `git diff` to compute the changed files for a pull request: - name: Get changed files id: changed-files if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} run: | - echo "files=$(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}"...HEAD | tr '\n' ',')" >> "$GITHUB_OUTPUT" + echo "files=$(git diff --name-only "origin/$BASE_REF"...HEAD | tr '\n' ',')" >> "$GITHUB_OUTPUT" - name: Get languages from repo id: set-matrix @@ -233,6 +235,14 @@ If you want to run all languages **other than Swift** on a specific group of run runs-on: ${{ matrix.language == 'swift' && 'macos-latest' || fromJSON('{"group":"runner-group-name"}') }} ``` +## Development + +`main.py`'s logic (language mapping, excludes, build modes, and the changed-files filter) is covered by unit tests in `test_main.py`. Run them with: + +``` +python3 -m unittest test_main.py +``` + ## License This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE.md) for the full terms. From 7e7b64648b1cb5273ba290c6b94c742e89328ac4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:37:44 +0000 Subject: [PATCH 4/5] Address code-quality finding: explain empty except in parse_changed_files Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index c7eb5b2..8b92593 100644 --- a/main.py +++ b/main.py @@ -105,6 +105,7 @@ def parse_changed_files(raw): if isinstance(parsed, list): return [str(f).strip() for f in parsed if str(f).strip()] except ValueError: + # Not a JSON array (e.g. plain comma/newline/space separated list); fall through. pass return [f.strip() for f in raw.replace(",", "\n").split() if f.strip()] From 8561ba134d0b836169eaddc7fb6ab2377c0eac51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:09:40 +0000 Subject: [PATCH 5/5] Add ci.yml workflow to run tests on push and PR Co-authored-by: felickz <1760475+felickz@users.noreply.github.com> --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..35a174a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: python -m unittest test_main.py -v