Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac9f784602 | ||
|
|
4565a71863 | ||
|
|
3b38bc8f38 | ||
|
|
a5ab22f426 | ||
|
|
54b54fdece | ||
|
|
60d0c27db7 | ||
|
|
b6528466e0 | ||
|
|
146b1e9501 | ||
|
|
8fec37023f |
31
README.md
31
README.md
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
- `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 Bun-only Moonrepo + TypeScript + Varlock 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
|
||||||
|
|
||||||
- [Nix](https://nixos.org/download/) with flakes enabled
|
- [Nix](https://nixos.org/download/) with flakes enabled
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
## 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.5.0#default' --refresh
|
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1#default' --refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated repo includes:
|
The generated repo includes:
|
||||||
@@ -32,7 +34,7 @@ The generated repo includes:
|
|||||||
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.5.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";
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -154,10 +156,9 @@ config.release = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
run = {
|
versionMetaSet = {
|
||||||
script = ''
|
key = "desktop_binary_version_max";
|
||||||
echo "Released $FULL_TAG"
|
value = "$FULL_VERSION";
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -168,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
|
||||||
|
|
||||||
|
|||||||
292
docs/reviews/2026-03-21-repo-lib-audit.md
Normal file
292
docs/reviews/2026-03-21-repo-lib-audit.md
Normal 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 repo’s 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
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# TypeScript Monorepo Template Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the minimal starter template with a Bun-only Moonrepo + TypeScript + Varlock monorepo template exposed through the existing flake template.
|
|
||||||
|
|
||||||
**Architecture:** Expand `template/` into a complete repository skeleton while keeping `repo-lib.lib.mkRepo` as the integration point. Adapt the strict TypeScript config layout and Varlock command pattern from `../moon`, and update release tests so they evaluate the full template contents.
|
|
||||||
|
|
||||||
**Tech Stack:** Nix flakes, repo-lib, Bun, Moonrepo, Varlock, TypeScript
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: Documentation Baseline
|
|
||||||
|
|
||||||
### Task 1: Update public template docs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `README.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing expectation mentally against current docs**
|
|
||||||
|
|
||||||
Current docs describe only a minimal starter template and do not mention Bun, Moonrepo, or Varlock.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the README to describe the new template**
|
|
||||||
|
|
||||||
Document the generated workspace shape and first-run commands.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the README content is consistent with the template files**
|
|
||||||
|
|
||||||
Check all commands and filenames against the final template layout.
|
|
||||||
|
|
||||||
## Chunk 2: Template Skeleton
|
|
||||||
|
|
||||||
### Task 2: Replace the minimal template with a real monorepo skeleton
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `template/flake.nix`
|
|
||||||
- Create: `template/package.json`
|
|
||||||
- Create: `template/bunfig.toml`
|
|
||||||
- Create: `template/moon.yml`
|
|
||||||
- Create: `template/tsconfig.json`
|
|
||||||
- Create: `template/tsconfig.options.json`
|
|
||||||
- Create: `template/tsconfig/browser.json`
|
|
||||||
- Create: `template/tsconfig/bun.json`
|
|
||||||
- Create: `template/tsconfig/package.json`
|
|
||||||
- Create: `template/tsconfig/runtime.json`
|
|
||||||
- Create: `template/.env.schema`
|
|
||||||
- Modify: `template/.gitignore`
|
|
||||||
- Create: `template/README.md`
|
|
||||||
- Create: `template/apps/.gitkeep`
|
|
||||||
- Create: `template/packages/.gitkeep`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add or update template files**
|
|
||||||
|
|
||||||
Use `../moon` as the source for Moonrepo, Varlock, and TypeScript patterns, removing product-specific details.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify the template tree is coherent**
|
|
||||||
|
|
||||||
Check that all referenced files exist and that scripts reference only template-safe commands.
|
|
||||||
|
|
||||||
## Chunk 3: Test Coverage
|
|
||||||
|
|
||||||
### Task 3: Update release tests for the full template
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/release.sh`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add a failing test expectation**
|
|
||||||
|
|
||||||
The current template fixture copies only `template/flake.nix`, which is insufficient for the new template layout.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update fixture creation to copy the full template**
|
|
||||||
|
|
||||||
Rewrite template URL references in copied files as needed for local test evaluation.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the existing template evaluation case now uses the real skeleton**
|
|
||||||
|
|
||||||
Confirm `nix flake show` runs against the expanded template fixture.
|
|
||||||
|
|
||||||
## Chunk 4: Verification
|
|
||||||
|
|
||||||
### Task 4: Run template verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Verify: `README.md`
|
|
||||||
- Verify: `template/**/*`
|
|
||||||
- Verify: `tests/release.sh`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the release test suite**
|
|
||||||
|
|
||||||
Run: `nix develop -c bash tests/release.sh`
|
|
||||||
|
|
||||||
- [ ] **Step 2: Inspect the template file tree**
|
|
||||||
|
|
||||||
Run: `find template -maxdepth 3 -type f | sort`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the README examples still match the tagged template release pattern**
|
|
||||||
|
|
||||||
Check that versioned `repo-lib` URLs remain in the documented commands and release replacements.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# TypeScript Monorepo Template Design
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a new default template to this repository that generates a Bun-only TypeScript monorepo using Moonrepo, Varlock, and the shared TypeScript configuration pattern from `../moon`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
The generated template should include:
|
|
||||||
|
|
||||||
- a Nix flake wired through `repo-lib.lib.mkRepo`
|
|
||||||
- Bun-only JavaScript tooling
|
|
||||||
- Moonrepo root configuration
|
|
||||||
- strict shared TypeScript configs adapted from `../moon`
|
|
||||||
- Varlock enabled from day one
|
|
||||||
- a committed `.env.schema`
|
|
||||||
- empty `apps/` and `packages/` directories
|
|
||||||
- minimal documentation for first-run setup
|
|
||||||
|
|
||||||
The template should not include:
|
|
||||||
|
|
||||||
- demo apps or packages
|
|
||||||
- product-specific environment variables or OpenBao paths from `../moon`
|
|
||||||
- Node or pnpm support
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The existing `template/` directory remains the exported flake template. Instead of containing only a starter `flake.nix`, it will become a complete repository skeleton.
|
|
||||||
|
|
||||||
The generated repository will keep the current `repo-lib` integration pattern:
|
|
||||||
|
|
||||||
- `template/flake.nix` calls `repo-lib.lib.mkRepo`
|
|
||||||
- the shell provisions Bun, Moonrepo CLI, Varlock, and supporting tooling
|
|
||||||
- repo checks remain driven through `mkRepo` and Lefthook
|
|
||||||
|
|
||||||
Moonrepo and Varlock will be configured at the workspace root. The template will expose root tasks and scripts that work even before any projects are added.
|
|
||||||
|
|
||||||
## Template Contents
|
|
||||||
|
|
||||||
The template should contain:
|
|
||||||
|
|
||||||
- `flake.nix`
|
|
||||||
- `package.json`
|
|
||||||
- `bunfig.toml`
|
|
||||||
- `moon.yml`
|
|
||||||
- `tsconfig.json`
|
|
||||||
- `tsconfig.options.json`
|
|
||||||
- `tsconfig/browser.json`
|
|
||||||
- `tsconfig/bun.json`
|
|
||||||
- `tsconfig/package.json`
|
|
||||||
- `tsconfig/runtime.json`
|
|
||||||
- `.env.schema`
|
|
||||||
- `.gitignore`
|
|
||||||
- `README.md`
|
|
||||||
- `apps/.gitkeep`
|
|
||||||
- `packages/.gitkeep`
|
|
||||||
|
|
||||||
It may also keep generic repo support files already useful in templates, such as `.envrc`, `.gitlint`, `.gitleaks.toml`, `.vscode/settings.json`, and `flake.lock`, as long as they remain template-safe.
|
|
||||||
|
|
||||||
## Data And Command Flow
|
|
||||||
|
|
||||||
On first use:
|
|
||||||
|
|
||||||
1. the user creates a repo from the flake template
|
|
||||||
2. the shell provides Bun, Moonrepo, Varlock, and release support
|
|
||||||
3. `bun install` installs `@moonrepo/cli`, `varlock`, and TypeScript-related dependencies
|
|
||||||
4. entering the repo loads `varlock/auto-load`
|
|
||||||
5. root commands like `bun run env:check`, `bun run env:scan`, and `moon run :typecheck` work without any sample projects
|
|
||||||
|
|
||||||
## Varlock Design
|
|
||||||
|
|
||||||
The template will include a minimal `.env.schema` with:
|
|
||||||
|
|
||||||
- one canonical environment selector
|
|
||||||
- safe local defaults where practical
|
|
||||||
- placeholders for OpenBao-backed secrets using generic template paths
|
|
||||||
|
|
||||||
Root scripts in `package.json` will follow the `../moon` pattern for `env:check` and `env:scan`, including `BAO_*` and `OPENBAO_*` compatibility exports. The template will not encode any product-specific namespace names.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Existing release tests must continue to validate the exported template. The template fixture helper in `tests/release.sh` will need to copy the full template directory, not only `template/flake.nix`, so `nix flake show` exercises the real generated repository structure.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- Moonrepo root task behavior must remain valid with no projects present.
|
|
||||||
- Template-safe Varlock defaults must avoid broken first-run behavior while still demonstrating the intended pattern.
|
|
||||||
- The release test harness must not accidentally preserve upstream URLs inside the copied template.
|
|
||||||
34
flake.lock
generated
34
flake.lock
generated
@@ -1,5 +1,23 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772408722,
|
||||||
|
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"lefthook-nix": {
|
"lefthook-nix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
@@ -36,6 +54,21 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770107345,
|
"lastModified": 1770107345,
|
||||||
@@ -54,6 +87,7 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
"lefthook-nix": "lefthook-nix",
|
"lefthook-nix": "lefthook-nix",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
|
|||||||
20
flake.nix
20
flake.nix
@@ -3,6 +3,7 @@
|
|||||||
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";
|
||||||
lefthook-nix.url = "github:sudosubin/lefthook.nix";
|
lefthook-nix.url = "github:sudosubin/lefthook.nix";
|
||||||
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
|
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
outputs =
|
outputs =
|
||||||
{
|
{
|
||||||
self,
|
self,
|
||||||
|
flake-parts,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
treefmt-nix,
|
treefmt-nix,
|
||||||
lefthook-nix,
|
lefthook-nix,
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
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;
|
inherit flake-parts nixpkgs treefmt-nix;
|
||||||
lefthookNix = lefthook-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;
|
||||||
@@ -94,20 +96,16 @@
|
|||||||
pkgs.runCommand "release-tests"
|
pkgs.runCommand "release-tests"
|
||||||
{
|
{
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
bash
|
go
|
||||||
git
|
git
|
||||||
nix
|
|
||||||
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
1
packages/release/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
vendor/
|
||||||
127
packages/release/cmd/release/main.go
Normal file
127
packages/release/cmd/release/main.go
Normal 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
29
packages/release/go.mod
Normal 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
45
packages/release/go.sum
Normal 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=
|
||||||
91
packages/release/internal/release/exec.go
Normal file
91
packages/release/internal/release/exec.go
Normal 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
|
||||||
|
}
|
||||||
64
packages/release/internal/release/release_step.go
Normal file
64
packages/release/internal/release/release_step.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/release/internal/release/release_step_apply.go
Normal file
39
packages/release/internal/release/release_step_apply.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/release/internal/release/release_step_context.go
Normal file
73
packages/release/internal/release/release_step_context.go
Normal 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)
|
||||||
|
}
|
||||||
45
packages/release/internal/release/release_step_replace.go
Normal file
45
packages/release/internal/release/release_step_replace.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
18
packages/release/internal/release/release_step_write_file.go
Normal file
18
packages/release/internal/release/release_step_write_file.go
Normal 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
|
||||||
|
}
|
||||||
372
packages/release/internal/release/release_test.go
Normal file
372
packages/release/internal/release/release_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
packages/release/internal/release/runner.go
Normal file
129
packages/release/internal/release/runner.go
Normal 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
|
||||||
|
}
|
||||||
90
packages/release/internal/release/shell.go
Normal file
90
packages/release/internal/release/shell.go
Normal 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()
|
||||||
|
}
|
||||||
10
packages/release/internal/release/slices.go
Normal file
10
packages/release/internal/release/slices.go
Normal 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
|
||||||
|
}
|
||||||
505
packages/release/internal/release/ui.go
Normal file
505
packages/release/internal/release/ui.go
Normal 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]) + "..."
|
||||||
|
}
|
||||||
212
packages/release/internal/release/ui_test.go
Normal file
212
packages/release/internal/release/ui_test.go
Normal 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
|
||||||
|
}
|
||||||
261
packages/release/internal/release/version.go
Normal file
261
packages/release/internal/release/version.go
Normal 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)
|
||||||
|
}
|
||||||
112
packages/release/internal/release/version_file.go
Normal file
112
packages/release/internal/release/version_file.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
flake-parts,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
treefmt-nix,
|
treefmt-nix,
|
||||||
lefthookNix,
|
lefthookNix,
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
}:
|
}:
|
||||||
import ../repo-lib/lib.nix {
|
import ../repo-lib/lib.nix {
|
||||||
inherit
|
inherit
|
||||||
|
flake-parts
|
||||||
nixpkgs
|
nixpkgs
|
||||||
treefmt-nix
|
treefmt-nix
|
||||||
lefthookNix
|
lefthookNix
|
||||||
|
|||||||
@@ -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 "$@"
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
flake-parts,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
treefmt-nix,
|
treefmt-nix,
|
||||||
lefthookNix,
|
lefthookNix,
|
||||||
@@ -6,58 +7,12 @@
|
|||||||
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,812 +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
|
||||||
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";
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
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}'";
|
|
||||||
|
|
||||||
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
|
|
||||||
{ };
|
|
||||||
|
|
||||||
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 =
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
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 = { };
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mkDevShell =
|
|
||||||
{
|
|
||||||
system,
|
|
||||||
src ? ./.,
|
|
||||||
nixpkgsInput ? nixpkgs,
|
|
||||||
extraPackages ? [ ],
|
|
||||||
preToolHook ? "",
|
|
||||||
extraShellHook ? "",
|
|
||||||
additionalHooks ? { },
|
|
||||||
lefthook ? { },
|
|
||||||
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
|
||||||
lefthookConfig = lefthook;
|
importPkgs
|
||||||
shellConfig = shellConfig;
|
duplicateStrings
|
||||||
tools = legacyTools;
|
mergeUniqueAttrs
|
||||||
extraPackages =
|
;
|
||||||
extraPackages
|
inherit (defaults)
|
||||||
++ lib.optionals oxfmtEnabled [
|
supportedSystems
|
||||||
pkgs.oxfmt
|
defaultReleaseChannels
|
||||||
pkgs.oxlint
|
;
|
||||||
];
|
inherit (toolsModule)
|
||||||
};
|
normalizeStrictTool
|
||||||
|
;
|
||||||
mkRelease =
|
inherit (hooksModule)
|
||||||
{
|
normalizeLefthookConfig
|
||||||
system,
|
;
|
||||||
nixpkgsInput ? nixpkgs,
|
inherit normalizeShellBanner;
|
||||||
...
|
inherit (shellModule)
|
||||||
}@rawArgs:
|
buildShellArtifacts
|
||||||
let
|
;
|
||||||
pkgs = importPkgs nixpkgsInput system;
|
inherit (releaseModule)
|
||||||
release = normalizeReleaseConfig rawArgs;
|
mkRelease
|
||||||
channelList = lib.concatStringsSep " " release.channels;
|
|
||||||
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 = { };
|
|
||||||
lefthook = { };
|
|
||||||
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;
|
|
||||||
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 = 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: systemResults.${system}.env.checks);
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
packages/repo-lib/lib/common.nix
Normal file
29
packages/repo-lib/lib/common.nix
Normal 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;
|
||||||
|
}
|
||||||
26
packages/repo-lib/lib/defaults.nix
Normal file
26
packages/repo-lib/lib/defaults.nix
Normal 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";
|
||||||
|
};
|
||||||
|
}
|
||||||
116
packages/repo-lib/lib/hooks.nix
Normal file
116
packages/repo-lib/lib/hooks.nix
Normal 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
|
||||||
|
{ };
|
||||||
|
}
|
||||||
105
packages/repo-lib/lib/release.nix
Normal file
105
packages/repo-lib/lib/release.nix
Normal 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
|
||||||
|
;
|
||||||
|
}
|
||||||
195
packages/repo-lib/lib/repo.nix
Normal file
195
packages/repo-lib/lib/repo.nix
Normal 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
|
||||||
|
;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
221
packages/repo-lib/lib/shell.nix
Normal file
221
packages/repo-lib/lib/shell.nix
Normal 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;
|
||||||
|
}
|
||||||
90
packages/repo-lib/lib/tools.nix
Normal file
90
packages/repo-lib/lib/tools.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -54,12 +54,35 @@ repo-lib.lib.mkRepo {
|
|||||||
Generated outputs:
|
Generated outputs:
|
||||||
|
|
||||||
- `devShells.${system}.default`
|
- `devShells.${system}.default`
|
||||||
|
- `checks.${system}.formatting-check`
|
||||||
- `checks.${system}.hook-check`
|
- `checks.${system}.hook-check`
|
||||||
- `checks.${system}.lefthook-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:
|
||||||
@@ -71,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`
|
||||||
|
|
||||||
@@ -91,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
|
||||||
|
|
||||||
@@ -99,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 ];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -114,37 +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 `lefthook.nix`.
|
- The command is wrapped with `writeShellApplication`.
|
||||||
- `pre-commit` and `pre-push` commands are configured to run in parallel.
|
- `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
|
## Raw Lefthook config
|
||||||
|
|
||||||
Use `config.lefthook` or `perSystem.lefthook` for advanced Lefthook features that the built-in `checks` abstraction does not carry.
|
Use `config.lefthook` or `perSystem.lefthook` when the task needs advanced Lefthook features or unsupported stages.
|
||||||
|
|
||||||
Example:
|
Pass-through attrset example:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
{
|
{
|
||||||
checks.tests = {
|
checks.tests = {
|
||||||
command = "go test ./...";
|
command = "bun test";
|
||||||
stage = "pre-push";
|
stage = "pre-push";
|
||||||
|
runtimeInputs = [ pkgs.bun ];
|
||||||
};
|
};
|
||||||
|
|
||||||
lefthook.pre-push.commands.tests.stage_fixed = true;
|
lefthook.pre-push.commands.tests.stage_fixed = true;
|
||||||
|
|
||||||
lefthook.commit-msg.commands.commitlint = {
|
lefthook.commit-msg.commands.commitlint = {
|
||||||
run = "pnpm commitlint --edit {1}";
|
run = "bun commitlint --edit {1}";
|
||||||
stage_fixed = true;
|
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:
|
Rules:
|
||||||
|
|
||||||
- These attrsets are passed through to `lefthook.nix`.
|
- `config.lefthook` and `perSystem.lefthook` are recursive attrset passthroughs merged after generated checks.
|
||||||
- They are merged after generated checks, so they can extend generated commands.
|
- Structured hook entries support only:
|
||||||
- Prefer `checks` for the simple common case and `lefthook` for advanced fields such as `stage_fixed`, `files`, `glob`, `exclude`, `jobs`, or `scripts`.
|
`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
|
||||||
|
|
||||||
@@ -152,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;
|
||||||
})
|
})
|
||||||
@@ -174,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;
|
||||||
|
};
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,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:
|
||||||
@@ -246,73 +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`
|
|
||||||
- `lefthook`
|
|
||||||
- `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
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
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.5.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";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,29 +109,31 @@
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
shell.packages = [
|
shell = {
|
||||||
|
packages = [
|
||||||
self.packages.${system}.release
|
self.packages.${system}.release
|
||||||
pkgs.bun
|
|
||||||
pkgs.openbao
|
pkgs.openbao
|
||||||
pkgs.oxfmt
|
pkgs.oxfmt
|
||||||
pkgs.oxlint
|
pkgs.oxlint
|
||||||
];
|
];
|
||||||
|
};
|
||||||
|
|
||||||
checks.format = {
|
checks = {
|
||||||
|
format = {
|
||||||
command = "oxfmt --check .";
|
command = "oxfmt --check .";
|
||||||
stage = "pre-commit";
|
stage = "pre-commit";
|
||||||
passFilenames = false;
|
passFilenames = false;
|
||||||
runtimeInputs = [ pkgs.oxfmt ];
|
runtimeInputs = [ pkgs.oxfmt ];
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.typecheck = {
|
typecheck = {
|
||||||
command = "bun run typecheck";
|
command = "bun run typecheck";
|
||||||
stage = "pre-push";
|
stage = "pre-push";
|
||||||
passFilenames = false;
|
passFilenames = false;
|
||||||
runtimeInputs = [ pkgs.bun ];
|
runtimeInputs = [ pkgs.bun ];
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.env-check = {
|
env-check = {
|
||||||
command = "bun run env:check";
|
command = "bun run env:check";
|
||||||
stage = "pre-push";
|
stage = "pre-push";
|
||||||
passFilenames = false;
|
passFilenames = false;
|
||||||
@@ -141,7 +143,7 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.env-scan = {
|
env-scan = {
|
||||||
command = "bun run env:scan";
|
command = "bun run env:scan";
|
||||||
stage = "pre-commit";
|
stage = "pre-commit";
|
||||||
passFilenames = false;
|
passFilenames = false;
|
||||||
@@ -152,4 +154,5 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
1441
tests/release.sh
1441
tests/release.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user