From 2d0e934020b2b6617ba0e9a815b829cd669ff1e1 Mon Sep 17 00:00:00 2001 From: Venu Vardhan Reddy Tekula Date: Sun, 25 Jan 2026 15:08:54 -0500 Subject: [PATCH 1/7] feat: add OUTPUT_FILENAME to customize markdown output Expose OUTPUT_FILENAME env var with default contributors.md, wire into markdown output, and document/test it. Signed-off-by: Venu Vardhan Reddy Tekula --- README.md | 3 ++- contributors.py | 3 ++- env.py | 4 ++++ test_env.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46b9cb3..4bf12aa 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe | `END_DATE` | False | Current Date | The date at which you want to stop gathering contributor information. Must be later than the `START_DATE`. ie. Aug 2nd, 2023 would be `2023-08-02` | | `SPONSOR_INFO` | False | False | If you want to include sponsor information in the output. This will include the sponsor count and the sponsor URL. This will impact action performance. ie. SPONSOR_INFO = "False" or SPONSOR_INFO = "True" | | `LINK_TO_PROFILE` | False | True | If you want to link usernames to their GitHub profiles in the output. ie. LINK_TO_PROFILE = "True" or LINK_TO_PROFILE = "False" | +| `OUTPUT_FILENAME` | False | contributors.md | The output filename for the markdown report. ie. OUTPUT_FILENAME = "my-report.md" | **Note**: If `start_date` and `end_date` are specified then the action will determine if the contributor is new. A new contributor is one that has contributed in the date range specified but not before the start date. @@ -244,7 +245,7 @@ jobs: When running as a GitHub Action, the contributors report is automatically displayed in the [GitHub Actions Job Summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary). This provides immediate visibility of the results directly in the workflow run interface without needing to check separate files or issues. -The job summary contains the same markdown content that is written to the `contributors.md` file, making it easy to view contributor information right in the GitHub Actions UI. +The job summary contains the same markdown content that is written to the configured output file, making it easy to view contributor information right in the GitHub Actions UI. ## Local usage without Docker diff --git a/contributors.py b/contributors.py index 96cdd86..3a027c8 100644 --- a/contributors.py +++ b/contributors.py @@ -27,6 +27,7 @@ def main(): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() # Auth to GitHub.com @@ -75,7 +76,7 @@ def main(): # print(contributors) markdown.write_to_markdown( contributors, - "contributors.md", + output_filename, start_date, end_date, organization, diff --git a/env.py b/env.py index 486f536..935d5d3 100644 --- a/env.py +++ b/env.py @@ -115,6 +115,7 @@ def get_env_vars( str, bool, bool, + str, ]: """ Get the environment variables for use in the action. @@ -135,6 +136,7 @@ def get_env_vars( end_date (str): The end date to get contributor information to. sponsor_info (str): Whether to get sponsor information on the contributor link_to_profile (str): Whether to link username to Github profile in markdown output + output_filename (str): The output filename for the markdown report """ if not test: @@ -176,6 +178,7 @@ def get_env_vars( sponsor_info = get_bool_env_var("SPONSOR_INFO", False) link_to_profile = get_bool_env_var("LINK_TO_PROFILE", False) + output_filename = os.getenv("OUTPUT_FILENAME", "").strip() or "contributors.md" # Separate repositories_str into a list based on the comma separator repositories_list = [] @@ -197,4 +200,5 @@ def get_env_vars( end_date, sponsor_info, link_to_profile, + output_filename, ) diff --git a/test_env.py b/test_env.py index fb1734d..bd35622 100644 --- a/test_env.py +++ b/test_env.py @@ -23,6 +23,7 @@ def setUp(self): "GITHUB_APP_ENTERPRISE_ONLY", "GH_TOKEN", "ORGANIZATION", + "OUTPUT_FILENAME", "REPOSITORY", "START_DATE", ] @@ -65,6 +66,7 @@ def test_get_env_vars(self): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -79,6 +81,7 @@ def test_get_env_vars(self): self.assertEqual(end_date, "2022-12-31") self.assertFalse(sponsor_info) self.assertTrue(link_to_profile) + self.assertEqual(output_filename, "contributors.md") @patch.dict( os.environ, @@ -175,6 +178,7 @@ def test_get_env_vars_no_dates(self): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -189,6 +193,45 @@ def test_get_env_vars_no_dates(self): self.assertEqual(end_date, "") self.assertFalse(sponsor_info) self.assertTrue(link_to_profile) + self.assertEqual(output_filename, "contributors.md") + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "REPOSITORY": "repo,repo2", + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "token", + "GH_ENTERPRISE_URL": "", + "START_DATE": "", + "END_DATE": "", + "SPONSOR_INFO": "False", + "LINK_TO_PROFILE": "True", + "OUTPUT_FILENAME": "custom-report.md", + }, + clear=True, + ) + def test_get_env_vars_custom_output_filename(self): + """Test that OUTPUT_FILENAME overrides the default output filename.""" + ( + _organization, + _repository_list, + _gh_app_id, + _gh_app_installation_id, + _gh_app_private_key, + _gh_app_enterprise_only, + _token, + _ghe, + _start_date, + _end_date, + _sponsor_info, + _link_to_profile, + output_filename, + ) = env.get_env_vars() + + self.assertEqual(output_filename, "custom-report.md") @patch.dict(os.environ, {}) def test_get_env_vars_missing_org_or_repo(self): From 485a4c3636e2d2f4e155c9e39ab126072deb2c39 Mon Sep 17 00:00:00 2001 From: Venu Vardhan Reddy Tekula Date: Sun, 25 Jan 2026 15:36:38 -0500 Subject: [PATCH 2/7] docs: align workflow examples with OUTPUT_FILENAME Use a job-level OUTPUT_FILENAME and reference it in content-filepath so examples stay in sync. Signed-off-by: Venu Vardhan Reddy Tekula --- README.md | 8 ++++++-- env.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4bf12aa..9648edd 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + env: + OUTPUT_FILENAME: contributors.md steps: - name: Get dates for last month @@ -148,7 +150,7 @@ jobs: with: title: Monthly contributor report token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./contributors.md + content-filepath: ./${{ env.OUTPUT_FILENAME }} assignees: ``` @@ -170,6 +172,8 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + env: + OUTPUT_FILENAME: contributors.md steps: - name: Get dates for last month @@ -204,7 +208,7 @@ jobs: with: title: Monthly contributor report token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./contributors.md + content-filepath: ./${{ env.OUTPUT_FILENAME }} assignees: ``` diff --git a/env.py b/env.py index 935d5d3..26f3f3e 100644 --- a/env.py +++ b/env.py @@ -133,7 +133,7 @@ def get_env_vars( token (str): The GitHub token to use for authentication ghe (str): The GitHub Enterprise URL to use for authentication start_date (str): The start date to get contributor information from - end_date (str): The end date to get contributor information to. + end_date (str): The end date to get contributor information to sponsor_info (str): Whether to get sponsor information on the contributor link_to_profile (str): Whether to link username to Github profile in markdown output output_filename (str): The output filename for the markdown report From ea7eb4028d3f7cc382432ff9491f945a6e80cdae Mon Sep 17 00:00:00 2001 From: Venu Vardhan Reddy Tekula Date: Tue, 3 Feb 2026 22:42:40 -0500 Subject: [PATCH 3/7] docs: clarify that job summary output file is configurable Co-authored-by: Zack Koppert Signed-off-by: Venu Vardhan Reddy Tekula --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9648edd..5a1cc8d 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ jobs: When running as a GitHub Action, the contributors report is automatically displayed in the [GitHub Actions Job Summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary). This provides immediate visibility of the results directly in the workflow run interface without needing to check separate files or issues. -The job summary contains the same markdown content that is written to the configured output file, making it easy to view contributor information right in the GitHub Actions UI. +The job summary contains the same markdown content that is written to the configured output file (`contributors.md` by default), making it easy to view contributor information right in the GitHub Actions UI. ## Local usage without Docker From dd8381de2b9455cb63b8cd51ab98fde5f5ab5659 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Fri, 20 Feb 2026 17:10:44 -0800 Subject: [PATCH 4/7] fix: update test_env.py for OUTPUT_FILENAME return value and .env leak - Add missing _output_filename to tuple unpacking in test_get_env_vars_valid_date_range - Fix test_get_env_vars_missing_org_or_repo to use clear=True and test=True so local .env files don't interfere Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test_env.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test_env.py b/test_env.py index bd35622..1acca8e 100644 --- a/test_env.py +++ b/test_env.py @@ -233,11 +233,11 @@ def test_get_env_vars_custom_output_filename(self): self.assertEqual(output_filename, "custom-report.md") - @patch.dict(os.environ, {}) + @patch.dict(os.environ, {}, clear=True) def test_get_env_vars_missing_org_or_repo(self): """Test that an error is raised if required environment variables are not set""" with self.assertRaises(ValueError) as cm: - env.get_env_vars() + env.get_env_vars(test=True) the_exception = cm.exception self.assertEqual( str(the_exception), @@ -333,6 +333,7 @@ def test_get_env_vars_valid_date_range(self): end_date, _sponsor_info, _link_to_profile, + _output_filename, ) = env.get_env_vars() self.assertEqual(start_date, "2024-01-01") self.assertEqual(end_date, "2025-01-01") From e60a9057390dad36a1f59bfee8c4f53a627721b5 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Fri, 20 Feb 2026 17:14:29 -0800 Subject: [PATCH 5/7] fix: add output_filename to mock return values in test_contributors.py Add the 13th return value ('contributors.md') to all mocked get_env_vars tuples so tests match the updated function signature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test_contributors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test_contributors.py b/test_contributors.py index eb556f0..0403516 100644 --- a/test_contributors.py +++ b/test_contributors.py @@ -272,6 +272,7 @@ def test_main_runs_under_main_guard(self): "2022-12-31", False, False, + "contributors.md", ) mock_auth = MagicMock() @@ -344,6 +345,7 @@ def test_main_sets_new_contributor_flag(self): "2022-12-31", False, False, + "contributors.md", ) mock_auth_to_github.return_value = MagicMock() mock_get_all_contributors.side_effect = [[contributor], []] @@ -398,6 +400,7 @@ def test_main_fetches_sponsor_info_when_enabled(self): "", "true", False, + "contributors.md", ) mock_auth_to_github.return_value = MagicMock() mock_get_all_contributors.return_value = [contributor] From 784fca0b667b4abb934821acba18a84e831a10b7 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Fri, 20 Feb 2026 17:30:24 -0800 Subject: [PATCH 6/7] fix: validate OUTPUT_FILENAME to prevent path traversal and unsafe characters Reject filenames containing path separators, special characters, or absolute paths. Only alphanumeric characters, hyphens, underscores, and dots are allowed. Adds four security tests covering path traversal, absolute paths, directory separators, and shell metacharacters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- env.py | 10 ++++++++++ test_env.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/env.py b/env.py index 26f3f3e..c66b75f 100644 --- a/env.py +++ b/env.py @@ -5,6 +5,7 @@ import datetime import os +import re from os.path import dirname, join from dotenv import load_dotenv @@ -179,6 +180,15 @@ def get_env_vars( sponsor_info = get_bool_env_var("SPONSOR_INFO", False) link_to_profile = get_bool_env_var("LINK_TO_PROFILE", False) output_filename = os.getenv("OUTPUT_FILENAME", "").strip() or "contributors.md" + if not re.match(r"^[a-zA-Z0-9_\-\.]+$", output_filename): + raise ValueError( + "OUTPUT_FILENAME must contain only alphanumeric characters, " + "hyphens, underscores, and dots" + ) + if output_filename != os.path.basename(output_filename): + raise ValueError( + "OUTPUT_FILENAME must be a simple filename without path separators" + ) # Separate repositories_str into a list based on the comma separator repositories_list = [] diff --git a/test_env.py b/test_env.py index e86271e..f9eabd0 100644 --- a/test_env.py +++ b/test_env.py @@ -233,6 +233,62 @@ def test_get_env_vars_custom_output_filename(self): self.assertEqual(output_filename, "custom-report.md") + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "../../../etc/passwd", + }, + clear=True, + ) + def test_get_env_vars_output_filename_path_traversal_rejected(self): + """Test that OUTPUT_FILENAME rejects path traversal attempts.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "/tmp/output.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_absolute_path_rejected(self): + """Test that OUTPUT_FILENAME rejects absolute paths.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "reports/output.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_directory_separator_rejected(self): + """Test that OUTPUT_FILENAME rejects filenames with directory separators.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "file;rm -rf /.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_special_chars_rejected(self): + """Test that OUTPUT_FILENAME rejects filenames with special characters.""" + with self.assertRaises(ValueError): + env.get_env_vars() + @patch.dict(os.environ, {}, clear=True) def test_get_env_vars_missing_org_or_repo(self): """Test that an error is raised if required environment variables are not set""" From 06203fe7ae0ec1de799a268de9a472c37a899740 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Fri, 20 Feb 2026 17:38:02 -0800 Subject: [PATCH 7/7] chore: retrigger CI checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>