diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml
new file mode 100644
index 0000000..964d2ab
--- /dev/null
+++ b/.github/workflows/actionlint.yml
@@ -0,0 +1,30 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: actionlint
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ actionlint:
+ name: actionlint
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run actionlint
+ uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
+ with:
+ version: 1.7.12
+ shellcheck: true
diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml
new file mode 100644
index 0000000..d887d97
--- /dev/null
+++ b/.github/workflows/reuse.yml
@@ -0,0 +1,27 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: REUSE Compliance Check
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ reuse:
+ name: REUSE Compliance Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run REUSE
+ uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0
diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml
new file mode 100644
index 0000000..6b948ee
--- /dev/null
+++ b/.github/workflows/typescript.yml
@@ -0,0 +1,39 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: TypeScript
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ typescript:
+ name: TypeScript
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version: 24
+ package-manager-cache: false
+
+ - name: Install dependencies
+ run: npm install --ignore-scripts
+
+ - name: Type-check
+ run: npm run typecheck
+
+ - name: Build CLI
+ run: npm run build
diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml
new file mode 100644
index 0000000..3ed87e6
--- /dev/null
+++ b/.github/workflows/vitest.yml
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: Vitest
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ vitest:
+ name: Vitest
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node.js
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
+ with:
+ node-version: 24
+ package-manager-cache: false
+
+ - name: Install dependencies
+ run: npm install --ignore-scripts
+
+ - name: Run Vitest
+ run: npm test
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
new file mode 100644
index 0000000..f1a6d15
--- /dev/null
+++ b/.github/workflows/zizmor.yml
@@ -0,0 +1,31 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+name: zizmor
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ zizmor:
+ name: zizmor
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run zizmor
+ uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
+ with:
+ version: 1.30.1
+ advanced-security: false
+ online-audits: false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..06c3eac
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+coverage/
+dist/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fbdf310
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ libresign
+ Copyright (C) 2020 LibreCode Coop
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt
new file mode 100644
index 0000000..fbdf310
--- /dev/null
+++ b/LICENSES/AGPL-3.0-or-later.txt
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ libresign
+ Copyright (C) 2020 LibreCode Coop
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index d8898f9..c6e2701 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,34 @@
+
+
# GitHub Governance
-Shared, testable GitHub governance tooling for LibreCodeCoop and LibreSign.
+[](https://github.com/LibreCodeCoop/github-governance/actions/workflows/vitest.yml)
+[](https://github.com/LibreCodeCoop/github-governance/actions/workflows/typescript.yml)
+[](https://api.reuse.software/info/github.com/LibreCodeCoop/github-governance)
+
+Security and repository governance as reviewed, testable code.
+
+GitHub Governance helps LibreCode Coop and LibreSign keep repository protection
+consistent without copying automation between organizations. Policies are
+centrally maintained, dry-run by default, and can be applied with short-lived
+credentials scoped to a single repository.
+
+The first capability manages GitHub repository rulesets, including automatic
+Nextcloud app detection and the required `nextcloud-bot` exception.
+
+## Principles
+
+- **Secure by default:** changes are planned before they are applied.
+- **Least privilege:** repository-scoped execution supports short-lived tokens.
+- **Testable:** policy composition, API behavior, and drift reconciliation have automated tests.
+- **Reusable:** organization repositories keep configuration, not duplicated implementation.
+- **Open:** the project is licensed under AGPL-3.0-or-later and follows REUSE.
-The project will provide a thin policy/composition layer around upstream GitHub
-governance tooling such as Safe Settings. Organization repositories keep only
-their organization-specific configuration; reusable implementation and shared
-policies live here.
+## Documentation
-Development starts by reproducing the current LibreSign ruleset behavior with
-automated tests before any production synchronization is migrated.
+- [Architecture](docs/architecture.md)
+- [Development and quality checks](docs/development.md)
+- [Safe Settings evaluation](docs/safe-settings.md)
diff --git a/REUSE.toml b/REUSE.toml
new file mode 100644
index 0000000..f909c31
--- /dev/null
+++ b/REUSE.toml
@@ -0,0 +1,23 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+version = 1
+SPDX-PackageName = "github-governance"
+SPDX-PackageSupplier = "LibreCode "
+SPDX-PackageDownloadLocation = "https://github.com/LibreCodeCoop/github-governance"
+
+default-license = "AGPL-3.0-or-later"
+default-copyright = "2026 LibreCode coop and contributors"
+
+[[annotations]]
+path = [
+ ".gitignore",
+ "governance.config.json",
+ "package.json",
+ "package-lock.json",
+ "tsconfig.json",
+ "tsconfig.build.json"
+]
+precedence = "aggregate"
+SPDX-FileCopyrightText = "2026 LibreCode coop and contributors"
+SPDX-License-Identifier = "AGPL-3.0-or-later"
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..db5b653
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,56 @@
+
+
+# Architecture
+
+GitHub Governance separates shared implementation from organization-specific
+configuration.
+
+## Responsibilities
+
+The central repository owns:
+
+- shared repository policies;
+- repository classification;
+- GitHub API access;
+- drift planning and reconciliation;
+- dry-run and apply behavior;
+- automated tests.
+
+Organization repositories such as `LibreSign/.github` and
+`LibreCodeCoop/.github` should keep only:
+
+- organization-specific policy selection;
+- exceptional repository overrides;
+- GitHub App credentials;
+- a small workflow that invokes the shared implementation.
+
+## Execution model
+
+Repository-scoped execution is preferred for production changes.
+
+A caller can enumerate repositories with read-only permissions, generate a
+short-lived GitHub App token restricted to one repository, and invoke the
+governance CLI for that repository. This preserves the limited blast radius of
+the existing LibreSign automation without duplicating its implementation.
+
+The CLI is dry-run by default. Mutation requires an explicit `--apply`.
+
+## Policy model
+
+The base policy applies to public, non-archived repositories.
+
+Nextcloud applications are detected through `appinfo/info.xml`. They receive
+the pinned `nextcloud-bot` bypass required for translation workflows.
+
+Shared additional policies can be selected by name from caller configuration.
+Exceptional repository-specific rulesets can be declared by the caller without
+hardcoding organization or repository names in the engine.
+
+## Current scope
+
+The first capability is repository ruleset reconciliation. Other GitHub settings
+should use established upstream tooling when it provides the required behavior.
+See [Safe Settings evaluation](safe-settings.md).
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..59adc94
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,59 @@
+
+
+# Development and quality
+
+Quality checks are intentionally split by tool so failures are easy to identify
+and each workflow has a clear responsibility.
+
+## Local checks
+
+Install dependencies and run the complete local check:
+
+```bash
+npm install
+npm run check
+```
+
+Build the CLI with:
+
+```bash
+npm run build
+```
+
+## GitHub Actions
+
+The repository uses separate workflows for:
+
+- `actionlint.yml`: validates GitHub Actions syntax and embedded shell;
+- `vitest.yml`: runs behavior and policy tests;
+- `typescript.yml`: runs strict TypeScript checks and builds the CLI;
+- `reuse.yml`: validates SPDX and REUSE compliance;
+- `zizmor.yml`: audits GitHub Actions security.
+
+Actions are pinned to commit SHAs and workflows use read-only permissions unless
+additional access is required.
+
+## Licensing
+
+Source and test files carry SPDX headers directly. Formats where inline comments
+are undesirable are covered by `REUSE.toml`.
+
+The project license is AGPL-3.0-or-later and the canonical license text is stored
+under `LICENSES/`.
+
+## Governance CLI
+
+The CLI is dry-run by default:
+
+```bash
+GITHUB_TOKEN=... node dist/cli.js --org LibreSign --config /path/to/governance.config.json
+GITHUB_TOKEN=... node dist/cli.js --repo LibreSign/libresign --config /path/to/governance.config.json
+```
+
+Mutation requires `--apply`.
+
+Production integration must prove parity with the existing LibreSign ruleset
+automation before the old implementation is removed.
diff --git a/docs/safe-settings.md b/docs/safe-settings.md
new file mode 100644
index 0000000..708da4d
--- /dev/null
+++ b/docs/safe-settings.md
@@ -0,0 +1,43 @@
+
+
+# Safe Settings compatibility
+
+GitHub Safe Settings remains useful for repository and organization settings, but
+its current configuration model does not satisfy this project's repository
+ruleset requirement.
+
+LibreCodeCoop and LibreSign use GitHub Free for organizations and therefore need
+repository-level rulesets for public repositories. Organization-wide rulesets
+require GitHub Team or Enterprise.
+
+Safe Settings contains an internal rulesets plugin capable of calling both
+organization and repository ruleset REST endpoints. However, its current
+configuration flow treats rulesets as organization-level settings:
+
+- the published settings schema describes `rulesets` as org-level only;
+- repo/suborg schemas do not expose `rulesets`;
+- `Settings.returnRepoSpecificConfigs()` explicitly removes `rulesets`
+ before repository-level configuration is applied.
+
+Therefore this project does not use Safe Settings as the repository-ruleset
+reconciliation engine.
+
+The project can still integrate Safe Settings later for settings it supports
+well, while keeping repository ruleset reconciliation in this shared,
+well-tested implementation.
+
+
+## Upstream opportunity
+
+The limitation appears to be in Safe Settings' configuration plumbing rather
+than in its REST implementation. Its rulesets plugin already supports
+repository-scoped GET/POST/PUT/DELETE operations and has unit tests for
+repository rulesets.
+
+A future upstream contribution can evaluate exposing `rulesets` in repo/suborg
+schemas and removing the current filtering from repository-specific config.
+Until that behavior is available and released upstream, this project keeps the
+repository-ruleset reconciler small and independently tested.
diff --git a/governance.config.json b/governance.config.json
new file mode 100644
index 0000000..1e2f4cd
--- /dev/null
+++ b/governance.config.json
@@ -0,0 +1,9 @@
+{
+ "repositories": {
+ "github-governance": {
+ "policies": [
+ "governance-ci"
+ ]
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..10fe06c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@librecodecoop/github-governance",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Shared GitHub governance policy composition for LibreCodeCoop and LibreSign",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "check": "npm run typecheck && npm test && npm run build"
+ },
+ "devDependencies": {
+ "@types/node": "24.3.0",
+ "typescript": "5.9.2",
+ "vitest": "3.2.4"
+ }
+}
diff --git a/src/cli-runner.ts b/src/cli-runner.ts
new file mode 100644
index 0000000..98ba44e
--- /dev/null
+++ b/src/cli-runner.ts
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import {
+ loadGovernanceConfig,
+ resolveExtraRulesets,
+ type GovernanceConfig,
+} from './config.js';
+import type { GovernanceClient } from './governance.js';
+import {
+ planOrganization,
+ planRepository,
+ syncOrganization,
+ syncRepository,
+} from './governance.js';
+
+export type CliEnvironment = {
+ GITHUB_TOKEN?: string | undefined;
+};
+
+export type CliOutput = {
+ log(message: string): void;
+ error(message: string): void;
+};
+
+export type GovernanceConfigLoader = (
+ path: string | undefined,
+) => Promise;
+
+export async function runCli(
+ args: string[],
+ environment: CliEnvironment,
+ clientFactory: (token: string) => GovernanceClient,
+ output: CliOutput,
+ configLoader: GovernanceConfigLoader = loadGovernanceConfig,
+): Promise {
+ const organization = option(args, '--org');
+ const repositoryArgument = option(args, '--repo');
+ const configPath = option(args, '--config');
+ const apply = args.includes('--apply');
+ const token = environment.GITHUB_TOKEN;
+
+ if ((organization ? 1 : 0) + (repositoryArgument ? 1 : 0) !== 1) {
+ output.error('Specify exactly one of --org OWNER or --repo OWNER/REPO');
+ return 2;
+ }
+
+ if (!token) {
+ output.error('GITHUB_TOKEN is required');
+ return 2;
+ }
+
+ const client = clientFactory(token);
+ const config = await configLoader(configPath);
+
+ if (repositoryArgument) {
+ const [owner, repository, ...extra] = repositoryArgument.split('/');
+ if (!owner || !repository || extra.length > 0) {
+ output.error('--repo must use OWNER/REPO');
+ return 2;
+ }
+
+ const metadata = await client.getRepository(owner, repository);
+ const extraRulesets = resolveExtraRulesets(config, repository);
+ const plan = apply
+ ? await syncRepository(client, metadata, extraRulesets)
+ : await planRepository(client, metadata, extraRulesets);
+
+ writePlans([plan], apply, output);
+ return !apply && hasDrift([plan]) ? 1 : 0;
+ }
+
+ const resolver = (repository: { name: string }) =>
+ resolveExtraRulesets(config, repository.name);
+
+ const plans = apply
+ ? await syncOrganization(client, organization!, resolver)
+ : await planOrganization(client, organization!, resolver);
+
+ writePlans(plans, apply, output);
+ return !apply && hasDrift(plans) ? 1 : 0;
+}
+
+function writePlans(
+ plans: Awaited>,
+ apply: boolean,
+ output: CliOutput,
+): void {
+ for (const plan of plans) {
+ const changes = plan.changes.filter(
+ (change) => change.action !== 'unchanged',
+ );
+ const kind = plan.isNextcloudApp ? 'Nextcloud app' : 'repository';
+
+ if (changes.length === 0) {
+ output.log(`OK ${plan.repository} (${kind})`);
+ continue;
+ }
+
+ output.log(
+ `${apply ? 'APPLIED' : 'DRIFT'} ${plan.repository} (${kind})`,
+ );
+ for (const change of changes) {
+ output.log(` - ${change.action}: ${change.desired.name}`);
+ }
+ }
+}
+
+function hasDrift(
+ plans: Awaited>,
+): boolean {
+ return plans.some((plan) =>
+ plan.changes.some((change) => change.action !== 'unchanged'),
+ );
+}
+
+function option(args: string[], name: string): string | undefined {
+ const index = args.indexOf(name);
+ if (index === -1) {
+ return undefined;
+ }
+ return args[index + 1];
+}
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..384fcb5
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { runCli } from './cli-runner.js';
+import { GitHubClient } from './github-client.js';
+
+const code = await runCli(
+ process.argv.slice(2),
+ { GITHUB_TOKEN: process.env.GITHUB_TOKEN },
+ (token) => new GitHubClient(token),
+ {
+ log: console.log,
+ error: console.error,
+ },
+);
+
+process.exitCode = code;
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..b58a766
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { readFile } from 'node:fs/promises';
+import { namedRulesets } from './policies.js';
+import type { RepositoryRuleset } from './types.js';
+
+export type RepositoryGovernanceConfig = {
+ policies?: string[];
+ rulesets?: RepositoryRuleset[];
+};
+
+export type GovernanceConfig = {
+ repositories?: Record;
+};
+
+export async function loadGovernanceConfig(
+ path: string | undefined,
+): Promise {
+ if (!path) {
+ return {};
+ }
+
+ const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
+ if (!isRecord(parsed)) {
+ throw new Error('Governance config must be a JSON object');
+ }
+
+ const repositories = parsed.repositories;
+ if (repositories === undefined) {
+ return {};
+ }
+ if (!isRecord(repositories)) {
+ throw new Error('Governance config repositories must be an object');
+ }
+
+ return {
+ repositories: repositories as Record,
+ };
+}
+
+export function resolveExtraRulesets(
+ config: GovernanceConfig,
+ repository: string,
+): RepositoryRuleset[] {
+ const repositoryConfig = config.repositories?.[repository];
+ if (!repositoryConfig) {
+ return [];
+ }
+
+ const rulesets: RepositoryRuleset[] = [];
+
+ for (const policy of repositoryConfig.policies ?? []) {
+ const ruleset = namedRulesets[policy];
+ if (!ruleset) {
+ throw new Error(`Unknown governance policy: ${policy}`);
+ }
+ rulesets.push(structuredClone(ruleset));
+ }
+
+ for (const ruleset of repositoryConfig.rulesets ?? []) {
+ rulesets.push(structuredClone(ruleset));
+ }
+
+ return rulesets;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/src/github-client.ts b/src/github-client.ts
new file mode 100644
index 0000000..d0c47bf
--- /dev/null
+++ b/src/github-client.ts
@@ -0,0 +1,433 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type {
+ RepositoryMetadata,
+ GitHubContentProbe,
+} from './repository-classifier.js';
+import type {
+ ExistingRepositoryRuleset,
+ RepositoryRulesetClient,
+} from './ruleset-reconciler.js';
+import type {
+ BypassActor,
+ RepositoryRuleset,
+ RulesetRule,
+} from './types.js';
+
+type FetchLike = typeof fetch;
+
+type GitHubRepository = {
+ name?: unknown;
+ archived?: unknown;
+ visibility?: unknown;
+ owner?: {
+ login?: unknown;
+ };
+};
+
+type RulesetSummary = {
+ id?: unknown;
+ source_type?: unknown;
+};
+
+export class GitHubClient
+ implements GitHubContentProbe, RepositoryRulesetClient
+{
+ constructor(
+ private readonly token: string,
+ private readonly fetchImpl: FetchLike = fetch,
+ private readonly apiUrl = 'https://api.github.com',
+ ) {}
+
+ async getRepository(
+ owner: string,
+ repository: string,
+ ): Promise {
+ const data = await this.requestJson(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`,
+ );
+
+ const name = data.name;
+ const login = data.owner?.login;
+ const visibility = data.visibility;
+ const archived = data.archived;
+
+ if (
+ login !== owner ||
+ name !== repository ||
+ !(
+ visibility === 'public' ||
+ visibility === 'private' ||
+ visibility === 'internal'
+ ) ||
+ typeof archived !== 'boolean'
+ ) {
+ throw new Error(`Invalid repository response for ${owner}/${repository}`);
+ }
+
+ return {
+ owner: login,
+ name,
+ visibility,
+ archived,
+ };
+ }
+
+ async listManagedRepositories(
+ organization: string,
+ ): Promise {
+ const result: RepositoryMetadata[] = [];
+
+ for (let page = 1; ; page += 1) {
+ const response = await this.requestJson<{
+ repositories?: GitHubRepository[];
+ }>(
+ `/installation/repositories?per_page=100&page=${page}`,
+ );
+ const repositories = response.repositories ?? [];
+
+ for (const repository of repositories) {
+ const owner = repository.owner?.login;
+ const name = repository.name;
+ const visibility = repository.visibility;
+ const archived = repository.archived;
+
+ if (
+ owner === organization &&
+ typeof name === 'string' &&
+ (visibility === 'public' ||
+ visibility === 'private' ||
+ visibility === 'internal') &&
+ typeof archived === 'boolean'
+ ) {
+ result.push({
+ owner,
+ name,
+ visibility,
+ archived,
+ });
+ }
+ }
+
+ if (repositories.length < 100) {
+ break;
+ }
+ }
+
+ return result.filter(
+ (repository) =>
+ repository.visibility === 'public' && !repository.archived,
+ );
+ }
+
+ async exists(
+ owner: string,
+ repository: string,
+ path: string,
+ ): Promise {
+ const response = await this.fetchImpl(
+ this.url(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path
+ .split('/')
+ .map(encodeURIComponent)
+ .join('/')}`,
+ ),
+ {
+ headers: this.headers(),
+ },
+ );
+
+ if (response.status === 404) {
+ return false;
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `GitHub content probe failed with HTTP ${response.status} for ${owner}/${repository}:${path}`,
+ );
+ }
+
+ return true;
+ }
+
+ async list(
+ owner: string,
+ repository: string,
+ ): Promise {
+ const summaries = await this.requestJson(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/rulesets?per_page=100`,
+ );
+
+ const rulesets: ExistingRepositoryRuleset[] = [];
+
+ for (const summary of summaries) {
+ if (
+ summary.source_type !== 'Repository' ||
+ typeof summary.id !== 'number'
+ ) {
+ continue;
+ }
+
+ const detail = await this.requestJson(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/rulesets/${summary.id}`,
+ );
+ rulesets.push(parseExistingRuleset(detail));
+ }
+
+ return rulesets;
+ }
+
+ async create(
+ owner: string,
+ repository: string,
+ ruleset: RepositoryRuleset,
+ ): Promise {
+ await this.requestJson(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/rulesets`,
+ {
+ method: 'POST',
+ body: JSON.stringify(ruleset),
+ },
+ );
+ }
+
+ async update(
+ owner: string,
+ repository: string,
+ id: number,
+ ruleset: RepositoryRuleset,
+ ): Promise {
+ await this.requestJson(
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/rulesets/${id}`,
+ {
+ method: 'PUT',
+ body: JSON.stringify(ruleset),
+ },
+ );
+ }
+
+ private async requestJson(
+ path: string,
+ init: RequestInit = {},
+ ): Promise {
+ const response = await this.fetchImpl(this.url(path), {
+ ...init,
+ headers: {
+ ...this.headers(),
+ ...(init.body === undefined
+ ? {}
+ : { 'Content-Type': 'application/json' }),
+ ...(init.headers ?? {}),
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(
+ `GitHub API ${init.method ?? 'GET'} ${path} failed with HTTP ${response.status}${
+ body === '' ? '' : `: ${body}`
+ }`,
+ );
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+ }
+
+ private headers(): Record {
+ return {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${this.token}`,
+ 'X-GitHub-Api-Version': '2026-03-10',
+ };
+ }
+
+ private url(path: string): string {
+ return `${this.apiUrl}${path}`;
+ }
+}
+
+function parseExistingRuleset(value: unknown): ExistingRepositoryRuleset {
+ if (!isRecord(value)) {
+ throw new Error('Invalid ruleset response from GitHub');
+ }
+
+ const id = value.id;
+ const name = value.name;
+ const target = value.target;
+ const enforcement = value.enforcement;
+ const conditions = value.conditions;
+
+ if (
+ typeof id !== 'number' ||
+ typeof name !== 'string' ||
+ target !== 'branch' ||
+ !(
+ enforcement === 'active' ||
+ enforcement === 'disabled' ||
+ enforcement === 'evaluate'
+ ) ||
+ !isRecord(conditions) ||
+ !isRecord(conditions.ref_name)
+ ) {
+ throw new Error('Invalid ruleset response from GitHub');
+ }
+
+ const bypassActors = Array.isArray(value.bypass_actors)
+ ? value.bypass_actors.map(parseBypassActor)
+ : [];
+ const rules = Array.isArray(value.rules)
+ ? value.rules.map(parseRule)
+ : [];
+
+ return {
+ id,
+ name,
+ target,
+ enforcement,
+ bypass_actors: bypassActors,
+ conditions: {
+ ref_name: {
+ include: stringArray(conditions.ref_name.include),
+ exclude: stringArray(conditions.ref_name.exclude),
+ },
+ },
+ rules,
+ };
+}
+
+function parseBypassActor(value: unknown): BypassActor {
+ if (!isRecord(value)) {
+ throw new Error('Invalid bypass actor from GitHub');
+ }
+
+ const actorType = value.actor_type;
+ const bypassMode = value.bypass_mode;
+ const actorId = value.actor_id;
+
+ if (
+ !(
+ actorType === 'OrganizationAdmin' ||
+ actorType === 'User' ||
+ actorType === 'Team' ||
+ actorType === 'Integration'
+ ) ||
+ !(bypassMode === 'always' || bypassMode === 'pull_request') ||
+ !(typeof actorId === 'number' || actorId === null)
+ ) {
+ throw new Error('Invalid bypass actor from GitHub');
+ }
+
+ return {
+ actor_id: actorId ?? 0,
+ actor_type: actorType,
+ bypass_mode: bypassMode,
+ };
+}
+
+function parseRule(value: unknown): RulesetRule {
+ if (!isRecord(value) || typeof value.type !== 'string') {
+ throw new Error('Invalid ruleset rule from GitHub');
+ }
+
+ if (value.type === 'deletion' || value.type === 'non_fast_forward') {
+ return { type: value.type };
+ }
+
+ if (value.type === 'pull_request' && isRecord(value.parameters)) {
+ const parameters = value.parameters;
+ const allowedMergeMethods = stringArray(parameters.allowed_merge_methods);
+
+ if (
+ !allowedMergeMethods.every(
+ (method) =>
+ method === 'merge' || method === 'squash' || method === 'rebase',
+ )
+ ) {
+ throw new Error('Invalid merge method from GitHub');
+ }
+
+ return {
+ type: 'pull_request',
+ parameters: {
+ allowed_merge_methods: allowedMergeMethods as Array<
+ 'merge' | 'squash' | 'rebase'
+ >,
+ dismiss_stale_reviews_on_push: boolean(
+ parameters.dismiss_stale_reviews_on_push,
+ ),
+ require_code_owner_review: boolean(
+ parameters.require_code_owner_review,
+ ),
+ require_last_push_approval: boolean(
+ parameters.require_last_push_approval,
+ ),
+ required_approving_review_count: number(
+ parameters.required_approving_review_count,
+ ),
+ required_review_thread_resolution: boolean(
+ parameters.required_review_thread_resolution,
+ ),
+ },
+ };
+ }
+
+ if (
+ value.type === 'required_status_checks' &&
+ isRecord(value.parameters)
+ ) {
+ const parameters = value.parameters;
+ const checks = Array.isArray(parameters.required_status_checks)
+ ? parameters.required_status_checks.map((check) => {
+ if (!isRecord(check) || typeof check.context !== 'string') {
+ throw new Error('Invalid required status check from GitHub');
+ }
+ return { context: check.context };
+ })
+ : [];
+
+ return {
+ type: 'required_status_checks',
+ parameters: {
+ required_status_checks: checks,
+ strict_required_status_checks_policy: boolean(
+ parameters.strict_required_status_checks_policy,
+ ),
+ do_not_enforce_on_create: boolean(
+ parameters.do_not_enforce_on_create,
+ ),
+ },
+ };
+ }
+
+ throw new Error(`Unsupported managed ruleset rule: ${value.type}`);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function stringArray(value: unknown): string[] {
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
+ throw new Error('Expected an array of strings from GitHub');
+ }
+ return value;
+}
+
+function boolean(value: unknown): boolean {
+ if (typeof value !== 'boolean') {
+ throw new Error('Expected a boolean from GitHub');
+ }
+ return value;
+}
+
+function number(value: unknown): number {
+ if (typeof value !== 'number') {
+ throw new Error('Expected a number from GitHub');
+ }
+ return value;
+}
diff --git a/src/governance.ts b/src/governance.ts
new file mode 100644
index 0000000..c7851d5
--- /dev/null
+++ b/src/governance.ts
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { classifyRepository } from './repository-classifier.js';
+import { composeRepositoryPolicy } from './policy-composer.js';
+import {
+ planRepositoryRulesets,
+ reconcileRepositoryRulesets,
+ type RepositoryRulesetClient,
+ type RulesetChange,
+} from './ruleset-reconciler.js';
+import type {
+ GitHubContentProbe,
+ RepositoryMetadata,
+} from './repository-classifier.js';
+import type { RepositoryRuleset } from './types.js';
+
+export interface GovernanceClient
+ extends GitHubContentProbe,
+ RepositoryRulesetClient {
+ getRepository(
+ owner: string,
+ repository: string,
+ ): Promise;
+ listManagedRepositories(organization: string): Promise;
+}
+
+export type RepositoryPlan = {
+ repository: string;
+ isNextcloudApp: boolean;
+ changes: RulesetChange[];
+};
+
+export type RepositoryPolicyResolver = (
+ repository: RepositoryMetadata,
+) => Promise | RepositoryRuleset[];
+
+export async function planRepository(
+ client: GovernanceClient,
+ repository: RepositoryMetadata,
+ extraRulesets: RepositoryRuleset[] = [],
+): Promise {
+ const classification = await classifyRepository(repository, client);
+ const policy = composeRepositoryPolicy(classification, { extraRulesets });
+ const existing = await client.list(repository.owner, repository.name);
+
+ return {
+ repository: `${repository.owner}/${repository.name}`,
+ isNextcloudApp: classification.isNextcloudApp,
+ changes: planRepositoryRulesets(existing, policy.rulesets),
+ };
+}
+
+export async function planOrganization(
+ client: GovernanceClient,
+ organization: string,
+ resolveExtraRulesets: RepositoryPolicyResolver = () => [],
+): Promise {
+ const repositories = await client.listManagedRepositories(organization);
+ const plans: RepositoryPlan[] = [];
+
+ for (const repository of repositories) {
+ plans.push(
+ await planRepository(
+ client,
+ repository,
+ await resolveExtraRulesets(repository),
+ ),
+ );
+ }
+
+ return plans;
+}
+
+export async function syncRepository(
+ client: GovernanceClient,
+ repository: RepositoryMetadata,
+ extraRulesets: RepositoryRuleset[] = [],
+): Promise {
+ const plan = await planRepository(client, repository, extraRulesets);
+ const desired = plan.changes.flatMap((change) =>
+ change.action === 'unchanged' ? [change.current] : [change.desired],
+ );
+
+ await reconcileRepositoryRulesets(
+ client,
+ repository.owner,
+ repository.name,
+ desired,
+ );
+
+ return plan;
+}
+
+export async function syncOrganization(
+ client: GovernanceClient,
+ organization: string,
+ resolveExtraRulesets: RepositoryPolicyResolver = () => [],
+): Promise {
+ const repositories = await client.listManagedRepositories(organization);
+ const plans: RepositoryPlan[] = [];
+
+ for (const repository of repositories) {
+ plans.push(
+ await syncRepository(
+ client,
+ repository,
+ await resolveExtraRulesets(repository),
+ ),
+ );
+ }
+
+ return plans;
+}
diff --git a/src/policies.ts b/src/policies.ts
new file mode 100644
index 0000000..6a7235c
--- /dev/null
+++ b/src/policies.ts
@@ -0,0 +1,77 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type { RepositoryRuleset } from './types.js';
+
+export const NEXTCLOUD_BOT_ID = 20296731;
+
+export const baseRuleset: RepositoryRuleset = {
+ name: 'Protect default and stable branches',
+ target: 'branch',
+ enforcement: 'active',
+ bypass_actors: [
+ {
+ actor_id: 0,
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'pull_request',
+ },
+ ],
+ conditions: {
+ ref_name: {
+ include: ['~DEFAULT_BRANCH', 'refs/heads/stable*'],
+ exclude: [],
+ },
+ },
+ rules: [
+ { type: 'deletion' },
+ { type: 'non_fast_forward' },
+ {
+ type: 'pull_request',
+ parameters: {
+ allowed_merge_methods: ['merge', 'squash', 'rebase'],
+ dismiss_stale_reviews_on_push: true,
+ require_code_owner_review: true,
+ require_last_push_approval: false,
+ required_approving_review_count: 1,
+ required_review_thread_resolution: true,
+ },
+ },
+ ],
+};
+
+export const governanceRepositoryCiRuleset: RepositoryRuleset = {
+ name: 'Require governance CI',
+ target: 'branch',
+ enforcement: 'active',
+ bypass_actors: [
+ {
+ actor_id: 0,
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'pull_request',
+ },
+ ],
+ conditions: {
+ ref_name: {
+ include: ['~DEFAULT_BRANCH'],
+ exclude: [],
+ },
+ },
+ rules: [
+ {
+ type: 'required_status_checks',
+ parameters: {
+ required_status_checks: [
+ { context: 'Governance policy tests' },
+ { context: 'TypeScript' },
+ ],
+ strict_required_status_checks_policy: true,
+ do_not_enforce_on_create: false,
+ },
+ },
+ ],
+};
+
+
+export const namedRulesets: Record = {
+ 'governance-ci': governanceRepositoryCiRuleset,
+};
diff --git a/src/policy-composer.ts b/src/policy-composer.ts
new file mode 100644
index 0000000..c1f4571
--- /dev/null
+++ b/src/policy-composer.ts
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { NEXTCLOUD_BOT_ID, baseRuleset } from './policies.js';
+import type {
+ PolicySet,
+ RepositoryClassification,
+ RepositoryRuleset,
+} from './types.js';
+
+export type RepositoryPolicyOptions = {
+ extraRulesets?: RepositoryRuleset[];
+};
+
+function cloneRuleset(ruleset: RepositoryRuleset): RepositoryRuleset {
+ return structuredClone(ruleset);
+}
+
+function withNextcloudBotBypass(ruleset: RepositoryRuleset): RepositoryRuleset {
+ const next = cloneRuleset(ruleset);
+ const alreadyPresent = next.bypass_actors.some(
+ (actor) =>
+ actor.actor_type === 'User' &&
+ actor.actor_id === NEXTCLOUD_BOT_ID &&
+ actor.bypass_mode === 'always',
+ );
+
+ if (!alreadyPresent) {
+ next.bypass_actors.push({
+ actor_id: NEXTCLOUD_BOT_ID,
+ actor_type: 'User',
+ bypass_mode: 'always',
+ });
+ }
+
+ return next;
+}
+
+export function composeRepositoryPolicy(
+ repository: RepositoryClassification,
+ options: RepositoryPolicyOptions = {},
+): PolicySet {
+ if (!repository.isPublic || repository.isArchived) {
+ return { rulesets: [] };
+ }
+
+ const rulesets = [
+ repository.isNextcloudApp
+ ? withNextcloudBotBypass(baseRuleset)
+ : cloneRuleset(baseRuleset),
+ ...(options.extraRulesets ?? []).map(cloneRuleset),
+ ];
+
+ return { rulesets };
+}
diff --git a/src/repository-classifier.ts b/src/repository-classifier.ts
new file mode 100644
index 0000000..bda95e5
--- /dev/null
+++ b/src/repository-classifier.ts
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+export type GitHubContentProbe = {
+ exists(owner: string, repository: string, path: string): Promise;
+};
+
+export type RepositoryMetadata = {
+ owner: string;
+ name: string;
+ visibility: 'public' | 'private' | 'internal';
+ archived: boolean;
+};
+
+export async function classifyRepository(
+ repository: RepositoryMetadata,
+ probe: GitHubContentProbe,
+) {
+ const isPublic = repository.visibility === 'public';
+
+ if (!isPublic || repository.archived) {
+ return {
+ owner: repository.owner,
+ name: repository.name,
+ isPublic,
+ isArchived: repository.archived,
+ isNextcloudApp: false,
+ };
+ }
+
+ const isNextcloudApp = await probe.exists(
+ repository.owner,
+ repository.name,
+ 'appinfo/info.xml',
+ );
+
+ return {
+ owner: repository.owner,
+ name: repository.name,
+ isPublic,
+ isArchived: repository.archived,
+ isNextcloudApp,
+ };
+}
diff --git a/src/ruleset-normalizer.ts b/src/ruleset-normalizer.ts
new file mode 100644
index 0000000..7307ffa
--- /dev/null
+++ b/src/ruleset-normalizer.ts
@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type { RepositoryRuleset, RulesetRule } from './types.js';
+
+function normalizeRule(rule: RulesetRule): RulesetRule {
+ if (rule.type === 'pull_request') {
+ return {
+ type: rule.type,
+ parameters: {
+ allowed_merge_methods: [...rule.parameters.allowed_merge_methods].sort(),
+ dismiss_stale_reviews_on_push:
+ rule.parameters.dismiss_stale_reviews_on_push,
+ require_code_owner_review: rule.parameters.require_code_owner_review,
+ require_last_push_approval: rule.parameters.require_last_push_approval,
+ required_approving_review_count:
+ rule.parameters.required_approving_review_count,
+ required_review_thread_resolution:
+ rule.parameters.required_review_thread_resolution,
+ },
+ };
+ }
+
+ if (rule.type === 'required_status_checks') {
+ return {
+ type: rule.type,
+ parameters: {
+ required_status_checks: [...rule.parameters.required_status_checks].sort(
+ (a, b) => a.context.localeCompare(b.context),
+ ),
+ strict_required_status_checks_policy:
+ rule.parameters.strict_required_status_checks_policy,
+ do_not_enforce_on_create: rule.parameters.do_not_enforce_on_create,
+ },
+ };
+ }
+
+ return { type: rule.type };
+}
+
+export function normalizeRuleset(
+ ruleset: RepositoryRuleset,
+): RepositoryRuleset {
+ return {
+ name: ruleset.name,
+ target: ruleset.target,
+ enforcement: ruleset.enforcement,
+ bypass_actors: ruleset.bypass_actors
+ .map((actor) => ({
+ ...actor,
+ actor_id:
+ actor.actor_type === 'OrganizationAdmin' ? 0 : actor.actor_id,
+ }))
+ .sort((a, b) =>
+ [a.actor_type, a.actor_id, a.bypass_mode]
+ .join(':')
+ .localeCompare([b.actor_type, b.actor_id, b.bypass_mode].join(':')),
+ ),
+ conditions: {
+ ref_name: {
+ include: [...ruleset.conditions.ref_name.include].sort(),
+ exclude: [...ruleset.conditions.ref_name.exclude].sort(),
+ },
+ },
+ rules: ruleset.rules
+ .map(normalizeRule)
+ .sort((a, b) => a.type.localeCompare(b.type)),
+ };
+}
+
+export function rulesetsEqual(
+ left: RepositoryRuleset,
+ right: RepositoryRuleset,
+): boolean {
+ return JSON.stringify(normalizeRuleset(left)) === JSON.stringify(normalizeRuleset(right));
+}
diff --git a/src/ruleset-reconciler.ts b/src/ruleset-reconciler.ts
new file mode 100644
index 0000000..e75e57a
--- /dev/null
+++ b/src/ruleset-reconciler.ts
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import type { RepositoryRuleset } from './types.js';
+import { rulesetsEqual } from './ruleset-normalizer.js';
+
+export type ExistingRepositoryRuleset = RepositoryRuleset & {
+ id: number;
+};
+
+export interface RepositoryRulesetClient {
+ list(owner: string, repository: string): Promise;
+ create(
+ owner: string,
+ repository: string,
+ ruleset: RepositoryRuleset,
+ ): Promise;
+ update(
+ owner: string,
+ repository: string,
+ id: number,
+ ruleset: RepositoryRuleset,
+ ): Promise;
+}
+
+export type RulesetChange =
+ | { action: 'create'; desired: RepositoryRuleset }
+ | {
+ action: 'update';
+ id: number;
+ current: ExistingRepositoryRuleset;
+ desired: RepositoryRuleset;
+ }
+ | { action: 'unchanged'; current: ExistingRepositoryRuleset };
+
+export function planRepositoryRulesets(
+ existing: ExistingRepositoryRuleset[],
+ desiredRulesets: RepositoryRuleset[],
+): RulesetChange[] {
+ const byName = new Map(existing.map((ruleset) => [ruleset.name, ruleset]));
+
+ return desiredRulesets.map((desired): RulesetChange => {
+ const current = byName.get(desired.name);
+
+ if (!current) {
+ return { action: 'create', desired };
+ }
+
+ if (rulesetsEqual(current, desired)) {
+ return { action: 'unchanged', current };
+ }
+
+ return {
+ action: 'update',
+ id: current.id,
+ current,
+ desired,
+ };
+ });
+}
+
+export type ReconcileResult = {
+ created: string[];
+ updated: string[];
+ unchanged: string[];
+};
+
+export async function reconcileRepositoryRulesets(
+ client: RepositoryRulesetClient,
+ owner: string,
+ repository: string,
+ desiredRulesets: RepositoryRuleset[],
+): Promise {
+ const changes = planRepositoryRulesets(
+ await client.list(owner, repository),
+ desiredRulesets,
+ );
+
+ const result: ReconcileResult = {
+ created: [],
+ updated: [],
+ unchanged: [],
+ };
+
+ for (const change of changes) {
+ if (change.action === 'create') {
+ await client.create(owner, repository, change.desired);
+ result.created.push(change.desired.name);
+ continue;
+ }
+
+ if (change.action === 'update') {
+ await client.update(owner, repository, change.id, change.desired);
+ result.updated.push(change.desired.name);
+ continue;
+ }
+
+ result.unchanged.push(change.current.name);
+ }
+
+ return result;
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..7e43ee8
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+export type BypassActor = {
+ actor_id: number;
+ actor_type: 'OrganizationAdmin' | 'User' | 'Team' | 'Integration';
+ bypass_mode: 'always' | 'pull_request';
+};
+
+export type PullRequestRule = {
+ type: 'pull_request';
+ parameters: {
+ allowed_merge_methods: Array<'merge' | 'squash' | 'rebase'>;
+ dismiss_stale_reviews_on_push: boolean;
+ require_code_owner_review: boolean;
+ require_last_push_approval: boolean;
+ required_approving_review_count: number;
+ required_review_thread_resolution: boolean;
+ };
+};
+
+export type RequiredStatusChecksRule = {
+ type: 'required_status_checks';
+ parameters: {
+ required_status_checks: Array<{ context: string }>;
+ strict_required_status_checks_policy: boolean;
+ do_not_enforce_on_create: boolean;
+ };
+};
+
+export type SimpleRule = {
+ type: 'deletion' | 'non_fast_forward';
+};
+
+export type RulesetRule = PullRequestRule | RequiredStatusChecksRule | SimpleRule;
+
+export type RepositoryRuleset = {
+ name: string;
+ target: 'branch';
+ enforcement: 'active' | 'disabled' | 'evaluate';
+ bypass_actors: BypassActor[];
+ conditions: {
+ ref_name: {
+ include: string[];
+ exclude: string[];
+ };
+ };
+ rules: RulesetRule[];
+};
+
+export type RepositoryClassification = {
+ owner: string;
+ name: string;
+ isPublic: boolean;
+ isArchived: boolean;
+ isNextcloudApp: boolean;
+};
+
+export type PolicySet = {
+ rulesets: RepositoryRuleset[];
+};
diff --git a/tests/cli-runner.test.ts b/tests/cli-runner.test.ts
new file mode 100644
index 0000000..93becf1
--- /dev/null
+++ b/tests/cli-runner.test.ts
@@ -0,0 +1,199 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { runCli } from '../src/cli-runner.js';
+import type { GovernanceClient } from '../src/governance.js';
+import type { RepositoryMetadata } from '../src/repository-classifier.js';
+import type {
+ ExistingRepositoryRuleset,
+} from '../src/ruleset-reconciler.js';
+import type { RepositoryRuleset } from '../src/types.js';
+
+class FakeClient implements GovernanceClient {
+ constructor(
+ private readonly existing: ExistingRepositoryRuleset[] = [],
+ ) {}
+
+ async getRepository(
+ owner: string,
+ repository: string,
+ ): Promise {
+ return {
+ owner,
+ name: repository,
+ visibility: 'public',
+ archived: false,
+ };
+ }
+
+ async listManagedRepositories(): Promise {
+ return [
+ {
+ owner: 'LibreSign',
+ name: 'documentation',
+ visibility: 'public',
+ archived: false,
+ },
+ ];
+ }
+
+ async exists(): Promise {
+ return false;
+ }
+
+ async list(): Promise {
+ return structuredClone(this.existing);
+ }
+
+ async create(
+ _owner: string,
+ _repository: string,
+ _ruleset: RepositoryRuleset,
+ ): Promise {}
+
+ async update(
+ _owner: string,
+ _repository: string,
+ _id: number,
+ _ruleset: RepositoryRuleset,
+ ): Promise {}
+}
+
+describe('runCli', () => {
+ it('requires an organization', async () => {
+ const errors: string[] = [];
+
+ const code = await runCli(
+ [],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: () => undefined,
+ error: (message) => errors.push(message),
+ },
+ );
+
+ expect(code).toBe(2);
+ expect(errors).toEqual([
+ 'Specify exactly one of --org OWNER or --repo OWNER/REPO',
+ ]);
+ });
+
+ it('requires a GitHub token', async () => {
+ const errors: string[] = [];
+
+ const code = await runCli(
+ ['--org', 'LibreSign'],
+ {},
+ () => new FakeClient(),
+ {
+ log: () => undefined,
+ error: (message) => errors.push(message),
+ },
+ );
+
+ expect(code).toBe(2);
+ expect(errors).toEqual(['GITHUB_TOKEN is required']);
+ });
+
+ it('returns drift status without applying by default', async () => {
+ const messages: string[] = [];
+
+ const code = await runCli(
+ ['--org', 'LibreSign'],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: (message) => messages.push(message),
+ error: () => undefined,
+ },
+ );
+
+ expect(code).toBe(1);
+ expect(messages[0]).toContain('DRIFT LibreSign/documentation');
+ });
+
+ it('supports repository-scoped dry-run', async () => {
+ const messages: string[] = [];
+
+ const code = await runCli(
+ ['--repo', 'LibreSign/documentation'],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: (message) => messages.push(message),
+ error: () => undefined,
+ },
+ );
+
+ expect(code).toBe(1);
+ expect(messages[0]).toContain('DRIFT LibreSign/documentation');
+ });
+
+ it('rejects malformed repository selectors', async () => {
+ const errors: string[] = [];
+
+ const code = await runCli(
+ ['--repo', 'LibreSign/documentation/extra'],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: () => undefined,
+ error: (message) => errors.push(message),
+ },
+ );
+
+ expect(code).toBe(2);
+ expect(errors).toEqual(['--repo must use OWNER/REPO']);
+ });
+
+ it('applies repository-specific config selected by the caller', async () => {
+ const messages: string[] = [];
+
+ const code = await runCli(
+ [
+ '--repo',
+ 'LibreCodeCoop/github-governance',
+ '--config',
+ 'governance.config.json',
+ ],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: (message) => messages.push(message),
+ error: () => undefined,
+ },
+ async () => ({
+ repositories: {
+ 'github-governance': {
+ policies: ['governance-ci'],
+ },
+ },
+ }),
+ );
+
+ expect(code).toBe(1);
+ expect(messages).toContain(
+ ' - create: Protect default and stable branches',
+ );
+ expect(messages).toContain(' - create: Require governance CI');
+ });
+
+ it('uses apply mode only when explicitly requested', async () => {
+ const messages: string[] = [];
+
+ const code = await runCli(
+ ['--org', 'LibreSign', '--apply'],
+ { GITHUB_TOKEN: 'token' },
+ () => new FakeClient(),
+ {
+ log: (message) => messages.push(message),
+ error: () => undefined,
+ },
+ );
+
+ expect(code).toBe(0);
+ expect(messages[0]).toContain('APPLIED LibreSign/documentation');
+ });
+});
diff --git a/tests/config.test.ts b/tests/config.test.ts
new file mode 100644
index 0000000..5728b26
--- /dev/null
+++ b/tests/config.test.ts
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { resolveExtraRulesets } from '../src/config.js';
+
+describe('resolveExtraRulesets', () => {
+ it('resolves shared named policies without duplicating their ruleset body', () => {
+ const rulesets = resolveExtraRulesets(
+ {
+ repositories: {
+ 'github-governance': {
+ policies: ['governance-ci'],
+ },
+ },
+ },
+ 'github-governance',
+ );
+
+ expect(rulesets.map(({ name }) => name)).toEqual([
+ 'Require governance CI',
+ ]);
+ });
+
+ it('supports repository-local rulesets for exceptional requirements', () => {
+ const rulesets = resolveExtraRulesets(
+ {
+ repositories: {
+ '.github': {
+ rulesets: [
+ {
+ name: 'Organization repository CI',
+ target: 'branch',
+ enforcement: 'active',
+ bypass_actors: [],
+ conditions: {
+ ref_name: {
+ include: ['~DEFAULT_BRANCH'],
+ exclude: [],
+ },
+ },
+ rules: [],
+ },
+ ],
+ },
+ },
+ },
+ '.github',
+ );
+
+ expect(rulesets[0]?.name).toBe('Organization repository CI');
+ });
+
+ it('fails closed for an unknown shared policy', () => {
+ expect(() =>
+ resolveExtraRulesets(
+ {
+ repositories: {
+ repo: {
+ policies: ['unknown-policy'],
+ },
+ },
+ },
+ 'repo',
+ ),
+ ).toThrow('Unknown governance policy');
+ });
+
+ it('returns no extra rulesets when the repository has no override', () => {
+ expect(resolveExtraRulesets({}, 'libresign')).toEqual([]);
+ });
+});
diff --git a/tests/github-client.test.ts b/tests/github-client.test.ts
new file mode 100644
index 0000000..b696f36
--- /dev/null
+++ b/tests/github-client.test.ts
@@ -0,0 +1,221 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { GitHubClient } from '../src/github-client.js';
+import { baseRuleset } from '../src/policies.js';
+
+type ExpectedRequest = {
+ url: string;
+ method?: string;
+ response: Response;
+};
+
+function fakeFetch(expectations: ExpectedRequest[]): typeof fetch {
+ return async (input, init) => {
+ const next = expectations.shift();
+ if (!next) {
+ throw new Error(`Unexpected request: ${String(input)}`);
+ }
+
+ expect(String(input)).toBe(next.url);
+ expect(init?.method ?? 'GET').toBe(next.method ?? 'GET');
+ return next.response;
+ };
+}
+
+describe('GitHubClient', () => {
+ it('loads repository metadata for repository-scoped execution', async () => {
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign',
+ response: Response.json({
+ name: 'libresign',
+ owner: { login: 'LibreSign' },
+ visibility: 'public',
+ archived: false,
+ }),
+ },
+ ];
+
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ await expect(
+ client.getRepository('LibreSign', 'libresign'),
+ ).resolves.toEqual({
+ owner: 'LibreSign',
+ name: 'libresign',
+ visibility: 'public',
+ archived: false,
+ });
+ expect(requests).toHaveLength(0);
+ });
+
+ it('lists only public non-archived repositories from the selected organization', async () => {
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/installation/repositories?per_page=100&page=1',
+ response: Response.json({
+ repositories: [
+ {
+ name: 'libresign',
+ owner: { login: 'LibreSign' },
+ visibility: 'public',
+ archived: false,
+ },
+ {
+ name: 'archive',
+ owner: { login: 'LibreSign' },
+ visibility: 'public',
+ archived: true,
+ },
+ {
+ name: 'private',
+ owner: { login: 'LibreSign' },
+ visibility: 'private',
+ archived: false,
+ },
+ {
+ name: 'other',
+ owner: { login: 'OtherOrg' },
+ visibility: 'public',
+ archived: false,
+ },
+ ],
+ }),
+ },
+ ];
+
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ await expect(client.listManagedRepositories('LibreSign')).resolves.toEqual([
+ {
+ owner: 'LibreSign',
+ name: 'libresign',
+ visibility: 'public',
+ archived: false,
+ },
+ ]);
+ expect(requests).toHaveLength(0);
+ });
+
+ it('returns false only for a 404 content probe', async () => {
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/repos/LibreSign/documentation/contents/appinfo/info.xml',
+ response: new Response('', { status: 404 }),
+ },
+ ];
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ await expect(
+ client.exists('LibreSign', 'documentation', 'appinfo/info.xml'),
+ ).resolves.toBe(false);
+ });
+
+ it('fails closed for unexpected content probe errors', async () => {
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign/contents/appinfo/info.xml',
+ response: new Response('', { status: 403 }),
+ },
+ ];
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ await expect(
+ client.exists('LibreSign', 'libresign', 'appinfo/info.xml'),
+ ).rejects.toThrow('HTTP 403');
+ });
+
+ it('loads repository ruleset details and ignores organization-sourced rulesets', async () => {
+ const rulesetResponse = {
+ id: 7,
+ name: baseRuleset.name,
+ target: baseRuleset.target,
+ enforcement: baseRuleset.enforcement,
+ bypass_actors: [
+ {
+ actor_id: null,
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'pull_request',
+ },
+ ],
+ conditions: baseRuleset.conditions,
+ rules: baseRuleset.rules,
+ };
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign/rulesets?per_page=100',
+ response: Response.json([
+ { id: 7, source_type: 'Repository' },
+ { id: 8, source_type: 'Organization' },
+ ]),
+ },
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign/rulesets/7',
+ response: Response.json(rulesetResponse),
+ },
+ ];
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ const rulesets = await client.list('LibreSign', 'libresign');
+
+ expect(rulesets).toHaveLength(1);
+ expect(rulesets[0]).toMatchObject({
+ id: 7,
+ name: baseRuleset.name,
+ bypass_actors: [
+ {
+ actor_id: 0,
+ actor_type: 'OrganizationAdmin',
+ bypass_mode: 'pull_request',
+ },
+ ],
+ });
+ });
+
+ it('creates and updates repository rulesets with JSON bodies', async () => {
+ const requests: ExpectedRequest[] = [
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign/rulesets',
+ method: 'POST',
+ response: Response.json({}, { status: 201 }),
+ },
+ {
+ url: 'https://api.github.test/repos/LibreSign/libresign/rulesets/9',
+ method: 'PUT',
+ response: Response.json({}),
+ },
+ ];
+ const client = new GitHubClient(
+ 'token',
+ fakeFetch(requests),
+ 'https://api.github.test',
+ );
+
+ await client.create('LibreSign', 'libresign', baseRuleset);
+ await client.update('LibreSign', 'libresign', 9, baseRuleset);
+
+ expect(requests).toHaveLength(0);
+ });
+});
diff --git a/tests/governance.test.ts b/tests/governance.test.ts
new file mode 100644
index 0000000..92f68a4
--- /dev/null
+++ b/tests/governance.test.ts
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { planOrganization } from '../src/governance.js';
+import { baseRuleset } from '../src/policies.js';
+import type {
+ ExistingRepositoryRuleset,
+ RepositoryRulesetClient,
+} from '../src/ruleset-reconciler.js';
+import type {
+ GitHubContentProbe,
+ RepositoryMetadata,
+} from '../src/repository-classifier.js';
+import type { RepositoryRuleset } from '../src/types.js';
+
+class FakeGovernanceClient
+ implements GitHubContentProbe, RepositoryRulesetClient
+{
+ constructor(
+ private readonly repositories: RepositoryMetadata[],
+ private readonly nextcloudApps: Set,
+ private readonly existing: Map,
+ ) {}
+
+ async getRepository(owner: string, repository: string): Promise {
+ const match = this.repositories.find(
+ (candidate) => candidate.owner === owner && candidate.name === repository,
+ );
+ if (!match) {
+ throw new Error('repository not found');
+ }
+ return structuredClone(match);
+ }
+
+ async listManagedRepositories(): Promise {
+ return structuredClone(this.repositories);
+ }
+
+ async exists(owner: string, repository: string): Promise {
+ return this.nextcloudApps.has(`${owner}/${repository}`);
+ }
+
+ async list(
+ owner: string,
+ repository: string,
+ ): Promise {
+ return structuredClone(this.existing.get(`${owner}/${repository}`) ?? []);
+ }
+
+ async create(
+ _owner: string,
+ _repository: string,
+ _ruleset: RepositoryRuleset,
+ ): Promise {
+ throw new Error('not expected in planning');
+ }
+
+ async update(
+ _owner: string,
+ _repository: string,
+ _id: number,
+ _ruleset: RepositoryRuleset,
+ ): Promise {
+ throw new Error('not expected in planning');
+ }
+}
+
+describe('planOrganization', () => {
+ it('plans ordinary and Nextcloud repositories with shared policy rules', async () => {
+ const client = new FakeGovernanceClient(
+ [
+ {
+ owner: 'LibreSign',
+ name: 'documentation',
+ visibility: 'public',
+ archived: false,
+ },
+ {
+ owner: 'LibreSign',
+ name: 'libresign',
+ visibility: 'public',
+ archived: false,
+ },
+ ],
+ new Set(['LibreSign/libresign']),
+ new Map([
+ [
+ 'LibreSign/documentation',
+ [{ ...structuredClone(baseRuleset), id: 1 }],
+ ],
+ ]),
+ );
+
+ const plans = await planOrganization(client, 'LibreSign');
+
+ expect(plans).toHaveLength(2);
+ expect(plans[0]).toMatchObject({
+ repository: 'LibreSign/documentation',
+ isNextcloudApp: false,
+ changes: [{ action: 'unchanged' }],
+ });
+ expect(plans[1]).toMatchObject({
+ repository: 'LibreSign/libresign',
+ isNextcloudApp: true,
+ changes: [{ action: 'create' }],
+ });
+ });
+});
diff --git a/tests/policy-composer.test.ts b/tests/policy-composer.test.ts
new file mode 100644
index 0000000..e4cc2c9
--- /dev/null
+++ b/tests/policy-composer.test.ts
@@ -0,0 +1,138 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { NEXTCLOUD_BOT_ID } from '../src/policies.js';
+import { composeRepositoryPolicy } from '../src/policy-composer.js';
+
+describe('composeRepositoryPolicy', () => {
+ it('protects default and stable branches for public repositories', () => {
+ const policy = composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'documentation',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: false,
+ });
+
+ expect(policy.rulesets).toHaveLength(1);
+ expect(policy.rulesets[0]?.conditions.ref_name.include).toEqual([
+ '~DEFAULT_BRANCH',
+ 'refs/heads/stable*',
+ ]);
+ });
+
+ it('adds the pinned nextcloud-bot bypass to Nextcloud apps', () => {
+ const policy = composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'libresign',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: true,
+ });
+
+ expect(
+ policy.rulesets[0]?.bypass_actors.filter(
+ (actor) =>
+ actor.actor_type === 'User' &&
+ actor.actor_id === NEXTCLOUD_BOT_ID &&
+ actor.bypass_mode === 'always',
+ ),
+ ).toHaveLength(1);
+ });
+
+ it('does not add the Nextcloud bot bypass to ordinary repositories', () => {
+ const policy = composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'documentation',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: false,
+ });
+
+ expect(
+ policy.rulesets[0]?.bypass_actors.some(
+ (actor) =>
+ actor.actor_type === 'User' && actor.actor_id === NEXTCLOUD_BOT_ID,
+ ),
+ ).toBe(false);
+ });
+
+ it('does not manage private repositories', () => {
+ expect(
+ composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'private',
+ isPublic: false,
+ isArchived: false,
+ isNextcloudApp: false,
+ }).rulesets,
+ ).toEqual([]);
+ });
+
+ it('does not manage archived repositories', () => {
+ expect(
+ composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'archive',
+ isPublic: true,
+ isArchived: true,
+ isNextcloudApp: false,
+ }).rulesets,
+ ).toEqual([]);
+ });
+
+ it('adds repository-specific rulesets only when explicitly selected', () => {
+ const extraRuleset = {
+ name: 'Repository-specific CI',
+ target: 'branch' as const,
+ enforcement: 'active' as const,
+ bypass_actors: [],
+ conditions: {
+ ref_name: {
+ include: ['~DEFAULT_BRANCH'],
+ exclude: [],
+ },
+ },
+ rules: [],
+ };
+
+ const policy = composeRepositoryPolicy(
+ {
+ owner: 'LibreSign',
+ name: '.github',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: false,
+ },
+ {
+ extraRulesets: [extraRuleset],
+ },
+ );
+
+ expect(policy.rulesets.map((ruleset) => ruleset.name)).toEqual([
+ 'Protect default and stable branches',
+ 'Repository-specific CI',
+ ]);
+ });
+
+ it('does not mutate shared policies between calls', () => {
+ const nextcloudPolicy = composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'libresign',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: true,
+ });
+ const ordinaryPolicy = composeRepositoryPolicy({
+ owner: 'LibreSign',
+ name: 'documentation',
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: false,
+ });
+
+ expect(nextcloudPolicy.rulesets[0]?.bypass_actors).toHaveLength(2);
+ expect(ordinaryPolicy.rulesets[0]?.bypass_actors).toHaveLength(1);
+ });
+});
diff --git a/tests/repository-classifier.test.ts b/tests/repository-classifier.test.ts
new file mode 100644
index 0000000..160471e
--- /dev/null
+++ b/tests/repository-classifier.test.ts
@@ -0,0 +1,71 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import {
+ classifyRepository,
+ type GitHubContentProbe,
+} from '../src/repository-classifier.js';
+
+class FakeProbe implements GitHubContentProbe {
+ constructor(
+ private readonly result: boolean | Error,
+ ) {}
+
+ async exists(): Promise {
+ if (this.result instanceof Error) {
+ throw this.result;
+ }
+ return this.result;
+ }
+}
+
+describe('classifyRepository', () => {
+ it('detects a Nextcloud app from appinfo/info.xml', async () => {
+ await expect(
+ classifyRepository(
+ {
+ owner: 'LibreSign',
+ name: 'libresign',
+ visibility: 'public',
+ archived: false,
+ },
+ new FakeProbe(true),
+ ),
+ ).resolves.toMatchObject({
+ isPublic: true,
+ isArchived: false,
+ isNextcloudApp: true,
+ });
+ });
+
+ it('treats a missing appinfo/info.xml as a non-Nextcloud repository', async () => {
+ await expect(
+ classifyRepository(
+ {
+ owner: 'LibreSign',
+ name: 'documentation',
+ visibility: 'public',
+ archived: false,
+ },
+ new FakeProbe(false),
+ ),
+ ).resolves.toMatchObject({
+ isNextcloudApp: false,
+ });
+ });
+
+ it('fails closed when Nextcloud detection fails unexpectedly', async () => {
+ await expect(
+ classifyRepository(
+ {
+ owner: 'LibreSign',
+ name: 'libresign',
+ visibility: 'public',
+ archived: false,
+ },
+ new FakeProbe(new Error('HTTP 403')),
+ ),
+ ).rejects.toThrow('HTTP 403');
+ });
+});
diff --git a/tests/ruleset-normalizer.test.ts b/tests/ruleset-normalizer.test.ts
new file mode 100644
index 0000000..f89ec7a
--- /dev/null
+++ b/tests/ruleset-normalizer.test.ts
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { baseRuleset, governanceRepositoryCiRuleset } from '../src/policies.js';
+import { normalizeRuleset, rulesetsEqual } from '../src/ruleset-normalizer.js';
+
+describe('normalizeRuleset', () => {
+ it('normalizes organization admin actor IDs returned by GitHub', () => {
+ const current = structuredClone(baseRuleset);
+ current.bypass_actors[0]!.actor_id = 999;
+
+ expect(rulesetsEqual(current, baseRuleset)).toBe(true);
+ });
+
+ it('preserves required status checks as managed policy', () => {
+ const normalized = normalizeRuleset(governanceRepositoryCiRuleset);
+ const rule = normalized.rules.find(
+ (candidate) => candidate.type === 'required_status_checks',
+ );
+
+ expect(rule).toMatchObject({
+ type: 'required_status_checks',
+ parameters: {
+ strict_required_status_checks_policy: true,
+ },
+ });
+
+ if (rule?.type !== 'required_status_checks') {
+ throw new Error('required_status_checks rule was not found');
+ }
+
+ expect(rule.parameters.required_status_checks.map(({ context }) => context)).toEqual([
+ 'Governance policy tests',
+ 'TypeScript',
+ ]);
+ });
+});
diff --git a/tests/ruleset-reconciler.test.ts b/tests/ruleset-reconciler.test.ts
new file mode 100644
index 0000000..808d742
--- /dev/null
+++ b/tests/ruleset-reconciler.test.ts
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest';
+import { baseRuleset } from '../src/policies.js';
+import {
+ reconcileRepositoryRulesets,
+ type ExistingRepositoryRuleset,
+ type RepositoryRulesetClient,
+} from '../src/ruleset-reconciler.js';
+import type { RepositoryRuleset } from '../src/types.js';
+
+class FakeClient implements RepositoryRulesetClient {
+ readonly created: RepositoryRuleset[] = [];
+ readonly updated: Array<{ id: number; ruleset: RepositoryRuleset }> = [];
+
+ constructor(private readonly existing: ExistingRepositoryRuleset[]) {}
+
+ async list(): Promise {
+ return structuredClone(this.existing);
+ }
+
+ async create(
+ _owner: string,
+ _repository: string,
+ ruleset: RepositoryRuleset,
+ ): Promise {
+ this.created.push(structuredClone(ruleset));
+ }
+
+ async update(
+ _owner: string,
+ _repository: string,
+ id: number,
+ ruleset: RepositoryRuleset,
+ ): Promise {
+ this.updated.push({ id, ruleset: structuredClone(ruleset) });
+ }
+}
+
+describe('reconcileRepositoryRulesets', () => {
+ it('creates a missing managed ruleset', async () => {
+ const client = new FakeClient([]);
+
+ const result = await reconcileRepositoryRulesets(
+ client,
+ 'LibreSign',
+ 'documentation',
+ [baseRuleset],
+ );
+
+ expect(result.created).toEqual(['Protect default and stable branches']);
+ expect(client.created).toHaveLength(1);
+ });
+
+ it('does not update an equivalent ruleset', async () => {
+ const client = new FakeClient([{ ...structuredClone(baseRuleset), id: 7 }]);
+
+ const result = await reconcileRepositoryRulesets(
+ client,
+ 'LibreSign',
+ 'documentation',
+ [baseRuleset],
+ );
+
+ expect(result.unchanged).toEqual(['Protect default and stable branches']);
+ expect(client.updated).toEqual([]);
+ });
+
+ it('updates a managed ruleset when policy drifts', async () => {
+ const current = structuredClone(baseRuleset);
+ const pullRequestRule = current.rules.find(
+ (rule) => rule.type === 'pull_request',
+ );
+ if (pullRequestRule?.type !== 'pull_request') {
+ throw new Error('pull_request rule was not found');
+ }
+ pullRequestRule.parameters.required_approving_review_count = 0;
+
+ const client = new FakeClient([{ ...current, id: 9 }]);
+
+ const result = await reconcileRepositoryRulesets(
+ client,
+ 'LibreSign',
+ 'documentation',
+ [baseRuleset],
+ );
+
+ expect(result.updated).toEqual(['Protect default and stable branches']);
+ expect(client.updated).toEqual([
+ { id: 9, ruleset: baseRuleset },
+ ]);
+ });
+
+ it('does not delete unrelated rulesets', async () => {
+ const unrelated: ExistingRepositoryRuleset = {
+ ...structuredClone(baseRuleset),
+ id: 11,
+ name: 'Repository-specific policy',
+ };
+ const client = new FakeClient([unrelated]);
+
+ await reconcileRepositoryRulesets(
+ client,
+ 'LibreSign',
+ 'documentation',
+ [baseRuleset],
+ );
+
+ expect(client.created).toHaveLength(1);
+ expect(client.updated).toHaveLength(0);
+ });
+});
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..7168055
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["tests/**/*.ts"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c31561e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
+}