You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/08/05 14:27:41 UTC
(tooling-trusted-releases) 01/01: Allow Tooling presentation votes to be resolved early
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 05bc82a891c58b9119b1f2d754dcdef14481e0b8
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Aug 5 15:27:16 2026 +0100
Allow Tooling presentation votes to be resolved early
---
atr/db/interaction.py | 9 +--
atr/get/resolve.py | 2 +-
atr/storage/writers/vote.py | 4 +-
atr/templates/resolve-tabulated.html | 4 +-
tests/unit/test_vote_resolution.py | 118 ++++++++++++++++++++++++++++++-----
5 files changed, 113 insertions(+), 24 deletions(-)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index bd126fd1..04dcb935 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -52,6 +52,7 @@ PENDING_QUARANTINE_VOTE_BLOCK_MESSAGE: Final[str] = (
# audit_guidance required actor for ATR distribution workflows; must not be used for project TP workflows.
_GITHUB_TRUSTED_ROLE_NID: Final[int] = 254436773
_NO_EXPECTED_VOTE_ROUND: Final[object] = object()
+_PRODUCTION_VOTE_RESOLUTION_BYPASS_PROJECT_KEYS: Final[frozenset[str]] = frozenset({"tooling-presentations"})
class ApacheUserMissingError(RuntimeError):
@@ -1037,10 +1038,6 @@ async def validate_trusted_jwt(publisher: str, jwt: str) -> tuple[github.Trusted
return payload, asf_uid
-def vote_duration_bypass() -> bool:
- return not config.is_production_mode()
-
-
def vote_end_get(latest_vote_task: sql.Task | None) -> datetime.datetime | None:
if latest_vote_task is None:
return None
@@ -1061,6 +1058,10 @@ def vote_pass_fail_allowed(latest_vote_task: sql.Task | None) -> bool:
return datetime.datetime.now(datetime.UTC) >= vote_end
+def vote_resolution_bypass(release: sql.Release) -> bool:
+ return (not config.is_production_mode()) or (release.project_key in _PRODUCTION_VOTE_RESOLUTION_BYPASS_PROJECT_KEYS)
+
+
async def wait_for_task(
task: sql.Task,
caller_data: db.Session | None = None,
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index 77127d79..159694f2 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -152,7 +152,7 @@ async def selected( # noqa: C901
mail_warnings = latest_vote_task.result.mail_send_warnings
pass_fail_allowed = interaction.vote_pass_fail_allowed(latest_vote_task)
- bypass_active = interaction.vote_duration_bypass()
+ bypass_active = interaction.vote_resolution_bypass(release)
vote_end = interaction.vote_end_get(latest_vote_task)
is_trusted_mode = release.effective_vote_mode == sql.VoteMode.TRUSTED
vote_round = interaction.trusted_vote_round(release)
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 5080d299..bad4b873 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -577,7 +577,7 @@ class ReleaseManager(CommitteeParticipant):
if (
(vote_result != "cancelled")
and (not interaction.vote_pass_fail_allowed(latest_vote_task))
- and (not interaction.vote_duration_bypass())
+ and (not interaction.vote_resolution_bypass(release))
):
raise storage.AccessError(
"The vote cannot be resolved before the voting period has ended unless it is cancelled.",
@@ -1005,7 +1005,7 @@ class ReleaseManager(CommitteeParticipant):
latest_vote_task = await interaction.release_current_vote_task(release, self.__data)
vote_end = interaction.vote_end_get(latest_vote_task)
- resolution_bypass = interaction.vote_duration_bypass()
+ resolution_bypass = interaction.vote_resolution_bypass(release)
if (
(vote_result != "cancelled")
and (vote_end is not None)
diff --git a/atr/templates/resolve-tabulated.html b/atr/templates/resolve-tabulated.html
index f2f30314..209d24cd 100644
--- a/atr/templates/resolve-tabulated.html
+++ b/atr/templates/resolve-tabulated.html
@@ -272,13 +272,13 @@
{% if bypass_active %}
<div class="border rounded bg-warning-subtle p-3 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
- <strong>Debug bypass:</strong>
+ <strong>Early resolution enabled:</strong>
{% if vote_end %}
The voting period has not ended yet (ends {{ vote_end.strftime('%Y-%m-%d %H:%M:%S UTC') }}).
{% else %}
The end of the vote could not be determined.
{% endif %}
- Resolving as passed or failed is allowed because debug/test mode is active.
+ ATR allows this vote to be resolved as passed or failed now.
</div>
{% else %}
<div class="border rounded bg-info-subtle p-3 mb-3">
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 18d9b16f..4aff149e 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -20,6 +20,7 @@ import datetime
import inspect
import unittest.mock as mock
from types import SimpleNamespace
+from typing import Literal
import pytest
import quart
@@ -514,7 +515,7 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
future_task = _latest_vote_task_with_end(24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
writer._ReleaseManager__resolve_release = mock.AsyncMock(
return_value=(release, None, "Vote marked as cancelled", None)
@@ -531,14 +532,18 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
assert success == "Vote marked as cancelled"
+@pytest.mark.parametrize("vote_result", ["failed", "passed"])
@pytest.mark.asyncio
-async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.MonkeyPatch) -> None:
+async def test_resolve_allows_early_result_for_production_bypass_project(
+ monkeypatch: pytest.MonkeyPatch, vote_result: Literal["failed", "passed"]
+) -> None:
"""Writer allows Passed before the end of the vote when bypass is active."""
data = _mock_data()
write_as = _mock_write_as()
writer = _writer_with_mocks(data, write_as)
release = _candidate_release()
+ release.project_key = "tooling-presentations"
query = mock.MagicMock()
query.demand = mock.AsyncMock(return_value=release)
data.release = mock.MagicMock(return_value=query)
@@ -546,21 +551,21 @@ async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.Monke
future_task = _latest_vote_task_with_end(24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
writer._ReleaseManager__resolve_release = mock.AsyncMock(
- return_value=(release, None, "Vote marked as passed", None)
+ return_value=(release, None, f"Vote marked as {vote_result}", None)
)
_release, _round, success, _error = await writer.resolve(
- _project_key(),
+ safe.ProjectKey("tooling-presentations"),
_version_key(),
- "passed",
+ vote_result,
"Chair",
- "The vote has passed.",
+ f"The vote has {vote_result}.",
)
- assert success == "Vote marked as passed"
+ assert success == f"Vote marked as {vote_result}"
@pytest.mark.asyncio
@@ -578,7 +583,7 @@ async def test_resolve_allows_passed_after_vote_end(monkeypatch: pytest.MonkeyPa
past_task = _latest_vote_task_with_end(-24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(interaction, "vote_resolution_bypass", lambda _release: False)
writer._ReleaseManager__resolve_release = mock.AsyncMock(
return_value=(release, None, "Vote marked as passed", None)
@@ -728,6 +733,25 @@ async def test_resolve_page_uses_cancel_form_before_vote_end(
assert form_render.call_args.kwargs["submit_label"] == "Cancel vote"
+@pytest.mark.asyncio
+async def test_resolve_page_allows_early_result_when_bypass_is_active(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ vote_end = datetime.datetime(2026, 4, 1, 12, 0, 0, tzinfo=datetime.UTC)
+
+ context, form_render, _archive_lookup, _vote_committee, _vote_details = await _render_standard_resolve_page(
+ monkeypatch,
+ pass_fail_allowed=False,
+ resolution_bypass=True,
+ vote_end=vote_end,
+ )
+
+ assert context["cancel_only"] is False
+ assert form_render.call_args.kwargs["model_cls"] is shared.resolve.SubmitForm
+ assert form_render.call_args.kwargs["submit_classes"] == "btn-primary"
+ assert form_render.call_args.kwargs["submit_label"] == "Resolve vote"
+
+
@pytest.mark.asyncio
async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Writer rejects Failed when the end of the vote has not been reached and no bypass is active."""
@@ -736,6 +760,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
writer = _writer_with_mocks(data, write_as)
release = _candidate_release()
+ release.project_key = "project"
query = mock.MagicMock()
query.demand = mock.AsyncMock(return_value=release)
data.release = mock.MagicMock(return_value=query)
@@ -743,7 +768,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
future_task = _latest_vote_task_with_end(24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
with pytest.raises(storage.AccessError, match="unless it is cancelled"):
await writer.resolve(
@@ -763,13 +788,14 @@ async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) ->
writer = _writer_with_mocks(data, write_as)
release = _candidate_release()
+ release.project_key = "project"
query = mock.MagicMock()
query.demand = mock.AsyncMock(return_value=release)
data.release = mock.MagicMock(return_value=query)
future_task = _latest_vote_task_with_end(24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
with pytest.raises(storage.AccessError, match="voting period"):
await writer.resolve(
@@ -969,6 +995,7 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
writer._ReleaseManager__send_resolution = mock.AsyncMock(return_value=None)
release = _candidate_release()
+ release.project_key = "tooling-presentations"
release.vote_mode = sql.VoteMode.TRUSTED
release.effective_vote_mode = sql.VoteMode.TRUSTED
release.current_vote_seq = 1
@@ -977,14 +1004,14 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
data.release = mock.MagicMock(return_value=query)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
monkeypatch.setattr(interaction, "ballots_for_resolution", mock.AsyncMock(return_value=[]))
monkeypatch.setattr(
interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
)
_release, _round, success, _error = await writer.resolve(
- _project_key(),
+ safe.ProjectKey("tooling-presentations"),
_version_key(),
"passed",
"Chair",
@@ -997,6 +1024,42 @@ async def test_trusted_resolve_allows_insufficient_votes_with_bypass(monkeypatch
write_as.revision.create_revision_with_quarantine.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_trusted_resolve_rejects_insufficient_votes_for_other_production_project(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data = _mock_data()
+ write_as = _mock_write_as()
+ writer = _writer_with_mocks(data, write_as)
+
+ release = _candidate_release()
+ release.project_key = "project"
+ release.vote_mode = sql.VoteMode.TRUSTED
+ release.effective_vote_mode = sql.VoteMode.TRUSTED
+ release.current_vote_seq = 1
+ query = mock.MagicMock()
+ query.demand = mock.AsyncMock(return_value=release)
+ data.release = mock.MagicMock(return_value=query)
+
+ monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task()))
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: True)
+ monkeypatch.setattr(interaction, "ballots_for_resolution", mock.AsyncMock(return_value=[]))
+ monkeypatch.setattr(
+ interaction, "trusted_ballot_summary", mock.AsyncMock(return_value=interaction.TrustedVoteSummary())
+ )
+
+ with pytest.raises(storage.AccessError, match="does not have enough binding"):
+ await writer.resolve(
+ _project_key(),
+ _version_key(),
+ "passed",
+ "Chair",
+ "The vote has passed.",
+ expected_vote_seq=1,
+ expected_vote_mode=sql.VoteMode.TRUSTED,
+ )
+
+
@pytest.mark.asyncio
async def test_trusted_resolve_page_passes_authoritative_ballot_context(
monkeypatch: pytest.MonkeyPatch,
@@ -1133,7 +1196,7 @@ async def test_trusted_resolve_passes_round_two_via_carried_ipmc_ballots(monkeyp
past_task = _latest_vote_task_with_end(-24)
monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
- monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(interaction, "vote_resolution_bypass", lambda _release: False)
effective_ballots = mock.AsyncMock(return_value=[mock.MagicMock(), mock.MagicMock(), mock.MagicMock()])
monkeypatch.setattr(interaction, "effective_trusted_ballots", effective_ballots)
monkeypatch.setattr(
@@ -1215,6 +1278,28 @@ def test_vote_pass_fail_allowed_returns_true_after_vote_end() -> None:
assert interaction.vote_pass_fail_allowed(task) is True
+@pytest.mark.parametrize(
+ ("production_mode", "project_key", "expected"),
+ [
+ (False, "project", True),
+ (True, "tooling", False),
+ (True, "tooling-presentations", True),
+ (True, "tooling-presentations-demo", False),
+ ],
+)
+def test_vote_resolution_bypass(
+ monkeypatch: pytest.MonkeyPatch,
+ production_mode: bool,
+ project_key: str,
+ expected: bool,
+) -> None:
+ release = _candidate_release()
+ release.project_key = project_key
+ monkeypatch.setattr(interaction.config, "is_production_mode", lambda: production_mode)
+
+ assert interaction.vote_resolution_bypass(release) is expected
+
+
@pytest.mark.asyncio
async def test_vote_tabulate_api_passes_trusted_receipt_exclusions(monkeypatch: pytest.MonkeyPatch) -> None:
release = _candidate_release()
@@ -1460,6 +1545,7 @@ async def _render_standard_resolve_page(
ballot_receipt_message_ids: mock.AsyncMock | None = None,
ballots_for_resolution: mock.AsyncMock | None = None,
pass_fail_allowed: bool = True,
+ resolution_bypass: bool = False,
vote_end: datetime.datetime | None = None,
) -> tuple[dict[str, object], mock.AsyncMock, mock.AsyncMock, mock.AsyncMock, mock.AsyncMock]:
release = release or _candidate_release()
@@ -1473,6 +1559,7 @@ async def _render_standard_resolve_page(
vote_committee = mock.AsyncMock(return_value=release.project.committee)
vote_details = mock.AsyncMock()
form_render = mock.AsyncMock(return_value="FORM")
+ resolution_bypass_check = mock.Mock(return_value=resolution_bypass)
ballot_receipt_message_ids = ballot_receipt_message_ids or mock.AsyncMock(return_value=set())
ballots_for_resolution = ballots_for_resolution or mock.AsyncMock(return_value=[])
is_binding_for_release = mock.AsyncMock(return_value=(True, "Project"))
@@ -1505,7 +1592,7 @@ async def _render_standard_resolve_page(
monkeypatch.setattr(
resolve.interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
)
- monkeypatch.setattr(resolve.interaction, "vote_duration_bypass", lambda: False)
+ monkeypatch.setattr(resolve.interaction, "vote_resolution_bypass", resolution_bypass_check)
monkeypatch.setattr(resolve.interaction, "vote_end_get", lambda _task: vote_end)
monkeypatch.setattr(resolve.interaction, "vote_pass_fail_allowed", lambda _task: pass_fail_allowed)
monkeypatch.setattr(resolve.interaction, "ballot_receipt_message_ids", ballot_receipt_message_ids)
@@ -1520,6 +1607,7 @@ async def _render_standard_resolve_page(
html = await _resolve_handler()(session, "resolve", _project_key(), _version_key())
assert html == "HTML"
+ resolution_bypass_check.assert_called_once_with(release)
return context, form_render, archive_lookup, vote_committee, vote_details
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org