Files
plezy/scripts/test_check_workflow_action_pins.py

290 lines
9.9 KiB
Python

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, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
(7, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
(8, "<unsupported multiline, tagged, anchored, or aliased mapping key>"),
],
)
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()