# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Run the exact command of each release step against a mocked GitHub.
A step gated on a release commit runs on no ordinary push, so no CI run between
two releases reaches it: a wrong argument would only surface as a failed
release. Each test here reads the step's `run:` line out of the workflow file,
expands its environment variables with plausible values, and invokes the
command that line holds. The arguments a release passes are the ones these
tests run.
"""
from __future__ import annotations
import json
import re
import shlex
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from click_extra.testing import CliRunner
from repomatic.cli.main import repomatic
from repomatic.github.gh import GhCommandError
from repomatic.github.pr import PrOperation, PrSyncResult
from repomatic.github.pr_body import PrContent
from repomatic.github.threads import BOT_ISSUE_LABEL
from repomatic.metadata.core import Metadata
from repomatic.upstream import DEFAULT_REPO
from tests.conftest import load_workflow
from tests.test_attestation import write_bundle
from tests.test_git_ops import _run_git, _seed_commit
REPO_ROOT = Path(__file__).parent.parent
REPOSITORY = "kdeldycke/papaya"
"""The repository the steps run in."""
VERSION = "1.2.3"
"""The version the release publishes."""
COMMIT = "6f27db47612aaee06fdf08744b09a9f5f6c26a1b"
"""The release commit."""
CLI_MARK = "-- repomatic "
"""What separates the command that starts the CLI from the arguments it gets."""
VARIABLE_RE = re.compile(r"\$\{(\w+)\}|\$(\w+)")
"""A shell variable, braced or bare."""
PYPROJECT = f"""\
[project]
name = "papaya"
version = "{VERSION}"
[tool.repomatic]
release-assets = ["papaya.zip", "mango.zip"]
"""
"""The project the release steps work on."""
CHANGELOG = f"""\
# Changelog
## [`{VERSION}` (2026-10-21)](https://github.com/{REPOSITORY}/compare/v1.2.2...v{VERSION})
- Add a juicer.
## [`1.2.2` (2026-10-01)](https://github.com/{REPOSITORY}/compare/v1.2.1...v1.2.2)
- Peel faster.
"""
"""A changelog holding the section of the release."""
[docs]
def find_step(document: dict, job: str, name: str) -> dict:
"""The step of *job* named *name*, the one step matching it."""
steps: list[dict] = [
step for step in document["jobs"][job]["steps"] if step.get("name") == name
]
assert len(steps) == 1, f"{job} has {len(steps)} step(s) named {name!r}"
return steps[0]
[docs]
def step_argv(step: dict, env: dict[str, str]) -> list[str]:
"""The arguments a step passes to the CLI, its variables expanded.
Every variable the line reads must be one the step declares in its `env:`
or one GitHub sets, which *env* stands in for: an undeclared one would
reach the CLI empty.
:param step: The workflow step.
:param env: The values of the variables the line reads.
:return: The arguments after the `repomatic` command.
"""
run = " ".join(step["run"].split())
assert CLI_MARK in run, f"No repomatic call in {run!r}"
arguments = run.split(CLI_MARK, 1)[1]
assert "${{" not in arguments, f"Expression left in the run line: {run!r}"
def expand(match: re.Match[str]) -> str:
name = match.group(1) or match.group(2)
assert name in (step.get("env") or {}) or name.startswith("GITHUB_"), (
f"{name} is neither in the step's env nor set by GitHub"
)
return env[name]
return [VARIABLE_RE.sub(expand, token) for token in shlex.split(arguments)]
[docs]
@pytest.fixture
def project(tmp_path, monkeypatch):
"""A checkout of the project being released, the working directory."""
(tmp_path / "pyproject.toml").write_text(PYPROJECT, encoding="UTF-8")
(tmp_path / "changelog.md").write_text(CHANGELOG, encoding="UTF-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("GITHUB_REPOSITORY", REPOSITORY)
monkeypatch.setenv("GITHUB_ACTOR", "kevin")
return tmp_path
[docs]
def invoke(argv: list[str]):
"""Run the CLI on *argv*, as the step would."""
return CliRunner().invoke(repomatic, argv)
# ---------------------------------------------------------------------------
# _release-build.yaml
# ---------------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("message", "reported"),
(
pytest.param(f"Release `v{VERSION}` (#42)", True, id="squashed"),
pytest.param(f"[changelog] Release v{VERSION}", False, id="rebased"),
),
)
def test_squashed_release_step(project, monkeypatch, message, reported):
"""A squash-merged release PR reopens one issue and fails the job."""
event = project / "event.json"
event.write_text(
json.dumps({"head_commit": {"message": message}}), encoding="UTF-8"
)
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event))
step = find_step(
load_workflow("_release-build.yaml"),
"squashed-release",
"Open or update the squash-merge issue, then fail",
)
with patch(
"repomatic.cli.release.manage_issue_lifecycle", return_value=7
) as lifecycle:
result = invoke(step_argv(step, {}))
if not reported:
assert result.exit_code == 0, result.output
lifecycle.assert_not_called()
return
assert result.exit_code == 1, result.output
kwargs = lifecycle.call_args.kwargs
assert kwargs["has_issues"] is True
assert kwargs["title"] == "🚨 Squashed release"
assert kwargs["labels"] == (BOT_ISSUE_LABEL,)
assert "#42" in kwargs["body"]
assert kwargs["repository"] == REPOSITORY
assert kwargs["assignees"] == ("kevin",)
assert (
"::error::Squash merge detected. Release was skipped. "
f"See https://github.com/{REPOSITORY}/issues/7"
) in result.output
# ---------------------------------------------------------------------------
# _release-engine.yaml
# ---------------------------------------------------------------------------
ENGINE = load_workflow("_release-engine.yaml")
[docs]
def test_create_release_step(project):
"""The draft gets the release notes, the target commit and the package."""
artifact = project / "release_artifact"
artifact.mkdir()
wheel = artifact / f"papaya-{VERSION}-py3-none-any.whl"
wheel.touch()
step = find_step(ENGINE, "create-release", "Create GitHub release draft")
argv = step_argv(step, {"CURRENT_VERSION": VERSION, "COMMIT": COMMIT})
with patch(
"repomatic.github.releases.run_gh_command",
side_effect=[GhCommandError("release not found"), ""],
) as mock_gh:
result = invoke(argv)
assert result.exit_code == 0, result.output
create = mock_gh.call_args.args[0]
assert create[:8] == [
"release",
"create",
f"v{VERSION}",
"--draft",
"--target",
COMMIT,
"--title",
f"v{VERSION}",
]
notes = create[create.index("--notes") + 1]
assert "- Add a juicer." in notes
assert f"https://pypi.org/project/papaya/{VERSION}/" in notes
assert create[-1] == str(wheel)
[docs]
def test_create_release_step_skips_an_existing_release(project):
"""A re-run of the job finds the draft its first attempt created."""
step = find_step(ENGINE, "create-release", "Create GitHub release draft")
argv = step_argv(step, {"CURRENT_VERSION": VERSION, "COMMIT": COMMIT})
with patch(
"repomatic.github.releases.run_gh_command", return_value='{"tagName": "v1"}'
) as mock_gh:
result = invoke(argv)
assert result.exit_code == 0, result.output
assert mock_gh.call_count == 1
[docs]
def test_compile_binaries_attestation_step(project):
"""The binary's provenance is checked, and its bundle named after it."""
binary = project / f"papaya-{VERSION}-linux-x64.bin"
binary.write_bytes(b"\x7fELF")
bundle = write_bundle(project / "attestation.json", binary.name)
step = find_step(
ENGINE,
"compile-binaries",
"Verify the attestation and name its bundle after the binary",
)
with patch("repomatic.release.attestation.run_gh_command") as mock_gh:
result = invoke(step_argv(step, {"BUNDLE_PATH": str(bundle)}))
assert result.exit_code == 0, result.output
mock_gh.assert_called_once_with([
"attestation",
"verify",
str(binary),
"--repo",
REPOSITORY,
"--signer-repo",
DEFAULT_REPO,
])
assert (project / f"{binary.name}.attestation.json").is_file()
[docs]
def test_manpages_steps(project):
"""The tarball the attest step names is the one the pack step writes."""
man = project / "man"
man.mkdir()
(man / "papaya.1").write_text(".TH PAPAYA 1\n", encoding="UTF-8")
pack = find_step(ENGINE, "manpages", "Bundle as tarball")
attest = next(
step
for step in ENGINE["jobs"]["manpages"]["steps"]
if step.get("id") == "attest-manpages"
)
result = invoke(step_argv(pack, {}))
assert result.exit_code == 0, result.output
tarball = project / result.stdout.strip()
assert tarball.is_file()
# The attest step spells the name from the same metadata key.
subject = attest["with"]["subject-path"]
assert subject == (
"${{ format('{0}.tar.gz', fromJSON(inputs.metadata).manpages_asset_name) }}"
)
assert tarball.name == f"{Metadata().manpages_asset_name}.tar.gz"
bundle = write_bundle(project / "attestation.json", tarball.name)
verify = find_step(ENGINE, "manpages", "Verify tarball attestation")
upload = find_step(ENGINE, "manpages", "Attach to release draft")
env = {"BUNDLE_PATH": str(bundle), "CURRENT_VERSION": VERSION}
with (
patch("repomatic.release.attestation.run_gh_command") as mock_verify,
patch("repomatic.github.releases.run_gh_command") as mock_upload,
):
assert invoke(step_argv(verify, env)).exit_code == 0
assert invoke(step_argv(upload, env)).exit_code == 0
assert mock_verify.call_args.args[0][:3] == ["attestation", "verify", str(tarball)]
assert mock_upload.call_args.args[0] == [
"release",
"upload",
f"v{VERSION}",
str(tarball),
str(project / f"{tarball.name}.attestation.json"),
"--repo",
REPOSITORY,
"--clobber",
]
[docs]
def test_publish_release_steps(project):
"""The binaries and their aliases reach the draft, which then publishes."""
compiled = project / "compile-assets"
compiled.mkdir()
binary = compiled / f"papaya-{VERSION}-linux-x64.bin"
binary.write_bytes(b"\x7fELF")
(compiled / f"papaya-{VERSION}-py3-none-any.whl").touch()
upload = find_step(
ENGINE, "publish-release", "Upload binaries and attestation bundles to release"
)
publish = find_step(ENGINE, "publish-release", "Publish release")
env = {"CURRENT_VERSION": VERSION, "REPO": REPOSITORY}
with (
patch("repomatic.github.releases.run_gh_command") as mock_upload,
patch("repomatic.cli.tools.run_tool", return_value=0) as mock_run,
):
assert invoke(step_argv(upload, env)).exit_code == 0
assert invoke(step_argv(publish, env)).exit_code == 0
# The versioned binary and its versionless alias, the package left out.
assert mock_upload.call_args.args[0] == [
"release",
"upload",
f"v{VERSION}",
str(binary),
str(compiled / "papaya-linux-x64.bin"),
"--repo",
REPOSITORY,
"--clobber",
]
assert mock_run.call_args.args[0] == "gh"
assert mock_run.call_args.kwargs["extra_args"] == (
"release",
"edit",
f"v{VERSION}",
"--draft=false",
"--repo",
REPOSITORY,
)
# ---------------------------------------------------------------------------
# .github/actions/publish-pypi/action.yaml
# ---------------------------------------------------------------------------
[docs]
def test_wait_pypi_step(tmp_path):
"""The composite action runs the CLI of the tree it was fetched with."""
action_dir = REPO_ROOT / ".github" / "actions" / "publish-pypi"
action = yaml.safe_load((action_dir / "action.yaml").read_text(encoding="UTF-8"))
step = next(
step
for step in action["runs"]["steps"]
if step.get("name") == "Wait for PyPI propagation"
)
# The project the CLI runs from is the repository the action sits in.
project = re.search(r'--project "\$\{GITHUB_ACTION_PATH\}/([./]+)"', step["run"])
assert project is not None
assert (action_dir / project.group(1)).resolve() == REPO_ROOT.resolve()
(tmp_path / f"papaya-{VERSION}.tar.gz").touch()
with (
patch("repomatic.pypi.get_json_soft", return_value={"info": {}}),
patch("repomatic.pypi.time.sleep"),
):
result = invoke(step_argv(step, {"DOWNLOAD_PATH": str(tmp_path)}))
assert result.exit_code == 0, result.output
assert "Visible after 1 attempt(s)" in result.output
# ---------------------------------------------------------------------------
# changelog.yaml
# ---------------------------------------------------------------------------
CHANGELOG_WORKFLOW = load_workflow("changelog.yaml")
[docs]
@pytest.fixture
def release_checkout(tmp_path, monkeypatch, hermetic_git):
"""A repository whose version was just stripped of its `.dev` suffix."""
checkout = tmp_path / "checkout"
checkout.mkdir()
_run_git("init", "--initial-branch=main", str(checkout), cwd=checkout)
(checkout / ".github" / "workflows").mkdir(parents=True)
_seed_commit(
checkout,
"changelog.md",
f"# Changelog\n\n## [`{VERSION}` (unreleased)]"
f"(https://github.com/{REPOSITORY}/compare/v1.2.2...main)\n\n- Juicer.\n",
"Seed changelog",
)
_seed_commit(
checkout,
"pyproject.toml",
f'[project]\nname = "papaya"\nversion = "{VERSION}"\n\n'
f'[tool.bumpversion]\ncurrent_version = "{VERSION}"\n',
"Seed project",
)
monkeypatch.chdir(checkout)
monkeypatch.setenv("GITHUB_REPOSITORY", REPOSITORY)
return checkout
[docs]
def test_prepare_release_commit_steps(release_checkout, tmp_path, monkeypatch):
"""The freeze and the unfreeze commits carry the messages the lane reads."""
output = tmp_path / "github-output.txt"
env = {"GITHUB_OUTPUT": str(output)}
monkeypatch.setenv("GITHUB_OUTPUT", str(output))
freeze = find_step(CHANGELOG_WORKFLOW, "prepare-release", "Create freeze commit")
unfreeze = find_step(
CHANGELOG_WORKFLOW, "prepare-release", "Create unfreeze commit"
)
result = invoke(step_argv(freeze, env))
assert result.exit_code == 0, result.output
assert output.read_text(encoding="UTF-8") == f"version={VERSION}\n"
assert _run_git("log", "-1", "--format=%s", cwd=release_checkout).strip() == (
f"[changelog] Release v{VERSION}"
)
changelog = (release_checkout / "changelog.md").read_text(encoding="UTF-8")
assert "(unreleased)" not in changelog
# The patch bump the steps in between apply.
pyproject = release_checkout / "pyproject.toml"
pyproject.write_text(
pyproject.read_text(encoding="UTF-8").replace(
f'current_version = "{VERSION}"', 'current_version = "1.2.4.dev0"'
),
encoding="UTF-8",
)
result = invoke(step_argv(unfreeze, {}))
assert result.exit_code == 0, result.output
assert _run_git("log", "-1", "--format=%s", cwd=release_checkout).strip() == (
f"[changelog] Post-release bump v{VERSION} → v1.2.4.dev0"
)
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=release_checkout,
capture_output=True,
text=True,
encoding="UTF-8",
check=True,
)
assert status.stdout == ""
[docs]
@pytest.mark.parametrize("allowed", (True, False))
def test_bump_version_step(project, monkeypatch, allowed):
"""An allowed bump relocks before its pull request, a stale one closes."""
step = next(
step
for step in CHANGELOG_WORKFLOW["jobs"]["bump-version"]["steps"]
if "bump-version" in (step.get("run") or "")
)
calls: list[str] = []
monkeypatch.setattr(
"repomatic.cli.release.is_version_bump_allowed", lambda part: allowed
)
def run_tool(name: str, args: list[str]) -> int:
calls.append(f"{name} {' '.join(args)}")
return 0
def close(part: str) -> list[int]:
calls.append(f"close {part}")
return []
monkeypatch.setattr("repomatic.cli.release.run_tool", run_tool)
monkeypatch.setattr("repomatic.cli.release.relock", lambda: calls.append("lock"))
monkeypatch.setattr(
"repomatic.cli.release.render_pr",
lambda *args, **kwargs: PrContent("Title", "Body", "Message"),
)
upserted = {}
def upsert(**kwargs):
calls.append("pr")
upserted.update(kwargs)
return PrSyncResult(PrOperation.CREATED, kwargs["branch"], number=12)
monkeypatch.setattr("repomatic.cli.release.upsert_pr", upsert)
monkeypatch.setattr("repomatic.cli.release.close_stale_bump_prs", close)
result = invoke(step_argv(step, {"PART": "minor"}))
assert result.exit_code == 0, result.output
if not allowed:
assert calls == ["close minor"]
return
assert calls == ["bump-my-version bump --verbose minor", "lock", "pr"]
assert upserted["branch"] == "minor-version-increment"
assert upserted["draft"] is True
assert upserted["assignees"] == ("kevin",)