TirSystem/github-action: Publish wiki / publish-wiki (push) Skipped
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Skipped
Validate workflows / validate (pull_request) Successful in 15s
Task: MIL-004#5
185 lines
5.8 KiB
Python
185 lines
5.8 KiB
Python
"""Offline tests for tools/release.py and .gitea/workflows/release.yml."""
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from workflow_source import REPO_ROOT, load_script
|
|
|
|
release = load_script(REPO_ROOT / "tools" / "release.py", "release")
|
|
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
|
|
|
SHA = "a" * 40
|
|
CONTEXT = "Validate workflows / validate (push)"
|
|
|
|
CHANGELOG = """# Changelog
|
|
|
|
## [Unreleased]
|
|
|
|
### Added
|
|
|
|
- Something later.
|
|
|
|
## [1.1.0] - 2026-11-01
|
|
|
|
### Added
|
|
|
|
- A workflow.
|
|
|
|
## [1.0.0] - 2026-10-20
|
|
|
|
### Added
|
|
|
|
- First release.
|
|
"""
|
|
|
|
|
|
class FakeGitea:
|
|
"""Answers the calls of the release script and records the writes."""
|
|
|
|
def __init__(self, tip=SHA, status="success", tags=(), context=CONTEXT):
|
|
self.tip = tip
|
|
self.status = status
|
|
self.tags = list(tags)
|
|
self.context = context
|
|
self.writes = []
|
|
|
|
def __call__(self, url, method="GET", headers=None, body=None):
|
|
path = url.split("?")[0]
|
|
if method != "GET":
|
|
self.writes.append((method, path.rsplit("/repos/o/r", 1)[-1], body))
|
|
return None
|
|
if path.endswith("/branches/main"):
|
|
return {"commit": {"id": self.tip}}
|
|
if path.endswith(f"/commits/{SHA}/status"):
|
|
if self.status is None:
|
|
return {"statuses": []}
|
|
return {"statuses": [{"context": self.context, "status": self.status}]}
|
|
if path.endswith("/tags"):
|
|
return [{"name": name} for name in self.tags] if "page=1" in url else []
|
|
raise AssertionError(f"unexpected request {method} {url}")
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path, monkeypatch):
|
|
changelog = tmp_path / "CHANGELOG.md"
|
|
changelog.write_text(CHANGELOG, encoding="utf-8")
|
|
values = {
|
|
"GITEA_API_URL": "https://git.example/api/v1",
|
|
"SOURCE_REPOSITORY": "o/r",
|
|
"RELEASE_CREDENTIALS": "t" * 40,
|
|
"RELEASE_VERSION": "1.1.0",
|
|
"RELEASE_SHA": SHA,
|
|
"RELEASE_DRY_RUN": "false",
|
|
"CHANGELOG_FILE": str(changelog),
|
|
}
|
|
for key, value in values.items():
|
|
monkeypatch.setenv(key, value)
|
|
monkeypatch.delenv("RELEASE_BRANCH", raising=False)
|
|
monkeypatch.delenv("VALIDATION_CONTEXT", raising=False)
|
|
return monkeypatch
|
|
|
|
|
|
def run(gitea):
|
|
return release.main(request=gitea)
|
|
|
|
|
|
def test_green_commit_with_entry_creates_release_and_major_tag(env):
|
|
gitea = FakeGitea(tags=["v1.0.0", "v1"])
|
|
assert run(gitea) == 0
|
|
methods = [(m, p) for m, p, _ in gitea.writes]
|
|
assert methods == [("POST", "/releases"), ("DELETE", "/tags/v1"), ("POST", "/tags")]
|
|
release_body = gitea.writes[0][2]
|
|
assert release_body["tag_name"] == "v1.1.0"
|
|
assert release_body["target_commitish"] == SHA
|
|
assert "### Added" in release_body["body"] and "A workflow." in release_body["body"]
|
|
assert gitea.writes[2][2]["tag_name"] == "v1" and gitea.writes[2][2]["target"] == SHA
|
|
|
|
|
|
def test_first_release_creates_the_major_tag_without_deleting(env):
|
|
env.setenv("RELEASE_VERSION", "1.0.0")
|
|
gitea = FakeGitea(tags=[])
|
|
assert run(gitea) == 0
|
|
assert [(m, p) for m, p, _ in gitea.writes] == [("POST", "/releases"), ("POST", "/tags")]
|
|
|
|
|
|
def test_failed_validation_run_creates_no_tag(env):
|
|
gitea = FakeGitea(status="failure")
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(gitea)
|
|
assert error.value.category == "validation"
|
|
assert gitea.writes == []
|
|
|
|
|
|
def test_missing_validation_run_creates_no_tag(env):
|
|
gitea = FakeGitea(status=None)
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(gitea)
|
|
assert "No validation run" in str(error.value)
|
|
assert gitea.writes == []
|
|
|
|
|
|
def test_commit_that_is_not_the_tip_is_refused(env):
|
|
gitea = FakeGitea(tip="b" * 40)
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(gitea)
|
|
assert "not the tip" in str(error.value)
|
|
assert gitea.writes == []
|
|
|
|
|
|
def test_version_without_changelog_entry_is_refused(env):
|
|
env.setenv("RELEASE_VERSION", "1.2.0")
|
|
gitea = FakeGitea()
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(gitea)
|
|
assert error.value.category == "validation"
|
|
assert gitea.writes == []
|
|
|
|
|
|
def test_existing_tag_is_never_moved(env):
|
|
gitea = FakeGitea(tags=["v1.1.0", "v1.0.0", "v1"])
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(gitea)
|
|
assert "already exists" in str(error.value)
|
|
assert gitea.writes == []
|
|
|
|
|
|
def test_older_patch_does_not_move_the_major_tag(env):
|
|
env.setenv("RELEASE_VERSION", "1.0.0")
|
|
gitea = FakeGitea(tags=["v1.1.0", "v1"])
|
|
assert run(gitea) == 0
|
|
assert [(m, p) for m, p, _ in gitea.writes] == [("POST", "/releases")]
|
|
|
|
|
|
def test_dry_run_is_the_default_and_writes_nothing(env, capsys):
|
|
env.delenv("RELEASE_DRY_RUN")
|
|
gitea = FakeGitea(tags=["v1.0.0", "v1"])
|
|
assert run(gitea) == 0
|
|
assert gitea.writes == []
|
|
assert "Dry run" in capsys.readouterr().out
|
|
|
|
|
|
@pytest.mark.parametrize("version", ["v1.0.0", "1.0", "1.0.0-rc1", ""])
|
|
def test_malformed_version_is_a_configuration_error(env, version):
|
|
env.setenv("RELEASE_VERSION", version)
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(FakeGitea())
|
|
assert error.value.category == "configuration"
|
|
|
|
|
|
def test_missing_token_is_a_configuration_error(env):
|
|
env.setenv("RELEASE_CREDENTIALS", " ")
|
|
with pytest.raises(release.WorkflowError) as error:
|
|
run(FakeGitea())
|
|
assert error.value.category == "configuration"
|
|
|
|
|
|
def test_workflow_runs_the_file_and_defaults_to_a_dry_run():
|
|
data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
|
triggers = data.get("on", data.get(True))
|
|
inputs = triggers["workflow_dispatch"]["inputs"]
|
|
assert inputs["dry_run"]["default"] == "true"
|
|
job = data["jobs"]["release"]
|
|
assert job["permissions"] == {"contents": "write"}
|
|
runs = [step.get("run", "") for step in job["steps"]]
|
|
assert "python3 tools/release.py" in runs
|