41 Commits

Author SHA1 Message Date
eric
ac9f784602 chore(release): v4.0.0 2026-03-21 01:52:51 +01:00
eric
4565a71863 chore: update template 2026-03-21 01:52:22 +01:00
eric
3b38bc8f38 chore(release): v3.6.2 2026-03-21 01:43:45 +01:00
eric
a5ab22f426 fix: update steps 2026-03-21 01:43:20 +01:00
eric
54b54fdece fix: typo 2026-03-21 01:42:48 +01:00
eric
60d0c27db7 fix: typo 2026-03-21 01:42:38 +01:00
eric
b6528466e0 feat: update tui 2026-03-21 01:40:42 +01:00
eric
146b1e9501 chore(release): v3.5.1 2026-03-21 01:27:56 +01:00
eric
8fec37023f feat: modernize 2026-03-21 01:27:42 +01:00
eric
e9d04c7f8d chore(release): v3.5.0 2026-03-16 17:42:57 +01:00
eric
ece1dffb39 feat: add monorepo to template 2026-03-16 17:42:50 +01:00
eric
71c7fe09cd chore(release): v3.4.0 2026-03-15 17:15:23 +01:00
eric
45f3830794 ci: limit lefthook logging 2026-03-15 17:14:25 +01:00
eric
b8d0a69d4d fix: lefthook logging 2026-03-15 17:10:26 +01:00
eric
c5f8ee6005 chore(release): v3.3.0 2026-03-15 16:55:02 +01:00
eric
9983f0b8e9 feat: expose more options for lefthook 2026-03-15 16:51:49 +01:00
eric
0d339e2de0 chore(release): v3.2.0 2026-03-15 16:31:43 +01:00
eric
7dcb0d1b3a feat: replace githooks with lefthook 2026-03-15 16:31:32 +01:00
eric
f8658265ae chore(release): v3.1.0 2026-03-15 15:50:04 +01:00
eric
c42899c89e feat: add icons and command tools (like nix) 2026-03-15 15:49:54 +01:00
eric
00fb6ef297 feat: add icons and command tools (like nix) 2026-03-15 15:48:51 +01:00
eric
dc475afcd1 chore(release): v3.0.0 2026-03-12 18:55:20 +01:00
eric
96d2d19046 chore(release): v3.0.0 2026-03-07 07:51:15 +01:00
eric
976fc8c1a7 fix: release parser 2026-03-07 07:51:08 +01:00
eric
30029e5954 fix: release parser 2026-03-07 07:49:40 +01:00
eric
9edb042e69 fix: release parser 2026-03-07 07:42:44 +01:00
eric
198b0bb1b0 feat: upgrade the lib interface 2026-03-07 07:39:39 +01:00
eric
00a9ab240a chore(release): v2.1.0 2026-03-07 06:54:41 +01:00
eric
53e498ca45 feat: add option to install tool 2026-03-07 06:54:30 +01:00
eric
80cc529de7 chore(release): v2.0.1 2026-03-06 17:37:39 +01:00
eric
4d2579ae1e fix: don't run tests in consumers of the lib 2026-03-06 17:37:27 +01:00
eric
1399862dec chore(release): v2.0.0 2026-03-06 17:20:32 +01:00
eric
ba4a992474 feat: add randomized testing 2026-03-06 17:20:20 +01:00
eric
aa4a050390 chore(release): v1.0.7 2026-03-05 01:04:48 +01:00
eric
b7558a4218 fix: run treefmt in ci mode 2026-03-05 01:04:34 +01:00
eric
f7dce637d5 chore(release): v1.0.6 2026-03-04 18:46:15 +01:00
eric
250882da1f test: ensure versions are consistent 2026-03-04 18:45:55 +01:00
eric
e445e49baf chore(release): v1.0.5 2026-03-04 09:35:15 +01:00
eric
ef3cf30a34 fix: expose releases as well 2026-03-04 09:35:05 +01:00
eric
86a0792b6e chore(release): v1.0.4 2026-03-04 08:48:24 +01:00
eric
d1aea76dd9 fix: formatting 2026-03-04 08:48:16 +01:00
82 changed files with 6155 additions and 1102 deletions

1
.envrc
View File

@@ -1 +1,2 @@
watch_file flake.nix
use flake

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.pre-commit-config.yaml
lefthook.yml
.direnv
result
template/flake.lock

1
.tools/bun/bin/moon Symbolic link
View File

@@ -0,0 +1 @@
../install/global/node_modules/@moonrepo/cli/moon.js

1
.tools/bun/bin/moonx Symbolic link
View File

@@ -0,0 +1 @@
../install/global/node_modules/@moonrepo/cli/moonx.js

View File

@@ -0,0 +1,28 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@moonrepo/cli": "^2.0.4",
},
},
},
"packages": {
"@moonrepo/cli": ["@moonrepo/cli@2.0.4", "", { "dependencies": { "detect-libc": "^2.1.2" }, "optionalDependencies": { "@moonrepo/core-linux-arm64-gnu": "2.0.4", "@moonrepo/core-linux-arm64-musl": "2.0.4", "@moonrepo/core-linux-x64-gnu": "2.0.4", "@moonrepo/core-linux-x64-musl": "2.0.4", "@moonrepo/core-macos-arm64": "2.0.4", "@moonrepo/core-windows-x64-msvc": "2.0.4" }, "bin": { "moon": "moon.js", "moonx": "moonx.js" } }, "sha512-cP62Fa7hzToEi0I2i3Gx7zhPPdimVCeW8WqbSlE5y6fhjH38g1N77vZg4A6rcauDEUl/cpA5+vWQyxm4KCsqUA=="],
"@moonrepo/core-linux-arm64-gnu": ["@moonrepo/core-linux-arm64-gnu@2.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pvMgTfYUN8ARvhOgVumR1j1XQY0V+59qz+Bi9BBmgOJsaB/QosGGWkCfdV2dChpzfE9AELnjIBgrazUGcOX9KA=="],
"@moonrepo/core-linux-arm64-musl": ["@moonrepo/core-linux-arm64-musl@2.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lF+KuN8ymTR+kynI7OU+CN//V6hSGL9eq8+7VUVFY3cRobyzjEulB7xkPgVYH2C81E8TUK2tp1R79Y8nOgEHaA=="],
"@moonrepo/core-linux-x64-gnu": ["@moonrepo/core-linux-x64-gnu@2.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-0auEy/jyMm5vjIZy/dLdrIoOJ0hnoxcEk+veBGwZatS65dSKEvXdUYrYSGWTRvykbsXDBTAaXFUtj1enGzIXmg=="],
"@moonrepo/core-linux-x64-musl": ["@moonrepo/core-linux-x64-musl@2.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h7b7uw8GdhTyZg2R7rHA04mqBIvYvcHtgtVBUXwwZQlh6jprFFtxuvehAdK32PN8sxygwRa+AdTfgq3vZHXFmw=="],
"@moonrepo/core-macos-arm64": ["@moonrepo/core-macos-arm64@2.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gz2FO0xRHUOXQjzBAz97S4pShL0YbTeNWLdCuRyjX8SNU1l82TX+l20e6OKfyt6jThWvCZeSiolx7W02xDT+iA=="],
"@moonrepo/core-windows-x64-msvc": ["@moonrepo/core-windows-x64-msvc@2.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-LjwpvjWTeusQjpqLNK7N7tVr/IQSd0W8v0L7fUIKBXNGmuONhDCHwgDqSs3yVzwcQMluiHM2qwu9YSKIEVfTIw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
}
}

View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2021 moonrepo, Inc., Miles Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,104 @@
# @moonrepo/cli
The official CLI for [moon](https://moonrepo.dev), a build system and repo management tool for the
web ecosystem, written in Rust! Supports JavaScript, TypeScript, Bash, Rust, Go, and much more!
- [Documentation](https://moonrepo.dev/docs)
- [Getting started](https://moonrepo.dev/docs/install)
- [Feature comparison](https://moonrepo.dev/docs/comparison)
- [FAQ](https://moonrepo.dev/docs/faq)
## Installation
moon can be installed with bash:
```shell
curl -fsSL https://moonrepo.dev/install/moon.sh | bash
```
Or with npm, pnpm, or yarn.
```shell
yarn add --dev @moonrepo/cli
```
Once installed, initialize moon in your repository.
```shell
moon init
```
## Usage
Once [projects](https://moonrepo.dev/docs/create-project) and
[tasks](https://moonrepo.dev/docs/create-task) have been configured, tasks can be ran with:
```bash
# Run `lint` in project `app`
moon run app:lint
# Run `lint` in all projects
moon run :lint
```
## Why use moon?
Working in the JavaScript ecosystem can be very involved, especially when it comes to managing a
repository effectively. Which package manager to use? Which Node.js version to use? How to import
node modules? How to build packages? So on and so forth. moon aims to streamline this entire process
and provide a first-class developer experience.
- **Increased productivity** - With [Rust](https://www.rust-lang.org/) as our foundation, we can
ensure robust speeds, high performance, and low memory usage. Instead of long builds blocking you,
focus on your work.
- **Exceptional developer experience** - As veterans of the JavaScript ecosystem, we're well aware
of the pain points and frustrations. Our goal is to mitigate and overcome these obstacles.
- **Incremental adoption** - At its core, moon has been designed to be adopted incrementally and is
_not_ an "all at once adoption". Migrate project-by-project, or task-by-task, it's up to you!
- **Reduced scripts confusion** - `package.json` scripts can become unwieldy, very quickly. No more
duplicating the same script into every package, or reverse-engineering which root scripts to use.
With moon, all you need to know is the project name, and a task name.
- **Ensure correct versions** - Whether it's Node.js or npm, ensure the same version of each tool is
the same across _every_ developer's environment. No more wasted hours of debugging.
- **Automation built-in** - When applicable, moon will automatically install `node_modules`, or sync
package dependencies, or even sync TypeScript project references.
- And of course, the amazing list of features below!
## Features
> Not all features are currently supported, view the documentation for an accurate list!
#### Management
- **Smart hashing** - Collects inputs from multiple sources to ensure builds are deterministic and
reproducible.
- **Remote caching** - Persists builds, hashes, and caches between teammates and CI/CD environments.
- **Integrated toolchain** - Automatically downloads and installs explicit versions of Node.js and
other tools for consistency across the entire workspace or per project.
- **Multi-platform** - Runs on common development platforms: Linux, macOS, and Windows.
#### Organization
- **Project graph** - Generates a project graph for dependency and dependent relationships.
- **Code generation** - Easily scaffold new applications, libraries, tooling, and more!
- **Dependency workspaces** - Works alongside package manager workspaces so that projects have
distinct dependency trees.
- **Code ownership** - Declare owners, maintainers, support channels, and more. Generate CODEOWNERS.
#### Orchestration
- **Dependency graph** - Generates a dependency graph to increase performance and reduce workloads.
- **Action pipeline** - Executes actions in parallel and in order using a thread pool and our
dependency graph.
- **Action distribution** - Distributes actions across multiple machines to increase throughput.
- **Incremental builds** - With our smart hashing, only rebuild projects that have been changed
since the last build.
#### Notification
- **Flakiness detection** - Reduce flaky builds with automatic retries and passthrough settings.
- **Webhook events** - Receive a webhook for every event in the pipeline. Useful for metrics
gathering and insights.
- **Terminal notifications** - Receives notifications in your chosen terminal when builds are
successful... or are not.
- **Git hooks** - Manage Git hooks to enforce workflows and requirements for contributors.

15
.tools/bun/install/global/node_modules/@moonrepo/cli/moon.js generated vendored Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
const cp = require('child_process');
const { findMoonExe } = require('./utils');
const result = cp.spawnSync(findMoonExe(), process.argv.slice(2), {
shell: false,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exitCode = result.status;

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
const cp = require('child_process');
const { findMoonxExe } = require('./utils');
const result = cp.spawnSync(findMoonxExe(), process.argv.slice(2), {
shell: false,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exitCode = result.status;

View File

@@ -0,0 +1,39 @@
{
"name": "@moonrepo/cli",
"version": "2.0.4",
"type": "commonjs",
"description": "moon command line and core system.",
"keywords": [
"moon",
"repo",
"cli",
"core"
],
"files": [
"moon.js",
"moonx.js",
"utils.js"
],
"author": "Miles Johnson",
"license": "MIT",
"bin": {
"moon": "moon.js",
"moonx": "moonx.js"
},
"repository": {
"type": "git",
"url": "https://github.com/moonrepo/moon",
"directory": "packages/cli"
},
"dependencies": {
"detect-libc": "^2.1.2"
},
"optionalDependencies": {
"@moonrepo/core-linux-arm64-gnu": "2.0.4",
"@moonrepo/core-linux-arm64-musl": "2.0.4",
"@moonrepo/core-linux-x64-gnu": "2.0.4",
"@moonrepo/core-linux-x64-musl": "2.0.4",
"@moonrepo/core-macos-arm64": "2.0.4",
"@moonrepo/core-windows-x64-msvc": "2.0.4"
}
}

View File

@@ -0,0 +1,51 @@
const fs = require('fs');
const path = require('path');
const isLinux = process.platform === 'linux';
const isMacos = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const platform = isWindows ? 'windows' : isMacos ? 'macos' : process.platform;
const arch =
process.env['npm_config_user_agent'] && process.env['npm_config_user_agent'].match(/^bun.*arm64$/)
? 'arm64'
: process.arch; // https://github.com/moonrepo/moon/issues/1103
const parts = [platform, arch];
if (isLinux) {
const { familySync } = require('detect-libc');
if (familySync() === 'musl') {
parts.push('musl');
// } else if (process.arch === 'arm') {
// parts.push('gnueabihf');
} else {
parts.push('gnu');
}
} else if (isWindows) {
parts.push('msvc');
}
const triple = parts.join('-');
function findExe(name) {
const pkgPath = require.resolve(`@moonrepo/core-${triple}/package.json`);
const exePath = path.join(path.dirname(pkgPath), isWindows ? `${name}.exe` : name);
if (fs.existsSync(exePath)) {
return exePath;
}
throw new Error(`moon executable "${exePath}" not found!`);
}
function findMoonExe() {
return findExe('moon');
}
function findMoonxExe() {
return findExe('moonx');
}
exports.findMoonExe = findMoonExe;
exports.findMoonxExe = findMoonxExe;

View File

@@ -0,0 +1,3 @@
# @moonrepo/core-macos-arm64
This is the `aarch64-apple-darwin` binary for `@moonrepo/cli`.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,32 @@
{
"name": "@moonrepo/core-macos-arm64",
"version": "2.0.4",
"description": "macOS ARM64 (Silicon) binary for moon.",
"keywords": [
"moon",
"repo",
"macos",
"darwin",
"arm64"
],
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"author": "Miles Johnson",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/moonrepo/moon",
"directory": "packages/core-macos-arm64"
},
"publishConfig": {
"access": "public",
"executableFiles": [
"moon",
"moonx"
]
}
}

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,163 @@
# detect-libc
Node.js module to detect details of the C standard library (libc)
implementation provided by a given Linux system.
Currently supports detection of GNU glibc and MUSL libc.
Provides asychronous and synchronous functions for the
family (e.g. `glibc`, `musl`) and version (e.g. `1.23`, `1.2.3`).
The version numbers of libc implementations
are not guaranteed to be semver-compliant.
For previous v1.x releases, please see the
[v1](https://github.com/lovell/detect-libc/tree/v1) branch.
## Install
```sh
npm install detect-libc
```
## API
### GLIBC
```ts
const GLIBC: string = 'glibc';
```
A String constant containing the value `glibc`.
### MUSL
```ts
const MUSL: string = 'musl';
```
A String constant containing the value `musl`.
### family
```ts
function family(): Promise<string | null>;
```
Resolves asychronously with:
* `glibc` or `musl` when the libc family can be determined
* `null` when the libc family cannot be determined
* `null` when run on a non-Linux platform
```js
const { family, GLIBC, MUSL } = require('detect-libc');
switch (await family()) {
case GLIBC: ...
case MUSL: ...
case null: ...
}
```
### familySync
```ts
function familySync(): string | null;
```
Synchronous version of `family()`.
```js
const { familySync, GLIBC, MUSL } = require('detect-libc');
switch (familySync()) {
case GLIBC: ...
case MUSL: ...
case null: ...
}
```
### version
```ts
function version(): Promise<string | null>;
```
Resolves asychronously with:
* The version when it can be determined
* `null` when the libc family cannot be determined
* `null` when run on a non-Linux platform
```js
const { version } = require('detect-libc');
const v = await version();
if (v) {
const [major, minor, patch] = v.split('.');
}
```
### versionSync
```ts
function versionSync(): string | null;
```
Synchronous version of `version()`.
```js
const { versionSync } = require('detect-libc');
const v = versionSync();
if (v) {
const [major, minor, patch] = v.split('.');
}
```
### isNonGlibcLinux
```ts
function isNonGlibcLinux(): Promise<boolean>;
```
Resolves asychronously with:
* `false` when the libc family is `glibc`
* `true` when the libc family is not `glibc`
* `false` when run on a non-Linux platform
```js
const { isNonGlibcLinux } = require('detect-libc');
if (await isNonGlibcLinux()) { ... }
```
### isNonGlibcLinuxSync
```ts
function isNonGlibcLinuxSync(): boolean;
```
Synchronous version of `isNonGlibcLinux()`.
```js
const { isNonGlibcLinuxSync } = require('detect-libc');
if (isNonGlibcLinuxSync()) { ... }
```
## Licensing
Copyright 2017 Lovell Fuller and others.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0.html)
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,14 @@
// Copyright 2017 Lovell Fuller and others.
// SPDX-License-Identifier: Apache-2.0
export const GLIBC: 'glibc';
export const MUSL: 'musl';
export function family(): Promise<string | null>;
export function familySync(): string | null;
export function isNonGlibcLinux(): Promise<boolean>;
export function isNonGlibcLinuxSync(): boolean;
export function version(): Promise<string | null>;
export function versionSync(): string | null;

View File

@@ -0,0 +1,313 @@
// Copyright 2017 Lovell Fuller and others.
// SPDX-License-Identifier: Apache-2.0
'use strict';
const childProcess = require('child_process');
const { isLinux, getReport } = require('./process');
const { LDD_PATH, SELF_PATH, readFile, readFileSync } = require('./filesystem');
const { interpreterPath } = require('./elf');
let cachedFamilyInterpreter;
let cachedFamilyFilesystem;
let cachedVersionFilesystem;
const command = 'getconf GNU_LIBC_VERSION 2>&1 || true; ldd --version 2>&1 || true';
let commandOut = '';
const safeCommand = () => {
if (!commandOut) {
return new Promise((resolve) => {
childProcess.exec(command, (err, out) => {
commandOut = err ? ' ' : out;
resolve(commandOut);
});
});
}
return commandOut;
};
const safeCommandSync = () => {
if (!commandOut) {
try {
commandOut = childProcess.execSync(command, { encoding: 'utf8' });
} catch (_err) {
commandOut = ' ';
}
}
return commandOut;
};
/**
* A String constant containing the value `glibc`.
* @type {string}
* @public
*/
const GLIBC = 'glibc';
/**
* A Regexp constant to get the GLIBC Version.
* @type {string}
*/
const RE_GLIBC_VERSION = /LIBC[a-z0-9 \-).]*?(\d+\.\d+)/i;
/**
* A String constant containing the value `musl`.
* @type {string}
* @public
*/
const MUSL = 'musl';
const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-');
const familyFromReport = () => {
const report = getReport();
if (report.header && report.header.glibcVersionRuntime) {
return GLIBC;
}
if (Array.isArray(report.sharedObjects)) {
if (report.sharedObjects.some(isFileMusl)) {
return MUSL;
}
}
return null;
};
const familyFromCommand = (out) => {
const [getconf, ldd1] = out.split(/[\r\n]+/);
if (getconf && getconf.includes(GLIBC)) {
return GLIBC;
}
if (ldd1 && ldd1.includes(MUSL)) {
return MUSL;
}
return null;
};
const familyFromInterpreterPath = (path) => {
if (path) {
if (path.includes('/ld-musl-')) {
return MUSL;
} else if (path.includes('/ld-linux-')) {
return GLIBC;
}
}
return null;
};
const getFamilyFromLddContent = (content) => {
content = content.toString();
if (content.includes('musl')) {
return MUSL;
}
if (content.includes('GNU C Library')) {
return GLIBC;
}
return null;
};
const familyFromFilesystem = async () => {
if (cachedFamilyFilesystem !== undefined) {
return cachedFamilyFilesystem;
}
cachedFamilyFilesystem = null;
try {
const lddContent = await readFile(LDD_PATH);
cachedFamilyFilesystem = getFamilyFromLddContent(lddContent);
} catch (e) {}
return cachedFamilyFilesystem;
};
const familyFromFilesystemSync = () => {
if (cachedFamilyFilesystem !== undefined) {
return cachedFamilyFilesystem;
}
cachedFamilyFilesystem = null;
try {
const lddContent = readFileSync(LDD_PATH);
cachedFamilyFilesystem = getFamilyFromLddContent(lddContent);
} catch (e) {}
return cachedFamilyFilesystem;
};
const familyFromInterpreter = async () => {
if (cachedFamilyInterpreter !== undefined) {
return cachedFamilyInterpreter;
}
cachedFamilyInterpreter = null;
try {
const selfContent = await readFile(SELF_PATH);
const path = interpreterPath(selfContent);
cachedFamilyInterpreter = familyFromInterpreterPath(path);
} catch (e) {}
return cachedFamilyInterpreter;
};
const familyFromInterpreterSync = () => {
if (cachedFamilyInterpreter !== undefined) {
return cachedFamilyInterpreter;
}
cachedFamilyInterpreter = null;
try {
const selfContent = readFileSync(SELF_PATH);
const path = interpreterPath(selfContent);
cachedFamilyInterpreter = familyFromInterpreterPath(path);
} catch (e) {}
return cachedFamilyInterpreter;
};
/**
* Resolves with the libc family when it can be determined, `null` otherwise.
* @returns {Promise<?string>}
*/
const family = async () => {
let family = null;
if (isLinux()) {
family = await familyFromInterpreter();
if (!family) {
family = await familyFromFilesystem();
if (!family) {
family = familyFromReport();
}
if (!family) {
const out = await safeCommand();
family = familyFromCommand(out);
}
}
}
return family;
};
/**
* Returns the libc family when it can be determined, `null` otherwise.
* @returns {?string}
*/
const familySync = () => {
let family = null;
if (isLinux()) {
family = familyFromInterpreterSync();
if (!family) {
family = familyFromFilesystemSync();
if (!family) {
family = familyFromReport();
}
if (!family) {
const out = safeCommandSync();
family = familyFromCommand(out);
}
}
}
return family;
};
/**
* Resolves `true` only when the platform is Linux and the libc family is not `glibc`.
* @returns {Promise<boolean>}
*/
const isNonGlibcLinux = async () => isLinux() && await family() !== GLIBC;
/**
* Returns `true` only when the platform is Linux and the libc family is not `glibc`.
* @returns {boolean}
*/
const isNonGlibcLinuxSync = () => isLinux() && familySync() !== GLIBC;
const versionFromFilesystem = async () => {
if (cachedVersionFilesystem !== undefined) {
return cachedVersionFilesystem;
}
cachedVersionFilesystem = null;
try {
const lddContent = await readFile(LDD_PATH);
const versionMatch = lddContent.match(RE_GLIBC_VERSION);
if (versionMatch) {
cachedVersionFilesystem = versionMatch[1];
}
} catch (e) {}
return cachedVersionFilesystem;
};
const versionFromFilesystemSync = () => {
if (cachedVersionFilesystem !== undefined) {
return cachedVersionFilesystem;
}
cachedVersionFilesystem = null;
try {
const lddContent = readFileSync(LDD_PATH);
const versionMatch = lddContent.match(RE_GLIBC_VERSION);
if (versionMatch) {
cachedVersionFilesystem = versionMatch[1];
}
} catch (e) {}
return cachedVersionFilesystem;
};
const versionFromReport = () => {
const report = getReport();
if (report.header && report.header.glibcVersionRuntime) {
return report.header.glibcVersionRuntime;
}
return null;
};
const versionSuffix = (s) => s.trim().split(/\s+/)[1];
const versionFromCommand = (out) => {
const [getconf, ldd1, ldd2] = out.split(/[\r\n]+/);
if (getconf && getconf.includes(GLIBC)) {
return versionSuffix(getconf);
}
if (ldd1 && ldd2 && ldd1.includes(MUSL)) {
return versionSuffix(ldd2);
}
return null;
};
/**
* Resolves with the libc version when it can be determined, `null` otherwise.
* @returns {Promise<?string>}
*/
const version = async () => {
let version = null;
if (isLinux()) {
version = await versionFromFilesystem();
if (!version) {
version = versionFromReport();
}
if (!version) {
const out = await safeCommand();
version = versionFromCommand(out);
}
}
return version;
};
/**
* Returns the libc version when it can be determined, `null` otherwise.
* @returns {?string}
*/
const versionSync = () => {
let version = null;
if (isLinux()) {
version = versionFromFilesystemSync();
if (!version) {
version = versionFromReport();
}
if (!version) {
const out = safeCommandSync();
version = versionFromCommand(out);
}
}
return version;
};
module.exports = {
GLIBC,
MUSL,
family,
familySync,
isNonGlibcLinux,
isNonGlibcLinuxSync,
version,
versionSync
};

View File

@@ -0,0 +1,39 @@
// Copyright 2017 Lovell Fuller and others.
// SPDX-License-Identifier: Apache-2.0
'use strict';
const interpreterPath = (elf) => {
if (elf.length < 64) {
return null;
}
if (elf.readUInt32BE(0) !== 0x7F454C46) {
// Unexpected magic bytes
return null;
}
if (elf.readUInt8(4) !== 2) {
// Not a 64-bit ELF
return null;
}
if (elf.readUInt8(5) !== 1) {
// Not little-endian
return null;
}
const offset = elf.readUInt32LE(32);
const size = elf.readUInt16LE(54);
const count = elf.readUInt16LE(56);
for (let i = 0; i < count; i++) {
const headerOffset = offset + (i * size);
const type = elf.readUInt32LE(headerOffset);
if (type === 3) {
const fileOffset = elf.readUInt32LE(headerOffset + 8);
const fileSize = elf.readUInt32LE(headerOffset + 32);
return elf.subarray(fileOffset, fileOffset + fileSize).toString().replace(/\0.*$/g, '');
}
}
return null;
};
module.exports = {
interpreterPath
};

View File

@@ -0,0 +1,51 @@
// Copyright 2017 Lovell Fuller and others.
// SPDX-License-Identifier: Apache-2.0
'use strict';
const fs = require('fs');
const LDD_PATH = '/usr/bin/ldd';
const SELF_PATH = '/proc/self/exe';
const MAX_LENGTH = 2048;
/**
* Read the content of a file synchronous
*
* @param {string} path
* @returns {Buffer}
*/
const readFileSync = (path) => {
const fd = fs.openSync(path, 'r');
const buffer = Buffer.alloc(MAX_LENGTH);
const bytesRead = fs.readSync(fd, buffer, 0, MAX_LENGTH, 0);
fs.close(fd, () => {});
return buffer.subarray(0, bytesRead);
};
/**
* Read the content of a file
*
* @param {string} path
* @returns {Promise<Buffer>}
*/
const readFile = (path) => new Promise((resolve, reject) => {
fs.open(path, 'r', (err, fd) => {
if (err) {
reject(err);
} else {
const buffer = Buffer.alloc(MAX_LENGTH);
fs.read(fd, buffer, 0, MAX_LENGTH, 0, (_, bytesRead) => {
resolve(buffer.subarray(0, bytesRead));
fs.close(fd, () => {});
});
}
});
});
module.exports = {
LDD_PATH,
SELF_PATH,
readFileSync,
readFile
};

View File

@@ -0,0 +1,24 @@
// Copyright 2017 Lovell Fuller and others.
// SPDX-License-Identifier: Apache-2.0
'use strict';
const isLinux = () => process.platform === 'linux';
let report = null;
const getReport = () => {
if (!report) {
/* istanbul ignore next */
if (isLinux() && process.report) {
const orig = process.report.excludeNetwork;
process.report.excludeNetwork = true;
report = process.report.getReport();
process.report.excludeNetwork = orig;
} else {
report = {};
}
}
return report;
};
module.exports = { isLinux, getReport };

View File

@@ -0,0 +1,44 @@
{
"name": "detect-libc",
"version": "2.1.2",
"description": "Node.js module to detect the C standard library (libc) implementation family and version",
"main": "lib/detect-libc.js",
"files": [
"lib/",
"index.d.ts"
],
"scripts": {
"test": "semistandard && nyc --reporter=text --check-coverage --branches=100 ava test/unit.js",
"changelog": "conventional-changelog -i CHANGELOG.md -s",
"bench": "node benchmark/detect-libc",
"bench:calls": "node benchmark/call-familySync.js && sleep 1 && node benchmark/call-isNonGlibcLinuxSync.js && sleep 1 && node benchmark/call-versionSync.js"
},
"repository": {
"type": "git",
"url": "git://github.com/lovell/detect-libc.git"
},
"keywords": [
"libc",
"glibc",
"musl"
],
"author": "Lovell Fuller <npm@lovell.info>",
"contributors": [
"Niklas Salmoukas <niklas@salmoukas.com>",
"Vinícius Lourenço <vinyygamerlol@gmail.com>"
],
"license": "Apache-2.0",
"devDependencies": {
"ava": "^2.4.0",
"benchmark": "^2.1.4",
"conventional-changelog-cli": "^5.0.0",
"eslint-config-standard": "^13.0.1",
"nyc": "^15.1.0",
"proxyquire": "^2.1.3",
"semistandard": "^14.2.3"
},
"engines": {
"node": ">=8"
},
"types": "index.d.ts"
}

View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@moonrepo/cli": "^2.0.4"
}
}

197
README.md
View File

@@ -1,72 +1,201 @@
# repo-lib
Simple Nix flake library for:
`repo-lib` is a pure-first Nix flake library for repo-level developer workflows:
- a shared development shell (`mkDevShell`)
- an optional release command (`mkRelease`)
- a starter template (`template/`)
- `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release`
- structured tool banners driven from package-backed tool specs
- structured release steps (`writeFile`, `replace`, `versionMetaSet`, `versionMetaUnset`)
- a Bun-only Moonrepo + TypeScript + Varlock template in [`template/`](/Users/eric/Projects/repo-lib/template)
Audit and replacement review: [`docs/reviews/2026-03-21-repo-lib-audit.md`](/Users/eric/Projects/repo-lib/docs/reviews/2026-03-21-repo-lib-audit.md)
## Prerequisites
- [Nix](https://nixos.org/download/) with flakes enabled
- [`direnv`](https://direnv.net/) (recommended)
## Use the template (new repo)
From your new project folder:
## Use the template
```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v1.0.3#default' --refresh
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1#default' --refresh
```
## Use the library (existing repo)
The generated repo includes:
- a `repo-lib`-managed Nix flake
- Bun as the only JS runtime and package manager
- Moonrepo root tasks
- shared TypeScript configs adapted from `../moon`
- Varlock with a committed `.env.schema`
- empty `apps/` and `packages/` directories for new projects
## Use the library
Add this flake input:
```nix
inputs.devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v1.0.3";
inputs.devshell-lib.inputs.nixpkgs.follows = "nixpkgs";
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
```
Create your shell from `mkDevShell`:
Build your repo outputs from `mkRepo`:
```nix
env = devshell-lib.lib.mkDevShell {
inherit system;
extraPackages = [ ];
tools = [ ];
additionalHooks = { };
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config = {
checks.tests = {
command = "echo 'No tests defined yet.'";
stage = "pre-push";
passFilenames = false;
};
release = {
steps = [ ];
};
};
perSystem = { pkgs, system, ... }: {
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
version.args = [ "--version" ];
command = "nix";
})
];
shell.packages = [
self.packages.${system}.release
];
};
};
```
`mkRepo` generates:
- `devShells.${system}.default`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
Checks are installed through `lefthook`, with `pre-commit` and `pre-push` commands configured to run in parallel.
repo-lib also sets Lefthook `output = [ "failure" "summary" ]` by default.
For advanced Lefthook features, use raw `config.lefthook` or `perSystem.lefthook`. Those attrsets are merged after generated checks, so you can augment a generated command with fields that the simple `checks` abstraction does not carry, such as `stage_fixed`:
```nix
config.lefthook.pre-push.commands.tests.stage_fixed = true;
```
## Tool banners
Tools are declared once. Package-backed tools are added to the shell automatically, and both package-backed and command-backed tools are rendered in the startup banner.
```nix
(repo-lib.lib.tools.fromPackage {
name = "Go";
package = pkgs.go;
version.args = [ "version" ];
banner.color = "CYAN";
})
```
Required tools fail shell startup if their version probe fails. This keeps banner output honest instead of silently hiding misconfiguration.
When a tool should come from the host environment instead of `nixpkgs`, use `fromCommand`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
})
```
## Purity model
The default path is pure: declare tools and packages in Nix, then let `mkRepo` assemble the shell.
Impure bootstrap work is still possible, but it must be explicit:
```nix
config.shell = {
bootstrap = ''
export GOBIN="$PWD/.tools/bin"
export PATH="$GOBIN:$PATH"
'';
allowImpureBootstrap = true;
};
```
Expose it in `devShells` as `default` and run:
## Release steps
```bash
nix develop
```
## Common commands
```bash
nix fmt # format files
```
## Optional: release command
If your flake defines:
Structured release steps are preferred over raw `sed` snippets:
```nix
packages.${system}.release = devshell-lib.lib.mkRelease { inherit system; };
config.release = {
steps = [
{
writeFile = {
path = "src/version.ts";
text = ''
export const APP_VERSION = "$FULL_VERSION" as const;
'';
};
}
{
replace = {
path = "README.md";
regex = ''^(version = ")[^"]*(")$'';
replacement = ''\1$FULL_VERSION\2'';
};
}
{
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
];
};
```
Run releases with:
The generated `release` command still supports:
```bash
release
release select
release --dry-run patch
release patch
release patch --commit
release patch --commit --tag
release patch --commit --tag --push
release beta
release minor beta
release stable
release set 1.2.3
```
The release script uses `./VERSION` as the source of truth and creates tags like `v1.2.3`.
By default, `release` updates repo files, runs structured release steps, executes `postVersion`, and runs `nix fmt`, but it does not commit, tag, or push unless you opt in with flags.
- `--commit` stages all changes and creates `chore(release): <tag>`
- `--tag` creates the Git tag after commit
- `--push` pushes the current branch and, when tagging is enabled, pushes tags too
- `--dry-run` resolves and prints the plan without mutating the repo
When `release` runs with no args in an interactive terminal, it opens a Bubble Tea picker so you can preview the exact command, flags, and resolved next version before executing it. Use `release select` to force that picker explicitly.
## Low-level APIs
`mkRelease` remains available for repos that want lower-level control over release automation.
## Common command
```bash
nix fmt
```

View File

@@ -1,3 +1,3 @@
1.0.3
4.0.0
stable
0

View File

@@ -0,0 +1,292 @@
# Repo-Lib Audit
Date: 2026-03-21
## Direct Answers
### 1. Does it work today?
Partially.
- `nix flake show --all-systems` succeeds from the repo root.
- Before this audit, `nix flake check` failed because the old shell-based release test harness relied on a brittle regex over `nix derivation show` internals instead of asserting against stable JSON structure.
- `mkRepo`, the template flake, the dev shell, and the formatter/check outputs all evaluate. The primary failure was test-harness fragility, not a clear functional break in the library itself.
- The release path still carries real operational risk because [`packages/release/release.sh`](../../../packages/release/release.sh) combines mutation, formatting, commit, tag, and push in one command and uses destructive rollback.
### 2. Is the code organization maintainable?
Not in its current shape.
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix) is a single 879-line module that owns unrelated concerns:
- tool schema normalization
- hook/check config synthesis
- shell banner generation
- release step normalization
- public API assembly
- [`packages/repo-lib/shell-hook.sh`](../../../packages/repo-lib/shell-hook.sh) is not just presentation. It contains tool probing, parsing, error handling, and shell-failure behavior.
- `mkDevShell` and `mkRepo` overlap conceptually and preserve legacy paths that make the main implementation harder to reason about.
### 3. Is the public API readable and usable for consumers?
Usable, but underspecified and harder to learn than the README suggests.
- [`README.md`](../../../README.md) presents `mkRepo` as a compact abstraction, but the real behavior is distributed across `lib.nix`, `shell-hook.sh`, generated Lefthook config, and the release script.
- The boundaries between `config`, `perSystem`, `checks`, `lefthook`, `shell`, `tools`, and `release` are not obvious from the docs alone.
- Raw `config.lefthook` / `perSystem.lefthook` passthrough is effectively required for advanced use, which means the higher-level `checks` abstraction is incomplete.
### 4. Which parts should be kept, split, or replaced?
Keep:
- the high-level `mkRepo` consumer entrypoint, if compatibility matters
- the template
- the structured release-step idea
Split:
- release tooling from repo shell/hook wiring
- shell banner rendering from shell/package assembly
- hook/check generation from the rest of `mkRepo`
Replace:
- custom flake output composition with `flake-parts`
- custom hook glue with `lefthook.nix`
- keep `treefmt-nix` as the formatting layer rather than wrapping it deeper
### 5. What is the lowest-complexity target architecture?
Option A: keep `repo-lib.lib.mkRepo` as a thin compatibility wrapper, but rebase its internals on established components:
- `flake-parts` for flake structure and `perSystem`
- `treefmt-nix` for formatting
- `lefthook.nix` for hooks
- a separate `mkRelease` package for release automation, with explicit opt-ins for commit/tag/push
That preserves migration cost for consumers while removing most of the custom orchestration burden from this repo.
## Correctness Findings
### High: self-checks were failing because the test harness depended on unstable derivation internals
Files:
- the removed shell-based release test harness
Details:
- The repo did not pass `nix flake check` on the host system before this audit because the tests around `lefthook-check` assumed `nix derivation show` would expose `"/nix/store/...-lefthook.yml.drv"` as a quoted string.
- Current Nix emits input derivations in a different JSON shape, so the regex broke even though the underlying derivation still existed.
- This is a release blocker because the repos own baseline was red.
Assessment:
- Fixed in this audit by replacing the ad hoc scrape with a helper that locates the relevant input derivation from the JSON more defensibly.
### High: release rollback is destructive
Files:
- [`packages/release/release.sh`](../../../packages/release/release.sh)
Details:
- `revert_on_failure` runs `git reset --hard "$START_HEAD"` after any trapped error.
- That will discard all working tree changes created during the release flow, including user-visible file changes that might be useful for debugging or manual recovery.
Assessment:
- This is too aggressive for a library-provided command.
- Rollback should be opt-in, staged to a temp branch/worktree, or replaced with a safer failure mode that leaves artifacts visible.
### Medium: release performs too many side effects in one irreversible flow
Files:
- [`packages/release/release.sh`](../../../packages/release/release.sh)
Details:
- The default flow updates version state, runs release steps, formats, stages, commits, tags, and pushes.
- There is no dry-run mode.
- There is no `--no-push`, `--no-tag`, or `--no-commit` mode.
- The command is framed as a package generated by the library, so consumers inherit a strong opinionated workflow whether they want it or not.
Assessment:
- Release should be separated from repo shell wiring and broken into explicit phases or flags.
## Organization And Readability Findings
### High: `lib.nix` is a monolith
Files:
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- One file owns normalization helpers, shell assembly, banner formatting inputs, Lefthook synthesis, release templating, compatibility APIs, and top-level outputs.
- The public API is therefore not separable from its implementation detail.
Assessment:
- This is the main maintainability problem in the repo.
- Even if behavior is mostly correct, the cost of safely changing it is too high.
### Medium: shell UX logic is coupled to operational behavior
Files:
- [`packages/repo-lib/shell-hook.sh`](../../../packages/repo-lib/shell-hook.sh)
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- Tool banners do more than render text. They probe commands, parse versions, print failures, and may exit the shell startup for required tools.
- That behavior is not obvious from the README example and is spread across generated shell script fragments.
Assessment:
- The banner feature is nice, but it is expensive in complexity and debugging surface relative to the value it adds.
- If retained, it should be optional and isolated behind a smaller interface.
### Medium: legacy compatibility paths dominate the core implementation
Files:
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- `mkDevShell` uses legacy tool normalization and its own feature toggles.
- `mkRepo` carries a newer strict tool shape.
- Both flows feed the same shell-artifact builder, which means the common implementation has to keep both mental models alive.
Assessment:
- Deprecate `mkDevShell` once a thin `mkRepo` wrapper exists over standard components.
## Public API And Usability Findings
### High: README underspecifies the real API
Files:
- [`README.md`](../../../README.md)
Details:
- The README explains the happy-path shape of `mkRepo`, but not the actual behavioral contract.
- It does not provide a reference for:
- tool spec fields
- shell banner behavior
- exact merge order between `config` and `perSystem`
- what the `checks` abstraction cannot express
- what `release` is allowed to mutate by default
Assessment:
- Consumers can start quickly, but they cannot predict behavior well without reading the implementation.
### Medium: abstraction boundaries are blurry
Files:
- [`README.md`](../../../README.md)
- [`template/flake.nix`](../../../template/flake.nix)
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- `checks` looks like the high-level hook API, but advanced usage requires raw Lefthook passthrough.
- `shell.bootstrap` is documented as the purity escape hatch, but the template uses it for tool bootstrapping and operational setup.
- `release` is presented as optional packaging, but it is operational automation with repo mutation and remote side effects.
Assessment:
- These concepts should be separate modules with narrower contracts.
## Replacement Options
### Option A: thin compatibility layer
Keep `repo-lib.lib.mkRepo`, but make it a wrapper over standard components.
Use:
- `flake-parts` for top-level flake assembly and `perSystem`
- `treefmt-nix` for formatting
- `lefthook.nix` for Git hooks
- a standalone `mkRelease` output for release automation
Pros:
- lower migration cost
- preserves existing entrypoint
- reduces bespoke glue
Cons:
- some compatibility debt remains
- requires a staged migration plan
### Option B: full replacement
Stop positioning this as a general-purpose Nix library and keep only:
- the template
- any repo-specific release helper
- migration docs to standard tools
Pros:
- lowest long-term maintenance burden
- clearest product boundary
Cons:
- highest consumer migration cost
- discards the existing `mkRepo` API
## Final Recommendation
Choose **Option A**.
Rationale:
- `mkRepo` has enough consumer value to keep as a compatibility surface.
- Most of the complexity is not unique value. It is custom orchestration around capabilities already provided by better-maintained ecosystem tools.
- The release flow should be split out regardless of which option is chosen.
Concrete target:
1. Rebase flake structure on `flake-parts`.
2. Replace custom hook synthesis with `lefthook.nix`.
3. Keep `treefmt-nix` directly exposed instead of deeply wrapped.
4. Make shell banners optional or move them behind a very small isolated module.
5. Move release automation into a separate package with explicit side-effect flags.
6. Mark `mkDevShell` deprecated once `mkRepo` is stable on the new internals.
## Migration Cost And Compatibility Notes
- A thin compatibility wrapper keeps consumer migration reasonable.
- The biggest compatibility risk is release behavior, because some consumers may depend on the current commit/tag/push flow.
- Introduce safer release behavior behind new flags first, then deprecate the old all-in-one default.
- Keep template output working during the transition; it is currently the clearest example of intended usage.
## Required Validation For Follow-Up Work
- `nix flake show --all-systems`
- `nix flake check`
- minimal consumer repo using `mkRepo`
- template repo evaluation
- release smoke test in a temporary git repo
- hook assertions that do not depend on private derivation naming/layout
## Sources
- `flake-parts`: https://flake.parts/
- `treefmt-nix`: https://github.com/numtide/treefmt-nix
- `lefthook.nix`: https://github.com/cachix/lefthook.nix
- `devenv`: https://github.com/cachix/devenv

93
flake.lock generated
View File

@@ -1,79 +1,44 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"flake-parts": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1772024342,
"narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476",
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"gitignore": {
"lefthook-nix": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"lastModified": 1770377107,
"narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=",
"owner": "sudosubin",
"repo": "lefthook.nix",
"rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"owner": "sudosubin",
"repo": "lefthook.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -89,7 +54,22 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs-lib": {
"locked": {
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -107,14 +87,15 @@
},
"root": {
"inputs": {
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs_2",
"flake-parts": "flake-parts",
"lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1770228511,

371
flake.nix
View File

@@ -1,322 +1,129 @@
# flake.nix — devshell-lib
# flake.nix — repo-lib
{
description = "Shared devshell boilerplate library";
description = "Pure-first repo development platform for Nix flakes";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
git-hooks.url = "github:cachix/git-hooks.nix";
lefthook-nix.url = "github:sudosubin/lefthook.nix";
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix";
};
outputs =
{
self,
flake-parts,
nixpkgs,
treefmt-nix,
git-hooks,
lefthook-nix,
...
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
lib = {
lib = nixpkgs.lib;
repoLib = import ./packages/repo-lib/lib.nix {
inherit flake-parts nixpkgs treefmt-nix;
lefthookNix = lefthook-nix;
releaseScriptPath = ./packages/release/release.sh;
shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
};
supportedSystems = repoLib.systems.default;
importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; };
# ── mkDevShell ───────────────────────────────────────────────────────
mkDevShell =
{
system,
extraPackages ? [ ],
extraShellHook ? "",
additionalHooks ? { },
tools ? [ ],
includeStandardPackages ? true,
# tools = list of { name, bin, versionCmd, color? }
# e.g. { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; }
formatters ? { },
# formatters = treefmt-nix programs attrset, merged over { nixfmt.enable = true; }
# e.g. { gofmt.enable = true; shfmt.enable = true; }
formatterSettings ? { },
# formatterSettings = treefmt-nix settings.formatter attrset
# e.g. { shfmt.options = [ "-i" "2" "-s" "-w" ]; }
features ? { },
# features.oxfmt = true → adds pkgs.oxfmt + pkgs.oxlint, enables oxfmt in treefmt
}:
let
pkgs = import nixpkgs { inherit system; };
standardPackages = with pkgs; [
nixfmt
gitlint
gitleaks
shfmt
];
selectedStandardPackages = pkgs.lib.optionals includeStandardPackages standardPackages;
oxfmtEnabled = features.oxfmt or false;
oxfmtPackages = pkgs.lib.optionals oxfmtEnabled [
pkgs.oxfmt
pkgs.oxlint
];
oxfmtFormatters = pkgs.lib.optionalAttrs oxfmtEnabled {
oxfmt.enable = true;
};
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true; # always on — every repo has a flake.nix
}
// oxfmtFormatters
// formatters;
settings.formatter = { } // formatterSettings;
};
pre-commit-check = git-hooks.lib.${system}.run {
src = ./.;
hooks = {
treefmt = {
enable = true;
entry = "${treefmtEval.config.build.wrapper}/bin/treefmt";
pass_filenames = true;
};
gitlint.enable = true;
gitleaks = {
enable = true;
entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
pass_filenames = false;
projectOutputs = repoLib.mkRepo {
inherit self nixpkgs;
src = ./.;
config = {
release = {
steps = [
{
replace = {
path = "template/flake.nix";
regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
};
}
// additionalHooks;
};
toolNameWidth = builtins.foldl' (
maxWidth: t: pkgs.lib.max maxWidth (builtins.stringLength t.name)
) 0 tools;
toolLabelWidth = toolNameWidth + 1;
toolBannerScript = pkgs.lib.concatMapStrings (
t:
let
colorVar = "$" + (t.color or "YELLOW");
in
''
if command -v ${t.bin} >/dev/null 2>&1; then
version="$(${t.bin} ${t.versionCmd} 2>/dev/null | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
printf " $CYAN %-${toString toolLabelWidth}s$RESET ${colorVar}%s$RESET\n" "${t.name}:" "$version"
fi
''
) tools;
in
{
inherit pre-commit-check;
formatter = treefmtEval.config.build.wrapper;
shell = pkgs.mkShell {
packages = selectedStandardPackages ++ extraPackages ++ oxfmtPackages;
buildInputs = pre-commit-check.enabledPackages;
shellHook = ''
${pre-commit-check.shellHook}
if [ -t 1 ]; then
command -v tput >/dev/null 2>&1 && tput clear || printf '\033c'
fi
GREEN='\033[1;32m'
CYAN='\033[1;36m'
YELLOW='\033[1;33m'
BLUE='\033[1;34m'
RED='\033[1;31m'
MAGENTA='\033[1;35m'
WHITE='\033[1;37m'
GRAY='\033[0;90m'
BOLD='\033[1m'
UNDERLINE='\033[4m'
RESET='\033[0m'
printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n"
${toolBannerScript}
printf "\n"
${extraShellHook}
'';
};
{
replace = {
path = "README.md";
regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
};
}
{
replace = {
path = "README.md";
regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
};
}
];
};
# ── mkRelease ────────────────────────────────────────────────────────
mkRelease =
};
perSystem =
{
pkgs,
system,
# Source of truth is always $ROOT_DIR/VERSION.
# Format:
# line 1: X.Y.Z
# line 2: CHANNEL (stable|alpha|beta|rc|internal|...)
# line 3: N (prerelease number, 0 for stable)
postVersion ? "",
# Shell string — runs after VERSION + release steps are written/run, before git add.
# Same env vars available.
release ? [ ],
# Unified list processed in declaration order:
# { file = "path/to/file"; content = ''...$FULL_VERSION...''; } # write file
# { run = ''...shell snippet...''; } # run script
# Example:
# release = [
# {
# file = "src/version.ts";
# content = ''export const APP_VERSION = "$FULL_VERSION" as const;'';
# }
# {
# file = "internal/version/version.go";
# content = ''
# package version
#
# const Version = "$FULL_VERSION"
# '';
# }
# {
# run = ''
# sed -E -i "s#^([[:space:]]*my-lib\\.url = \")github:org/my-lib[^"]*(\";)#\\1github:org/my-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/flake.nix"
# '';
# }
# ];
# Runtime env includes: BASE_VERSION, CHANNEL, PRERELEASE_NUM, FULL_VERSION, FULL_TAG.
channels ? [
"alpha"
"beta"
"rc"
"internal"
],
# Valid release channels beyond "stable". Validated at runtime.
extraRuntimeInputs ? [ ],
# Extra packages available in the release script's PATH.
...
}:
let
pkgs = import nixpkgs { inherit system; };
channelList = pkgs.lib.concatStringsSep " " channels;
{
tools = [
(repoLib.tools.fromCommand {
name = "Nix";
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
];
releaseStepsScript = pkgs.lib.concatMapStrings (
entry:
if entry ? file then
''
mkdir -p "$(dirname "${entry.file}")"
cat > "${entry.file}" << NIXEOF
${entry.content}
NIXEOF
log "Generated version file: ${entry.file}"
''
else if entry ? run then
''
${entry.run}
''
else
builtins.throw "release entry must have either 'file' or 'run'"
) release;
script =
builtins.replaceStrings
[
"__CHANNEL_LIST__"
"__RELEASE_STEPS__"
"__POST_VERSION__"
]
[
channelList
releaseStepsScript
postVersion
]
(builtins.readFile ./packages/release/release.sh);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
]
++ extraRuntimeInputs;
text = script;
shell.packages = [ self.packages.${system}.release ];
};
};
# ── packages ────────────────────────────────────────────────────────────
packages = forAllSystems (system: {
# Expose a no-op release package for the lib repo itself (dogfood)
release = self.lib.mkRelease {
inherit system;
release = [
{
run = ''
sed -E -i "s#^([[:space:]]*devshell-lib\\.url = \")git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^\"]*(\";)#\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/template/flake.nix"
log "Updated template/flake.nix devshell-lib ref to $FULL_TAG"
sed -E -i "s|(nix flake new myapp -t ')git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^']*(#default' --refresh)|\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2|" "$ROOT_DIR/README.md"
sed -E -i "s#^([[:space:]]*inputs\\.devshell-lib\\.url = \")git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^\"]*(\";)#\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/README.md"
log "Updated README.md devshell-lib refs to $FULL_TAG"
'';
}
];
};
});
# ── devShells ───────────────────────────────────────────────────────────
devShells = forAllSystems (
testChecks = lib.genAttrs supportedSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
env = self.lib.mkDevShell {
inherit system;
extraPackages = with pkgs; [
self.packages.${system}.release
];
tools = [
pkgs = importPkgs nixpkgs system;
in
{
release-tests =
pkgs.runCommand "release-tests"
{
name = "Nix";
bin = "${pkgs.nix}/bin/nix";
versionCmd = "--version";
color = "YELLOW";
nativeBuildInputs = with pkgs; [
go
git
];
}
];
};
in
{
default = env.shell;
''
export HOME="$PWD/.home"
export GOCACHE="$PWD/.go-cache"
mkdir -p "$GOCACHE" "$HOME"
cd ${./packages/release}
go test ./...
touch "$out"
'';
}
);
in
projectOutputs
// {
lib = repoLib;
# ── checks ──────────────────────────────────────────────────────────────
checks = forAllSystems (
system:
let
env = self.lib.mkDevShell { inherit system; };
in
{
inherit (env) pre-commit-check;
}
);
# ── formatter ───────────────────────────────────────────────────────────
formatter = forAllSystems (system: (self.lib.mkDevShell { inherit system; }).formatter);
# ── templates ───────────────────────────────────────────────────────────
templates = {
default = {
path = ./template;
description = "Product repo using devshell-lib";
description = "Product repo using repo-lib";
};
};
checks = lib.genAttrs supportedSystems (
system: projectOutputs.checks.${system} // testChecks.${system}
);
};
}

1
packages/release/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
vendor/

View File

@@ -0,0 +1,127 @@
package main
import (
"fmt"
"os"
"strings"
release "repo-lib/packages/release/internal/release"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) > 0 && args[0] == "version-meta" {
return runVersionMeta(args[1:])
}
releaseArgs, execution, selectMode, err := parseReleaseCLIArgs(args)
if err != nil {
return err
}
config := release.Config{
RootDir: os.Getenv("REPO_LIB_RELEASE_ROOT_DIR"),
AllowedChannels: splitEnvList("REPO_LIB_RELEASE_CHANNELS"),
ReleaseStepsJSON: os.Getenv("REPO_LIB_RELEASE_STEPS_JSON"),
PostVersion: os.Getenv("REPO_LIB_RELEASE_POST_VERSION"),
Execution: execution,
Env: os.Environ(),
Stdout: os.Stdout,
Stderr: os.Stderr,
}
if shouldRunInteractiveSelector(releaseArgs, selectMode) {
if !release.IsInteractiveTerminal(os.Stdin, os.Stdout) {
return fmt.Errorf("interactive release selector requires a terminal")
}
selectedArgs, confirmed, err := release.SelectCommand(config)
if err != nil {
return err
}
if !confirmed {
return nil
}
releaseArgs = selectedArgs
}
r := &release.Runner{Config: config}
return r.Run(releaseArgs)
}
func shouldRunInteractiveSelector(args []string, selectMode bool) bool {
if selectMode {
return true
}
if len(args) == 0 {
return release.IsInteractiveTerminal(os.Stdin, os.Stdout)
}
return false
}
func parseReleaseCLIArgs(args []string) ([]string, release.ExecutionOptions, bool, error) {
var releaseArgs []string
execution := release.ExecutionOptions{}
selectMode := false
for _, arg := range args {
switch arg {
case "select":
selectMode = true
default:
if strings.HasPrefix(arg, "--") {
return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg)
}
releaseArgs = append(releaseArgs, arg)
}
}
if selectMode && len(releaseArgs) > 0 {
return nil, release.ExecutionOptions{}, false, fmt.Errorf("select does not take a release argument")
}
return releaseArgs, execution.Normalize(), selectMode, nil
}
func runVersionMeta(args []string) error {
if len(args) < 2 {
return fmt.Errorf("version-meta requires an action and key")
}
rootDir := os.Getenv("ROOT_DIR")
if rootDir == "" {
return fmt.Errorf("ROOT_DIR is required")
}
versionPath := rootDir + "/VERSION"
file, err := release.ReadVersionFile(versionPath)
if err != nil {
return err
}
switch args[0] {
case "set":
if len(args) != 3 {
return fmt.Errorf("version-meta set requires key and value")
}
file.Metadata.Set(args[1], args[2])
case "unset":
if len(args) != 2 {
return fmt.Errorf("version-meta unset requires key")
}
file.Metadata.Unset(args[1])
default:
return fmt.Errorf("unknown version-meta action %q", args[0])
}
return file.Write(versionPath)
}
func splitEnvList(name string) []string {
raw := strings.Fields(os.Getenv(name))
if len(raw) == 0 {
return nil
}
return raw
}

29
packages/release/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module repo-lib/packages/release
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
golang.org/x/term v0.41.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

45
packages/release/go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@@ -0,0 +1,91 @@
package release
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
func requireCleanGit(rootDir string) error {
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--cached", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
return nil
}
func gitOutput(dir string, args ...string) (string, error) {
return runCommand(dir, nil, nil, nil, "git", args...)
}
func runCommand(dir string, env []string, stdout io.Writer, stderr io.Writer, name string, args ...string) (string, error) {
resolvedName, err := resolveExecutable(name, env)
if err != nil {
return "", err
}
cmd := exec.Command(resolvedName, args...)
if dir != "" {
cmd.Dir = dir
}
if env != nil {
cmd.Env = env
} else {
cmd.Env = os.Environ()
}
var out bytes.Buffer
cmd.Stdout = io.MultiWriter(&out, writerOrDiscard(stdout))
cmd.Stderr = io.MultiWriter(&out, writerOrDiscard(stderr))
err = cmd.Run()
if err != nil {
output := strings.TrimSpace(out.String())
if output == "" {
return out.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return out.String(), fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, output)
}
return out.String(), nil
}
func resolveExecutable(name string, env []string) (string, error) {
if strings.ContainsRune(name, os.PathSeparator) {
return name, nil
}
pathValue := os.Getenv("PATH")
for _, entry := range env {
if strings.HasPrefix(entry, "PATH=") {
pathValue = strings.TrimPrefix(entry, "PATH=")
}
}
for _, dir := range filepath.SplitList(pathValue) {
if dir == "" {
dir = "."
}
candidate := filepath.Join(dir, name)
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
if info.Mode()&0o111 == 0 {
continue
}
return candidate, nil
}
return "", fmt.Errorf("executable %q not found in PATH", name)
}
func writerOrDiscard(w io.Writer) io.Writer {
if w == nil {
return io.Discard
}
return w
}

View File

@@ -0,0 +1,64 @@
package release
import (
"encoding/json"
"fmt"
)
type ReleaseStep struct {
Kind string `json:"kind"`
Path string `json:"path,omitempty"`
Text string `json:"text,omitempty"`
Regex string `json:"regex,omitempty"`
Replacement string `json:"replacement,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
}
func decodeReleaseSteps(raw string) ([]ReleaseStep, error) {
if raw == "" {
return nil, nil
}
var steps []ReleaseStep
if err := json.Unmarshal([]byte(raw), &steps); err != nil {
return nil, fmt.Errorf("decode release steps: %w", err)
}
for i, step := range steps {
if err := validateReleaseStep(step); err != nil {
return nil, fmt.Errorf("release step %d: %w", i+1, err)
}
}
return steps, nil
}
func validateReleaseStep(step ReleaseStep) error {
switch step.Kind {
case "writeFile":
if step.Path == "" {
return fmt.Errorf("writeFile.path is required")
}
return nil
case "replace":
if step.Path == "" {
return fmt.Errorf("replace.path is required")
}
if step.Regex == "" {
return fmt.Errorf("replace.regex is required")
}
return nil
case "versionMetaSet":
if step.Key == "" {
return fmt.Errorf("versionMetaSet.key is required")
}
return nil
case "versionMetaUnset":
if step.Key == "" {
return fmt.Errorf("versionMetaUnset.key is required")
}
return nil
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,39 @@
package release
import (
"fmt"
"io"
)
func (r *Runner) runReleaseSteps(rootDir string, versionPath string, versionFile *VersionFile, version Version, stdout io.Writer, stderr io.Writer) error {
steps, err := decodeReleaseSteps(r.Config.ReleaseStepsJSON)
if err != nil {
return err
}
if len(steps) == 0 {
return nil
}
ctx := newReleaseStepContext(rootDir, versionPath, versionFile, version, r.Config.Env)
for i, step := range steps {
if err := applyReleaseStep(ctx, step); err != nil {
return fmt.Errorf("release step %d (%s): %w", i+1, step.Kind, err)
}
}
return nil
}
func applyReleaseStep(ctx *ReleaseStepContext, step ReleaseStep) error {
switch step.Kind {
case "writeFile":
return applyWriteFileStep(ctx, step)
case "replace":
return applyReplaceStep(ctx, step)
case "versionMetaSet":
return applyVersionMetaSetStep(ctx, step)
case "versionMetaUnset":
return applyVersionMetaUnsetStep(ctx, step)
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,73 @@
package release
import (
"os"
"path/filepath"
"strconv"
"strings"
)
type ReleaseStepContext struct {
RootDir string
VersionPath string
Version Version
VersionFile *VersionFile
Env map[string]string
}
func newReleaseStepContext(rootDir string, versionPath string, versionFile *VersionFile, version Version, env []string) *ReleaseStepContext {
return &ReleaseStepContext{
RootDir: rootDir,
VersionPath: versionPath,
Version: version,
VersionFile: versionFile,
Env: buildReleaseEnv(rootDir, versionFile, version, env),
}
}
func buildReleaseEnv(rootDir string, versionFile *VersionFile, version Version, baseEnv []string) map[string]string {
env := make(map[string]string, len(baseEnv)+8+len(versionFile.Metadata.lines))
if len(baseEnv) == 0 {
baseEnv = os.Environ()
}
for _, entry := range baseEnv {
key, value, ok := strings.Cut(entry, "=")
if ok {
env[key] = value
}
}
env["ROOT_DIR"] = rootDir
env["BASE_VERSION"] = version.BaseString()
env["CHANNEL"] = version.Channel
env["FULL_VERSION"] = version.String()
env["FULL_TAG"] = version.Tag()
if version.Channel == "stable" {
env["PRERELEASE_NUM"] = ""
} else {
env["PRERELEASE_NUM"] = strconv.Itoa(version.Prerelease)
}
for _, line := range versionFile.Metadata.lines {
key, value, ok := strings.Cut(line, "=")
if !ok || key == "" {
continue
}
env[sanitizeMetaEnvName(key)] = value
}
return env
}
func (c *ReleaseStepContext) expand(raw string) string {
return os.Expand(raw, func(name string) string {
return c.Env[name]
})
}
func (c *ReleaseStepContext) resolvePath(path string) string {
expanded := c.expand(path)
if filepath.IsAbs(expanded) {
return expanded
}
return filepath.Join(c.RootDir, expanded)
}

View File

@@ -0,0 +1,45 @@
package release
import (
"fmt"
"os"
"regexp"
"strings"
)
func applyReplaceStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
content, err := os.ReadFile(targetPath)
if err != nil {
return fmt.Errorf("read %s: %w", targetPath, err)
}
pattern, err := regexp.Compile("(?m)" + ctx.expand(step.Regex))
if err != nil {
return fmt.Errorf("compile regex for %s: %w", targetPath, err)
}
replacement := translateReplacementBackrefs(ctx.expand(step.Replacement))
updated := pattern.ReplaceAllString(string(content), replacement)
if err := os.WriteFile(targetPath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}
func translateReplacementBackrefs(raw string) string {
var b strings.Builder
b.Grow(len(raw))
for i := 0; i < len(raw); i++ {
if raw[i] == '\\' && i+1 < len(raw) && raw[i+1] >= '1' && raw[i+1] <= '9' {
b.WriteString("${")
b.WriteByte(raw[i+1])
b.WriteByte('}')
i++
continue
}
b.WriteByte(raw[i])
}
return b.String()
}

View File

@@ -0,0 +1,13 @@
package release
import "testing"
func TestTranslateReplacementBackrefsWrapsCaptureNumbers(t *testing.T) {
t.Parallel()
got := translateReplacementBackrefs(`\1git+https://example.test/ref\2`)
want := `${1}git+https://example.test/ref${2}`
if got != want {
t.Fatalf("translateReplacementBackrefs() = %q, want %q", got, want)
}
}

View File

@@ -0,0 +1,21 @@
package release
import "fmt"
func applyVersionMetaSetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Set(step.Key, ctx.expand(step.Value))
ctx.Env[sanitizeMetaEnvName(step.Key)] = ctx.VersionFile.Metadata.Get(step.Key)
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}
func applyVersionMetaUnsetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Unset(step.Key)
delete(ctx.Env, sanitizeMetaEnvName(step.Key))
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}

View File

@@ -0,0 +1,18 @@
package release
import (
"fmt"
"os"
"path/filepath"
)
func applyWriteFileStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
if err := os.WriteFile(targetPath, []byte(ctx.expand(step.Text)), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}

View File

@@ -0,0 +1,372 @@
package release
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestResolveNextVersion(t *testing.T) {
t.Parallel()
allowed := []string{"alpha", "beta", "rc", "internal"}
tests := []struct {
name string
current string
args []string
want string
wantErr string
}{
{
name: "channel only from stable bumps patch",
current: "1.0.0",
args: []string{"beta"},
want: "1.0.1-beta.1",
},
{
name: "explicit minor bump keeps requested bump",
current: "1.0.0",
args: []string{"minor", "beta"},
want: "1.1.0-beta.1",
},
{
name: "full promotes prerelease to stable",
current: "1.1.5-beta.1",
args: []string{"full"},
want: "1.1.5",
},
{
name: "set stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"set", "1.1.5"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "patch stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"patch", "stable"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "full no-op fails",
current: "1.1.5",
args: []string{"full"},
wantErr: "Version 1.1.5 is already current; nothing to do.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
current, err := ParseVersion(tc.current)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", tc.current, err)
}
got, err := ResolveNextVersion(current, tc.args, allowed)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("ResolveNextVersion(%q, %v) succeeded, want error", tc.current, tc.args)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("ResolveNextVersion(%q, %v) error = %q, want substring %q", tc.current, tc.args, err.Error(), tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("ResolveNextVersion(%q, %v): %v", tc.current, tc.args, err)
}
if got.String() != tc.want {
t.Fatalf("ResolveNextVersion(%q, %v) = %q, want %q", tc.current, tc.args, got.String(), tc.want)
}
})
}
}
func TestVersionFileMetadataRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "VERSION")
content := strings.Join([]string{
"1.0.0",
"stable",
"0",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=binary",
"desktop_unused=temporary",
"",
}, "\n")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
file, err := ReadVersionFile(path)
if err != nil {
t.Fatalf("ReadVersionFile: %v", err)
}
if got := file.Current().String(); got != "1.0.0" {
t.Fatalf("Current() = %q, want 1.0.0", got)
}
if got := file.Metadata.Get("desktop_backend_change_scope"); got != "bindings" {
t.Fatalf("Metadata.Get(scope) = %q, want bindings", got)
}
file.Version = MustParseVersion(t, "1.0.1")
file.Metadata.Set("desktop_release_mode", "codepush")
file.Metadata.Set("desktop_binary_version_min", "1.0.0")
file.Metadata.Set("desktop_binary_version_max", "1.0.1")
file.Metadata.Set("desktop_backend_compat_id", "compat-123")
file.Metadata.Unset("desktop_unused")
if err := file.Write(path); err != nil {
t.Fatalf("Write(VERSION): %v", err)
}
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(VERSION): %v", err)
}
got := string(gotBytes)
for _, needle := range []string{
"1.0.1\nstable\n0\n",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(got, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, got)
}
}
if strings.Contains(got, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", got)
}
}
func TestRunnerExecutesReleaseFlow(t *testing.T) {
t.Parallel()
root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustRun(t, root, "git", "config", "commit.gpgsign", "false")
mustRun(t, root, "git", "config", "tag.gpgsign", "false")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\ndesktop_backend_change_scope=bindings\ndesktop_release_mode=binary\ndesktop_unused=temporary\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("version=old\n"), 0o644); err != nil {
t.Fatalf("WriteFile(notes.txt): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
mustRun(t, root, "git", "init", "--bare", remote)
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "HEAD")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
ReleaseStepsJSON: mustJSON(t, []ReleaseStep{
{Kind: "writeFile", Path: "generated/version.txt", Text: "$FULL_VERSION\n"},
{Kind: "replace", Path: "notes.txt", Regex: "^version=.*$", Replacement: "version=$FULL_VERSION"},
{Kind: "writeFile", Path: "release.tag", Text: "$FULL_TAG\n"},
{Kind: "writeFile", Path: "metadata/scope.txt", Text: "$VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE\n"},
{Kind: "writeFile", Path: "metadata/mode-before.txt", Text: "$VERSION_META_DESKTOP_RELEASE_MODE\n"},
{Kind: "versionMetaSet", Key: "desktop_release_mode", Value: "codepush"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_min", Value: "1.0.0"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_max", Value: "$FULL_VERSION"},
{Kind: "versionMetaSet", Key: "desktop_backend_compat_id", Value: "compat-123"},
{Kind: "versionMetaUnset", Key: "desktop_unused"},
}),
PostVersion: "printf '%s\\n' \"$FULL_VERSION\" >\"$ROOT_DIR/post-version.txt\"",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
versionFile, err := ReadVersionFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadVersionFile(after): %v", err)
}
if got := versionFile.Current().String(); got != "1.0.1" {
t.Fatalf("Current() after release = %q, want 1.0.1", got)
}
assertFileEquals(t, filepath.Join(root, "generated/version.txt"), "1.0.1\n")
assertFileEquals(t, filepath.Join(root, "notes.txt"), "version=1.0.1\n")
assertFileEquals(t, filepath.Join(root, "release.tag"), "v1.0.1\n")
assertFileEquals(t, filepath.Join(root, "metadata/scope.txt"), "bindings\n")
assertFileEquals(t, filepath.Join(root, "metadata/mode-before.txt"), "binary\n")
assertFileEquals(t, filepath.Join(root, "post-version.txt"), "1.0.1\n")
versionBytes, err := os.ReadFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadFile(VERSION after): %v", err)
}
versionText := string(versionBytes)
for _, needle := range []string{
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(versionText, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, versionText)
}
}
if strings.Contains(versionText, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", versionText)
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1"))
if tagList != "v1.0.1" {
t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList)
}
}
func TestRunnerAlwaysCommitsTagsAndPushes(t *testing.T) {
t.Parallel()
root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustRun(t, root, "git", "config", "commit.gpgsign", "false")
mustRun(t, root, "git", "config", "tag.gpgsign", "false")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
mustRun(t, root, "git", "init", "--bare", remote)
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "HEAD")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.1\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short"))
if status != "" {
t.Fatalf("git status --short = %q, want empty", status)
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1"))
if tagList != "v1.0.1" {
t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList)
}
branch := strings.TrimSpace(mustOutput(t, root, "git", "branch", "--show-current"))
remoteHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "origin/"+branch))
localHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
if remoteHead != localHead {
t.Fatalf("origin/%s = %q, want %q", branch, remoteHead, localHead)
}
}
func MustParseVersion(t *testing.T, raw string) Version {
t.Helper()
v, err := ParseVersion(raw)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", raw, err)
}
return v
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return string(data)
}
func mustRun(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
}
func mustOutput(t *testing.T, dir string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
return string(out)
}
func assertFileEquals(t *testing.T, path string, want string) {
t.Helper()
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if got := string(gotBytes); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}

View File

@@ -0,0 +1,129 @@
package release
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Config struct {
RootDir string
AllowedChannels []string
ReleaseStepsJSON string
PostVersion string
Execution ExecutionOptions
Env []string
Stdout io.Writer
Stderr io.Writer
}
type ExecutionOptions struct {
Commit bool
Tag bool
Push bool
}
type Runner struct {
Config Config
}
func (o ExecutionOptions) Normalize() ExecutionOptions {
return ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
}
}
func (r *Runner) Run(args []string) error {
rootDir, err := r.rootDir()
if err != nil {
return err
}
stdout := writerOrDiscard(r.Config.Stdout)
stderr := writerOrDiscard(r.Config.Stderr)
execution := r.Config.Execution.Normalize()
versionFile, versionPath, err := r.loadVersionFile(rootDir)
if err != nil {
return err
}
nextVersion, err := ResolveNextVersion(versionFile.Version, args, r.Config.AllowedChannels)
if err != nil {
return err
}
if err := requireCleanGit(rootDir); err != nil {
return err
}
versionFile.Version = nextVersion
if err := versionFile.Write(versionPath); err != nil {
return err
}
if err := r.runReleaseSteps(rootDir, versionPath, versionFile, nextVersion, stdout, stderr); err != nil {
return err
}
if err := r.runShell(rootDir, versionFile, nextVersion, r.Config.PostVersion, stdout, stderr); err != nil {
return err
}
if err := r.finalizeRelease(rootDir, nextVersion, execution, stdout, stderr); err != nil {
return err
}
return nil
}
func (r *Runner) rootDir() (string, error) {
if r.Config.RootDir != "" {
return r.Config.RootDir, nil
}
rootDir, err := gitOutput("", "rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
return strings.TrimSpace(rootDir), nil
}
func (r *Runner) loadVersionFile(rootDir string) (*VersionFile, string, error) {
versionPath := filepath.Join(rootDir, "VERSION")
if _, err := os.Stat(versionPath); err != nil {
return nil, "", fmt.Errorf("VERSION file not found at %s", versionPath)
}
versionFile, err := ReadVersionFile(versionPath)
if err != nil {
return nil, "", err
}
return versionFile, versionPath, nil
}
func (r *Runner) finalizeRelease(rootDir string, version Version, execution ExecutionOptions, stdout io.Writer, stderr io.Writer) error {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "nix", "fmt"); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil {
return err
}
commitMsg := "chore(release): " + version.Tag()
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "commit", "-m", commitMsg); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,90 @@
package release
import (
"io"
"strings"
)
const shellPrelude = `
log() { echo "[release] $*" >&2; }
version_meta_get() {
local key="${1-}"
local line
while IFS= read -r line; do
[[ $line == "$key="* ]] && printf '%s\n' "${line#*=}" && return 0
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" -v value="$value" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { print key "=" value; updated=1; next }
{ print }
END { if (!updated) print key "=" value }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
version_meta_unset() {
local key="${1-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { next }
{ print }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
export_version_meta_env() {
local line key value env_key
while IFS= read -r line; do
[[ $line == *=* ]] || continue
key="${line%%=*}"
value="${line#*=}"
env_key="$(printf '%s' "$key" | tr -c '[:alnum:]' '_' | tr '[:lower:]' '[:upper:]')"
export "VERSION_META_${env_key}=$value"
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_meta_env
`
func (r *Runner) runShell(rootDir string, versionFile *VersionFile, version Version, script string, stdout io.Writer, stderr io.Writer) error {
if strings.TrimSpace(script) == "" {
return nil
}
env := r.shellEnv(rootDir, versionFile, version)
_, err := runCommand(rootDir, env, stdout, stderr, "bash", "-euo", "pipefail", "-c", shellPrelude+"\n"+script)
return err
}
func (r *Runner) shellEnv(rootDir string, versionFile *VersionFile, version Version) []string {
envMap := buildReleaseEnv(rootDir, versionFile, version, r.Config.Env)
env := make([]string, 0, len(envMap))
for key, value := range envMap {
env = append(env, key+"="+value)
}
return env
}
func sanitizeMetaEnvName(key string) string {
var b strings.Builder
b.WriteString("VERSION_META_")
for _, r := range key {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r - 32)
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}

View File

@@ -0,0 +1,10 @@
package release
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,505 @@
package release
import (
"fmt"
"io"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
type CommandOption struct {
Title string
Description string
Command string
Args []string
NextVersion Version
Preview string
}
func IsInteractiveTerminal(stdin io.Reader, stdout io.Writer) bool {
in, inOK := stdin.(*os.File)
out, outOK := stdout.(*os.File)
if !inOK || !outOK {
return false
}
return term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd()))
}
func SelectCommand(config Config) ([]string, bool, error) {
r := &Runner{Config: config}
rootDir, err := r.rootDir()
if err != nil {
return nil, false, err
}
versionFile, _, err := r.loadVersionFile(rootDir)
if err != nil {
return nil, false, err
}
options := BuildCommandOptions(config, versionFile.Version)
if len(options) == 0 {
return nil, false, fmt.Errorf("no release commands available for current version %s", versionFile.Version.String())
}
model := newCommandPickerModel(config, versionFile.Version)
finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil {
return nil, false, err
}
result := finalModel.(commandPickerModel)
if !result.confirmed {
return nil, false, nil
}
return append([]string(nil), result.selected.Args...), true, nil
}
func BuildCommandOptions(config Config, current Version) []CommandOption {
var options []CommandOption
seen := map[string]struct{}{}
for _, args := range candidateCommandArgs(current, config.AllowedChannels) {
command := formatReleaseCommand(args)
if _, exists := seen[command]; exists {
continue
}
next, err := ResolveNextVersion(current, args, config.AllowedChannels)
if err != nil {
continue
}
options = append(options, CommandOption{
Title: titleForArgs(args),
Description: descriptionForArgs(current, args, next),
Command: command,
Args: append([]string(nil), args...),
NextVersion: next,
Preview: buildPreview(config, current, args, next),
})
seen[command] = struct{}{}
}
return options
}
func candidateCommandArgs(current Version, allowedChannels []string) [][]string {
candidates := [][]string{
{"patch"},
{"minor"},
{"major"},
}
if current.Channel != "stable" {
candidates = append([][]string{{"stable"}}, candidates...)
}
for _, channel := range allowedChannels {
candidates = append(candidates,
[]string{channel},
[]string{"minor", channel},
[]string{"major", channel},
)
}
return candidates
}
func formatReleaseCommand(args []string) string {
return formatReleaseCommandWithExecution(args, ExecutionOptions{})
}
func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string {
var parts []string
parts = append(parts, "release")
if len(args) == 0 {
return strings.Join(parts, " ")
}
return strings.Join(append(parts, args...), " ")
}
func titleForArgs(args []string) string {
if len(args) == 0 {
return "Patch release"
}
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Patch release"
case "minor":
return "Minor release"
case "major":
return "Major release"
case "stable":
return "Promote to stable"
default:
return strings.ToUpper(args[0][:1]) + args[0][1:] + " prerelease"
}
case 2:
return capitalize(args[0]) + " " + args[1]
default:
return strings.Join(args, " ")
}
}
func descriptionForArgs(current Version, args []string, next Version) string {
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Bump patch and keep the current channel."
case "minor":
return "Bump minor and keep the current channel."
case "major":
return "Bump major and keep the current channel."
case "stable":
return "Promote the current prerelease to a stable release."
default:
if current.Channel == args[0] && current.Channel != "stable" {
return "Advance the current prerelease number."
}
return "Switch to the " + args[0] + " channel."
}
case 2:
return fmt.Sprintf("Bump %s and publish to %s.", args[0], args[1])
default:
return "Release " + next.String() + "."
}
}
func buildPreview(config Config, current Version, args []string, next Version) string {
execution := config.Execution.Normalize()
var lines []string
lines = append(lines,
"Command",
" "+formatReleaseCommandWithExecution(args, execution),
"",
"Version",
" Current: "+current.String(),
" Next: "+next.String(),
" Tag: "+next.Tag(),
"",
"Flow",
" Release steps: "+yesNo(strings.TrimSpace(config.ReleaseStepsJSON) != ""),
" Post-version: "+yesNo(strings.TrimSpace(config.PostVersion) != ""),
" nix fmt: yes",
" git commit: "+yesNo(execution.Commit),
" git tag: "+yesNo(execution.Tag),
" git push: "+yesNo(execution.Push),
)
return strings.Join(lines, "\n")
}
func yesNo(v bool) string {
if v {
return "yes"
}
return "no"
}
func capitalize(s string) string {
if s == "" {
return s
}
runes := []rune(s)
first := runes[0]
if first >= 'a' && first <= 'z' {
runes[0] = first - 32
}
return string(runes)
}
type commandPickerModel struct {
config Config
current Version
width int
height int
focusSection int
focusIndex int
bumpOptions []selectionOption
channelOptions []selectionOption
bumpCursor int
channelCursor int
confirmed bool
selected CommandOption
err string
}
type selectionOption struct {
Label string
Value string
}
func newCommandPickerModel(config Config, current Version) commandPickerModel {
return commandPickerModel{
config: config,
current: current,
bumpOptions: buildBumpOptions(current),
channelOptions: buildChannelOptions(current, config.AllowedChannels),
}
}
func (m commandPickerModel) Init() tea.Cmd {
return nil
}
func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k", "shift+tab":
m.moveFocus(-1)
case "down", "j", "tab":
m.moveFocus(1)
case " ":
m.selectFocused()
case "enter":
option, err := m.selectedOption()
if err != nil {
m.err = err.Error()
return m, nil
}
m.confirmed = true
m.selected = option
return m, tea.Quit
}
}
return m, nil
}
func (m commandPickerModel) View() string {
if len(m.bumpOptions) == 0 || len(m.channelOptions) == 0 {
return "No release commands available.\n"
}
preview := m.preview()
header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down to move through options, Space to select, Enter to run, q to cancel.\n", m.current.String())
sections := strings.Join([]string{
m.renderSection("Bump type", m.bumpOptions, m.bumpCursor, m.focusSection == 0, m.focusedOptionIndex()),
"",
m.renderSection("Channel", m.channelOptions, m.channelCursor, m.focusSection == 1, m.focusedOptionIndex()),
}, "\n")
if m.width >= 100 {
return header + "\n" + renderColumns(sections, preview, m.width)
}
return header + "\n" + sections + "\n\n" + preview + "\n"
}
func buildBumpOptions(current Version) []selectionOption {
options := []selectionOption{
{Label: "Patch", Value: "patch"},
{Label: "Minor", Value: "minor"},
{Label: "Major", Value: "major"},
}
if current.Channel != "stable" {
options = append(options, selectionOption{
Label: "None",
Value: "",
})
return options
}
options = append(options, selectionOption{
Label: "None",
Value: "",
})
return options
}
func buildChannelOptions(current Version, allowedChannels []string) []selectionOption {
options := []selectionOption{{
Label: "Current",
Value: current.Channel,
}}
if current.Channel != "stable" {
options = append(options, selectionOption{
Label: "Stable",
Value: "stable",
})
}
for _, channel := range allowedChannels {
if channel == current.Channel {
continue
}
options = append(options, selectionOption{
Label: capitalize(channel),
Value: channel,
})
}
return options
}
func (m *commandPickerModel) moveFocus(delta int) {
total := len(m.bumpOptions) + len(m.channelOptions)
if total == 0 {
return
}
index := wrapIndex(m.focusIndex+delta, total)
m.focusIndex = index
if index < len(m.bumpOptions) {
m.focusSection = 0
return
}
m.focusSection = 1
}
func (m *commandPickerModel) selectFocused() {
if m.focusSection == 0 {
m.bumpCursor = m.focusedOptionIndex()
return
}
m.channelCursor = m.focusedOptionIndex()
}
func wrapIndex(idx int, size int) int {
if size == 0 {
return 0
}
for idx < 0 {
idx += size
}
return idx % size
}
func (m commandPickerModel) focusedOptionIndex() int {
if m.focusSection == 0 {
return m.focusIndex
}
return m.focusIndex - len(m.bumpOptions)
}
func (m commandPickerModel) renderSection(title string, options []selectionOption, cursor int, focused bool, focusedIndex int) string {
lines := []string{title}
for i, option := range options {
pointer := " "
if focused && i == focusedIndex {
pointer = ">"
}
radio := "( )"
if i == cursor {
radio = "(*)"
}
lines = append(lines, fmt.Sprintf("%s %s %s", pointer, radio, option.Label))
}
return strings.Join(lines, "\n")
}
func (m commandPickerModel) selectedArgs() []string {
bump := m.bumpOptions[m.bumpCursor].Value
channel := m.channelOptions[m.channelCursor].Value
if bump == "" {
if channel == "stable" {
return []string{"stable"}
}
if channel == m.current.Channel {
if channel == "stable" {
return nil
}
return []string{channel}
}
return []string{channel}
}
if channel == m.current.Channel || (channel == "stable" && m.current.Channel == "stable") {
return []string{bump}
}
return []string{bump, channel}
}
func (m commandPickerModel) selectedOption() (CommandOption, error) {
args := m.selectedArgs()
next, err := ResolveNextVersion(m.current, args, m.config.AllowedChannels)
if err != nil {
return CommandOption{}, err
}
return CommandOption{
Title: titleForArgs(args),
Description: descriptionForArgs(m.current, args, next),
Command: formatReleaseCommand(args),
Args: append([]string(nil), args...),
NextVersion: next,
Preview: buildPreview(m.config, m.current, args, next),
}, nil
}
func (m commandPickerModel) preview() string {
option, err := m.selectedOption()
if err != nil {
lines := []string{
"Command",
" " + formatReleaseCommand(m.selectedArgs()),
"",
"Selection",
" " + err.Error(),
}
if m.err != "" {
lines = append(lines, "", "Error", " "+m.err)
}
return strings.Join(lines, "\n")
}
return option.Preview
}
func renderColumns(left string, right string, width int) string {
if width < 40 {
return left + "\n\n" + right
}
leftWidth := width / 2
rightWidth := width - leftWidth - 3
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
maxLines := len(leftLines)
if len(rightLines) > maxLines {
maxLines = len(rightLines)
}
var b strings.Builder
for i := 0; i < maxLines; i++ {
leftLine := ""
if i < len(leftLines) {
leftLine = leftLines[i]
}
rightLine := ""
if i < len(rightLines) {
rightLine = rightLines[i]
}
b.WriteString(padRight(trimRunes(leftLine, leftWidth), leftWidth))
b.WriteString(" | ")
b.WriteString(trimRunes(rightLine, rightWidth))
b.WriteByte('\n')
}
return b.String()
}
func padRight(s string, width int) string {
missing := width - len([]rune(s))
if missing <= 0 {
return s
}
return s + strings.Repeat(" ", missing)
}
func trimRunes(s string, width int) string {
runes := []rune(s)
if len(runes) <= width {
return s
}
if width <= 1 {
return string(runes[:width])
}
if width <= 3 {
return string(runes[:width])
}
return string(runes[:width-3]) + "..."
}

View File

@@ -0,0 +1,212 @@
package release
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestBuildCommandOptionsForStableVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.0.0")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta"},
ReleaseStepsJSON: `[{"kind":"writeFile","path":"VERSION.txt","text":"$FULL_VERSION\n"}]`,
PostVersion: "echo post",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
}, current)
want := map[string]string{
"release patch": "1.0.1",
"release minor": "1.1.0",
"release major": "2.0.0",
"release alpha": "1.0.1-alpha.1",
"release minor beta": "1.1.0-beta.1",
}
for command, nextVersion := range want {
option, ok := findOptionByCommand(options, command)
if !ok {
t.Fatalf("expected command %q in options", command)
}
if option.NextVersion.String() != nextVersion {
t.Fatalf("%s next version = %q, want %q", command, option.NextVersion.String(), nextVersion)
}
if !strings.Contains(option.Preview, "Release steps: yes") {
t.Fatalf("%s preview missing release steps marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "Post-version: yes") {
t.Fatalf("%s preview missing post-version marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "git push: yes") {
t.Fatalf("%s preview missing git push marker:\n%s", command, option.Preview)
}
}
}
func TestBuildCommandOptionsForPrereleaseVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.2.3-beta.2")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta", "rc"},
}, current)
stableOption, ok := findOptionByCommand(options, "release stable")
if !ok {
t.Fatalf("expected release stable option")
}
if stableOption.NextVersion.String() != "1.2.3" {
t.Fatalf("release stable next version = %q, want 1.2.3", stableOption.NextVersion.String())
}
betaOption, ok := findOptionByCommand(options, "release beta")
if !ok {
t.Fatalf("expected release beta option")
}
if betaOption.NextVersion.String() != "1.2.3-beta.3" {
t.Fatalf("release beta next version = %q, want 1.2.3-beta.3", betaOption.NextVersion.String())
}
patchOption, ok := findOptionByCommand(options, "release patch")
if !ok {
t.Fatalf("expected release patch option")
}
if patchOption.NextVersion.String() != "1.2.4-beta.1" {
t.Fatalf("release patch next version = %q, want 1.2.4-beta.1", patchOption.NextVersion.String())
}
}
func TestCommandPickerSelectionForStableVersion(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta"},
}, MustParseVersion(t, "1.2.3"))
model.bumpCursor = 1
model.channelCursor = 0
option, err := model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(): %v", err)
}
if got := strings.Join(option.Args, " "); got != "minor" {
t.Fatalf("selected args = %q, want %q", got, "minor")
}
if option.NextVersion.String() != "1.3.0" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.3.0")
}
model.bumpCursor = 3
model.channelCursor = 1
option, err = model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(channel only): %v", err)
}
if got := strings.Join(option.Args, " "); got != "alpha" {
t.Fatalf("selected args = %q, want %q", got, "alpha")
}
if option.NextVersion.String() != "1.2.4-alpha.1" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.4-alpha.1")
}
}
func TestCommandPickerSelectionForPrereleaseVersion(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta", "rc"},
}, MustParseVersion(t, "1.2.3-beta.2"))
model.bumpCursor = 3
model.channelCursor = 0
option, err := model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(current prerelease): %v", err)
}
if got := strings.Join(option.Args, " "); got != "beta" {
t.Fatalf("selected args = %q, want %q", got, "beta")
}
if option.NextVersion.String() != "1.2.3-beta.3" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3-beta.3")
}
model.channelCursor = 1
option, err = model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(promote): %v", err)
}
if got := strings.Join(option.Args, " "); got != "stable" {
t.Fatalf("selected args = %q, want %q", got, "stable")
}
if option.NextVersion.String() != "1.2.3" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3")
}
model.bumpCursor = 0
if _, err := model.selectedOption(); err == nil {
t.Fatalf("selectedOption(patch stable from prerelease) succeeded, want error")
}
}
func TestCommandPickerFocusMovesAcrossSections(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta"},
}, MustParseVersion(t, "1.2.3"))
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
if model.focusSection != 0 || model.focusIndex != 1 || model.bumpCursor != 0 {
t.Fatalf("after first down: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
if model.focusSection != 1 || model.focusIndex != 4 || model.channelCursor != 0 {
t.Fatalf("after moving into channel section: focusSection=%d focusIndex=%d channelCursor=%d", model.focusSection, model.focusIndex, model.channelCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = next.(commandPickerModel)
if model.channelCursor != 0 {
t.Fatalf("space changed selection unexpectedly: channelCursor=%d", model.channelCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
model = next.(commandPickerModel)
if model.focusSection != 0 || model.focusIndex != 3 || model.bumpCursor != 0 {
t.Fatalf("after moving back up: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = next.(commandPickerModel)
if model.bumpCursor != 3 {
t.Fatalf("space did not update bump selection: bumpCursor=%d", model.bumpCursor)
}
}
func findOptionByCommand(options []CommandOption, command string) (CommandOption, bool) {
for _, option := range options {
if option.Command == command {
return option, true
}
}
return CommandOption{}, false
}

View File

@@ -0,0 +1,261 @@
package release
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var versionPattern = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([A-Za-z]+)\.([0-9]+))?$`)
type Version struct {
Major int
Minor int
Patch int
Channel string
Prerelease int
}
func ParseVersion(raw string) (Version, error) {
match := versionPattern.FindStringSubmatch(raw)
if match == nil {
return Version{}, fmt.Errorf("invalid version %q (expected x.y.z or x.y.z-channel.N)", raw)
}
major, _ := strconv.Atoi(match[1])
minor, _ := strconv.Atoi(match[2])
patch, _ := strconv.Atoi(match[3])
v := Version{
Major: major,
Minor: minor,
Patch: patch,
Channel: "stable",
}
if match[4] != "" {
pre, _ := strconv.Atoi(match[5])
v.Channel = match[4]
v.Prerelease = pre
}
return v, nil
}
func (v Version) String() string {
if v.Channel == "" || v.Channel == "stable" {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
return fmt.Sprintf("%d.%d.%d-%s.%d", v.Major, v.Minor, v.Patch, v.Channel, v.Prerelease)
}
func (v Version) BaseString() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v Version) Tag() string {
return "v" + v.String()
}
func (v Version) cmp(other Version) int {
if v.Major != other.Major {
return compareInt(v.Major, other.Major)
}
if v.Minor != other.Minor {
return compareInt(v.Minor, other.Minor)
}
if v.Patch != other.Patch {
return compareInt(v.Patch, other.Patch)
}
if v.Channel == "stable" && other.Channel != "stable" {
return 1
}
if v.Channel != "stable" && other.Channel == "stable" {
return -1
}
if v.Channel == "stable" && other.Channel == "stable" {
return 0
}
if v.Channel != other.Channel {
return comparePrerelease(v.Channel, other.Channel)
}
return compareInt(v.Prerelease, other.Prerelease)
}
func ResolveNextVersion(current Version, args []string, allowedChannels []string) (Version, error) {
currentFull := current.String()
action := ""
rest := args
if len(rest) > 0 {
action = rest[0]
rest = rest[1:]
}
if action == "set" {
return resolveSetVersion(current, currentFull, rest, allowedChannels)
}
part := ""
targetChannel := ""
wasChannelOnly := false
switch action {
case "":
part = "patch"
case "major", "minor", "patch":
part = action
if len(rest) > 0 {
targetChannel = rest[0]
rest = rest[1:]
}
case "stable", "full":
if len(rest) > 0 {
return Version{}, fmt.Errorf("%q takes no second argument", action)
}
targetChannel = "stable"
default:
if contains(allowedChannels, action) {
if len(rest) > 0 {
return Version{}, errors.New("channel-only bump takes no second argument")
}
targetChannel = action
wasChannelOnly = true
} else {
return Version{}, fmt.Errorf("unknown argument %q", action)
}
}
if targetChannel == "" {
targetChannel = current.Channel
}
if err := validateChannel(targetChannel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && targetChannel == "stable" && action != "stable" && action != "full" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
if part == "" && wasChannelOnly && current.Channel == "stable" && targetChannel != "stable" {
part = "patch"
}
next := current
oldBase := next.BaseString()
oldChannel := next.Channel
oldPre := next.Prerelease
if part != "" {
bumpVersion(&next, part)
}
if targetChannel == "stable" {
next.Channel = "stable"
next.Prerelease = 0
} else {
if next.BaseString() == oldBase && targetChannel == oldChannel && oldPre > 0 {
next.Prerelease = oldPre + 1
} else {
next.Prerelease = 1
}
next.Channel = targetChannel
}
if next.String() == currentFull {
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
}
return next, nil
}
func resolveSetVersion(current Version, currentFull string, args []string, allowedChannels []string) (Version, error) {
if len(args) == 0 {
return Version{}, errors.New("'set' requires a version argument")
}
next, err := ParseVersion(args[0])
if err != nil {
return Version{}, err
}
if err := validateChannel(next.Channel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && next.Channel == "stable" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
switch next.cmp(current) {
case 0:
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
case -1:
return Version{}, fmt.Errorf("%s is lower than current %s", next.String(), currentFull)
}
return next, nil
}
func validateChannel(channel string, allowedChannels []string) error {
if channel == "" || channel == "stable" {
return nil
}
if contains(allowedChannels, channel) {
return nil
}
return fmt.Errorf("unknown channel %q", channel)
}
func bumpVersion(v *Version, part string) {
switch part {
case "major":
v.Major++
v.Minor = 0
v.Patch = 0
case "minor":
v.Minor++
v.Patch = 0
case "patch":
v.Patch++
default:
panic("unknown bump part: " + part)
}
}
func compareInt(left int, right int) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func comparePrerelease(left string, right string) int {
values := []string{left, right}
sort.Slice(values, func(i int, j int) bool {
return semverLikeLess(values[i], values[j])
})
switch {
case left == right:
return 0
case values[len(values)-1] == left:
return 1
default:
return -1
}
}
func semverLikeLess(left string, right string) bool {
leftParts := strings.FieldsFunc(left, func(r rune) bool { return r == '.' || r == '-' })
rightParts := strings.FieldsFunc(right, func(r rune) bool { return r == '.' || r == '-' })
for i := 0; i < len(leftParts) && i < len(rightParts); i++ {
li, lerr := strconv.Atoi(leftParts[i])
ri, rerr := strconv.Atoi(rightParts[i])
switch {
case lerr == nil && rerr == nil:
if li != ri {
return li < ri
}
default:
if leftParts[i] != rightParts[i] {
return leftParts[i] < rightParts[i]
}
}
}
return len(leftParts) < len(rightParts)
}

View File

@@ -0,0 +1,112 @@
package release
import (
"bytes"
"fmt"
"os"
"strconv"
"strings"
)
type Metadata struct {
lines []string
}
func (m Metadata) Lines() []string {
return append([]string(nil), m.lines...)
}
func (m Metadata) Get(key string) string {
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
return strings.TrimPrefix(line, key+"=")
}
}
return ""
}
func (m *Metadata) Set(key string, value string) {
for i, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
m.lines[i] = key + "=" + value
return
}
}
m.lines = append(m.lines, key+"="+value)
}
func (m *Metadata) Unset(key string) {
filtered := make([]string, 0, len(m.lines))
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
continue
}
filtered = append(filtered, line)
}
m.lines = filtered
}
type VersionFile struct {
Version Version
Metadata Metadata
}
func (f VersionFile) Current() Version {
return f.Version
}
func ReadVersionFile(path string) (*VersionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
lines := strings.Split(text, "\n")
if len(lines) < 3 {
return nil, fmt.Errorf("invalid VERSION file %q", path)
}
base := strings.TrimSpace(lines[0])
channel := strings.TrimSpace(lines[1])
preRaw := strings.TrimSpace(lines[2])
if channel == "" {
channel = "stable"
}
rawVersion := base
if channel != "stable" {
rawVersion = fmt.Sprintf("%s-%s.%s", base, channel, preRaw)
}
version, err := ParseVersion(rawVersion)
if err != nil {
return nil, err
}
metaLines := make([]string, 0, len(lines)-3)
for _, line := range lines[3:] {
if line == "" {
continue
}
metaLines = append(metaLines, line)
}
return &VersionFile{
Version: version,
Metadata: Metadata{lines: metaLines},
}, nil
}
func (f *VersionFile) Write(path string) error {
channel := f.Version.Channel
pre := strconv.Itoa(f.Version.Prerelease)
if channel == "" || channel == "stable" {
channel = "stable"
pre = "0"
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "%s\n%s\n%s\n", f.Version.BaseString(), channel, pre)
for _, line := range f.Metadata.lines {
fmt.Fprintln(&buf, line)
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}

View File

@@ -1,57 +1,18 @@
# release.nix
{
pkgs,
postVersion ? "",
release ? [ ],
# Unified list, processed in declaration order:
# { file = "path/to/file"; content = "..."; } — write file
# { run = "shell snippet..."; } — run script
channels ? [
"alpha"
"beta"
"rc"
"internal"
],
extraRuntimeInputs ? [ ],
flake-parts,
nixpkgs,
treefmt-nix,
lefthookNix,
releaseScriptPath ? ./release.sh,
shellHookTemplatePath ? ../repo-lib/shell-hook.sh,
}:
let
channelList = pkgs.lib.concatStringsSep " " channels;
releaseScript = pkgs.lib.concatMapStrings (
entry:
if entry ? file then
''
mkdir -p "$(dirname "${entry.file}")"
cat > "${entry.file}" << NIXEOF
${entry.content}
NIXEOF
log "Generated version file: ${entry.file}"
''
else if entry ? run then
''
${entry.run}
''
else
builtins.throw "release entry must have either 'file' or 'run'"
) release;
script =
builtins.replaceStrings
[ "__CHANNEL_LIST__" "__RELEASE_STEPS__" "__POST_VERSION__" ]
[ channelList releaseScript postVersion ]
(builtins.readFile ./release.sh);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
]
++ extraRuntimeInputs;
text = script;
import ../repo-lib/lib.nix {
inherit
flake-parts
nixpkgs
treefmt-nix
lefthookNix
releaseScriptPath
shellHookTemplatePath
;
}

View File

@@ -2,393 +2,18 @@
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD=""
CREATED_TAG=""
REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)"
export REPO_LIB_RELEASE_ROOT_DIR
export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__'
REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF'
__RELEASE_STEPS_JSON__
EOF
)"
export REPO_LIB_RELEASE_STEPS_JSON
REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF'
__POST_VERSION__
EOF
)"
export REPO_LIB_RELEASE_POST_VERSION
# ── logging ────────────────────────────────────────────────────────────────
log() { echo "[release] $*" >&2; }
usage() {
local cmd
cmd="$(basename "$0")"
printf '%s\n' \
"Usage:" \
" ${cmd} [major|minor|patch] [stable|__CHANNEL_LIST__]" \
" ${cmd} set <version>" \
"" \
"Bump types:" \
" (none) bump patch, keep current channel" \
" major/minor/patch bump the given part, keep current channel" \
" stable / full remove prerelease suffix" \
" __CHANNEL_LIST__ switch channel (bumps prerelease number if same base+channel)" \
"" \
"Examples:" \
" ${cmd} # patch bump on current channel" \
" ${cmd} minor # minor bump on current channel" \
" ${cmd} patch beta # patch bump, switch to beta channel" \
" ${cmd} rc # switch to rc channel" \
" ${cmd} stable # promote to stable release" \
" ${cmd} set 1.2.3" \
" ${cmd} set 1.2.3-beta.1"
}
# ── git ────────────────────────────────────────────────────────────────────
require_clean_git() {
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: git working tree is not clean. Commit or stash changes first." >&2
exit 1
fi
}
revert_on_failure() {
local status=$?
if [[ -n $START_HEAD ]]; then
log "Release failed — reverting to $START_HEAD"
git reset --hard "$START_HEAD"
fi
if [[ -n $CREATED_TAG ]]; then
git tag -d "$CREATED_TAG" >/dev/null 2>&1 || true
fi
exit $status
}
# ── version parsing ────────────────────────────────────────────────────────
parse_base_version() {
local v="$1"
if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Error: invalid base version '$v' (expected x.y.z)" >&2
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
}
parse_full_version() {
local v="$1"
CHANNEL="stable"
PRERELEASE_NUM=""
if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
CHANNEL="${BASH_REMATCH[2]}"
PRERELEASE_NUM="${BASH_REMATCH[3]}"
elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "Error: invalid version '$v' (expected x.y.z or x.y.z-channel.N)" >&2
exit 1
fi
parse_base_version "$BASE_VERSION"
}
validate_channel() {
local ch="$1"
[[ $ch == "stable" ]] && return 0
local valid_channels="__CHANNEL_LIST__"
for c in $valid_channels; do
[[ $ch == "$c" ]] && return 0
done
echo "Error: unknown channel '$ch'. Valid channels: stable $valid_channels" >&2
exit 1
}
version_cmp() {
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
# Stable > prerelease for same base version
local v1="$1" v2="$2"
[[ $v1 == "$v2" ]] && return 0
local base1="" pre1="" base2="" pre2=""
if [[ $v1 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base1="${BASH_REMATCH[1]}"
pre1="${BASH_REMATCH[2]}"
else
base1="$v1"
fi
if [[ $v2 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base2="${BASH_REMATCH[1]}"
pre2="${BASH_REMATCH[2]}"
else
base2="$v2"
fi
if [[ $base1 != "$base2" ]]; then
local highest_base
highest_base=$(printf '%s\n%s\n' "$base1" "$base2" | sort -V | tail -n1)
[[ $highest_base == "$base1" ]] && return 1 || return 2
fi
[[ -z $pre1 && -n $pre2 ]] && return 1 # stable > prerelease
[[ -n $pre1 && -z $pre2 ]] && return 2 # prerelease < stable
[[ -z $pre1 && -z $pre2 ]] && return 0 # both stable
local highest_pre
highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1)
[[ $highest_pre == "$pre1" ]] && return 1 || return 2
}
bump_base_version() {
case "$1" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch) PATCH=$((PATCH + 1)) ;;
*)
echo "Error: unknown bump part '$1'" >&2
exit 1
;;
esac
BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}"
}
compute_full_version() {
if [[ $CHANNEL == "stable" || -z $CHANNEL ]]; then
FULL_VERSION="$BASE_VERSION"
else
FULL_VERSION="${BASE_VERSION}-${CHANNEL}.${PRERELEASE_NUM:-1}"
fi
FULL_TAG="v$FULL_VERSION"
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG
}
# ── gitlint ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() {
[[ ! -f $GITLINT_FILE ]] && return 0
awk '
/^\[title-match-regex\]$/ { in_section=1; next }
/^\[/ { in_section=0 }
in_section && /^regex=/ { sub(/^regex=/, ""); print; exit }
' "$GITLINT_FILE"
}
validate_commit_message() {
local msg="$1"
local regex
regex="$(get_gitlint_title_regex)"
if [[ -n $regex && ! $msg =~ $regex ]]; then
echo "Error: commit message does not match .gitlint title-match-regex" >&2
echo "Regex: $regex" >&2
echo "Message: $msg" >&2
exit 1
fi
}
# ── version file generation ────────────────────────────────────────────────
run_release_steps() {
:
__RELEASE_STEPS__
}
# ── version source (built-in) ──────────────────────────────────────────────
# Initializes $ROOT_DIR/VERSION from git tags if it doesn't exist.
# Must be called outside of any subshell so log output stays on stderr
# and never contaminates the stdout of do_read_version.
init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then
return 0
fi
local highest_tag=""
while IFS= read -r raw_tag; do
local tag="${raw_tag#v}"
[[ $tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$ ]] || continue
if [[ -z $highest_tag ]]; then
highest_tag="$tag"
continue
fi
local cmp_status=0
version_cmp "$tag" "$highest_tag" || cmp_status=$?
[[ $cmp_status -eq 1 ]] && highest_tag="$tag"
done < <(git tag --list)
[[ -z $highest_tag ]] && highest_tag="0.0.1"
parse_full_version "$highest_tag"
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION"
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
}
do_read_version() {
local base_line channel_line n_line
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
n_line="$(sed -n '3p' "$ROOT_DIR/VERSION" | tr -d '\r')"
if [[ -z $channel_line || $channel_line == "stable" ]]; then
printf '%s\n' "$base_line"
else
printf '%s-%s.%s\n' "$base_line" "$channel_line" "$n_line"
fi
}
do_write_version() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION"
}
# ── user-provided hook ─────────────────────────────────────────────────────
do_post_version() {
:
__POST_VERSION__
}
# ── main ───────────────────────────────────────────────────────────────────
main() {
[[ ${1-} == "-h" || ${1-} == "--help" ]] && usage && exit 0
require_clean_git
START_HEAD="$(git rev-parse HEAD)"
trap revert_on_failure ERR
# Initialize VERSION file outside any subshell so log lines never
# bleed into the stdout capture below.
init_version_file
local raw_version
raw_version="$(do_read_version | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$' | tail -n1)"
if [[ -z $raw_version ]]; then
echo "Error: could not determine current version from VERSION source" >&2
exit 1
fi
parse_full_version "$raw_version"
log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}"
local action="${1-}"
shift || true
if [[ $action == "set" ]]; then
local newv="${1-}"
[[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1
compute_full_version
local current_full="$FULL_VERSION"
parse_full_version "$newv"
validate_channel "$CHANNEL"
compute_full_version
local cmp_status=0
version_cmp "$FULL_VERSION" "$current_full" || cmp_status=$?
case $cmp_status in
0)
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
;;
2)
echo "Error: $FULL_VERSION is lower than current $current_full" >&2
exit 1
;;
esac
else
local part="" target_channel=""
case "$action" in
"") part="patch" ;;
major | minor | patch)
part="$action"
target_channel="${1-}"
;;
stable | full)
[[ -n ${1-} ]] && echo "Error: '$action' takes no second argument" >&2 && usage && exit 1
target_channel="stable"
;;
*)
# check if action is a valid channel
local is_channel=0
for c in __CHANNEL_LIST__; do
[[ $action == "$c" ]] && is_channel=1 && break
done
if [[ $is_channel == 1 ]]; then
[[ -n ${1-} ]] && echo "Error: channel-only bump takes no second argument" >&2 && usage && exit 1
target_channel="$action"
else
echo "Error: unknown argument '$action'" >&2
usage
exit 1
fi
;;
esac
[[ -z $target_channel ]] && target_channel="$CHANNEL"
[[ $target_channel == "full" ]] && target_channel="stable"
validate_channel "$target_channel"
local old_base="$BASE_VERSION" old_channel="$CHANNEL" old_pre="$PRERELEASE_NUM"
[[ -n $part ]] && bump_base_version "$part"
if [[ $target_channel == "stable" ]]; then
CHANNEL="stable"
PRERELEASE_NUM=""
else
if [[ $BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then
PRERELEASE_NUM=$((old_pre + 1))
else
PRERELEASE_NUM=1
fi
CHANNEL="$target_channel"
fi
fi
compute_full_version
log "Releasing $FULL_VERSION"
do_write_version
log "Updated version source"
run_release_steps
log "Release steps done"
do_post_version
log "Post-version hook done"
(cd "$ROOT_DIR" && nix fmt)
log "Formatted files"
git add -A
local commit_msg="chore(release): v$FULL_VERSION"
validate_commit_message "$commit_msg"
git commit -m "$commit_msg"
log "Created commit"
git tag "$FULL_TAG"
CREATED_TAG="$FULL_TAG"
log "Tagged $FULL_TAG"
git push
git push --tags
log "Done — released $FULL_TAG"
trap - ERR
}
main "$@"
exec __RELEASE_RUNNER__ "$@"

100
packages/repo-lib/lib.nix Normal file
View File

@@ -0,0 +1,100 @@
{
flake-parts,
nixpkgs,
treefmt-nix,
lefthookNix,
releaseScriptPath,
shellHookTemplatePath,
}:
let
defaults = import ./lib/defaults.nix { };
common = import ./lib/common.nix { inherit nixpkgs; };
normalizeShellBanner =
rawBanner:
let
banner = defaults.defaultShellBanner // rawBanner;
in
if
!(builtins.elem banner.style [
"simple"
"pretty"
])
then
throw "repo-lib: config.shell.banner.style must be one of simple or pretty"
else
banner;
toolsModule = import ./lib/tools.nix {
lib = common.lib;
};
hooksModule = import ./lib/hooks.nix {
inherit (common) lib sanitizeName;
};
shellModule = import ./lib/shell.nix {
inherit (common)
lib
;
inherit
treefmt-nix
lefthookNix
shellHookTemplatePath
;
inherit (defaults)
defaultShellBanner
;
inherit normalizeShellBanner;
inherit (hooksModule)
normalizeLefthookConfig
parallelHookStageConfig
checkToLefthookConfig
hookToLefthookConfig
;
};
releaseModule = import ./lib/release.nix {
inherit (common)
lib
importPkgs
;
inherit
nixpkgs
releaseScriptPath
;
inherit (defaults)
defaultReleaseChannels
;
};
repoModule = import ./lib/repo.nix {
inherit
flake-parts
nixpkgs
;
inherit (common)
lib
importPkgs
duplicateStrings
mergeUniqueAttrs
;
inherit (defaults)
supportedSystems
defaultReleaseChannels
;
inherit (toolsModule)
normalizeStrictTool
;
inherit (hooksModule)
normalizeLefthookConfig
;
inherit normalizeShellBanner;
inherit (shellModule)
buildShellArtifacts
;
inherit (releaseModule)
mkRelease
;
};
in
{
systems.default = defaults.supportedSystems;
inherit (toolsModule) tools;
inherit (repoModule) normalizeRepoConfig mkRepo;
inherit (releaseModule) mkRelease;
}

View File

@@ -0,0 +1,29 @@
{ nixpkgs }:
let
lib = nixpkgs.lib;
in
{
inherit lib;
importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; };
duplicateStrings =
names:
lib.unique (
builtins.filter (
name: builtins.length (builtins.filter (candidate: candidate == name) names) > 1
) names
);
mergeUniqueAttrs =
label: left: right:
let
overlap = builtins.attrNames (lib.intersectAttrs left right);
in
if overlap != [ ] then
throw "repo-lib: duplicate ${label}: ${lib.concatStringsSep ", " overlap}"
else
left // right;
sanitizeName = name: lib.strings.sanitizeDerivationName name;
}

View File

@@ -0,0 +1,26 @@
{ }:
{
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
defaultReleaseChannels = [
"alpha"
"beta"
"rc"
"internal"
];
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
}

View File

@@ -0,0 +1,116 @@
{
lib,
sanitizeName,
}:
let
hookStageFileArgs =
stage: passFilenames:
if !passFilenames then
""
else if stage == "pre-commit" then
" {staged_files}"
else if stage == "pre-push" then
" {push_files}"
else if stage == "commit-msg" then
" {1}"
else
throw "repo-lib: unsupported lefthook stage '${stage}'";
normalizeHookStage =
hookName: stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
"commit-msg"
]
then
stage
else
throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook";
in
{
inherit hookStageFileArgs normalizeHookStage;
checkToLefthookConfig =
pkgs: name: rawCheck:
let
check = {
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ ];
}
// rawCheck;
wrapperName = "repo-lib-check-${sanitizeName name}";
wrapper = pkgs.writeShellApplication {
name = wrapperName;
runtimeInputs = check.runtimeInputs;
text = ''
set -euo pipefail
${check.command}
'';
};
in
if !(check ? command) then
throw "repo-lib: check '${name}' is missing 'command'"
else if
!(builtins.elem check.stage [
"pre-commit"
"pre-push"
])
then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else
lib.setAttrByPath [ check.stage "commands" name ] {
run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
};
normalizeLefthookConfig =
label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset";
hookToLefthookConfig =
name: hook:
let
supportedFields = [
"description"
"enable"
"entry"
"name"
"package"
"pass_filenames"
"stages"
];
unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) (
builtins.attrNames hook
);
stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]);
passFilenames = hook.pass_filenames or false;
in
if unsupportedFields != [ ] then
throw ''
repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields}
''
else if !(hook ? entry) then
throw "repo-lib: hook '${name}' is missing 'entry'"
else
lib.foldl' lib.recursiveUpdate { } (
builtins.map (
stage:
lib.setAttrByPath [ stage "commands" name ] {
run = "${hook.entry}${hookStageFileArgs stage passFilenames}";
}
) stages
);
parallelHookStageConfig =
stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
]
then
lib.setAttrByPath [ stage "parallel" ] true
else
{ };
}

View File

@@ -0,0 +1,105 @@
{
lib,
nixpkgs,
releaseScriptPath,
defaultReleaseChannels,
importPkgs,
}:
let
normalizeReleaseStep =
step:
if step ? writeFile then
{
kind = "writeFile";
path = step.writeFile.path;
text = step.writeFile.text;
}
else if step ? replace then
{
kind = "replace";
path = step.replace.path;
regex = step.replace.regex;
replacement = step.replace.replacement;
}
else if step ? versionMetaSet then
{
kind = "versionMetaSet";
key = step.versionMetaSet.key;
value = step.versionMetaSet.value;
}
else if step ? versionMetaUnset then
{
kind = "versionMetaUnset";
key = step.versionMetaUnset.key;
}
else
throw "repo-lib: release step must contain one of writeFile, replace, versionMetaSet, or versionMetaUnset";
normalizeReleaseConfig =
raw:
let
steps = if raw ? steps then builtins.map normalizeReleaseStep raw.steps else [ ];
in
{
postVersion = raw.postVersion or "";
channels = raw.channels or defaultReleaseChannels;
runtimeInputs = raw.runtimeInputs or [ ];
steps = steps;
};
mkRelease =
{
system,
nixpkgsInput ? nixpkgs,
...
}@rawArgs:
let
pkgs = importPkgs nixpkgsInput system;
release = normalizeReleaseConfig rawArgs;
channelList = lib.concatStringsSep " " release.channels;
releaseStepsJson = builtins.toJSON release.steps;
releaseRunner = pkgs.buildGoModule {
pname = "repo-lib-release-runner";
version = "0.0.0";
src = ../../release;
vendorHash = "sha256-fGFteYruAda2MBHkKgbTeCpIgO30tKCa+tzF6HcUvWM=";
subPackages = [ "cmd/release" ];
};
script =
builtins.replaceStrings
[
"__CHANNEL_LIST__"
"__RELEASE_STEPS_JSON__"
"__POST_VERSION__"
"__RELEASE_RUNNER__"
]
[
channelList
releaseStepsJson
release.postVersion
(lib.getExe' releaseRunner "release")
]
(builtins.readFile releaseScriptPath);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
]
++ release.runtimeInputs;
text = script;
};
in
{
inherit
normalizeReleaseStep
normalizeReleaseConfig
mkRelease
;
}

View File

@@ -0,0 +1,195 @@
{
flake-parts,
nixpkgs,
lib,
importPkgs,
duplicateStrings,
mergeUniqueAttrs,
supportedSystems,
defaultReleaseChannels,
normalizeStrictTool,
normalizeLefthookConfig,
normalizeShellBanner,
buildShellArtifacts,
mkRelease,
}:
let
normalizeRepoConfig =
rawConfig:
let
merged = lib.recursiveUpdate {
includeStandardPackages = true;
shell = {
env = { };
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
lefthook = { };
release = null;
} rawConfig;
release =
if merged.release == null then
null
else
{
channels = defaultReleaseChannels;
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
// merged.release;
in
if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then
throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true"
else
merged
// {
inherit release;
shell = merged.shell // {
banner = normalizeShellBanner merged.shell.banner;
};
};
buildRepoSystemOutputs =
{
pkgs,
system,
src,
nixpkgsInput,
normalizedConfig,
userPerSystem,
}:
let
perSystemResult = {
tools = [ ];
shell = { };
checks = { };
lefthook = { };
packages = { };
apps = { };
}
// userPerSystem {
inherit pkgs system;
lib = nixpkgs.lib;
config = normalizedConfig;
};
strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools);
mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks;
mergedLefthookConfig =
lib.recursiveUpdate (normalizeLefthookConfig "config.lefthook" normalizedConfig.lefthook)
(normalizeLefthookConfig "perSystem.lefthook" (perSystemResult.lefthook or { }));
shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { });
env =
if duplicateToolNames != [ ] then
throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}"
else
buildShellArtifacts {
inherit
pkgs
system
src
;
includeStandardPackages = normalizedConfig.includeStandardPackages;
formatting = normalizedConfig.formatting;
tools = strictTools;
checkSpecs = mergedChecks;
lefthookConfig = mergedLefthookConfig;
shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ];
};
releasePackages =
if normalizedConfig.release == null then
{ }
else
{
release = mkRelease {
inherit system;
nixpkgsInput = nixpkgsInput;
channels = normalizedConfig.release.channels;
steps = normalizedConfig.release.steps;
postVersion = normalizedConfig.release.postVersion;
runtimeInputs = normalizedConfig.release.runtimeInputs;
};
};
in
{
checks = env.checks;
formatter = env.formatter;
shell = env.shell;
packages = mergeUniqueAttrs "package" releasePackages perSystemResult.packages;
apps = perSystemResult.apps;
};
in
{
inherit normalizeRepoConfig;
mkRepo =
{
self,
nixpkgs,
src ? ./.,
systems ? supportedSystems,
config ? { },
perSystem ? (
{
pkgs,
system,
lib,
config,
}:
{ }
),
}:
let
normalizedConfig = normalizeRepoConfig config;
userPerSystem = perSystem;
in
flake-parts.lib.mkFlake
{
inputs = {
inherit self nixpkgs;
flake-parts = flake-parts;
};
}
{
inherit systems;
perSystem =
{
pkgs,
system,
...
}:
let
systemOutputs = buildRepoSystemOutputs {
inherit
pkgs
system
src
normalizedConfig
;
nixpkgsInput = nixpkgs;
userPerSystem = userPerSystem;
};
in
{
devShells.default = systemOutputs.shell;
inherit (systemOutputs)
apps
checks
formatter
packages
;
};
};
}

View File

@@ -0,0 +1,221 @@
{
lib,
treefmt-nix,
lefthookNix,
shellHookTemplatePath,
defaultShellBanner,
normalizeShellBanner,
normalizeLefthookConfig,
parallelHookStageConfig,
checkToLefthookConfig,
hookToLefthookConfig,
}:
let
buildShellHook =
{
hooksShellHook,
shellEnvScript,
bootstrap,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
let
template = builtins.readFile shellHookTemplatePath;
in
builtins.replaceStrings
[
"@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
hooksShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
shellBannerScript
extraShellText
]
template;
in
{
inherit buildShellHook;
buildShellArtifacts =
{
pkgs,
system,
src,
includeStandardPackages ? true,
formatting,
tools ? [ ],
shellConfig ? {
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ],
}:
let
standardPackages = with pkgs; [
nixfmt
gitlint
gitleaks
shfmt
];
toolPackages = lib.filter (pkg: pkg != null) (builtins.map (tool: tool.package or null) tools);
selectedStandardPackages = lib.optionals includeStandardPackages standardPackages;
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
}
// formatting.programs;
settings.formatter = { } // formatting.settings;
};
treefmtWrapper = treefmtEval.config.build.wrapper;
lefthookBinWrapper = pkgs.writeShellScript "lefthook-dumb-term" ''
exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@"
'';
normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
lefthookCheck = lefthookNix.lib.${system}.run {
inherit src;
config = lib.foldl' lib.recursiveUpdate { } (
[
{
output = [
"failure"
"summary"
];
}
(parallelHookStageConfig "pre-commit")
(parallelHookStageConfig "pre-push")
(lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
run = "${treefmtWrapper}/bin/treefmt --no-cache {staged_files}";
stage_fixed = true;
})
(lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] {
run = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
})
(lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
})
]
++ lib.mapAttrsToList (name: check: checkToLefthookConfig pkgs name check) checkSpecs
++ lib.mapAttrsToList hookToLefthookConfig rawHookEntries
++ [ normalizedLefthookConfig ]
);
};
selectedCheckOutputs = {
formatting-check = treefmtEval.config.build.check src;
hook-check = lefthookCheck;
lefthook-check = lefthookCheck;
};
toolNames = builtins.map (tool: tool.name) tools;
toolNameWidth =
if toolNames == [ ] then
0
else
builtins.foldl' (maxWidth: name: lib.max maxWidth (builtins.stringLength name)) 0 toolNames;
toolLabelWidth = toolNameWidth + 1;
shellEnvScript = lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value: "export ${name}=${lib.escapeShellArg (toString value)}"
) shellConfig.env
);
banner = normalizeShellBanner (shellConfig.banner or { });
shellBannerScript =
if banner.style == "pretty" then
''
repo_lib_print_pretty_header \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (tool: ''
repo_lib_print_pretty_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
'') tools
+ ''
repo_lib_print_pretty_footer \
${lib.escapeShellArg banner.borderColor}
''
else
''
repo_lib_print_simple_header \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (tool: ''
repo_lib_print_simple_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
'') tools
+ ''
printf "\n"
'';
in
{
checks = selectedCheckOutputs;
formatter = treefmtWrapper;
shell = pkgs.mkShell {
LEFTHOOK_BIN = builtins.toString lefthookBinWrapper;
packages = lib.unique (
selectedStandardPackages
++ extraPackages
++ toolPackages
++ [
pkgs.lefthook
treefmtWrapper
]
);
shellHook = buildShellHook {
hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
}
// selectedCheckOutputs;
}

View File

@@ -0,0 +1,90 @@
{
lib,
}:
let
normalizeStrictTool =
pkgs: tool:
let
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
}
// (tool.version or { });
banner = {
color = "YELLOW";
icon = null;
iconColor = null;
}
// (tool.banner or { });
executable =
if tool ? command && tool.command != null then
tool.command
else if tool ? exe && tool.exe != null then
"${lib.getExe' tool.package tool.exe}"
else
"${lib.getExe tool.package}";
in
if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else
{
kind = "strict";
inherit executable version banner;
name = tool.name;
package = tool.package or null;
required = tool.required or true;
};
in
{
inherit normalizeStrictTool;
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
package
exe
version
banner
required
;
};
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
command
version
banner
required
;
};
simple =
name: package: args:
fromPackage {
inherit name package;
version.args = args;
};
};
}

View File

@@ -0,0 +1,233 @@
@HOOKS_SHELL_HOOK@
if [ -t 1 ]; then
command -v tput >/dev/null 2>&1 && tput clear || printf '\033c'
fi
GREEN=$'\033[1;32m'
CYAN=$'\033[1;36m'
YELLOW=$'\033[1;33m'
BLUE=$'\033[1;34m'
RED=$'\033[1;31m'
MAGENTA=$'\033[1;35m'
WHITE=$'\033[1;37m'
GRAY=$'\033[0;90m'
BOLD=$'\033[1m'
UNDERLINE=$'\033[4m'
RESET=$'\033[0m'
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
repo_lib_capture_tool() {
local required="$1"
local line_no="$2"
local group_no="$3"
local regex="$4"
local match_regex="$5"
local executable="$6"
shift 6
local output=""
local selected=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! output="$("$executable" "$@" 2>&1)"; then
REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2
return 1
fi
if [ -n "$match_regex" ]; then
selected="$(printf '%s\n' "$output" | grep -E -m 1 "$match_regex" || true)"
else
selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")"
fi
selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -n "$regex" ]; then
if [[ "$selected" =~ $regex ]]; then
version="${BASH_REMATCH[$group_no]}"
else
REPO_LIB_TOOL_ERROR="version parse failed"
printf "%s\n" "$output" >&2
return 1
fi
else
version="$selected"
fi
if [ -z "$version" ]; then
REPO_LIB_TOOL_ERROR="empty version"
printf "%s\n" "$output" >&2
return 1
fi
REPO_LIB_TOOL_VERSION="$version"
return 0
}
repo_lib_print_simple_header() {
local title_color_name="$1"
local icon="$2"
local title="$3"
local subtitle_color_name="$4"
local subtitle="$5"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n\n"
}
repo_lib_print_simple_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local line_no="$6"
local group_no="$7"
local regex="$8"
local match_regex="$9"
local executable="${10}"
shift 10
local color="${!color_name:-$YELLOW}"
local effective_icon_color_name="$icon_color_name"
local icon_color=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then
icon_color="${!effective_icon_color_name:-$color}"
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$icon_color" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION"
else
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$RED" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR"
if [ "$required" = "1" ]; then
exit 1
fi
fi
}
repo_lib_print_pretty_header() {
local border_color_name="$1"
local title_color_name="$2"
local icon="$3"
local title="$4"
local subtitle_color_name="$5"
local subtitle="$6"
local border_color="${!border_color_name:-$BLUE}"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s╭─%s %s" "$border_color" "$RESET" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n"
}
repo_lib_print_pretty_row() {
local border_color_name="$1"
local icon="$2"
local icon_color_name="$3"
local label="$4"
local value="$5"
local value_color_name="$6"
local border_color="${!border_color_name:-$BLUE}"
local icon_color="${!icon_color_name:-$WHITE}"
local value_color="${!value_color_name:-$YELLOW}"
if [ -z "$icon" ]; then
icon="•"
fi
printf "%s│%s %s%s%s ${WHITE}%-@TOOL_LABEL_WIDTH@s${RESET} %s%s${RESET}\n" \
"$border_color" "$RESET" "$icon_color" "$icon" "$RESET" "$label" "$value_color" "$value"
}
repo_lib_print_pretty_tool() {
local border_color_name="$1"
local name="$2"
local color_name="$3"
local icon="$4"
local icon_color_name="$5"
local required="$6"
local line_no="$7"
local group_no="$8"
local regex="$9"
local match_regex="${10}"
local executable="${11}"
shift 11
local effective_icon_color_name="$icon_color_name"
local value_color_name="$color_name"
local value=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then
value="$REPO_LIB_TOOL_VERSION"
else
value="$REPO_LIB_TOOL_ERROR"
effective_icon_color_name="RED"
value_color_name="RED"
fi
repo_lib_print_pretty_row \
"$border_color_name" \
"$icon" \
"$effective_icon_color_name" \
"$name" \
"$value" \
"$value_color_name"
if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then
exit 1
fi
}
repo_lib_print_pretty_footer() {
local border_color_name="$1"
local border_color="${!border_color_name:-$BLUE}"
printf "%s╰─%s\n\n" "$border_color" "$RESET"
}
@SHELL_ENV_SCRIPT@
@BOOTSTRAP@
@SHELL_BANNER_SCRIPT@
@EXTRA_SHELL_TEXT@

View File

@@ -0,0 +1,54 @@
---
name: repo-lib-consumer
description: Edit or extend repos that consume `repo-lib` through `repo-lib.lib.mkRepo`, `repo-lib.lib.mkRelease`, or a template generated from this library. Use when Codex needs to add or change tools, shell banner or bootstrap behavior, shell packages, checks, raw lefthook config, formatters, release steps, version metadata, release channels, or release automation in a Nix flake built on repo-lib.
---
# Repo Lib Consumer
Use this skill when changing a repo that already depends on `repo-lib`.
## Workflow
1. Detect the integration style.
Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`.
2. Prefer the repo's current abstraction level.
If the repo uses `mkRepo`, keep edits inside `config` and `perSystem`.
If the repo uses `mkRelease` directly, preserve that style unless the user asked to migrate.
3. Load the right reference before editing.
Read `references/api.md` for exact option names, merge points, generated outputs, hook limitations, and release behavior.
Read `references/recipes.md` for concrete change patterns such as adding a tool, adding a check, wiring `commit-msg`, changing the banner, or updating release-managed files.
4. Follow repo-lib conventions.
Add bannered CLIs through `perSystem.tools`, not `shell.packages`.
Use `shell.packages` for packages that should exist in the shell but not in the banner.
Keep shells pure-first; only use `bootstrap` with `allowImpureBootstrap = true`.
Prefer `config.checks` for simple `pre-commit` and `pre-push` commands.
Use raw `config.lefthook` or `perSystem.lefthook` when the task needs `commit-msg` or extra lefthook fields.
Prefer structured `release.steps` over shell hooks; current step kinds are `writeFile`, `replace`, `versionMetaSet`, and `versionMetaUnset`.
5. Verify after edits.
Run `nix flake show --json`.
Run `nix flake check` when feasible.
If local flake evaluation cannot see newly created files because the repo is loaded as a git flake, stage the new files before rerunning checks.
## Decision Rules
- Prefer `repo-lib.lib.tools.fromPackage` for packaged CLIs and `fromCommand` only when the tool should come from the host environment.
- Use `repo-lib.lib.tools.simple` only for very small package-backed probes that only need `version.args`.
- Required tools fail shell startup if their probe fails. Do not mark a tool optional unless that is intentional.
- `config.checks` supports only `pre-commit` and `pre-push`. `commit-msg` must go through raw lefthook config.
- Generated checks include `formatting-check`, `hook-check`, and `lefthook-check`.
- `config.shell.banner.style` must be `simple` or `pretty`.
- Treat `postVersion` as pre-format, pre-commit, pre-tag, and pre-push.
- Do not model a true post-tag webhook inside `repo-lib`; prefer CI triggered by tag push.
- The current generated `release` command is destructive and opinionated: it formats, stages, commits, tags, and pushes as part of the flow. Document that clearly when editing consumer release automation.
## References
- `references/api.md`
Use for the exact consumer API, generated outputs, hook and banner behavior, and current release semantics.
- `references/recipes.md`
Use for common changes: add a tool, add a check, add a `commit-msg` hook, customize the shell banner, or update release-managed files.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Repo Lib Consumer"
short_description: "Edit mkRepo or mkRelease consumers safely"
default_prompt: "Use $repo-lib-consumer to update a repo that consumes repo-lib through mkRepo, mkRelease, or the repo-lib template."

View File

@@ -0,0 +1,381 @@
# repo-lib Consumer API
## Detect the repo shape
Look for one of these patterns in the consuming repo:
- `repo-lib.lib.mkRepo`
- `repo-lib.lib.mkRelease`
- `inputs.repo-lib`
Prefer editing the existing style instead of migrating incidentally.
## Preferred `mkRepo` shape
```nix
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
systems = repo-lib.lib.systems.default; # optional
config = {
includeStandardPackages = true;
shell = {
env = { };
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
lefthook = { };
release = null; # or attrset below
};
perSystem = { pkgs, system, lib, config }: {
tools = [ ];
shell.packages = [ ];
checks = { };
lefthook = { };
packages = { };
apps = { };
};
}
```
Generated outputs:
- `devShells.${system}.default`
- `checks.${system}.formatting-check`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
Merge points:
- `config.checks` merges with `perSystem.checks`
- `config.lefthook` recursively merges with `perSystem.lefthook`
- `config.shell` recursively merges with `perSystem.shell`
- generated release packages merge with `perSystem.packages`
Conflicts on `checks`, `packages`, and `apps` names throw.
## `config.includeStandardPackages`
Default: `true`
When enabled, the shell includes:
- `nixfmt`
- `gitlint`
- `gitleaks`
- `shfmt`
Use `false` only when the consumer explicitly wants to own the full shell package list.
## `config.shell`
Fields:
- `env`
Attrset of environment variables exported in the shell.
- `extraShellText`
Extra shell snippet appended after the banner.
- `bootstrap`
Shell snippet that runs before the banner.
- `allowImpureBootstrap`
Must be `true` when `bootstrap` is non-empty.
- `banner`
Shell banner configuration.
Rules:
- Default is pure-first.
- Do not add bootstrap work unless the user actually wants imperative local setup.
- The template uses bootstrap intentionally for Bun global install paths and Moon bootstrapping; do not generalize that into normal package setup unless the repo already wants that behavior.
### `config.shell.banner`
Defaults:
```nix
{
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
}
```
Rules:
- `style` must be `simple` or `pretty`.
- `borderColor` matters only for `pretty`.
- Tool rows can also set `banner.color`, `banner.icon`, and `banner.iconColor`.
- Required tool probe failures abort shell startup.
## `config.formatting`
Fields:
- `programs`
Passed to `treefmt-nix.lib.evalModule`.
- `settings`
Passed to `settings.formatter`.
Rules:
- `nixfmt` is always enabled.
- Use formatter settings instead of shell hooks for formatting behavior.
## Checks
`config.checks.<name>` and `perSystem.checks.<name>` use this shape:
```nix
{
command = "bun test";
stage = "pre-push"; # or "pre-commit"
passFilenames = false;
runtimeInputs = [ pkgs.bun ];
}
```
Defaults:
- `stage = "pre-commit"`
- `passFilenames = false`
- `runtimeInputs = [ ]`
Rules:
- Only `pre-commit` and `pre-push` are supported here.
- The command is wrapped with `writeShellApplication`.
- `pre-commit` and `pre-push` stages are configured to run in parallel.
- `passFilenames = true` maps to `{staged_files}` for `pre-commit` and `{push_files}` for `pre-push`.
## Raw Lefthook config
Use `config.lefthook` or `perSystem.lefthook` when the task needs advanced Lefthook features or unsupported stages.
Pass-through attrset example:
```nix
{
checks.tests = {
command = "bun test";
stage = "pre-push";
runtimeInputs = [ pkgs.bun ];
};
lefthook.pre-push.commands.tests.stage_fixed = true;
lefthook.commit-msg.commands.commitlint = {
run = "bun commitlint --edit {1}";
stage_fixed = true;
};
}
```
Structured hook-entry example in a raw hook list:
```nix
perSystem = { pkgs, ... }: {
lefthook.biome = {
entry = "${pkgs.biome}/bin/biome check";
pass_filenames = true;
stages = [ "pre-commit" "pre-push" ];
};
};
```
Rules:
- `config.lefthook` and `perSystem.lefthook` are recursive attrset passthroughs merged after generated checks.
- Structured hook entries support only:
`description`, `enable`, `entry`, `name`, `package`, `pass_filenames`, `stages`
- `stages` may include `pre-commit`, `pre-push`, or `commit-msg`.
- `pass_filenames = true` maps to `{1}` for `commit-msg`.
## Tools
Preferred shape in `perSystem.tools`:
```nix
(repo-lib.lib.tools.fromPackage {
name = "Bun";
package = pkgs.bun;
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
};
banner = {
color = "YELLOW";
icon = "";
iconColor = null;
};
required = true;
})
```
For a tool that should come from the host `PATH` instead of `nixpkgs`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
})
```
Helper:
```nix
repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
```
Tool behavior:
- Package-backed tools are added to the shell automatically.
- Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically.
- Banner probing uses the resolved executable path.
- `required = true` by default.
- When `version.match` is set, the first matching output line is selected before `regex` extraction.
- Required tool probe failure aborts shell startup.
Use `shell.packages` instead of `tools` when:
- the package should be in the shell but not in the banner
- the package is not a CLI tool with a stable version probe
## `config.release`
Shape:
```nix
{
channels = [ "alpha" "beta" "rc" "internal" ];
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
```
Defaults:
- `channels = [ "alpha" "beta" "rc" "internal" ]`
- `steps = [ ]`
- `postVersion = ""`
- `runtimeInputs = [ ]`
Set `release = null` to disable the generated release package.
## Release step shapes
### `writeFile`
```nix
{
writeFile = {
path = "src/version.ts";
text = ''
export const APP_VERSION = "$FULL_VERSION" as const;
'';
};
}
```
### `replace`
```nix
{
replace = {
path = "README.md";
regex = ''^(version = ")[^"]*(")$'';
replacement = ''\1$FULL_VERSION\2'';
};
}
```
### `versionMetaSet`
```nix
{
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
```
### `versionMetaUnset`
```nix
{
versionMetaUnset = {
key = "desktop_unused";
};
}
```
Rules:
- Current supported step kinds are only `writeFile`, `replace`, `versionMetaSet`, and `versionMetaUnset`.
- Do not document or implement a `run` step in consumer repos unless the library itself gains that feature.
## Release ordering
The generated `release` command currently does this:
1. Require a clean git worktree
2. Update `VERSION`
3. Run `release.steps`
4. Run `postVersion`
5. Run `nix fmt`
6. `git add -A`
7. Commit with `chore(release): <tag>`
8. Tag
9. Push branch
10. Push tags
Important consequences:
- `postVersion` is before formatting, commit, tag, and push.
- There is no true post-tag or post-push hook in current `repo-lib`.
- The current release runner is opinionated and performs commit, tag, and push as part of the flow.
## `mkRelease`
`repo-lib.lib.mkRelease` remains available when a repo wants only the release package:
```nix
repo-lib.lib.mkRelease {
system = system;
nixpkgsInput = nixpkgs; # optional
channels = [ "alpha" "beta" "rc" "internal" ];
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
```
Use the same release-step rules as `config.release`.

View File

@@ -0,0 +1,245 @@
# repo-lib Change Recipes
## Add a new bannered tool
Edit `perSystem.tools` in the consuming repo:
```nix
tools = [
(repo-lib.lib.tools.fromPackage {
name = "Bun";
package = pkgs.bun;
version.args = [ "--version" ];
banner = {
color = "YELLOW";
icon = "";
};
})
];
```
Notes:
- Do not also add the same package to `shell.packages`; `tools` already adds package-backed tools to the shell.
- Use `exe = "name"` only when the package exposes multiple binaries or the default binary is not the right one.
- Use `fromCommand` when the executable should come from the host environment instead of `nixpkgs`.
## Add a non-banner package to the shell
Use `shell.packages`:
```nix
shell.packages = [
self.packages.${system}.release
pkgs.jq
];
```
Use this for:
- helper CLIs that do not need a banner entry
- internal scripts
- the generated `release` package itself
## Customize the shell banner
Use `config.shell.banner`:
```nix
config.shell.banner = {
style = "pretty";
icon = "";
title = "Moonrepo shell ready";
titleColor = "GREEN";
subtitle = "Bun + TypeScript + Varlock";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
```
Guidance:
- Use `style = "pretty"` when the repo already has a styled shell banner.
- Keep icons and colors consistent with the repo's current shell UX.
- Remember that required tool probe failures will still abort shell startup.
## Add a test phase or lint hook
For a simple shared check:
```nix
config.checks.typecheck = {
command = "bun run typecheck";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [ pkgs.bun ];
};
```
For a system-specific check:
```nix
perSystem = { pkgs, ... }: {
checks.format = {
command = "oxfmt --check .";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
};
```
Guidance:
- Use `pre-commit` for fast format or lint work.
- Use `pre-push` for slower test suites.
- Prefer `runtimeInputs` over inline absolute paths when the command needs extra CLIs.
## Add a `commit-msg` hook
`config.checks` cannot target `commit-msg`. Use raw Lefthook config:
```nix
config.lefthook.commit-msg.commands.gitlint = {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
stage_fixed = true;
};
```
Or use a structured hook entry:
```nix
perSystem = { pkgs, ... }: {
lefthook.commitlint = {
entry = "${pkgs.nodejs}/bin/node scripts/commitlint.mjs";
pass_filenames = true;
stages = [ "commit-msg" ];
};
};
```
## Add or change formatters
Use `config.formatting`:
```nix
config.formatting = {
programs = {
shfmt.enable = true;
oxfmt.enable = true;
};
settings = {
shfmt.options = [ "-i" "2" "-s" "-w" ];
oxfmt.excludes = [ "*.md" "*.yml" ];
};
};
```
## Add release-managed files
Generate a file from the release version:
```nix
config.release.steps = [
{
writeFile = {
path = "src/version.ts";
text = ''
export const APP_VERSION = "$FULL_VERSION" as const;
'';
};
}
];
```
Update an existing file with a regex:
```nix
config.release.steps = [
{
replace = {
path = "README.md";
regex = ''^(version = ")[^"]*(")$'';
replacement = ''\1$FULL_VERSION\2'';
};
}
];
```
Update metadata inside `VERSION`:
```nix
config.release.steps = [
{
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
{
versionMetaUnset = {
key = "desktop_unused";
};
}
];
```
## Add a webhook during release
Current `repo-lib` does not expose a `run` release step. If the action must happen during local release execution, put it in `postVersion`:
```nix
config.release.postVersion = ''
curl -fsS https://example.invalid/release-hook \
-H 'content-type: application/json' \
-d '{"version":"'"$FULL_VERSION"'","tag":"'"$FULL_TAG"'"}'
'';
config.release.runtimeInputs = [ pkgs.curl ];
```
Important:
- `postVersion` still runs before `nix fmt`, commit, tag, and push.
- This is not a true post-tag hook.
## Add a true post-tag webhook
Do not fake this with `postVersion`.
Preferred approach in the consuming repo:
1. Keep local version generation in `repo-lib`.
2. Trigger CI from tag push.
3. Put the webhook call in CI, where the tag already exists remotely.
Only change `repo-lib` itself if the user explicitly asks for a new library capability.
## Add impure bootstrap work
Only do this when the user actually wants imperative shell setup:
```nix
config.shell = {
bootstrap = ''
export BUN_INSTALL_GLOBAL_DIR="$PWD/.tools/bun/install/global"
export BUN_INSTALL_BIN="$PWD/.tools/bun/bin"
export PATH="$BUN_INSTALL_BIN:$PATH"
'';
allowImpureBootstrap = true;
};
```
Do not add bootstrap work for normal Nix-packaged tools.
## Move from direct `mkRelease` to `mkRepo`
Only do this if requested.
Migration outline:
1. Move release package config into `config.release`.
2. Move shell setup into `config.shell` and `perSystem.shell.packages`.
3. Move bannered CLIs into `perSystem.tools`.
4. Move hook commands into `config.checks` or raw `lefthook`.
5. Keep behavior the same first; do not redesign the repo in the same change unless asked.

18
template/.env.schema Normal file
View File

@@ -0,0 +1,18 @@
# @currentEnv=$REPO_ENVIRONMENT
# ---
# Canonical repo environment used by Varlock.
# @type=enum(development,ci,production)
REPO_ENVIRONMENT=development
# Safe starter values for local development.
# @type=string
APP_NAME=typescript-monorepo
# @type=port
APP_PORT=3000
# Optional example secret resolved from OpenBao.
# Replace the namespace and secret path with values for your repo before use.
# @optional @sensitive
EXAMPLE_API_TOKEN=exec('bao kv get -mount=kv -namespace="${BAO_NAMESPACE:+$BAO_NAMESPACE/}template" -field=EXAMPLE_API_TOKEN "$REPO_ENVIRONMENT/shared" 2>/dev/null || true')

View File

@@ -1 +1,2 @@
watch_file flake.nix
use flake

4
template/.gitignore vendored
View File

@@ -1,8 +1,12 @@
.direnv/
.moon/cache/
.pre-commit-config.yaml
lefthook.yml
.tools/
bazel-*
build/
dist/
node_modules/
.env.sh

View File

@@ -0,0 +1,11 @@
$schema: "./cache/schemas/workspace.json"
projects:
globs:
- "apps/*"
- "packages/*"
sources:
root: "."
vcs:
defaultBranch: "main"

32
template/README.md Normal file
View File

@@ -0,0 +1,32 @@
# TypeScript Monorepo Template
This template gives you a Bun-only monorepo with:
- Moonrepo at the workspace root
- strict shared TypeScript configs
- Varlock with a committed `.env.schema`
- a Nix flake shell built through `repo-lib`
## First Run
1. Enter the shell with `direnv allow` or `nix develop`.
2. Install workspace dependencies with `bun install`.
3. Review and customize `.env.schema` for your repo.
4. Run `bun run env:check`.
5. Run `moon run :typecheck`.
## Layout
- `apps/` for applications
- `packages/` for shared libraries
- `tsconfig/` for shared TypeScript profiles
- `moon.yml` for root Moonrepo tasks
## Varlock
`bunfig.toml` preloads `varlock/auto-load`, and the root scripts expose:
- `bun run env:check`
- `bun run env:scan`
If you use OpenBao locally, set `OPENBAO_ADDR`, `OPENBAO_NAMESPACE`, and `OPENBAO_CACERT` in your shell or an ignored `.env.sh` file before running those commands.

1
template/apps/.gitkeep Normal file
View File

@@ -0,0 +1 @@

2
template/bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
env = false
preload = ["varlock/auto-load"]

159
template/flake.lock generated
View File

@@ -1,159 +0,0 @@
{
"nodes": {
"devshell-lib": {
"inputs": {
"git-hooks": "git-hooks",
"nixpkgs": [
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1772603902,
"narHash": "sha256-GN5EC9m0flWDuc6qaB6QoIBD73yFnhl2PBIYXzSTGeQ=",
"ref": "v0.0.2",
"rev": "db4ed150e01e2f9245e668077245447d0089163f",
"revCount": 15,
"type": "git",
"url": "https://git.dgren.dev/eric/nix-flake-lib"
},
"original": {
"ref": "v0.0.2",
"type": "git",
"url": "https://git.dgren.dev/eric/nix-flake-lib"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1772024342,
"narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devshell-lib",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devshell-lib": "devshell-lib",
"nixpkgs": "nixpkgs_3"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1770228511,
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,145 +1,158 @@
# flake.nix — product repo template
{
description = "my-product";
description = "typescript-monorepo";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v1.0.3";
devshell-lib.inputs.nixpkgs.follows = "nixpkgs";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v4.0.0";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{
self,
nixpkgs,
devshell-lib,
repo-lib,
...
}:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
devShells = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
env = devshell-lib.lib.mkDevShell {
inherit system;
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
# includeStandardPackages = false; # opt out of nixfmt/gitlint/gitleaks/shfmt defaults
extraPackages = with pkgs; [
# add your tools here, e.g.:
# go
# bun
# rustc
];
features = {
# oxfmt = true; # enables oxfmt + oxlint from nixpkgs
};
formatters = {
# shfmt.enable = true;
# gofmt.enable = true;
};
formatterSettings = {
# shfmt.options = [ "-i" "2" "-s" "-w" ];
# oxfmt.includes = [ "*.ts" "*.tsx" "*.js" "*.json" ];
};
additionalHooks = {
tests = {
enable = true;
entry = "echo 'No tests defined yet.'"; # replace with your test command
pass_filenames = false;
stages = [ "pre-push" ];
};
# my-hook = {
# enable = true;
# entry = "${pkgs.some-tool}/bin/some-tool";
# pass_filenames = false;
# };
};
tools = [
# { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; }
# { name = "Go"; bin = "${pkgs.go}/bin/go"; versionCmd = "version"; color = "CYAN"; }
# { name = "Rust"; bin = "${pkgs.rustc}/bin/rustc"; versionCmd = "--version"; color = "YELLOW"; }
];
extraShellHook = ''
# any repo-specific shell setup here
'';
config = {
shell = {
banner = {
style = "pretty";
icon = "";
title = "Moonrepo shell ready";
titleColor = "GREEN";
subtitle = "Bun + TypeScript + Varlock";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
in
extraShellText = ''
export PATH="$PWD/node_modules/.bin:$PATH"
'';
bootstrap = ''
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
export BUN_INSTALL_GLOBAL_DIR="$repo_root/.tools/bun/install/global"
export BUN_INSTALL_BIN="$repo_root/.tools/bun/bin"
export PATH="$BUN_INSTALL_BIN:$PATH"
mkdir -p "$BUN_INSTALL_GLOBAL_DIR" "$BUN_INSTALL_BIN"
if [ ! -x "$BUN_INSTALL_BIN/moon" ]; then
bun add -g @moonrepo/cli
fi
'';
allowImpureBootstrap = true;
};
formatting = {
programs = {
oxfmt.enable = true;
};
settings = {
oxfmt.excludes = [
"*.css"
"*.graphql"
"*.hbs"
"*.html"
"*.md"
"*.mdx"
"*.mustache"
"*.scss"
"*.vue"
"*.yaml"
"*.yml"
];
};
};
release = {
steps = [ ];
};
};
perSystem =
{
default = env.shell;
}
);
checks = forAllSystems (
system:
let
env = devshell-lib.lib.mkDevShell { inherit system; };
in
pkgs,
system,
...
}:
{
inherit (env) pre-commit-check;
}
);
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
formatter = forAllSystems (system: (devshell-lib.lib.mkDevShell { inherit system; }).formatter);
(repo-lib.lib.tools.fromPackage {
name = "Bun";
package = pkgs.bun;
version.args = [ "--version" ];
banner = {
color = "YELLOW";
icon = "";
};
})
];
# Optional: release command (`release`)
#
# The release script always updates VERSION first, then:
# 1) runs release steps in order (file writes and scripts)
# 2) runs postVersion hook
# 3) formats, stages, commits, tags, and pushes
#
# Runtime env vars available in release.run/postVersion:
# BASE_VERSION, CHANNEL, PRERELEASE_NUM, FULL_VERSION, FULL_TAG
#
# packages = forAllSystems (
# system:
# {
# release = devshell-lib.lib.mkRelease {
# inherit system;
#
# release = [
# {
# file = "src/version.ts";
# content = ''
# export const APP_VERSION = "$FULL_VERSION" as const;
# '';
# }
# {
# file = "internal/version/version.go";
# content = ''
# package version
#
# const Version = "$FULL_VERSION"
# '';
# }
# {
# run = ''
# sed -E -i "s#^([[:space:]]*my-lib\\.url = \")github:org/my-lib[^"]*(\";)#\\1github:org/my-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/flake.nix"
# '';
# }
# ];
#
# postVersion = ''
# echo "Released $FULL_TAG"
# '';
# };
# }
# );
shell = {
packages = [
self.packages.${system}.release
pkgs.openbao
pkgs.oxfmt
pkgs.oxlint
];
};
checks = {
format = {
command = "oxfmt --check .";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
typecheck = {
command = "bun run typecheck";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [ pkgs.bun ];
};
env-check = {
command = "bun run env:check";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [
pkgs.bun
pkgs.openbao
];
};
env-scan = {
command = "bun run env:scan";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [
pkgs.bun
pkgs.openbao
];
};
};
};
};
}

45
template/moon.yml Normal file
View File

@@ -0,0 +1,45 @@
$schema: "./.moon/cache/schemas/project.json"
tasks:
typecheck:
command: "bun"
args:
- "run"
- "typecheck"
inputs:
- "package.json"
- "tsconfig.json"
- "tsconfig.options.json"
- "tsconfig/**/*"
options:
cache: false
runFromWorkspaceRoot: true
env-check:
command: "bun"
args:
- "run"
- "env:check"
inputs:
- ".env.schema"
- "package.json"
- "bunfig.toml"
toolchains: "system"
options:
cache: false
runFromWorkspaceRoot: true
env-scan:
command: "bun"
args:
- "run"
- "env:scan"
inputs:
- ".env.schema"
- "package.json"
- "bunfig.toml"
toolchains: "system"
options:
cache: false
runFromWorkspaceRoot: true
runInCI: "skip"

23
template/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "typescript-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"type": "module",
"scripts": {
"typecheck": "bunx tsc -p tsconfig.json --pretty false",
"env:check": "sh -ec 'export BAO_ADDR=\"${BAO_ADDR:-${OPENBAO_ADDR:-}}\"; export BAO_NAMESPACE=\"${BAO_NAMESPACE:-${OPENBAO_NAMESPACE:-}}\"; export BAO_CACERT=\"${BAO_CACERT:-${OPENBAO_CACERT:-}}\"; exec varlock load --show-all'",
"env:scan": "sh -ec 'export BAO_ADDR=\"${BAO_ADDR:-${OPENBAO_ADDR:-}}\"; export BAO_NAMESPACE=\"${BAO_NAMESPACE:-${OPENBAO_NAMESPACE:-}}\"; export BAO_CACERT=\"${BAO_CACERT:-${OPENBAO_CACERT:-}}\"; exec varlock scan --staged'",
"check": "bun run typecheck && bun run env:check"
},
"dependencies": {
"@moonrepo/cli": "^2.0.4"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^6.0.0-beta",
"varlock": "0.5.0"
}
}

View File

@@ -0,0 +1 @@

4
template/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.options.json",
"files": []
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": "./runtime.json",
"compilerOptions": {
"types": ["vite/client"]
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": "./runtime.json",
"compilerOptions": {
"types": ["@types/bun"]
}
}

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"types": []
},
"extends": "../tsconfig.options.json"
}

View File

@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.options.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"]
}
}