You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/10/21 13:48:21 UTC
(tooling-trusted-releases) branch compare_git_hashes updated (74aa119e -> 711ee3de)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch compare_git_hashes
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 74aa119e #1727 - Multi-repository support for source uploads and compare check.
discard b4834363 Add Maven plugin mention on the compose upload page
discard 12760360 Move heatmap unreviewed warning up the page
add d80b6317 Move heatmap unreviewed warning up the page
add 74260466 Add Maven plugin mention on the compose upload page
new 711ee3de #1727 - Multi-repository support for source uploads and compare check.
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 (74aa119e)
\
N -- N -- N refs/heads/compare_git_hashes (711ee3de)
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:
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #1727 - Multi-repository support for source uploads and compare check.
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch compare_git_hashes
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 711ee3debc670126cb6bb96dbc1185fbb0cd51eb
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Oct 20 17:02:02 2026 +0100
#1727 - Multi-repository support for source uploads and compare check.
---
atr/api/__init__.py | 64 ++++++++++
atr/attestable.py | 66 ++++++----
atr/db/interaction.py | 55 +++++---
atr/docs/input-validation.md | 13 +-
atr/docs/trusted-publishing.md | 66 ++++++++--
atr/get/draft.py | 55 ++++++++
atr/get/upload.py | 14 ++-
atr/models/api.py | 53 ++++++++
atr/models/args.py | 1 +
atr/models/attestable.py | 9 ++
atr/models/sql.py | 12 ++
atr/post/draft.py | 40 ++++++
atr/post/upload.py | 10 +-
atr/shared/draft.py | 11 ++
atr/shared/projects.py | 3 +-
atr/source.py | 81 ++++++++----
atr/ssh.py | 9 ++
atr/storage/datatypes.py | 3 +
atr/storage/readers/releases.py | 18 +++
atr/storage/writers/release.py | 71 +++++++++++
atr/storage/writers/revision.py | 26 +++-
atr/storage/writers/ssh.py | 3 +
atr/tasks/__init__.py | 4 +-
atr/tasks/checks/compare.py | 73 +++++++----
atr/tasks/quarantine.py | 3 +
atr/templates/check-selected-path-table.html | 7 ++
atr/templates/draft-tools.html | 19 +++
atr/util.py | 7 +-
migrations/versions/0135_2026.10.21_7e613682.py | 44 +++++++
pip-audit.requirements | 16 +--
pyproject.toml | 4 +-
tests/unit/test_api_release_source.py | 113 +++++++++++++++++
tests/unit/test_attestable.py | 23 ++++
tests/unit/test_checks_compare.py | 116 ++++++++++++++---
tests/unit/test_create_revision.py | 36 +-----
tests/unit/test_quarantine_task.py | 1 +
tests/unit/test_release_writer_archive_sources.py | 146 ++++++++++++++++++++++
tests/unit/test_source.py | 142 +++++++++++++++++++--
tests/unit/test_trusted_project.py | 114 +++++++++++++++++
tests/unit/test_upload_json.py | 60 +++------
tests/unit/test_validate.py | 11 +-
uv.lock | 140 +++++++++++----------
42 files changed, 1449 insertions(+), 313 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index a075252a..cff00710 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1201,6 +1201,33 @@ async def publisher_release_announce(
).model_dump(mode="json"), 200
+@api.typed(
+ auth_scheme=api_auth.Auth.BODY_OIDC,
+ response=(models.api.PublisherReleaseSourceResults, 201),
+)
+async def publisher_release_source(
+ _publisher_release_source: Literal["publisher/release/source"],
+ data: models.api.PublisherReleaseSourceArgs,
+) -> DictResponse:
+ """
+ URL: POST /publisher/release/source
+
+ Declare the repository and commit that source archives in a draft were built from,
+ with a corroborating Trusted Publisher JWT.
+ """
+ ctx = api.auth.trusted_publisher_context()
+ asf_uid, project = await interaction.trusted_project_for_payload(
+ ctx.payload,
+ ctx.asf_uid,
+ interaction.TrustedProjectPhase.COMPOSE,
+ )
+ revision = await _declare_sources(asf_uid, project.safe_key, data.version, data.sources, data.expected_revision)
+ return models.api.PublisherReleaseSourceResults(
+ endpoint="/publisher/release/source",
+ revision=revision,
+ ).model_dump(mode="json"), 201
+
+
@api.typed(
auth_scheme=api_auth.Auth.BODY_OIDC,
rate_limit=(10, datetime.timedelta(hours=1)),
@@ -1233,6 +1260,7 @@ async def publisher_ssh_register(
data.ssh_key,
payload,
source_commit=data.source_commit,
+ declare_sources=data.declare_sources,
)
return models.api.PublisherSshRegisterResults(
@@ -1564,6 +1592,27 @@ async def release_revisions(
).model_dump(mode="json"), 200
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.ReleaseSourceResults, 201),
+)
+async def release_source(
+ _release_source: Literal["release/source"],
+ data: models.api.ReleaseSourceArgs,
+) -> DictResponse:
+ """
+ URL: POST /release/source
+
+ Declare the repository and commit that source archives in a draft were built from.
+ This is for projects with several GitHub repositories, where ATR can't tell which one an archive came from.
+ """
+ revision = await _declare_sources(_jwt_asf_uid(), data.project, data.version, data.sources, data.expected_revision)
+ return models.api.ReleaseSourceResults(
+ endpoint="/release/source",
+ revision=revision,
+ ).model_dump(mode="json"), 201
+
+
@api.typed(
auth_scheme=api_auth.Auth.BEARER,
method="POST",
@@ -2274,6 +2323,21 @@ async def vote_tabulate(
).model_dump(mode="json"), 200
+async def _declare_sources(
+ asf_uid: str,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ sources: list[models.api.SourceDeclaration],
+ expected_revision: safe.RevisionNumber | None,
+) -> sql.Revision:
+ try:
+ async with storage.write(asf_uid) as write:
+ wacp = await write.as_project_committee_participant(project_key)
+ return await wacp.release.set_archive_sources(project_key, version_key, sources, expected_revision)
+ except storage.AccessError as e:
+ raise _http_exception_from_storage_access_error(e) from e
+
+
def _http_exception_from_storage_access_error(error: storage.AccessError) -> exceptions.HTTPException:
message = str(error)
status = error.status or 500
diff --git a/atr/attestable.py b/atr/attestable.py
index 8f002893..5674ecf4 100644
--- a/atr/attestable.py
+++ b/atr/attestable.py
@@ -231,30 +231,15 @@ def effective_path_provenance(
path_to_hash: dict[safe.RelPath, str],
previous: models.Attestable | None,
) -> dict[str, models.ProvenanceV2]:
- result: dict[str, models.ProvenanceV2] = {}
- caller: dict[str, models.ProvenanceV2] = {}
- if path_provenance is not None:
- caller = {str(path_key): value for path_key, value in path_provenance.items()}
- prev_v2: models.AttestableV2 | None = previous if isinstance(previous, models.AttestableV2) else None
- for path_key, content_hash in path_to_hash.items():
- path_str = str(path_key)
- # Use the provenance from the caller, if provided
- if (caller_entry := caller.get(path_str)) is not None:
- result[path_str] = caller_entry
- continue
- if prev_v2 is None:
- continue
- prev_entry = prev_v2.paths.get(path_str)
- # Skip the path if it is not in the previous attestable data
- if prev_entry is None:
- continue
- if prev_entry.content_hash != content_hash:
- continue
- if prev_entry.provenance is None:
- continue
- # Otherwise, we use the provenance from the previous attestable
- result[path_str] = prev_entry.provenance
- return result
+ return _effective_path_values(path_provenance, path_to_hash, previous, lambda entry: entry.provenance)
+
+
+def effective_path_sources(
+ path_sources: dict[safe.RelPath, models.ArchiveSourceV2] | None,
+ path_to_hash: dict[safe.RelPath, str],
+ previous: models.Attestable | None,
+) -> dict[str, models.ArchiveSourceV2]:
+ return _effective_path_values(path_sources, path_to_hash, previous, lambda entry: entry.source)
def github_tp_payload_path(
@@ -523,6 +508,7 @@ async def write_files_data(
base_path: safe.StatePath,
classifications: dict[safe.RelPath, str] | None = None,
effective_path_provenance: dict[str, models.ProvenanceV2] | None = None,
+ effective_path_sources: dict[str, models.ArchiveSourceV2] | None = None,
swhid_dirs: dict[str, str] | None = None,
sha3_hashes: dict[str, str] | None = None,
source: models.SourceV2 | None = None,
@@ -537,6 +523,7 @@ async def write_files_data(
base_path,
classifications=classifications,
effective_path_provenance=effective_path_provenance,
+ effective_path_sources=effective_path_sources,
swhid_dirs=swhid_dirs,
sha3_hashes=sha3_hashes,
)
@@ -558,6 +545,34 @@ async def _atomic_write_readonly(file_path: pathlib.Path, content: str) -> None:
await asyncio.to_thread(os.chmod, file_path, _READONLY_PERMISSIONS)
+def _effective_path_values[T](
+ given: dict[safe.RelPath, T] | None,
+ path_to_hash: dict[safe.RelPath, str],
+ previous: models.Attestable | None,
+ recorded: Callable[[models.PathEntryV2], T | None],
+) -> dict[str, T]:
+ # A per-path value is what the caller gives, or else what the previous revision recorded
+ # for the same path with the same content
+ result: dict[str, T] = {}
+ caller: dict[str, T] = {}
+ if given is not None:
+ caller = {str(path_key): value for path_key, value in given.items()}
+ prev_v2: models.AttestableV2 | None = previous if isinstance(previous, models.AttestableV2) else None
+ for path_key, content_hash in path_to_hash.items():
+ path_str = str(path_key)
+ if (caller_entry := caller.get(path_str)) is not None:
+ result[path_str] = caller_entry
+ continue
+ if prev_v2 is None:
+ continue
+ prev_entry = prev_v2.paths.get(path_str)
+ if (prev_entry is None) or (prev_entry.content_hash != content_hash):
+ continue
+ if (value := recorded(prev_entry)) is not None:
+ result[path_str] = value
+ return result
+
+
def _compute_hashes_with_attribution( # noqa: C901
current_hash_to_paths: dict[str, set[safe.RelPath]],
path_to_size: dict[safe.RelPath, int],
@@ -613,6 +628,7 @@ async def _generate_files_data(
base_path: safe.StatePath,
classifications: dict[safe.RelPath, str] | None = None,
effective_path_provenance: dict[str, models.ProvenanceV2] | None = None,
+ effective_path_sources: dict[str, models.ArchiveSourceV2] | None = None,
swhid_dirs: dict[str, str] | None = None,
sha3_hashes: dict[str, str] | None = None,
) -> models.AttestableV2:
@@ -633,6 +649,7 @@ async def _generate_files_data(
if classifications is None:
classifications = await compute_classifications(path_to_hash, release_policy, base_path)
provenance_map = effective_path_provenance or {}
+ source_map = effective_path_sources or {}
return models.AttestableV2(
hashes=dict(new_hashes),
paths={
@@ -640,6 +657,7 @@ async def _generate_files_data(
content_hash=hash_ref,
classification=classifications[path_key],
provenance=provenance_map.get(str(path_key)),
+ source=source_map.get(str(path_key)),
)
for path_key, hash_ref in path_to_hash.items()
},
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 68b8c347..3fec3516 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -1410,35 +1410,31 @@ async def _trusted_dist_lookup(
async def _trusted_project(repository: str, workflow_ref: str, phase: TrustedProjectPhase) -> sql.Project:
# Debugging
log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
- repository_name, workflow_path = _trusted_project_checks(repository, workflow_ref)
+ workflow_path = _trusted_project_checks(repository, workflow_ref)
rpnf_error = ReleasePolicyNotFoundError(
- f"Release policy for repository {repository_name} and {phase.value} workflow path {workflow_path} not found"
+ f"Release policy for {repository} and {phase.value} workflow path {workflow_path} not found"
)
# TODO: If a policy is reused between projects, we can't get the project
async with db.session() as db_data:
match phase:
case TrustedProjectPhase.COMPOSE:
# Searches in github_*compose*_workflow_path
- policy = await db_data.release_policy(
- github_repository_name=repository_name,
- github_compose_workflow_path_has=workflow_path,
- ).demand(rpnf_error)
+ policies = await db_data.release_policy(github_compose_workflow_path_has=workflow_path).all()
case TrustedProjectPhase.VOTE:
# Searches in github_*vote*_workflow_path
- policy = await db_data.release_policy(
- github_repository_name=repository_name,
- github_vote_workflow_path_has=workflow_path,
- ).demand(rpnf_error)
+ policies = await db_data.release_policy(github_vote_workflow_path_has=workflow_path).all()
case TrustedProjectPhase.FINISH:
# Searches in github_*finish*_workflow_path
- policy = await db_data.release_policy(
- github_repository_name=repository_name,
- github_finish_workflow_path_has=workflow_path,
- ).demand(rpnf_error)
- project = await db_data.project(release_policy_id=policy.id).demand(
- InteractionError(f"Project for release policy {policy.id} not found")
- )
+ policies = await db_data.release_policy(github_finish_workflow_path_has=workflow_path).all()
+ matches = await _trusted_project_matches(db_data, policies, repository)
+ if not matches:
+ raise rpnf_error
+ # Two projects with the same repository and workflow can't be told apart, so neither is chosen
+ if len(matches) > 1:
+ keys = ", ".join(sorted(project.key for project in matches))
+ raise InteractionError(f"{repository} and {workflow_path} match more than one project: {keys}")
+ project = matches[0]
if project.committee is None:
raise InteractionError(f"Project {project.key} has no committee")
if not await ci_builds_enabled(project.committee.key):
@@ -1446,10 +1442,9 @@ async def _trusted_project(repository: str, workflow_ref: str, phase: TrustedPro
return project
-def _trusted_project_checks(repository: str, workflow_ref: str) -> tuple[str, str]:
+def _trusted_project_checks(repository: str, workflow_ref: str) -> str:
if not repository.startswith("apache/"):
raise InteractionError("Repository must start with 'apache/'")
- repository_name = repository.removeprefix("apache/")
if not workflow_ref.startswith(repository + "/"):
raise InteractionError(f"Workflow ref must start with repository, got {workflow_ref}")
workflow_path_at = workflow_ref.removeprefix(repository + "/")
@@ -1458,7 +1453,27 @@ def _trusted_project_checks(repository: str, workflow_ref: str) -> tuple[str, st
workflow_path = workflow_path_at.rsplit("@", 1)[0]
if not workflow_path.startswith(".github/workflows/"):
raise InteractionError(f"Workflow path must start with '.github/workflows/', got {workflow_path}")
- return repository_name, workflow_path
+ return workflow_path
+
+
+async def _trusted_project_matches(
+ db_data: db.Session, policies: Sequence[sql.ReleasePolicy], repository: str
+) -> list[sql.Project]:
+ matches: list[sql.Project] = []
+ for policy in policies:
+ project = await db_data.project(release_policy_id=policy.id).get()
+ # A policy with no project can't be the one a workflow is acting for, so it mustn't spoil the lookup
+ if project is None:
+ log.warning(f"Release policy {policy.id} lists a workflow path but has no project")
+ continue
+ # A named repository pins the workflow to it - an empty one means any repository the metadata lists
+ if policy.github_repository_name:
+ allowed = {"apache/" + policy.github_repository_name}
+ else:
+ allowed = project.github_repositories
+ if repository in allowed:
+ matches.append(project)
+ return matches
def _trusted_summary_add(summary: TrustedVoteSummary, choice: sql.VoteChoice, is_binding: bool) -> None:
diff --git a/atr/docs/input-validation.md b/atr/docs/input-validation.md
index e853850f..35ddf266 100644
--- a/atr/docs/input-validation.md
+++ b/atr/docs/input-validation.md
@@ -305,7 +305,7 @@ These checks find stored records that disagree with each other, and stored setti
| Release and policy download path suffixes are valid paths after filling in their template variables. An unset release suffix uses the policy suffix, and an empty one means the distribution root. Both are valid. | [`release_download_path_suffix`](/ref/atr/validate.py), [`project_download_path_suffix`](/ref/atr/validate.py) | [`validate_download_path_suffix`](/ref/atr/models/validation.py) |
| Recipient defaults have only `vote` and `announce` entries. Each has a `to` string and `cc` and `bcc` lists of strings, and every address is on `apache.org` or one of its subdomains. The web forms and policy API do not check these addresses when saving them. An entry with an empty `to` and empty lists is allowed. | [`project_recipient_defaults`](/ref/atr/validate.py) | [`_set_recipient_defaults`](/ref/atr/storage/writers/policy.py), [`_validate_recipient`](/ref/atr/mail.py) |
| The minimum voting period is unset, 0, or 72 to 168 hours. Template and RAT excludes URLs use HTTPS on an allowed host. A security contact, if set, is `security@apache.org` or the committee's own `security@` address. | [`project_min_hours`](/ref/atr/validate.py), [`project_fetch_urls`](/ref/atr/validate.py), [`project_security_contact`](/ref/atr/validate.py) | [`validation.py`](/ref/atr/models/validation.py) |
-| A Trusted Publishing branch or workflow path requires a repository name. The name has no slash, and every workflow path starts with `.github/workflows/`. | [`project_trusted_publishing`](/ref/atr/validate.py) | [`validate_trusted_publishing_constraints`](/ref/atr/util.py) |
+| A Trusted Publishing repository name, if set, has no slash, and every workflow path starts with `.github/workflows/`. | [`project_trusted_publishing`](/ref/atr/validate.py) | [`validate_trusted_publishing_constraints`](/ref/atr/util.py) |
| A podling project does not use manual voting. | [`project_vote_mode`](/ref/atr/validate.py) | [`edit_vote`](/ref/atr/storage/writers/policy.py) |
| Each release finalisation task has a completed SVN publication task for the same project, version, and revision. That task has a valid stored result that records the SVN revision. This only audits the task records. Finalisation itself verifies the published files. | [`release_finalise_publication`](/ref/atr/validate.py) | [`release`](/ref/atr/storage/writers/announce.py) |
| A pending quarantine has a queued or active validation task. Without one, the upload stays pending and blocks the vote. | [`quarantine_pending`](/ref/atr/validate.py) | [`create_revision_with_quarantine`](/ref/atr/storage/writers/revision.py) |
@@ -374,17 +374,16 @@ fields alone.
Trusted Publishing settings are validated when they are stored and again when they are used. On
write, [`validate_trusted_publishing_constraints`](/ref/atr/util.py)
(validate_trusted_publishing_constraints) and [`policy.py`](/ref/atr/storage/writers/policy.py)
-normalize the configured repository name, branch, and workflow paths and reject incomplete or
-impossible combinations. A workflow path cannot be stored without a repository name. A branch cannot
-be stored without a repository name. Repository names are stored without a slash. Every workflow
-path must begin with `.github/workflows/`.
+normalize the configured repository name, branch, and workflow paths. The repository name is
+optional and is stored without a slash. Every workflow path must begin with `.github/workflows/`.
At request time, [`_trusted_project_checks`](/ref/atr/db/interaction.py) (_trusted_project_checks)
and [`_trusted_project`](/ref/atr/db/interaction.py) (_trusted_project) compare the GitHub token
claims with the stored policy. The repository must be under `apache`. The workflow reference must
begin with that same repository, must include a git ref, and must resolve to a workflow path under
-`.github/workflows/`. ATR then looks up the project by repository name and by the phase specific
-workflow path that was stored for compose, vote, or finish. Distribution callbacks add one more
+`.github/workflows/`. ATR then looks up the project by the phase specific workflow path that was
+stored for compose, vote, or finish, and by repository: the one named in the policy when it sets
+one, or otherwise any GitHub repository in the project metadata. Distribution callbacks add one more
contextual check in [`trusted_jwt_for_dist`](/ref/atr/db/interaction.py) (trusted_jwt_for_dist),
which refuses the request unless the named release exists and is in the expected phase for the
requested operation. The cryptographic validation of the token itself is described in
diff --git a/atr/docs/trusted-publishing.md b/atr/docs/trusted-publishing.md
index 517e42f7..775545a0 100644
--- a/atr/docs/trusted-publishing.md
+++ b/atr/docs/trusted-publishing.md
@@ -144,23 +144,24 @@ genuine, matching what was built on the GitHub runners.
## Configuring repository and workflow paths
For ATR to accept release operations that a GitHub Actions workflow performs on the project's
-behalf, it has to know which repository the workflow runs in, and which workflows are permitted to
-perform the operations of each phase of a release. You configure this in the project's release
+behalf, it has to know which repositories the workflow may run in, and which workflows are permitted
+to perform the operations of each phase of a release. You configure this in the project's release
policy, under the Trusted Publishing tab of the project settings.
There are three groups of settings.
### Repository name
-The name of the project's GitHub repository, without the `apache/` prefix. For example, if the
-repository is `apache/example`, enter `example`. The name must not contain a slash. You have to set
-this before any workflow path will be accepted, because ATR matches the repository named in the
-GitHub token against this value.
+The name of one GitHub repository, without the `apache/` prefix, whose workflows may act for the
+project. For example, if the repository is `apache/example`, enter `example`. The name must not
+contain a slash. This is optional. When it is set, ATR only accepts tokens from that repository.
+When it is empty, ATR accepts tokens from any GitHub repository listed in the project metadata. Set
+it when the release workflow lives somewhere other than the source repositories, or when you want
+to pin workflows to one of several.
### Repository branch
-The branch that release builds run from, for example `main` or `2.5.x`. This is optional, but if you
-do set a branch you must also set a repository name.
+The branch that release builds run from, for example `main` or `2.5.x`. This is optional.
### Workflow paths
@@ -184,11 +185,11 @@ finish a release unless it is also listed under finish.
When a workflow calls one of the Trusted Publishing endpoints, GitHub sends ATR an OIDC token that
names the repository, such as `apache/example`, and the workflow reference, such as
-`apache/example/.github/workflows/release-compose.yml@refs/heads/main`. ATR strips the `apache/`
-prefix and the trailing `@` git ref, then looks for a project whose release policy has a matching
-repository name and lists that workflow path under the phase being requested. If there is no match,
-the request is refused. The committee must also be eligible for Trusted Publishing, as described
-below.
+`apache/example/.github/workflows/release-compose.yml@refs/heads/main`. ATR strips the trailing `@`
+git ref, then looks for a project whose release policy lists that workflow path under the phase
+being requested and whose repository matches the token: the policy's repository name when one is
+set, or otherwise any GitHub repository in the project metadata. If there is no match, the request
+is refused. The committee must also be eligible for Trusted Publishing, as described below.
## GitHub Actions for ATR
@@ -224,6 +225,45 @@ Each action's README has the full list of inputs and some example workflows. Whe
release in ATR, the upload page also shows an example workflow already filled in with your project
name.
+### Projects with several source repositories
+
+When the project metadata lists more than one GitHub repository, ATR cannot tell which repository a
+source archive was built from, so it does not record a single source commit for the release.
+Instead, each source archive is declared on its own, as a repository and a commit, and ATR compares
+the archive against that commit.
+
+You can declare an archive from the compose page, using the _Declare source_ button beside it. A
+workflow does not usually need to, because ATR declares the source archives that a workflow uploads
+as coming from the repository the workflow ran in. The commit is the one the workflow declared when
+it registered its SSH key, or otherwise the commit the run started from.
+
+If your workflow builds archives from more than one repository, that would record the wrong
+repository for some of them. Pass `"declare_sources": false` when the workflow registers its key,
+and ATR will leave the archives undeclared. The workflow then declares them itself after the upload,
+by calling `POST /api/publisher/release/source` with the path, repository, and commit of each
+archive:
+
+```json
+{
+ "publisher": "user",
+ "jwt": "<OIDC token>",
+ "version": "1.0.0",
+ "sources": [
+ {"relpath": "example-1.0.0-src.tar.gz", "repository": "apache/example", "commit": "<40 hex digits>"}
+ ]
+}
+```
+
+Each call creates a new revision of the draft, so declare all of the archives in one call. The
+repository must be one that the project metadata lists, and each path must be a source archive in
+the draft. If you pass `expected_revision`, ATR refuses the call when the draft has changed since
+that revision. You can make the same call with a personal API token as `POST /api/release/source`,
+giving `project` in place of `publisher` and `jwt`.
+
+A declaration applies to the archive as it was when the declaration was made, so uploading the
+archive again removes it. Until an archive has a declaration, ATR cannot compare it against its
+source, and records a concern that the release manager must note before starting a vote.
+
## How ATR detects automated release keys
ATR identifies automated release keys in two ways, at two different levels.
diff --git a/atr/get/draft.py b/atr/get/draft.py
index a33d3235..97b7e75d 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -23,10 +23,14 @@ from typing import Literal
import aiofiles.os
import asfquart.base as base
+import atr.attestable as attestable
import atr.blueprints.get as get
+import atr.classify as classify
import atr.errors as errors
import atr.form as form
+import atr.models.attestable
import atr.models.safe as safe
+import atr.models.sql as sql
import atr.paths as paths
import atr.post as post
import atr.shared as shared
@@ -79,6 +83,14 @@ async def tools(
submit_classes="btn-outline-secondary",
empty=True,
)
+ entry = await _path_entry(release, file_path)
+ declared = entry.source if (entry is not None) else None
+ source_form = None
+ candidates = sorted(release.project.github_repositories)
+ if (len(candidates) > 1) and (entry is not None) and (entry.classification == classify.FileType.SOURCE.value):
+ source_form = await _render_source_form(
+ project_key, version_key, file_path, candidates, declared, release.safe_latest_revision_number
+ )
return await template.render(
"draft-tools.html",
asf_id=session.uid,
@@ -89,4 +101,47 @@ async def tools(
release=release,
format_file_size=util.format_file_size,
sha512_form=sha512_form,
+ source_form=source_form,
+ source_declared=declared,
)
+
+
+async def _path_entry(release: sql.Release, file_path: safe.RelPath) -> atr.models.attestable.PathEntryV2 | None:
+ if release.latest_revision_number is None:
+ return None
+ recorded = await attestable.load(
+ release.safe_project_key, release.safe_version_key, release.safe_latest_revision_number
+ )
+ if not isinstance(recorded, atr.models.attestable.AttestableV2):
+ return None
+ return recorded.paths.get(str(file_path))
+
+
+async def _render_source_form(
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ file_path: safe.RelPath,
+ candidates: list[str],
+ declared: atr.models.attestable.ArchiveSourceV2 | None,
+ revision_number: safe.RevisionNumber,
+) -> str:
+ # The select can't be told which option is current, so that one goes first
+ if (declared is not None) and (declared.repository in candidates):
+ candidates = [declared.repository, *(c for c in candidates if (c != declared.repository))]
+ rendered = await form.render(
+ model_cls=shared.draft.ArchiveSourceForm,
+ action=util.as_url(
+ post.draft.archive_source,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ file_path=str(file_path),
+ ),
+ submit_label="Declare source",
+ submit_classes="btn-outline-primary",
+ defaults={
+ "repository": [(candidate, candidate) for candidate in candidates],
+ "commit": declared.commit if (declared is not None) else "",
+ "rendered_revision": revision_number,
+ },
+ )
+ return str(rendered)
diff --git a/atr/get/upload.py b/atr/get/upload.py
index b9c25db0..6d5bce36 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -88,12 +88,22 @@ async def selected(
block.h2(id="file-upload")["File upload"]
block.p["Use this form to add files to this candidate draft."]
+ # With several repositories a release level commit means nothing, so each archive is declared after upload
+ several_repositories = len(selected_source.candidates) > 1
+ source_note = None
+ if several_repositories:
+ source_note = htm.p(".text-muted")[
+ "This project lists several GitHub repositories, so the repository and commit each source archive "
+ "was built from are declared on the compose page once the files are uploaded.",
+ ]
await form.render_block(
block,
model_cls=shared.upload.AddFilesForm,
submit_label="Add files",
form_classes=".atr-canary.py-4.px-5",
defaults={"commit_hash": selected_source.override or ""},
+ skip=["commit_hash"] if several_repositories else None,
+ pre_submit=source_note,
)
block.append(htpy.div("#upload-progress-container.d-none"))
@@ -314,7 +324,9 @@ def _workflow_upload_warning(selected_source: atr.models.attestable.SourceV2) ->
"If you meant to update the release, re-running the workflow is probably the better route.",
]
# An override means someone has already said which commit the files come from
- if not selected_source.override:
+ if len(selected_source.candidates) > 1:
+ warning.p(".mb-0")["Declare the source of each archive you upload on the compose page afterwards."]
+ elif not selected_source.override:
warning.p(".mb-0")[
"The workflow recorded the source commit as ",
htm.code[selected_source.sha],
diff --git a/atr/models/api.py b/atr/models/api.py
index e66cd763..2c81c1c7 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -722,6 +722,12 @@ class PublisherDistributionRecordResults(schema.Strict):
success: Literal[True] = schema.example(True)
+class SourceDeclaration(schema.Strict):
+ relpath: safe.RelPath = schema.example("example-0.0.1-src.tar.gz")
+ repository: str = schema.example("apache/example")
+ commit: safe.CommitHash = schema.example("0123456789abcdef0123456789abcdef01234567")
+
+
class PublisherReleaseAnnounceArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
@@ -737,11 +743,34 @@ class PublisherReleaseAnnounceResults(schema.Strict):
success: Literal[True] = schema.example(True)
+class PublisherReleaseSourceArgs(schema.Strict):
+ publisher: str = schema.example("user")
+ jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
+ version: safe.VersionKey = schema.example("0.0.1")
+ sources: list[SourceDeclaration] = schema.example(
+ [
+ {
+ "relpath": "example-0.0.1-src.tar.gz",
+ "repository": "apache/example",
+ "commit": "0123456789abcdef0123456789abcdef01234567",
+ }
+ ]
+ )
+ expected_revision: safe.RevisionNumber | None = schema.default_example(None, "00003")
+
+
+class PublisherReleaseSourceResults(schema.Strict):
+ endpoint: Literal["/publisher/release/source"] = schema.alias("endpoint")
+ revision: sql.Revision
+
+
class PublisherSshRegisterArgs(schema.Strict):
publisher: str = schema.example("user")
jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
ssh_key: str = schema.example("ssh-ed25519 AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl")
source_commit: safe.OptionalCommitHash = schema.default_example(None, "0123456789abcdef0123456789abcdef01234567")
+ # False when the workflow builds from more than one repository, and declares each archive itself
+ declare_sources: bool = schema.default_example(True, True)
class PublisherSshRegisterResults(schema.Strict):
@@ -865,6 +894,26 @@ class ReleaseRevisionsResults(schema.Strict):
revisions: Sequence[sql.Revision]
+class ReleaseSourceArgs(schema.Strict):
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
+ sources: list[SourceDeclaration] = schema.example(
+ [
+ {
+ "relpath": "example-0.0.1-src.tar.gz",
+ "repository": "apache/example",
+ "commit": "0123456789abcdef0123456789abcdef01234567",
+ }
+ ]
+ )
+ expected_revision: safe.RevisionNumber | None = schema.default_example(None, "00003")
+
+
+class ReleaseSourceResults(schema.Strict):
+ endpoint: Literal["/release/source"] = schema.alias("endpoint")
+ revision: sql.Revision
+
+
@dataclasses.dataclass
class ReleaseStoreQuery:
project: str
@@ -1125,6 +1174,7 @@ type Results = Annotated[
| ProjectsListResults
| PublisherDistributionRecordResults
| PublisherReleaseAnnounceResults
+ | PublisherReleaseSourceResults
| PublisherSshRegisterResults
| PublisherVoteResolveResults
| ReleaseAnnounceResults
@@ -1134,6 +1184,7 @@ type Results = Annotated[
| ReleaseGetResults
| ReleasePathsResults
| ReleaseRevisionsResults
+ | ReleaseSourceResults
| ReleaseStoreResults
| ReleaseUploadResults
| ReleasesListResults
@@ -1189,6 +1240,7 @@ validate_project_releases = validator(ProjectReleasesResults)
validate_projects_list = validator(ProjectsListResults)
validate_publisher_distribution_record = validator(PublisherDistributionRecordResults)
validate_publisher_release_announce = validator(PublisherReleaseAnnounceResults)
+validate_publisher_release_source = validator(PublisherReleaseSourceResults)
validate_publisher_ssh_register = validator(PublisherSshRegisterResults)
validate_publisher_vote_resolve = validator(PublisherVoteResolveResults)
validate_release_announce = validator(ReleaseAnnounceResults)
@@ -1198,6 +1250,7 @@ validate_release_draft_delete = validator(ReleaseDraftDeleteResults)
validate_release_get = validator(ReleaseGetResults)
validate_release_paths = validator(ReleasePathsResults)
validate_release_revisions = validator(ReleaseRevisionsResults)
+validate_release_source = validator(ReleaseSourceResults)
validate_release_store = validator(ReleaseStoreResults)
validate_release_upload = validator(ReleaseUploadResults)
validate_releases_list = validator(ReleasesListResults)
diff --git a/atr/models/args.py b/atr/models/args.py
index 27b5d511..9b9299ef 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -170,6 +170,7 @@ class QuarantineValidate(schema.Strict):
archives: list[QuarantineArchiveEntry]
github_payload: github.TrustedPublisherPayload | None = None
source_commit: safe.CommitHash | None = None
+ declare_sources: bool = True
# Absent means leave the override alone, and None means clear it, so use model_fields_set to tell them apart
source_override: str | None = None
diff --git a/atr/models/attestable.py b/atr/models/attestable.py
index 73224e8d..ee034fa8 100644
--- a/atr/models/attestable.py
+++ b/atr/models/attestable.py
@@ -65,10 +65,17 @@ class ProvenanceV2(schema.Strict):
metadata: dict[str, Any] = schema.factory(dict)
+class ArchiveSourceV2(schema.Strict):
+ repository: str
+ commit: str
+
+
class PathEntryV2(schema.Strict):
content_hash: str
classification: str
provenance: ProvenanceV2 | None = None
+ # Where a source archive was built from, declared per archive when the project has several repositories
+ source: ArchiveSourceV2 | None = None
class SourceV2(schema.Strict):
@@ -76,6 +83,8 @@ class SourceV2(schema.Strict):
default: str | None = None
declared: str | None = None
override: str | None = None
+ # Every GitHub repository in the project metadata when the revision was made
+ candidates: list[str] = schema.factory(list)
@property
def sha(self) -> str:
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 4e7b7bd1..b7bb260f 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -28,6 +28,7 @@ import enum
import hashlib
import ipaddress
import json
+import re
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeVar, assert_never, overload
import pydantic
@@ -1021,6 +1022,8 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
)
# Declared by the workflow rather than signed by GitHub, so kept out of github_payload
source_commit: str | None = sqlmodel.Field(default=None)
+ # Whether the archives this key uploads are taken to come from the workflow's own repository
+ declare_sources: bool = sqlmodel.Field(default=True)
expires: int = sqlmodel.Field()
revoked: bool = sqlmodel.Field(default=False)
@@ -1241,6 +1244,15 @@ class Project(sqlmodel.SQLModel, table=True):
"""Whether the project's status is active."""
return self.status == ProjectStatus.ACTIVE
+ @property
+ def github_repositories(self) -> set[str]:
+ # Only the metadata says where the source lives - the policy's repository is where upload workflows run
+ return {
+ match[1].removesuffix(".git")
+ for url in self.repositories
+ if (match := re.fullmatch(r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?", url))
+ }
+
@property
def safe_key(self) -> safe.ProjectKey:
"""Get the typesafe validated name for the Project"""
diff --git a/atr/post/draft.py b/atr/post/draft.py
index fc1d5ba3..c6b621d4 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -29,6 +29,7 @@ import atr.db.interaction as interaction
import atr.form as form
import atr.get as get
import atr.log as log
+import atr.models.api as api
import atr.models.safe as safe
import atr.models.sql as sql
import atr.shared as shared
@@ -38,6 +39,45 @@ import atr.util as util
import atr.web as web
+@post.typed
+async def archive_source(
+ session: web.Committer,
+ _draft_source: Literal["draft/source"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ file_path: safe.RelPath,
+ source_form: shared.draft.ArchiveSourceForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /draft/source/<project_key>/<version_key>/<file_path>
+ Record which repository and commit a draft file was built from, creating a new revision.
+ """
+ try:
+ async with storage.write(session) as write:
+ wacp = await write.as_project_committee_participant(project_key)
+ declaration = api.SourceDeclaration(
+ relpath=file_path, repository=source_form.repository, commit=source_form.commit
+ )
+ await wacp.release.set_archive_sources(
+ project_key, version_key, [declaration], source_form.rendered_revision
+ )
+ except storage.AccessError as e:
+ await quart.flash(str(e), "error")
+ return await session.redirect(
+ get.draft.tools,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ file_path=str(file_path),
+ )
+
+ return await session.redirect(
+ get.compose.selected,
+ success=f"Source declared for {file_path}",
+ project_key=str(project_key),
+ version_key=str(version_key),
+ )
+
+
@post.typed
async def cache_reset(
session: web.Committer,
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 5ea90ce4..5dc9d158 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -26,7 +26,6 @@ import atr.get as get
import atr.log as log
import atr.models.safe as safe
import atr.shared as shared
-import atr.source as source
import atr.storage as storage
import atr.util as util
import atr.web as web
@@ -193,10 +192,13 @@ async def _upload_source_override(
) -> db.Opt[str | None]:
commit_hash = add_form.commit_hash
async with db.session() as data:
- release = await session.release(project_key, version_key, data=data, with_project_release_policy=True)
- repository = source.repository(release.project)
+ release = await session.release(project_key, version_key, data=data)
+ candidates = release.project.github_repositories
+ # With several repositories the source is declared per archive, so a release level commit is ignored
+ if len(candidates) > 1:
+ return db.NOT_SET
# The commit hash only means something for a GitHub source, so we insist on it there
- if repository and (commit_hash is None):
+ if candidates and (commit_hash is None):
raise storage.AccessError(
"This project has a GitHub source repository, so a source commit hash is required.", status=400
)
diff --git a/atr/shared/draft.py b/atr/shared/draft.py
index 896660ec..b4b61ffd 100644
--- a/atr/shared/draft.py
+++ b/atr/shared/draft.py
@@ -19,6 +19,17 @@ import atr.form as form
import atr.models.safe as safe
+class ArchiveSourceForm(form.Form):
+ repository: str = form.label("Repository", widget=form.Widget.SELECT, required=True)
+ commit: safe.CommitHash = form.label(
+ "Source commit hash",
+ "The full 40-character commit this archive was built from.",
+ widget=form.Widget.TEXT,
+ required=True,
+ )
+ rendered_revision: safe.RevisionNumber = form.label("Rendered revision", widget=form.Widget.HIDDEN)
+
+
class ClearQuarantineForm(form.Form):
quarantined_id: form.Int = form.label("Quarantine ID", widget=form.Widget.HIDDEN)
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 2b636f8e..8c487318 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -561,7 +561,8 @@ class TrustedPublishingPolicyForm(form.Form):
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
github_repository_name: str = form.label(
"GitHub repository name",
- "The name of the GitHub repository to use for the release, excluding the apache/ prefix.",
+ "Excluding the apache/ prefix. Leave empty to accept workflows from any GitHub repository in the"
+ " project metadata.",
)
github_repository_branch: str = form.label(
"GitHub repository branch",
diff --git a/atr/source.py b/atr/source.py
index 7935920c..38d1f7f5 100644
--- a/atr/source.py
+++ b/atr/source.py
@@ -17,8 +17,6 @@
from __future__ import annotations
-import re
-
import atr.attestable as attestable
import atr.db as db
import atr.models.attestable
@@ -36,8 +34,8 @@ def advance(
) -> atr.models.attestable.SourceV2:
source = source.model_copy()
if payload is not None:
- ensure_payload_repository(source, payload)
- source.default = payload.sha
+ # The run's starting commit only describes the source when the workflow ran in the source repository
+ source.default = payload.sha if (payload.repository == source.repository) else None
# The payload sha is only the commit the run started from, so the workflow can tell us what it checked out
# An upload that doesn't say should clear any earlier value, otherwise it'd be stale
source.declared = str(declared) if (declared is not None) else None
@@ -50,6 +48,16 @@ def advance(
return source
+async def archive(
+ project_key: safe.ProjectKey, version_key: safe.VersionKey, revision_number: safe.RevisionNumber, rel_path: str
+) -> atr.models.attestable.ArchiveSourceV2 | None:
+ recorded = await attestable.load(project_key, version_key, revision_number)
+ if not isinstance(recorded, atr.models.attestable.AttestableV2):
+ return None
+ entry = recorded.paths.get(rel_path)
+ return entry.source if (entry is not None) else None
+
+
async def current(release: sql.Release) -> atr.models.attestable.SourceV2:
previous = None
if release.latest_revision_number is not None:
@@ -59,23 +67,17 @@ async def current(release: sql.Release) -> atr.models.attestable.SourceV2:
return await initial(release, previous)
-def ensure_payload_repository(source: atr.models.attestable.SourceV2, payload: github.TrustedPublisherPayload) -> None:
- # A payload from some other repository says nothing about this release's source
- if source.repository != payload.repository:
- expected = source.repository or "no configured repository"
- raise ValueError(f"This release's source is {expected}, but the upload came from {payload.repository}")
-
-
async def initial(
release: sql.Release, previous: atr.models.attestable.Attestable | None
) -> atr.models.attestable.SourceV2:
+ candidates = sorted(release.project.github_repositories)
if isinstance(previous, atr.models.attestable.AttestableV2) and (previous.source is not None):
source = previous.source.model_copy()
- # The release policy says where the source lives, so it wins over whatever an earlier revision recorded
- if release.project.policy_github_repository_name or (not source.repository):
- source.repository = repository(release.project)
+ # Both follow the metadata, so a repository added or corrected since the last revision shows up
+ source.repository = repository(release.project)
+ source.candidates = candidates
return source
- source = atr.models.attestable.SourceV2(repository=repository(release.project))
+ source = atr.models.attestable.SourceV2(repository=repository(release.project), candidates=candidates)
if not release.commit_hash:
return source
payload = await attestable.latest_github_tp_payload(release.safe_project_key, release.safe_version_key)
@@ -88,10 +90,24 @@ async def initial(
async def inputs(
- project_key: safe.ProjectKey, version_key: safe.VersionKey, revision_number: safe.RevisionNumber
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ *,
+ rel_path: str | None = None,
) -> dict[str, str]:
source = await read(project_key, version_key, revision_number)
- return {"source_repository": source.repository, "source_commit": source.sha}
+ # The candidates decide whether a comparison is even possible, so they belong in the cache key too
+ values = {
+ "source_repository": source.repository,
+ "source_commit": source.sha,
+ "source_candidates": ",".join(source.candidates),
+ }
+ if rel_path is not None:
+ declared = await archive(project_key, version_key, revision_number, rel_path)
+ values["archive_repository"] = declared.repository if (declared is not None) else ""
+ values["archive_commit"] = declared.commit if (declared is not None) else ""
+ return values
async def read(
@@ -107,11 +123,28 @@ async def read(
def repository(project: sql.Project) -> str:
- if name := project.policy_github_repository_name:
- return "apache/" + name
- repositories = {
- match[1].removesuffix(".git")
- for url in project.repositories
- if (match := re.fullmatch(r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?", url))
- }
+ # More than one candidate means we can't say which repository a given archive came from, so nothing is chosen
+ repositories = project.github_repositories
return repositories.pop() if (len(repositories) == 1) else ""
+
+
+def uploaded(
+ record: atr.models.attestable.SourceV2,
+ payload: github.TrustedPublisherPayload,
+ path_to_hash: dict[safe.RelPath, str],
+ classifications: dict[safe.RelPath, str],
+ previous: atr.models.attestable.Attestable | None,
+) -> dict[safe.RelPath, atr.models.attestable.ArchiveSourceV2]:
+ # A workflow's archives come from the repository it ran in, when that is one of the source repositories
+ # With one repository the release level record already says so, and an earlier upload's archives aren't ours
+ if (len(record.candidates) < 2) or (payload.repository not in record.candidates):
+ return {}
+ declared = atr.models.attestable.ArchiveSourceV2(
+ repository=payload.repository, commit=record.declared or payload.sha
+ )
+ previous_hashes = attestable.path_hashes(previous) if (previous is not None) else {}
+ return {
+ path: declared
+ for path, content_hash in path_to_hash.items()
+ if (classifications[path] == "source") and (previous_hashes.get(str(path)) != content_hash)
+ }
diff --git a/atr/ssh.py b/atr/ssh.py
index 85d77626..09ba174e 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -95,6 +95,7 @@ class SSHServer(asyncssh.SSHServer):
self._github_project_key: safe.ProjectKey | None = None
self._github_read_only = False
self._github_source_commit: safe.CommitHash | None = None
+ self._github_declare_sources = True
peer_addr = conn.get_extra_info("peername")[0]
log.info(f"SSH connection received from {peer_addr}")
if not _rate_limit_check(global_ip_rate_buckets, peer_addr, _RATE_LIMIT_IP):
@@ -220,6 +221,7 @@ class SSHServer(asyncssh.SSHServer):
self._github_read_only = workflow_key.github_nid == interaction.GITHUB_TRUSTED_ROLE_NID
if workflow_key.source_commit is not None:
self._github_source_commit = safe.CommitHash(workflow_key.source_commit)
+ self._github_declare_sources = workflow_key.declare_sources
return True
def _get_asf_uid(self, process: asyncssh.SSHServerProcess) -> str:
@@ -244,6 +246,12 @@ class SSHServer(asyncssh.SSHServer):
raise RsyncArgsError("GitHub authentication did not resolve a project")
return self._github_project_key
+ def _get_github_declare_sources(self, process: asyncssh.SSHServerProcess) -> bool:
+ username = process.get_extra_info("username")
+ if username != "github":
+ return True
+ return self._github_declare_sources
+
def _get_github_source_commit(self, process: asyncssh.SSHServerProcess) -> safe.CommitHash | None:
username = process.get_extra_info("username")
if username != "github":
@@ -724,6 +732,7 @@ async def _step_07b_process_validated_rsync_write(
modify=modify,
github_payload=github_payload,
source_commit=source_commit,
+ declare_sources=server._get_github_declare_sources(process),
)
if isinstance(result, sql.Quarantined):
log.info(f"rsync upload quarantined for release {release_key}")
diff --git a/atr/storage/datatypes.py b/atr/storage/datatypes.py
index f52e49a1..069669f5 100644
--- a/atr/storage/datatypes.py
+++ b/atr/storage/datatypes.py
@@ -22,6 +22,7 @@ import enum
from collections.abc import Callable
import atr.classify as classify
+import atr.models.attestable
import atr.models.safe as safe
import atr.models.schema as schema
import atr.models.sql as sql
@@ -120,6 +121,8 @@ class PATRevocation(schema.Strict):
class PathInfo(schema.Strict):
+ # Source archives that need a declared repository and commit, mapped to it, or to None while missing
+ archive_sources: dict[safe.RelPath, atr.models.attestable.ArchiveSourceV2 | None] = schema.factory(dict)
blockers: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
checker_stats: list[CheckerStats] = schema.factory(list)
concerns: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
diff --git a/atr/storage/readers/releases.py b/atr/storage/readers/releases.py
index 71aa106e..640b71e0 100644
--- a/atr/storage/readers/releases.py
+++ b/atr/storage/readers/releases.py
@@ -22,9 +22,11 @@ import collections
import dataclasses
import atr.analysis as analysis
+import atr.attestable as attestable
import atr.classify as classify
import atr.db as db
import atr.db.interaction as interaction
+import atr.models.attestable
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
@@ -73,10 +75,26 @@ class GeneralPublic:
if file_types is None:
file_types = await self.__file_types(release, revision, all_paths)
info.file_types.update(file_types)
+ await self.__archive_sources(release, revision, info)
self.__pair_sboms(info, all_paths)
self.__compute_checker_stats(info, all_paths, tally.counts)
return info
+ async def __archive_sources(
+ self, release: sql.Release, revision: safe.RevisionNumber, info: datatypes.PathInfo
+ ) -> None:
+ # Only a project with several GitHub repositories declares per archive
+ if len(release.project.github_repositories) < 2:
+ return
+ recorded = await attestable.load(release.safe_project_key, release.safe_version_key, revision)
+ if not isinstance(recorded, atr.models.attestable.AttestableV2):
+ return
+ for path, file_type in info.file_types.items():
+ if file_type != classify.FileType.SOURCE:
+ continue
+ entry = recorded.paths.get(str(path))
+ info.archive_sources[path] = entry.source if (entry is not None) else None
+
def __accumulate_results(
self,
results: dict[safe.RelPath, list[sql.CheckResult]],
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index abfe6bf4..773a5bdf 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -39,6 +39,7 @@ import atr.analysis as analysis
import atr.attestable as attestable
import atr.auditlog as auditlog
import atr.catalog_site as catalog_site
+import atr.classify as classify
import atr.config as config
import atr.constants as constants
import atr.construct as construct
@@ -475,6 +476,76 @@ class CommitteeParticipant(FoundationCommitter):
)
return release
+ async def set_archive_sources(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ sources: Sequence[api.SourceDeclaration],
+ expected_revision: safe.RevisionNumber | None = None,
+ ) -> sql.Revision:
+ if not sources:
+ raise storage.AccessError("No archives were given to declare a source for", status=400)
+ if len({declaration.relpath for declaration in sources}) != len(sources):
+ raise storage.AccessError("Each archive can only be declared once", status=400)
+ release = await self.__data.release(project_key=str(project_key), version=str(version_key)).demand(
+ storage.AccessError("Release not found", status=404)
+ )
+ unknown = {declaration.repository for declaration in sources} - release.project.github_repositories
+ if unknown:
+ raise storage.AccessError(
+ f"Not one of this project's GitHub repositories: {', '.join(sorted(unknown))}", status=400
+ )
+ if release.latest_revision_number is None:
+ raise storage.AccessError("This draft has no files yet", status=404)
+ # The declarations are checked against the revision the caller was looking at, and the new
+ # revision is only made if that is still the latest - a declaration is about one particular upload
+ revision_number = expected_revision or release.safe_latest_revision_number
+ await self.__check_source_archives(project_key, version_key, revision_number, sources)
+ description = (
+ f"Source declared for {sources[0].relpath}"
+ if (len(sources) == 1)
+ else f"Source declared for {len(sources)} archives"
+ )
+ try:
+ result = await self.__write_as.revision.create_revision_with_quarantine(
+ project_key,
+ version_key,
+ self.__asf_uid,
+ allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
+ description=description,
+ expected_revision=revision_number,
+ path_sources={
+ declaration.relpath: attestable_models.ArchiveSourceV2(
+ repository=declaration.repository, commit=str(declaration.commit)
+ )
+ for declaration in sources
+ },
+ )
+ except (datatypes.FailedError, datatypes.RevisionMismatchError) as e:
+ raise storage.AccessError(str(e), status=409) from e
+ except ValueError as e:
+ raise storage.AccessError(str(e), status=400) from e
+ if isinstance(result, sql.Quarantined):
+ raise storage.AccessError("Source archives need validation before their source can be set", status=409)
+ return result
+
+ async def __check_source_archives(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ sources: Sequence[api.SourceDeclaration],
+ ) -> None:
+ recorded = await attestable.load(project_key, version_key, revision_number)
+ if not isinstance(recorded, attestable_models.AttestableV2):
+ raise storage.AccessError(f"Revision {revision_number} was not found", status=404)
+ for declaration in sources:
+ entry = recorded.paths.get(str(declaration.relpath))
+ if entry is None:
+ raise storage.AccessError(f"{declaration.relpath} is not in this draft", status=404)
+ if entry.classification != classify.FileType.SOURCE.value:
+ raise storage.AccessError(f"{declaration.relpath} is not a source archive", status=400)
+
async def set_commit_hash(
self,
project_key: safe.ProjectKey,
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 6ac51dcb..c38266eb 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -101,6 +101,7 @@ async def finalise_revision(
github_payload: github.TrustedPublisherPayload | None = None,
source_commit: safe.CommitHash | None = None,
source_override: db.Opt[str | None] = db.NOT_SET,
+ declare_sources: bool = True,
) -> sql.Revision:
try:
previous_attestable, merge_base_revision_key, _, merged_release = await _lock_and_merge(
@@ -143,6 +144,7 @@ async def finalise_revision(
github_payload=github_payload,
source_commit=source_commit,
source_override=source_override,
+ declare_sources=declare_sources,
)
@@ -167,6 +169,8 @@ async def _commit_new_revision(
github_payload: github.TrustedPublisherPayload | None = None,
source_commit: safe.CommitHash | None = None,
source_override: db.Opt[str | None] = db.NOT_SET,
+ path_sources: dict[safe.RelPath, atr.models.attestable.ArchiveSourceV2] | None = None,
+ declare_sources: bool = True,
) -> sql.Revision:
try:
source_record = source.advance(
@@ -234,6 +238,11 @@ async def _commit_new_revision(
swhid_dirs = attestable.compute_swhid_dirs(path_to_hash, previous_attestable, extracted_swhids)
sha3_hashes = await attestable.compute_sha3_hashes(path_to_hash, previous_attestable, new_revision_dir, sha3_hashes)
effective_provenance = attestable.effective_path_provenance(path_provenance, path_to_hash, previous_attestable)
+ if declare_sources and (github_payload is not None):
+ # The workflow's own repository and commit cover the archives it uploaded, unless told otherwise
+ uploaded = source.uploaded(source_record, github_payload, path_to_hash, classifications, previous_attestable)
+ path_sources = {**uploaded, **(path_sources or {})}
+ effective_sources = attestable.effective_path_sources(path_sources, path_to_hash, previous_attestable)
await attestable.write_files_data(
project_key,
@@ -247,6 +256,7 @@ async def _commit_new_revision(
new_revision_dir,
classifications=classifications,
effective_path_provenance=effective_provenance,
+ effective_path_sources=effective_sources,
swhid_dirs=swhid_dirs,
sha3_hashes=sha3_hashes,
source=source_record,
@@ -496,6 +506,8 @@ class CommitteeParticipant(FoundationCommitter):
github_payload: github.TrustedPublisherPayload | None = None,
source_commit: safe.CommitHash | None = None,
source_override: db.Opt[str | None] = db.NOT_SET,
+ path_sources: dict[safe.RelPath, atr.models.attestable.ArchiveSourceV2] | None = None,
+ declare_sources: bool = True,
) -> sql.Revision | sql.Quarantined:
"""Create a new revision, quarantining archives that require validation."""
async with db.session() as data:
@@ -521,10 +533,6 @@ class CommitteeParticipant(FoundationCommitter):
else:
old_revision = await interaction.latest_revision(release)
- # Checked again when the revision is recorded, but this rejects the upload before any files arrive
- if github_payload is not None:
- source.ensure_payload_repository(await source.current(release), github_payload)
-
if clone_from is not None:
old_release_dir = paths.release_directory_base(release) / str(clone_from)
else:
@@ -661,9 +669,10 @@ class CommitteeParticipant(FoundationCommitter):
if archive_paths:
# Setting the commit on its own brings no new files, so there's nothing to validate it against.
# An upload with a commit is quarantined, and the commit is applied when it is promoted.
- if (not isinstance(source_override, db.NotSet)) and (modify is None):
+ source_only = (not isinstance(source_override, db.NotSet)) or (path_sources is not None)
+ if source_only and (modify is None):
await aioshutil.rmtree(temp_dir)
- raise datatypes.FailedError("Source archives need validation before the source commit can be set.")
+ raise datatypes.FailedError("Source archives need validation before their source can be set.")
deduped = detection.deduplicate_quarantine_archives(archive_paths, path_to_hash)
if deduped:
return await self._quarantine_archives(
@@ -680,6 +689,7 @@ class CommitteeParticipant(FoundationCommitter):
github_payload=github_payload,
source_commit=source_commit,
source_override=source_override,
+ declare_sources=declare_sources,
)
return await _commit_new_revision(
@@ -700,6 +710,8 @@ class CommitteeParticipant(FoundationCommitter):
github_payload=github_payload,
source_commit=source_commit,
source_override=source_override,
+ path_sources=path_sources,
+ declare_sources=declare_sources,
)
async def set_tag(
@@ -768,6 +780,7 @@ class CommitteeParticipant(FoundationCommitter):
github_payload: github.TrustedPublisherPayload | None = None,
source_commit: safe.CommitHash | None = None,
source_override: db.Opt[str | None] = db.NOT_SET,
+ declare_sources: bool = True,
) -> sql.Quarantined:
file_metadata = [
sql.QuarantineFileEntryV1(
@@ -811,6 +824,7 @@ class CommitteeParticipant(FoundationCommitter):
github_payload.model_dump(exclude={"exp", "nbf"}) if (github_payload is not None) else None
),
"source_commit": source_commit,
+ "declare_sources": declare_sources,
"archives": [{"rel_path": entry.rel_path, "content_hash": entry.content_hash} for entry in file_metadata],
}
# Only present when set, as None is itself a value meaning to clear the override
diff --git a/atr/storage/writers/ssh.py b/atr/storage/writers/ssh.py
index d75a2b93..666efa08 100644
--- a/atr/storage/writers/ssh.py
+++ b/atr/storage/writers/ssh.py
@@ -106,6 +106,7 @@ class CommitteeParticipant(FoundationCommitter):
github_payload: github.TrustedPublisherPayload,
*,
source_commit: safe.CommitHash | None = None,
+ declare_sources: bool = True,
) -> tuple[str, int]:
now = int(time.time())
# Twenty minutes to upload all files
@@ -126,6 +127,7 @@ class CommitteeParticipant(FoundationCommitter):
github_nid=github_nid,
github_payload=json_payload,
source_commit=wsk_source_commit,
+ declare_sources=declare_sources,
expires=expires,
)
self.__data.add(wsk)
@@ -137,6 +139,7 @@ class CommitteeParticipant(FoundationCommitter):
github_uid=github_uid,
github_nid=github_nid,
source_commit=wsk_source_commit,
+ declare_sources=declare_sources,
expires=expires,
)
return fingerprint, expires
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 08effa69..c8e52c98 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -610,7 +610,7 @@ async def tar_gz_checks(
compare.INPUT_POLICY_KEYS,
release,
revision,
- await source.inputs(release.safe_project_key, release.safe_version_key, revision),
+ await source.inputs(release.safe_project_key, release.safe_version_key, revision, rel_path=path),
file=path,
)
license_h_ck = await checks.resolve_cache_key(
@@ -711,7 +711,7 @@ async def zip_checks(
compare.INPUT_POLICY_KEYS,
release,
revision,
- await source.inputs(release.safe_project_key, release.safe_version_key, revision),
+ await source.inputs(release.safe_project_key, release.safe_version_key, revision, rel_path=path),
file=path,
)
license_h_ck = await checks.resolve_cache_key(
diff --git a/atr/tasks/checks/compare.py b/atr/tasks/checks/compare.py
index 4b744370..0ac17172 100644
--- a/atr/tasks/checks/compare.py
+++ b/atr/tasks/checks/compare.py
@@ -56,7 +56,7 @@ _PERMITTED_ADDED_PATHS: Final[dict[str, list[str]]] = {
# Release policy fields which this check relies on - used for result caching
INPUT_POLICY_KEYS: Final[list[str]] = []
INPUT_EXTRA_ARGS: Final[list[str]] = ["github_tp_sha"]
-CHECK_VERSION: Final[str] = "6"
+CHECK_VERSION: Final[str] = "7"
@dataclasses.dataclass
@@ -97,43 +97,58 @@ async def source_trees(args: checks.FunctionArguments) -> results.Results | None
return None
payload = await source.read(args.project_key, args.version_key, args.revision_number)
- checkout_dir: str | None = None
- archive_dir: str | None = None
- if payload.repository and payload.sha:
- try:
- outcome = await _compare_checkout_and_archive(args, recorder, payload)
- except OSError as e:
- raise task.CheckRetryableError(
- "Error reading the extracted archive tree",
- {"error": str(e)},
- ) from e
- if outcome is None:
+ repository, sha = payload.repository, payload.sha
+ if not (repository and sha):
+ # With several repositories the release level record is empty, and each archive says for itself
+ declared = await source.archive(
+ args.project_key, args.version_key, args.revision_number, str(args.primary_rel_path)
+ )
+ if declared is None:
+ # A GitHub project we can't compare gets a concern rather than silence, so the release
+ # manager has to note it before the vote and voters can see the comparison is on them
+ if reason := _uncompared_reason(payload):
+ await recorder.concern(
+ "Could not compare this archive against its GitHub source. "
+ "Voters should check against the tagged source themselves",
+ {"reason": reason, "repositories": list(payload.candidates), "commit": sha or None},
+ )
return None
- checkout_dir, archive_dir = outcome
+ repository, sha = declared.repository, declared.commit
+ try:
+ outcome = await _compare_checkout_and_archive(args, recorder, repository, sha)
+ except OSError as e:
+ raise task.CheckRetryableError(
+ "Error reading the extracted archive tree",
+ {"error": str(e)},
+ ) from e
+ if outcome is None:
+ return None
+ checkout_dir, archive_dir = outcome
log.info(
"Ran compare.source_trees successfully",
project=args.project_key,
version=args.version_key,
revision=args.revision_number,
path=args.primary_rel_path,
- source_commit=payload.model_dump(),
+ repository=repository,
+ sha=sha,
github_checkout=checkout_dir,
archive_checkout=archive_dir,
)
return None
-async def _checkout_github_source(payload: atr.models.attestable.SourceV2, checkout_dir: safe.StatePath) -> str | None:
- repo_url = f"https://github.com/{payload.repository}.git"
+async def _checkout_github_source(repository: str, sha: str, checkout_dir: safe.StatePath) -> str | None:
+ repo_url = f"https://github.com/{repository}.git"
started_ns = time.perf_counter_ns()
try:
- await asyncio.wait_for(asyncio.to_thread(_clone_repo, repo_url, payload.sha, checkout_dir), timeout=360)
+ await asyncio.wait_for(asyncio.to_thread(_clone_repo, repo_url, sha, checkout_dir), timeout=360)
except TimeoutError:
elapsed_ms = (time.perf_counter_ns() - started_ns) / 1_000_000.0
log.exception(
"Timeout cloning GitHub repo for compare.source_trees",
repo_url=repo_url,
- sha=payload.sha,
+ sha=sha,
checkout_dir=str(checkout_dir),
clone_ms=elapsed_ms,
git_author_name=os.environ.get("GIT_AUTHOR_NAME"),
@@ -158,7 +173,7 @@ async def _checkout_github_source(payload: atr.models.attestable.SourceV2, check
log.exception(
"Failed to clone GitHub repo for compare.source_trees",
repo_url=repo_url,
- sha=payload.sha,
+ sha=sha,
checkout_dir=str(checkout_dir),
clone_ms=elapsed_ms,
git_author_name=os.environ.get("GIT_AUTHOR_NAME"),
@@ -174,7 +189,7 @@ async def _checkout_github_source(payload: atr.models.attestable.SourceV2, check
log.debug(
"Cloned GitHub repo for compare.source_trees",
repo_url=repo_url,
- sha=payload.sha,
+ sha=sha,
checkout_dir=str(checkout_dir),
clone_ms=elapsed_ms,
)
@@ -204,7 +219,8 @@ def _clone_repo(repo_url: str, sha: str, checkout_dir: safe.StatePath) -> None:
async def _compare_checkout_and_archive( # noqa: C901
args: checks.FunctionArguments,
recorder: checks.Recorder,
- payload: atr.models.attestable.SourceV2,
+ repository: str,
+ sha: str,
) -> tuple[str, str] | None:
if not (primary_abs_path := await recorder.abs_path()):
return None
@@ -219,12 +235,12 @@ async def _compare_checkout_and_archive( # noqa: C901
async with util.async_temporary_directory(prefix="trees-", dir=tmp_dir) as temp_dir:
github_dir = safe.StatePath(temp_dir) / "github"
await aiofiles.os.makedirs(github_dir, exist_ok=True)
- checkout_dir = await _checkout_github_source(payload, github_dir)
+ checkout_dir = await _checkout_github_source(repository, sha, github_dir)
if checkout_dir is None:
- repo_url = f"https://github.com/{payload.repository}.git"
+ repo_url = f"https://github.com/{repository}.git"
raise task.CheckRetryableError(
"Failed to clone GitHub repository for comparison",
- {"repo_url": repo_url, "sha": payload.sha},
+ {"repo_url": repo_url, "sha": sha},
)
archive_root_result = await _find_archive_root(primary_abs_path, extracted_dir)
if archive_root_result.root is None:
@@ -376,3 +392,12 @@ async def _find_archive_root(archive_path: safe.StatePath, extract_dir: safe.Sta
extra_entries=extra_entries,
)
return ArchiveRootResult(root=found_root, extra_entries=extra_entries)
+
+
+def _uncompared_reason(payload: atr.models.attestable.SourceV2) -> str | None:
+ # Projects without a GitHub repository were never promised a comparison
+ if not payload.candidates:
+ return None
+ if len(payload.candidates) > 1:
+ return "Several GitHub repositories are listed and none is declared for this archive. Declare one under Tools"
+ return "No source commit has been recorded for this release"
diff --git a/atr/tasks/quarantine.py b/atr/tasks/quarantine.py
index b92ad665..658eea40 100644
--- a/atr/tasks/quarantine.py
+++ b/atr/tasks/quarantine.py
@@ -155,6 +155,7 @@ async def validate(task_args: args.QuarantineValidate) -> results.Results | None
source_override=(
task_args.source_override if ("source_override" in task_args.model_fields_set) else db.NOT_SET
),
+ declare_sources=task_args.declare_sources,
)
return None
@@ -363,6 +364,7 @@ async def _promote(
github_payload: github.TrustedPublisherPayload | None = None,
source_commit: safe.CommitHash | None = None,
source_override: db.Opt[str | None] = db.NOT_SET,
+ declare_sources: bool = True,
) -> None:
quarantine_dir_path = pathlib.Path(quarantine_dir)
@@ -419,6 +421,7 @@ async def _promote(
github_payload=github_payload,
source_commit=source_commit,
source_override=source_override,
+ declare_sources=declare_sources,
)
async with db.session() as data:
diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html
index 35efd3a3..7a5f8415 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -85,6 +85,9 @@
<code>{{ path }}</code>
{% endif %}
</a>
+ {% if info and (path in info.archive_sources) and (info.archive_sources[path] is none) %}
+ <span class="atr-text-concern small ms-2">Source not declared</span>
+ {% endif %}
{% else %}
{% if path_style_class %}
<strong><code class="{{ path_style_class }}">{{ path }} (empty)</code></strong>
@@ -129,6 +132,10 @@
<a href="{{ as_url(get.sbom.quality, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
class="btn btn-sm btn-outline-secondary">View SBOM</a>
{% endif %}
+ {% if info and (path in info.archive_sources) %}
+ <a href="{{ as_url(get.draft.tools, project_key=project_key|string, version_key=version_key|string, file_path=path) }}#source"
+ class="btn btn-sm btn-outline-secondary">Declare source</a>
+ {% endif %}
{% if has_blockers %}
<a href="{{ as_url(get.report.selected_path, project_key=project_key|string, version_key=version_key|string, rel_path=path) }}"
class="btn btn-sm atr-btn-outline-blocker">Show details</a>
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index 40972fd9..309eb071 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -32,4 +32,23 @@
{{ sha512_form|safe }}
</div>
+ {% if source_form %}
+ <h3 id="source">Declare source</h3>
+ <p>
+ This project lists several GitHub repositories. Say which one this archive was built
+ from and at which commit, and the source tree comparison will run against it.
+ Re-uploading the archive clears the declaration.
+ </p>
+ {% if source_declared %}
+ <p>
+ <strong>Declared:</strong> <code>{{ source_declared.repository }}</code> at <code>{{ source_declared.commit }}</code>
+ </p>
+ {% else %}
+ <p class="atr-text-concern">Not yet declared for this archive.</p>
+ {% endif %}
+ <div class="mb-4">
+ {{ source_form|safe }}
+ </div>
+ {% endif %}
+
{% endblock content %}
diff --git a/atr/util.py b/atr/util.py
index 95b1f902..e45ef310 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -1745,12 +1745,7 @@ def validate_trusted_publishing_constraints(
github_repository_branch: str | None,
all_paths: list[str],
) -> None:
- if all_paths and (not github_repository_name):
- raise ValueError("GitHub repository name is required when any workflow path is set.")
-
- if github_repository_branch and (not github_repository_name):
- raise ValueError("GitHub repository name is required when a GitHub branch is set.")
-
+ # The repository name is optional
validation.validate_github_repository_name(github_repository_name)
validation.validate_trusted_publishing_workflow_paths(all_paths)
diff --git a/migrations/versions/0135_2026.10.21_7e613682.py b/migrations/versions/0135_2026.10.21_7e613682.py
new file mode 100644
index 00000000..fc586647
--- /dev/null
+++ b/migrations/versions/0135_2026.10.21_7e613682.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Record whether a workflow key declares the sources of what it uploads
+
+Revision ID: 0135_2026.10.21_7e613682
+Revises: 0134_2026.10.05_a3c91e47
+Create Date: 2026-10-21 13:06:25.862623+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0135_2026.10.21_7e613682"
+down_revision: str | None = "0134_2026.10.05_a3c91e47"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("workflowsshkey") as batch_op:
+ batch_op.add_column(sa.Column("declare_sources", sa.Boolean(), nullable=False, server_default=sa.true()))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("workflowsshkey") as batch_op:
+ batch_op.drop_column("declare_sources")
diff --git a/pip-audit.requirements b/pip-audit.requirements
index 3b5141be..40076cf2 100644
--- a/pip-audit.requirements
+++ b/pip-audit.requirements
@@ -49,7 +49,7 @@ blockbuster==1.5.29
# via tooling-trusted-releases
boolean-py==5.0
# via license-expression
-certifi==2026.7.22
+certifi==2026.10.19
# via requests
cffi==2.1.1
# via
@@ -83,14 +83,14 @@ defusedxml==0.7.1
# via py-serializable
distlib==0.4.3
# via virtualenv
-djlint==1.46.4
+djlint==1.46.6
dnspython==2.9.0
# via
# email-validator
# tooling-trusted-releases
dulwich==1.2.17
# via tooling-trusted-releases
-dunamai==1.26.2
+dunamai==1.26.3
# via tooling-trusted-releases
easydict==1.13
# via
@@ -108,7 +108,7 @@ ezt==1.1
# via
# asfpy
# asfquart
-filelock==4.1.2
+filelock==4.2.0
# via
# python-discovery
# virtualenv
@@ -221,7 +221,7 @@ packaging==26.3
# virtualenv
pathspec==1.1.1
# via djlint
-platformdirs==4.13.0
+platformdirs==4.13.3
# via virtualenv
pre-commit==4.7.0
priority==2.0.0
@@ -264,7 +264,7 @@ python-dateutil==2.9.0.post0
# strictyaml
python-decouple==3.8
# via tooling-trusted-releases
-python-discovery==1.6.2
+python-discovery==1.6.3
# via virtualenv
pyyaml==6.0.3
# via
@@ -314,7 +314,7 @@ rpds-py==2026.9.1
# referencing
rpgp-py==0.20.0
# via tooling-trusted-releases
-ruff==0.17.0
+ruff==0.17.1
semver==3.1.0
# via tooling-trusted-releases
six==1.17.0
@@ -362,7 +362,7 @@ urllib3==2.8.0
# requests
uvloop==0.23.0
# via hypercorn
-virtualenv==21.14.7
+virtualenv==21.14.8
# via pre-commit
watchfiles==1.3.0
# via
diff --git a/pyproject.toml b/pyproject.toml
index 768d328b..159ab364 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -174,8 +174,8 @@ strict-checking = true
[tool.uv]
# Dependency cooldown, requires uv 0.9.17+
exclude-newer = "5 days"
-exclude-newer-package = { cryptography = "2026-08-04", h2 = "2026-08-07" }
-override-dependencies = ["cryptography>=50.0,<51.0.0","h2>=4.4.1,<4.4.2"]
+exclude-newer-package = { cryptography = "2026-08-04", h2 = "2026-08-07", "certifi" = "2026-10-21" }
+override-dependencies = ["cryptography>=50.0,<51.0.0","h2>=4.4.1,<4.4.2", "certifi >=2026.10.19"]
required-version = ">=0.9.17"
[tool.uv.extra-build-dependencies]
diff --git a/tests/unit/test_api_release_source.py b/tests/unit/test_api_release_source.py
new file mode 100644
index 00000000..667069d8
--- /dev/null
+++ b/tests/unit/test_api_release_source.py
@@ -0,0 +1,113 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import inspect
+import unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+import werkzeug.exceptions as exceptions
+
+import atr.api as api
+import atr.db.interaction as interaction
+import atr.models.api as models_api
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+
+PK, VK = safe.ProjectKey("project"), safe.VersionKey("1.0.0")
+
+
+@pytest.fixture
+def set_archive_sources() -> mock.AsyncMock:
+ revision = sql.Revision(number="00003", asfuid="tester", phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+ return mock.AsyncMock(return_value=revision)
+
+
+@pytest.fixture
+def storage_write(monkeypatch: pytest.MonkeyPatch, set_archive_sources: mock.AsyncMock) -> mock.MagicMock:
+ wacp = mock.MagicMock()
+ wacp.release.set_archive_sources = set_archive_sources
+ write = mock.AsyncMock()
+ write.as_project_committee_participant = mock.AsyncMock(return_value=wacp)
+ cm = mock.AsyncMock()
+ cm.__aenter__.return_value = write
+ patched = mock.MagicMock(return_value=cm)
+ monkeypatch.setattr(api.storage, "write", patched)
+ return patched
+
+
+async def test_release_source_declares_for_the_caller(
+ monkeypatch: pytest.MonkeyPatch, set_archive_sources: mock.AsyncMock, storage_write: mock.MagicMock
+):
+ monkeypatch.setattr(api, "_jwt_asf_uid", lambda: "tester")
+ args = models_api.ReleaseSourceArgs(
+ project=PK, version=VK, sources=[_declaration()], expected_revision=safe.RevisionNumber("00002")
+ )
+
+ body, status = await inspect.unwrap(api.release_source)(_release_source="release/source", data=args)
+
+ assert status == 201
+ assert body["endpoint"] == "/release/source"
+ assert body["revision"]["number"] == "00003"
+ set_archive_sources.assert_awaited_once_with(PK, VK, args.sources, safe.RevisionNumber("00002"))
+ storage_write.assert_called_once_with("tester")
+
+
+async def test_publisher_release_source_declares_for_the_calling_project(
+ monkeypatch: pytest.MonkeyPatch, set_archive_sources: mock.AsyncMock, storage_write: mock.MagicMock
+):
+ context = SimpleNamespace(payload=object(), asf_uid="tester")
+ monkeypatch.setattr(api.api.auth, "trusted_publisher_context", lambda: context)
+ resolve = mock.AsyncMock(return_value=("tester", SimpleNamespace(safe_key=PK)))
+ monkeypatch.setattr(api.interaction, "trusted_project_for_payload", resolve)
+ args = models_api.PublisherReleaseSourceArgs(publisher="tester", jwt="token", version=VK, sources=[_declaration()])
+
+ body, status = await inspect.unwrap(api.publisher_release_source)(
+ _publisher_release_source="publisher/release/source", data=args
+ )
+
+ assert status == 201
+ assert body["endpoint"] == "/publisher/release/source"
+ # The project comes from the token, and declaring is a compose operation
+ resolve.assert_awaited_once_with(context.payload, "tester", interaction.TrustedProjectPhase.COMPOSE)
+ set_archive_sources.assert_awaited_once_with(PK, VK, args.sources, None)
+ storage_write.assert_called_once_with("tester")
+
+
+@pytest.mark.parametrize(
+ ("status", "expected"), [(400, exceptions.BadRequest), (404, exceptions.NotFound), (409, exceptions.Conflict)]
+)
+async def test_release_source_maps_writer_errors(
+ monkeypatch: pytest.MonkeyPatch,
+ set_archive_sources: mock.AsyncMock,
+ storage_write: mock.MagicMock,
+ status: int,
+ expected: type[exceptions.HTTPException],
+):
+ monkeypatch.setattr(api, "_jwt_asf_uid", lambda: "tester")
+ set_archive_sources.side_effect = storage.AccessError("No", status=status)
+ args = models_api.ReleaseSourceArgs(project=PK, version=VK, sources=[_declaration()])
+
+ with pytest.raises(expected):
+ await inspect.unwrap(api.release_source)(_release_source="release/source", data=args)
+
+
+def _declaration() -> models_api.SourceDeclaration:
+ return models_api.SourceDeclaration(
+ relpath=safe.RelPath("a-src.tar.gz"), repository="apache/first", commit=safe.CommitHash("a" * 40)
+ )
diff --git a/tests/unit/test_attestable.py b/tests/unit/test_attestable.py
index bade38bb..7aac2c49 100644
--- a/tests/unit/test_attestable.py
+++ b/tests/unit/test_attestable.py
@@ -164,6 +164,29 @@ def test_effective_path_provenance_drops_inheritance_when_hash_changes():
assert result == {}
+def test_effective_path_sources_follow_content():
+ declared = models.ArchiveSourceV2(repository="apache/second", commit="d" * 40)
+ given = models.ArchiveSourceV2(repository="apache/first", commit="e" * 40)
+ previous = models.AttestableV2(
+ paths={
+ "kept.tar.gz": models.PathEntryV2(content_hash="h1", classification="source", source=declared),
+ "changed.tar.gz": models.PathEntryV2(content_hash="h2", classification="source", source=declared),
+ "new.tar.gz": models.PathEntryV2(content_hash="h3", classification="source"),
+ },
+ )
+ result = attestable.effective_path_sources(
+ {safe.RelPath("new.tar.gz"): given},
+ {
+ safe.RelPath("kept.tar.gz"): "h1",
+ safe.RelPath("changed.tar.gz"): "h2-changed",
+ safe.RelPath("new.tar.gz"): "h3",
+ },
+ previous,
+ )
+
+ assert result == {"kept.tar.gz": declared, "new.tar.gz": given}
+
+
def test_effective_path_provenance_v1_previous_returns_empty():
previous = models.AttestableV1(paths={"a.tar.gz.sha512": "h2"})
result = attestable.effective_path_provenance(
diff --git a/tests/unit/test_checks_compare.py b/tests/unit/test_checks_compare.py
index 9269c9d9..4ebefeef 100644
--- a/tests/unit/test_checks_compare.py
+++ b/tests/unit/test_checks_compare.py
@@ -43,16 +43,14 @@ import atr.tasks.task as task
class CheckoutRecorder:
def __init__(self, return_value: str | None = None) -> None:
self.checkout_dir: safe.StatePath | None = None
- self.source: atr.models.attestable.SourceV2 | None = None
+ self.repository: str | None = None
+ self.sha: str | None = None
self.return_value = return_value
- async def __call__(
- self,
- payload: atr.models.attestable.SourceV2,
- checkout_dir: safe.StatePath,
- ) -> str | None:
+ async def __call__(self, repository: str, sha: str, checkout_dir: safe.StatePath) -> str | None:
self.checkout_dir = checkout_dir
- self.source = payload
+ self.repository = repository
+ self.sha = sha
assert await aiofiles.os.path.exists(checkout_dir)
if self.return_value is not None:
return self.return_value
@@ -60,11 +58,7 @@ class CheckoutRecorder:
class CheckoutFailure:
- async def __call__(
- self,
- payload: atr.models.github.TrustedPublisherPayload,
- checkout_dir: safe.StatePath,
- ) -> str | None:
+ async def __call__(self, repository: str, sha: str, checkout_dir: safe.StatePath) -> str | None:
return None
@@ -342,7 +336,7 @@ async def test_checkout_github_source_uses_provided_dir(
monkeypatch.setattr(atr.tasks.checks.compare, "_clone_repo", clone_recorder)
- result = await atr.tasks.checks.compare._checkout_github_source(payload, checkout_dir)
+ result = await atr.tasks.checks.compare._checkout_github_source(payload.repository, payload.sha, checkout_dir)
assert result == str(checkout_dir)
assert len(clone_recorder.calls) == 1
@@ -830,8 +824,7 @@ async def test_source_trees_creates_temp_workspace_and_cleans_up(
await atr.tasks.checks.compare.source_trees(args)
assert checkout.checkout_dir is not None
- assert checkout.source is not None
- assert checkout.source.sha == (override or payload.sha)
+ assert checkout.sha == (override or payload.sha)
checkout_dir = checkout.checkout_dir
assert checkout_dir.path.name == "github"
assert checkout_dir.parent.path.parent == tmp_root.path
@@ -861,6 +854,95 @@ async def test_source_trees_payload_none_skips_temp_workspace(monkeypatch: pytes
monkeypatch.setattr(atr.tasks.checks.compare.paths, "get_tmp_dir", RaiseSync("get_tmp_dir should not be called"))
await atr.tasks.checks.compare.source_trees(args)
+ assert recorder.concern_calls == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("candidates", "override", "reason_start"),
+ [
+ (["apache/first", "apache/second"], None, "Several GitHub repositories"),
+ (["apache/first", "apache/second"], "c" * 40, "Several GitHub repositories"),
+ (["apache/first"], None, "No source commit"),
+ ],
+)
+async def test_source_trees_concern_when_uncomparable(
+ monkeypatch: pytest.MonkeyPatch, candidates: list[str], override: str | None, reason_start: str
+) -> None:
+ recorder = RecorderStub(True)
+ args = _make_args(recorder)
+ # Either the repository or the sha is missing from the record, so there's nothing to clone
+ recorded = atr.models.attestable.AttestableV2(
+ source=atr.models.attestable.SourceV2(override=override, candidates=candidates)
+ )
+
+ monkeypatch.setattr(atr.attestable, "load", mock.AsyncMock(return_value=recorded))
+ monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(None))
+ monkeypatch.setattr(
+ atr.tasks.checks.compare,
+ "_checkout_github_source",
+ RaiseAsync("_checkout_github_source should not be called"),
+ )
+
+ await atr.tasks.checks.compare.source_trees(args)
+
+ assert len(recorder.concern_calls) == 1
+ message, data = recorder.concern_calls[0]
+ assert message.startswith("Could not compare this archive against its GitHub source")
+ assert isinstance(data, dict)
+ assert data["reason"].startswith(reason_start)
+ assert data["repositories"] == candidates
+ assert data["commit"] == override
+
+
+@pytest.mark.asyncio
+async def test_source_trees_stays_quiet_without_github_repository(monkeypatch: pytest.MonkeyPatch) -> None:
+ recorder = RecorderStub(True)
+ args = _make_args(recorder)
+ recorded = atr.models.attestable.AttestableV2(source=atr.models.attestable.SourceV2(candidates=[]))
+
+ monkeypatch.setattr(atr.attestable, "load", mock.AsyncMock(return_value=recorded))
+ monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(None))
+
+ await atr.tasks.checks.compare.source_trees(args)
+
+ assert recorder.concern_calls == []
+
+
+@pytest.mark.asyncio
+async def test_source_trees_uses_declared_source(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
+ temp_dir = safe.StatePath(tmp_path)
+ recorder = RecorderStub(True)
+ args = _make_args(recorder)
+ checkout = CheckoutRecorder()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
+ find_root = FindArchiveRootRecorder("artifact")
+ compare = CompareRecorder(repo_only=set())
+ # Two candidates and no release level choice, but this archive has said where it came from
+ declared = atr.models.attestable.ArchiveSourceV2(repository="apache/second", commit="d" * 40)
+ recorded = atr.models.attestable.AttestableV2(
+ paths={
+ "artifact.tar.gz": atr.models.attestable.PathEntryV2(
+ content_hash="h", classification="source", source=declared
+ )
+ },
+ source=atr.models.attestable.SourceV2(candidates=["apache/first", "apache/second"]),
+ )
+
+ monkeypatch.setattr(atr.attestable, "load", mock.AsyncMock(return_value=recorded))
+ monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(None))
+ monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
+ monkeypatch.setattr(atr.tasks.checks, "resolve_archive_dir", ArchiveDirResolver(cache_dir))
+ monkeypatch.setattr(atr.tasks.checks.compare, "_find_archive_root", find_root)
+ monkeypatch.setattr(atr.tasks.checks.compare, "_compare_trees", compare)
+ monkeypatch.setattr(atr.tasks.checks.compare.paths, "get_tmp_dir", ReturnValue(temp_dir / "temporary-root"))
+
+ await atr.tasks.checks.compare.source_trees(args)
+
+ assert (checkout.repository, checkout.sha) == ("apache/second", "d" * 40)
+ assert recorder.concern_calls == []
+ assert len(recorder.note_calls) == 1
@pytest.mark.asyncio
@@ -902,7 +984,9 @@ async def test_checkout_returns_none_for_http_auth_failures(
monkeypatch.setattr(atr.tasks.checks.compare, "_clone_repo", raise_unauthorized)
payload = _make_payload()
- checkout_dir = await atr.tasks.checks.compare._checkout_github_source(payload, safe.StatePath(tmp_path))
+ checkout_dir = await atr.tasks.checks.compare._checkout_github_source(
+ payload.repository, payload.sha, safe.StatePath(tmp_path)
+ )
assert checkout_dir is None
diff --git a/tests/unit/test_create_revision.py b/tests/unit/test_create_revision.py
index bbd0c49f..04d18f85 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -224,7 +224,7 @@ async def test_commit_new_revision_writes_metadata_before_checks(
)
release.release_policy = None
release.project.release_policy = None
- release.project.policy_github_repository_name = "test"
+ release.project.github_repositories = {"apache/test"}
release.activity_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
safe_data = MockSafeData(parent_key=None)
payload = github_payload if has_github_payload else None
@@ -873,38 +873,6 @@ async def test_open_for_replace_silently_replaces_readonly_hardlink(tmp_path: pa
assert target.stat().st_ino != prior_file.stat().st_ino
-@pytest.mark.asyncio
-async def test_payload_from_other_repository_rejected_before_modify(
- tmp_path: pathlib.Path, github_payload: github.TrustedPublisherPayload
-):
- modify = mock.AsyncMock()
- release = mock.MagicMock()
- release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
- release.project.status = sql.ProjectStatus.ACTIVE
- mock_session = _mock_db_session(release)
- participant = _make_participant()
- recorded = atr.models.attestable.SourceV2(repository="apache/other")
-
- with (
- mock.patch.object(revision.db, "session", return_value=mock_session),
- mock.patch.object(revision.interaction, "latest_revision", new_callable=mock.AsyncMock, return_value=None),
- mock.patch.object(revision.source, "current", new_callable=mock.AsyncMock, return_value=recorded),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=safe.StatePath(tmp_path)),
- ):
- with pytest.raises(ValueError, match="but the upload came from apache/test"):
- await participant.create_revision_with_quarantine(
- safe.ProjectKey("proj"),
- safe.VersionKey("1.0"),
- "test",
- allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
- modify=modify,
- github_payload=github_payload,
- )
-
- modify.assert_not_awaited()
- assert not os.listdir(tmp_path)
-
-
@pytest.mark.asyncio
async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathlib.Path):
temp_dir = safe.StatePath(tmp_path)
@@ -1185,7 +1153,7 @@ def _make_release_participant() -> release.CommitteeParticipant:
def _mock_db_session(release: mock.MagicMock, selected_revision: mock.MagicMock | None = None) -> mock.MagicMock:
release.project.committee_key = "test"
- release.project.policy_github_repository_name = "test"
+ release.project.github_repositories = {"apache/test"}
release.commit_hash = None
mock_query = mock.MagicMock()
mock_query.demand = mock.AsyncMock(return_value=release)
diff --git a/tests/unit/test_quarantine_task.py b/tests/unit/test_quarantine_task.py
index 229bb053..180909c4 100644
--- a/tests/unit/test_quarantine_task.py
+++ b/tests/unit/test_quarantine_task.py
@@ -706,6 +706,7 @@ async def test_validate_success_calls_promote(
github_payload=payload,
source_commit=source_commit,
source_override=quarantine.db.NOT_SET,
+ declare_sources=True,
)
mock_mark.assert_not_awaited()
diff --git a/tests/unit/test_release_writer_archive_sources.py b/tests/unit/test_release_writer_archive_sources.py
new file mode 100644
index 00000000..9edd9a21
--- /dev/null
+++ b/tests/unit/test_release_writer_archive_sources.py
@@ -0,0 +1,146 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest.mock as mock
+
+import pytest
+
+import atr.models.api as api
+import atr.models.attestable as attestable_models
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.datatypes as datatypes
+import atr.storage.writers.release as release
+
+PK, VK = safe.ProjectKey("project"), safe.VersionKey("1.0.0")
+
+
+@pytest.fixture
+def create() -> mock.AsyncMock:
+ return mock.AsyncMock(return_value=mock.MagicMock(spec=sql.Revision, number="00003"))
+
+
+@pytest.fixture
+def load(monkeypatch: pytest.MonkeyPatch) -> mock.AsyncMock:
+ recorded = attestable_models.AttestableV2(
+ paths={
+ "a-src.tar.gz": attestable_models.PathEntryV2(content_hash="h1", classification="source"),
+ "b-src.tar.gz": attestable_models.PathEntryV2(content_hash="h2", classification="source"),
+ "c-bin.tar.gz": attestable_models.PathEntryV2(content_hash="h3", classification="binary"),
+ }
+ )
+ load = mock.AsyncMock(return_value=recorded)
+ monkeypatch.setattr(release.attestable, "load", load)
+ return load
+
+
+@pytest.fixture
+def writer(create: mock.AsyncMock, load: mock.AsyncMock) -> release.CommitteeParticipant:
+ release_row = mock.MagicMock()
+ release_row.project.github_repositories = {"apache/first", "apache/second"}
+ release_row.latest_revision_number = "00002"
+ release_row.safe_latest_revision_number = safe.RevisionNumber("00002")
+ data = mock.MagicMock()
+ data.release.return_value.demand = mock.AsyncMock(return_value=release_row)
+ write = mock.MagicMock()
+ write.authorisation.asf_uid = "tester"
+ write_as = mock.MagicMock()
+ write_as.revision.create_revision_with_quarantine = create
+ return release.CommitteeParticipant(write, write_as, data, "project")
+
+
+async def test_set_archive_sources_makes_one_revision_for_all_declarations(
+ writer: release.CommitteeParticipant, create: mock.AsyncMock
+):
+ result = await writer.set_archive_sources(
+ PK,
+ VK,
+ [_declaration("a-src.tar.gz", "apache/first"), _declaration("b-src.tar.gz", "apache/second", "b" * 40)],
+ )
+
+ assert result.number == "00003"
+ create.assert_awaited_once()
+ kwargs = create.call_args_list[0].kwargs
+ assert {str(path): source for path, source in kwargs["path_sources"].items()} == {
+ "a-src.tar.gz": attestable_models.ArchiveSourceV2(repository="apache/first", commit="a" * 40),
+ "b-src.tar.gz": attestable_models.ArchiveSourceV2(repository="apache/second", commit="b" * 40),
+ }
+ assert kwargs["expected_revision"] == safe.RevisionNumber("00002")
+ assert kwargs["description"] == "Source declared for 2 archives"
+
+
+async def test_set_archive_sources_checks_the_revision_the_caller_saw(
+ writer: release.CommitteeParticipant, create: mock.AsyncMock, load: mock.AsyncMock
+):
+ await writer.set_archive_sources(PK, VK, [_declaration()], safe.RevisionNumber("00001"))
+
+ load.assert_awaited_once_with(PK, VK, safe.RevisionNumber("00001"))
+ kwargs = create.call_args_list[0].kwargs
+ assert kwargs["expected_revision"] == safe.RevisionNumber("00001")
+ assert kwargs["description"] == "Source declared for a-src.tar.gz"
+
+
+@pytest.mark.parametrize(
+ ("archives", "status"),
+ [
+ pytest.param([], 400, id="none"),
+ pytest.param([("a-src.tar.gz", "apache/first")] * 2, 400, id="twice"),
+ pytest.param([("a-src.tar.gz", "apache/other")], 400, id="other-repository"),
+ pytest.param([("missing.tar.gz", "apache/first")], 404, id="not-in-draft"),
+ pytest.param([("c-bin.tar.gz", "apache/first")], 400, id="not-source"),
+ ],
+)
+async def test_set_archive_sources_rejects_bad_declarations(
+ writer: release.CommitteeParticipant,
+ create: mock.AsyncMock,
+ archives: list[tuple[str, str]],
+ status: int,
+):
+ declarations = [_declaration(relpath, repository) for relpath, repository in archives]
+
+ with pytest.raises(storage.AccessError) as exc:
+ await writer.set_archive_sources(PK, VK, declarations)
+
+ assert exc.value.status == status
+ create.assert_not_awaited()
+
+
+async def test_set_archive_sources_reports_a_changed_release_as_a_conflict(
+ writer: release.CommitteeParticipant, create: mock.AsyncMock
+):
+ create.side_effect = datatypes.RevisionMismatchError("Revision 00002 is no longer the latest")
+
+ with pytest.raises(storage.AccessError) as exc:
+ await writer.set_archive_sources(PK, VK, [_declaration()])
+
+ assert exc.value.status == 409
+
+
+async def test_set_archive_sources_refuses_a_quarantine(writer: release.CommitteeParticipant, create: mock.AsyncMock):
+ create.return_value = mock.MagicMock(spec=sql.Quarantined)
+
+ with pytest.raises(storage.AccessError) as exc:
+ await writer.set_archive_sources(PK, VK, [_declaration()])
+
+ assert exc.value.status == 409
+
+
+def _declaration(
+ relpath: str = "a-src.tar.gz", repository: str = "apache/first", commit: str = "a" * 40
+) -> api.SourceDeclaration:
+ return api.SourceDeclaration(relpath=safe.RelPath(relpath), repository=repository, commit=safe.CommitHash(commit))
diff --git a/tests/unit/test_source.py b/tests/unit/test_source.py
index 6f654c15..6931ede6 100644
--- a/tests/unit/test_source.py
+++ b/tests/unit/test_source.py
@@ -28,10 +28,15 @@ import atr.source as source
@pytest.mark.parametrize("repository", ["apache/other", ""])
-def test_advance_rejects_payload_from_other_repository(repository: str, github_payload: github.TrustedPublisherPayload):
+def test_advance_ignores_run_commit_from_other_repository(
+ repository: str, github_payload: github.TrustedPublisherPayload
+):
+ # A workflow may run somewhere other than the source repository, so its starting commit means nothing here
selected = atr.models.attestable.SourceV2(repository=repository, default="a" * 40, override="c" * 40)
- with pytest.raises(ValueError, match="but the upload came from apache/test"):
- source.advance(selected, github_payload, declared=safe.CommitHash("e" * 40))
+ result = source.advance(selected, github_payload, declared=safe.CommitHash("e" * 40))
+ assert result.default is None
+ assert result.override is None
+ assert result.declared == "e" * 40
def test_advance_keeps_override_without_payload():
@@ -79,12 +84,12 @@ async def test_initial_accepts_repository_configured_after_first_revision():
assert previous.source.repository == ""
-async def test_initial_prefers_policy_repository_over_recorded_one():
+async def test_initial_follows_metadata_not_policy_or_earlier_record():
project = sql.Project(
key="test",
name="Test",
- repositories=["https://github.com/apache/test"],
- release_policy=sql.ReleasePolicy(github_repository_name="moved"),
+ repositories=["https://github.com/apache/moved"],
+ release_policy=sql.ReleasePolicy(github_repository_name="workflow-home"),
)
release = sql.Release(project=project, version="1.0")
recorded = atr.models.attestable.SourceV2(repository="apache/test", default="a" * 40, override="c" * 40)
@@ -95,6 +100,20 @@ async def test_initial_prefers_policy_repository_over_recorded_one():
assert recorded.repository == "apache/test"
+async def test_initial_refreshes_candidates():
+ project = sql.Project(
+ key="test",
+ name="Test",
+ repositories=["https://github.com/apache/second", "https://github.com/apache/first"],
+ )
+ release = sql.Release(project=project, version="1.0")
+ recorded = atr.models.attestable.SourceV2(candidates=["apache/first"])
+ selected = await source.initial(release, atr.models.attestable.AttestableV2(source=recorded))
+ assert selected.repository == ""
+ assert selected.candidates == ["apache/first", "apache/second"]
+ assert recorded.candidates == ["apache/first"]
+
+
@pytest.mark.parametrize("recorded_hash", ["b" * 40, "c" * 40])
async def test_initial_preserves_legacy_choice(
monkeypatch: pytest.MonkeyPatch, github_payload: github.TrustedPublisherPayload, recorded_hash: str
@@ -110,6 +129,55 @@ async def test_initial_preserves_legacy_choice(
assert selected.override == (recorded_hash if (recorded_hash != github_payload.sha) else None)
+@pytest.mark.parametrize(
+ ("candidates", "declared", "expected_commit"),
+ [
+ (["apache/other", "apache/test"], None, "payload"),
+ (["apache/other", "apache/test"], "d" * 40, "d" * 40),
+ ],
+)
+def test_uploaded_declares_changed_source_archives_from_the_workflow_repository(
+ github_payload: github.TrustedPublisherPayload, candidates: list[str], declared: str | None, expected_commit: str
+):
+ record = atr.models.attestable.SourceV2(candidates=candidates, declared=declared)
+ previous = atr.models.attestable.AttestableV2(
+ paths={
+ "same-src.tar.gz": atr.models.attestable.PathEntryV2(content_hash="h1", classification="source"),
+ "changed-src.tar.gz": atr.models.attestable.PathEntryV2(content_hash="h2", classification="source"),
+ }
+ )
+ path_to_hash = {
+ safe.RelPath("same-src.tar.gz"): "h1",
+ safe.RelPath("changed-src.tar.gz"): "h2-new",
+ safe.RelPath("new-src.tar.gz"): "h3",
+ safe.RelPath("new-bin.tar.gz"): "h4",
+ }
+ classifications = {path: ("binary" if ("bin" in str(path)) else "source") for path in path_to_hash}
+
+ result = source.uploaded(record, github_payload, path_to_hash, classifications, previous)
+
+ commit = github_payload.sha if (expected_commit == "payload") else expected_commit
+ expected = atr.models.attestable.ArchiveSourceV2(repository=github_payload.repository, commit=commit)
+ # Unchanged archives keep whatever they had, and binaries have no source to declare
+ assert result == {safe.RelPath("changed-src.tar.gz"): expected, safe.RelPath("new-src.tar.gz"): expected}
+
+
+@pytest.mark.parametrize(
+ "candidates",
+ [
+ pytest.param(["apache/test"], id="one-repository"),
+ pytest.param(["apache/other", "apache/another"], id="workflow-not-a-source"),
+ ],
+)
+def test_uploaded_declares_nothing_outside_the_several_source_case(
+ github_payload: github.TrustedPublisherPayload, candidates: list[str]
+):
+ record = atr.models.attestable.SourceV2(candidates=candidates)
+ path_to_hash = {safe.RelPath("a-src.tar.gz"): "h1"}
+
+ assert source.uploaded(record, github_payload, path_to_hash, {safe.RelPath("a-src.tar.gz"): "source"}, None) == {}
+
+
def test_override_can_be_set_or_cleared():
selected = atr.models.attestable.SourceV2(repository="apache/test", default="a" * 40, override="b" * 40)
confirmed = source.advance(selected, None, "b" * 40)
@@ -134,7 +202,11 @@ async def test_read_and_cache_inputs_use_requested_snapshot(
original = mock.AsyncMock(return_value=github_payload)
monkeypatch.setattr(attestable, "github_tp_payload_read", original)
assert (await source.read(pk, vk, rev)).sha == "c" * 40
- assert await source.inputs(pk, vk, rev) == {"source_repository": "apache/test", "source_commit": "c" * 40}
+ assert await source.inputs(pk, vk, rev) == {
+ "source_repository": "apache/test",
+ "source_commit": "c" * 40,
+ "source_candidates": "",
+ }
attestable.load.assert_awaited_with(pk, vk, rev)
original.assert_not_awaited()
monkeypatch.setattr(attestable, "load", mock.AsyncMock(return_value=None))
@@ -147,7 +219,7 @@ async def test_read_and_cache_inputs_use_requested_snapshot(
[
(["https://github.com/apache/test.git"], "", "apache/test"),
(["https://github.com/apache/first", "https://github.com/apache/second"], "", ""),
- (["https://github.com/apache/first"], "configured", "apache/configured"),
+ (["https://github.com/apache/first"], "configured", "apache/first"),
([], "", ""),
],
)
@@ -159,3 +231,57 @@ def test_repository_requires_one_choice(repositories: list[str], policy_name: st
release_policy=sql.ReleasePolicy(github_repository_name=policy_name),
)
assert source.repository(project) == expected
+
+
+@pytest.mark.parametrize(
+ ("repositories", "policy_name", "expected"),
+ [
+ (["https://github.com/apache/test.git"], "", {"apache/test"}),
+ (
+ ["https://github.com/apache/first", "https://github.com/apache/second"],
+ "",
+ {"apache/first", "apache/second"},
+ ),
+ (
+ ["https://github.com/apache/first", "https://github.com/apache/second"],
+ "configured",
+ {"apache/first", "apache/second"},
+ ),
+ (["https://gitbox.apache.org/repos/asf/test.git"], "", set()),
+ ([], "configured", set()),
+ ],
+)
+def test_github_repositories_ignores_policy_and_other_hosts(
+ repositories: list[str], policy_name: str, expected: set[str]
+):
+ project = sql.Project(
+ key="test",
+ name="Test",
+ repositories=repositories,
+ release_policy=sql.ReleasePolicy(github_repository_name=policy_name),
+ )
+ assert project.github_repositories == expected
+
+
+async def test_inputs_include_declaration(monkeypatch: pytest.MonkeyPatch):
+ pk, vk, rev = safe.ProjectKey("test"), safe.VersionKey("1.0"), safe.RevisionNumber("00001")
+ declared = atr.models.attestable.ArchiveSourceV2(repository="apache/second", commit="c" * 40)
+ recorded = atr.models.attestable.AttestableV2(
+ paths={
+ "a.tar.gz": atr.models.attestable.PathEntryV2(content_hash="h", classification="source", source=declared)
+ },
+ source=atr.models.attestable.SourceV2(candidates=["apache/first", "apache/second"]),
+ )
+ monkeypatch.setattr(attestable, "load", mock.AsyncMock(return_value=recorded))
+ common = {"source_repository": "", "source_commit": "", "source_candidates": "apache/first,apache/second"}
+ assert await source.archive(pk, vk, rev, "a.tar.gz") == declared
+ assert await source.inputs(pk, vk, rev, rel_path="a.tar.gz") == {
+ **common,
+ "archive_repository": "apache/second",
+ "archive_commit": "c" * 40,
+ }
+ assert await source.inputs(pk, vk, rev, rel_path="b.tar.gz") == {
+ **common,
+ "archive_repository": "",
+ "archive_commit": "",
+ }
diff --git a/tests/unit/test_trusted_project.py b/tests/unit/test_trusted_project.py
new file mode 100644
index 00000000..72f2c869
--- /dev/null
+++ b/tests/unit/test_trusted_project.py
@@ -0,0 +1,114 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest.mock as mock
+
+import pytest
+
+import atr.db.interaction as interaction
+import atr.models.sql as sql
+
+WORKFLOW_REF = "apache/first/.github/workflows/release.yml@refs/heads/main"
+
+
+@pytest.mark.parametrize(
+ ("policy_name", "metadata", "matches"),
+ [
+ pytest.param("first", [], True, id="named-repository"),
+ pytest.param("elsewhere", ["https://github.com/apache/first"], False, id="named-other-repository"),
+ pytest.param("", ["https://github.com/apache/first", "https://github.com/apache/second"], True, id="metadata"),
+ pytest.param("", ["https://github.com/apache/second"], False, id="not-in-metadata"),
+ pytest.param("", ["https://gitbox.apache.org/repos/asf/first.git"], False, id="not-github"),
+ ],
+)
+async def test_matches_follow_the_policy_or_the_metadata(policy_name: str, metadata: list[str], matches: bool):
+ project = sql.Project(key="one", name="One", repositories=metadata)
+ data = _data({1: project})
+
+ found = await interaction._trusted_project_matches(
+ data, [sql.ReleasePolicy(id=1, github_repository_name=policy_name)], "apache/first"
+ )
+
+ assert found == ([project] if matches else [])
+
+
+async def test_two_projects_with_the_same_repository_are_refused(monkeypatch: pytest.MonkeyPatch):
+ first = sql.Project(key="one", name="One", repositories=["https://github.com/apache/first"])
+ fork = sql.Project(key="fork", name="Fork", repositories=["https://github.com/apache/first"])
+ _patch_lookup(monkeypatch, [sql.ReleasePolicy(id=1), sql.ReleasePolicy(id=2)], {1: first, 2: fork})
+
+ with pytest.raises(interaction.InteractionError, match="more than one project: fork, one"):
+ await interaction._trusted_project("apache/first", WORKFLOW_REF, interaction.TrustedProjectPhase.COMPOSE)
+
+
+async def test_a_policy_without_a_project_is_skipped(monkeypatch: pytest.MonkeyPatch):
+ project = sql.Project(
+ key="one",
+ name="One",
+ repositories=["https://github.com/apache/first"],
+ committee=sql.Committee(key="one", name="One"),
+ )
+ _patch_lookup(monkeypatch, [sql.ReleasePolicy(id=1), sql.ReleasePolicy(id=2)], {1: None, 2: project})
+ monkeypatch.setattr(interaction, "ci_builds_enabled", mock.AsyncMock(return_value=True))
+
+ found = await interaction._trusted_project("apache/first", WORKFLOW_REF, interaction.TrustedProjectPhase.COMPOSE)
+
+ assert found is project
+
+
+async def test_no_matching_project_is_not_found(monkeypatch: pytest.MonkeyPatch):
+ other = sql.Project(key="other", name="Other", repositories=["https://github.com/apache/second"])
+ _patch_lookup(monkeypatch, [sql.ReleasePolicy(id=1)], {1: other})
+
+ with pytest.raises(interaction.ReleasePolicyNotFoundError):
+ await interaction._trusted_project("apache/first", WORKFLOW_REF, interaction.TrustedProjectPhase.COMPOSE)
+
+
+async def test_one_matching_project_is_returned(monkeypatch: pytest.MonkeyPatch):
+ project = sql.Project(
+ key="one",
+ name="One",
+ repositories=["https://github.com/apache/first"],
+ committee=sql.Committee(key="one", name="One"),
+ )
+ _patch_lookup(monkeypatch, [sql.ReleasePolicy(id=1)], {1: project})
+ monkeypatch.setattr(interaction, "ci_builds_enabled", mock.AsyncMock(return_value=True))
+
+ found = await interaction._trusted_project("apache/first", WORKFLOW_REF, interaction.TrustedProjectPhase.COMPOSE)
+
+ assert found is project
+
+
+def _data(projects: dict[int, sql.Project | None]) -> mock.MagicMock:
+ def project(release_policy_id: int) -> mock.MagicMock:
+ query = mock.MagicMock()
+ query.get = mock.AsyncMock(return_value=projects[release_policy_id])
+ return query
+
+ data = mock.MagicMock()
+ data.project.side_effect = project
+ return data
+
+
+def _patch_lookup(
+ monkeypatch: pytest.MonkeyPatch, policies: list[sql.ReleasePolicy], projects: dict[int, sql.Project | None]
+) -> None:
+ data = _data(projects)
+ data.release_policy.return_value.all = mock.AsyncMock(return_value=policies)
+ session = mock.AsyncMock()
+ session.__aenter__.return_value = data
+ monkeypatch.setattr(interaction.db, "session", mock.MagicMock(return_value=session))
diff --git a/tests/unit/test_upload_json.py b/tests/unit/test_upload_json.py
index 8c604949..27ec1c76 100644
--- a/tests/unit/test_upload_json.py
+++ b/tests/unit/test_upload_json.py
@@ -136,61 +136,41 @@ async def test_add_files_json_unexpected_error_includes_traceback(app):
@pytest.mark.asyncio
async def test_upload_source_override_requires_hash_with_github_repo():
- session = mock.AsyncMock()
+ session = _session_with_repositories({"apache/test"})
add_form = mock.MagicMock()
add_form.commit_hash = None
- with (
- mock.patch.object(upload.db, "session", _mock_db_session()),
- mock.patch.object(upload.source, "repository", mock.Mock(return_value="apache/test")),
- ):
+ with mock.patch.object(upload.db, "session", _mock_db_session()):
with pytest.raises(upload.storage.AccessError) as exc:
await upload._upload_source_override(session, add_form, safe.ProjectKey("test"), safe.VersionKey("1.0.0"))
assert exc.value.status == 400
@pytest.mark.asyncio
-async def test_upload_source_override_returns_hash_with_github_repo():
- session = mock.AsyncMock()
- add_form = mock.MagicMock()
- add_form.commit_hash = safe.CommitHash("a" * 40)
- with (
- mock.patch.object(upload.db, "session", _mock_db_session()),
- mock.patch.object(upload.source, "repository", mock.Mock(return_value="apache/test")),
- ):
- result = await upload._upload_source_override(
- session, add_form, safe.ProjectKey("test"), safe.VersionKey("1.0.0")
- )
- assert result == "a" * 40
-
-
-@pytest.mark.asyncio
-async def test_upload_source_override_optional_without_repo():
- session = mock.AsyncMock()
+@pytest.mark.parametrize(
+ ("repositories", "commit_hash", "expected"),
+ [
+ ({"apache/test"}, "a" * 40, "a" * 40),
+ (set(), None, upload.db.NOT_SET),
+ (set(), "b" * 40, "b" * 40),
+ # With several repositories the source is declared per archive, so a hash here is ignored
+ ({"apache/first", "apache/second"}, "c" * 40, upload.db.NOT_SET),
+ ],
+)
+async def test_upload_source_override_by_repository_count(repositories: set[str], commit_hash: str | None, expected):
+ session = _session_with_repositories(repositories)
add_form = mock.MagicMock()
- add_form.commit_hash = None
- with (
- mock.patch.object(upload.db, "session", _mock_db_session()),
- mock.patch.object(upload.source, "repository", mock.Mock(return_value="")),
- ):
+ add_form.commit_hash = safe.CommitHash(commit_hash) if (commit_hash is not None) else None
+ with mock.patch.object(upload.db, "session", _mock_db_session()):
result = await upload._upload_source_override(
session, add_form, safe.ProjectKey("test"), safe.VersionKey("1.0.0")
)
- assert result is upload.db.NOT_SET
+ assert result == expected
-@pytest.mark.asyncio
-async def test_upload_source_override_records_hash_without_repo():
+def _session_with_repositories(repositories: set[str]) -> mock.AsyncMock:
session = mock.AsyncMock()
- add_form = mock.MagicMock()
- add_form.commit_hash = safe.CommitHash("b" * 40)
- with (
- mock.patch.object(upload.db, "session", _mock_db_session()),
- mock.patch.object(upload.source, "repository", mock.Mock(return_value="")),
- ):
- result = await upload._upload_source_override(
- session, add_form, safe.ProjectKey("test"), safe.VersionKey("1.0.0")
- )
- assert result == "b" * 40
+ session.release.return_value.project.github_repositories = repositories
+ return session
def _mock_db_session():
diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py
index 1c6cf94b..d49d75a0 100644
--- a/tests/unit/test_validate.py
+++ b/tests/unit/test_validate.py
@@ -318,14 +318,9 @@ def test_project_recipient_defaults(defaults: Any, expected: list[str]) -> None:
pytest.param(False, {}, {"rat_excludes_url": "https://example.org/x"}, ["project_fetch_urls"], id="rat-host"),
pytest.param(False, {}, {"start_vote_template_url": "http://apache.org/t"}, ["project_fetch_urls"], id="http"),
pytest.param(False, {}, {"github_vote_workflow_path": None}, [], id="null-paths"),
- pytest.param(
- False,
- {},
- {"github_finish_workflow_path": [".github/workflows/finish.yml"]},
- ["project_trusted_publishing"],
- id="paths-without-repository",
- ),
- pytest.param(False, {}, {"github_repository_branch": "main"}, ["project_trusted_publishing"], id="branch"),
+ # Workflow paths and a branch stand on their own now - the repository name is optional
+ pytest.param(False, {}, {"github_finish_workflow_path": [".github/workflows/finish.yml"]}, [], id="paths"),
+ pytest.param(False, {}, {"github_repository_branch": "main"}, [], id="branch"),
pytest.param(False, {}, {"github_repository_name": "apache/x"}, ["project_trusted_publishing"], id="slash"),
pytest.param(
False,
diff --git a/uv.lock b/uv.lock
index 1f958ae9..cb8bfd53 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = "==3.14.*"
[options]
@@ -7,11 +7,13 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for
exclude-newer-span = "P5D"
[options.exclude-newer-package]
+certifi = "2026-10-21T23:00:00Z"
cryptography = "2026-08-04T23:00:00Z"
h2 = "2026-08-07T23:00:00Z"
[manifest]
overrides = [
+ { name = "certifi", specifier = ">=2026.10.19" },
{ name = "cryptography", specifier = ">=50.0,<51.0.0" },
{ name = "h2", specifier = ">=4.4.1,<4.4.2" },
]
@@ -313,11 +315,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.7.22"
+version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
@@ -591,7 +593,7 @@ wheels = [
[[package]]
name = "djlint"
-version = "1.46.4"
+version = "1.46.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -602,27 +604,35 @@ dependencies = [
{ name = "pyyaml" },
{ name = "regex" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/97/89/802575614cda0554b57e112c025a79729b50a0bcc32daaf4f8a989850d68/djlint-1.46.4.tar.gz", hash = "sha256:5e05f356bf14a7ecd3d885867b5110e778dafba19640ef88e701c060f3a98a20", size = 127997, upload-time = "2026-10-01T00:39:14.984Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/11/4676b4eb1063131276fc0779495b126a5c0199bc04081210e9b3a5575713/djlint-1.46.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:513793eca770a321fd3ee7bbc129f503d86e1205b1c43c593232e19f84791d1d", size = 1343284, upload-time = "2026-10-01T00:38:19.629Z" },
- { url = "https://files.pythonhosted.org/packages/1a/88/f35ef8cca6bd4189ab730443746980d8f9359957fb76113a2f7c77dcf765/djlint-1.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:870cea4c99e2046b31f1ff97b01699e2e7704548de3d28f56390e5eae8af8600", size = 1267111, upload-time = "2026-10-01T00:38:21.807Z" },
- { url = "https://files.pythonhosted.org/packages/60/85/c81ab9bb5a7af8a74298f053c832305d3314a7fb7ce91e3693e095f53892/djlint-1.46.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2bbddbee156d4db50f345d4323f3cf342a3134ae649c5bd6db1ad78ae675b28", size = 1338477, upload-time = "2026-10-01T00:38:23.292Z" },
- { url = "https://files.pythonhosted.org/packages/54/33/7507026a9cc35dbe3745a33e7056da50f7cb1109a44edc30d14c60433c8f/djlint-1.46.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9155aff5ddaaed5349ceb0ea6181fbe5015ee9e2b91386af5e820d74e8488858", size = 1393026, upload-time = "2026-10-01T00:38:24.948Z" },
- { url = "https://files.pythonhosted.org/packages/ca/d1/be31f853d649b4d490d4ce488eebcdaab36c9e329d82fcb533ee2f11d3c7/djlint-1.46.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1264ec664413e94814ffd0bdd4da57f1336b8455444ca28be80fdfe03695a0f0", size = 1360654, upload-time = "2026-10-01T00:38:26.466Z" },
- { url = "https://files.pythonhosted.org/packages/d8/54/37f5c7e083fe04c16d317c4389bc915f3b11451303aca2a2bfdebdfd601c/djlint-1.46.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b098201876332a330b122807beb5373f9dd05429699b72abbb0a1bf2cc6e0792", size = 1415457, upload-time = "2026-10-01T00:38:28.078Z" },
- { url = "https://files.pythonhosted.org/packages/15/b4/547698046de9f87af8532ffcff168a18319b1f4fa55bc0559024653d5791/djlint-1.46.4-cp314-cp314-win32.whl", hash = "sha256:a006942b5fe416445c25beed326b687d7dce98a46f52020873ac409b1361e7c7", size = 1043705, upload-time = "2026-10-01T00:38:29.682Z" },
- { url = "https://files.pythonhosted.org/packages/3c/72/3b42dcae63ea312587010dcc113422f18aaff40dbd5d6c1194b660beb0a5/djlint-1.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:f798c378b272308dcb7a5438235b40a8ca7b8854ecc37eaa8eec74eb6e12f80c", size = 1186794, upload-time = "2026-10-01T00:38:31.172Z" },
- { url = "https://files.pythonhosted.org/packages/67/ce/f5bb10fe2ceb8e708422ee12a52a4f9ce1e73480705475188b3fa367c4fc/djlint-1.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:50b99356d7e651afe9740133b9402fea6798843fe12a496727a0532cf83a40ac", size = 1115905, upload-time = "2026-10-01T00:38:33.167Z" },
- { url = "https://files.pythonhosted.org/packages/8c/74/756ff5ce0b61b8c3bb0d8d2f57acfdc1ffdf25f32e2f85e9f577d369ab1f/djlint-1.46.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef2797712edfb66b1b8e74bb7712c8d5c95216f1736fef473108e3f44459697c", size = 1443533, upload-time = "2026-10-01T00:38:34.789Z" },
- { url = "https://files.pythonhosted.org/packages/77/61/8c89125861671f845ea62141bd4276335f60d07ec93768b4ff1a21ed76cd/djlint-1.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6885080da5cca2a9537c4c331ecf53820d6aff1d9558fdfccfd6c74430d3139", size = 1360700, upload-time = "2026-10-01T00:38:36.608Z" },
- { url = "https://files.pythonhosted.org/packages/90/11/00ca2b4b3e86983fa0b168922f878bb3353b2fbce8487f6b45a6979ce42a/djlint-1.46.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eec665942a0e86ebc15c8142dd59678fdd9a59e2e2099a56919b91c507ea1a33", size = 1399136, upload-time = "2026-10-01T00:38:38.274Z" },
- { url = "https://files.pythonhosted.org/packages/93/b4/2b8b7013b2acdb1a09fd7fb9472a864a8dfe63306a2329316962c2a34969/djlint-1.46.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77e4fd05a5b3f8ae114c495f71be6f1328adf7339f82ee752edfe8b0e6a27a1", size = 1449460, upload-time = "2026-10-01T00:38:39.871Z" },
- { url = "https://files.pythonhosted.org/packages/de/d4/7bcab4b34b8544450c7efce93fb38c2e4a758ffca6cc433a90156ffaf817/djlint-1.46.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:594ec91f8e5346597731ab5e7ef396546116b0ed6dd2267222cd223d5975ceab", size = 1418360, upload-time = "2026-10-01T00:38:41.918Z" },
- { url = "https://files.pythonhosted.org/packages/44/06/9a4663b3ec2c9eef53a53e26cf7442078d342343eac3d1847e234a8e8010/djlint-1.46.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3f0822e7ee23c9c65316dddeedb440604a21266b4135cd33393984177de06570", size = 1474741, upload-time = "2026-10-01T00:38:43.563Z" },
- { url = "https://files.pythonhosted.org/packages/32/3e/5551f03b736ca1a70d45d6499de0cd425b9556782651d9e3f28387f31807/djlint-1.46.4-cp314-cp314t-win32.whl", hash = "sha256:ba2565be7d35061df1a9fbd342fd84b0feeefe2318def7ef88ea4e7509c930a2", size = 1105596, upload-time = "2026-10-01T00:38:44.908Z" },
- { url = "https://files.pythonhosted.org/packages/0f/64/1cf6a61e811dbc1222684d8a6174d07c914274ef8cd48c24ab572736ccd1/djlint-1.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9c1ab43f24ebe7a8ab2a3e09bd5781af3d9e0842a7e59e059d2d813cf87fee", size = 1246241, upload-time = "2026-10-01T00:38:46.259Z" },
- { url = "https://files.pythonhosted.org/packages/90/2c/a0bf62341e182c20d860bca0b974a1e0486486deeeb486771dad50296abf/djlint-1.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8c193c0df13a6671746d277a2725dce6add5398cdfc94de24a40121a5f663b80", size = 1172531, upload-time = "2026-10-01T00:38:47.5Z" },
- { url = "https://files.pythonhosted.org/packages/b7/b2/052fae10a96f7a9099de7e4172c46b5f44fa87f059f06437140faf25b493/djlint-1.46.4-py3-none-any.whl", hash = "sha256:a22c3952f4f8178519bd46747a1eeeb5f3468ddb09763968ac09c56788d206a5", size = 161859, upload-time = "2026-10-01T00:39:13.798Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/da/df/4140783b3e4043b1eed65b118657d1a7cd2d6ce7bd2818e23bfec17759a7/djlint-1.46.6.tar.gz", hash = "sha256:22902d741035cc101d83f7c759ed01426c34bf203a0e3211fc8c2ffb4bb35480", size = 131612, upload-time = "2026-10-15T03:27:08.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/28/00f947f7b30268c2c621e1d58778e5e0e14d0ca194d6096625d49e34e57d/djlint-1.46.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f1f15a96c2f39361b19c296a47d98d169da5ed67bcfea636f1fe06c51bd099f", size = 1337363, upload-time = "2026-10-15T02:59:14.41Z" },
+ { url = "https://files.pythonhosted.org/packages/45/41/19c4bbed20cf927909a89c4b03dc995d0cceb794b8368026b6a3185e2895/djlint-1.46.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:269e6d099e4c82cbeaefdbb1eba349f9df6e78d381a0212c1f771cecb53103b0", size = 1262518, upload-time = "2026-10-15T02:59:16.301Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e5/b7ebaeb53472853d06d2505dd6e4346ba800617fbd5fc648934d87f6e79a/djlint-1.46.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3455d90163bfca2caa47d3500e351fb6aed303ab6ad9f96210eac2222d274293", size = 1413409, upload-time = "2026-10-15T02:59:18.532Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/1c/89c82d91e98107c90045b97736c17b537d34149660a615456e9cf8c9030c/djlint-1.46.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465cb40b32e7c74cedbedad76ce43e55279c8d9b7f4e3467cf3295c5e31ec00c", size = 1336115, upload-time = "2026-10-15T02:59:20.567Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/9b/5b222ce71426a9f176148c63d7983e564d737949932e723a8b104750b09b/djlint-1.46.6-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3160ef7bc8ba4812f70f9f19eb42f667e309c2c0f54e2adecfe8cd8162d8a1c", size = 1352240, upload-time = "2026-10-15T02:59:22.885Z" },
+ { url = "https://files.pythonhosted.org/packages/77/0f/b33d03f1c1903444fa1c547b0bacd1cfe161f4f64b4aecad07eea66045b4/djlint-1.46.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cf61a936f4cd60cf8cc670a07fc29c283ffe35be26c4151e575c39974e29646", size = 1382333, upload-time = "2026-10-15T02:59:24.797Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/c7/f068ddd174b9b66e9105d9c4036086170cb08acef1eadd6d0938262fb392/djlint-1.46.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6743f54c053ef65f294e4520be3bb6bb8ff27221058f208ff0059f4e694feb4d", size = 1358291, upload-time = "2026-10-15T02:59:26.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/80/c0b1bbd2e0680ba12c842f5bf87ce095f23beeb33668b5bf733e342d2e86/djlint-1.46.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3fdb21413c4bfe968aee8a0da3fda5fce1fcbb7feeff36dc31c9a7391f5f70a0", size = 1367848, upload-time = "2026-10-15T02:59:29.051Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/fa/1d91f514cdc3c0340593a550fc0766729ece9cfb66012f388348938e2627/djlint-1.46.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:87019693cc55fe47332c3c5109bd62270b1186933ea1deecc12d699a1e42de33", size = 1440700, upload-time = "2026-10-15T02:59:31.186Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8e/4fc875b219c276f563e89d51cb07a11fc972f30085394f6c927d1de54517/djlint-1.46.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f779c01332521cc7f74286a5b1b215d8122ada38784d1037fbd5a71aabe068ba", size = 1407801, upload-time = "2026-10-15T02:59:33.074Z" },
+ { url = "https://files.pythonhosted.org/packages/34/30/b41fb027954b653e9d7a1ba827b7b6ce18a717cd2817167ebf9815fad86b/djlint-1.46.6-cp314-cp314-win32.whl", hash = "sha256:d312bfd586340ee108d20dc1de14c0645ac3e42816dc105b1fbcfd004ac3ca61", size = 1035766, upload-time = "2026-10-15T02:59:34.826Z" },
+ { url = "https://files.pythonhosted.org/packages/77/9c/a006f10cdae9838afcc908b3a1e2898cede2adf06ca7104dd0236295c9ae/djlint-1.46.6-cp314-cp314-win_amd64.whl", hash = "sha256:a418d355c2c5952cd67beeb76a41e92d52b28dd9de75ac6e454ed32d9c386a0d", size = 1178600, upload-time = "2026-10-15T02:59:37.042Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/06/8506d1cff8d5e6fbbc7bc2de2473f45b34ef2be3b91312b304b0b8b4d3b0/djlint-1.46.6-cp314-cp314-win_arm64.whl", hash = "sha256:e894def910e4cf3235b8b904943fe7d5c55f4fd8d73760e08aafd3894fcfa29b", size = 1107041, upload-time = "2026-10-15T02:59:39.888Z" },
+ { url = "https://files.pythonhosted.org/packages/29/d1/b9e2c90a1baa9abf92895e843dbe690d23b3a31cce34c1026eb105e4ebb6/djlint-1.46.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1a8593773d7258964c09f7032e281d5b094b9360b32436756270b81d639b970e", size = 1455443, upload-time = "2026-10-15T02:59:41.624Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/09/6259e7aa2f16477fc75c242e40be5f15d8c0ccbc3e1e55b43594e08f7efc/djlint-1.46.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:756264d2d0de66ef9442cfaf0b8380accd6534b4fc880ce6ce4fb42d84a184a1", size = 1381106, upload-time = "2026-10-15T02:59:43.324Z" },
+ { url = "https://files.pythonhosted.org/packages/55/8d/572c12d8afac6cd08d9c133c4dd4ee897c1762c5fc69226fd4d30a996555/djlint-1.46.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5865104f80153f3ff39fe3f343e58a16edb650eb8f466eb6769372e8ccb28fc8", size = 1469328, upload-time = "2026-10-15T02:59:45.432Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/3b/0ce0f99c901b8a4da33b3981411e928f847d2a2ba832077b87e444fa3eb0/djlint-1.46.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:815da3a8aa82a6d546b7c45d1a8a7217107d21df4eaedf6cc1810c6511f73bc0", size = 1393705, upload-time = "2026-10-15T02:59:47.166Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/9d/79f31161f356bea68d16b31b6252ef83db2556abc36d8f21bdc4cbea8e7d/djlint-1.46.6-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55a143dab8fb3211ac42434db8f6b44d1ae4a27813c2d1ab1cfb7954d7cd91ec", size = 1395119, upload-time = "2026-10-15T02:59:49.256Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/4a/171ff2d36af4adefd35b5eeada57c2b9ac26d3863f098edc3962c804027a/djlint-1.46.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58be11a4a956544d8efa55bf7846f9941eb73ea9e577eae36f803c28b6d9a6ca", size = 1449658, upload-time = "2026-10-15T02:59:50.969Z" },
+ { url = "https://files.pythonhosted.org/packages/99/65/2430cb34bc7e733872f6546c9ae729b26bc5d05271c991787f1e324578b8/djlint-1.46.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:11b2d4f18eb169c2930dbda50660542acfe15968163a3fab7167c30b8c7db2d1", size = 1410932, upload-time = "2026-10-15T02:59:53.223Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/36/1ffea3cb47c9e5cf97ebdd3e1cddf081b9b102d23dd15db52952a3375a70/djlint-1.46.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4fa30c2e3cb68c399ca7d780315f5451a28bebb2c6d5e4bd885eb02ee11a61e", size = 1433889, upload-time = "2026-10-15T02:59:55.032Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9c/0addbf259e49b704e56b5e15969bf68433c677b21d84692f965239968c6b/djlint-1.46.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5ed1a3f5dd78ea61f632c0d3ad5496d7852dd905b247b0138c9c47ebb20e7346", size = 1499571, upload-time = "2026-10-15T02:59:56.844Z" },
+ { url = "https://files.pythonhosted.org/packages/22/67/a32b812aec9ec70678c284b6a730d91f2f4117977154f15e976318c43b9f/djlint-1.46.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:147e6ef0f7032cb4b0f489bb2ccbc67074948c76e65978ec706c039d4ba08f62", size = 1473024, upload-time = "2026-10-15T02:59:58.596Z" },
+ { url = "https://files.pythonhosted.org/packages/20/58/bd6a08ea217f7ff435cd07168b7b7159278c7d8a7b6aeeca4cdc5c42403d/djlint-1.46.6-cp314-cp314t-win32.whl", hash = "sha256:057caf7a2296546e5152ae9c53667aa83adda9376b9f8140038bdf9fef0599ad", size = 1095069, upload-time = "2026-10-15T03:26:06.752Z" },
+ { url = "https://files.pythonhosted.org/packages/60/9e/83e83c883013a58b797cb4de1b274b94eb9b02201c27854c7fccef427c15/djlint-1.46.6-cp314-cp314t-win_amd64.whl", hash = "sha256:8a62099ac8049a552e3e18a972718c343936faf94dc5b3b1095129c5a07ed729", size = 1232589, upload-time = "2026-10-15T03:26:09.334Z" },
+ { url = "https://files.pythonhosted.org/packages/46/6e/3de71066dcd81a2ab96b1bf0bd17fcf61fcc6a1ba28d7e04abb3201a6ea3/djlint-1.46.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4c80acda65b612536a0f9a4e07fd3af5452dfa6769bd35b3a8162ba1c0609116", size = 1158471, upload-time = "2026-10-15T03:26:11.14Z" },
+ { url = "https://files.pythonhosted.org/packages/07/6c/bf0e4fe1740b081997d116fa478970ba8f5ccfb0e850e28ad48941cb052c/djlint-1.46.6-py3-none-any.whl", hash = "sha256:b5f09b2817372484e3d9501f114599a9e01261c8e19fc25350b731ccd1071cf2", size = 165985, upload-time = "2026-10-15T03:27:07.016Z" },
]
[[package]]
@@ -662,14 +672,14 @@ wheels = [
[[package]]
name = "dunamai"
-version = "1.26.2"
+version = "1.26.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/12/18/020d3b27a10450ddb11429f637404e8ea67ecf4d9fd999d4f1d553f25506/dunamai-1.26.2.tar.gz", hash = "sha256:84ea45eddf9bb4b40df7610b1b22a03137365e6257dbf9d7b72128fdccca564c", size = 46536, upload-time = "2026-08-02T03:32:50.276Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c5/62/edf5352d8ce1371129ccfe971e6de3a1ce4dc7f9bdc80390fb79856b78d4/dunamai-1.26.3.tar.gz", hash = "sha256:fe2f732186710ee5048044fdbef6c76c6afbb9ee7de4f6d00cf34256e9ec2e7f", size = 46820, upload-time = "2026-10-14T20:13:30.921Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl", hash = "sha256:4234be3a90c3ec13ecf75d94a2248068e5a3018e8112892fcd9f3a1f287c9c32", size = 27478, upload-time = "2026-08-02T03:32:49.348Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/f4/e3be26f6301535ab4a3a940b09e55615c6286195b07fec9aa2cf8b842563/dunamai-1.26.3-py3-none-any.whl", hash = "sha256:cc486963f21ad5b5b20d95424909ff5029990241eea3e2272865e1542c5fd19f", size = 27628, upload-time = "2026-10-14T20:13:29.974Z" },
]
[[package]]
@@ -725,11 +735,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/81/91/462c781b7f4e141eb
[[package]]
name = "filelock"
-version = "4.1.2"
+version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8c/32/5f1e1408eda63bdecdb814dfd18b1109d239fb9695d51780c2e8a2816e76/filelock-4.1.2.tar.gz", hash = "sha256:c541dd91878706be3ff20e934a0b913972d4eff7a20ddfc17defd2b7328d006c", size = 565129, upload-time = "2026-10-14T14:47:34.513Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/a9/1af41b37c3279712b22cdc63aac78a52432202b6fe1f9666a2a3d2831fb4/filelock-4.2.0.tar.gz", hash = "sha256:7a60906c75227cf04d0c273afadc8219400f11aeb13cc69591d4f6cdc6c8036e", size = 569667, upload-time = "2026-10-14T20:57:13.11Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/84/16cbc2a5a102edf4b745ca43d7b9aaa8e26d46686c8e54d70d3d1225659e/filelock-4.1.2-py3-none-any.whl", hash = "sha256:4d5dc2a1d64d6b48a5f0540814000986fd62ebd3ac9ccbb1d07d669e075db9bb", size = 132861, upload-time = "2026-10-14T14:47:33Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a3/9bc26acff301fe1aaea1cc3d82a1d57e0a34df3e1cadbfa91ac2dbcdde5c/filelock-4.2.0-py3-none-any.whl", hash = "sha256:2ff5690882e8cdb00ef31fb3d01a3094c29f30985426c59495afb1733f3b7238", size = 135032, upload-time = "2026-10-14T20:57:11.349Z" },
]
[[package]]
@@ -1327,11 +1337,11 @@ wheels = [
[[package]]
name = "platformdirs"
-version = "4.13.0"
+version = "4.13.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094, upload-time = "2026-10-11T02:05:24.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce", size = 61559, upload-time = "2026-10-16T01:16:16.573Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4", size = 32762, upload-time = "2026-10-16T01:16:15.051Z" },
]
[[package]]
@@ -1355,11 +1365,11 @@ wheels = [
[[package]]
name = "pluggy"
-version = "1.6.0"
+version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
@@ -1695,14 +1705,14 @@ wheels = [
[[package]]
name = "python-discovery"
-version = "1.6.2"
+version = "1.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz", hash = "sha256:cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4", size = 86921, upload-time = "2026-10-10T01:24:27.912Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/54/fad125f5c48f7821644ee6f223ebfcedc4e23597c1a16eeb13d33aa4a91a/python_discovery-1.6.3.tar.gz", hash = "sha256:a62b301d96cf5489cb96ab023b32e068421867fdd5df2e42eece877869047fa2", size = 88998, upload-time = "2026-10-16T03:54:29.507Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl", hash = "sha256:f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2", size = 38912, upload-time = "2026-10-10T01:24:26.721Z" },
+ { url = "https://files.pythonhosted.org/packages/de/ec/643826e4d69b7bdce4213f221e8f5ed06d0a9dd69fa9b33342578d2cc8c8/python_discovery-1.6.3-py3-none-any.whl", hash = "sha256:cb7654125e3dcb594269a6feb56ac693f1f13b7c654493a93eba72dc22d18034", size = 39386, upload-time = "2026-10-16T03:54:28.237Z" },
]
[[package]]
@@ -1998,27 +2008,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
- { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
- { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
- { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
- { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
- { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
- { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
- { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
- { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
- { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
- { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
- { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
- { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
- { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
- { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
- { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
- { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
+version = "0.17.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" },
+ { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" },
+ { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" },
+ { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" },
+ { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" },
+ { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
]
[[package]]
@@ -2364,7 +2374,7 @@ wheels = [
[[package]]
name = "virtualenv"
-version = "21.14.7"
+version = "21.14.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
@@ -2373,9 +2383,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "python-discovery" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/dc/3f/935cf1757643381fb7f7cc98197b4bc119223d9c2cf37dc3b03ad2a4455f/virtualenv-21.14.7.tar.gz", hash = "sha256:5f427d56f39eb7e7447d641dd4b7ab1e9c92793f498f35f26da2e4972c5546ae", size = 5471741, upload-time = "2026-10-12T01:27:31.842Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/6a/fbdeea5c07d5674139639d42a97a41d26e9b9b8cb2a1265fa936f655ee20/virtualenv-21.14.8.tar.gz", hash = "sha256:394e45ed610ceffb5018e1a179e3a8012c02fd87fe792ab4aae67ce5e7e3afbd", size = 5474561, upload-time = "2026-10-16T00:09:09.672Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/52/5b/799c3e5b8e04d5fa1c9b61a4c1f5079603e747205c7311b92abd30283df5/virtualenv-21.14.7-py3-none-any.whl", hash = "sha256:3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b", size = 5487587, upload-time = "2026-10-12T01:27:29.37Z" },
+ { url = "https://files.pythonhosted.org/packages/14/01/cd63b97dd556293e1c74d76053936d9cef11a28eac605c735ae9f0ee324f/virtualenv-21.14.8-py3-none-any.whl", hash = "sha256:d2a347fdbc1a65e33c2c5e84dc767b4a3538a25749536a752744d4fe1f71c37a", size = 5487606, upload-time = "2026-10-16T00:09:07.441Z" },
]
[[package]]
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org