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:40 UTC
(tooling-trusted-releases) branch sbp updated (03d815f0 -> 05bc82a8)
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a change to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 03d815f0 Require SVN in development, and check SVN reachability in production
add 9961f677 Bump the codeql-action group with 2 updates
add b4155bc8 #1420 - revocation now verified
add 1b8611f0 Give mandatory form fields an asterisk and fix error with file form inputs validation
new 05bc82a8 Allow Tooling presentation votes to be resolved early
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (03d815f0)
\
N -- N -- N refs/heads/sbp (05bc82a8)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.github/workflows/codeql.yaml | 4 +-
Makefile | 11 +-
atr/api/__init__.py | 20 ++--
atr/config.py | 50 +++------
atr/db/interaction.py | 9 +-
atr/form.py | 31 ++++--
atr/get/resolve.py | 2 +-
atr/pgp.py | 183 +++++++++++++++++++-------------
atr/server.py | 34 ------
atr/shared/keys.py | 1 +
atr/shared/projects.py | 3 +
atr/storage/writers/announce.py | 5 +-
atr/storage/writers/keys.py | 17 ++-
atr/storage/writers/vote.py | 4 +-
atr/tasks/checks/signature.py | 82 +++++++-------
atr/templates/resolve-tabulated.html | 4 +-
atr/util.py | 8 +-
playwright/test.py | 34 ------
pyproject.toml | 2 +-
scripts/hints_import.py | 2 +-
start-atr.sh | 10 --
start-dev.sh | 10 --
tests/e2e/announce/conftest.py | 20 ----
tests/unit/test_checks_signature.py | 10 +-
tests/unit/test_config_secrets.py | 48 ---------
tests/unit/test_keys_writer.py | 20 ++--
tests/unit/test_pgp.py | 120 ++++++++++++++++-----
tests/unit/test_signature_provenance.py | 24 ++---
tests/unit/test_vote_resolution.py | 118 +++++++++++++++++---
uv.lock | 22 ++--
30 files changed, 470 insertions(+), 438 deletions(-)
delete mode 100644 tests/unit/test_config_secrets.py
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: Allow Tooling presentation votes to be resolved early
Posted by sb...@apache.org.
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