This repository has been archived on 2026-11-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
platform-ci/tests/test_effective_version.py

504 lines
31 KiB
Python

"""Source-only tests of the effective version strategy. UNTESTED: written while test execution was suspended; never executed."""
import base64
import io
import unittest
import urllib.error
import urllib.parse
from collections.abc import Callable, Mapping, Sequence
from datetime import UTC, datetime
from email.message import Message
from pathlib import Path
from typing import cast, override
from platform_ci.build_inputs import compute_build_inputs
from platform_ci.configuration import ConfigurationError, ProjectConfiguration, parse_project_configuration
from platform_ci.delivery.errors import DeliveryError
from platform_ci.delivery.forge import ForgeGitHistory, RepositoryScope, prepare_job_started
from platform_ci.delivery.git_history import GitHistoryError
from platform_ci.delivery.publisher import _plan_for_run # pyright: ignore[reportPrivateUsage]
from platform_ci.delivery.selection import release_activity_for_run
from platform_ci.effective_version import (
DEVELOP_NEXT_MINOR,
FEATURE_SNAPSHOT,
PATCH_BUMP,
RC_BASELINE_NONE,
RC_BASELINE_RECONCILED,
RC_FROM_BRANCH,
RELEASE_ACTIVITY_WINDOW_SECONDS,
STABLE_FROM_TAG,
ReleaseActivityError,
effective_version,
has_release_activity,
message_matches_release_activity,
release_activity_since,
version_parts,
with_version_property,
)
from platform_ci.event_selection import EventFacts, push_facts, tag_facts
from platform_ci.plan import ReleaseActivityReader, Target, plan_targets
from platform_ci.versions import parse_declared_version
from platform_ci.workspace import PipelineError
from tests.support import SOURCE_REVISION, SOURCE_TREE, TEST_RUNTIME_CATALOG, component_mapping, example_mapping
POM = """<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>library</artifactId>
<version>${{revision}}</version>
<properties><revision>{revision}</revision></properties>
</project>
"""
LITERAL_POM = POM.replace("${{revision}}", "9.9.9")
def _configuration(rule: str = RC_FROM_BRANCH, trigger: Mapping[str, object] | None = None, extra: dict[str, object] | None = None, publish: bool = True) -> dict[str, object]:
"""The Maven publication example with one candidate channel carrying an effective version rule (and, unless `publish` is false, one Maven publication on it)."""
configuration = example_mapping("maven-publication.ci.yaml")
channel: dict[str, object] = {"trigger": trigger or {"push_branches": ["release/**"]}, "versioning": "maven-release", "effective_version": {"rule": rule}}
channel.update(extra or {})
publications = {"library-packages": {"component": "library", "artifact": "packages", "destination": "company-packages", "channels": ["candidate"]}} if publish else {}
configuration["release"] = {"channels": {"candidate": channel}, "publications": publications}
return configuration
def _reader(revision: str, pom: str = POM) -> Callable[[Target], str]:
"""A declared-version reader over a fixed POM, the same parser the checkout and forge readers use."""
return lambda target: parse_declared_version(target["build_tool"], pom.format(revision=revision).encode(), target["commands"], target.get("version_source"))
def _plan(project: ProjectConfiguration, facts: EventFacts, revision: str = "1.5.0-SNAPSHOT", pom: str = POM, release_activity: ReleaseActivityReader | None = None) -> list[Target]:
return plan_targets(project, TEST_RUNTIME_CATALOG, facts, _reader(revision, pom), release_activity)
class RuleTest(unittest.TestCase):
def test_release_branch_first_candidate(self) -> None:
self.assertEqual(effective_version(RC_FROM_BRANCH, "1.5.0-SNAPSHOT", "release/1.6", ""), "1.6.0-RC.1")
def test_release_branch_increments_a_declared_candidate(self) -> None:
self.assertEqual(effective_version(RC_FROM_BRANCH, "1.6.0-RC.3", "release/1.6", ""), "1.6.0-RC.4")
def test_release_branch_accepts_every_captured_form(self) -> None:
"""The captured search is unanchored: any `release/<digits>.<digits>` prefix works, as the legacy qbit and core RC branches used."""
for branch in ("release/1.6", "release/1.6.0", "release/1.6.x", "release/1.6-hotfix"):
self.assertEqual(effective_version(RC_FROM_BRANCH, "1.5.0-SNAPSHOT", branch, ""), "1.6.0-RC.1")
def test_release_branch_without_a_numeric_line_is_refused(self) -> None:
for branch in ("release/next", "develop", ""):
with self.assertRaises(PipelineError):
effective_version(RC_FROM_BRANCH, "1.5.0-SNAPSHOT", branch, "")
def test_branch_rules_refuse_a_tag(self) -> None:
for rule in (DEVELOP_NEXT_MINOR, RC_FROM_BRANCH, PATCH_BUMP):
with self.assertRaises(PipelineError):
effective_version(rule, "1.5.0", "", "v1.5.0")
def test_develop_next_minor_from_rc_or_stable(self) -> None:
for declared in ("1.5.0-RC.2", "1.5.0"):
self.assertEqual(effective_version(DEVELOP_NEXT_MINOR, declared, "develop", ""), "1.6.0-SNAPSHOT")
with self.assertRaises(PipelineError):
effective_version(DEVELOP_NEXT_MINOR, "1.5.0", "dev", "")
def test_develop_with_a_snapshot_and_no_release_activity_input_is_refused(self) -> None:
"""Without the history input the captured handler's outcome for a SNAPSHOT is unknown; it is refused, never assumed to be `keep`."""
for declared in ("1.5.0-SNAPSHOT", "1.5.0-new-abc1234-SNAPSHOT"):
with self.assertRaisesRegex(ReleaseActivityError, "merged into develop within 3 days"):
effective_version(DEVELOP_NEXT_MINOR, declared, "develop", "")
def test_develop_snapshot_is_unchanged_without_release_activity(self) -> None:
"""The captured `keep` outcome: the declared SNAPSHOT is the build version, plain or feature-format."""
for declared in ("0.5.0-SNAPSHOT", "1.5.0-new-abc1234-SNAPSHOT"):
self.assertEqual(effective_version(DEVELOP_NEXT_MINOR, declared, "develop", "", release_activity=lambda: False), declared)
def test_develop_snapshot_bumps_to_the_next_minor_after_recent_release_activity(self) -> None:
self.assertEqual(effective_version(DEVELOP_NEXT_MINOR, "0.5.0-SNAPSHOT", "develop", "", release_activity=lambda: True), "0.6.0-SNAPSHOT")
self.assertEqual(effective_version(DEVELOP_NEXT_MINOR, "1.5.0-new-abc1234-SNAPSHOT", "develop", "", release_activity=lambda: True), "1.6.0-SNAPSHOT")
def test_release_activity_is_asked_only_when_it_decides_the_version_and_only_once(self) -> None:
calls: list[str] = []
def activity() -> bool:
calls.append("asked")
return False
for declared in ("1.5.0-RC.2", "1.5.0"): # RC and stable always bump: the captured handler never looks at history
self.assertEqual(effective_version(DEVELOP_NEXT_MINOR, declared, "develop", "", release_activity=activity), "1.6.0-SNAPSHOT")
self.assertEqual(calls, [])
effective_version(DEVELOP_NEXT_MINOR, "1.5.0-SNAPSHOT", "develop", "", release_activity=activity)
self.assertEqual(calls, ["asked"])
def test_reconciled_rc_baseline_refuses_only_the_undecidable_first_counter(self) -> None:
with self.assertRaisesRegex(PipelineError, "tagged first-parent RC counter"):
effective_version(RC_FROM_BRANCH, "1.6.0-SNAPSHOT", "release/1.6", "", RC_BASELINE_RECONCILED)
self.assertEqual(effective_version(RC_FROM_BRANCH, "1.6.0-RC.2", "release/1.6", "", RC_BASELINE_RECONCILED), "1.6.0-RC.3")
self.assertEqual(effective_version(RC_FROM_BRANCH, "1.6.0-SNAPSHOT", "release/1.6", "", RC_BASELINE_NONE), "1.6.0-RC.1")
def test_patch_bump_from_every_captured_format(self) -> None:
for declared in ("1.5.0", "1.5.0-SNAPSHOT", "1.5.0-RC.2", "1.5.0-new-abc1234-SNAPSHOT"):
self.assertEqual(effective_version(PATCH_BUMP, declared, "hotfix/1.5", ""), "1.5.1")
with self.assertRaises(PipelineError):
effective_version(PATCH_BUMP, "1.5.0", "release/1.5", "")
def test_stable_from_tag(self) -> None:
self.assertEqual(effective_version(STABLE_FROM_TAG, "1.5.0-RC.2", "", "v1.5.0"), "1.5.0")
for tag in ("", "v1.5", "v1.5.0-RC.1", "1.5.0"):
with self.assertRaises(PipelineError):
effective_version(STABLE_FROM_TAG, "1.5.0-RC.2", "", tag)
def test_feature_snapshot_is_always_refused_and_substitutes_no_name_or_hash(self) -> None:
"""No `publish` fallback name and no fixed 7-character hash: the originating feature branch and git's abbreviation are not carried inputs."""
for declared, branch, tag in (("1.5.0-SNAPSHOT", "", "publish-1"), ("1.5.0", "", "publish"), ("1.5.0-SNAPSHOT", "feature/x", "")):
with self.assertRaisesRegex(PipelineError, "originating feature branch"):
effective_version(FEATURE_SNAPSHOT, declared, branch, tag)
def test_unparseable_declared_version_is_an_error_for_every_rule(self) -> None:
for rule, branch, tag in ((DEVELOP_NEXT_MINOR, "develop", ""), (RC_FROM_BRANCH, "release/1.6", ""), (PATCH_BUMP, "hotfix/1.5", ""), (STABLE_FROM_TAG, "", "v1.5.0"), (FEATURE_SNAPSHOT, "", "publish")):
with self.assertRaises(PipelineError):
effective_version(rule, "undeclared", branch, tag)
self.assertEqual(version_parts("v2.3.4"), (2, 3, 4))
def test_unknown_rule_is_refused(self) -> None:
with self.assertRaises(PipelineError):
effective_version("latest-tag", "1.5.0", "main", "")
def test_property_conflict_is_refused(self) -> None:
self.assertEqual(with_version_property([["mvn", "verify"]], "revision", "1.6.0-RC.1"), [["mvn", "verify", "-Drevision=1.6.0-RC.1"]])
with self.assertRaises(PipelineError):
with_version_property([["mvn", "-Drevision=1", "verify"]], "revision", "1.6.0-RC.1")
class ConfigurationTest(unittest.TestCase):
def test_valid_rule_is_parsed_with_the_default_property(self) -> None:
channel = parse_project_configuration(_configuration()).channels[0]
self.assertEqual((channel.effective_version, channel.version_property), (RC_FROM_BRANCH, "revision"))
def test_a_verification_only_channel_without_publications_is_accepted(self) -> None:
"""The captured core profile ran the calculator for every publish kind; a repository that only verifies still declares the rule."""
self.assertEqual(parse_project_configuration(_configuration(publish=False)).channels[0].effective_version, RC_FROM_BRANCH)
def test_tag_and_branch_channels_take_their_rules(self) -> None:
for rule, trigger in ((STABLE_FROM_TAG, {"push_tags": ["v*.*.*"]}), (FEATURE_SNAPSHOT, {"push_tags": ["publish*"]}), (DEVELOP_NEXT_MINOR, {"push_branches": ["develop"]}), (PATCH_BUMP, {"push_branches": ["hotfix/**"]})):
with self.subTest(rule):
self.assertEqual(parse_project_configuration(_configuration(rule=rule, trigger=trigger, publish=False)).channels[0].effective_version, rule)
def test_absent_rule_changes_nothing(self) -> None:
configuration = _configuration()
cast("dict[str, dict[str, dict[str, object]]]", configuration["release"])["channels"]["candidate"].pop("effective_version")
self.assertEqual(parse_project_configuration(configuration).channels[0].effective_version, "")
def test_invalid_declarations_are_refused(self) -> None:
cases = {
"unknown rule": _configuration(rule="latest-tag"),
"bad property": _configuration(extra={"effective_version": {"rule": RC_FROM_BRANCH, "property": "a b"}}),
"missing rule": _configuration(extra={"effective_version": {"property": "revision"}}),
}
for label, configuration in cases.items():
with self.subTest(label), self.assertRaises(ConfigurationError):
parse_project_configuration(configuration)
class PlanApplicationTest(unittest.TestCase):
@override
def setUp(self) -> None:
self.project = parse_project_configuration(_configuration())
def test_push_applies_the_effective_version_to_every_command(self) -> None:
(target,) = _plan(self.project, push_facts("release/1.6", SOURCE_REVISION))
self.assertTrue(all(command[-1] == "-Drevision=1.6.0-RC.1" for command in target["commands"]))
def test_a_verification_only_component_gets_the_version_too(self) -> None:
project = parse_project_configuration(_configuration(publish=False))
(target,) = _plan(project, push_facts("release/1.6", SOURCE_REVISION))
self.assertEqual(target["commands"][0][-1], "-Drevision=1.6.0-RC.1")
def test_recorded_build_input_is_the_effective_version_and_digest_differs_from_the_declared_build(self) -> None:
(effective,) = _plan(self.project, push_facts("release/1.6", SOURCE_REVISION))
(declared,) = plan_targets(self.project, TEST_RUNTIME_CATALOG, EventFacts("pull_request", branch="feature/x", source_revision=SOURCE_REVISION))
# what packaging records: the same reader over the target's own commands, so the override is the version
recorded = compute_build_inputs(effective, SOURCE_TREE, _reader("1.5.0-SNAPSHOT")(effective))
self.assertEqual(recorded["version"], "1.6.0-RC.1")
self.assertNotEqual(recorded["config_digest"], compute_build_inputs(declared, SOURCE_TREE, "1.5.0-SNAPSHOT")["config_digest"])
def test_same_inputs_give_the_same_target(self) -> None:
facts = push_facts("release/1.6", SOURCE_REVISION)
self.assertEqual(_plan(self.project, facts), _plan(self.project, facts))
def test_a_different_declared_version_is_a_different_input(self) -> None:
facts = push_facts("release/1.6", SOURCE_REVISION)
(first,) = _plan(self.project, facts, "1.5.0-SNAPSHOT")
(second,) = _plan(self.project, facts, "1.6.0-RC.1")
self.assertEqual(second["commands"][0][-1], "-Drevision=1.6.0-RC.2")
self.assertNotEqual(first["commands"], second["commands"])
def test_pull_requests_plan_the_declared_property_and_unmatched_pushes_are_not_changed(self) -> None:
"""A pull request is the original verification of a published component, so it plans the explicit property with the declared version (see `test_unchanged_version_reuse`); a push no channel matches stays plain."""
(pull_request,) = _plan(self.project, EventFacts("pull_request", branch="release/1.6", source_revision=SOURCE_REVISION))
self.assertTrue(all(command[-1] == "-Drevision=1.5.0-SNAPSHOT" for command in pull_request["commands"]))
(unmatched,) = _plan(self.project, push_facts("develop", SOURCE_REVISION))
self.assertFalse(any(token.startswith("-Drevision=") for command in unmatched["commands"] for token in command))
def test_without_facts_nothing_is_applied(self) -> None:
(target,) = plan_targets(self.project, TEST_RUNTIME_CATALOG)
self.assertFalse(any(token.startswith("-Drevision=") for command in target["commands"] for token in command))
def test_a_rule_without_a_reader_is_refused(self) -> None:
with self.assertRaises(PipelineError):
plan_targets(self.project, TEST_RUNTIME_CATALOG, push_facts("release/1.6", SOURCE_REVISION))
def test_a_pom_that_does_not_resolve_through_the_property_is_refused(self) -> None:
with self.assertRaisesRegex(PipelineError, "resolves version"):
_plan(self.project, push_facts("release/1.6", SOURCE_REVISION), pom=LITERAL_POM)
def test_a_command_that_sets_the_property_is_refused(self) -> None:
configuration = _configuration()
component_mapping(configuration, "library")["build"] = {"tool": "maven", "arguments": ["-Drevision=1", "verify"]}
project = parse_project_configuration(configuration)
with self.assertRaises(PipelineError):
_plan(project, push_facts("release/1.6", SOURCE_REVISION))
def test_tag_rules_use_the_pushed_tag_and_commit(self) -> None:
stable = parse_project_configuration(_configuration(rule=STABLE_FROM_TAG, trigger={"push_tags": ["v*.*.*"]}, extra={"tag_policy": "stable"}))
(target,) = _plan(stable, tag_facts("v1.6.0", SOURCE_REVISION), "1.6.0-RC.2")
self.assertEqual(target["commands"][0][-1], "-Drevision=1.6.0")
feature = parse_project_configuration(_configuration(rule=FEATURE_SNAPSHOT, trigger={"push_tags": ["publish*"]}, publish=False))
with self.assertRaisesRegex(PipelineError, "originating feature branch"):
_plan(feature, tag_facts("publish-7", SOURCE_REVISION), "1.6.0-SNAPSHOT")
def test_the_reconciled_baseline_declaration_reaches_the_rule(self) -> None:
project = parse_project_configuration(_configuration(extra={"effective_version": {"rule": RC_FROM_BRANCH, "rc_baseline": "reconciled"}}))
self.assertEqual(project.channels[0].rc_baseline, "reconciled")
with self.assertRaisesRegex(PipelineError, "tagged first-parent RC counter"):
_plan(project, push_facts("release/1.6", SOURCE_REVISION), "1.6.0-SNAPSHOT")
(target,) = _plan(project, push_facts("release/1.6", SOURCE_REVISION), "1.6.0-RC.1")
self.assertEqual(target["commands"][0][-1], "-Drevision=1.6.0-RC.2")
def test_overlapping_channels_with_different_rules_are_refused(self) -> None:
configuration = _configuration()
channels = cast("dict[str, dict[str, object]]", cast("dict[str, object]", configuration["release"])["channels"])
channels["other"] = {"trigger": {"push_branches": ["release/**"]}, "effective_version": {"rule": PATCH_BUMP}}
with self.assertRaises(PipelineError):
_plan(parse_project_configuration(configuration), push_facts("release/1.6", SOURCE_REVISION))
class ProviderEquivalenceTest(unittest.TestCase):
def test_forge_style_reader_on_the_applied_target_agrees_with_the_build_record(self) -> None:
"""The publisher and tag planner recompute by reading the declared version of the APPLIED target; it must equal the version `prepare` applied."""
project = parse_project_configuration(_configuration())
facts = push_facts("release/1.6", SOURCE_REVISION)
(built,) = _plan(project, facts)
(recomputed,) = _plan(project, facts)
self.assertEqual(built, recomputed)
self.assertEqual(_reader("1.5.0-SNAPSHOT")(built), "1.6.0-RC.1")
class ReleaseActivityPredicateTest(unittest.TestCase):
def test_the_captured_merge_and_bump_messages_match(self) -> None:
for message in (
"Merge pull request 'Release 0.4' (#31) from release/0.4 into develop",
"Merge branch 'release/1.5.0' into develop",
"Merge branch 'release/1.5' back into develop\n\nbody",
"subject\n\nMerge release/2.0.1: done, back to develop",
"Bump version after release v1.5.0",
"Bump the version after release of v1.5.0 to 1.6.0-SNAPSHOT",
):
with self.subTest(message):
self.assertTrue(message_matches_release_activity(message))
def test_other_messages_do_not_match(self) -> None:
for message in (
"Merge branch 'release/next' into develop", # the patched matcher requires a numeric release/X.Y[.Z]
"Merge branch 'release/1.5x' into develop", # `x` continues the version token
"Merge branch 'release/1.5.0-RC.1' into main",
"Merge branch 'feature/x' into develop",
"Merge branch 'release/1.5'\ninto develop", # git greps one message line at a time
"Bump version to 1.6.0-SNAPSHOT",
"bump version after release v1", # case sensitive
"",
):
with self.subTest(message):
self.assertFalse(message_matches_release_activity(message))
def test_the_window_ends_at_the_build_start_and_includes_its_first_second(self) -> None:
start = 1_800_000_000
since = release_activity_since(start)
self.assertEqual(since, start - RELEASE_ACTIVITY_WINDOW_SECONDS)
self.assertEqual(RELEASE_ACTIVITY_WINDOW_SECONDS, 259_200)
merge = "Merge branch 'release/0.4' into develop"
self.assertTrue(has_release_activity([(since, merge)], since))
self.assertFalse(has_release_activity([(since - 1, merge)], since))
self.assertFalse(has_release_activity([(start, "ordinary change")], since))
REPOSITORY = "QRun-IO/example"
RUN_ID = 4242
PREPARE_NAME = f"Prepare plan [workflow {'a' * 40}]"
START = 1_800_000_000
DAY = 24 * 60 * 60
RELEASE_MERGE = "Merge pull request 'Release 0.4' (#31) from release/0.4 into develop"
SCOPE = RepositoryScope(REPOSITORY, 61, 1024 * 1024, 10, frozenset())
def _iso(epoch: int) -> str:
return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z")
class FakeForge:
"""A forge with runs (id -> prepare job start as epoch seconds or literal text; None leaves the field out), a newest-first commit history (a None time leaves the committer date out) and one `pom.xml`; every request is recorded."""
def __init__(self, starts: Mapping[int, int | str | None], commits: Sequence[tuple[int | None, str]] | None = None, pom: str | None = None, extra_jobs: Sequence[Mapping[str, object]] | None = None) -> None:
self.starts, self.extra_jobs = starts, list(extra_jobs or [])
self.commits = sorted(commits or [], key=lambda item: item[0] or 0, reverse=True)
self.pom = pom
self.requests: list[str] = []
def download(self, path: str, destination: Path, limit: int) -> None:
raise NotImplementedError(f"no artifact is served: {path} {destination} {limit}")
def get(self, path: str) -> object:
self.requests.append(path)
location, _, query = path.partition("?")
parameters = urllib.parse.parse_qs(query)
prefix = f"/repos/{REPOSITORY}/"
if location.startswith(f"{prefix}actions/runs/"):
run_id = int(location.removeprefix(f"{prefix}actions/runs/").split("/")[0])
if run_id not in self.starts:
raise self._missing(path)
if location.endswith("/jobs"):
started = self.starts[run_id]
prepare: dict[str, object] = {"name": PREPARE_NAME, "run_id": run_id, "head_sha": SOURCE_REVISION, "run_attempt": 1, **({"started_at": started if isinstance(started, str) else _iso(started)} if started is not None else {})}
jobs = [prepare, {"name": "Report", "run_id": run_id, "head_sha": SOURCE_REVISION, "run_attempt": 1}, *self.extra_jobs]
return {"total_count": len(jobs), "jobs": jobs}
return {"id": run_id, "head_sha": SOURCE_REVISION, "run_attempt": 1}
if location == f"{prefix}commits":
page, size = int(parameters["page"][0]), int(parameters["limit"][0])
return [{"sha": f"{index:040x}", "commit": {"message": message, "committer": {"date": _iso(when)} if when is not None else {}}} for index, (when, message) in enumerate(self.commits[(page - 1) * size : page * size])]
if location == f"{prefix}contents/pom.xml" and self.pom is not None:
data = self.pom.encode()
return {"type": "file", "encoding": "base64", "size": len(data), "content": base64.b64encode(data).decode()}
raise self._missing(path)
@staticmethod
def _missing(path: str) -> urllib.error.HTTPError:
return urllib.error.HTTPError(path, 404, "not found", Message(), io.BytesIO(b""))
class ServerClockBindingTest(unittest.TestCase):
def test_the_build_start_is_the_servers_prepare_job_start_floored_to_the_second(self) -> None:
self.assertEqual(prepare_job_started(FakeForge({RUN_ID: START}), SCOPE, RUN_ID), START)
self.assertEqual(prepare_job_started(FakeForge({RUN_ID: f"{_iso(START)[:-1]}.987654Z"}), SCOPE, RUN_ID), START)
def test_a_run_without_exactly_one_started_prepare_job_has_no_build_start(self) -> None:
with self.assertRaisesRegex(DeliveryError, "exactly one prepare job"):
prepare_job_started(FakeForge({RUN_ID: None}), SCOPE, RUN_ID)
second = {"name": PREPARE_NAME, "started_at": _iso(START)}
with self.assertRaisesRegex(DeliveryError, "exactly one prepare job"):
prepare_job_started(FakeForge({RUN_ID: START}, extra_jobs=[second]), SCOPE, RUN_ID)
with self.assertRaises(urllib.error.HTTPError):
prepare_job_started(FakeForge({}), SCOPE, RUN_ID)
def test_a_job_of_another_commit_is_refused(self) -> None:
foreign = {"name": "Verify x", "head_sha": "b" * 40, "run_id": RUN_ID, "run_attempt": 1}
with self.assertRaisesRegex(DeliveryError, "another run, commit or attempt"):
prepare_job_started(FakeForge({RUN_ID: START}, extra_jobs=[foreign]), SCOPE, RUN_ID)
def test_a_start_time_without_a_zone_is_not_a_trusted_timestamp(self) -> None:
with self.assertRaisesRegex(DeliveryError, "usable UTC timestamp"):
prepare_job_started(FakeForge({RUN_ID: "2027-01-15T08:00:00"}), SCOPE, RUN_ID)
with self.assertRaisesRegex(DeliveryError, "not a timestamp"):
prepare_job_started(FakeForge({RUN_ID: "yesterday"}), SCOPE, RUN_ID)
def test_the_answer_follows_the_runs_start_and_never_the_current_time(self) -> None:
"""The same history gives different answers for two runs, and the same run always gives the same one, so a later recomputation (publisher, tag, write-back) cannot drift."""
history: list[tuple[int | None, str]] = [(START - DAY, RELEASE_MERGE), (START - 5 * DAY, "ordinary")]
forge = FakeForge({1: START, 2: START + 3 * DAY + 1}, history)
self.assertTrue(release_activity_for_run(forge, SCOPE, SOURCE_REVISION, 1))
self.assertFalse(release_activity_for_run(forge, SCOPE, SOURCE_REVISION, 2))
self.assertTrue(release_activity_for_run(forge, SCOPE, SOURCE_REVISION, 1))
def test_commits_outside_the_window_or_not_matching_do_not_count(self) -> None:
older = FakeForge({RUN_ID: START}, [(START - 4 * DAY, RELEASE_MERGE), (START - DAY, "ordinary change")])
self.assertFalse(release_activity_for_run(older, SCOPE, SOURCE_REVISION, RUN_ID))
bump = FakeForge({RUN_ID: START}, [(START - 2 * DAY, "Bump version after release v0.4.0")])
self.assertTrue(release_activity_for_run(bump, SCOPE, SOURCE_REVISION, RUN_ID))
self.assertFalse(release_activity_for_run(FakeForge({RUN_ID: START}, []), SCOPE, SOURCE_REVISION, RUN_ID))
def test_a_match_on_a_later_page_is_found_and_reading_stops_at_the_first_page_outside_the_window(self) -> None:
recent = [(START - 100 - index, f"change {index}") for index in range(50)] # page 1: all inside the window, none matching
forge = FakeForge({RUN_ID: START}, [*recent, (START - 200, RELEASE_MERGE)])
self.assertTrue(release_activity_for_run(forge, SCOPE, SOURCE_REVISION, RUN_ID))
old = [(START - 10 * DAY - index, f"old {index}") for index in range(50)]
forge = FakeForge({RUN_ID: START}, [(START - 100, "recent"), *old, (START - 20 * DAY, RELEASE_MERGE)])
forge.requests.clear()
self.assertFalse(release_activity_for_run(forge, SCOPE, SOURCE_REVISION, RUN_ID))
pages = [request for request in forge.requests if "/commits?" in request]
self.assertEqual(len(pages), 2) # page 1 had one in-window commit, page 2 none: stop
def test_a_window_the_bound_cannot_cover_is_not_proven(self) -> None:
forge = FakeForge({RUN_ID: START}, [(START - 100 - index, f"change {index}") for index in range(100)])
with self.assertRaisesRegex(GitHistoryError, "not proven"):
ForgeGitHistory(forge, REPOSITORY, 2).has_release_activity(SOURCE_REVISION, release_activity_since(START))
def test_any_missing_fact_makes_the_version_unknown(self) -> None:
for label, forge in (("no run", FakeForge({})), ("no start", FakeForge({RUN_ID: None}))):
with self.subTest(label), self.assertRaises(ReleaseActivityError):
release_activity_for_run(forge, SCOPE, SOURCE_REVISION, RUN_ID)
with self.assertRaisesRegex(ReleaseActivityError, "committer date"):
release_activity_for_run(FakeForge({RUN_ID: START}, [(None, RELEASE_MERGE)]), SCOPE, SOURCE_REVISION, RUN_ID)
def _develop_project() -> ProjectConfiguration:
return parse_project_configuration(_configuration(rule=DEVELOP_NEXT_MINOR, trigger={"push_branches": ["develop"]}, extra={"versioning": "maven-snapshot"}))
class DevelopSnapshotFlowTest(unittest.TestCase):
"""The first real flow: declared `0.5.0-SNAPSHOT` on develop, in `prepare` and as the publisher recomputes it for the run it selects."""
def test_prepare_keeps_the_declared_snapshot_without_release_activity(self) -> None:
(target,) = _plan(_develop_project(), push_facts("develop", SOURCE_REVISION), "0.5.0-SNAPSHOT", release_activity=lambda: False)
self.assertTrue(all(command[-1] == "-Drevision=0.5.0-SNAPSHOT" for command in target["commands"]))
def test_prepare_bumps_after_recent_release_activity_and_the_inputs_differ(self) -> None:
facts = push_facts("develop", SOURCE_REVISION)
(kept,) = _plan(_develop_project(), facts, "0.5.0-SNAPSHOT", release_activity=lambda: False)
(bumped,) = _plan(_develop_project(), facts, "0.5.0-SNAPSHOT", release_activity=lambda: True)
self.assertEqual(bumped["commands"][0][-1], "-Drevision=0.6.0-SNAPSHOT")
kept_inputs = compute_build_inputs(kept, SOURCE_TREE, _reader("0.5.0-SNAPSHOT")(kept))
bumped_inputs = compute_build_inputs(bumped, SOURCE_TREE, _reader("0.5.0-SNAPSHOT")(bumped))
# reuse safety: another version is another config digest and another recorded version, so an earlier run's bytes cannot stand in for this one
self.assertEqual((kept_inputs["version"], bumped_inputs["version"]), ("0.5.0-SNAPSHOT", "0.6.0-SNAPSHOT"))
self.assertNotEqual(kept_inputs["config_digest"], bumped_inputs["config_digest"])
def test_prepare_without_the_input_fails_closed(self) -> None:
with self.assertRaises(ReleaseActivityError):
_plan(_develop_project(), push_facts("develop", SOURCE_REVISION), "0.5.0-SNAPSHOT")
def test_the_history_is_not_read_for_a_plan_that_does_not_need_it(self) -> None:
forge = FakeForge({RUN_ID: START})
_plan(_develop_project(), push_facts("develop", SOURCE_REVISION), "0.5.0-RC.1", release_activity=lambda: release_activity_for_run(forge, SCOPE, SOURCE_REVISION, RUN_ID))
_plan(_develop_project(), EventFacts("pull_request", branch="feature/x", source_revision=SOURCE_REVISION), "0.5.0-SNAPSHOT", release_activity=lambda: release_activity_for_run(forge, SCOPE, SOURCE_REVISION, RUN_ID))
self.assertEqual(forge.requests, [])
def test_the_publisher_recomputes_the_version_of_each_candidate_run_from_that_runs_start(self) -> None:
"""`prepare` of run 1 saw a release merge one day before its start and built 0.6.0-SNAPSHOT; run 2 started after the window closed and built 0.5.0-SNAPSHOT."""
forge = FakeForge({1: START, 2: START + 3 * DAY + 1}, [(START - DAY, RELEASE_MERGE)], POM.format(revision="0.5.0-SNAPSHOT"))
facts = push_facts("develop", SOURCE_REVISION)
project = _develop_project()
versions = {run: _plan_for_run(forge, SCOPE, TEST_RUNTIME_CATALOG, project, SOURCE_REVISION, facts, run)["library-linux-amd64"]["commands"][0][-1] for run in (1, 2)}
self.assertEqual(versions, {1: "-Drevision=0.6.0-SNAPSHOT", 2: "-Drevision=0.5.0-SNAPSHOT"})
# and each equals what `prepare` computes for the same run from the same facts
for run, expected in ((1, True), (2, False)):
(prepared,) = _plan(project, facts, "0.5.0-SNAPSHOT", release_activity=lambda run=run: release_activity_for_run(forge, SCOPE, SOURCE_REVISION, run))
self.assertEqual(prepared["commands"][0][-1], f"-Drevision={'0.6.0' if expected else '0.5.0'}-SNAPSHOT")
def test_a_run_whose_start_is_unknown_is_a_release_activity_error_for_that_run_only(self) -> None:
forge = FakeForge({1: START, 2: None}, [], POM.format(revision="0.5.0-SNAPSHOT"))
facts = push_facts("develop", SOURCE_REVISION)
project = _develop_project()
self.assertEqual(_plan_for_run(forge, SCOPE, TEST_RUNTIME_CATALOG, project, SOURCE_REVISION, facts, 1)["library-linux-amd64"]["commands"][0][-1], "-Drevision=0.5.0-SNAPSHOT")
with self.assertRaises(ReleaseActivityError):
_plan_for_run(forge, SCOPE, TEST_RUNTIME_CATALOG, project, SOURCE_REVISION, facts, 2)
if __name__ == "__main__":
unittest.main()