import contextlib import io import sys import tempfile import unittest from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) from check_workflow_action_pins import iter_uses_references, main, validate_reference SHA = "0123456789abcdef0123456789abcdef01234567" class WorkflowActionPinsTest(unittest.TestCase): def test_accepts_local_and_full_sha_references(self) -> None: self.assertIsNone(validate_reference("./.github/actions/local")) self.assertIsNone(validate_reference(f"actions/checkout@{SHA}")) self.assertIsNone(validate_reference(f"owner/repository/sub/action@{SHA}")) def test_rejects_mutable_dynamic_and_malformed_references(self) -> None: references = [ "actions/checkout@v4", "owner/action@latest", "owner/action@main", "owner/action@0123456", "owner/action@${{ inputs.ref }}", "docker://alpine:3", "not-a-reference", ] for reference in references: with self.subTest(reference=reference): self.assertIsNotNone(validate_reference(reference)) def test_parses_mapping_locations_without_comments_or_block_text(self) -> None: fixture = f'''\ name: policy jobs: reusable: uses: "owner/workflows/.github/workflows/check.yml@{SHA}" # reviewed steps: runs-on: ubuntu-latest steps: # - uses: actions/checkout@v4 - uses: 'actions/checkout@{SHA}' # v4 - run: | echo "uses: owner/action@main" ''' with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) self.assertEqual( references, [ (4, f"owner/workflows/.github/workflows/check.yml@{SHA}"), (9, f"actions/checkout@{SHA}"), ], ) def test_rejects_mutable_flow_style_reference(self) -> None: fixture = f"""\ jobs: {{ pinned: {{ uses: actions/checkout@{SHA} }}, mutable: {{ uses: owner/action@main }} }} """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (1, f"actions/checkout@{SHA}"), (1, "owner/action@main"), ], ) self.assertEqual(status, 1) self.assertIn("owner/action@main", stderr.getvalue()) def test_rejects_quoted_and_escaped_block_style_keys(self) -> None: fixture = f"""\ jobs: test: steps: - "uses": owner/action@main - "us\\x65s": owner/other@latest - "us\\u0065s": actions/checkout@{SHA} - 'uses': actions/checkout@{SHA} """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (4, "owner/action@main"), (5, "owner/other@latest"), (6, f"actions/checkout@{SHA}"), (7, f"actions/checkout@{SHA}"), ], ) self.assertEqual(status, 1) self.assertIn("owner/action@main", stderr.getvalue()) self.assertIn("owner/other@latest", stderr.getvalue()) def test_parses_folded_and_literal_uses_scalars(self) -> None: fixture = f"""\ jobs: test: steps: - uses: >- owner/folded@main - uses: | owner/literal@latest - uses: >- actions/checkout@{SHA} - uses: |- actions/checkout@{SHA} """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (4, "owner/folded@main"), (6, "owner/literal@latest"), (8, f"actions/checkout@{SHA}"), (10, f"actions/checkout@{SHA}"), ], ) self.assertEqual(status, 1) self.assertIn("owner/folded@main", stderr.getvalue()) self.assertIn("owner/literal@latest", stderr.getvalue()) def test_parses_explicit_mapping_uses_keys(self) -> None: fixture = f"""\ jobs: test: steps: - ? uses : owner/explicit@main - ? "us\\x65s" : actions/checkout@{SHA} """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (4, "owner/explicit@main"), (6, f"actions/checkout@{SHA}"), ], ) self.assertEqual(status, 1) self.assertIn("owner/explicit@main", stderr.getvalue()) def test_parses_flow_sequence_mapping_pairs(self) -> None: fixture = ( f"steps: [uses: owner/sequence@main, uses: actions/checkout@{SHA}]\n" ) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (1, "owner/sequence@main"), (1, f"actions/checkout@{SHA}"), ], ) self.assertEqual(status, 1) self.assertIn("owner/sequence@main", stderr.getvalue()) def test_fails_closed_on_multiline_aliased_and_tagged_keys(self) -> None: fixture = """\ uses_key: &uses-key uses jobs: test: steps: - "u\\ ses": owner/multiline@main - *uses-key: owner/alias@main - !!str uses: owner/tagged@main """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) stderr = io.StringIO() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(stderr): status = main([str(path)]) self.assertEqual( references, [ (5, ""), (7, ""), (8, ""), ], ) self.assertEqual(status, 1) self.assertIn("workflow.yaml:5", stderr.getvalue()) self.assertIn("workflow.yaml:7", stderr.getvalue()) self.assertIn("workflow.yaml:8", stderr.getvalue()) def test_does_not_treat_github_expressions_as_yaml_flow_mappings(self) -> None: fixture = f"""\ if: ${{{{ always() && !contains(needs.*.result, 'failure') }}}} steps: - uses: actions/checkout@{SHA} """ with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) self.assertEqual(references, [(3, f"actions/checkout@{SHA}")]) def test_block_scalar_ends_at_inferred_content_indentation(self) -> None: fixture = '''\ jobs: steps: runs-on: ubuntu-latest steps: - name: | Multiline step name uses: actions/checkout@v4 ''' with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "workflow.yaml" path.write_text(fixture, encoding="utf-8") references = list(iter_uses_references(path)) self.assertEqual(references, [(7, "actions/checkout@v4")]) def test_cli_reports_all_violations_with_file_and_line(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) first = root / "first.yml" second = root / "second.yaml" first.write_text("steps:\n - uses: actions/checkout@v4\n", encoding="utf-8") second.write_text("jobs:\n call:\n uses: owner/workflow@main\n", encoding="utf-8") stderr = io.StringIO() with contextlib.redirect_stderr(stderr): status = main([str(first), str(second)]) self.assertEqual(status, 1) output = stderr.getvalue() self.assertIn("first.yml:2", output) self.assertIn("second.yaml:3", output) self.assertIn("actions/checkout@v4", output) self.assertIn("owner/workflow@main", output) if __name__ == "__main__": unittest.main()