Skip to content

Commit 06ad646

Browse files
authored
CI: prepare for CI testing (#1)
1 parent 1753a22 commit 06ad646

File tree

5 files changed

+271
-10
lines changed

5 files changed

+271
-10
lines changed

.github/workflows/test.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
12+
cancel-in-progress: true
13+
14+
permissions: {}
15+
16+
jobs:
17+
test:
18+
runs-on: ubuntu-latest
19+
20+
permissions:
21+
contents: read
22+
23+
steps:
24+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
25+
with:
26+
persist-credentials: false
27+
28+
- name: setup uv
29+
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
30+
31+
- run: make test

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ lint:
1111
fix:
1212
uvx ruff format
1313
uvx ruff check --fix
14+
15+
.PHONY: test
16+
test:
17+
uvx --with=requests --with-requirements=action.py pytest -s -o log_cli=true -o log_cli_level=DEBUG test.py

action.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
# ]
77
# ///
88

9+
import base64
910
import logging
1011
import os
1112
import shlex
13+
from glob import glob
1214
from pathlib import Path
1315

1416
from pypi_attestations import Attestation, Distribution
@@ -23,11 +25,11 @@ def _get_input(name: str) -> str | None:
2325
"""
2426
Get an action input from the environment, or `None` if not set.
2527
"""
26-
env = f"ATTEST_ACTION_INPUT_{name.upper()}"
28+
env = f"ATTEST_ACTION_INPUT_{name.upper().replace('-', '_')}"
2729
return os.getenv(env)
2830

2931

30-
def _get_path_patterns() -> list[str]:
32+
def _get_path_patterns() -> set[str]:
3133
"""
3234
Retrieve and normalize the 'paths' input.
3335
@@ -44,28 +46,29 @@ def _get_path_patterns() -> list[str]:
4446
raise RuntimeError("No paths provided in 'paths' input")
4547

4648
# Normalize `foo/` to `foo/*`
47-
paths = [str(Path(p) / "*") if Path(p).is_dir() else p for p in paths]
49+
paths = [str(Path(p) / "*") if p.endswith(("/", "\\")) else p for p in paths]
4850

49-
return paths
51+
return set(paths)
5052

5153

52-
def _unroll_files(patterns: list[str]) -> list[Path]:
54+
def _unroll_files(patterns: set[str]) -> set[Path]:
5355
"""
5456
Given one or more path patterns (which may include glob patterns), unroll and
5557
return all matching files.
5658
"""
5759

58-
files = []
60+
files = set()
5961

6062
for pattern in patterns:
61-
for path in Path().glob(pattern):
63+
for path in glob(pattern):
64+
path = Path(path)
6265
if path.is_file():
63-
files.append(path)
66+
files.add(path)
6467

6568
return files
6669

6770

68-
def _collect_dists(patterns: list[str]) -> list[tuple[Path, Distribution]]:
71+
def _collect_dists(patterns: set[str]) -> list[tuple[Path, Distribution]]:
6972
"""
7073
Given one or more path patterns (which may include glob patterns), collect and
7174
return all Python distributions found at those paths.
@@ -146,7 +149,12 @@ def main() -> None:
146149

147150
dists = _collect_dists(path_patterns)
148151

149-
id_token = _get_id_token()
152+
if id_token := _get_input("id-token"):
153+
id_token = base64.b64decode(id_token).decode("utf-8")
154+
id_token = oidc.IdentityToken(raw_token=id_token)
155+
else:
156+
id_token = _get_id_token()
157+
150158
overwrite = _get_input("overwrite") == "true"
151159

152160
_attest(dists, id_token, overwrite=overwrite)

action.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ inputs:
1111
description: Whether to overwrite existing attestations if they already exist.
1212
required: false
1313
default: "false"
14+
id-token:
15+
description: >
16+
An OIDC identity token to use for signing attestations. If not provided,
17+
the ambient token will be used.
18+
19+
**IMPORTANT**: This input is an implementation detail. End users should
20+
never need to set it.
21+
required: false
22+
default: ""
1423

1524
outputs: {}
1625

@@ -26,3 +35,5 @@ runs:
2635
shell: bash
2736
env:
2837
ATTEST_ACTION_INPUT_PATHS: ${{ inputs.paths }}
38+
ATTEST_ACTION_INPUT_OVERWRITE: ${{ inputs.overwrite }}
39+
ATTEST_ACTION_INPUT_ID_TOKEN: ${{ inputs.id-token }}

test.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import logging
2+
import subprocess
3+
import time
4+
from pathlib import Path
5+
6+
import pytest
7+
import requests
8+
from pypi_attestations import Attestation, GitHubPublisher
9+
from sigstore import oidc
10+
11+
import action
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
@pytest.fixture(scope="session")
17+
def id_token() -> oidc.IdentityToken:
18+
def _id_token() -> oidc.IdentityToken | None:
19+
# GitHub loves to cache things it has no business caching.
20+
result = subprocess.run(
21+
[
22+
"git",
23+
"ls-remote",
24+
"https://github.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon",
25+
"refs/heads/current-token",
26+
],
27+
capture_output=True,
28+
text=True,
29+
check=True,
30+
)
31+
ref = result.stdout.split()[0]
32+
33+
resp = requests.get(
34+
f"https://raw.githubusercontent.com/sigstore-conformance/extremely-dangerous-public-oidc-beacon/{ref}/oidc-token.txt",
35+
)
36+
resp.raise_for_status()
37+
id_token = resp.text.strip()
38+
try:
39+
return oidc.IdentityToken(id_token)
40+
except Exception:
41+
return None
42+
43+
# Try up to 10 times to get a valid token, waiting 3 seconds between attempts.
44+
for n in range(10):
45+
token = _id_token()
46+
if token is not None:
47+
return token
48+
else:
49+
logger.warning(f"Waiting for valid OIDC identity token, try {n}...")
50+
time.sleep(3)
51+
52+
raise RuntimeError("Failed to obtain OIDC identity token for tests")
53+
54+
55+
@pytest.fixture
56+
def sampleproject(tmp_path: Path) -> Path:
57+
"""
58+
Create a sample Python project with a distribution file.
59+
"""
60+
61+
project_dir = tmp_path / "sampleproject"
62+
project_dir.mkdir()
63+
64+
pyproject = project_dir / "pyproject.toml"
65+
pyproject.write_text("""
66+
name = "astral-sh-attest-action-test-sampleproject"
67+
version = "0.1.0"
68+
description = "Who's wants to know?"
69+
requires-python = ">=3.10"
70+
""")
71+
72+
hello_py = project_dir / "hello.py"
73+
hello_py.write_text("""
74+
def main():
75+
print("Hello, world!")
76+
""")
77+
78+
return project_dir
79+
80+
81+
def test_get_input(monkeypatch: pytest.MonkeyPatch) -> None:
82+
monkeypatch.setenv("ATTEST_ACTION_INPUT_FOO", "expected")
83+
84+
assert action._get_input("foo") == "expected"
85+
86+
87+
def test_get_path_patterns(monkeypatch: pytest.MonkeyPatch) -> None:
88+
monkeypatch.setenv("ATTEST_ACTION_INPUT_PATHS", "dist/* another/** third/")
89+
90+
patterns = action._get_path_patterns()
91+
assert patterns == {"dist/*", "another/**", "third/*"}
92+
93+
# Deduplicates patterns / files.
94+
monkeypatch.setenv("ATTEST_ACTION_INPUT_PATHS", "dist/* dist/* another/")
95+
patterns = action._get_path_patterns()
96+
assert patterns == {"dist/*", "another/*"}
97+
98+
monkeypatch.setenv("ATTEST_ACTION_INPUT_PATHS", "a a b b c")
99+
patterns = action._get_path_patterns()
100+
assert patterns == {"a", "b", "c"}
101+
102+
103+
def test_attest(sampleproject: Path, id_token: oidc.IdentityToken) -> None:
104+
subprocess.run(["uv", "build"], cwd=sampleproject, check=True)
105+
dist_dir = sampleproject / "dist"
106+
107+
patterns = {str(dist_dir / "*")}
108+
109+
dists = action._collect_dists(patterns)
110+
assert len(dists) == 2 # sdist and wheel
111+
112+
action._attest(
113+
dists,
114+
id_token,
115+
overwrite=False,
116+
)
117+
118+
for dist_path, _ in dists:
119+
attestation_path = dist_path.with_name(f"{dist_path.name}.publish.attestation")
120+
assert attestation_path.exists()
121+
122+
123+
def test_attest_overwrite_fails(
124+
sampleproject: Path,
125+
id_token: oidc.IdentityToken,
126+
) -> None:
127+
subprocess.run(["uv", "build"], cwd=sampleproject, check=True)
128+
dist_dir = sampleproject / "dist"
129+
130+
patterns = {str(dist_dir / "*")}
131+
132+
dists = action._collect_dists(patterns)
133+
assert len(dists) == 2 # sdist and wheel
134+
135+
action._attest(
136+
dists,
137+
id_token,
138+
overwrite=False,
139+
)
140+
141+
with pytest.raises(RuntimeError, match="Attestation file already exists"):
142+
action._attest(
143+
dists,
144+
id_token,
145+
overwrite=False,
146+
)
147+
148+
149+
def test_attest_overwrite_succeeds(
150+
sampleproject: Path,
151+
id_token: oidc.IdentityToken,
152+
) -> None:
153+
subprocess.run(["uv", "build"], cwd=sampleproject, check=True)
154+
dist_dir = sampleproject / "dist"
155+
156+
patterns = {str(dist_dir / "*")}
157+
158+
dists = action._collect_dists(patterns)
159+
assert len(dists) == 2 # sdist and wheel
160+
161+
action._attest(
162+
dists,
163+
id_token,
164+
overwrite=False,
165+
)
166+
167+
# This should succeed without error.
168+
action._attest(
169+
dists,
170+
id_token,
171+
overwrite=True,
172+
)
173+
174+
175+
def test_attest_verify(
176+
sampleproject: Path,
177+
id_token: oidc.IdentityToken,
178+
) -> None:
179+
subprocess.run(["uv", "build"], cwd=sampleproject, check=True)
180+
dist_dir = sampleproject / "dist"
181+
182+
patterns = {str(dist_dir / "*")}
183+
184+
dists = action._collect_dists(patterns)
185+
assert len(dists) == 2 # sdist and wheel
186+
187+
action._attest(
188+
dists,
189+
id_token,
190+
overwrite=False,
191+
)
192+
193+
for dist_path, dist in dists:
194+
attestation_path = dist_path.with_name(f"{dist_path.name}.publish.attestation")
195+
assert attestation_path.exists()
196+
197+
attestation = Attestation.model_validate_json(attestation_path.read_bytes())
198+
identity = GitHubPublisher(
199+
repository="sigstore-conformance/extremely-dangerous-public-oidc-beacon",
200+
workflow="extremely-dangerous-oidc-beacon.yml",
201+
)
202+
203+
attestation.verify(
204+
identity=identity,
205+
dist=dist,
206+
offline=True,
207+
)

0 commit comments

Comments
 (0)