Skip to content
Draft
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,50 @@ 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'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
echo "files=$(git diff --name-only "origin/$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.
Expand Down Expand Up @@ -191,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.
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -33,4 +36,5 @@ runs:
- ${{ inputs.exclude }}
- ${{ inputs.build-mode-manual-override }}
- ${{ inputs.standard-language-names }}
- ${{ inputs.changed-files }}

2 changes: 1 addition & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh -l

# kick off the command
python /main.py $1 $2 "$3" "$4" "$5"
python /main.py $1 $2 "$3" "$4" "$5" "$6"
69 changes: 69 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,38 @@
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.
use_standard_language_names = standard_language_names.strip().lower() in ("true", "1", "yes")
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():
Expand Down Expand Up @@ -66,6 +91,48 @@ 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:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# 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()]

# 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
Expand Down Expand Up @@ -123,6 +190,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
Expand Down
86 changes: 86 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
@@ -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()