18 Commits
v3.1.0 ... main

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
83 changed files with 5253 additions and 3118 deletions

1
.envrc
View File

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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
.direnv .direnv
result result
template/flake.lock 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"
}
}

View File

@@ -4,8 +4,10 @@
- `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release` - `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release`
- structured tool banners driven from package-backed tool specs - structured tool banners driven from package-backed tool specs
- structured release steps (`writeFile`, `replace`, `run`) - structured release steps (`writeFile`, `replace`, `versionMetaSet`, `versionMetaUnset`)
- a minimal starter template in [`template/`](/Users/eric/Projects/repo-lib/template) - 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 ## Prerequisites
@@ -15,15 +17,24 @@
## Use the template ## Use the template
```bash ```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.1.0#default' --refresh +https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1#default' --refresh
``` ```
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 ## Use the library
Add this flake input: Add this flake input:
```nix ```nix
inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.1.0"; +https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs"; inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
``` ```
@@ -66,11 +77,21 @@ outputs = { self, nixpkgs, repo-lib, ... }:
`mkRepo` generates: `mkRepo` generates:
- `devShells.${system}.default` - `devShells.${system}.default`
- `checks.${system}.pre-commit-check` - `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}` - `formatter.${system}`
- `packages.${system}.release` when `config.release != null` - `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem` - 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 ## 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. 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.
@@ -135,10 +156,9 @@ config.release = {
}; };
} }
{ {
run = { versionMetaSet = {
script = '' key = "desktop_binary_version_max";
echo "Released $FULL_TAG" value = "$FULL_VERSION";
'';
}; };
} }
]; ];
@@ -149,16 +169,30 @@ The generated `release` command still supports:
```bash ```bash
release release
release select
release --dry-run patch
release patch release patch
release patch --commit
release patch --commit --tag
release patch --commit --tag --push
release beta release beta
release minor beta release minor beta
release stable release stable
release set 1.2.3 release set 1.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 ## Low-level APIs
`mkDevShell` and `mkRelease` remain available for repos that want lower-level control or a migration path from the older library shape. `mkRelease` remains available for repos that want lower-level control over release automation.
## Common command ## Common command

View File

@@ -1,4 +1,3 @@
3.1.0 4.0.0
stable stable
0 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": { "nodes": {
"flake-compat": { "flake-parts": {
"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": { "inputs": {
"flake-compat": "flake-compat", "nixpkgs-lib": "nixpkgs-lib"
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1772024342, "lastModified": 1772408722,
"narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=", "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "cachix", "owner": "hercules-ci",
"repo": "git-hooks.nix", "repo": "flake-parts",
"rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476", "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "cachix", "owner": "hercules-ci",
"repo": "git-hooks.nix", "repo": "flake-parts",
"type": "github" "type": "github"
} }
}, },
"gitignore": { "lefthook-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"git-hooks",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1709087332, "lastModified": 1770377107,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", "narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=",
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394", "rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "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": { "locked": {
"lastModified": 1772542754, "lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -89,7 +54,22 @@
"type": "github" "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": { "locked": {
"lastModified": 1770107345, "lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -107,14 +87,15 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"git-hooks": "git-hooks", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_2", "lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
} }
}, },
"treefmt-nix": { "treefmt-nix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs_3" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1770228511, "lastModified": 1770228511,

View File

@@ -3,23 +3,27 @@
description = "Pure-first repo development platform for Nix flakes"; description = "Pure-first repo development platform for Nix flakes";
inputs = { inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 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"; treefmt-nix.url = "github:numtide/treefmt-nix";
}; };
outputs = outputs =
{ {
self, self,
flake-parts,
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthook-nix,
... ...
}: }:
let let
lib = nixpkgs.lib; lib = nixpkgs.lib;
repoLib = import ./packages/repo-lib/lib.nix { repoLib = import ./packages/repo-lib/lib.nix {
inherit nixpkgs treefmt-nix git-hooks; inherit flake-parts nixpkgs treefmt-nix;
lefthookNix = lefthook-nix;
releaseScriptPath = ./packages/release/release.sh; releaseScriptPath = ./packages/release/release.sh;
shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh; shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
}; };
@@ -92,19 +96,16 @@
pkgs.runCommand "release-tests" pkgs.runCommand "release-tests"
{ {
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
bash go
git git
gnused
coreutils
gnugrep
perl
]; ];
} }
'' ''
export REPO_LIB_ROOT=${./.} export HOME="$PWD/.home"
export NIXPKGS_FLAKE_PATH=${nixpkgs} export GOCACHE="$PWD/.go-cache"
export HOME="$TMPDIR" mkdir -p "$GOCACHE" "$HOME"
${pkgs.bash}/bin/bash ${./tests/release.sh} cd ${./packages/release}
go test ./...
touch "$out" touch "$out"
''; '';
} }

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,15 +1,17 @@
{ {
flake-parts,
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthookNix,
releaseScriptPath ? ./release.sh, releaseScriptPath ? ./release.sh,
shellHookTemplatePath ? ../repo-lib/shell-hook.sh, shellHookTemplatePath ? ../repo-lib/shell-hook.sh,
}: }:
import ../repo-lib/lib.nix { import ../repo-lib/lib.nix {
inherit inherit
flake-parts
nixpkgs nixpkgs
treefmt-nix treefmt-nix
git-hooks lefthookNix
releaseScriptPath releaseScriptPath
shellHookTemplatePath shellHookTemplatePath
; ;

View File

@@ -2,539 +2,18 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)" REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint" export REPO_LIB_RELEASE_ROOT_DIR
START_HEAD="" export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__'
CREATED_TAG="" REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF'
VERSION_META_LINES=() __RELEASE_STEPS_JSON__
VERSION_META_EXPORT_NAMES=() EOF
)"
# ── logging ──────────────────────────────────────────────────────────────── export REPO_LIB_RELEASE_STEPS_JSON
REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF'
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 (only opt-in path to promote prerelease -> stable)" \
" __CHANNEL_LIST__ switch channel (from stable, auto-bumps patch unless bump is specified)" \
"" \
"Safety rule:" \
" If current version is prerelease (e.g. x.y.z-beta.N), promotion to stable is allowed only via 'stable' or 'full'." \
" Commands like '${cmd} set x.y.z' or '${cmd} patch stable' are blocked from prerelease channels." \
"" \
"Examples:" \
" ${cmd} # patch bump on current channel" \
" ${cmd} minor # minor bump on current channel" \
" ${cmd} beta # from stable: patch bump + beta.1" \
" ${cmd} patch beta # patch bump, switch to beta channel" \
" ${cmd} rc # switch to rc channel" \
" ${cmd} stable # promote prerelease to stable (opt-in)" \
" ${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
}
meta_env_name() {
local key="$1"
key="${key//[^[:alnum:]]/_}"
key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
printf 'VERSION_META_%s\n' "$key"
}
clear_version_meta_exports() {
local export_name
for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do
unset "$export_name"
done
VERSION_META_EXPORT_NAMES=()
}
load_version_metadata() {
VERSION_META_LINES=()
[[ ! -f "$ROOT_DIR/VERSION" ]] && return 0
while IFS= read -r line || [[ -n $line ]]; do
VERSION_META_LINES+=("$line")
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_metadata() {
clear_version_meta_exports
local line key value export_name
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line != *=* ]] && continue
key="${line%%=*}"
value="${line#*=}"
[[ -z $key ]] && continue
export_name="$(meta_env_name "$key")"
printf -v "$export_name" '%s' "$value"
export "${export_name?}=$value"
VERSION_META_EXPORT_NAMES+=("$export_name")
done
}
write_version_file() {
local channel_to_write="$1"
local n_to_write="$2"
{
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
printf '%s\n' "$line"
done
} >"$ROOT_DIR/VERSION"
}
version_meta_get() {
local key="${1-}"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
if [[ $line == "$key="* ]]; then
printf '%s\n' "${line#*=}"
return 0
fi
done
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
[[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1
local updated=0
local index
for index in "${!VERSION_META_LINES[@]}"; do
if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then
VERSION_META_LINES[index]="$key=$value"
updated=1
break
fi
done
if [[ $updated -eq 0 ]]; then
VERSION_META_LINES+=("$key=$value")
fi
export_version_metadata
version_meta_write
}
version_meta_unset() {
local key="${1-}"
[[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1
local filtered=()
local line
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line == "$key="* ]] && continue
filtered+=("$line")
done
VERSION_META_LINES=("${filtered[@]}")
export_version_metadata
version_meta_write
}
version_meta_write() {
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
write_version_file "$channel_to_write" "$n_to_write"
}
# ── 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
load_version_metadata
export_version_metadata
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
VERSION_META_LINES=()
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
}
do_read_version() {
load_version_metadata
export_version_metadata
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
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
}
# ── user-provided hook ─────────────────────────────────────────────────────
do_post_version() {
:
__POST_VERSION__ __POST_VERSION__
} EOF
)"
export REPO_LIB_RELEASE_POST_VERSION
# ── main ─────────────────────────────────────────────────────────────────── exec __RELEASE_RUNNER__ "$@"
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"
compute_full_version
local current_full="$FULL_VERSION"
log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}"
local action="${1-}"
shift || true
if [[ $action == "set" ]]; then
local newv="${1-}"
local current_channel="$CHANNEL"
[[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1
parse_full_version "$newv"
validate_channel "$CHANNEL"
if [[ $current_channel != "stable" && $CHANNEL == "stable" ]]; then
echo "Error: from prerelease channel '$current_channel', promote using 'stable' or 'full' only" >&2
exit 1
fi
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="" was_channel_only=0
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"
was_channel_only=1
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"
if [[ $CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then
echo "Error: from prerelease channel '$CHANNEL', promote using 'stable' or 'full' only" >&2
exit 1
fi
if [[ -z $part && $was_channel_only -eq 1 && $CHANNEL == "stable" && $target_channel != "stable" ]]; then
part="patch"
fi
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
if [[ $FULL_VERSION == "$current_full" ]]; then
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
fi
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 "$@"

View File

@@ -1,63 +1,18 @@
{ {
flake-parts,
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthookNix,
releaseScriptPath, releaseScriptPath,
shellHookTemplatePath, shellHookTemplatePath,
}: }:
let let
lib = nixpkgs.lib; defaults = import ./lib/defaults.nix { };
common = import ./lib/common.nix { inherit nixpkgs; };
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
defaultReleaseChannels = [
"alpha"
"beta"
"rc"
"internal"
];
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;
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
normalizeShellBanner = normalizeShellBanner =
rawBanner: rawBanner:
let let
banner = defaultShellBanner // rawBanner; banner = defaults.defaultShellBanner // rawBanner;
in in
if if
!(builtins.elem banner.style [ !(builtins.elem banner.style [
@@ -68,706 +23,78 @@ let
throw "repo-lib: config.shell.banner.style must be one of simple or pretty" throw "repo-lib: config.shell.banner.style must be one of simple or pretty"
else else
banner; banner;
toolsModule = import ./lib/tools.nix {
normalizeStrictTool = lib = common.lib;
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;
}; };
hooksModule = import ./lib/hooks.nix {
normalizeLegacyTool = inherit (common) lib sanitizeName;
pkgs: tool:
if tool ? package then
normalizeStrictTool pkgs tool
else
{
kind = "legacy";
name = tool.name;
command = tool.bin;
versionCommand = tool.versionCmd or "--version";
banner = {
color = tool.color or "YELLOW";
icon = tool.icon or null;
iconColor = tool.iconColor or null;
}; };
required = tool.required or false; shellModule = import ./lib/shell.nix {
}; inherit (common)
lib
normalizeCheck = ;
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
{
enable = true;
entry = "${wrapper}/bin/${wrapperName}";
pass_filenames = check.passFilenames;
stages = [ check.stage ];
};
normalizeReleaseStep =
step:
if step ? writeFile then
{
kind = "writeFile";
path = step.writeFile.path;
text = step.writeFile.text;
runtimeInputs = [ ];
}
else if step ? replace then
{
kind = "replace";
path = step.replace.path;
regex = step.replace.regex;
replacement = step.replace.replacement;
runtimeInputs = [ ];
}
else if step ? run && builtins.isAttrs step.run then
{
kind = "run";
script = step.run.script;
runtimeInputs = step.run.runtimeInputs or [ ];
}
else if step ? run then
{
kind = "run";
script = step.run;
runtimeInputs = [ ];
}
else if step ? file then
{
kind = "writeFile";
path = step.file;
text = step.content;
runtimeInputs = [ ];
}
else
throw "repo-lib: release step must contain one of writeFile, replace, or run";
releaseStepScript =
step:
if step.kind == "writeFile" then
''
target_path="$ROOT_DIR/${step.path}"
mkdir -p "$(dirname "$target_path")"
cat >"$target_path" << NIXEOF
${step.text}
NIXEOF
log "Generated version file: ${step.path}"
''
else if step.kind == "replace" then
''
target_path="$ROOT_DIR/${step.path}"
REPO_LIB_STEP_REGEX=$(cat <<'NIXEOF'
${step.regex}
NIXEOF
)
REPO_LIB_STEP_REPLACEMENT=$(cat <<NIXEOF
${step.replacement}
NIXEOF
)
export REPO_LIB_STEP_REGEX REPO_LIB_STEP_REPLACEMENT
perl - "$target_path" <<'REPO_LIB_PERL_REPLACE'
use strict;
use warnings;
my $path = shift @ARGV;
my $regex_src = $ENV{"REPO_LIB_STEP_REGEX"} // q{};
my $template = $ENV{"REPO_LIB_STEP_REPLACEMENT"} // q{};
open my $in, q{<}, $path or die "failed to open $path: $!";
local $/ = undef;
my $content = <$in>;
close $in;
my $regex = qr/$regex_src/ms;
$content =~ s{$regex}{
my @cap = map { defined $_ ? $_ : q{} } ($1, $2, $3, $4, $5, $6, $7, $8, $9);
my $result = $template;
$result =~ s{\\([1-9])}{$cap[$1 - 1]}ge;
$result;
}gems;
open my $out, q{>}, $path or die "failed to open $path for write: $!";
print {$out} $content;
close $out;
REPO_LIB_PERL_REPLACE
log "Updated ${step.path}"
''
else
''
${step.script}
'';
normalizeReleaseConfig =
raw:
let
hasLegacySteps = raw ? release;
hasStructuredSteps = raw ? steps;
steps =
if hasLegacySteps && hasStructuredSteps then
throw "repo-lib: pass either 'release' or 'steps' to mkRelease, not both"
else if hasStructuredSteps then
builtins.map normalizeReleaseStep raw.steps
else if hasLegacySteps then
builtins.map normalizeReleaseStep raw.release
else
[ ];
in
{
postVersion = raw.postVersion or "";
channels = raw.channels or defaultReleaseChannels;
runtimeInputs = (raw.runtimeInputs or [ ]) ++ (raw.extraRuntimeInputs or [ ]);
steps = steps;
};
buildShellHook =
{
preCommitShellHook,
shellEnvScript,
bootstrap,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
let
template = builtins.readFile shellHookTemplatePath;
in
builtins.replaceStrings
[
"\${pre-commit-check.shellHook}"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
preCommitShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
shellBannerScript
extraShellText
]
template;
buildShellArtifacts =
{
pkgs,
system,
src,
includeStandardPackages ? true,
formatting,
tools ? [ ],
shellConfig ? {
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
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;
};
normalizedChecks = lib.mapAttrs (name: check: normalizeCheck pkgs name check) checkSpecs;
hooks = mergeUniqueAttrs "hook" rawHookEntries normalizedChecks;
pre-commit-check = git-hooks.lib.${system}.run {
inherit src;
hooks = {
treefmt = {
enable = true;
entry = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci";
pass_filenames = true;
};
gitlint.enable = true;
gitleaks = {
enable = true;
entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
pass_filenames = false;
};
}
// hooks;
};
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:
if tool.kind == "strict" then
''
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}
''
else
''
repo_lib_print_pretty_legacy_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 tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) 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:
if tool.kind == "strict" then
''
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}
''
else
''
repo_lib_print_simple_legacy_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 tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) tools
+ ''
printf "\n"
'';
in
{
inherit pre-commit-check;
formatter = treefmtEval.config.build.wrapper;
shell = pkgs.mkShell {
packages = lib.unique (selectedStandardPackages ++ extraPackages ++ toolPackages);
buildInputs = pre-commit-check.enabledPackages;
shellHook = buildShellHook {
preCommitShellHook = pre-commit-check.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
};
in
rec {
systems = {
default = supportedSystems;
};
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
inherit inherit
name treefmt-nix
package lefthookNix
exe shellHookTemplatePath
version ;
banner inherit (defaults)
required defaultShellBanner
;
inherit normalizeShellBanner;
inherit (hooksModule)
normalizeLefthookConfig
parallelHookStageConfig
checkToLefthookConfig
hookToLefthookConfig
; ;
}; };
releaseModule = import ./lib/release.nix {
fromCommand = inherit (common)
{ lib
name, importPkgs
command, ;
version ? { },
banner ? { },
required ? true,
}:
{
inherit inherit
name nixpkgs
command releaseScriptPath
version ;
banner inherit (defaults)
required defaultReleaseChannels
; ;
}; };
repoModule = import ./lib/repo.nix {
simple =
name: package: args:
fromPackage {
inherit name package;
version.args = args;
};
};
normalizeRepoConfig =
rawConfig:
let
merged = lib.recursiveUpdate {
includeStandardPackages = true;
shell = {
env = { };
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
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;
};
};
mkDevShell =
{
system,
src ? ./.,
nixpkgsInput ? nixpkgs,
extraPackages ? [ ],
preToolHook ? "",
extraShellHook ? "",
additionalHooks ? { },
tools ? [ ],
includeStandardPackages ? true,
formatters ? { },
formatterSettings ? { },
features ? { },
}:
let
pkgs = importPkgs nixpkgsInput system;
oxfmtEnabled = features.oxfmt or false;
legacyTools = builtins.map (tool: normalizeLegacyTool pkgs tool) tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) legacyTools);
normalizedFormatting = {
programs =
(lib.optionalAttrs oxfmtEnabled {
oxfmt.enable = true;
})
// formatters;
settings = formatterSettings;
};
shellConfig = {
env = { };
extraShellText = extraShellHook;
allowImpureBootstrap = true;
bootstrap = preToolHook;
banner = defaultShellBanner;
};
in
if duplicateToolNames != [ ] then
throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}"
else
buildShellArtifacts {
inherit inherit
pkgs flake-parts
system nixpkgs
src
includeStandardPackages
; ;
formatting = normalizedFormatting; inherit (common)
rawHookEntries = additionalHooks; lib
shellConfig = shellConfig; importPkgs
tools = legacyTools; duplicateStrings
extraPackages = mergeUniqueAttrs
extraPackages ;
++ lib.optionals oxfmtEnabled [ inherit (defaults)
pkgs.oxfmt supportedSystems
pkgs.oxlint defaultReleaseChannels
]; ;
}; inherit (toolsModule)
normalizeStrictTool
mkRelease = ;
{ inherit (hooksModule)
system, normalizeLefthookConfig
nixpkgsInput ? nixpkgs, ;
... inherit normalizeShellBanner;
}@rawArgs: inherit (shellModule)
let buildShellArtifacts
pkgs = importPkgs nixpkgsInput system; ;
release = normalizeReleaseConfig rawArgs; inherit (releaseModule)
channelList = lib.concatStringsSep " " release.channels; mkRelease
releaseStepsScript = lib.concatMapStrings releaseStepScript release.steps;
script =
builtins.replaceStrings
[
"__CHANNEL_LIST__"
"__RELEASE_STEPS__"
"__POST_VERSION__"
]
[
channelList
releaseStepsScript
release.postVersion
]
(builtins.readFile releaseScriptPath);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
perl
]
++ release.runtimeInputs
++ lib.concatMap (step: step.runtimeInputs or [ ]) release.steps;
text = script;
};
mkRepo =
{
self,
nixpkgs,
src ? ./.,
systems ? supportedSystems,
config ? { },
perSystem ? (
{
pkgs,
system,
lib,
config,
}:
{ }
),
}:
let
normalizedConfig = normalizeRepoConfig config;
systemResults = lib.genAttrs systems (
system:
let
pkgs = importPkgs nixpkgs system;
perSystemResult = {
tools = [ ];
shell = { };
checks = { };
packages = { };
apps = { };
}
// perSystem {
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;
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;
shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ];
};
releasePackages =
if normalizedConfig.release == null then
{ }
else
{
release = mkRelease {
inherit system;
nixpkgsInput = nixpkgs;
channels = normalizedConfig.release.channels;
steps = normalizedConfig.release.steps;
postVersion = normalizedConfig.release.postVersion;
runtimeInputs = normalizedConfig.release.runtimeInputs;
};
}; };
in in
{ {
inherit env; systems.default = defaults.supportedSystems;
packages = mergeUniqueAttrs "package" releasePackages perSystemResult.packages; inherit (toolsModule) tools;
apps = perSystemResult.apps; inherit (repoModule) normalizeRepoConfig mkRepo;
} inherit (releaseModule) mkRelease;
);
in
{
devShells = lib.genAttrs systems (system: {
default = systemResults.${system}.env.shell;
});
checks = lib.genAttrs systems (system: {
inherit (systemResults.${system}.env) pre-commit-check;
});
formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter);
packages = lib.genAttrs systems (system: systemResults.${system}.packages);
apps = lib.genAttrs systems (system: systemResults.${system}.apps);
};
} }

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

@@ -1,4 +1,4 @@
${pre-commit-check.shellHook} @HOOKS_SHELL_HOOK@
if [ -t 1 ]; then if [ -t 1 ]; then
command -v tput >/dev/null 2>&1 && tput clear || printf '\033c' command -v tput >/dev/null 2>&1 && tput clear || printf '\033c'
@@ -70,39 +70,6 @@ repo_lib_capture_tool() {
return 0 return 0
} }
repo_lib_capture_legacy_tool() {
local required="$1"
local command_name="$2"
local version_command="$3"
local output=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! command -v "$command_name" >/dev/null 2>&1; then
REPO_LIB_TOOL_ERROR="missing command"
return 1
fi
if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then
REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2
return 1
fi
version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
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() { repo_lib_print_simple_header() {
local title_color_name="$1" local title_color_name="$1"
local icon="$2" local icon="$2"
@@ -164,42 +131,6 @@ repo_lib_print_simple_tool() {
fi fi
} }
repo_lib_print_simple_legacy_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local command_name="$6"
local version_command="$7"
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_legacy_tool "$required" "$command_name" "$version_command"; 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() { repo_lib_print_pretty_header() {
local border_color_name="$1" local border_color_name="$1"
local title_color_name="$2" local title_color_name="$2"
@@ -286,45 +217,6 @@ repo_lib_print_pretty_tool() {
fi fi
} }
repo_lib_print_pretty_legacy_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 command_name="$7"
local version_command="$8"
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_legacy_tool "$required" "$command_name" "$version_command"; 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() { repo_lib_print_pretty_footer() {
local border_color_name="$1" local border_color_name="$1"
local border_color="${!border_color_name:-$BLUE}" local border_color="${!border_color_name:-$BLUE}"

View File

@@ -1,48 +1,54 @@
--- ---
name: repo-lib-consumer name: repo-lib-consumer
description: Edit or extend repos that consume `repo-lib` through `repo-lib.lib.mkRepo`, `mkDevShell`, or `mkRelease`. Use when Codex needs to add or change tools, shell packages, checks or test phases, formatters, release steps, release channels, bootstrap hooks, or release automation in a Nix flake built on repo-lib. 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 # Repo Lib Consumer
Use this skill to make idiomatic changes in a repo that already depends on `repo-lib`. Use this skill when changing a repo that already depends on `repo-lib`.
## Workflow ## Workflow
1. Detect the integration style. 1. Detect the integration style.
Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkDevShell`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`. Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`.
2. Prefer the repo's current abstraction level. 2. Prefer the repo's current abstraction level.
If the repo already uses `mkRepo`, stay on `mkRepo`. If the repo uses `mkRepo`, keep edits inside `config` and `perSystem`.
If the repo still uses `mkDevShell` or `mkRelease`, preserve that style unless the user asked to migrate. If the repo uses `mkRelease` directly, preserve that style unless the user asked to migrate.
3. Load the right reference before editing. 3. Load the right reference before editing.
Read `references/api.md` for exact option names, defaults, generated outputs, and limitations. Read `references/api.md` for exact option names, merge points, generated outputs, hook limitations, and release behavior.
Read `references/recipes.md` for common edits such as adding a tool, adding a test phase, wiring release file updates, or handling webhooks. 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. 4. Follow repo-lib conventions.
Add bannered CLIs through `perSystem.tools`, not `shell.packages`. Add bannered CLIs through `perSystem.tools`, not `shell.packages`.
Use `shell.packages` for packages that should be present in the shell but not shown in the banner. 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`. Keep shells pure-first; only use `bootstrap` with `allowImpureBootstrap = true`.
Prefer structured `release.steps` over free-form shell when the task fits `writeFile` or `replace`. 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. 5. Verify after edits.
Run `nix flake show --json`. Run `nix flake show --json`.
Run `nix flake check` when feasible. Run `nix flake check` when feasible.
If local flake evaluation cannot see newly created files because the repo is being loaded as a git flake, stage the new files before rerunning checks. 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 ## Decision Rules
- Prefer `repo-lib.lib.tools.fromPackage` for tools with explicit metadata. - 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 simple `--version` or `version` probes. - Use `repo-lib.lib.tools.simple` only for very small package-backed probes that only need `version.args`.
- Put pre-commit and pre-push automation in `checks`, not shell hooks. - Required tools fail shell startup if their probe fails. Do not mark a tool optional unless that is intentional.
- Treat `postVersion` as pre-tag and pre-push. It is not a true post-tag hook. - `config.checks` supports only `pre-commit` and `pre-push`. `commit-msg` must go through raw lefthook config.
- For a webhook that must fire after the tag exists remotely, prefer CI triggered by tag push over local release command changes. - 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
- `references/api.md` - `references/api.md`
Use for the exact consumer API, option matrix, generated outputs, release ordering, and legacy compatibility. Use for the exact consumer API, generated outputs, hook and banner behavior, and current release semantics.
- `references/recipes.md` - `references/recipes.md`
Use for concrete change patterns: add a tool, add a test phase, update release-managed files, or wire webhook behavior. 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

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

View File

@@ -5,7 +5,6 @@
Look for one of these patterns in the consuming repo: Look for one of these patterns in the consuming repo:
- `repo-lib.lib.mkRepo` - `repo-lib.lib.mkRepo`
- `repo-lib.lib.mkDevShell`
- `repo-lib.lib.mkRelease` - `repo-lib.lib.mkRelease`
- `inputs.repo-lib` - `inputs.repo-lib`
@@ -27,6 +26,7 @@ repo-lib.lib.mkRepo {
extraShellText = ""; extraShellText = "";
allowImpureBootstrap = false; allowImpureBootstrap = false;
bootstrap = ""; bootstrap = "";
banner = { };
}; };
formatting = { formatting = {
@@ -35,6 +35,7 @@ repo-lib.lib.mkRepo {
}; };
checks = { }; checks = { };
lefthook = { };
release = null; # or attrset below release = null; # or attrset below
}; };
@@ -43,6 +44,7 @@ repo-lib.lib.mkRepo {
tools = [ ]; tools = [ ];
shell.packages = [ ]; shell.packages = [ ];
checks = { }; checks = { };
lefthook = { };
packages = { }; packages = { };
apps = { }; apps = { };
}; };
@@ -52,11 +54,35 @@ repo-lib.lib.mkRepo {
Generated outputs: Generated outputs:
- `devShells.${system}.default` - `devShells.${system}.default`
- `checks.${system}.pre-commit-check` - `checks.${system}.formatting-check`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}` - `formatter.${system}`
- `packages.${system}.release` when `config.release != null` - `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem` - 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` ## `config.shell`
Fields: Fields:
@@ -68,13 +94,38 @@ Fields:
- `bootstrap` - `bootstrap`
Shell snippet that runs before the banner. Shell snippet that runs before the banner.
- `allowImpureBootstrap` - `allowImpureBootstrap`
Must be `true` if `bootstrap` is non-empty. Must be `true` when `bootstrap` is non-empty.
- `banner`
Shell banner configuration.
Rules: Rules:
- Default is pure-first. - Default is pure-first.
- Do not add bootstrap work unless the user actually wants imperative setup. - Do not add bootstrap work unless the user actually wants imperative local setup.
- Use `bootstrap` for unavoidable local setup only. - 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` ## `config.formatting`
@@ -88,7 +139,7 @@ Fields:
Rules: Rules:
- `nixfmt` is always enabled. - `nixfmt` is always enabled.
- Use formatter settings instead of ad hoc shell formatting logic. - Use formatter settings instead of shell hooks for formatting behavior.
## Checks ## Checks
@@ -96,10 +147,10 @@ Rules:
```nix ```nix
{ {
command = "go test ./..."; command = "bun test";
stage = "pre-push"; # or "pre-commit" stage = "pre-push"; # or "pre-commit"
passFilenames = false; passFilenames = false;
runtimeInputs = [ pkgs.go ]; runtimeInputs = [ pkgs.bun ];
} }
``` ```
@@ -111,8 +162,53 @@ Defaults:
Rules: Rules:
- Only `pre-commit` and `pre-push` are supported. - Only `pre-commit` and `pre-push` are supported here.
- The command is wrapped as a script and connected into `git-hooks.nix`. - 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 ## Tools
@@ -120,17 +216,19 @@ Preferred shape in `perSystem.tools`:
```nix ```nix
(repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromPackage {
name = "Go"; name = "Bun";
package = pkgs.go; package = pkgs.bun;
exe = "go"; # optional
version = { version = {
args = [ "version" ]; args = [ "--version" ];
match = null;
regex = null; regex = null;
group = 0; group = 0;
line = 1; line = 1;
}; };
banner = { banner = {
color = "CYAN"; color = "YELLOW";
icon = "";
iconColor = null;
}; };
required = true; required = true;
}) })
@@ -142,7 +240,10 @@ For a tool that should come from the host `PATH` instead of `nixpkgs`:
(repo-lib.lib.tools.fromCommand { (repo-lib.lib.tools.fromCommand {
name = "Nix"; name = "Nix";
command = "nix"; command = "nix";
version.args = [ "--version" ]; version = {
args = [ "--version" ];
group = 1;
};
}) })
``` ```
@@ -154,10 +255,11 @@ repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
Tool behavior: Tool behavior:
- Tool packages are added to the shell automatically. - 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. - Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically.
- Banner probing uses absolute executable paths. - Banner probing uses the resolved executable path.
- `required = true` by default. - `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. - Required tool probe failure aborts shell startup.
Use `shell.packages` instead of `tools` when: Use `shell.packages` instead of `tools` when:
@@ -214,72 +316,66 @@ Set `release = null` to disable the generated release package.
} }
``` ```
### `run` ### `versionMetaSet`
```nix ```nix
{ {
run = { versionMetaSet = {
script = '' key = "desktop_binary_version_max";
curl -fsS https://example.invalid/hook \ value = "$FULL_VERSION";
-H 'content-type: application/json' \
-d '{"tag":"'"$FULL_TAG"'"}'
'';
runtimeInputs = [ pkgs.curl ];
}; };
} }
``` ```
Also accepted for compatibility: ### `versionMetaUnset`
- `{ run = ''...''; }` ```nix
- legacy `mkRelease { release = [ { file = ...; content = ...; } ... ]; }` {
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 ## Release ordering
The generated `release` command does this: The generated `release` command currently does this:
1. Update `VERSION` 1. Require a clean git worktree
2. Run `release.steps` 2. Update `VERSION`
3. Run `postVersion` 3. Run `release.steps`
4. Run `nix fmt` 4. Run `postVersion`
5. `git add -A` 5. Run `nix fmt`
6. Commit 6. `git add -A`
7. Tag 7. Commit with `chore(release): <tag>`
8. Push branch 8. Tag
9. Push tags 9. Push branch
10. Push tags
Important consequence: Important consequences:
- `postVersion` is still before commit, tag, and push. - `postVersion` is before formatting, commit, tag, and push.
- There is no true post-tag or post-push hook in current `repo-lib`. - 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.
## Post-tag webhook limitation ## `mkRelease`
If the user asks for a webhook after the tag exists remotely: `repo-lib.lib.mkRelease` remains available when a repo wants only the release package:
- Prefer CI triggered by pushed tags in the consuming repo. ```nix
- Do not claim `postVersion` is post-tag; it is not. repo-lib.lib.mkRelease {
- Only extend `repo-lib` itself if the user explicitly wants a new library capability. system = system;
nixpkgsInput = nixpkgs; # optional
channels = [ "alpha" "beta" "rc" "internal" ];
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
```
## Legacy API summary Use the same release-step rules as `config.release`.
`mkDevShell` still supports:
- `extraPackages`
- `preToolHook`
- `extraShellHook`
- `additionalHooks`
- old `tools = [ { name; bin; versionCmd; color; } ]`
- `features.oxfmt`
- `formatters`
- `formatterSettings`
`mkRelease` still supports:
- `release = [ ... ]` as legacy alias for `steps`
- `extraRuntimeInputs` as legacy alias merged into `runtimeInputs`
When a repo already uses these APIs:
- preserve them unless the user asked to migrate
- do not mix old and new styles accidentally in the same call

View File

@@ -7,18 +7,22 @@ Edit `perSystem.tools` in the consuming repo:
```nix ```nix
tools = [ tools = [
(repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromPackage {
name = "Go"; name = "Bun";
package = pkgs.go; package = pkgs.bun;
version.args = [ "version" ]; version.args = [ "--version" ];
banner.color = "CYAN"; banner = {
color = "YELLOW";
icon = "";
};
}) })
]; ];
``` ```
Notes: Notes:
- Do not also add `pkgs.go` to `shell.packages`; `tools` already adds it. - 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 main program is not the desired one. - 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 ## Add a non-banner package to the shell
@@ -37,16 +41,38 @@ Use this for:
- internal scripts - internal scripts
- the generated `release` package itself - the generated `release` package itself
## Add a test phase or lint hook ## Customize the shell banner
For a simple global check: Use `config.shell.banner`:
```nix ```nix
config.checks.tests = { config.shell.banner = {
command = "go test ./..."; 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"; stage = "pre-push";
passFilenames = false; passFilenames = false;
runtimeInputs = [ pkgs.go ]; runtimeInputs = [ pkgs.bun ];
}; };
``` ```
@@ -54,20 +80,44 @@ For a system-specific check:
```nix ```nix
perSystem = { pkgs, ... }: { perSystem = { pkgs, ... }: {
checks.lint = { checks.format = {
command = "bun test"; command = "oxfmt --check .";
stage = "pre-push"; stage = "pre-commit";
runtimeInputs = [ pkgs.bun ]; passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
}; };
}; };
``` ```
Guidance: Guidance:
- Use `pre-commit` for fast format/lint work. - Use `pre-commit` for fast format or lint work.
- Use `pre-push` for slower test suites. - Use `pre-push` for slower test suites.
- Prefer `runtimeInputs` over inline absolute paths when the command needs extra CLIs. - 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 ## Add or change formatters
Use `config.formatting`: Use `config.formatting`:
@@ -76,11 +126,12 @@ Use `config.formatting`:
config.formatting = { config.formatting = {
programs = { programs = {
shfmt.enable = true; shfmt.enable = true;
gofmt.enable = true; oxfmt.enable = true;
}; };
settings = { settings = {
shfmt.options = [ "-i" "2" "-s" "-w" ]; shfmt.options = [ "-i" "2" "-s" "-w" ];
oxfmt.excludes = [ "*.md" "*.yml" ];
}; };
}; };
``` ```
@@ -116,31 +167,27 @@ config.release.steps = [
]; ];
``` ```
## Add a webhook during release Update metadata inside `VERSION`:
If the webhook may run before commit and tag creation, use a `run` step or `postVersion`.
Use a `run` step when it belongs with other release mutations:
```nix ```nix
config.release = { config.release.steps = [
runtimeInputs = [ pkgs.curl ];
steps = [
{ {
run = { versionMetaSet = {
script = '' key = "desktop_binary_version_max";
curl -fsS https://example.invalid/release-hook \ value = "$FULL_VERSION";
-H 'content-type: application/json' \ };
-d '{"version":"'"$FULL_VERSION"'"}' }
''; {
runtimeInputs = [ pkgs.curl ]; versionMetaUnset = {
key = "desktop_unused";
}; };
} }
]; ];
};
``` ```
Use `postVersion` when the action should happen after all `steps`: ## 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 ```nix
config.release.postVersion = '' config.release.postVersion = ''
@@ -153,8 +200,8 @@ config.release.runtimeInputs = [ pkgs.curl ];
Important: Important:
- Both of these still run before commit, tag, and push. - `postVersion` still runs before `nix fmt`, commit, tag, and push.
- They are not true post-tag hooks. - This is not a true post-tag hook.
## Add a true post-tag webhook ## Add a true post-tag webhook
@@ -162,11 +209,11 @@ Do not fake this with `postVersion`.
Preferred approach in the consuming repo: Preferred approach in the consuming repo:
1. Keep local release generation in `repo-lib`. 1. Keep local version generation in `repo-lib`.
2. Add CI triggered by tag push. 2. Trigger CI from tag push.
3. Put the webhook call in CI, where the tag is already created and pushed. 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 local post-tag capability. Only change `repo-lib` itself if the user explicitly asks for a new library capability.
## Add impure bootstrap work ## Add impure bootstrap work
@@ -175,8 +222,9 @@ Only do this when the user actually wants imperative shell setup:
```nix ```nix
config.shell = { config.shell = {
bootstrap = '' bootstrap = ''
export GOBIN="$PWD/.tools/bin" export BUN_INSTALL_GLOBAL_DIR="$PWD/.tools/bun/install/global"
export PATH="$GOBIN:$PATH" export BUN_INSTALL_BIN="$PWD/.tools/bun/bin"
export PATH="$BUN_INSTALL_BIN:$PATH"
''; '';
allowImpureBootstrap = true; allowImpureBootstrap = true;
}; };
@@ -184,14 +232,14 @@ config.shell = {
Do not add bootstrap work for normal Nix-packaged tools. Do not add bootstrap work for normal Nix-packaged tools.
## Migrate a legacy consumer to `mkRepo` ## Move from direct `mkRelease` to `mkRepo`
Only do this if requested. Only do this if requested.
Migration outline: Migration outline:
1. Move repeated shell/check/formatter config into `config`. 1. Move release package config into `config.release`.
2. Move old banner tools into `perSystem.tools`. 2. Move shell setup into `config.shell` and `perSystem.shell.packages`.
3. Move extra shell packages into `perSystem.shell.packages`. 3. Move bannered CLIs into `perSystem.tools`.
4. Replace old `mkRelease { release = [ ... ]; }` with `config.release.steps`. 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. 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 use flake

4
template/.gitignore vendored
View File

@@ -1,8 +1,12 @@
.direnv/ .direnv/
.moon/cache/
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
.tools/
bazel-* bazel-*
build/ build/
dist/ dist/
node_modules/ 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,10 +1,9 @@
# flake.nix — product repo template
{ {
description = "my-product"; description = "typescript-monorepo";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.1.0"; repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v4.0.0";
repo-lib.inputs.nixpkgs.follows = "nixpkgs"; repo-lib.inputs.nixpkgs.follows = "nixpkgs";
}; };
@@ -20,60 +19,61 @@
src = ./.; src = ./.;
config = { config = {
# includeStandardPackages = false;
shell = { shell = {
env = { banner = {
# FOO = "bar"; style = "pretty";
icon = "";
title = "Moonrepo shell ready";
titleColor = "GREEN";
subtitle = "Bun + TypeScript + Varlock";
subtitleColor = "GRAY";
borderColor = "BLUE";
}; };
extraShellText = '' extraShellText = ''
# any repo-specific shell setup here export PATH="$PWD/node_modules/.bin:$PATH"
''; '';
# Impure bootstrap is available as an explicit escape hatch. bootstrap = ''
# bootstrap = '' repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
# export GOBIN="$PWD/.tools/bin"
# export PATH="$GOBIN:$PATH" export BUN_INSTALL_GLOBAL_DIR="$repo_root/.tools/bun/install/global"
# ''; export BUN_INSTALL_BIN="$repo_root/.tools/bun/bin"
# allowImpureBootstrap = true; 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 = { formatting = {
programs = { programs = {
# shfmt.enable = true; oxfmt.enable = true;
# gofmt.enable = true;
}; };
settings = { settings = {
# shfmt.options = [ "-i" "2" "-s" "-w" ]; oxfmt.excludes = [
"*.css"
"*.graphql"
"*.hbs"
"*.html"
"*.md"
"*.mdx"
"*.mustache"
"*.scss"
"*.vue"
"*.yaml"
"*.yml"
];
}; };
}; };
checks.tests = {
command = "echo 'No tests defined yet.'";
stage = "pre-push";
passFilenames = false;
};
release = { release = {
steps = [ 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'';
# };
# }
];
}; };
}; };
@@ -98,30 +98,61 @@
}; };
}) })
# (repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromPackage {
# name = "Go"; name = "Bun";
# package = pkgs.go; package = pkgs.bun;
# version.args = [ "version" ]; version.args = [ "--version" ];
# banner.color = "CYAN"; banner = {
# }) color = "YELLOW";
icon = "";
};
})
]; ];
shell.packages = [ shell = {
packages = [
self.packages.${system}.release self.packages.${system}.release
# pkgs.go pkgs.openbao
# pkgs.bun pkgs.oxfmt
pkgs.oxlint
]; ];
};
# checks.lint = { checks = {
# command = "go test ./..."; format = {
# stage = "pre-push"; command = "oxfmt --check .";
# runtimeInputs = [ pkgs.go ]; stage = "pre-commit";
# }; passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
# packages.my-tool = pkgs.writeShellApplication { typecheck = {
# name = "my-tool"; command = "bun run typecheck";
# text = ''echo hello''; 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"]
}
}

File diff suppressed because it is too large Load Diff