scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
290 lines
9.9 KiB
Python
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()
|