Skip to content
Open
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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ dependencies = [

[dependency-groups]
dev = [
"jsbeautifier>=1.15",
"pytest",
"requests>=2.31",
"rich>=13.0",
]

[tool.uv]
Expand Down
2 changes: 1 addition & 1 deletion utils/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ dependencies = [
]

[project.scripts]
verify-action-build = "verify_action_build:main"
verify-action-build = "verify_action_build.cli:main"
18 changes: 18 additions & 0 deletions utils/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
18 changes: 18 additions & 0 deletions utils/tests/verify_action_build/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
200 changes: 200 additions & 0 deletions utils/tests/verify_action_build/test_action_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
import pytest

from verify_action_build.action_ref import (
parse_action_ref,
extract_composite_uses,
detect_action_type_from_yml,
)


class TestParseActionRef:
def test_simple_ref(self):
org, repo, sub, hash_ = parse_action_ref("dorny/test-reporter@abc123def456789012345678901234567890abcd")
assert org == "dorny"
assert repo == "test-reporter"
assert sub == ""
assert hash_ == "abc123def456789012345678901234567890abcd"

def test_monorepo_sub_path(self):
org, repo, sub, hash_ = parse_action_ref("gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd")
assert org == "gradle"
assert repo == "actions"
assert sub == "setup-gradle"
assert hash_ == "abc123def456789012345678901234567890abcd"

def test_deep_sub_path(self):
org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@deadbeef" * 1 + "org/repo/a/b/c@" + "a" * 40)
# Reset: test clean
org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@" + "a" * 40)
assert org == "org"
assert repo == "repo"
assert sub == "a/b/c"
assert hash_ == "a" * 40

def test_missing_at_sign_exits(self):
with pytest.raises(SystemExit):
parse_action_ref("dorny/test-reporter")

def test_missing_org_repo_exits(self):
with pytest.raises(SystemExit):
parse_action_ref("singlepart@abc123")


class TestExtractCompositeUses:
def test_standard_action_ref(self):
yml = """
steps:
- uses: actions/checkout@abc123def456789012345678901234567890abcd
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["org"] == "actions"
assert results[0]["repo"] == "checkout"
assert results[0]["is_hash_pinned"] is True
assert results[0]["is_local"] is False

def test_tag_ref_not_hash_pinned(self):
yml = """
steps:
- uses: actions/checkout@v4
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["is_hash_pinned"] is False
assert results[0]["ref"] == "v4"

def test_local_action(self):
yml = """
steps:
- uses: ./.github/actions/my-action
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["is_local"] is True
assert results[0]["raw"] == "./.github/actions/my-action"

def test_docker_reference(self):
yml = """
steps:
- uses: docker://alpine:3.18
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0].get("is_docker") is True

def test_monorepo_sub_action(self):
yml = """
steps:
- uses: gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["org"] == "gradle"
assert results[0]["repo"] == "actions"
assert results[0]["sub_path"] == "setup-gradle"

def test_comment_stripped(self):
yml = """
steps:
- uses: actions/checkout@abc123def456789012345678901234567890abcd # v4
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["ref"] == "abc123def456789012345678901234567890abcd"

def test_multiple_uses(self):
yml = """
steps:
- uses: actions/checkout@abc123def456789012345678901234567890abcd
- uses: actions/setup-node@def456789012345678901234567890abcd123456
"""
results = extract_composite_uses(yml)
assert len(results) == 2

def test_no_uses(self):
yml = """
steps:
- run: echo hello
"""
results = extract_composite_uses(yml)
assert len(results) == 0

def test_quoted_uses(self):
yml = """
steps:
- uses: 'actions/checkout@abc123def456789012345678901234567890abcd'
"""
results = extract_composite_uses(yml)
assert len(results) == 1
assert results[0]["org"] == "actions"

def test_line_numbers(self):
yml = """line1
line2
- uses: actions/checkout@abc123def456789012345678901234567890abcd
line4
- uses: actions/setup-node@def456789012345678901234567890abcd123456
"""
results = extract_composite_uses(yml)
assert results[0]["line_num"] == 3
assert results[1]["line_num"] == 5


class TestDetectActionTypeFromYml:
def test_node20(self):
yml = """
name: Test
runs:
using: node20
main: dist/index.js
"""
assert detect_action_type_from_yml(yml) == "node20"

def test_composite(self):
yml = """
name: Test
runs:
using: composite
steps: []
"""
assert detect_action_type_from_yml(yml) == "composite"

def test_docker(self):
yml = """
name: Test
runs:
using: docker
image: Dockerfile
"""
assert detect_action_type_from_yml(yml) == "docker"

def test_quoted(self):
yml = """
runs:
using: 'node16'
"""
assert detect_action_type_from_yml(yml) == "node16"

def test_unknown_when_missing(self):
yml = """
name: Test
"""
assert detect_action_type_from_yml(yml) == "unknown"
128 changes: 128 additions & 0 deletions utils/tests/verify_action_build/test_approved_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
from pathlib import Path
from unittest import mock

from verify_action_build.approved_actions import find_approved_versions


SAMPLE_ACTIONS_YML = """\
actions/checkout:
abc123def456789012345678901234567890abcd:
tag: v4.2.0
expires_at: 2025-12-31
keep: true
def456789012345678901234567890abcd123456:
tag: v4.1.0
expires_at: 2025-06-30
dorny/test-reporter:
1111111111111111111111111111111111111111:
tag: v1.0.0
"""


class TestFindApprovedVersions:
def test_finds_all_versions(self, tmp_path):
actions_file = tmp_path / "actions.yml"
actions_file.write_text(SAMPLE_ACTIONS_YML)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("actions", "checkout")

assert len(result) == 2
assert result[0]["hash"] == "abc123def456789012345678901234567890abcd"
assert result[0]["tag"] == "v4.2.0"
assert result[0]["expires_at"] == "2025-12-31"
assert result[0]["keep"] == "true"
assert result[1]["hash"] == "def456789012345678901234567890abcd123456"
assert result[1]["tag"] == "v4.1.0"

def test_finds_different_action(self, tmp_path):
actions_file = tmp_path / "actions.yml"
actions_file.write_text(SAMPLE_ACTIONS_YML)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("dorny", "test-reporter")

assert len(result) == 1
assert result[0]["hash"] == "1111111111111111111111111111111111111111"
assert result[0]["tag"] == "v1.0.0"

def test_returns_empty_for_unknown_action(self, tmp_path):
actions_file = tmp_path / "actions.yml"
actions_file.write_text(SAMPLE_ACTIONS_YML)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("unknown", "action")

assert result == []

def test_returns_empty_when_file_missing(self, tmp_path):
missing_file = tmp_path / "nonexistent.yml"

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", missing_file):
result = find_approved_versions("actions", "checkout")

assert result == []

def test_handles_quoted_hashes(self, tmp_path):
yml = """\
actions/checkout:
'abc123def456789012345678901234567890abcd':
tag: v4
"""
actions_file = tmp_path / "actions.yml"
actions_file.write_text(yml)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("actions", "checkout")

assert len(result) == 1
assert result[0]["hash"] == "abc123def456789012345678901234567890abcd"

def test_ignores_comments(self, tmp_path):
yml = """\
# This is a comment
actions/checkout:
abc123def456789012345678901234567890abcd:
tag: v4
"""
actions_file = tmp_path / "actions.yml"
actions_file.write_text(yml)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("actions", "checkout")

assert len(result) == 1

def test_handles_missing_optional_fields(self, tmp_path):
yml = """\
actions/checkout:
abc123def456789012345678901234567890abcd:
tag: v4
"""
actions_file = tmp_path / "actions.yml"
actions_file.write_text(yml)

with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
result = find_approved_versions("actions", "checkout")

assert len(result) == 1
assert "expires_at" not in result[0]
assert "keep" not in result[0]
Loading
Loading