diff options
Diffstat
| -rw-r--r-- | .github/workflows/ci.yml | 44 | +0 −44 |
| -rw-r--r-- | .github/workflows/legal.yml | 34 | +0 −34 |
| -rw-r--r-- | .gitignore | 5 | +0 −5 |
| -rw-r--r-- | .mailmap | 4 | +0 −4 |
| -rw-r--r-- | CLA.md | 6 | +3 −3 |
| -rw-r--r-- | LICENSE | 214 | +12 −202 |
| l--------- | LICENSES/0BSD.txt | 1 | +1 −0 |
| l--------- | LICENSES/Apache-2.0.txt | 1 | +0 −1 |
| -rw-r--r-- | README.md | 131 | +7 −124 |
| -rw-r--r-- | REUSE.toml | 10 | +1 −9 |
| -rw-r--r-- | config/cla-unsupported-commits | 2 | +1 −1 |
| -rw-r--r-- | config/signoff-approved-emails | 2 | +1 −1 |
| -rw-r--r-- | mise.lock | 81 | +0 −81 |
| -rw-r--r-- | mise.toml | 30 | +0 −30 |
| -rwxr-xr-x | tasks/chart.py | 87 | +0 −87 |
| -rwxr-xr-x | tasks/container.py | 105 | +0 −105 |
| -rw-r--r-- | tasks/libs/__init__.py | 2 | +0 −2 |
| -rw-r--r-- | tasks/libs/apk.py | 144 | +0 −144 |
| -rw-r--r-- | tasks/libs/apt.py | 79 | +0 −79 |
| -rw-r--r-- | tasks/libs/common.py | 178 | +0 −178 |
| -rw-r--r-- | tasks/libs/repository.py | 111 | +0 −111 |
| -rw-r--r-- | tasks/libs/rpm.py | 54 | +0 −54 |
| -rw-r--r-- | tasks/libs/storage.py | 156 | +0 −156 |
| -rwxr-xr-x | tasks/licenses | 52 | +52 −0 |
| -rwxr-xr-x | tasks/licenses-json.py | 249 | +0 −249 |
| -rwxr-xr-x | tasks/licenses.py | 95 | +0 −95 |
| -rwxr-xr-x | tasks/package.py | 224 | +0 −224 |
| -rwxr-xr-x | tasks/publish.py | 66 | +0 −66 |
| -rwxr-xr-x | tasks/signoff | 155 | +155 −0 |
| -rwxr-xr-x | tasks/signoff.py | 208 | +0 −208 |
| -rw-r--r-- | tests/fakes/boto3.py | 127 | +0 −127 |
| -rwxr-xr-x | tests/licenses-json.py | 120 | +0 −120 |
| -rwxr-xr-x | tests/oci-tasks.py | 147 | +0 −147 |
| -rwxr-xr-x | tests/package-integration.py | 93 | +0 −93 |
| -rwxr-xr-x | tests/package.py | 270 | +0 −270 |
| -rwxr-xr-x | tests/publish-integration.py | 346 | +0 −346 |
36 files changed, 233 insertions, 3400 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - release-tasks: - name: Package and publish tasks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 - with: - version: 2026.7.5 - experimental: true - install: false - - - name: Install dependencies - run: mise bootstrap --locked --yes --update - - - name: Check Python - run: | - ruff check tasks tests - ruff format --check tasks tests - - - name: Tests - run: | - tests/licenses-json.py - tests/oci-tasks.py - - tests/package.py - tests/package-integration.py - - tests/publish-integration.py diff --git a/.github/workflows/legal.yml b/.github/workflows/legal.yml deleted file mode 100644 --- a/.github/workflows/legal.yml +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -name: Legal - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - legal: - name: Legal checks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 0 - - - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 - with: - version: 2026.7.5 - experimental: true - install: false - - - name: Check contribution sign-off - run: mise run signoff - - - name: Check licensing policy - run: mise run licenses diff --git a/.gitignore b/.gitignore deleted file mode 100644 --- a/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -__pycache__/ -*.py[cod] diff --git a/.mailmap b/.mailmap deleted file mode 100644 --- a/.mailmap +++ /dev/null @@ -1,4 +0,0 @@ -# Add new entries in alphabetical order - -Nikolay Govorov <me@govorov.online> -Nikolay Govorov <mr@dimidiumlabs.io> diff --git a/CLA.md b/CLA.md index eaf58a2..2427696 100644 --- a/CLA.md +++ b/CLA.md @@ -1,4 +1,4 @@ -# Dimidium Labs Platform Individual Contributor License Agreement +# Dimidium Labs Infrastructure Individual Contributor License Agreement Version 1.0 @@ -6,8 +6,8 @@ Version 1.0 > Agreement Version 1.0 licensed under a > [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/). -Thank you for your interest in contributing to Dimidium Labs Platform (the -"Project"). In this Agreement, "We" and "Us" mean Nikolay Govorov. +Thank you for your interest in contributing to Dimidium Labs Infrastructure +(the "Project"). In this Agreement, "We" and "Us" mean Nikolay Govorov. This contributor agreement ("Agreement") documents the rights granted by contributors to Us. To make this document effective, You must personally add diff --git a/LICENSE b/LICENSE index d645695..7d70db4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,202 +1,12 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed 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. +Copyright (C) 2026 Nikolay Govorov + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/LICENSES/0BSD.txt b/LICENSES/0BSD.txt new file mode 120000 --- /dev/null +++ b/LICENSES/0BSD.txt @@ -0,0 +1 @@ +../LICENSE
\ No newline at end of file diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt deleted file mode 120000 --- a/LICENSES/Apache-2.0.txt +++ /dev/null @@ -1 +0,0 @@ -../LICENSE
\ No newline at end of file diff --git a/README.md b/README.md index 941b3fd..a10fee3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ -# Dimidium Labs platform +# Dimidium Labs infrastructure -This repository contains shared building blocks for Dimidium Labs projects: -reusable development and release tasks, common Go and npm libraries, and shared -documentation. +This repository contains executable development and release tasks shared by +Dimidium Labs projects. GitHub Actions is only a runner for these tasks. -The current executable tasks live in `tasks/`. GitHub Actions is only a runner -for these tasks. Projects include `tasks/` with +Projects include `tasks/` with [mise remote Git includes](https://mise.jdx.dev/tasks/task-configuration.html#remote-git-includes). By default, tasks are fetched directly from this public repository over HTTPS: @@ -16,132 +14,17 @@ mise run licenses Consuming projects pin this repository by commit SHA. -## Packaging - -Projects build and stage their own binaries and keep their nFPM configuration. -The shared [`package`](tasks/package.py) task creates only the formats explicitly -requested by a project: nFPM packages (`deb`, `rpm`, or `apk`) and portable -archives (`tar.gz` or `zip`). APK configurations may use -`${PACKAGE_KEY_VERSION}` in `apk.signature.key_name`; the task renders the -four-digit generation before invoking nFPM. DEB and RPM payloads are built by -nFPM and then signed through `debsigs` and `rpmsign`, allowing CI to use only an -OpenPGP signing subkey while the certification key remains offline. The shared -[`publish`](tasks/publish.py) task adds -explicitly selected package formats to signed repositories in the organization -package bucket. - -```console -mise run package -- \ - --version VERSION --arch ARCH --output DIR \ - [--config nfpm.yaml] [--apk-public-key NAME.rsa.pub] \ - deb rpm apk - -mise run package -- \ - --archive-root DIR --archive-name NAME --output DIR \ - tar.gz zip -``` - -## OCI artifacts - -The shared [`container`](tasks/container.py) task builds one or more tagged OCI -images with Docker Buildx. Registry authentication is deliberately left to the -calling workflow, so the same build can be pushed to GHCR, Cloudflare, or -another OCI registry. The [`chart`](tasks/chart.py) task strictly lints a Helm -chart, packages an immutable version, and can push it to one or more OCI -repositories. - -```console -mise run container -- \ - --context . --file deploy/Dockerfile \ - --platform linux/amd64,linux/arm64 \ - --target site --build-arg APP=site \ - --tag ghcr.io/example/site:1.2.3 \ - --cache-scope site --push - -mise run chart -- \ - --chart charts/service --version 1.2.3 --app-version 1.2.3 \ - --output dist/charts --push oci://ghcr.io/example/charts -``` - -Container tags, chart versions, credentials, and release policy remain owned by -the consuming project. `--provenance false --sbom false` is available for -registries that do not accept OCI attestation indexes. Without `--push` or -`--load`, Buildx only validates and caches the build result. - -## Package repositories - -Projects publish beneath a service-owned prefix at -`https://pkg.dimidiumlabs.io/<service>/`. Channels are explicit, previously -published package payloads are retained, and an S3 lock serializes metadata -updates for each service/channel. - -```console -mise run publish -- \ - --service SERVICE --channel CHANNEL --input DIR \ - deb rpm apk -``` - -The selected formats map to these layouts: - -- APT: `<service>/apt/{dists,pool}/<channel>/` -- RPM: `<service>/rpm/<channel>/` -- APK: `<service>/apk/<channel>/<architecture>/` - -APT and RPM metadata refer to the aggregate organization OpenPGP bundle at -`/packages.gpg`. Immutable generation keys live at -`/keys/packages.<version>.gpg` and -`/keys/packages.<version>.rsa.pub`. APK packages and indexes embed -the versioned RSA key name. Public keys are provisioned independently; each -publication checks its signing keys against the selected generation and never -creates or replaces key objects. - -Bucket configuration comes from `S3_BUCKET`, `S3_ENDPOINT`, `S3_PUBLIC_URL`, -`S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`. `PACKAGE_KEY_VERSION` selects -the four-digit key generation. OpenPGP signing uses `GPG_PRIVATE_KEY`, -`GPG_PASSPHRASE`, and `GPG_KEY_ID`; APK index signing uses `APK_PRIVATE_KEY`. - -## Tool provisioning - -Each project declares its toolchain and standalone CLI dependencies in -`mise.toml`. A fresh checkout is provisioned with one command: - -```console -mise bootstrap -``` - -Shared tasks declare task-specific tools in their `#MISE tools` metadata, so -`mise run` installs the same pinned versions on demand. Python tasks declare -their dependencies inline with PEP 723 and run through pipx's standard pip -backend, without a project virtual environment or install step. System libraries -that cannot be installed as portable tools belong in `[bootstrap.packages]`. - ## Guardrails ### Licensing policy -`tasks/licenses.py` runs a pinned REUSE version and verifies the repository's +`tasks/licenses` runs a pinned REUSE version and verifies the repository's licensing metadata and canonical SPDX copyright headers. In Rust projects it also runs a pinned `cargo deny check`. -The separate `licenses-json` task uses cargo-about to generate a deterministic, -embeddable JSON bundle for a Rust binary. It accepts every license declared by -the dependency graph because policy enforcement remains the responsibility of -`cargo deny`. Repeat `--target` to produce one bundle for all supported targets: - -```console -mise run licenses-json -- \ - --manifest-path crates/server/Cargo.toml \ - --output crates/server/licenses.json \ - --target x86_64-unknown-linux-gnu \ - --target aarch64-unknown-linux-gnu -``` - -Use `--check` with the same arguments in CI to verify that a committed bundle is -up to date, or `--offline` when all dependency sources are already cached. - ### Sign-off policy -`tasks/signoff.py` verifies that: +`tasks/signoff` verifies that: - authors and co-authors with an email from `config/signoff-approved-emails` are trusted without a trailer; @@ -194,5 +77,5 @@ Remember, AI agents should make software better, not worse. ## Licensing -Unless noted otherwise, software and configuration are licensed under Apache-2.0. +Executable tasks and their configuration are licensed under 0BSD. Documentation is licensed under CC-BY-4.0. diff --git a/REUSE.toml b/REUSE.toml index f29e237..f4aa259 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: 0BSD version = 1 @@ -16,11 +16,3 @@ path = [ ] SPDX-FileCopyrightText = "2026 Nikolay Govorov" SPDX-License-Identifier = "CC-BY-3.0" - -[[annotations]] -path = [ - ".mailmap", - "mise.lock", -] -SPDX-FileCopyrightText = "2026 Nikolay Govorov" -SPDX-License-Identifier = "Apache-2.0" diff --git a/config/cla-unsupported-commits b/config/cla-unsupported-commits index 726ab62..3b38c1c 100644 --- a/config/cla-unsupported-commits +++ b/config/cla-unsupported-commits @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: 0BSD # # External contributions retained in history but not covered by a versioned CLA. diff --git a/config/signoff-approved-emails b/config/signoff-approved-emails index dd67622..234b671 100644 --- a/config/signoff-approved-emails +++ b/config/signoff-approved-emails @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: 0BSD # # One exact author or committer email per line. diff --git a/mise.lock b/mise.lock deleted file mode 100644 --- a/mise.lock +++ /dev/null @@ -1,81 +0,0 @@ -# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html - -[[tools.pipx]] -version = "1.16.7" -backend = "aqua:pypa/pipx" - -[tools.pipx."platforms.linux-arm64"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[tools.pipx."platforms.linux-arm64-musl"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[tools.pipx."platforms.linux-x64"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[tools.pipx."platforms.linux-x64-musl"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[tools.pipx."platforms.macos-arm64"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[tools.pipx."platforms.macos-x64"] -checksum = "sha256:302633d0061e0ab4269257501cbe1338cde5f00f22f121543706624aada0145e" -url = "https://github.com/pypa/pipx/releases/download/1.16.7/pipx.pyz" -url_api = "https://api.github.com/repos/pypa/pipx/releases/assets/513588802" - -[[tools.ruff]] -version = "0.16.3" -backend = "aqua:astral-sh/ruff" - -[tools.ruff."platforms.linux-arm64"] -checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" -provenance = "github-attestations" - -[tools.ruff."platforms.linux-arm64-musl"] -checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" -provenance = "github-attestations" - -[tools.ruff."platforms.linux-x64"] -checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" -provenance = "github-attestations" - -[tools.ruff."platforms.linux-x64-musl"] -checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" -provenance = "github-attestations" - -[tools.ruff."platforms.macos-arm64"] -checksum = "sha256:136a4db6512d9b16dda56ac8604696ed65c3b1a914a142de029e7f8d5006f1d9" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140218" -provenance = "github-attestations" - -[tools.ruff."platforms.macos-x64"] -checksum = "sha256:05c2a6705e7c0c056d6d93ff538978583f0c47b4c28d334ab9d58d2e8daf4c24" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140338" -provenance = "github-attestations" - -[tools.ruff."platforms.windows-x64"] -checksum = "sha256:f10c709755b393fd9821506b21070bcca969b9966504edd1e490efd08e3662ba" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140346" -provenance = "github-attestations" diff --git a/mise.toml b/mise.toml deleted file mode 100644 --- a/mise.toml +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -min_version = "2026.7.5" - -[settings] -experimental = true - -[tools] -pipx = "1.16.7" -ruff = "0.16.3" - -[bootstrap.packages] -# APT -"apt:apt-utils" = "latest" -"apt:createrepo-c" = "latest" -"apt:debsigs" = "latest" -"apt:gnupg" = "latest" -"apt:openssl" = "latest" -"apt:rpm" = "latest" - -# DNF -"dnf:createrepo_c" = "latest" -"dnf:gnupg2" = "latest" -"dnf:openssl" = "latest" -"dnf:rpm" = "latest" - -[task_config] -dir = "{{cwd}}" -includes = ["tasks"] diff --git a/tasks/chart.py b/tasks/chart.py deleted file mode 100755 --- a/tasks/chart.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Lint, package, and optionally publish a Helm chart" -#MISE tools={"pipx"="1.16.7","python"="3.14.7","helm"="4.1.1"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import re -import sys -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import TaskError, require_command, run, task_main - -TASK = "chart" -CHART_NAME = re.compile(r"^name:\s*([A-Za-z0-9_.-][A-Za-z0-9_.-]*)\s*$") - - -def chart_name(chart: Path) -> str: - for line in (chart / "Chart.yaml").read_text().splitlines(): - if match := CHART_NAME.fullmatch(line): - return match.group(1) - raise TaskError(f"{TASK}: cannot read chart name from {chart}/Chart.yaml") - - -async def main(args: Sequence[str]) -> None: - command = argparse.ArgumentParser(prog="mise run chart --") - command.add_argument("--chart", required=True, type=Path) - command.add_argument("--version") - command.add_argument("--app-version") - command.add_argument("--output", default=Path("dist/charts"), type=Path) - command.add_argument("--push", action="append", default=[], metavar="OCI_URL") - command.add_argument("--lint-only", action="store_true") - arguments = command.parse_args(args) - - require_command("helm", TASK) - if not arguments.chart.is_dir(): - raise TaskError(f"{TASK}: directory not found: {arguments.chart}") - if not (arguments.chart / "Chart.yaml").is_file(): - raise TaskError(f"{TASK}: Chart.yaml not found in {arguments.chart}") - for registry in arguments.push: - if not registry.startswith("oci://"): - command.error(f"registry must use oci://: {registry}") - - name = chart_name(arguments.chart) - await run("helm", "lint", arguments.chart, "--strict") - - if arguments.lint_only: - if arguments.version or arguments.app_version or arguments.push: - command.error("--lint-only cannot package or push a chart") - return - if not arguments.version: - command.error("--version is required unless --lint-only is used") - - arguments.output.mkdir(parents=True, exist_ok=True) - package_arguments: list[str | Path] = [ - "helm", - "package", - arguments.chart, - "--destination", - arguments.output, - "--version", - arguments.version, - ] - if arguments.app_version: - package_arguments.extend(("--app-version", arguments.app_version)) - await run(package_arguments) - - package = arguments.output / f"{name}-{arguments.version}.tgz" - if not package.is_file(): - raise TaskError(f"{TASK}: Helm did not create expected package: {package}") - for registry in arguments.push: - await run("helm", "push", package, registry.rstrip("/")) - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/container.py b/tasks/container.py deleted file mode 100755 --- a/tasks/container.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Build and optionally publish an OCI container image" -#MISE tools={"pipx"="1.16.7","python"="3.14.7"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import sys -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import TaskError, require_command, run, task_main - -TASK = "container" - - -async def main(args: Sequence[str]) -> None: - command = argparse.ArgumentParser(prog="mise run container --") - command.add_argument("--context", default=Path("."), type=Path) - command.add_argument("--file", default=Path("Dockerfile"), type=Path) - command.add_argument("--platform", default="linux/amd64") - command.add_argument("--target") - command.add_argument("--build-arg", action="append", default=[]) - command.add_argument("--label", action="append", default=[]) - command.add_argument("--tag", action="append", required=True) - command.add_argument("--cache-scope") - command.add_argument("--provenance", choices=("true", "false"), default="true") - command.add_argument("--sbom", choices=("true", "false"), default="true") - command.add_argument("--push", action="store_true") - command.add_argument("--load", action="store_true") - arguments = command.parse_args(args) - - require_command("docker", TASK) - if not arguments.context.is_dir(): - raise TaskError(f"{TASK}: context directory not found: {arguments.context}") - dockerfile = ( - arguments.file - if arguments.file.is_absolute() - else arguments.context / arguments.file - ) - if not dockerfile.is_file(): - raise TaskError(f"{TASK}: Dockerfile not found: {dockerfile}") - for value in arguments.build_arg: - if "\n" in value: - command.error("build arguments cannot contain newlines") - for value in arguments.label: - if "\n" in value: - command.error("labels cannot contain newlines") - for tag in arguments.tag: - if not tag or any(character.isspace() for character in tag): - command.error(f"invalid image tag: {tag}") - if arguments.push and arguments.load: - command.error("--push and --load are mutually exclusive") - if arguments.load and "," in arguments.platform: - command.error("--load supports exactly one platform") - - build: list[str | Path] = [ - "docker", - "buildx", - "build", - "--file", - dockerfile, - "--platform", - arguments.platform, - f"--provenance={arguments.provenance}", - f"--sbom={arguments.sbom}", - ] - if arguments.target: - build.extend(("--target", arguments.target)) - for value in arguments.build_arg: - build.extend(("--build-arg", value)) - for value in arguments.label: - build.extend(("--label", value)) - for tag in arguments.tag: - build.extend(("--tag", tag)) - if arguments.cache_scope: - build.extend( - ( - "--cache-from", - f"type=gha,scope={arguments.cache_scope}", - "--cache-to", - f"type=gha,mode=max,scope={arguments.cache_scope}", - ) - ) - if arguments.push: - build.append("--push") - elif arguments.load: - build.append("--load") - build.append(arguments.context) - - await run(*build) - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/libs/__init__.py b/tasks/libs/__init__.py deleted file mode 100644 --- a/tasks/libs/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 diff --git a/tasks/libs/apk.py b/tasks/libs/apk.py deleted file mode 100644 --- a/tasks/libs/apk.py +++ /dev/null @@ -1,144 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import asyncio -import gzip -import hashlib -import io -import os -import platform -import shutil -import tarfile -import urllib.request -from pathlib import Path - -from shellous import sh - -from .common import TaskError, capture, run - -TASK = "publish" -APK_TOOLS_VERSION = "2.14.10-r0" -APK_TOOLS_SHA256 = { - "x86_64": "c86e3822764e5fe19f41ce2e13553e48cac1ea4e74f858338e8d44bf0b616b61", - "aarch64": "3e22f80dd0272dc487e4ca84b2c6b660ca392cbad970764efe9ef9555b806ac8", -} - - -async def architecture(package: Path) -> str: - metadata = await capture("tar", "-xOzf", package, ".PKGINFO").stderr(sh.DEVNULL) - for line in metadata.splitlines(): - if line.startswith("arch = "): - return line.removeprefix("arch = ") - raise TaskError(f"{TASK}: cannot read APK architecture: {package}") - - -async def apk_tool(work: Path) -> Path: - configured = os.environ.get("APK_TOOL") - if configured: - tool = Path(configured) - if os.access(tool, os.X_OK): - return tool - raise TaskError(f"{TASK}: APK_TOOL is not executable: {tool}") - for name in ("apk.static", "apk"): - if command := shutil.which(name): - return Path(command) - - machine = platform.machine() - apk_arch = { - "x86_64": "x86_64", - "amd64": "x86_64", - "aarch64": "aarch64", - "arm64": "aarch64", - }.get(machine) - if apk_arch is None: - raise TaskError(f"{TASK}: apk-tools is unavailable for {machine}") - archive = work / "apk-tools-static.apk" - url = ( - "https://dl-cdn.alpinelinux.org/alpine/v3.22/main/" - f"{apk_arch}/apk-tools-static-{APK_TOOLS_VERSION}.apk" - ) - await asyncio.to_thread(urllib.request.urlretrieve, url, archive) - if hashlib.sha256(archive.read_bytes()).hexdigest() != APK_TOOLS_SHA256[apk_arch]: - raise TaskError(f"{TASK}: apk-tools checksum mismatch") - directory = work / "apk-tools" - directory.mkdir() - await run("tar", "-xzf", archive, "-C", directory, "sbin/apk.static").stderr( - sh.DEVNULL - ) - return directory / "sbin" / "apk.static" - - -async def sign_index(context, index: Path) -> None: - name = f".SIGN.RSA256.{context.apk_signing.public_key_name}" - signature = index.parent / name - await run.set(env=context.apk_signing.environment, inherit_env=False)( - "openssl", - "dgst", - "-sha256", - "-sign", - context.apk_signing.private_key_file, - "-out", - signature, - index, - ) - data = signature.read_bytes() - stream = io.BytesIO() - with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive: - information = tarfile.TarInfo(name) - information.size = len(data) - information.mode = 0o644 - information.mtime = int(os.environ.get("SOURCE_DATE_EPOCH", "0")) - archive.addfile(information, io.BytesIO(data)) - size = 512 + ((len(data) + 511) // 512) * 512 - index.write_bytes( - gzip.compress(stream.getvalue()[:size], mtime=0) + index.read_bytes() - ) - signature.unlink() - - -async def publish(context) -> None: - tool = await apk_tool(context.work) - packages = context.packages["apk"] - package_architectures = dict( - zip( - packages, - await asyncio.gather(*(architecture(package) for package in packages)), - strict=True, - ) - ) - architectures = sorted(set(package_architectures.values())) - keys = context.work / "apk-keys" - keys.mkdir() - for public_key in context.apk_public_keys: - shutil.copy2(public_key, keys / public_key.name) - - for apk_arch in architectures: - root = context.work / "apk" / apk_arch - root.mkdir(parents=True) - remote = context.storage.service_key("apk", context.channel, apk_arch) - context.storage.download_prefix(remote, root, "*.apk") - for package in packages: - if package_architectures[package] == apk_arch: - context.add_package(package, root) - for package in root.glob("*.apk"): - result = await run.result(tool, "verify", "--keys-dir", keys, package) - if result.exit_code: - raise TaskError( - f"{TASK}: APK signature verification failed: {package.name}" - ) - index = root / "APKINDEX.tar.gz" - await run( - tool, - "--allow-untrusted", - "index", - "--description", - f"Dimidium Labs {context.service} {context.channel}", - "--output", - index, - sorted(root.glob("*.apk")), - ) - await sign_index(context, index) - context.storage.upload_payloads(root, remote, "*.apk") - context.storage.upload(index, f"{remote}/APKINDEX.tar.gz") diff --git a/tasks/libs/apt.py b/tasks/libs/apt.py deleted file mode 100644 --- a/tasks/libs/apt.py +++ /dev/null @@ -1,79 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -from .common import TaskError, capture, require_command, run - -TASK = "publish" - - -async def publish(context) -> None: - require_command("apt-ftparchive", TASK) - require_command("dpkg-deb", TASK) - root = context.work / "apt" - pool = root / "pool" / context.channel - metadata = root / "dists" / context.channel - pool.mkdir(parents=True) - pool_prefix = context.storage.service_key("apt", "pool", context.channel) - metadata_prefix = context.storage.service_key("apt", "dists", context.channel) - context.storage.download_prefix(pool_prefix, pool, "*.deb") - for package in context.packages["deb"]: - context.add_package(package, pool) - - architectures = sorted( - { - (await capture("dpkg-deb", "-f", package, "Architecture")).strip() - for package in pool.glob("*.deb") - } - ) - if not architectures or "" in architectures: - raise TaskError(f"{TASK}: no DEB architectures found") - for architecture in architectures: - (metadata / "main" / f"binary-{architecture}").mkdir( - parents=True, exist_ok=True - ) - - cache = context.work / "apt-cache" - cache.mkdir() - config = context.work / "apt-ftparchive.conf" - architecture_list = " ".join(architectures) - config.write_text( - f'''Dir {{ ArchiveDir "{root}"; CacheDir "{cache}"; }}; -Default {{ Packages::Compress ". gzip"; Packages::Extensions ".deb"; }}; -TreeDefault {{ - Packages "$(DIST)/$(SECTION)/binary-$(ARCH)/Packages"; - BinCacheDB "packages-$(ARCH).db"; -}}; -Tree "dists/{context.channel}" {{ - Sections "main"; - Architectures "{architecture_list}"; - Directory "pool/{context.channel}"; -}}; -''' - ) - await run("apt-ftparchive", "generate", config) - release = metadata / "Release" - await run( - "apt-ftparchive", - "-o", - "APT::FTPArchive::Release::Origin=Dimidium Labs", - "-o", - f"APT::FTPArchive::Release::Label={context.service} {context.channel}", - "-o", - f"APT::FTPArchive::Release::Suite={context.channel}", - "-o", - f"APT::FTPArchive::Release::Codename={context.channel}", - "-o", - "APT::FTPArchive::Release::Components=main", - "-o", - f"APT::FTPArchive::Release::Architectures={architecture_list}", - "release", - f"{metadata}/", - ).stdout(release) - await context.gpg.sign( - metadata / "Release.gpg", "--armor", "--detach-sign", release - ) - await context.gpg.sign(metadata / "InRelease", "--clearsign", release) - context.storage.upload_payloads(pool, pool_prefix, "*.deb") - context.storage.replace_prefix(metadata, metadata_prefix) diff --git a/tasks/libs/common.py b/tasks/libs/common.py deleted file mode 100644 --- a/tasks/libs/common.py +++ /dev/null @@ -1,178 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import asyncio -import os -import shutil -import sys -from collections.abc import Awaitable, Callable, Sequence -from pathlib import Path - -from shellous import ResultError, sh - -run = sh.stdout(sh.INHERIT).stderr(sh.INHERIT) -capture = sh.stderr(sh.INHERIT) - - -class TaskError(RuntimeError): - pass - - -def require_command(name: str, task: str) -> str: - command = shutil.which(name) - if command is None: - raise TaskError(f"{task}: {name} is required") - return command - - -def required_env(name: str, task: str, purpose: str = "") -> str: - value = os.environ.get(name) - if value: - return value - suffix = f" {purpose}" if purpose else "" - raise TaskError(f"{task}: {name} is required{suffix}") - - -def task_main( - task: str, - main: Callable[[Sequence[str]], Awaitable[None]], - args: Sequence[str], -) -> None: - try: - asyncio.run(main(args)) - except TaskError as error: - print(error, file=sys.stderr) - raise SystemExit(1) from None - except ResultError as error: - exit_code = error.result.exit_code - print(f"{task}: command failed with exit code {exit_code}", file=sys.stderr) - raise SystemExit(exit_code) from None - - -class GPGSigning: - def __init__(self, task: str, work: Path): - self.task = task - require_command("gpg", task) - private_key = required_env("GPG_PRIVATE_KEY", task) - self.passphrase = required_env("GPG_PASSPHRASE", task) - self.key_id = required_env("GPG_KEY_ID", task) - self.short_key_id = self.key_id[-16:] - self.home = work / "gnupg" - self.home.mkdir(mode=0o700) - self.private_key_file = work / "signing.asc" - self.private_key_file.write_text(private_key) - self.private_key_file.chmod(0o600) - self.environment = dict(os.environ) - self.environment["GNUPGHOME"] = str(self.home) - self.environment.pop("GPG_PRIVATE_KEY", None) - self.environment.pop("GPG_PASSPHRASE", None) - self.environment.pop("APK_PRIVATE_KEY", None) - - @classmethod - async def create(cls, task: str, work: Path) -> GPGSigning: - signing = cls(task, work) - command = run.set(env=signing.environment, inherit_env=False) - await ( - f"{signing.passphrase}\n" - | command( - "gpg", - "--batch", - "--yes", - "--pinentry-mode", - "loopback", - "--passphrase-fd", - "0", - "--import", - signing.private_key_file, - ) - ) - return signing - - def package_environment(self) -> dict[str, str]: - environment = dict(self.environment) - environment["GPG_KEY_ID"] = self.short_key_id - return environment - - async def prime_agent(self) -> None: - signature = self.private_key_file.with_suffix(".sig") - await self.sign(signature, "--detach-sign", self.private_key_file) - signature.unlink() - - async def export_public_key(self, output: Path) -> None: - command = run.set(env=self.environment, inherit_env=False) - await command( - "gpg", - "--batch", - "--yes", - "--armor", - "--export", - self.key_id, - ).stdout(output) - - async def verify_public_bundle(self, bundle: Path) -> None: - command = capture.set(env=self.environment, inherit_env=False) - output = await command( - "gpg", - "--batch", - "--with-colons", - "--show-keys", - bundle, - ) - fingerprints = { - line.split(":")[9] - for line in output.splitlines() - if line.startswith("fpr:") - } - if self.key_id not in fingerprints: - raise TaskError( - f"{self.task}: packages.gpg does not contain signing key {self.key_id}" - ) - - async def sign(self, output: Path, *arguments: str | Path) -> None: - command = run.set(env=self.environment, inherit_env=False) - await ( - f"{self.passphrase}\n" - | command( - "gpg", - f"--default-key={self.key_id}", - "--batch", - "--yes", - "--pinentry-mode", - "loopback", - "--passphrase-fd", - "0", - "-o", - output, - arguments, - ) - ) - - -class APKSigning: - def __init__(self, task: str, work: Path, key_name: str = "packages"): - require_command("openssl", task) - private_key = required_env("APK_PRIVATE_KEY", task, "for APK signing") - self.key_name = key_name - self.public_key_name = f"{key_name}.rsa.pub" - self.private_key_file = work / f"{key_name}.rsa" - self.private_key_file.write_text(private_key) - self.private_key_file.chmod(0o600) - self.environment = dict(os.environ) - self.environment.pop("APK_PRIVATE_KEY", None) - self.environment.pop("GPG_PRIVATE_KEY", None) - self.environment.pop("GPG_PASSPHRASE", None) - self.environment["APK_SIGNING_KEY"] = str(self.private_key_file) - - async def export_public_key(self, output: Path) -> None: - command = run.set(env=self.environment, inherit_env=False) - await command( - "openssl", - "rsa", - "-in", - self.private_key_file, - "-pubout", - "-out", - output, - ) diff --git a/tasks/libs/repository.py b/tasks/libs/repository.py deleted file mode 100644 --- a/tasks/libs/repository.py +++ /dev/null @@ -1,111 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import re -import shutil -from pathlib import Path - -from .common import APKSigning, GPGSigning, TaskError, required_env -from .storage import S3Storage - -TASK = "publish" -KEY_VERSION = re.compile(r"^[0-9]{4}$") -RSA_PUBLIC_KEY = re.compile(r"^keys/packages\.[0-9]{4}\.rsa\.pub$") - - -class Repository: - def __init__(self, service, channel, input_directory, formats, work): - self.service = service - self.channel = channel - self.formats = formats - self.work = work - self.public_url = required_env("S3_PUBLIC_URL", TASK).rstrip("/") - self.key_version = required_env("PACKAGE_KEY_VERSION", TASK) - if not KEY_VERSION.fullmatch(self.key_version): - raise TaskError(f"{TASK}: invalid PACKAGE_KEY_VERSION: {self.key_version}") - self.storage = S3Storage(TASK, service) - self.gpg = None - self.gpg_public_key = None - self.apk_signing = None - self.apk_public_keys = [] - self.packages = { - package_format: sorted(input_directory.glob(f"*.{package_format}")) - for package_format in formats - } - for package_format, packages in self.packages.items(): - if not packages: - raise TaskError( - f"{TASK}: no .{package_format} packages found in {input_directory}" - ) - - def check_public_key(self, source: Path, key: str) -> None: - existing = self.work / f"existing-{source.name}" - if not self.storage.download(key, existing): - raise TaskError(f"{TASK}: organization key {key} is not provisioned") - if source.read_bytes() != existing.read_bytes(): - raise TaskError(f"{TASK}: signing key does not match {key}") - - @staticmethod - def add_package(source: Path, directory: Path) -> None: - destination = directory / source.name - if destination.exists() and source.read_bytes() != destination.read_bytes(): - raise TaskError( - f"{TASK}: immutable package filename has different content: {source.name}" - ) - if not destination.exists(): - shutil.copy2(source, destination) - - async def setup_openpgp(self) -> None: - self.gpg = await GPGSigning.create(TASK, self.work) - current = self.work / "current-packages.gpg" - await self.gpg.export_public_key(current) - self.check_public_key(current, f"keys/packages.{self.key_version}.gpg") - - bundle = self.work / "packages.gpg" - if not self.storage.download("packages.gpg", bundle): - raise TaskError(f"{TASK}: organization key packages.gpg is not provisioned") - await self.gpg.verify_public_bundle(bundle) - self.gpg_public_key = bundle - - async def setup_rsa(self) -> None: - key_name = f"packages.{self.key_version}" - self.apk_signing = APKSigning(TASK, self.work, key_name) - current = self.work / self.apk_signing.public_key_name - await self.apk_signing.export_public_key(current) - self.check_public_key( - current, - f"keys/{self.apk_signing.public_key_name}", - ) - - key_directory = self.work / "rsa-public-keys" - key_directory.mkdir() - for key in sorted(self.storage.objects("keys/")): - if RSA_PUBLIC_KEY.fullmatch(key): - destination = key_directory / Path(key).name - if destination.exists(): - raise TaskError(f"{TASK}: duplicate RSA public key name: {key}") - self.storage.download(key, destination) - self.apk_public_keys.append(destination) - if current.name not in {key.name for key in self.apk_public_keys}: - raise TaskError( - f"{TASK}: current RSA public key is absent from key archive" - ) - - async def setup_signing(self) -> None: - if {"deb", "rpm"} & set(self.formats): - await self.setup_openpgp() - if "apk" in self.formats: - await self.setup_rsa() - - async def publish(self) -> None: - from .apk import publish as publish_apk - from .apt import publish as publish_apt - from .rpm import publish as publish_rpm - - publishers = {"deb": publish_apt, "rpm": publish_rpm, "apk": publish_apk} - with self.storage.lock(self.channel): - await self.setup_signing() - for package_format in self.formats: - await publishers[package_format](self) diff --git a/tasks/libs/rpm.py b/tasks/libs/rpm.py deleted file mode 100644 --- a/tasks/libs/rpm.py +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import shutil - -from .common import TaskError, capture, require_command, run - -TASK = "publish" - - -async def publish(context) -> None: - require_command("createrepo_c", TASK) - require_command("rpmkeys", TASK) - root = context.work / "rpm" - root.mkdir() - remote = context.storage.service_key("rpm", context.channel) - context.storage.download_prefix(remote, root, "*.rpm") - for package in context.packages["rpm"]: - context.add_package(package, root) - - rpm_database = context.work / "rpmdb" - rpm_database.mkdir() - await run("rpmkeys", "--dbpath", rpm_database, "--import", context.gpg_public_key) - for package in root.glob("*.rpm"): - result = await capture( - "rpmkeys", "--dbpath", rpm_database, "--checksig", package - ) - if "signatures OK" not in result: - raise TaskError( - f"{TASK}: RPM is not signed by a trusted key: {package.name}" - ) - - shutil.rmtree(root / "repodata", ignore_errors=True) - await run("createrepo_c", root) - repomd = root / "repodata" / "repomd.xml" - await context.gpg.sign( - repomd.with_suffix(".xml.asc"), "--armor", "--detach-sign", repomd - ) - definition = root / f"{context.service}-{context.channel}.repo" - definition.write_text( - f"""[{context.service}-{context.channel}] -name={context.service} {context.channel} -gpgkey={context.public_url}/packages.gpg -baseurl={context.public_url}/{context.service}/rpm/{context.channel}/ -enabled=1 -gpgcheck=1 -repo_gpgcheck=1 -""" - ) - context.storage.upload_payloads(root, remote, "*.rpm") - context.storage.upload(definition, f"{remote}/{definition.name}") - context.storage.replace_prefix(root / "repodata", f"{remote}/repodata") diff --git a/tasks/libs/storage.py b/tasks/libs/storage.py deleted file mode 100644 --- a/tasks/libs/storage.py +++ /dev/null @@ -1,156 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import fnmatch -import hashlib -import json -import time -import uuid -from contextlib import contextmanager -from pathlib import Path - -import boto3 -from botocore.exceptions import ClientError - -from .common import TaskError, required_env - - -class S3Storage: - def __init__(self, task: str, service: str): - self.task = task - self.bucket = required_env("S3_BUCKET", task) - self.service_root = service - self.client = boto3.client( - "s3", - endpoint_url=required_env("S3_ENDPOINT", task), - aws_access_key_id=required_env("S3_ACCESS_KEY_ID", task), - aws_secret_access_key=required_env("S3_SECRET_ACCESS_KEY", task), - region_name="auto", - ) - - def service_key(self, *parts: str) -> str: - return "/".join((self.service_root, *parts)) - - def download(self, key: str, destination: Path) -> bool: - destination.parent.mkdir(parents=True, exist_ok=True) - try: - self.client.download_file(self.bucket, key, str(destination)) - except ClientError as error: - if error.response.get("Error", {}).get("Code") in { - "404", - "NoSuchKey", - "NotFound", - }: - return False - raise - return True - - def objects(self, prefix: str) -> set[str]: - pages = self.client.get_paginator("list_objects_v2").paginate( - Bucket=self.bucket, Prefix=prefix - ) - return {item["Key"] for page in pages for item in page.get("Contents", [])} - - def download_prefix(self, prefix: str, destination: Path, pattern: str) -> None: - destination.mkdir(parents=True, exist_ok=True) - prefix = prefix.rstrip("/") + "/" - for key in self.objects(prefix): - relative = key.removeprefix(prefix).lstrip("/") - if relative and "/" not in relative and fnmatch.fnmatch(relative, pattern): - self.client.download_file(self.bucket, key, str(destination / relative)) - - @staticmethod - def digest(path: Path) -> str: - with path.open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - @staticmethod - def conflict(error: ClientError) -> bool: - return error.response.get("Error", {}).get("Code") in { - "409", - "412", - "ConditionalRequestConflict", - "PreconditionFailed", - } - - def upload_immutable(self, source: Path, key: str) -> None: - digest = self.digest(source) - try: - with source.open("rb") as stream: - self.client.put_object( - Bucket=self.bucket, - Key=key, - Body=stream, - Metadata={"sha256": digest}, - IfNoneMatch="*", - ) - return - except ClientError as error: - if not self.conflict(error): - raise - - existing = self.client.head_object(Bucket=self.bucket, Key=key) - existing_digest = existing.get("Metadata", {}).get("sha256") - if not existing_digest: - body = self.client.get_object(Bucket=self.bucket, Key=key)["Body"] - existing_digest = hashlib.sha256(body.read()).hexdigest() - if existing_digest != digest: - raise TaskError( - f"{self.task}: immutable object has different content: {key}" - ) - - def upload_payloads(self, source: Path, prefix: str, pattern: str) -> None: - for path in sorted(source.glob(pattern)): - self.upload_immutable(path, f"{prefix.rstrip('/')}/{path.name}") - - def upload(self, source: Path, key: str) -> None: - self.client.upload_file(str(source), self.bucket, key) - - @contextmanager - def lock(self, name: str, lifetime: int = 3600): - key = self.service_key("_locks", name) - body = json.dumps( - {"expires": int(time.time()) + lifetime, "id": uuid.uuid4().hex} - ) - try: - result = self.client.put_object( - Bucket=self.bucket, Key=key, Body=body.encode(), IfNoneMatch="*" - ) - except ClientError as error: - if not self.conflict(error): - raise - current = self.client.get_object(Bucket=self.bucket, Key=key) - state = json.loads(current["Body"].read()) - if state["expires"] > time.time(): - raise TaskError(f"{self.task}: publication already in progress: {name}") - result = self.client.put_object( - Bucket=self.bucket, - Key=key, - Body=body.encode(), - IfMatch=current["ETag"], - ) - try: - yield - finally: - self.client.delete_object( - Bucket=self.bucket, Key=key, IfMatch=result["ETag"] - ) - - def replace_prefix(self, source: Path, prefix: str) -> None: - prefix = prefix.rstrip("/") + "/" - wanted: set[str] = set() - for path in sorted(item for item in source.rglob("*") if item.is_file()): - key = prefix + path.relative_to(source).as_posix() - wanted.add(key) - self.client.upload_file(str(path), self.bucket, key) - stale = sorted(set(self.objects(prefix)) - wanted) - for offset in range(0, len(stale), 1000): - self.client.delete_objects( - Bucket=self.bucket, - Delete={ - "Objects": [{"Key": key} for key in stale[offset : offset + 1000]], - "Quiet": True, - }, - ) diff --git a/tasks/licenses b/tasks/licenses new file mode 100755 --- /dev/null +++ b/tasks/licenses @@ -0,0 +1,52 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD +#MISE description="Verify repository licensing metadata" +#MISE tools={"pipx:reuse"="6.2.0","aqua:EmbarkStudios/cargo-deny"="0.19.0"} + +set -eu + +check_copyright_headers() { + invalid_headers=$( + git grep -n -I -E \ + 'Copyright[[:space:]]+(\([cC]\)|©)|SPDX-FileCopyrightText:' \ + -- . \ + ':(exclude)*.md' \ + ':(exclude)LICENSE' \ + ':(exclude)LICENSES/**' \ + ':(exclude)COPYING*' | + awk 'match($0, /:[0-9]+:/) { + line = substr($0, RSTART + 1, RLENGTH - 2) + if (line > 10) { + next + } + + text = substr($0, RSTART + RLENGTH) + reason = "" + if (text ~ /^[[:space:]]*((<!--|#|\/\/|\/\*|\*)[[:space:]]*)?Copyright[[:space:]]+(\([cC]\)|©)/) { + reason = "legacy copyright header" + } else if (text ~ /SPDX-FileCopyrightText:($|[^ ]| [[:space:]])/) { + reason = "expected exactly one space after colon" + } else if (text ~ /SPDX-FileCopyrightText:.*Nikolay Govorov/ && + text !~ /SPDX-FileCopyrightText: 2026 Nikolay Govorov([[:space:]]*(\*\/|-->))?$/) { + reason = "expected 2026 Nikolay Govorov" + } + + if (reason != "") { + print reason ": " $0 + } + }' + ) + + if [ -n "$invalid_headers" ]; then + printf '%s\n%s\n' 'Invalid copyright headers:' "$invalid_headers" >&2 + return 1 + fi +} + +check_copyright_headers +reuse lint + +if [ -f Cargo.toml ]; then + cargo-deny check +fi diff --git a/tasks/licenses-json.py b/tasks/licenses-json.py deleted file mode 100755 --- a/tasks/licenses-json.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Generate embeddable Rust dependency license JSON" -#MISE tools={"pipx"="1.16.7","python"="3.14.7","cargo:cargo-about"="0.8.4"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import json -import re -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -sys.dont_write_bytecode = True - -from libs.common import TaskError, capture, require_command, run, task_main - -TASK = "licenses-json" -# 0.8.4 intentionally matches the license-file deduplication behavior of the -# legacy in-tree generator. Keep the task tool pin and normalizer in sync. -SPDX_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9.+:-]*|[()]") -SPDX_OPERATORS = {"AND", "OR", "WITH"} - - -def license_requirements(expression: str) -> list[str]: - """Return SPDX requirements in expression order, matching the legacy generator.""" - normalized = expression.replace("/", " OR ") - tokens = SPDX_TOKEN.findall(normalized) - requirements: list[str] = [] - - index = 0 - while index < len(tokens): - token = tokens[index] - if token in {"(", ")", "AND", "OR"}: - index += 1 - continue - if token == "WITH": - raise TaskError(f"{TASK}: invalid SPDX expression: {expression}") - - requirement = token - if index + 1 < len(tokens) and tokens[index + 1] == "WITH": - if index + 2 >= len(tokens) or tokens[index + 2] in SPDX_OPERATORS | { - "(", - ")", - }: - raise TaskError(f"{TASK}: invalid SPDX expression: {expression}") - requirement = f"{token} WITH {tokens[index + 2]}" - index += 2 - - if requirement not in requirements: - requirements.append(requirement) - index += 1 - - return requirements - - -def cargo_about_config(metadata: dict[str, Any]) -> str: - accepted_by_crate: dict[str, list[str]] = {} - for package in metadata.get("packages", []): - expression = package.get("license") - if not expression: - continue - - accepted = accepted_by_crate.setdefault(package["name"], []) - for requirement in license_requirements(expression): - if requirement not in accepted: - accepted.append(requirement) - - lines = [ - "accepted = []", - "private = { ignore = true }", - "ignore-build-dependencies = true", - "ignore-dev-dependencies = true", - "ignore-transitive-dependencies = false", - ] - for name, accepted in sorted(accepted_by_crate.items()): - lines.extend( - ("", f"[{json.dumps(name)}]", f"accepted = {json.dumps(accepted)}") - ) - - return "\n".join(lines) + "\n" - - -def normalized_output(report: dict[str, Any]) -> str: - licenses: list[dict[str, Any]] = [] - for source in report.get("licenses", []): - used_by = [ - { - "crate": { - "name": usage["crate"]["name"], - "version": usage["crate"]["version"], - "repository": usage["crate"].get("repository"), - } - } - for usage in source["used_by"] - ] - used_by.sort(key=lambda usage: len(usage["crate"]["name"])) - licenses.append( - { - "name": source["name"], - "id": source["id"], - "first_of_kind": False, - "text": source["text"], - "used_by": used_by, - } - ) - - licenses.sort(key=lambda license_: license_["id"]) - - overview_by_id: dict[str, dict[str, Any]] = {} - for license_ in licenses: - first = license_["id"] not in overview_by_id - license_["first_of_kind"] = first - overview = overview_by_id.setdefault( - license_["id"], - { - "count": 0, - "name": license_["name"], - "id": license_["id"], - }, - ) - overview["count"] += len(license_["used_by"]) - - overview = sorted(overview_by_id.values(), key=lambda item: item["name"]) - output = {"overview": overview, "licenses": licenses} - return json.dumps(output, ensure_ascii=False, indent=2) + "\n" - - -async def cargo_metadata(manifest_path: Path, offline: bool) -> dict[str, Any]: - arguments: list[str | Path] = [ - "cargo", - "metadata", - "--format-version", - "1", - "--locked", - "--manifest-path", - manifest_path, - ] - if offline: - arguments.append("--offline") - - output = await capture(arguments) - try: - return json.loads(output) - except json.JSONDecodeError as error: - raise TaskError( - f"{TASK}: cargo metadata returned invalid JSON: {error}" - ) from error - - -async def generate( - manifest_path: Path, - targets: Sequence[str], - offline: bool, -) -> str: - metadata = await cargo_metadata(manifest_path, offline) - - with tempfile.TemporaryDirectory(prefix=f"{TASK}-") as temporary: - work = Path(temporary) - config = work / "about.toml" - report = work / "report.json" - config.write_text(cargo_about_config(metadata), encoding="utf-8") - - arguments: list[str | Path] = [ - "cargo-about", - "generate", - "--config", - config, - "--manifest-path", - manifest_path, - "--format", - "json", - "--locked", - "--output-file", - report, - ] - if offline: - arguments.append("--offline") - for target in targets: - arguments.extend(("--target", target)) - - quiet_arguments = ["cargo-about", "-L", "off", *arguments[1:]] - result = await capture.result(quiet_arguments) - if result.exit_code != 0: - # Repeat with diagnostics enabled only on failure. cargo-about 0.8.4 - # otherwise reports harmless scanner errors for deprecated SPDX IDs. - await run(arguments) - try: - raw_report = json.loads(report.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise TaskError( - f"{TASK}: cargo-about returned invalid JSON: {error}" - ) from error - - return normalized_output(raw_report) - - -async def main(args: Sequence[str]) -> None: - command = argparse.ArgumentParser(prog="mise run licenses-json --") - command.add_argument( - "--manifest-path", - default=Path("Cargo.toml"), - type=Path, - ) - command.add_argument("--output", required=True, type=Path) - command.add_argument("--target", action="append", required=True) - command.add_argument("--offline", action="store_true") - command.add_argument("--check", action="store_true") - arguments = command.parse_args(args) - - require_command("cargo", TASK) - require_command("cargo-about", TASK) - if not arguments.manifest_path.is_file(): - raise TaskError(f"{TASK}: manifest not found: {arguments.manifest_path}") - - output = await generate( - arguments.manifest_path.resolve(), - arguments.target, - arguments.offline, - ) - if arguments.check: - if not arguments.output.is_file(): - raise TaskError(f"{TASK}: output not found: {arguments.output}") - if arguments.output.read_text(encoding="utf-8") != output: - raise TaskError( - f"{TASK}: {arguments.output} is out of date; regenerate it without --check" - ) - return - - arguments.output.parent.mkdir(parents=True, exist_ok=True) - if ( - not arguments.output.is_file() - or arguments.output.read_text(encoding="utf-8") != output - ): - arguments.output.write_text(output, encoding="utf-8") - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/licenses.py b/tasks/licenses.py deleted file mode 100755 --- a/tasks/licenses.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Verify repository licensing metadata" -#MISE tools={"pipx"="1.16.7","python"="3.14.7","pipx:reuse"="6.2.0","aqua:EmbarkStudios/cargo-deny"="0.19.0"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import re -import sys -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import TaskError, capture, run, task_main - -TASK = "licenses" -HEADER = ( - r"^((<!--|#|//|/\*|\*)[[:space:]]*)?" - r"(Copyright[[:space:]]+(\([cC]\)|©)|SPDX-FileCopyrightText:)" -) -LEGACY_COPYRIGHT = re.compile(r"^\s*((<!--|#|//|/\*|\*)\s*)?Copyright\s+(\([cC]\)|©)") -CANONICAL_COPYRIGHT = re.compile( - r"SPDX-FileCopyrightText: 2026 Nikolay Govorov(?:\s*(?:\*/|-->))?$" -) - - -async def check_copyright_headers() -> None: - result = await capture.result( - "git", - "grep", - "-n", - "-I", - "-E", - HEADER, - "--", - ".", - ":(exclude)*.md", - ":(exclude)LICENSE", - ":(exclude)LICENSES/**", - ":(exclude)COPYING*", - ) - if result.exit_code not in {0, 1}: - raise TaskError(f"{TASK}: git grep failed with exit code {result.exit_code}") - - invalid: list[str] = [] - for line in result.output.splitlines(): - match = re.search(r":([0-9]+):", line) - if match is None or int(match.group(1)) > 10: - continue - text = line[match.end() :] - reason = "" - if LEGACY_COPYRIGHT.match(text): - reason = "legacy copyright header" - elif (position := text.find("SPDX-FileCopyrightText:")) >= 0: - suffix = text[position + len("SPDX-FileCopyrightText:") :] - if ( - not suffix - or not suffix.startswith(" ") - or (len(suffix) > 1 and suffix[1].isspace()) - ): - reason = "expected exactly one space after colon" - if ( - not reason - and "SPDX-FileCopyrightText:" in text - and "Nikolay Govorov" in text - and not CANONICAL_COPYRIGHT.search(text) - ): - reason = "expected 2026 Nikolay Govorov" - if reason: - invalid.append(f"{reason}: {line}") - - if invalid: - print("Invalid copyright headers:", *invalid, sep="\n", file=sys.stderr) - raise TaskError(f"{TASK}: invalid copyright headers") - - -async def main(args: Sequence[str]) -> None: - argparse.ArgumentParser(prog="mise run licenses --").parse_args(args) - await check_copyright_headers() - await run("reuse", "lint") - if Path("Cargo.toml").is_file(): - await run("cargo-deny", "check") - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/package.py b/tasks/package.py deleted file mode 100755 --- a/tasks/package.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Build release archives and signed Linux packages" -#MISE tools={"pipx"="1.16.7","python"="3.14.7","nfpm"="2.47.0"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import os -import re -import shutil -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import ( - APKSigning, - GPGSigning, - TaskError, - require_command, - run, - task_main, -) - -TASK = "package" -SYSTEM_FORMATS = {"deb", "rpm", "apk"} -ARCHIVE_FORMATS = {"tar.gz", "zip"} -SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") -SAFE_KEY_VERSION = re.compile(r"^[0-9]{4}$") - - -def validate(arguments: argparse.Namespace, command: argparse.ArgumentParser) -> None: - formats = set(arguments.formats) - if formats & SYSTEM_FORMATS: - if not arguments.version or not arguments.arch: - command.error("--version and --arch are required for deb, rpm, and apk") - if not arguments.config.is_file(): - raise TaskError(f"{TASK}: {arguments.config} not found") - if formats & ARCHIVE_FORMATS: - if arguments.archive_root is None or not arguments.archive_name: - command.error( - "--archive-root and --archive-name are required for tar.gz and zip" - ) - if not arguments.archive_root.is_dir(): - raise TaskError(f"{TASK}: {arguments.archive_root} not found") - if not SAFE_NAME.fullmatch(arguments.archive_name): - raise TaskError(f"{TASK}: invalid archive name: {arguments.archive_name}") - if arguments.apk_public_key and not SAFE_NAME.fullmatch(arguments.apk_public_key): - raise TaskError( - f"{TASK}: invalid APK public key name: {arguments.apk_public_key}" - ) - - -async def create_archive( - archive_format: str, - output: Path, - root: Path, - name: str, -) -> None: - if archive_format == "tar.gz": - require_command("tar", TASK) - await run("tar", "-czf", output / f"{name}.tar.gz", "-C", root, ".") - return - require_command("zip", TASK) - destination = output / f"{name}.zip" - destination.unlink(missing_ok=True) - await run.set(cwd=root)("zip", "-qry", destination, ".") - - -async def sign_package( - package_format: str, package: Path, signing: GPGSigning | None -) -> None: - if signing is None or package_format not in {"deb", "rpm"}: - return - command = run.set(env=signing.environment, inherit_env=False) - if package_format == "deb": - require_command("debsigs", TASK) - await command( - "debsigs", - "--sign=origin", - f"--default-key={signing.key_id}", - package, - ) - else: - require_command("rpmsign", TASK) - await command( - "rpmsign", - "--define", - f"_gpg_name {signing.key_id}", - "--addsign", - package, - ) - - -async def main(args: Sequence[str]) -> None: - command = argparse.ArgumentParser( - prog="mise run package --", - usage=( - "%(prog)s --output DIR [--config FILE] " - "[--version VERSION --arch ARCH] [--apk-public-key FILE] " - "[--archive-root DIR --archive-name NAME] " - "deb|rpm|apk|tar.gz|zip..." - ), - ) - command.add_argument("--config", default="nfpm.yaml", type=Path) - command.add_argument("--version") - command.add_argument("--arch") - command.add_argument("--output", required=True, type=Path) - command.add_argument("--archive-root", type=Path) - command.add_argument("--archive-name") - command.add_argument("--apk-public-key") - command.add_argument( - "formats", nargs="+", choices=sorted(SYSTEM_FORMATS | ARCHIVE_FORMATS) - ) - arguments = command.parse_args(args) - validate(arguments, command) - - arguments.output.mkdir(parents=True, exist_ok=True) - output = arguments.output.resolve() - formats = set(arguments.formats) - - with tempfile.TemporaryDirectory(prefix="package-") as directory: - work = Path(directory) - environment = dict(os.environ) - for name in ( - "GPG_PRIVATE_KEY", - "APK_PRIVATE_KEY", - "SIGNING_PRIVATE_KEY", - "NFPM_PASSPHRASE", - "NFPM_DEB_PASSPHRASE", - "NFPM_RPM_PASSPHRASE", - ): - environment.pop(name, None) - config = arguments.config - if "apk" in formats and "${PACKAGE_KEY_VERSION}" in config.read_text(): - key_version = os.environ.get("PACKAGE_KEY_VERSION", "") - if not SAFE_KEY_VERSION.fullmatch(key_version): - raise TaskError( - f"{TASK}: invalid PACKAGE_KEY_VERSION: {key_version or '<empty>'}" - ) - config = work / "nfpm.yaml" - config.write_text( - arguments.config.read_text().replace( - "${PACKAGE_KEY_VERSION}", key_version - ) - ) - - gpg_signing: GPGSigning | None = None - if formats & {"deb", "rpm"} and os.environ.get("GPG_PRIVATE_KEY"): - gpg_signing = await GPGSigning.create(TASK, work) - await gpg_signing.prime_agent() - environment = gpg_signing.package_environment() - - apk_signing: APKSigning | None = None - if "apk" in formats and os.environ.get("APK_PRIVATE_KEY"): - apk_signing = APKSigning(TASK, work) - environment["APK_SIGNING_KEY"] = str(apk_signing.private_key_file) - elif os.environ.get("APK_SIGNING_KEY"): - environment["APK_SIGNING_KEY"] = os.environ["APK_SIGNING_KEY"] - - if "apk" in formats and arguments.apk_public_key: - public_key = output / arguments.apk_public_key - if apk_signing is not None: - await apk_signing.export_public_key(public_key) - elif environment.get("APK_SIGNING_KEY"): - require_command("openssl", TASK) - await run.set(env=environment, inherit_env=False)( - "openssl", - "rsa", - "-in", - environment["APK_SIGNING_KEY"], - "-pubout", - "-out", - public_key, - ) - - for package_format in arguments.formats: - if package_format in SYSTEM_FORMATS: - require_command("nfpm", TASK) - package_environment = dict(environment) - package_environment.update( - ARCH=arguments.arch, - VERSION=arguments.version, - ) - package_output = work / f"output-{package_format}" - package_output.mkdir() - await run.set(env=package_environment, inherit_env=False)( - "nfpm", - "package", - "--config", - config, - "--packager", - package_format, - "--target", - f"{package_output}/", - ) - packages = list(package_output.iterdir()) - if len(packages) != 1 or not packages[0].is_file(): - raise TaskError( - f"{TASK}: nFPM produced an unexpected number of packages" - ) - await sign_package(package_format, packages[0], gpg_signing) - shutil.move(packages[0], output / packages[0].name) - else: - await create_archive( - package_format, - output, - arguments.archive_root, - arguments.archive_name, - ) - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/publish.py b/tasks/publish.py deleted file mode 100755 --- a/tasks/publish.py +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Publish signed package repositories to shared S3 storage" -#MISE tools={"pipx"="1.16.7","python"="3.14.7"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["boto3==1.43.75", "shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import os -import re -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import TaskError, task_main - -if test_path := os.environ.get("PUBLISH_TEST_PYTHONPATH"): - sys.path.insert(0, test_path) - -from libs.repository import Repository - -TASK = "publish" -FORMATS = {"deb", "rpm", "apk"} -SAFE_SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") - - -async def main(args: Sequence[str]) -> None: - command = argparse.ArgumentParser( - prog="mise run publish --", - usage="%(prog)s --service NAME --channel CHANNEL --input DIR deb|rpm|apk...", - ) - command.add_argument("--service", required=True) - command.add_argument("--channel", required=True) - command.add_argument("--input", required=True, type=Path) - command.add_argument("formats", nargs="+", choices=sorted(FORMATS)) - arguments = command.parse_args(args) - for label, value in ( - ("service name", arguments.service), - ("channel", arguments.channel), - ): - if not SAFE_SLUG.fullmatch(value) or "--" in value: - raise TaskError(f"{TASK}: invalid {label}: {value}") - if not arguments.input.is_dir(): - raise TaskError(f"{TASK}: {arguments.input} not found") - with tempfile.TemporaryDirectory(prefix="publish-") as directory: - await Repository( - arguments.service, - arguments.channel, - arguments.input.resolve(), - arguments.formats, - Path(directory), - ).publish() - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tasks/signoff b/tasks/signoff new file mode 100755 --- /dev/null +++ b/tasks/signoff @@ -0,0 +1,155 @@ +#!/bin/sh -eu +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD +#MISE description="Verify contributor identities and CLA acceptance trailers" + +root=$(git rev-parse --show-toplevel) +cd "$root" + +task_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +approved_emails_file="$task_dir/../config/signoff-approved-emails" +unsupported_commits_file="$task_dir/../config/cla-unsupported-commits" + +if [ ! -f CLA.md ]; then + echo "CLA.md is missing" >&2 + exit 1 +fi + +head_cla_version_count=$(grep -c '^Version ' CLA.md || :) +if [ "$head_cla_version_count" -ne 1 ]; then + echo "CLA.md must declare exactly one version" >&2 + exit 1 +fi + +head_cla_version=$(sed -n 's/^Version //p' CLA.md) +if [ -z "$head_cla_version" ]; then + echo "CLA.md declares an empty version" >&2 + exit 1 +fi + +is_approved_email() { + grep -v '^[[:space:]]*#' "$approved_emails_file" | + grep -Fqx -- "$1" +} + +is_unsupported_commit() { + grep -v '^[[:space:]]*#' "$unsupported_commits_file" | + grep -Fqx -- "$1" +} + +check_identity() { + identity=$1 + role=$2 + email=$(printf '%s\n' "$identity" | sed -n 's/^.*<\([^<>]*\)>$/\1/p') + + if [ -z "$email" ]; then + echo "Commit $short_sha has an invalid $role identity: $identity" + bad=1 + return + fi + + if is_approved_email "$email"; then + return + fi + + requires_cla=1 + if ! printf '%s\n' "$signoffs" | grep -Fqx -- "$identity"; then + echo "Commit $short_sha $role $identity is missing a matching Signed-off-by" + bad=1 + fi +} + +if [ ! -f "$approved_emails_file" ]; then + echo "Approved email configuration is missing: $approved_emails_file" >&2 + exit 1 +fi + +if [ ! -f "$unsupported_commits_file" ]; then + echo "Unsupported commit configuration is missing: $unsupported_commits_file" >&2 + exit 1 +fi + +bad=0 +for sha in $(git log --no-merges --format=%H); do + short_sha=$(printf '%.8s' "$sha") + signoffs=$(git show -s --format='%(trailers:key=Signed-off-by,valueonly)' "$sha") + requires_cla=0 + + author=$(git show -s --format='%an <%ae>' "$sha") + check_identity "$author" "author" + + coauthors=$(git show -s --format='%(trailers:key=Co-authored-by,valueonly)' "$sha") + if [ -n "$coauthors" ]; then + old_ifs=$IFS + IFS=' +' + for coauthor in $coauthors; do + check_identity "$coauthor" "co-author" + done + IFS=$old_ifs + fi + + if [ "$requires_cla" -eq 1 ] && ! is_unsupported_commit "$sha"; then + expected_cla_version_count=$( + git show "$sha:CLA.md" 2>/dev/null | grep -c '^Version ' || : + ) + if [ "$expected_cla_version_count" -ne 1 ]; then + echo "Commit $short_sha does not contain a CLA.md with exactly one version" + bad=1 + continue + fi + + expected_cla_version=$( + git show "$sha:CLA.md" | sed -n 's/^Version //p' + ) + if [ -z "$expected_cla_version" ]; then + echo "Commit $short_sha contains an empty CLA version" + bad=1 + continue + fi + + commit_cla_version=$( + git show -s --format='%(trailers:key=CLA-Version,valueonly)' "$sha" + ) + if [ "$commit_cla_version" != "$expected_cla_version" ]; then + if [ -z "$commit_cla_version" ]; then + echo "Commit $short_sha is missing CLA-Version: $expected_cla_version" + else + echo "Commit $short_sha has invalid CLA-Version: $commit_cla_version" + echo "Expected CLA-Version: $expected_cla_version" + fi + bad=1 + fi + fi +done + +if [ "$bad" -ne 0 ]; then + echo "Every non-approved author and co-author must accept the CLA in their commit" + echo "Required trailers:" + echo " CLA-Version: <version from CLA.md>" + echo " Signed-off-by: Name <email>" + echo "See CLA.md" + exit 1 +fi + +if [ ! -f .mailmap ]; then + echo "Contributor registry .mailmap is missing" >&2 + exit 1 +fi + +missing=0 +for email in $(git log --no-merges --format='%ae%n%ce' | sort -u); do + if is_approved_email "$email"; then + continue + fi + + if ! grep -v '^[[:space:]]*#' .mailmap | grep -qF -- "<${email}>"; then + echo "Email <${email}> is not in .mailmap" + missing=1 + fi +done + +if [ "$missing" -ne 0 ]; then + echo "All authors and committers must be listed in .mailmap" + exit 1 +fi diff --git a/tasks/signoff.py b/tasks/signoff.py deleted file mode 100755 --- a/tasks/signoff.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# fmt: off -#MISE description="Verify contributor identities and CLA acceptance trailers" -#MISE tools={"pipx"="1.16.7","python"="3.14.7"} -# fmt: on -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import argparse -import os -import re -import sys -from collections.abc import Sequence -from pathlib import Path - -sys.dont_write_bytecode = True - -from libs.common import TaskError, capture, task_main -from shellous import sh - -TASK = "signoff" - - -def configured_values(path: Path) -> set[str]: - return { - line for line in path.read_text().splitlines() if not re.match(r"^\s*#", line) - } - - -def cla_version(document: str) -> tuple[int, str]: - versions = [ - line.removeprefix("Version ") - for line in document.splitlines() - if line.startswith("Version ") - ] - return len(versions), versions[0] if len(versions) == 1 else "" - - -def check_identity( - identity: str, - role: str, - short_sha: str, - approved_emails: set[str], - signoffs: set[str], -) -> tuple[bool, bool]: - match = re.fullmatch(r".*<([^<>]*)>", identity) - if match is None or not match.group(1): - print(f"Commit {short_sha} has an invalid {role} identity: {identity}") - return True, False - if match.group(1) in approved_emails: - return False, False - if identity not in signoffs: - print( - f"Commit {short_sha} {role} {identity} is missing a matching Signed-off-by" - ) - return True, True - return False, True - - -async def git(*arguments: str) -> str: - return await capture("git", arguments) - - -async def main(args: Sequence[str]) -> None: - argparse.ArgumentParser(prog="mise run signoff --").parse_args(args) - root = Path((await git("rev-parse", "--show-toplevel")).strip()) - os.chdir(root) - - task_directory = Path(__file__).resolve().parent - approved_emails_file = task_directory.parent / "config/signoff-approved-emails" - unsupported_commits_file = task_directory.parent / "config/cla-unsupported-commits" - - cla_file = Path("CLA.md") - if not cla_file.is_file(): - raise TaskError("CLA.md is missing") - version_count, head_version = cla_version(cla_file.read_text()) - if version_count != 1: - raise TaskError("CLA.md must declare exactly one version") - if not head_version: - raise TaskError("CLA.md declares an empty version") - if not approved_emails_file.is_file(): - raise TaskError( - f"Approved email configuration is missing: {approved_emails_file}" - ) - if not unsupported_commits_file.is_file(): - raise TaskError( - f"Unsupported commit configuration is missing: {unsupported_commits_file}" - ) - - approved_emails = configured_values(approved_emails_file) - unsupported_commits = configured_values(unsupported_commits_file) - bad = False - - commits = (await git("log", "--no-merges", "--format=%H")).splitlines() - for sha in commits: - short_sha = sha[:8] - signoffs = set( - ( - await git( - "show", - "-s", - "--format=%(trailers:key=Signed-off-by,valueonly)", - sha, - ) - ).splitlines() - ) - requires_cla = False - - author = (await git("show", "-s", "--format=%an <%ae>", sha)).rstrip("\n") - invalid, required = check_identity( - author, "author", short_sha, approved_emails, signoffs - ) - bad |= invalid - requires_cla |= required - - coauthor_output = ( - await git( - "show", - "-s", - "--format=%(trailers:key=Co-authored-by,valueonly)", - sha, - ) - ).rstrip("\n") - for coauthor in coauthor_output.splitlines() if coauthor_output else (): - invalid, required = check_identity( - coauthor, "co-author", short_sha, approved_emails, signoffs - ) - bad |= invalid - requires_cla |= required - - if requires_cla and sha not in unsupported_commits: - result = await capture.result("git", "show", f"{sha}:CLA.md").stderr( - sh.DEVNULL - ) - document = result.output if result.exit_code == 0 else "" - expected_count, expected_version = cla_version(document) - if expected_count != 1: - print( - f"Commit {short_sha} does not contain a CLA.md with exactly one version" - ) - bad = True - continue - if not expected_version: - print(f"Commit {short_sha} contains an empty CLA version") - bad = True - continue - - commit_version = ( - await git( - "show", - "-s", - "--format=%(trailers:key=CLA-Version,valueonly)", - sha, - ) - ).rstrip("\n") - if commit_version != expected_version: - if not commit_version: - print( - f"Commit {short_sha} is missing CLA-Version: {expected_version}" - ) - else: - print( - f"Commit {short_sha} has invalid CLA-Version: {commit_version}" - ) - print(f"Expected CLA-Version: {expected_version}") - bad = True - - if bad: - print( - "Every non-approved author and co-author must accept the CLA in their commit" - ) - print("Required trailers:") - print(" CLA-Version: <version from CLA.md>") - print(" Signed-off-by: Name <email>") - print("See CLA.md") - raise SystemExit(1) - - mailmap = Path(".mailmap") - if not mailmap.is_file(): - raise TaskError("Contributor registry .mailmap is missing") - mailmap_lines = [ - line - for line in mailmap.read_text().splitlines() - if not re.match(r"^\s*#", line) - ] - emails = sorted( - set((await git("log", "--no-merges", "--format=%ae%n%ce")).splitlines()) - ) - missing = False - for email in emails: - if email in approved_emails: - continue - if not any(f"<{email}>" in line for line in mailmap_lines): - print(f"Email <{email}> is not in .mailmap") - missing = True - if missing: - print("All authors and committers must be listed in .mailmap") - raise SystemExit(1) - - -if __name__ == "__main__": - task_main(TASK, main, sys.argv[1:]) diff --git a/tests/fakes/boto3.py b/tests/fakes/boto3.py deleted file mode 100644 --- a/tests/fakes/boto3.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import hashlib -import io -import os -import shutil -from pathlib import Path - -from botocore.exceptions import ClientError - - -def error(code: str, operation: str) -> ClientError: - return ClientError({"Error": {"Code": code, "Message": code}}, operation) - - -class Paginator: - def __init__(self, client): - self.client = client - - def paginate(self, *, Bucket: str, Prefix: str): - root = self.client.root / Bucket - contents = [] - if root.is_dir(): - for path in root.rglob("*"): - if path.is_file(): - key = path.relative_to(root).as_posix() - if key.startswith(Prefix): - contents.append({"Key": key, "Size": path.stat().st_size}) - yield {"Contents": contents} - - -class Client: - def __init__(self): - self.root = Path(os.environ["PUBLISH_REMOTE"]) - self.metadata: dict[tuple[str, str], dict[str, str]] = {} - self.log = os.environ.get("PUBLISH_TEST_LOG") - - def record(self, operation: str, key: str) -> None: - if self.log: - with Path(self.log).open("a") as stream: - stream.write(f"s3 {operation} {key}\n") - - def path(self, bucket: str, key: str) -> Path: - path = self.root / bucket / key - path.resolve().relative_to(self.root.resolve()) - return path - - def get_paginator(self, name: str): - assert name == "list_objects_v2" - return Paginator(self) - - def download_file(self, bucket: str, key: str, destination: str) -> None: - source = self.path(bucket, key) - if not source.is_file(): - raise error("NoSuchKey", "DownloadFile") - Path(destination).parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) - self.record("download", key) - - def upload_file(self, source: str, bucket: str, key: str) -> None: - destination = self.path(bucket, key) - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) - self.record("upload", key) - - def put_object( - self, - *, - Bucket, - Key, - Body, - Metadata=None, - IfNoneMatch=None, - IfMatch=None, - ): - destination = self.path(Bucket, Key) - if IfNoneMatch == "*" and destination.exists(): - raise error("PreconditionFailed", "PutObject") - if IfMatch and self.etag(destination) != IfMatch: - raise error("PreconditionFailed", "PutObject") - destination.parent.mkdir(parents=True, exist_ok=True) - data = Body.read() if hasattr(Body, "read") else Body - destination.write_bytes(data) - self.metadata[(Bucket, Key)] = Metadata or {} - self.record("immutable", Key) - return {"ETag": self.etag(destination)} - - def head_object(self, *, Bucket, Key): - path = self.path(Bucket, Key) - if not path.exists(): - raise error("NoSuchKey", "HeadObject") - return {"Metadata": self.metadata.get((Bucket, Key), {})} - - @staticmethod - def etag(path: Path) -> str | None: - if not path.exists(): - return None - return f'"{hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()}"' - - def get_object(self, *, Bucket, Key): - path = self.path(Bucket, Key) - return {"Body": io.BytesIO(path.read_bytes()), "ETag": self.etag(path)} - - def delete_object(self, *, Bucket, Key, IfMatch): - path = self.path(Bucket, Key) - if self.etag(path) != IfMatch: - raise error("PreconditionFailed", "DeleteObject") - path.unlink() - self.record("delete", Key) - return {} - - def delete_objects(self, *, Bucket, Delete): - for item in Delete["Objects"]: - self.path(Bucket, item["Key"]).unlink(missing_ok=True) - self.record("delete", item["Key"]) - return {} - - -def client(name: str, **kwargs): - assert name == "s3" - assert kwargs["endpoint_url"] - assert kwargs["aws_access_key_id"] - assert kwargs["aws_secret_access_key"] - return Client() diff --git a/tests/licenses-json.py b/tests/licenses-json.py deleted file mode 100755 --- a/tests/licenses-json.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import importlib.util -import json -import sys -from pathlib import Path -from types import ModuleType - - -def load_task() -> ModuleType: - path = Path(__file__).parents[1] / "tasks/licenses-json.py" - sys.path.insert(0, str(path.parent)) - spec = importlib.util.spec_from_file_location("licenses_json", path) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def main() -> None: - task = load_task() - - assert task.license_requirements("MIT/Apache-2.0") == ["MIT", "Apache-2.0"] - assert task.license_requirements( - "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" - ) == ["Apache-2.0 WITH LLVM-exception", "Apache-2.0", "MIT"] - - config = task.cargo_about_config( - { - "packages": [ - {"name": "dual", "license": "MIT OR Apache-2.0"}, - {"name": "dual", "license": "MIT/Apache-2.0"}, - {"name": "private", "license": None}, - ] - } - ) - assert "accepted = []\nprivate = { ignore = true }" in config - assert '["dual"]\naccepted = ["MIT", "Apache-2.0"]' in config - assert '["private"]' not in config - - output = json.loads( - task.normalized_output( - { - "overview": [], - "licenses": [ - { - "name": "MIT License", - "id": "MIT", - "first_of_kind": True, - "source_path": "/tmp/LICENSE", - "text": "MIT text", - "used_by": [ - { - "crate": { - "name": "long-name", - "version": "2.0.0", - "repository": None, - "manifest_path": "/tmp/Cargo.toml", - }, - "path": None, - }, - { - "crate": { - "name": "short", - "version": "1.0.0", - "repository": "https://example.invalid/short", - }, - "path": None, - }, - ], - }, - { - "name": "Apache License 2.0", - "id": "Apache-2.0", - "first_of_kind": False, - "text": "Apache text", - "used_by": [ - { - "crate": { - "name": "dependency", - "version": "3.0.0", - "repository": None, - } - } - ], - }, - ], - "crates": [{"package": {"manifest_path": "/tmp/Cargo.toml"}}], - } - ) - ) - - assert output["overview"] == [ - {"count": 1, "name": "Apache License 2.0", "id": "Apache-2.0"}, - {"count": 2, "name": "MIT License", "id": "MIT"}, - ] - assert [license_["id"] for license_ in output["licenses"]] == [ - "Apache-2.0", - "MIT", - ] - assert all(license_["first_of_kind"] for license_ in output["licenses"]) - assert [usage["crate"]["name"] for usage in output["licenses"][1]["used_by"]] == [ - "short", - "long-name", - ] - assert "source_path" not in output["licenses"][1] - assert "manifest_path" not in output["licenses"][1]["used_by"][0]["crate"] - assert "crates" not in output - - -if __name__ == "__main__": - main() diff --git a/tests/oci-tasks.py b/tests/oci-tasks.py deleted file mode 100755 --- a/tests/oci-tasks.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import asyncio -import os -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path - -from shellous import sh - -run = sh.stdout(sh.INHERIT).stderr(sh.INHERIT) - - -def executable(path: Path, source: str) -> None: - path.write_text("#!/usr/bin/env python3\n" + source) - path.chmod(0o755) - - -async def main(args: Sequence[str]) -> None: - if args: - raise SystemExit(f"unexpected arguments: {' '.join(args)}") - root = Path(__file__).resolve().parent.parent - with tempfile.TemporaryDirectory() as directory: - work = Path(directory) - binary = work / "bin" - project = work / "project" - chart = project / "chart" - (chart / "templates").mkdir(parents=True) - (project / "Dockerfile").touch() - (chart / "Chart.yaml").write_text( - "apiVersion: v2\nname: fixture\nversion: 0.0.0\n" - ) - (chart / "values.yaml").write_text("image: fixture\n") - (chart / "templates/configmap.yaml").write_text( - "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: fixture\n" - ) - binary.mkdir() - executable( - binary / "docker", - """import os -import sys -from pathlib import Path -Path(os.environ["OCI_TEST_DOCKER_LOG"]).write_text("\\n".join(sys.argv[1:]) + "\\n") -""", - ) - executable( - binary / "helm", - """import os -import sys -from pathlib import Path -arguments = sys.argv[1:] -log = Path(os.environ["OCI_TEST_HELM_LOG"]) -with log.open("a") as stream: - stream.write("\\n".join(arguments) + "\\n") -if arguments and arguments[0] == "package": - destination = Path(arguments[arguments.index("--destination") + 1]) - version = arguments[arguments.index("--version") + 1] - (destination / f"fixture-{version}.tgz").touch() -""", - ) - - docker_log = work / "docker.log" - helm_log = work / "helm.log" - environment = dict(os.environ) - environment.update( - PATH=f"{binary}:{environment['PATH']}", - OCI_TEST_DOCKER_LOG=str(docker_log), - OCI_TEST_HELM_LOG=str(helm_log), - ) - command = run.set(env=environment, inherit_env=False) - - await command( - root / "tasks/container.py", - "--context", - project, - "--platform", - "linux/amd64", - "--target", - "site", - "--build-arg", - "APP=site", - "--build-arg", - "TITLE=hello world", - "--label", - "org.example.title=Example site", - "--tag", - "ghcr.io/example/site:sha-abc", - "--tag", - "ghcr.io/example/site:latest", - "--cache-scope", - "site", - "--provenance", - "false", - "--sbom", - "false", - "--push", - ) - docker_arguments = docker_log.read_text().splitlines() - for expected in ( - "buildx", - "--target", - "site", - "TITLE=hello world", - "org.example.title=Example site", - "ghcr.io/example/site:sha-abc", - "ghcr.io/example/site:latest", - "type=gha,mode=max,scope=site", - "--push", - ): - assert expected in docker_arguments - - await command( - root / "tasks/chart.py", - "--chart", - chart, - "--version", - "1.2.3", - "--app-version", - "sha-abc", - "--output", - work / "output", - "--push", - "oci://ghcr.io/example/charts", - ) - helm_arguments = helm_log.read_text().splitlines() - for expected in ( - "lint", - "package", - "push", - "oci://ghcr.io/example/charts", - ): - assert expected in helm_arguments - - print("oci tasks: ok") - - -if __name__ == "__main__": - asyncio.run(main(sys.argv[1:])) diff --git a/tests/package-integration.py b/tests/package-integration.py deleted file mode 100755 --- a/tests/package-integration.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import asyncio -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path - -from shellous import sh - -run = sh.stdout(sh.INHERIT).stderr(sh.INHERIT) -capture = sh.stderr(sh.INHERIT) - - -async def main(args: Sequence[str]) -> None: - if args: - raise SystemExit(f"unexpected arguments: {' '.join(args)}") - root = Path(__file__).resolve().parent.parent - with tempfile.TemporaryDirectory() as directory: - work = Path(directory) - stage = work / "stage" - stage.mkdir() - (stage / "package-contract").write_text("package contract\n") - config = work / "nfpm.yaml" - config.write_text( - """name: package-contract -arch: ${ARCH} -version: ${VERSION} -platform: linux -maintainer: Dimidium Labs <me@govorov.online> -description: Shared package task integration fixture -license: 0BSD -""" - ) - output = work / "out" - await run( - "mise", - "--cd", - root, - "run", - "package", - "--", - "--config", - config, - "--version", - "1.2.3~nightly.42", - "--arch", - "amd64", - "--output", - output, - "--archive-root", - stage, - "--archive-name", - "package-contract-linux-amd64", - "deb", - "rpm", - "apk", - "tar.gz", - "zip", - ) - - assert len(list(output.glob("*.deb"))) == 1 - assert len(list(output.glob("*.rpm"))) == 1 - assert len(list(output.glob("*.apk"))) == 1 - tarball = output / "package-contract-linux-amd64.tar.gz" - zipfile = output / "package-contract-linux-amd64.zip" - assert tarball.is_file() - assert zipfile.is_file() - - package = next(output.glob("*.deb")) - assert (await capture("dpkg-deb", "-f", package, "Package")).strip() == ( - "package-contract" - ) - assert (await capture("dpkg-deb", "-f", package, "Version")).strip() == ( - "1.2.3~nightly.42" - ) - assert (await capture("dpkg-deb", "-f", package, "Architecture")).strip() == ( - "amd64" - ) - assert "./package-contract" in await capture("tar", "-tzf", tarball) - assert "package-contract" in await capture("unzip", "-l", zipfile) - - -if __name__ == "__main__": - asyncio.run(main(sys.argv[1:])) diff --git a/tests/package.py b/tests/package.py deleted file mode 100755 --- a/tests/package.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import asyncio -import os -import sys -import tempfile -from collections.abc import Sequence -from pathlib import Path - -from shellous import sh - -run = sh.stdout(sh.INHERIT).stderr(sh.INHERIT) -capture = sh.stderr(sh.INHERIT) - - -def executable(path: Path, source: str) -> None: - path.write_text("#!/usr/bin/env python3\n" + source) - path.chmod(0o755) - - -async def main(args: Sequence[str]) -> None: - if args: - raise SystemExit(f"unexpected arguments: {' '.join(args)}") - root = Path(__file__).resolve().parent.parent - with tempfile.TemporaryDirectory() as directory: - work = Path(directory) - binary = work / "bin" - stage = work / "stage" - output = work / "out" - binary.mkdir() - (stage / "sub").mkdir(parents=True) - output.mkdir() - (stage / "tool").write_text("payload\n") - (stage / "sub/file").write_text("nested\n") - outside = work / "outside" - outside.write_text("outside\n") - (stage / "outside-link").symlink_to(outside) - - executable( - binary / "nfpm", - """import os -import sys -from pathlib import Path -secrets = ( - "GPG_PRIVATE_KEY", "APK_PRIVATE_KEY", "SIGNING_PRIVATE_KEY", - "NFPM_PASSPHRASE", -) -if any(os.environ.get(name) for name in secrets): - print("raw private key leaked to nFPM", file=sys.stderr) - raise SystemExit(1) -arguments = sys.argv[1:] -config = Path(arguments[arguments.index("--config") + 1]) -packager = arguments[arguments.index("--packager") + 1] -target = Path(arguments[arguments.index("--target") + 1]) -with Path(os.environ["PACKAGE_TEST_LOG"]).open("a") as stream: - stream.write(f"ARCH={os.environ['ARCH']}\\n") - stream.write(f"VERSION={os.environ['VERSION']}\\n") - stream.write(f"GPG_KEY_ID={os.environ.get('GPG_KEY_ID', '')}\\n") - stream.write(f"{packager}\\n") - for line in config.read_text().splitlines(): - if line.startswith("key_name:"): - stream.write(f"{line}\\n") -(target / f"test.{packager}").touch() -""", - ) - executable( - binary / "gpg", - """import os -import sys -from pathlib import Path -with Path(os.environ["PACKAGE_TEST_LOG"]).open("a") as stream: - stream.write("gpg\\n") -arguments = sys.argv[1:] -if "-o" in arguments: - Path(arguments[arguments.index("-o") + 1]).write_text("signature") -""", - ) - for name in ("debsigs", "rpmsign"): - executable( - binary / name, - """import os -import sys -from pathlib import Path -with Path(os.environ["PACKAGE_TEST_LOG"]).open("a") as stream: - stream.write(f"{Path(sys.argv[0]).name}\\n") -""", - ) - executable( - binary / "openssl", - """import sys -from pathlib import Path -arguments = sys.argv[1:] -Path(arguments[arguments.index("-out") + 1]).write_text("public key\\n") -""", - ) - - config = work / "nfpm.yaml" - config.write_text("name: test\n") - log = work / "package.log" - environment = dict(os.environ) - environment.update( - PATH=f"{binary}:{environment['PATH']}", - PACKAGE_TEST_LOG=str(log), - ) - command = run.set(env=environment, inherit_env=False) - package = root / "tasks/package.py" - - await command( - package, - "--output", - output, - "--archive-root", - stage, - "--archive-name", - "test-linux-amd64", - "tar.gz", - "zip", - ) - tarball = output / "test-linux-amd64.tar.gz" - zipfile = output / "test-linux-amd64.zip" - assert tarball.is_file() - assert zipfile.is_file() - assert "./tool" in await capture("tar", "-tzf", tarball) - assert "sub/file" in await capture("unzip", "-l", zipfile) - assert (await capture("unzip", "-p", zipfile, "outside-link")) == str(outside) - - await command( - package, - "--config", - config, - "--version", - "1.2.3~nightly.42", - "--arch", - "arm64", - "--output", - output, - "deb", - "rpm", - ) - assert (output / "test.deb").is_file() - assert (output / "test.rpm").is_file() - assert "ARCH=arm64" in log.read_text().splitlines() - assert "VERSION=1.2.3~nightly.42" in log.read_text().splitlines() - - signing_environment = dict(environment) - signing_environment.update( - GPG_PRIVATE_KEY="private", - GPG_PASSPHRASE="passphrase", - GPG_KEY_ID="0123456789ABCDEF0123456789ABCDEF01234567", - APK_PRIVATE_KEY="apk-private", - ) - await run.set(env=signing_environment, inherit_env=False)( - package, - "--config", - config, - "--version", - "1.2.3", - "--arch", - "amd64", - "--output", - output, - "--apk-public-key", - "test.rsa.pub", - "deb", - "rpm", - "apk", - ) - assert (output / "test.apk").is_file() - assert (output / "test.rsa.pub").is_file() - log_lines = log.read_text().splitlines() - for expected in ( - "gpg", - "debsigs", - "rpmsign", - "GPG_KEY_ID=89ABCDEF01234567", - ): - assert expected in log_lines - - apk_environment = dict(environment, APK_PRIVATE_KEY="apk-private") - await run.set(env=apk_environment, inherit_env=False)( - package, - "--config", - config, - "--version", - "1.2.3", - "--arch", - "amd64", - "--output", - output, - "apk", - ) - - versioned_config = work / "versioned.yaml" - versioned_config.write_text("key_name: packages.${PACKAGE_KEY_VERSION}\n") - result = await run.result.set(env=apk_environment, inherit_env=False)( - package, - "--config", - versioned_config, - "--version", - "1.2.3", - "--arch", - "amd64", - "--output", - output, - "apk", - ).stderr(sh.DEVNULL) - assert result.exit_code != 0 - - versioned_environment = dict(apk_environment, PACKAGE_KEY_VERSION="0001") - await run.set(env=versioned_environment, inherit_env=False)( - package, - "--config", - versioned_config, - "--version", - "1.2.3", - "--arch", - "amd64", - "--output", - output, - "apk", - ) - assert "key_name: packages.0001" in log.read_text().splitlines() - - invalid_commands = ( - (package, "--output", output, "deb"), - ( - package, - "--output", - output, - "--archive-root", - stage, - "--archive-name", - "../escape", - "zip", - ), - ) - for arguments in invalid_commands: - result = await command.result(*arguments).stderr(sh.DEVNULL) - assert result.exit_code != 0 - - external_key_environment = dict( - environment, APK_SIGNING_KEY=str(work / "apk.rsa") - ) - result = await run.result.set(env=external_key_environment, inherit_env=False)( - package, - "--config", - config, - "--version", - "1.2.3", - "--arch", - "amd64", - "--output", - output, - "--apk-public-key", - "../escape", - "apk", - ).stderr(sh.DEVNULL) - assert result.exit_code != 0 - - -if __name__ == "__main__": - asyncio.run(main(sys.argv[1:])) diff --git a/tests/publish-integration.py b/tests/publish-integration.py deleted file mode 100755 --- a/tests/publish-integration.py +++ /dev/null @@ -1,346 +0,0 @@ -#!/usr/bin/env -S pipx run --backend pip -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# /// script -# requires-python = ">=3.11" -# dependencies = ["shellous==0.42.0"] -# /// - -from __future__ import annotations - -import asyncio -import hashlib -import os -import re -import shutil -import sys -import tempfile -import urllib.request -from collections.abc import Sequence -from pathlib import Path - -from shellous import Result, sh - -run = sh.stdout(sh.INHERIT).stderr(sh.INHERIT) -capture = sh.stderr(sh.INHERIT) -APK_TOOLS_URL = ( - "https://dl-cdn.alpinelinux.org/alpine/v3.22/main/x86_64/" - "apk-tools-static-2.14.10-r0.apk" -) -APK_TOOLS_SHA256 = "c86e3822764e5fe19f41ce2e13553e48cac1ea4e74f858338e8d44bf0b616b61" - - -async def main(args: Sequence[str]) -> None: - if args: - raise SystemExit(f"unexpected arguments: {' '.join(args)}") - root = Path(__file__).resolve().parent.parent - with tempfile.TemporaryDirectory() as directory: - work = Path(directory) - remote = work / "remote/integration" - binary = work / "bin" - package_input = work / "input" - fixture = work / "fixture" - for path in (remote, binary, package_input, fixture): - path.mkdir(parents=True) - - payload = fixture / "publish-contract" - payload.write_text("publish integration fixture\n") - config = fixture / "nfpm.yaml" - config.write_text( - f"""name: publish-contract -arch: ${{ARCH}} -version: ${{VERSION}} -platform: linux -maintainer: Dimidium Labs <me@govorov.online> -description: Shared publish task integration fixture -license: 0BSD -contents: - - src: {payload} - dst: /usr/local/bin/publish-contract -deb: - signature: - method: debsign - key_id: ${{GPG_KEY_ID}} - key_file: ${{SIGNING_PRIVATE_KEY}} -rpm: - signature: - key_id: ${{GPG_KEY_ID}} - key_file: ${{SIGNING_PRIVATE_KEY}} -apk: - signature: - key_file: ${{APK_SIGNING_KEY}} - key_name: packages.${{PACKAGE_KEY_VERSION}} -""" - ) - - source_gnupg = work / "source-gnupg" - source_gnupg.mkdir(mode=0o700) - source_environment = dict(os.environ, GNUPGHOME=str(source_gnupg)) - gpg = run.set(env=source_environment, inherit_env=False) - gpg_capture = capture.set(env=source_environment, inherit_env=False) - await gpg( - "gpg", - "--batch", - "--pinentry-mode", - "loopback", - "--passphrase", - "integration-pass", - "--quick-generate-key", - "Publish Integration <publish@example.invalid>", - "rsa2048", - "sign", - "1d", - ).stdout(sh.DEVNULL) - key_listing = await gpg_capture( - "gpg", "--batch", "--with-colons", "--list-secret-keys" - ) - fingerprint = next( - fields[9] - for line in key_listing.splitlines() - if (fields := line.split(":"))[0] == "fpr" - ) - private_gpg = work / "private.gpg" - await gpg( - "gpg", - "--batch", - "--pinentry-mode", - "loopback", - "--passphrase", - "integration-pass", - "--armor", - "--export-secret-keys", - fingerprint, - ).stdout(private_gpg) - private_rsa = work / "private.rsa" - await ( - run("openssl", "genrsa", "-out", private_rsa, "2048") - .stdout(sh.DEVNULL) - .stderr(sh.DEVNULL) - ) - - key_version = "0001" - keys = remote / "keys" - keys.mkdir() - public_gpg = remote / "packages.gpg" - await gpg("gpg", "--batch", "--armor", "--export", fingerprint).stdout( - public_gpg - ) - shutil.copy2(public_gpg, keys / f"packages.{key_version}.gpg") - await ( - run( - "openssl", - "rsa", - "-in", - private_rsa, - "-pubout", - "-out", - keys / f"packages.{key_version}.rsa.pub", - ) - .stdout(sh.DEVNULL) - .stderr(sh.DEVNULL) - ) - gpg_private_key = private_gpg.read_text() - apk_private_key = private_rsa.read_text() - - async def package_version(version: str) -> None: - environment = dict(os.environ) - environment.update( - GPG_PRIVATE_KEY=gpg_private_key, - GPG_PASSPHRASE="integration-pass", - GPG_KEY_ID=fingerprint, - APK_PRIVATE_KEY=apk_private_key, - PACKAGE_KEY_VERSION=key_version, - ) - await run.set(env=environment, inherit_env=False)( - "mise", - "--cd", - root, - "run", - "package", - "--", - "--config", - config, - "--version", - version, - "--arch", - "amd64", - "--output", - package_input, - "deb", - "rpm", - "apk", - ) - - publish_environment = dict(os.environ) - publish_environment.update( - PUBLISH_REMOTE=str(work / "remote"), - PUBLISH_TEST_LOG=str(work / "s3.log"), - PUBLISH_TEST_PYTHONPATH=str(root / "tests/fakes"), - PATH=f"{binary}:{os.environ['PATH']}", - S3_BUCKET="integration", - S3_ENDPOINT="https://example.invalid", - S3_PUBLIC_URL="https://pkg.dimidiumlabs.io", - S3_ACCESS_KEY_ID="integration", - S3_SECRET_ACCESS_KEY="integration", - GPG_PRIVATE_KEY=gpg_private_key, - GPG_PASSPHRASE="integration-pass", - GPG_KEY_ID=fingerprint, - APK_PRIVATE_KEY=apk_private_key, - PACKAGE_KEY_VERSION=key_version, - ) - publish_command = run.result.set(env=publish_environment, inherit_env=False) - - async def publish(*, quiet: bool = False) -> Result: - command = publish_command( - root / "tasks/publish.py", - "--service", - "publish-contract", - "--channel", - "nightly", - "--input", - package_input, - "deb", - "rpm", - "apk", - ) - if quiet: - command = command.stderr(sh.DEVNULL) - return await command - - await package_version("1.2.3~nightly.42") - assert (await publish()).exit_code == 0 - - apt_root = remote / "publish-contract/apt" - rpm_root = remote / "publish-contract/rpm/nightly" - apk_root = remote / "publish-contract/apk/nightly/x86_64" - assert public_gpg.is_file() - assert (keys / f"packages.{key_version}.gpg").is_file() - assert (keys / f"packages.{key_version}.rsa.pub").is_file() - assert len(list((apt_root / "pool/nightly").glob("*.deb"))) == 1 - assert len(list(rpm_root.glob("*.rpm"))) == 1 - assert len(list(apk_root.glob("*.apk"))) == 1 - packages_file = apt_root / "dists/nightly/main/binary-amd64/Packages" - assert "Package: publish-contract" in packages_file.read_text().splitlines() - repository_file = rpm_root / "publish-contract-nightly.repo" - assert ( - "baseurl=https://pkg.dimidiumlabs.io/publish-contract/rpm/nightly/" - in repository_file.read_text().splitlines() - ) - - keyring = work / "packages.gpg" - await run("gpg", "--batch", "--dearmor", "-o", keyring, public_gpg) - await run( - "gpgv", - "--keyring", - keyring, - apt_root / "dists/nightly/InRelease", - ).stdout(sh.DEVNULL) - await run( - "gpgv", - "--keyring", - keyring, - rpm_root / "repodata/repomd.xml.asc", - rpm_root / "repodata/repomd.xml", - ).stdout(sh.DEVNULL) - - apk_tool = next( - ( - path - for base in ( - Path.home() / ".cache/mise", - Path.home() / ".local/share/mise", - ) - if base.is_dir() - for path in base.rglob("apk.static") - if path.is_file() - ), - None, - ) - if apk_tool is None: - archive = work / "apk-tools-static.apk" - await asyncio.to_thread(urllib.request.urlretrieve, APK_TOOLS_URL, archive) - assert hashlib.sha256(archive.read_bytes()).hexdigest() == APK_TOOLS_SHA256 - apk_directory = work / "apk-tools" - apk_directory.mkdir() - await run( - "tar", - "-xzf", - archive, - "-C", - apk_directory, - "sbin/apk.static", - ).stderr(sh.DEVNULL) - apk_tool = apk_directory / "sbin/apk.static" - apk_keys = work / "apk-keys" - apk_keys.mkdir() - shutil.copy2(keys / f"packages.{key_version}.rsa.pub", apk_keys) - await run( - apk_tool, - "verify", - "--keys-dir", - apk_keys, - apk_root / "APKINDEX.tar.gz", - ).stdout(sh.DEVNULL) - - (apt_root / "dists/nightly/stale").write_text("stale") - (rpm_root / "repodata/stale").write_text("stale") - shutil.rmtree(package_input) - package_input.mkdir() - await package_version("1.2.3~nightly.43") - assert (await publish()).exit_code == 0 - assert len(list((apt_root / "pool/nightly").glob("*.deb"))) == 2 - assert len(list(rpm_root.glob("*.rpm"))) == 2 - assert len(list(apk_root.glob("*.apk"))) == 2 - assert not (apt_root / "dists/nightly/stale").exists() - assert not (rpm_root / "repodata/stale").exists() - assert ( - packages_file.read_text().splitlines().count("Package: publish-contract") - == 2 - ) - await run( - apk_tool, - "verify", - "--keys-dir", - apk_keys, - apk_root / "APKINDEX.tar.gz", - ).stdout(sh.DEVNULL) - - deb = next(package_input.glob("*.deb")) - with deb.open("a") as stream: - stream.write("\nchanged\n") - assert (await publish(quiet=True)).exit_code != 0 - shutil.copy2(apt_root / "pool/nightly" / deb.name, deb) - lock = remote / "publish-contract/_locks/nightly" - lock.parent.mkdir(parents=True, exist_ok=True) - lock.write_text('{"expires":9999999999}\n') - assert (await publish(quiet=True)).exit_code != 0 - lock.unlink() - (keys / f"packages.{key_version}.gpg").write_text("different key\n") - assert (await publish(quiet=True)).exit_code != 0 - - allowed_delete = re.compile( - r"^publish-contract/(apt/dists|rpm/nightly/repodata|_locks/nightly)" - ) - for line in (work / "s3.log").read_text().splitlines(): - fields = line.split(maxsplit=2) - if len(fields) == 3 and fields[:2] == ["s3", "delete"]: - assert allowed_delete.match(fields[2]) - - invalid_environment = dict( - os.environ, PUBLISH_TEST_PYTHONPATH=str(root / "tests/fakes") - ) - result = await run.result.set(env=invalid_environment, inherit_env=False)( - root / "tasks/publish.py", - "--service", - "../escape", - "--channel", - "nightly", - "--input", - package_input, - "deb", - ).stderr(sh.DEVNULL) - assert result.exit_code != 0 - - -if __name__ == "__main__": - asyncio.run(main(sys.argv[1:])) |
