Skip to content

Commit d614a77

Browse files
authored
Merge pull request #675 from apache/refactor/verify-action-build-modularize
Refactor verify-action-build into modular package with tests
2 parents 92c5789 + 2a3532b commit d614a77

36 files changed

Lines changed: 4903 additions & 3042 deletions

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ dependencies = [
2727

2828
[dependency-groups]
2929
dev = [
30+
"jsbeautifier>=1.15",
3031
"pytest",
32+
"requests>=2.31",
33+
"rich>=13.0",
3134
]
3235

3336
[tool.uv]

utils/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ dependencies = [
2828
]
2929

3030
[project.scripts]
31-
verify-action-build = "verify_action_build:main"
31+
verify-action-build = "verify_action_build.cli:main"

utils/tests/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
import pytest
20+
21+
from verify_action_build.action_ref import (
22+
parse_action_ref,
23+
extract_composite_uses,
24+
detect_action_type_from_yml,
25+
)
26+
27+
28+
class TestParseActionRef:
29+
def test_simple_ref(self):
30+
org, repo, sub, hash_ = parse_action_ref("dorny/test-reporter@abc123def456789012345678901234567890abcd")
31+
assert org == "dorny"
32+
assert repo == "test-reporter"
33+
assert sub == ""
34+
assert hash_ == "abc123def456789012345678901234567890abcd"
35+
36+
def test_monorepo_sub_path(self):
37+
org, repo, sub, hash_ = parse_action_ref("gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd")
38+
assert org == "gradle"
39+
assert repo == "actions"
40+
assert sub == "setup-gradle"
41+
assert hash_ == "abc123def456789012345678901234567890abcd"
42+
43+
def test_deep_sub_path(self):
44+
org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@deadbeef" * 1 + "org/repo/a/b/c@" + "a" * 40)
45+
# Reset: test clean
46+
org, repo, sub, hash_ = parse_action_ref("org/repo/a/b/c@" + "a" * 40)
47+
assert org == "org"
48+
assert repo == "repo"
49+
assert sub == "a/b/c"
50+
assert hash_ == "a" * 40
51+
52+
def test_missing_at_sign_exits(self):
53+
with pytest.raises(SystemExit):
54+
parse_action_ref("dorny/test-reporter")
55+
56+
def test_missing_org_repo_exits(self):
57+
with pytest.raises(SystemExit):
58+
parse_action_ref("singlepart@abc123")
59+
60+
61+
class TestExtractCompositeUses:
62+
def test_standard_action_ref(self):
63+
yml = """
64+
steps:
65+
- uses: actions/checkout@abc123def456789012345678901234567890abcd
66+
"""
67+
results = extract_composite_uses(yml)
68+
assert len(results) == 1
69+
assert results[0]["org"] == "actions"
70+
assert results[0]["repo"] == "checkout"
71+
assert results[0]["is_hash_pinned"] is True
72+
assert results[0]["is_local"] is False
73+
74+
def test_tag_ref_not_hash_pinned(self):
75+
yml = """
76+
steps:
77+
- uses: actions/checkout@v4
78+
"""
79+
results = extract_composite_uses(yml)
80+
assert len(results) == 1
81+
assert results[0]["is_hash_pinned"] is False
82+
assert results[0]["ref"] == "v4"
83+
84+
def test_local_action(self):
85+
yml = """
86+
steps:
87+
- uses: ./.github/actions/my-action
88+
"""
89+
results = extract_composite_uses(yml)
90+
assert len(results) == 1
91+
assert results[0]["is_local"] is True
92+
assert results[0]["raw"] == "./.github/actions/my-action"
93+
94+
def test_docker_reference(self):
95+
yml = """
96+
steps:
97+
- uses: docker://alpine:3.18
98+
"""
99+
results = extract_composite_uses(yml)
100+
assert len(results) == 1
101+
assert results[0].get("is_docker") is True
102+
103+
def test_monorepo_sub_action(self):
104+
yml = """
105+
steps:
106+
- uses: gradle/actions/setup-gradle@abc123def456789012345678901234567890abcd
107+
"""
108+
results = extract_composite_uses(yml)
109+
assert len(results) == 1
110+
assert results[0]["org"] == "gradle"
111+
assert results[0]["repo"] == "actions"
112+
assert results[0]["sub_path"] == "setup-gradle"
113+
114+
def test_comment_stripped(self):
115+
yml = """
116+
steps:
117+
- uses: actions/checkout@abc123def456789012345678901234567890abcd # v4
118+
"""
119+
results = extract_composite_uses(yml)
120+
assert len(results) == 1
121+
assert results[0]["ref"] == "abc123def456789012345678901234567890abcd"
122+
123+
def test_multiple_uses(self):
124+
yml = """
125+
steps:
126+
- uses: actions/checkout@abc123def456789012345678901234567890abcd
127+
- uses: actions/setup-node@def456789012345678901234567890abcd123456
128+
"""
129+
results = extract_composite_uses(yml)
130+
assert len(results) == 2
131+
132+
def test_no_uses(self):
133+
yml = """
134+
steps:
135+
- run: echo hello
136+
"""
137+
results = extract_composite_uses(yml)
138+
assert len(results) == 0
139+
140+
def test_quoted_uses(self):
141+
yml = """
142+
steps:
143+
- uses: 'actions/checkout@abc123def456789012345678901234567890abcd'
144+
"""
145+
results = extract_composite_uses(yml)
146+
assert len(results) == 1
147+
assert results[0]["org"] == "actions"
148+
149+
def test_line_numbers(self):
150+
yml = """line1
151+
line2
152+
- uses: actions/checkout@abc123def456789012345678901234567890abcd
153+
line4
154+
- uses: actions/setup-node@def456789012345678901234567890abcd123456
155+
"""
156+
results = extract_composite_uses(yml)
157+
assert results[0]["line_num"] == 3
158+
assert results[1]["line_num"] == 5
159+
160+
161+
class TestDetectActionTypeFromYml:
162+
def test_node20(self):
163+
yml = """
164+
name: Test
165+
runs:
166+
using: node20
167+
main: dist/index.js
168+
"""
169+
assert detect_action_type_from_yml(yml) == "node20"
170+
171+
def test_composite(self):
172+
yml = """
173+
name: Test
174+
runs:
175+
using: composite
176+
steps: []
177+
"""
178+
assert detect_action_type_from_yml(yml) == "composite"
179+
180+
def test_docker(self):
181+
yml = """
182+
name: Test
183+
runs:
184+
using: docker
185+
image: Dockerfile
186+
"""
187+
assert detect_action_type_from_yml(yml) == "docker"
188+
189+
def test_quoted(self):
190+
yml = """
191+
runs:
192+
using: 'node16'
193+
"""
194+
assert detect_action_type_from_yml(yml) == "node16"
195+
196+
def test_unknown_when_missing(self):
197+
yml = """
198+
name: Test
199+
"""
200+
assert detect_action_type_from_yml(yml) == "unknown"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
from pathlib import Path
20+
from unittest import mock
21+
22+
from verify_action_build.approved_actions import find_approved_versions
23+
24+
25+
SAMPLE_ACTIONS_YML = """\
26+
actions/checkout:
27+
abc123def456789012345678901234567890abcd:
28+
tag: v4.2.0
29+
expires_at: 2025-12-31
30+
keep: true
31+
def456789012345678901234567890abcd123456:
32+
tag: v4.1.0
33+
expires_at: 2025-06-30
34+
dorny/test-reporter:
35+
1111111111111111111111111111111111111111:
36+
tag: v1.0.0
37+
"""
38+
39+
40+
class TestFindApprovedVersions:
41+
def test_finds_all_versions(self, tmp_path):
42+
actions_file = tmp_path / "actions.yml"
43+
actions_file.write_text(SAMPLE_ACTIONS_YML)
44+
45+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
46+
result = find_approved_versions("actions", "checkout")
47+
48+
assert len(result) == 2
49+
assert result[0]["hash"] == "abc123def456789012345678901234567890abcd"
50+
assert result[0]["tag"] == "v4.2.0"
51+
assert result[0]["expires_at"] == "2025-12-31"
52+
assert result[0]["keep"] == "true"
53+
assert result[1]["hash"] == "def456789012345678901234567890abcd123456"
54+
assert result[1]["tag"] == "v4.1.0"
55+
56+
def test_finds_different_action(self, tmp_path):
57+
actions_file = tmp_path / "actions.yml"
58+
actions_file.write_text(SAMPLE_ACTIONS_YML)
59+
60+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
61+
result = find_approved_versions("dorny", "test-reporter")
62+
63+
assert len(result) == 1
64+
assert result[0]["hash"] == "1111111111111111111111111111111111111111"
65+
assert result[0]["tag"] == "v1.0.0"
66+
67+
def test_returns_empty_for_unknown_action(self, tmp_path):
68+
actions_file = tmp_path / "actions.yml"
69+
actions_file.write_text(SAMPLE_ACTIONS_YML)
70+
71+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
72+
result = find_approved_versions("unknown", "action")
73+
74+
assert result == []
75+
76+
def test_returns_empty_when_file_missing(self, tmp_path):
77+
missing_file = tmp_path / "nonexistent.yml"
78+
79+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", missing_file):
80+
result = find_approved_versions("actions", "checkout")
81+
82+
assert result == []
83+
84+
def test_handles_quoted_hashes(self, tmp_path):
85+
yml = """\
86+
actions/checkout:
87+
'abc123def456789012345678901234567890abcd':
88+
tag: v4
89+
"""
90+
actions_file = tmp_path / "actions.yml"
91+
actions_file.write_text(yml)
92+
93+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
94+
result = find_approved_versions("actions", "checkout")
95+
96+
assert len(result) == 1
97+
assert result[0]["hash"] == "abc123def456789012345678901234567890abcd"
98+
99+
def test_ignores_comments(self, tmp_path):
100+
yml = """\
101+
# This is a comment
102+
actions/checkout:
103+
abc123def456789012345678901234567890abcd:
104+
tag: v4
105+
"""
106+
actions_file = tmp_path / "actions.yml"
107+
actions_file.write_text(yml)
108+
109+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
110+
result = find_approved_versions("actions", "checkout")
111+
112+
assert len(result) == 1
113+
114+
def test_handles_missing_optional_fields(self, tmp_path):
115+
yml = """\
116+
actions/checkout:
117+
abc123def456789012345678901234567890abcd:
118+
tag: v4
119+
"""
120+
actions_file = tmp_path / "actions.yml"
121+
actions_file.write_text(yml)
122+
123+
with mock.patch("verify_action_build.approved_actions.ACTIONS_YML", actions_file):
124+
result = find_approved_versions("actions", "checkout")
125+
126+
assert len(result) == 1
127+
assert "expires_at" not in result[0]
128+
assert "keep" not in result[0]

0 commit comments

Comments
 (0)