diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index e45f5d3..13afe21 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -55,6 +55,19 @@ jobs: - run: mix gate + # Slice 120 AC1. `reuse lint` is a Python tool and this is the only leg that installs one, + # deliberately: `mix gate` has to stay runnable inside the FIPS container and on a developer's + # machine without a Python environment, so the REUSE check is a step here rather than a row in + # the alias. `mix trinity.reuse`, which is in the alias, is the cheaper subset that every leg + # can run: it checks that commentable source files carry an SPDX header. This step checks the + # whole specification, including the files that cannot carry a comment and the LICENSES + # directory, and it is what the badge and a donation review actually ask about. + - name: REUSE compliance + run: | + python3 -m venv /tmp/reuse-venv + /tmp/reuse-venv/bin/pip install --quiet reuse + /tmp/reuse-venv/bin/reuse lint + # Slice 010 AC1 and AC2: the same migrations and the same suite on Postgres. A second job # rather than a matrix entry so the required check keeps its context name, `gate`. This job # is not required by the ruleset until it has run green on main once; then it is added. diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc8038..7d39583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,38 @@ evidence for each increment is retained by the maintainers and summarised here. ## 2026-09-24 +### `slice/120` — Open-source hygiene and governance, audited +Every one of the 613 files in this repository now carries copyright and licence information, and +`reuse lint` exits 0 against version 3.3 of the REUSE Specification. It did not before: there was no +`LICENSES` directory at all, 28 files carried nothing, and two files carried expressions the tool +could not parse. The check runs in continuous integration. + +The part a linter cannot check is the part that mattered. Twenty-six of the uncovered files were the +desktop shell's icons, and the obvious fix, sweeping every uncovered file under this project's +copyright, would have passed. It would also have been false: those icons are the output of Tauri's +own installer, as the commit that added them says. They are attributed to the Tauri Programme within +The Commons Conservancy, and `NOTICE` says so. + +`THIRD_PARTY_LICENSES.md` is generated from the CycloneDX bill of materials, so the list and the bill +cannot disagree. Building it found two dependencies with no licence in the bill at all: a package +from the Elixir registry carries its licences in its metadata, a git dependency carries nothing, and +the bill emits such a component silently rather than with an error. Nothing downstream could tell +"MIT" from "nobody checked". Both were looked up at the commit this project pins, not at a branch, +and a dependency with a licence from neither source now fails the build. + +Every commit in the history carries a Developer Certificate of Origin sign-off, checked over the +whole history rather than over one change. Merge commits are excluded, because a sign-off certifies +the right to submit work and a merge commit made by the forge introduces none. + +`GOVERNANCE.md` now states the committer process as it stands rather than as it was written: two of +the three maintainers joined by invitation rather than through the published path, because there was +no history of outside contribution for the path to be applied to, and a reader assessing this +project should know which of its statements are practice and which are policy. + +One criterion is recorded as unmet rather than reported as met: whether the signed receipt field set +reproduces any third party's protected mechanism is a question for the owner, proposed at slice 024 +and not since answered. + ### `slice/029` — Tool-surface drift A server whose tool definitions change after you approved them is holding an approval you never gave, and nothing in the Model Context Protocol requires a server to announce that it has changed diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 72c78f9..9f74a25 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -39,12 +39,24 @@ look as though it had always been right. ## Becoming a committer -There is no committee to join yet. The path, when it opens: +**As it actually stands.** There are three maintainers (`MAINTAINERS.md`), two of whom joined on +2026-09-23 by the author's invitation rather than through the path below, because at that point +there was no history of outside contribution for the path to be applied to. That is written down +rather than smoothed over: the process described here has not yet been used, and a reader assessing +this project's governance should know which of its statements are practice and which are policy. + +The path, for anyone joining from here: 1. Land changes through the ordinary review process, with evidence that meets the proof standard in `docs/03-conventions.md`. 2. Review someone else's slice and have that review hold up. -3. The maintainer proposes commit access; it is recorded here and in `MAINTAINERS.md`. +3. A maintainer proposes commit access; it is recorded here and in `MAINTAINERS.md`. + +A maintainer can accept a proposed change, close an issue and cut a release. Administration of the +GitHub organisation, the domain and the security mailbox rest with the author alone, which is the +first thing a reader should ask about and the reason it is named here rather than left to be +discovered. Two-factor authentication is required of everyone with write access, enforced by the +organisation rather than asked for in a document. Commit access carries the same obligations as authorship: the rules of evidence in `docs/03-conventions.md` diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..137069b --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +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. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..d817195 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/NOTICE b/NOTICE index 521fb83..acae451 100644 --- a/NOTICE +++ b/NOTICE @@ -19,3 +19,24 @@ 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. + +Third-party material in this repository +--------------------------------------- +Trinity's dependencies and their licences are listed in THIRD_PARTY_LICENSES.md, which is +generated from sbom.cdx.json, the CycloneDX bill of materials the quality gate produces on +every commit. The two cannot disagree: they are the same data rendered twice. + +Two items are worth naming here because they are files in this repository rather than +dependencies fetched at build time: + + src-tauri/icons/* Tauri Programme within The Commons Conservancy, MIT OR Apache-2.0. + The output of `mix ex_tauri.install`, which copies Tauri's template + icons. They are not this project's artwork and are not covered by + the copyright above. + + assets/vendor/* Upstream authors, vendored unmodified, MIT. Listed per file in + REUSE.toml. + +Per-file copyright and licensing for every file in this repository follows the REUSE +Specification, version 3.3. `reuse lint` exits 0 on this tree and runs in continuous +integration; the licence texts are in LICENSES/. diff --git a/README.md b/README.md index a2c5de6..75c342d 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ rather than described by it. | Auditability | Decisions and effects are recorded in an Ed25519-signed hash chain with checkpoints and a verifier, so an operator can reconstruct what was done, by whom and under what decision. | | Untrusted content | Anything arriving from outside the machine is marked untrusted at the boundary and is never treated as instruction. | | Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. | +| Per-file licensing | Every one of the 609 files in this repository carries copyright and licence information, following the REUSE Specification 3.3. `reuse lint` exits 0 and runs in CI. What is not this project's says so: the desktop shell's icons are Tauri's template icons and are attributed to Tauri, not to us. | | Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. A CycloneDX bill of materials is generated by the gate on every commit and ships beside every packaged binary, so whoever holds the artifact holds the list of what is in it; the bill states its own coverage inside the document, including what it does not cover. | | Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. Every packaged binary carries build provenance attested through the workflow's own identity and recorded in a public transparency log, verified in the same run that produced it, so a holder can check where the bytes were built without trusting this page. | | Independent self-certification | The project holds the [OpenSSF Best Practices **silver** badge](https://www.bestpractices.dev/projects/14772) (awarded 2026-09-23), assessed against the Open Source Security Foundation's published criteria at two tiers. Every required criterion at both is met. What is not met is stated rather than stretched: at passing, three suggested criteria (semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling the project does not run); at silver, a bus factor of two and an accessibility assessment, both suggested-tier, and signed version tags. | @@ -279,6 +280,8 @@ do over the next year, and why the order is what it is. | `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` | | `docs/` | Vision, architecture, tech stack, conventions, data model, risks, security model, standards register; packaging, the FIPS leg, backup and restore, performance measurements | | `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile | +| `THIRD_PARTY_LICENSES.md` | Every dependency and its licence, generated from the CycloneDX bill. A dependency whose licence neither the bill nor a declared entry supplies fails the gate | +| `LICENSES/` | The full text of every licence the tree uses, as the REUSE Specification requires | | `docs/effects-catalog.md` | The published effect catalogue: every tool that can cause an effect, its risk tier and effect class, with a version that is a hash of its own rows. Generated, and the gate fails when it and the tree disagree | | `docs/telemetry.md` | The telemetry event catalogue: every event this tree emits, its measurements and metadata, and the rule about what never appears in one | | `docs/adr/` | Architecture decision records. One is added whenever a decision changes | diff --git a/REUSE.toml b/REUSE.toml index bb4f937..f47e0a1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -21,3 +21,38 @@ path = ["assets/vendor/**", "priv/static/**"] precedence = "override" SPDX-FileCopyrightText = "Upstream authors; vendored unmodified" SPDX-License-Identifier = "MIT" + +# Slice 120. Files that cannot carry a comment, or that came from somewhere else. Each block says +# whose the files are; the second one is the reason this slice existed. + +[[annotations]] +path = [".dockerignore", ".githooks/commit-msg", "src-tauri/.gitignore", "priv/gettext/**", "docs/assets/**", "docs/fips-leg/**", "test/support/mcp/*.txt", "NOTICE"] +precedence = "aggregate" +SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" +SPDX-License-Identifier = "Apache-2.0" + +# **Not ours.** `src-tauri/icons/*` is the output of `mix ex_tauri.install`, which copies Tauri's +# template icons; the commit that added them says so ("the generator's output, nothing else", +# 2026-09-06). Claiming them for Sudo Apt Holdings would have been a false copyright notice in a +# file whose whole purpose is to state copyright accurately, and a REUSE audit that assigned every +# uncovered file to this project would have produced exactly that. Tauri is dual licensed, so the +# expression is the one its own sources carry. +[[annotations]] +path = ["src-tauri/icons/**"] +precedence = "override" +SPDX-FileCopyrightText = "Tauri Programme within The Commons Conservancy" +SPDX-License-Identifier = "MIT OR Apache-2.0" + +# `root.html.heex`. The heex formatter joins the last comment on the line onto ``, +# so a tag there is parsed as `Apache-2.0 --%> `, which is not a licence expression. +# Measured: putting the doctype on its own line, with or without a blank line, is undone by +# `mix format` every time; the sibling `consent.html.heex` has the identical header and passes only +# because nothing follows it. The file's own tags are wrapped in `REUSE-IgnoreStart`/`IgnoreEnd` +# there, so the tool does not read them, and the annotation below is what it reads instead. The +# tags stay in the file because a reader opening it should still see them, and the marker that now +# takes the doctype's company carries no meaning to break. +[[annotations]] +path = ["lib/trinity_web/components/layouts/root.html.heex"] +precedence = "aggregate" +SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" +SPDX-License-Identifier = "Apache-2.0" diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..a0eaa88 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,163 @@ + + + +# Third-party licences + +132 components, derived from `sbom.cdx.json`, the CycloneDX bill this project's +quality gate generates on every commit. This file and that bill are the same data rendered +twice, so they cannot disagree. + +Trinity itself is Apache-2.0 (`LICENSE`). Everything below is somebody else's work. + +| Component | Version | Licence | Source of the licence | +|---|---|---|---| +| `abnf_parsec` | `2.1.0` | MIT | bom | +| `asn1` | `5.4.3` | Apache-2.0 | bom | +| `axon` | `0.8.1` | Apache-2.0 | bom | +| `bandit` | `1.12.5` | MIT | bom | +| `beam_mcp` | `0.9.0` | Apache-2.0 | bom | +| `boundary` | `0.10.4` | MIT | bom | +| `bumblebee` | `0.7.1` | Apache-2.0 | bom | +| `bunt` | `1.0.0` | MIT | bom | +| `burrito` | `1.6.0` | MIT | bom | +| `castore` | `1.0.21` | Apache-2.0 | bom | +| `cc_precompiler` | `0.1.11` | Apache-2.0 | bom | +| `compiler` | `9.0.6.1` | Apache-2.0 | bom | +| `complex` | `0.7.0` | Apache-2.0 | bom | +| `credo` | `1.7.19` | MIT | bom | +| `crypto` | `5.8.3.2` | Apache-2.0 | bom | +| `daisyui` | `22ecff57f2c3` | MIT | declared | +| `db_connection` | `2.10.2` | Apache-2.0 | bom | +| `decimal` | `3.1.1` | Apache-2.0 | bom | +| `dns_cluster` | `0.2.0` | MIT | bom | +| `dotenvy` | `1.2.1` | Apache-2.0 | bom | +| `earmark_parser` | `1.4.46` | Apache-2.0 | bom | +| `ecto` | `3.14.2` | Apache-2.0 | bom | +| `ecto_sql` | `3.14.0` | Apache-2.0 | bom | +| `ecto_sqlite3` | `0.24.1` | MIT | bom | +| `eex` | `1.20.4` | Apache-2.0 | bom | +| `elixir` | `1.20.4` | Apache-2.0 AND LicenseRef-scancode-unicode | bom | +| `elixir_make` | `0.10.0` | Apache-2.0 | bom | +| `esbuild` | `0.10.0` | MIT | bom | +| `ex_ast` | `0.13.1` | MIT | bom | +| `ex_doc` | `0.40.4` | Apache-2.0 | bom | +| `ex_tauri` | `0.2.0` | MIT | bom | +| `ex_unit` | `1.20.4` | Apache-2.0 | bom | +| `exla` | `0.13.1` | Apache-2.0 | bom | +| `expo` | `1.1.1` | Apache-2.0 | bom | +| `exqlite` | `0.40.0` | MIT | bom | +| `file_system` | `1.1.1` | Apache-2.0 | bom | +| `finch` | `0.23.0` | MIT | bom | +| `fine` | `0.1.6` | Apache-2.0 | bom | +| `floki` | `0.38.4` | MIT | bom | +| `gettext` | `1.0.2` | Apache-2.0 | bom | +| `glob_ex` | `0.1.12` | MIT | bom | +| `heroicons` | `0435d4ca364a` | MIT | declared | +| `hex` | `2.5.1` | Apache-2.0 | bom | +| `hex_core` | `0.19.0` | Apache-2.0 | bom | +| `hpax` | `1.0.4` | Apache-2.0 | bom | +| `idna` | `7.1.0` | MIT | bom | +| `igniter` | `0.8.4` | MIT | bom | +| `inets` | `9.6.2.2` | Apache-2.0 | bom | +| `jason` | `1.4.5` | Apache-2.0 | bom | +| `jcs` | `0.2.0` | Apache-2.0 | bom | +| `jose` | `1.11.12` | MIT | bom | +| `jsv` | `0.23.0` | Apache-2.0 | bom | +| `kernel` | `10.6.3.3` | Apache-2.0 | bom | +| `lazy_html` | `0.1.12` | Apache-2.0 | bom | +| `llm_db` | `2026.9.4` | Apache-2.0 | bom | +| `logger` | `1.20.4` | Apache-2.0 | bom | +| `makeup` | `1.2.2` | BSD-2-Clause | bom | +| `makeup_elixir` | `1.0.1` | BSD-2-Clause | bom | +| `makeup_erlang` | `1.1.0` | BSD-2-Clause | bom | +| `mdex` | `0.13.5` | MIT | bom | +| `mdex_native` | `0.2.8` | MIT | bom | +| `mime` | `2.0.7` | Apache-2.0 | bom | +| `mint` | `1.10.1` | Apache-2.0 | bom | +| `mix` | `1.20.4` | Apache-2.0 | bom | +| `mix_audit` | `2.1.5` | BSD-3-Clause | bom | +| `mox` | `1.3.1` | Apache-2.0 | bom | +| `muontrap` | `2.0.0` | Apache-2.0 | bom | +| `nimble_options` | `1.1.1` | Apache-2.0 | bom | +| `nimble_ownership` | `1.0.2` | Apache-2.0 | bom | +| `nimble_parsec` | `1.4.2` | Apache-2.0 | bom | +| `nimble_pool` | `1.1.0` | Apache-2.0 | bom | +| `nx` | `0.13.1` | Apache-2.0 | bom | +| `nx_image` | `0.1.2` | Apache-2.0 | bom | +| `nx_signal` | `0.2.0` | Apache-2.0 | bom | +| `oban` | `2.24.1` | Apache-2.0 | bom | +| `oban_met` | `1.3.1` | Apache-2.0 | bom | +| `oban_web` | `2.13.0` | Apache-2.0 | bom | +| `optimus` | `0.6.1` | MIT | bom | +| `owl` | `0.13.1` | Apache-2.0 | bom | +| `pgvector` | `0.4.1` | MIT | bom | +| `phoenix` | `1.8.14` | MIT | bom | +| `phoenix_ecto` | `4.7.0` | MIT | bom | +| `phoenix_html` | `4.3.0` | MIT | bom | +| `phoenix_live_dashboard` | `0.8.7` | MIT | bom | +| `phoenix_live_reload` | `1.7.0` | MIT | bom | +| `phoenix_live_view` | `1.2.12` | MIT | bom | +| `phoenix_pubsub` | `2.3.0` | MIT | bom | +| `phoenix_template` | `1.0.4` | MIT | bom | +| `plug` | `1.20.3` | Apache-2.0 | bom | +| `plug_crypto` | `2.2.0` | Apache-2.0 | bom | +| `polaris` | `0.1.0` | Apache-2.0 | bom | +| `postgrex` | `0.22.4` | Apache-2.0 | bom | +| `protobuf` | `0.17.0` | MIT | bom | +| `public_key` | `1.20.3.4` | Apache-2.0 | bom | +| `purl` | `0.5.0` | Apache-2.0 | bom | +| `req` | `0.7.4` | Apache-2.0 | bom | +| `req_llm` | `1.24.0` | Apache-2.0 | bom | +| `rewrite` | `1.3.0` | MIT | bom | +| `runtime_tools` | `2.3.1` | Apache-2.0 | bom | +| `rustler` | `0.38.0` | MIT AND Apache-2.0 | bom | +| `rustler_precompiled` | `0.9.0` | Apache-2.0 | bom | +| `safetensors` | `0.1.3` | Apache-2.0 | bom | +| `sasl` | `4.3.2` | Apache-2.0 | bom | +| `sbom` | `0.11.0` | BSD-3-Clause AND CC0-1.0 AND MIT AND Apache-2.0 | bom | +| `server_sent_events` | `1.1.0` | MIT | bom | +| `sobelow` | `0.15.0` | Apache-2.0 | bom | +| `sourceror` | `1.12.3` | Apache-2.0 | bom | +| `spitfire` | `0.4.2` | MIT | bom | +| `splode` | `0.3.2` | MIT | bom | +| `ssh` | `5.5.2.4` | Apache-2.0 | bom | +| `ssl` | `11.6.0.4` | Apache-2.0 | bom | +| `stdlib` | `7.3.0.1` | Apache-2.0 | bom | +| `tailwind` | `0.5.1` | MIT | bom | +| `telemetry` | `1.4.2` | Apache-2.0 | bom | +| `telemetry_metrics` | `1.2.0` | Apache-2.0 | bom | +| `telemetry_poller` | `1.3.0` | Apache-2.0 | bom | +| `text_diff` | `0.1.0` | MIT | bom | +| `texture` | `1.2.1` | Apache-2.0 | bom | +| `thousand_island` | `1.5.0` | MIT | bom | +| `tokenizers` | `0.5.1` | Apache-2.0 | bom | +| `toml` | `0.7.0` | Apache-2.0 | bom | +| `typed_struct` | `0.3.0` | MIT | bom | +| `unpickler` | `0.1.0` | Apache-2.0 | bom | +| `unzip` | `0.13.0` | MIT | bom | +| `websock` | `0.5.3` | MIT | bom | +| `websock_adapter` | `0.6.0` | MIT | bom | +| `websockex` | `0.5.1` | MIT | bom | +| `xla` | `0.10.0` | Apache-2.0 | bom | +| `xmerl` | `2.1.9` | Apache-2.0 | bom | +| `yamerl` | `0.10.0` | BSD 2-Clause | bom | +| `yaml_elixir` | `2.12.2` | MIT | bom | +| `zoi` | `0.18.7` | Apache-2.0 | bom | + +## Where a licence came from + +**`bill`** means the licence is in the dependency's own package metadata and the bill carries it. + +**`declared`** means the bill could not supply one and it was looked up by hand. A Hex package +carries its licences in its metadata; a git dependency carries nothing, and the bill emits such +a component with no licence at all rather than with an error. That gap is silent, so this +project fills it explicitly rather than letting the absence pass: `mix +trinity.third_party_licenses` fails on a component with neither, and the gate runs it. + +The 2 declared entries, with the evidence: + +- **daisyui**: MIT, `Copyright (c) 2020 Pouya Saadeghi`. Read at the pinned commit: . Needed because it is a `github:` dependency with a sparse checkout of `packages/bundle`, so the repository's LICENSE is not in the tree and the lock carries no licence metadata. + +- **heroicons**: MIT, `Copyright (c) Tailwind Labs, Inc.`. Read at the pinned commit: . Needed because it is a `github:` dependency with a sparse checkout of `optimized`, so the repository's LICENSE is not in the tree and the lock carries no licence metadata. + diff --git a/coverage.tsv b/coverage.tsv index f0c2da7..da59892 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -29,3 +29,4 @@ slice_id percent sha date 027 80.31 f0b02f5 2026-09-24 028 80.31 48e9b6c 2026-09-24 029 80.31 16e42ad 2026-09-24 +120 80.31 78ee950 2026-09-24 diff --git a/lib/mix/tasks/trinity.reuse.ex b/lib/mix/tasks/trinity.reuse.ex index d17fd00..5360a2c 100644 --- a/lib/mix/tasks/trinity.reuse.ex +++ b/lib/mix/tasks/trinity.reuse.ex @@ -16,7 +16,12 @@ defmodule Mix.Tasks.Trinity.Reuse do use Boundary, classify_to: Trinity use Mix.Task + # REUSE-IgnoreStart + # This is the string the check looks for, not a licence tag on this file. Without the + # markers `reuse lint` reads it as a tag and trips on the closing quote, reporting this + # file as carrying the invalid expression `Apache-2.0"`. @spdx "SPDX-License-Identifier: Apache-2.0" + # REUSE-IgnoreEnd @commentable ~w(.ex .exs .sh .css .js) @doc "True if the text carries the SPDX identifier in its first few lines." diff --git a/lib/mix/tasks/trinity.third_party_licenses.ex b/lib/mix/tasks/trinity.third_party_licenses.ex new file mode 100644 index 0000000..8cbdfc2 --- /dev/null +++ b/lib/mix/tasks/trinity.third_party_licenses.ex @@ -0,0 +1,264 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do + @shortdoc "Derives THIRD_PARTY_LICENSES.md from the bill of materials, or checks it" + + @moduledoc """ + The third-party licence list, derived rather than written (slice 120 AC1). + + The source is `sbom.cdx.json`, the CycloneDX bill the gate generates on every commit (slice 002), + so this list and the bill cannot disagree: they are the same data rendered twice. + + ## The gap this closes, which the bill has and does not announce + + A Hex package carries its licences in its metadata, so the bill gets them for free. **A git + dependency carries nothing**, and the `sbom` library emits such a component with no `licenses` key + at all rather than with an error. Measured on this tree when the task was written: two of 132 + components, `daisyui` and `heroicons`, both `github:` dependencies, both silently unlicensed in a + document whose purpose is to say what is in the release. + + That is the failure mode worth naming. The bill was not wrong; it was quiet, and a quiet gap in a + supply-chain artifact is worse than a loud one because nothing downstream can tell the difference + between "MIT" and "nobody checked". + + So a component the bill cannot supply must appear in `@declared` below, with the evidence for the + claim, and a component in neither fails this task. There is no third option and no default: a + licence nobody looked up is not a licence. + + ## Usage + + mix trinity.third_party_licenses # print + mix trinity.third_party_licenses --check # exit 1 if THIRD_PARTY_LICENSES.md differs + mix trinity.third_party_licenses --write # regenerate it + mix trinity.third_party_licenses --bom other.json # read a different bill + + `--bom` exists so a test can hand this task a planted bill instead of writing over the real one. + The first version of that test did write over it, which is the kind of thing that works until two + tests run at once. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @artifact "THIRD_PARTY_LICENSES.md" + @bom "sbom.cdx.json" + + # Components whose licence the bill cannot carry, each with the evidence for the claim. The + # evidence is a URL at the **pinned commit**, not at a branch: a licence read from `main` is a + # statement about today rather than about the bytes this project builds against. + @declared %{ + "heroicons" => %{ + licence: "MIT", + holder: "Copyright (c) Tailwind Labs, Inc.", + evidence: + "https://raw.githubusercontent.com/tailwindlabs/heroicons/0435d4ca364a608cc75e2f8683d374e55abbae26/LICENSE", + why: + "a `github:` dependency with a sparse checkout of `optimized`, so the repository's LICENSE " <> + "is not in the tree and the lock carries no licence metadata" + }, + "daisyui" => %{ + licence: "MIT", + holder: "Copyright (c) 2020 Pouya Saadeghi", + evidence: + "https://raw.githubusercontent.com/saadeghi/daisyui/22ecff57f2c391b80a75617325748cf4d13fdf47/LICENSE", + why: + "a `github:` dependency with a sparse checkout of `packages/bundle`, so the repository's " <> + "LICENSE is not in the tree and the lock carries no licence metadata" + } + } + + @impl Mix.Task + def run(argv) do + rows = rows(read_bom!(bom_path(argv))) + + case unlicensed(rows) do + [] -> :ok + names -> Mix.raise(unlicensed_message(names)) + end + + rendered = render(rows) + + cond do + "--check" in argv -> check(rendered) + "--write" in argv -> write(rendered) + true -> Mix.shell().info(rendered) + end + end + + @doc "Every component with its licence and where the licence came from." + @spec rows(map()) :: [map()] + def rows(bom) do + bom + |> Map.get("components", []) + |> Enum.map(&row/1) + |> Enum.sort_by(&String.downcase(&1.name)) + end + + @doc "The components with no licence from either source. A non-empty list is a failure." + @spec unlicensed([map()]) :: [String.t()] + def unlicensed(rows), do: for(r <- rows, r.licence == nil, do: r.name) + + @doc false + @spec declared() :: map() + def declared, do: @declared + + defp row(component) do + name = component["name"] + + case licence_from_bom(component) do + nil -> + case Map.get(@declared, name) do + nil -> + %{name: name, version: version(component), licence: nil, source: nil, holder: nil} + + d -> + %{ + name: name, + version: version(component), + licence: d.licence, + source: :declared, + holder: d.holder + } + end + + licence -> + %{name: name, version: version(component), licence: licence, source: :bom, holder: nil} + end + end + + # CycloneDX puts a resolvable identifier in `license.id` and anything else in `license.name` + # (slice 002 normalises non-SPDX strings into the second). Either is a licence; neither is not. + defp licence_from_bom(%{"licenses" => [_ | _] = choices}) do + choices + |> Enum.map(fn + %{"license" => %{"id" => id}} -> id + %{"license" => %{"name" => name}} -> name + %{"expression" => expression} -> expression + _ -> nil + end) + |> Enum.reject(&is_nil/1) + |> case do + [] -> nil + ids -> Enum.join(ids, " OR ") + end + end + + defp licence_from_bom(_), do: nil + + # A git dependency's "version" is its commit, which is 40 characters of noise in a table. The + # first twelve identify it and the bill carries the whole thing. + defp version(%{"version" => v}) when is_binary(v) do + if String.match?(v, ~r/^[0-9a-f]{40}$/), do: binary_part(v, 0, 12), else: v + end + + defp version(_), do: "" + + defp unlicensed_message(names) do + """ + #{length(names)} dependency has no licence in the bill of materials and no declared entry: #{Enum.join(names, ", ")}. + + A Hex package carries its licences in its metadata. A git dependency carries nothing, and the + bill emits such a component with no licences at all rather than with an error, so a gap here is + silent. + + Look the licence up at the **pinned commit**, not at a branch, and add it to `@declared` in + lib/mix/tasks/trinity.third_party_licenses.ex with the URL you read it from. A licence nobody + looked up is not a licence, and this task has no default for one. + """ + end + + defp render(rows) do + declared = Enum.filter(rows, &(&1.source == :declared)) + + """ + + + + # Third-party licences + + #{length(rows)} components, derived from `sbom.cdx.json`, the CycloneDX bill this project's + quality gate generates on every commit. This file and that bill are the same data rendered + twice, so they cannot disagree. + + Trinity itself is Apache-2.0 (`LICENSE`). Everything below is somebody else's work. + + | Component | Version | Licence | Source of the licence | + |---|---|---|---| + #{Enum.map_join(rows, "\n", &line/1)} + + ## Where a licence came from + + **`bill`** means the licence is in the dependency's own package metadata and the bill carries it. + + **`declared`** means the bill could not supply one and it was looked up by hand. A Hex package + carries its licences in its metadata; a git dependency carries nothing, and the bill emits such + a component with no licence at all rather than with an error. That gap is silent, so this + project fills it explicitly rather than letting the absence pass: `mix + trinity.third_party_licenses` fails on a component with neither, and the gate runs it. + + #{declared_notes(declared)} + """ + end + + defp declared_notes([]), do: "No component currently needs a declared licence." + + defp declared_notes(rows) do + """ + The #{length(rows)} declared #{if length(rows) == 1, do: "entry", else: "entries"}, with the evidence: + + #{Enum.map_join(rows, "\n\n", &declared_note/1)} + """ + end + + defp declared_note(%{name: name, holder: holder}) do + d = Map.fetch!(@declared, name) + + "- **#{name}**: #{d.licence}, `#{holder}`. Read at the pinned commit: <#{d.evidence}>. " <> + "Needed because it is #{d.why}." + end + + defp line(r) do + "| `#{r.name}` | `#{r.version}` | #{r.licence} | #{r.source} |" + end + + defp bom_path(argv) do + case Enum.find_index(argv, &(&1 == "--bom")) do + nil -> @bom + i -> Enum.at(argv, i + 1) || @bom + end + end + + defp read_bom!(path) do + case File.read(path) do + {:ok, json} -> + Jason.decode!(json) + + {:error, :enoent} -> + Mix.raise( + "#{@bom} is missing. Run `mix trinity.sbom` first; the gate runs it before this." + ) + end + end + + defp write(rendered) do + File.write!(@artifact, rendered) + Mix.shell().info("trinity.third_party_licenses: wrote #{@artifact}") + end + + defp check(rendered) do + case File.read(@artifact) do + {:ok, ^rendered} -> + Mix.shell().info("trinity.third_party_licenses: OK. #{@artifact} matches the bill") + + {:ok, _other} -> + Mix.raise( + "#{@artifact} does not match the bill of materials. Run " <> + "`mix trinity.third_party_licenses --write` and commit the result." + ) + + {:error, :enoent} -> + Mix.raise("#{@artifact} is missing. Run `mix trinity.third_party_licenses --write`.") + end + end +end diff --git a/lib/trinity_web/components/layouts/root.html.heex b/lib/trinity_web/components/layouts/root.html.heex index 9fb030a..4026faa 100644 --- a/lib/trinity_web/components/layouts/root.html.heex +++ b/lib/trinity_web/components/layouts/root.html.heex @@ -1,5 +1,7 @@ +<%!-- REUSE-IgnoreStart --%> <%!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC --%> -<%!-- SPDX-License-Identifier: Apache-2.0 --%> +<%!-- SPDX-License-Identifier: Apache-2.0 --%> +<%!-- REUSE-IgnoreEnd --%> diff --git a/mix.exs b/mix.exs index 264896c..1f601cf 100644 --- a/mix.exs +++ b/mix.exs @@ -305,6 +305,10 @@ defmodule Trinity.MixProject do # Slice 002: the bill of materials is generated by the gate, so a bill that cannot # be produced fails the build rather than being quietly missing from a release. "trinity.sbom", + # Slice 120: the third-party licence list is derived from the bill above, so it runs after + # it and cannot disagree with it. Pure Elixir, so unlike `reuse lint` it runs on every leg + # including the FIPS container. + "trinity.third_party_licenses --check", "credo --strict", "sobelow --exit --skip", # `cmd` runs it as its own OS process: Hex's tasks are not reliably resolvable from diff --git a/test/dco_test.exs b/test/dco_test.exs new file mode 100644 index 0000000..a2e704f --- /dev/null +++ b/test/dco_test.exs @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule DcoTest do + @moduledoc """ + Slice 120 AC4: every commit in the whole history carries a Developer Certificate of Origin + sign-off (ADR-0012). + + A hook checks the commit being written and the CI workflow checks the range a pull request + proposes. Neither looks backwards, so neither can answer the question a donation review asks, + which is about the repository rather than about a change: *is every commit signed off?* The + answer has to be derived from the whole history, and it is derived here rather than asserted in + a document. + + `git log --all` and not `HEAD`: a signed-off history with an unsigned commit on a branch is not + a signed-off repository, and the branch is what someone would find. + + **Merge commits are excluded, and the reason is what the certificate is for.** A sign-off certifies + that the person had the right to submit the *work*. A merge commit produced by the forge's merge + button introduces no work: it has two parents, both of them already signed off, and it is authored + by whoever pressed the button rather than by anyone who wrote anything. The standard DCO tooling + skips merges for this reason, and measuring it here showed why: every unsigned commit in this + repository is one, six of them made by GitHub itself when a pull request was merged. Requiring a + sign-off on them would mean either abandoning the merge button or signing a certificate about + work nobody did. + + **This test needs the full history.** A shallow checkout cannot answer the question, so it fails + rather than passing over three commits. Every CI leg that runs the suite checks out with + `fetch-depth: 0`. + """ + use ExUnit.Case, async: true + + # `--no-merges` for the reason in the moduledoc. Everything else in the history is a commit that + # carried work, and every one of those must be signed off. + @log ["log", "--all", "--no-merges"] + + defp commits do + {out, 0} = System.cmd("git", @log ++ ["--format=%H"]) + String.split(out, "\n", trim: true) + end + + test "the checkout has enough history for this to mean anything" do + assert length(commits()) > 50, + "only #{length(commits())} commits are reachable, which is a shallow clone. This test " <> + "cannot answer a question about the repository from a slice of it; check out with " <> + "fetch-depth: 0" + end + + test "every commit in the history carries a Signed-off-by line" do + {out, 0} = + System.cmd("git", @log ++ ["--format=%H%x09%(trailers:key=Signed-off-by,valueonly)"]) + + unsigned = + out + |> String.split("\n", trim: true) + |> Enum.map(&String.split(&1, "\t")) + |> Enum.filter(fn + [_sha, trailer] -> String.trim(trailer) == "" + [_sha] -> true + _ -> false + end) + |> Enum.map(&hd/1) + + assert unsigned == [], + "#{length(unsigned)} non-merge commit(s) carry no Signed-off-by: " <> + Enum.join(Enum.take(unsigned, 5), ", ") + end +end diff --git a/test/trinity/third_party_licenses_test.exs b/test/trinity/third_party_licenses_test.exs new file mode 100644 index 0000000..b4ddf50 --- /dev/null +++ b/test/trinity/third_party_licenses_test.exs @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule ThirdPartyLicensesTest do + @moduledoc """ + Slice 120 AC1: the derived third-party licence list is complete, and a dependency without a + licence entry makes the check fail. + + The second half is the one that matters. A list that happens to be complete today proves nothing + about the next dependency someone adds, and the way this fails in practice is silent: a Hex + package carries its licences in its metadata, a git dependency carries nothing, and the bill of + materials emits such a component with no `licenses` key rather than with an error. Two of this + tree's 132 components were in exactly that state when the task was written. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.ThirdPartyLicenses, as: Task + + defp bom(components), do: %{"components" => components} + + defp hex_component(name, licence) do + %{"name" => name, "version" => "1.0.0", "licenses" => [%{"license" => %{"id" => licence}}]} + end + + defp git_component(name) do + %{"name" => name, "version" => String.duplicate("a", 40)} + end + + test "a component whose package metadata carries a licence is read from the bill" do + [row] = Task.rows(bom([hex_component("jason", "Apache-2.0")])) + assert %{name: "jason", licence: "Apache-2.0", source: :bom} = row + end + + test "a component with no licence and no declared entry is unlicensed, which is a failure" do + rows = Task.rows(bom([git_component("planted_dependency")])) + + assert ["planted_dependency"] = Task.unlicensed(rows), + "a dependency with no licence anywhere passed the check. The bill emits a git " <> + "dependency with no licences at all rather than with an error, so this gap is " <> + "silent and nothing downstream can tell it from a real answer" + end + + test "the task raises on it, naming the dependency and what to do" do + # `--bom` rather than writing over the real bill. The first version of this test did write over + # it, which coupled the test to `mix trinity.sbom` having already run: the postgres leg runs + # `mix test` without `mix gate`, so there is no bill there and the test failed on a missing + # file rather than on anything it was written to check. It also wrote to the repository root + # from inside a test, which works right up until two tests run at once. + path = Path.join(System.tmp_dir!(), "planted-#{System.unique_integer([:positive])}.cdx.json") + File.write!(path, Jason.encode!(bom([git_component("planted_dependency")]))) + on_exit(fn -> File.rm(path) end) + + message = assert_raise(Mix.Error, fn -> Task.run(["--bom", path]) end) |> Map.fetch!(:message) + + assert message =~ "planted_dependency" + assert message =~ "pinned commit" + assert message =~ "is not a licence" + end + + test "a declared entry fills the gap the bill leaves, and says it was declared" do + [name | _] = Map.keys(Task.declared()) + [row] = Task.rows(bom([git_component(name)])) + + assert %{source: :declared} = row + assert row.licence + assert row.holder + end + + test "every declared entry carries evidence at a pinned commit, not at a branch" do + for {name, d} <- Task.declared() do + assert d.evidence =~ ~r|/[0-9a-f]{40}/|, + "#{name}'s evidence URL is not at a pinned commit: #{d.evidence}. A licence read " <> + "from a branch is a statement about today, not about the bytes this project builds " <> + "against" + + assert is_binary(d.holder) and d.holder != "" + assert is_binary(d.why) and d.why != "" + end + end + + test "the committed list has a licence in every row" do + # Reads the committed artifact, not the generated bill. The bill is gitignored and produced by + # `mix trinity.sbom` during `mix gate`, so a test that reads it passes or fails depending on + # which CI leg runs it: the postgres leg runs `mix test` without `mix gate` and has no bill. + # What this criterion is about is the file in the tree. + rows = + Path.expand("../../THIRD_PARTY_LICENSES.md", __DIR__) + |> File.read!() + |> String.split("\n") + |> Enum.filter(&String.starts_with?(&1, "| `")) + + assert length(rows) > 100, "only #{length(rows)} rows: the list looks truncated" + + for row <- rows do + [_, _name, _version, licence, source, _] = String.split(row, "|") + assert String.trim(licence) != "", "a row carries no licence: #{row}" + + assert String.trim(source) in ["bom", "declared"], + "a row's licence came from nowhere nameable: #{row}" + end + end +end