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 | 170 | +0 −170 |
| -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-- | LICENSES/CC-BY-3.0.txt | 319 | +0 −319 |
| -rw-r--r-- | README.md | 196 | +19 −177 |
| -rw-r--r-- | REUSE.toml | 17 | +1 −16 |
| -rw-r--r-- | config/cla-unsupported-commits | 7 | +0 −7 |
| -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 | 66 | +66 −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 |
37 files changed, 152 insertions, 3952 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 deleted file mode 100644 --- a/CLA.md +++ /dev/null @@ -1,170 +0,0 @@ -# Dimidium Labs Platform Individual Contributor License Agreement - -Version 1.0 - -> This agreement is based on the Harmony Individual Contributor License -> 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. - -This contributor agreement ("Agreement") documents the rights granted by -contributors to Us. To make this document effective, You must personally add -the following trailers to every commit You Submit: - -```text -CLA-Version: 1.0 -Signed-off-by: Your Name <your.email@example.com> -``` - -The name and email address in `Signed-off-by` must identify You and match the -commit author. By Submitting a commit containing these trailers, You -electronically sign and accept this Agreement. No other person or automated -system may add the `Signed-off-by` trailer on Your behalf. This is a legally -binding document, so please read it carefully before agreeing to it. - -## 1. Definitions - -"You" means the individual who Submits a Contribution to Us. - -"Contribution" means any work of authorship that is Submitted by You to Us in -which You own or assert ownership of the Copyright. If You do not own the -Copyright in the entire work of authorship, please follow the instructions in -Section 3(d). - -"Copyright" means all rights protecting works of authorship owned or controlled -by You, including copyright, moral and neighboring rights, as appropriate, for -the full term of their existence including any extensions by You. - -"Material" means the work of authorship which is made available by Us to third -parties as part of the Project. After You Submit the Contribution, it may be -included in the Material. - -"Submit" means any form of electronic, verbal, or written communication sent to -Us or our representatives, including but not limited to electronic mailing -lists, source code control systems, and issue tracking systems that are managed -by, or on behalf of, Us for the purpose of discussing and improving the -Material, but excluding communication that is conspicuously marked or otherwise -designated in writing by You as "Not a Contribution." - -"Submission Date" means the date on which You Submit a Contribution to Us. - -"Effective Date" means the date You execute this Agreement or the date You first -Submit a Contribution to Us, whichever is earlier. - -"Media" means any portion of a Contribution which is not software. - -## 2. Grant of Rights - -### 2.1 Copyright License - -(a) You retain ownership of the Copyright in Your Contribution and have the same -rights to use or license the Contribution which You would have had without -entering into the Agreement. - -(b) To the maximum extent permitted by the relevant law, You grant to Us a -perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable -license under the Copyright covering the Contribution, with the right to -sublicense such rights through multiple tiers of sublicensees, to reproduce, -modify, display, perform and distribute the Contribution as part of the -Material; provided that this license is conditioned upon compliance with Section -2.3. - -### 2.2 Patent License - -For patent claims including, without limitation, method, process, and apparatus -claims which You own, control or have the right to grant, -now or in the future, You grant to Us a perpetual, worldwide, non-exclusive, -transferable, royalty-free, irrevocable patent license, with the right to -sublicense these rights to multiple tiers of sublicensees, to make, have made, -use, sell, offer for sale, import and otherwise transfer the Contribution and -the Contribution in combination with the Material (and portions of such -combination). This license is granted only to the extent that the exercise of -the licensed rights infringes such patent claims; and provided that this license -is conditioned upon compliance with Section 2.3. - -### 2.3 Outbound License - -Based on the grant of rights in Sections 2.1 and 2.2, if We include Your -Contribution in a Material, We may license the Contribution under any license, -including copyleft, permissive, commercial, or proprietary licenses. As a -condition on the exercise of this right, We agree to also license the -Contribution under the terms of the license or licenses which We are using for -the Material on the Submission Date. - -### 2.4 Moral Rights - -If moral rights apply to the Contribution, to the maximum extent permitted by -law, You waive and agree not to assert such moral rights against Us or our -successors in interest, or any of our licensees, either direct or indirect. - -### 2.5 Our Rights - -You acknowledge that We are not obligated to use Your Contribution as part of -the Material and may decide to include any Contribution We consider appropriate. - -### 2.6 Reservation of Rights - -Any rights not expressly licensed under this section are expressly -reserved by You. - -## 3. Agreement - -You confirm that: - -(a) You have the legal authority to enter into this Agreement. - -(b) You own the Copyright and patent claims covering the -Contribution which are required to grant the rights under Section 2. - -(c) The grant of rights under Section 2 does not violate any grant -of rights which You have made to third parties, including Your employer. If You -are an employee, You have had Your employer approve this Agreement or sign the -Entity version of this document. If You are less than eighteen years old, please -have Your parents or guardian sign the Agreement. - -(d) If You do not own the Copyright in the entire work of authorship Submitted, -You have clearly identified the third-party work, its source, and its license in -the Submission. - -## 4. Disclaimer - -EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS -IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT -LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US. -TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY -IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. - -## 5. Consequential Damage Waiver - -TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU BE -LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, -INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT -OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR -OTHERWISE) UPON WHICH THE CLAIM IS BASED. - -## 6. Miscellaneous - -6.1 This Agreement sets out the entire agreement between You and Us for Your -Contributions to Us and overrides all other agreements or understandings. - -6.2 If You or We assign the rights or obligations received through this -Agreement to a third party, as a condition of the assignment, that third party -must agree in writing to abide by all the rights and obligations in the -Agreement. - -6.3 The failure of either party to require performance by the other party of any -provision of this Agreement in one situation shall not affect the right of a -party to require such performance at any time in the future. A waiver of -performance under a provision in one situation shall not be considered a waiver -of the performance of the provision in the future or a waiver of the provision -in its entirety. - -6.4 If any provision of this Agreement is found void and unenforceable, such -provision will be replaced to the extent possible with a provision that comes -closest to the meaning of the original provision and which is enforceable. The -terms and conditions set forth in this Agreement shall apply notwithstanding any -failure of essential purpose of this Agreement or any limited remedy to the -maximum extent possible under law. 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/LICENSES/CC-BY-3.0.txt b/LICENSES/CC-BY-3.0.txt deleted file mode 100644 --- a/LICENSES/CC-BY-3.0.txt +++ /dev/null @@ -1,319 +0,0 @@ -Creative Commons Legal Code - -Attribution 3.0 Unported - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR - DAMAGES RESULTING FROM ITS USE. - -License - -THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE -COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY -COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS -AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. - -BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE -TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY -BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS -CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND -CONDITIONS. - -1. Definitions - - a. "Adaptation" means a work based upon the Work, or upon the Work and - other pre-existing works, such as a translation, adaptation, - derivative work, arrangement of music or other alterations of a - literary or artistic work, or phonogram or performance and includes - cinematographic adaptations or any other form in which the Work may be - recast, transformed, or adapted including in any form recognizably - derived from the original, except that a work that constitutes a - Collection will not be considered an Adaptation for the purpose of - this License. For the avoidance of doubt, where the Work is a musical - work, performance or phonogram, the synchronization of the Work in - timed-relation with a moving image ("synching") will be considered an - Adaptation for the purpose of this License. - b. "Collection" means a collection of literary or artistic works, such as - encyclopedias and anthologies, or performances, phonograms or - broadcasts, or other works or subject matter other than works listed - in Section 1(f) below, which, by reason of the selection and - arrangement of their contents, constitute intellectual creations, in - which the Work is included in its entirety in unmodified form along - with one or more other contributions, each constituting separate and - independent works in themselves, which together are assembled into a - collective whole. A work that constitutes a Collection will not be - considered an Adaptation (as defined above) for the purposes of this - License. - c. "Distribute" means to make available to the public the original and - copies of the Work or Adaptation, as appropriate, through sale or - other transfer of ownership. - d. "Licensor" means the individual, individuals, entity or entities that - offer(s) the Work under the terms of this License. - e. "Original Author" means, in the case of a literary or artistic work, - the individual, individuals, entity or entities who created the Work - or if no individual or entity can be identified, the publisher; and in - addition (i) in the case of a performance the actors, singers, - musicians, dancers, and other persons who act, sing, deliver, declaim, - play in, interpret or otherwise perform literary or artistic works or - expressions of folklore; (ii) in the case of a phonogram the producer - being the person or legal entity who first fixes the sounds of a - performance or other sounds; and, (iii) in the case of broadcasts, the - organization that transmits the broadcast. - f. "Work" means the literary and/or artistic work offered under the terms - of this License including without limitation any production in the - literary, scientific and artistic domain, whatever may be the mode or - form of its expression including digital form, such as a book, - pamphlet and other writing; a lecture, address, sermon or other work - of the same nature; a dramatic or dramatico-musical work; a - choreographic work or entertainment in dumb show; a musical - composition with or without words; a cinematographic work to which are - assimilated works expressed by a process analogous to cinematography; - a work of drawing, painting, architecture, sculpture, engraving or - lithography; a photographic work to which are assimilated works - expressed by a process analogous to photography; a work of applied - art; an illustration, map, plan, sketch or three-dimensional work - relative to geography, topography, architecture or science; a - performance; a broadcast; a phonogram; a compilation of data to the - extent it is protected as a copyrightable work; or a work performed by - a variety or circus performer to the extent it is not otherwise - considered a literary or artistic work. - g. "You" means an individual or entity exercising rights under this - License who has not previously violated the terms of this License with - respect to the Work, or who has received express permission from the - Licensor to exercise rights under this License despite a previous - violation. - h. "Publicly Perform" means to perform public recitations of the Work and - to communicate to the public those public recitations, by any means or - process, including by wire or wireless means or public digital - performances; to make available to the public Works in such a way that - members of the public may access these Works from a place and at a - place individually chosen by them; to perform the Work to the public - by any means or process and the communication to the public of the - performances of the Work, including by public digital performance; to - broadcast and rebroadcast the Work by any means including signs, - sounds or images. - i. "Reproduce" means to make copies of the Work by any means including - without limitation by sound or visual recordings and the right of - fixation and reproducing fixations of the Work, including storage of a - protected performance or phonogram in digital form or other electronic - medium. - -2. Fair Dealing Rights. Nothing in this License is intended to reduce, -limit, or restrict any uses free from copyright or rights arising from -limitations or exceptions that are provided for in connection with the -copyright protection under copyright law or other applicable laws. - -3. License Grant. Subject to the terms and conditions of this License, -Licensor hereby grants You a worldwide, royalty-free, non-exclusive, -perpetual (for the duration of the applicable copyright) license to -exercise the rights in the Work as stated below: - - a. to Reproduce the Work, to incorporate the Work into one or more - Collections, and to Reproduce the Work as incorporated in the - Collections; - b. to create and Reproduce Adaptations provided that any such Adaptation, - including any translation in any medium, takes reasonable steps to - clearly label, demarcate or otherwise identify that changes were made - to the original Work. For example, a translation could be marked "The - original work was translated from English to Spanish," or a - modification could indicate "The original work has been modified."; - c. to Distribute and Publicly Perform the Work including as incorporated - in Collections; and, - d. to Distribute and Publicly Perform Adaptations. - e. For the avoidance of doubt: - - i. Non-waivable Compulsory License Schemes. In those jurisdictions in - which the right to collect royalties through any statutory or - compulsory licensing scheme cannot be waived, the Licensor - reserves the exclusive right to collect such royalties for any - exercise by You of the rights granted under this License; - ii. Waivable Compulsory License Schemes. In those jurisdictions in - which the right to collect royalties through any statutory or - compulsory licensing scheme can be waived, the Licensor waives the - exclusive right to collect such royalties for any exercise by You - of the rights granted under this License; and, - iii. Voluntary License Schemes. The Licensor waives the right to - collect royalties, whether individually or, in the event that the - Licensor is a member of a collecting society that administers - voluntary licensing schemes, via that society, from any exercise - by You of the rights granted under this License. - -The above rights may be exercised in all media and formats whether now -known or hereafter devised. The above rights include the right to make -such modifications as are technically necessary to exercise the rights in -other media and formats. Subject to Section 8(f), all rights not expressly -granted by Licensor are hereby reserved. - -4. Restrictions. The license granted in Section 3 above is expressly made -subject to and limited by the following restrictions: - - a. You may Distribute or Publicly Perform the Work only under the terms - of this License. You must include a copy of, or the Uniform Resource - Identifier (URI) for, this License with every copy of the Work You - Distribute or Publicly Perform. You may not offer or impose any terms - on the Work that restrict the terms of this License or the ability of - the recipient of the Work to exercise the rights granted to that - recipient under the terms of the License. You may not sublicense the - Work. You must keep intact all notices that refer to this License and - to the disclaimer of warranties with every copy of the Work You - Distribute or Publicly Perform. When You Distribute or Publicly - Perform the Work, You may not impose any effective technological - measures on the Work that restrict the ability of a recipient of the - Work from You to exercise the rights granted to that recipient under - the terms of the License. This Section 4(a) applies to the Work as - incorporated in a Collection, but this does not require the Collection - apart from the Work itself to be made subject to the terms of this - License. If You create a Collection, upon notice from any Licensor You - must, to the extent practicable, remove from the Collection any credit - as required by Section 4(b), as requested. If You create an - Adaptation, upon notice from any Licensor You must, to the extent - practicable, remove from the Adaptation any credit as required by - Section 4(b), as requested. - b. If You Distribute, or Publicly Perform the Work or any Adaptations or - Collections, You must, unless a request has been made pursuant to - Section 4(a), keep intact all copyright notices for the Work and - provide, reasonable to the medium or means You are utilizing: (i) the - name of the Original Author (or pseudonym, if applicable) if supplied, - and/or if the Original Author and/or Licensor designate another party - or parties (e.g., a sponsor institute, publishing entity, journal) for - attribution ("Attribution Parties") in Licensor's copyright notice, - terms of service or by other reasonable means, the name of such party - or parties; (ii) the title of the Work if supplied; (iii) to the - extent reasonably practicable, the URI, if any, that Licensor - specifies to be associated with the Work, unless such URI does not - refer to the copyright notice or licensing information for the Work; - and (iv) , consistent with Section 3(b), in the case of an Adaptation, - a credit identifying the use of the Work in the Adaptation (e.g., - "French translation of the Work by Original Author," or "Screenplay - based on original Work by Original Author"). The credit required by - this Section 4 (b) may be implemented in any reasonable manner; - provided, however, that in the case of a Adaptation or Collection, at - a minimum such credit will appear, if a credit for all contributing - authors of the Adaptation or Collection appears, then as part of these - credits and in a manner at least as prominent as the credits for the - other contributing authors. For the avoidance of doubt, You may only - use the credit required by this Section for the purpose of attribution - in the manner set out above and, by exercising Your rights under this - License, You may not implicitly or explicitly assert or imply any - connection with, sponsorship or endorsement by the Original Author, - Licensor and/or Attribution Parties, as appropriate, of You or Your - use of the Work, without the separate, express prior written - permission of the Original Author, Licensor and/or Attribution - Parties. - c. Except as otherwise agreed in writing by the Licensor or as may be - otherwise permitted by applicable law, if You Reproduce, Distribute or - Publicly Perform the Work either by itself or as part of any - Adaptations or Collections, You must not distort, mutilate, modify or - take other derogatory action in relation to the Work which would be - prejudicial to the Original Author's honor or reputation. Licensor - agrees that in those jurisdictions (e.g. Japan), in which any exercise - of the right granted in Section 3(b) of this License (the right to - make Adaptations) would be deemed to be a distortion, mutilation, - modification or other derogatory action prejudicial to the Original - Author's honor and reputation, the Licensor will waive or not assert, - as appropriate, this Section, to the fullest extent permitted by the - applicable national law, to enable You to reasonably exercise Your - right under Section 3(b) of this License (right to make Adaptations) - but not otherwise. - -5. Representations, Warranties and Disclaimer - -UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR -OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY -KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, -INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, -FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF -LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, -WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION -OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. - -6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE -LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR -ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES -ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS -BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -7. Termination - - a. This License and the rights granted hereunder will terminate - automatically upon any breach by You of the terms of this License. - Individuals or entities who have received Adaptations or Collections - from You under this License, however, will not have their licenses - terminated provided such individuals or entities remain in full - compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will - survive any termination of this License. - b. Subject to the above terms and conditions, the license granted here is - perpetual (for the duration of the applicable copyright in the Work). - Notwithstanding the above, Licensor reserves the right to release the - Work under different license terms or to stop distributing the Work at - any time; provided, however that any such election will not serve to - withdraw this License (or any other license that has been, or is - required to be, granted under the terms of this License), and this - License will continue in full force and effect unless terminated as - stated above. - -8. Miscellaneous - - a. Each time You Distribute or Publicly Perform the Work or a Collection, - the Licensor offers to the recipient a license to the Work on the same - terms and conditions as the license granted to You under this License. - b. Each time You Distribute or Publicly Perform an Adaptation, Licensor - offers to the recipient a license to the original Work on the same - terms and conditions as the license granted to You under this License. - c. If any provision of this License is invalid or unenforceable under - applicable law, it shall not affect the validity or enforceability of - the remainder of the terms of this License, and without further action - by the parties to this agreement, such provision shall be reformed to - the minimum extent necessary to make such provision valid and - enforceable. - d. No term or provision of this License shall be deemed waived and no - breach consented to unless such waiver or consent shall be in writing - and signed by the party to be charged with such waiver or consent. - e. This License constitutes the entire agreement between the parties with - respect to the Work licensed here. There are no understandings, - agreements or representations with respect to the Work not specified - here. Licensor shall not be bound by any additional provisions that - may appear in any communication from You. This License may not be - modified without the mutual written agreement of the Licensor and You. - f. The rights granted under, and the subject matter referenced, in this - License were drafted utilizing the terminology of the Berne Convention - for the Protection of Literary and Artistic Works (as amended on - September 28, 1979), the Rome Convention of 1961, the WIPO Copyright - Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 - and the Universal Copyright Convention (as revised on July 24, 1971). - These rights and subject matter take effect in the relevant - jurisdiction in which the License terms are sought to be enforced - according to the corresponding provisions of the implementation of - those treaty provisions in the applicable national law. If the - standard suite of rights granted under applicable copyright law - includes additional rights not granted under this License, such - additional rights are deemed to be included in the License; this - License is not intended to restrict the license of any rights under - applicable law. - - -Creative Commons Notice - - Creative Commons is not a party to this License, and makes no warranty - whatsoever in connection with the Work. Creative Commons will not be - liable to You or any party on any legal theory for any damages - whatsoever, including without limitation any general, special, - incidental or consequential damages arising in connection to this - license. Notwithstanding the foregoing two (2) sentences, if Creative - Commons has expressly identified itself as the Licensor hereunder, it - shall have all rights and obligations of Licensor. - - Except for the limited purpose of indicating to the public that the - Work is licensed under the CCPL, Creative Commons does not authorize - the use by either party of the trademark "Creative Commons" or any - related trademark or logo of Creative Commons without the prior - written consent of Creative Commons. Any permitted use will be in - compliance with Creative Commons' then-current trademark usage - guidelines, as may be published on its website or otherwise made - available upon request from time to time. For the avoidance of doubt, - this trademark restriction does not form part of this License. - - Creative Commons may be contacted at https://creativecommons.org/. diff --git a/README.md b/README.md index 941b3fd..ba734bc 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,183 +14,27 @@ mise run licenses Consuming projects pin this repository by commit SHA. -## Packaging +## Sign-off policy -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. +`tasks/signoff` verifies that: -```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 -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: - -- authors and co-authors with an email from - `config/signoff-approved-emails` are trusted without a trailer; -- `CLA.md` declares exactly one version; -- every non-approved author and co-author has a `Signed-off-by` trailer exactly - matching their commit identity; -- every commit with a non-approved author or co-author has exactly one - `CLA-Version` trailer matching the version declared by `CLA.md` in that - commit; -- commits listed in `config/cla-unsupported-commits` retain their - `Signed-off-by` requirement but are explicitly not treated as covered by a - versioned CLA; +- commits authored with an email from `config/signoff-approved-emails` are + trusted without a trailer; +- every other non-merge commit contains a `Signed-off-by` trailer exactly + matching its author; - every non-approved author and committer email in the complete non-merge history is registered in `.mailmap`. -Approved emails and unsupported commits are maintained centrally so a pull -request in a consuming repository cannot grant itself an exemption. - -## Contributing - -We welcome your contributions, including code, bug reports, ideas, and success -stories. - -If you are making a contribution for the first time or from a new email, please -add yourself to the `.mailmap`. - -### Signoff +Approved emails are maintained centrally so a pull request in a consuming +repository cannot grant itself an exemption. -To include your code, we ask that you read and agree to the [CLA](./CLA.md). To -sign, add a `CLA-Version: 1.0` and a `Signed-off-by` trailer to every commit -(`git commit -s --trailer "CLA-Version: 1.0"`). Each commit in a pull request -must carry a valid `Signed-off-by` line matching the commit author. Please use -your real name. We cannot include code from anonymous contributors. +## Licensing policy -AI agents MUST NOT add Signed-off-by tags. Only humans can legally certify the -Contributor License Agreement. - -### AI policy - -You may use AI agents when writing code and documentation. AI is not allowed for -media including images, videos, fonts at all. You must fully read, understand, -and cleanup any code generated by the agent. We ask that you disclose the -agent's use and indicate the tool, model, and extent of contribution. - -Contributions should include an Assisted-by tag in the following format: -`Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]`, for example: -`Assisted-by: Claude:claude-4.6-opus coccinelle sparse` - -Remember, AI agents should make software better, not worse. +`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`. -## Licensing +## License -Unless noted otherwise, software and configuration are licensed under Apache-2.0. -Documentation is licensed under CC-BY-4.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..515ecc4 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 @@ -9,18 +9,3 @@ path = [ ] SPDX-FileCopyrightText = "2026 Nikolay Govorov" SPDX-License-Identifier = "CC-BY-4.0" - -[[annotations]] -path = [ - "CLA.md", -] -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 deleted file mode 100644 --- a/config/cla-unsupported-commits +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: Apache-2.0 -# -# External contributions retained in history but not covered by a versioned CLA. - -ca483864b5f1f8048905c9016aff20ef36395840 -eb9caf82d99d61a022f67695f208067992a1a9ec 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,66 @@ +#!/bin/sh -eu +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD +#MISE description="Verify contributor identities and Signed-off-by 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" + +is_approved_email() { + grep -v '^[[:space:]]*#' "$approved_emails_file" | + grep -Fqx -- "$1" +} + +if [ ! -f "$approved_emails_file" ]; then + echo "Approved email configuration is missing: $approved_emails_file" >&2 + exit 1 +fi + +bad=0 +for sha in $(git log --no-merges --format=%H); do + author=$(git show -s --format='%an' "$sha") + email=$(git show -s --format='%ae' "$sha") + if is_approved_email "$email"; then + continue + fi + + expected="${author} <${email}>" + + if ! git show -s --format='%(trailers:key=Signed-off-by,valueonly)' "$sha" | + grep -Fqx -- "$expected"; then + short_sha=$(printf '%.8s' "$sha") + echo "Commit $short_sha by $expected is missing a valid Signed-off-by" + bad=1 + fi +done + +if [ "$bad" -ne 0 ]; then + echo "Every commit from a non-approved author email must be signed off (git commit -s)" + 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:])) |
