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