diff --git a/Build/Program.fs b/Build/Program.fs
index b75a6c5..11cc8cb 100644
--- a/Build/Program.fs
+++ b/Build/Program.fs
@@ -53,6 +53,7 @@ module Project =
"build", Repo.Project.``Partas.Build``.Path
"baked", Repo.Project.``Partas.Build.Baked``.Path
"cmd", Repo.Project.``Partas.Build.Cmd``.Path
+ "shipit", Repo.Project.``Partas.Build.EasyBuild.ShipIt``.Path
"external-annotations", Repo.Project.``Partas.ExternalAnnotations``.Path
"external-annotations-tool", Repo.Project.``Partas.ExternalAnnotations.Tool``.Path
"build-external-annotations", Repo.Project.``Partas.Build.ExternalAnnotations``.Path
diff --git a/Partas.Build.slnx b/Partas.Build.slnx
index 37009d3..d4309a5 100644
--- a/Partas.Build.slnx
+++ b/Partas.Build.slnx
@@ -20,6 +20,9 @@
+
+
+
diff --git a/README.md b/README.md
index 1776065..29541c1 100644
--- a/README.md
+++ b/README.md
@@ -353,6 +353,7 @@ Build/
src/Partas.Build/ the library
src/Partas.Build.Cmd/ the command value and the process runner
src/Partas.Build.Baked/ ready-made options, stages and semver helpers
+src/extensions/Partas.Build.EasyBuild.ShipIt/ ShipIt-backed bump, release inputs, commands and explicit setup
docs/ the Nacara site (Site.fs, docs.fsproj, content/, blog/, static/)
tests/ the Expecto suites
```
diff --git a/docs/Site.fs b/docs/Site.fs
index da16e5d..85203fe 100644
--- a/docs/Site.fs
+++ b/docs/Site.fs
@@ -9,6 +9,7 @@ let theme =
Theme.defaults
|> Theme.navbar [
NavbarSection("Build", "build", "/build/")
+ NavbarSection("Extensions", "extensions", "/extensions/")
NavbarSection("External Annotations", "external-annotations", "/external-annotations/")
NavbarSection("Reference", "reference", "/reference/")
NavbarDivider
@@ -32,7 +33,10 @@ let theme =
let apiOptions = {
FSharpApi.defaults with
Root = "reference"
- Sources = [ FSharpApiSource.create "../src/Partas.Build/bin/Release/net10.0/Partas.Build.dll" ]
+ Sources = [
+ FSharpApiSource.create "../src/Partas.Build/bin/Release/net10.0/Partas.Build.dll"
+ FSharpApiSource.create "../src/extensions/Partas.Build.EasyBuild.ShipIt/bin/Release/net10.0/Partas.Build.EasyBuild.ShipIt.dll"
+ ]
Exclude = [ "Partas.Build.Internal" ]
}
diff --git a/docs/content/extensions/index.md b/docs/content/extensions/index.md
new file mode 100644
index 0000000..e738c89
--- /dev/null
+++ b/docs/content/extensions/index.md
@@ -0,0 +1,13 @@
+---
+title: Extensions
+category: Extensions
+order: 0
+---
+
+# Extensions
+
+Extensions package workflows for specific tools as composable Partas.Build operations, inputs, stages and commands.
+
+- [EasyBuild.ShipIt](shipit.md): conventional-commit versioning, synchronized changelog/project updates, release commands and explicit setup.
+
+Use `Partas.Build.Baked` for general build operations. Use an extension when its tool should own a workflow, such as calculating package versions from commit history.
diff --git a/docs/content/extensions/shipit.md b/docs/content/extensions/shipit.md
new file mode 100644
index 0000000..be1fa08
--- /dev/null
+++ b/docs/content/extensions/shipit.md
@@ -0,0 +1,113 @@
+---
+title: EasyBuild.ShipIt
+category: Extensions
+order: 1
+---
+
+# EasyBuild.ShipIt
+
+`Partas.Build.EasyBuild.ShipIt` wraps [EasyBuild.ShipIt](https://github.com/easybuild-org/EasyBuild.ShipIt) as reusable operations, inputs, stages and commands. ShipIt calculates release versions from conventional commits, updates changelogs and configured project files, and can open release pull requests.
+
+The extension supports `net10.0`, `net8.0` and `netstandard2.0`. The default ShipIt tool version, 3.1.0, requires .NET 10. Add the extension as a project/package reference; script consumers can use the [package feed described on the home page](https://shayanhabibi.github.io/Partas.Build/).
+
+## Add the commands to your build
+
+```fsharp
+open Partas.Build
+open Partas.Build.EasyBuild.ShipIt
+
+let root = Command.root {
+ workingDir __SOURCE_DIRECTORY__
+ addCommand Commands.shipit
+ addCommand (command "bump" {
+ Command.pipeline { Stages.bump }
+ })
+}
+
+let build args = Command.invoke args root
+```
+
+`Commands.shipit` exposes `generate`, `github`, `bump`, `version`, `conventions`, `setup`, and `init changelog|workflows|github|project`. Each command registers the inputs its stages read. Help, schema and explain inspect the workflow without running setup or release operations.
+
+For a build project, invoke it as `dotnet run --project Build.fsproj -- shipit ...`. For a script, use `dotnet fsi build.fsx -- shipit ...` after your usual root invocation. The following examples use the build-project form.
+
+## Set up the local tool
+
+```shell
+dotnet run --project Build.fsproj -- shipit setup
+```
+
+Setup creates the Git repository's `.config/dotnet-tools.json` if absent, installs EasyBuild.ShipIt 3.1.0 only if unregistered, and restores the tools. Existing registrations retain their pinned version. `--tool-version` selects the version for a new registration. There is no global installation.
+
+From a subdirectory, setup still uses the closest Git repository's manifest. Competing nested manifests or an alternate root `dotnet-tools.json` are rejected; invoke from the repository root or remove the competing manifest. Normal ShipIt prefab stages apply the same boundary check.
+
+Setup is explicit: bumping does not install the tool or change GitHub settings.
+
+## Keep package versions and changelogs together
+
+```shell
+dotnet run --project Build.fsproj -- shipit init project \
+ --changelog src/MyLibrary/CHANGELOG.md \
+ --project src/MyLibrary/MyLibrary.fsproj
+```
+
+Use a single line in PowerShell. This creates the changelog if missing and registers a ShipIt XML updater. Project/changelog input paths are relative to the stage's working directory; updater paths are relative to the changelog:
+
+```yaml
+updaters:
+ - xml:
+ file: MyLibrary.fsproj
+ selector: /Project/PropertyGroup/Version
+```
+
+Configuration preserves existing front matter, comments, changelog body, line endings and UTF-8 BOM. Repeated setup does not duplicate equivalent XML updaters. Projects must contain exactly one unconditional literal `` under ``. Conditional, computed or externally defined versions require manual updater configuration. Existing `updaters` must use a nonempty block sequence; remove an empty key before initialization.
+
+For independently versioned packages, use a changelog for each package. For a shared version, register multiple projects against one changelog using `--project`. When adopting an existing package, seed its changelog with its last released version and set `last_commit_released` to the correct commit. ShipIt calculates from the changelog's release history, rather than reading the `.fsproj` version as its baseline.
+
+`AssemblyVersion` remains under your compatibility policy. This setup only registers updates for the package `Version`.
+
+## Use the ShipIt-backed bump
+
+```shell
+dotnet run --project Build.fsproj -- shipit bump --allow-branch master --dry-run
+dotnet run --project Build.fsproj -- shipit bump --allow-branch master
+```
+
+`Stages.bump`, `Stages.bumpWith` and `shipit bump` always use **local mode**: ShipIt calculates the version and updates the changelog and configured project files without pushing or creating a PR. Even consumer-supplied options with another mode are forced to local. The bump command has no `--mode` input.
+
+Do not follow this with the agnostic `Partas.Build.Baked.SemVer` bump. ShipIt owns the release version; a second independent increment could make the changelog and package disagree. Review the local changes, commit them, then compose your normal build, pack and publish stages.
+
+Consumer-defined inputs can supply the configuration directly:
+
+```fsharp
+let bump = Stages.bumpWith (InputSpec.ret {
+ ReleaseOptions.defaults with
+ AllowedBranches = [ "master" ]
+ PreRelease = Some "beta"
+})
+
+let bumpCommand = command "bump" {
+ Command.pipeline { bump }
+}
+```
+
+Omit `PreRelease` for a stable CLI request, or supply a prefix such as `beta`. A prerelease configured in changelog front matter still applies when the CLI prefix is omitted.
+
+## Release and initialize GitHub explicitly
+
+`shipit generate` supports `--mode local|pull-request|push`, defaulting to `pull-request`. `shipit github` selects the GitHub provider explicitly and accepts a sensitive `--token`; it falls back to `GITHUB_TOKEN` or upstream `gh` authentication. Token values are masked in command labels, schema and explain; upstream tool output remains upstream's responsibility.
+
+Release inputs also include `--allow-branch` (default `main`, multiple values), `--pre-release PREFIX`, remote hostname/owner/repository overrides, `--skip-invalid-commit`, `--skip-merge-commit`, and `--dry-run`. Use `--allow-branch master` for repositories using master.
+
+`shipit init changelog` and `shipit init workflows` delegate upstream scaffolding, which refuses to overwrite existing files. `shipit init github` applies GitHub merge and workflow-permission settings using authenticated `gh`; preview them with `--dry-run`. `--org` additionally changes organization settings and requires the appropriate administrative access.
+
+CI needs full Git history, conventional commits and suitable GitHub permissions/authentication. ShipIt generates release changes; it does not publish NuGet packages. See [upstream's configuration and CI recipes](https://github.com/easybuild-org/EasyBuild.ShipIt#configuration) for changelog include/exclude rules and release automation.
+
+## Choose the composition level
+
+- `Operations`: pure `Cmd` builders for upstream operations. Low-level callers supply their own working directory/tool resolution.
+- `Inputs`: reusable typed CLI inputs, including token defaults evaluated per invocation.
+- `Stages`: ready-made stages and `*With` variants for consumer-supplied `InputSpec` values. They inherit normal execution settings.
+- `Commands.shipit`: the complete command tree for a build root.
+
+The extension is also included in the [API reference](https://shayanhabibi.github.io/Partas.Build/reference/).
diff --git a/docs/static/llms.txt b/docs/static/llms.txt
index f0e8113..be9634e 100644
--- a/docs/static/llms.txt
+++ b/docs/static/llms.txt
@@ -11,6 +11,7 @@
- [Index](https://shayanhabibi.github.io/Partas.Build/build/): the layers, and a first pipeline
- [Composition](https://shayanhabibi.github.io/Partas.Build/build/composition/): nesting stages, and reusing commands across files
- [Hosting](https://shayanhabibi.github.io/Partas.Build/build/hosting/): running a build from a long-lived F# session (SageFs) through `Command.invoke`
+- [ShipIt extension](https://shayanhabibi.github.io/Partas.Build/extensions/shipit/): ShipIt-backed package bumps, release commands and explicit setup
## Ask the script rather than reading it
diff --git a/docs/superpowers/plans/2026-10-07-easybuild-shipit.md b/docs/superpowers/plans/2026-10-07-easybuild-shipit.md
new file mode 100644
index 0000000..069faf5
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-easybuild-shipit.md
@@ -0,0 +1,173 @@
+# EasyBuild.ShipIt Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
+
+**Goal:** Ship a separately packaged extension exposing ShipIt operations, inputs, setup, commands, and a ShipIt-backed local bump.
+
+**Architecture:** Pure Cmd builders describe upstream CLI operations. InputSpec-based stages compose them with Partas.Build execution; explicit setup owns tool registration and project-updater configuration. ShipIt alone calculates versions and applies release changes.
+
+**Tech Stack:** F#, Partas.Build, System.Text.Json, YamlDotNet for front-matter preservation, System.Xml.Linq, Expecto, repository build CLI.
+
+**Spec:** `docs/superpowers/specs/2026-10-07-easybuild-shipit-design.md`
+
+## Global Constraints
+
+- Project: `src/extensions/Partas.Build.EasyBuild.ShipIt/Partas.Build.EasyBuild.ShipIt.fsproj`; namespace/package name `Partas.Build.EasyBuild.ShipIt`.
+- Package version 0.1.0; assembly identity 0.0.0.0; targets net10.0, net8.0, netstandard2.0.
+- Reference Partas.Build directly. Never reference ShipIt's executable as a library or duplicate its algorithms.
+- Default new tool installation pins EasyBuild.ShipIt 3.1.0; preserve existing registrations. Tool runtime currently requires .NET 10.
+- No global installation, remote PR creation, publication, or GitHub settings changes during development/tests.
+- ShipIt owns release versions. Bump is local-only; no Baked.SemVer increment. Project configuration leaves AssemblyVersion unchanged.
+- Every shell command starts with rtk. Check callable SageFs tools before F# edits; if available follow the user's SageFs workflow, otherwise follow CLAUDE.md.
+
+## Review Focus
+
+- Hostile argument values and token values must retain argument boundaries and never leak into labels (Task 1).
+- Running from a nested directory must use the intended repository manifest, without touching an ancestor repository (Task 2).
+- Front matter with unknown keys/comments and Windows line endings must survive configuration (Task 3).
+- Conditional or externally supplied MSBuild versions must produce a useful error before any file mutation (Task 3).
+- Help/schema/explain must never install tools, edit files, or initiate releases (Task 4).
+
+## File map
+
+Extension files compile in this order: `Types.fs`, `Operations.fs`, `Inputs.fs`, `ToolSetup.fs`, `ProjectSetup.fs`, `Stages.fs`, `Commands.fs`. Each owns its named responsibility. Package README documents setup and examples. Tests live in `tests/Partas.Build.Tests/ShipItTests.fs`, registered in the existing suite with an extension project reference. Modify `Partas.Build.slnx`, `Build/Program.fs`, and root README for discovery, packing, and version bump registration.
+
+## Task 1: Pure operation builders and package skeleton
+
+**Files:** Create extension fsproj, Types.fs, Operations.fs; create ShipItTests.fs; modify main test fsproj and solution.
+
+**Interfaces:**
+
+```fsharp
+type ReleaseMode = Local | PullRequest | Push
+type ReleaseOptions = {
+ AllowedBranches: string list
+ Mode: ReleaseMode
+ PreRelease: string option
+ RemoteHostname: string option
+ RemoteOwner: string option
+ RemoteRepository: string option
+ SkipInvalidCommit: bool
+ SkipMergeCommit: bool
+ DryRun: bool
+}
+// ReleaseOptions.defaults has main, PullRequest, None optional fields, false flags.
+// Operations signatures, all returning Cmd:
+// generate: ReleaseOptions -> Cmd
+// github: ReleaseOptions -> string option -> Cmd
+// version: unit -> Cmd; conventions: unit -> Cmd
+// initChangelog: string option -> Cmd; initWorkflows: unit -> Cmd
+// initGithub: bool -> bool -> Cmd (organization, dryRun)
+```
+
+- [ ] Write failing tests against this contract; register them in the existing Expecto suite. Initial assertions:
+
+```fsharp
+test "local generation retains literal prefix boundaries" {
+ let options = { ReleaseOptions.defaults with Mode = Local; PreRelease = Some "beta candidate" }
+ let operation = Operations.generate options
+ Expect.equal operation.Executable "dotnet" "dotnet launcher"
+ Expect.sequenceEqual operation.Arguments
+ [ "shipit"; "--allow-branch"; "main"; "--mode"; "local"; "--pre-release"; "beta candidate" ]
+ "no shell splitting"
+}
+test "github token is marked secret" {
+ let operation = Operations.github ReleaseOptions.defaults (Some "secret value")
+ let index = operation.Arguments |> List.findIndex ((=) "secret value")
+ Expect.isTrue (operation.Secrets.Contains index) "token must be masked"
+}
+```
+
+- [ ] Build/run `rtk proxy dotnet run --project tests/Partas.Build.Tests -- --filter-test-list shipit --sequenced`; observe missing API failures before implementation.
+- [ ] Create the library project using Baked's framework/package conventions. Use `Cmd.ofList "dotnet"` and `Cmd.args`, `Cmd.argWhenSome`, `Cmd.secretOption`; enumerate mode strings as local/pull-request/push. Emit repeated `--allow-branch` values according to verified upstream Spectre parsing. Validate an empty AllowedBranches list and blank optional values with ArgumentException rather than accidentally restoring main upstream.
+- [ ] Extend operation tests across every optional argument/flag, repeated branches, all modes, blank values, executable tokens, omitted prefix, custom changelog paths with spaces, and GitHub init flags. Inspect actual Arguments, not just rendered strings.
+- [ ] Run focused tests and commit `feat: add ShipIt operation builders`.
+
+## Task 2: Explicit repository-local tool setup
+
+**Files:** ToolSetup.fs; ShipItTests.fs.
+
+**Interfaces:** `ToolSetup.runWith : (Cmd -> Async) -> string -> string -> Async` takes executor, repository directory, version. `ToolSetup.run : string -> string -> Async` supplies real process execution via Partas.Build. Keep executor seam internal with InternalsVisibleTo the main tests.
+
+- [ ] Write failing temporary-directory tests: absent manifest yields new-manifest, install, restore in order; registered package restores only; missing registration installs pinned/custom version; repeated setup does not reinstall; malformed manifest fails before execution. Fake executor simulates manifest creation/registration and records commands:
+
+```fsharp
+let recorded = ResizeArray()
+let execute operation = async {
+ recorded.Add operation
+ return 0
+}
+// Use this executor for existing manifests; for missing manifests have the fake
+// write the corresponding fixture after the new/install operation.
+```
+
+- [ ] Run focused tests and observe the expected missing implementation failure.
+- [ ] Resolve the git repository boundary explicitly for the public setup stage; use `/.config/dotnet-tools.json` and a single consistent working directory. Inspect manifest JSON using System.Text.Json (netstandard-only package reference consistent with main library). Fail on malformed tools/commands data. Preserve existing version; validate the supplied new version is nonblank. Create root manifest only when absent, install using explicit manifest path/version, then restore using explicit manifest path. Nonzero command exit stops setup with stage failure; never ignore it.
+- [ ] Test nested directory resolution, ancestor-manifest isolation, missing git repository, executor failures, cancellation, and custom version argument boundaries. No PATH/global installation changes.
+- [ ] Run focused tests and commit `feat: add explicit local ShipIt tool setup`.
+
+## Task 3: Configure project version updaters without losing changelog data
+
+**Files:** ProjectSetup.fs; extension fsproj dependency entries; ShipItTests.fs.
+
+**Interface:** `ProjectSetup.configure : string -> string list -> unit` takes an existing changelog path and project paths. Upstream changelog creation is a preceding stage, used only if the file is absent. Configuration function itself does not execute processes.
+
+- [ ] Write failing fixtures with project Version and AssemblyVersion, changelog front matter containing unknown nested keys, existing updaters/comments, body text, LF/CRLF and UTF-8 BOM. Assert preserved text and duplicate-free XML updater insertion:
+
+```fsharp
+let before = File.ReadAllText changelog
+ProjectSetup.configure changelog [ project ]
+let once = File.ReadAllText changelog
+ProjectSetup.configure changelog [ project ]
+Expect.equal (File.ReadAllText changelog) once "configuration is idempotent"
+Expect.stringContains once "selector: /Project/PropertyGroup/Version" "ShipIt XML selector"
+Expect.stringContains once "# retained comment" "existing configuration remains"
+Expect.stringContains (File.ReadAllText project) "0.0.0.0" "identity unchanged"
+```
+
+- [ ] Run focused tests red. Verify/pin a YamlDotNet version supporting netstandard2.0 using official package/source data at implementation time; use its parser/representation nodes for structure validation and source marks for narrowly inserting entries. Avoid serializing all existing YAML, which loses comments and formatting.
+- [ ] Validate all projects before writes: exactly one unconditional `/Project/PropertyGroup/Version` element, no Condition on node/group, nonempty literal version, no property expressions. Reject missing/duplicate/conditional/external versions with the project path and explanation. Resolve updater file paths relative to changelog with forward slashes. Validate front matter delimiters, mapping root, updaters sequence, and existing xml updater structure; reject ambiguous configurations. Insert only missing entries while preserving original bytes/text outside the insertion region, newline style, BOM, comments, and body. Commit replacement atomically after every validation succeeds.
+- [ ] Add tests for duplicate project inputs, existing equivalent relative path updater, special YAML path characters, escaped filename, malformed YAML, conditional versions, no Version, property expressions, and multi-project all-or-nothing validation.
+- [ ] Run focused tests and commit `feat: configure ShipIt project version updaters`.
+
+## Task 4: Inputs, composable stages, and the command tree
+
+**Files:** Inputs.fs, Stages.fs, Commands.fs; ShipItTests.fs.
+
+**Interfaces:** `Inputs.release : InputSpec`; `Inputs.bump : InputSpec` does not register --mode and supplies Local; `Inputs.token : InputSpec`; `Inputs.toolVersion : InputSpec`; setup/init inputs mirror operation parameters. `Stages.generateWith : InputSpec -> InputSpec` and `Stages.githubWith : InputSpec -> InputSpec -> InputSpec`; default counterparts use Inputs. `Stages.bumpWith` takes release options but forces Local, including for caller-supplied Push/PullRequest values. `Stages.bump` uses Inputs.bump. `Stages.setupWith` takes tool-version input; `Stages.setup` supplies default. `Stages.configureProjects` accepts changelog and project paths and explicitly initializes only a missing changelog. `Commands.shipit` supplies the composed command tree.
+
+- [ ] Write failing tests using existing Helpers.parse to resolve specs and inspect stage labels. Assert release modes, branch defaults, token env/CLI override, invalid enum parsing, every init command, and consumer InputSpec.ret overrides.
+
+```fsharp
+let localOnly = Stages.bumpWith (InputSpec.ret { ReleaseOptions.defaults with Mode = Push })
+let resolved = localOnly.Read (parse localOnly.Inputs "")
+let labels = resolved.Steps |> List.choose (function
+ | Step.Operation(ValueSome name, _) | Step.StepFn(ValueSome name, _) -> Some name
+ | _ -> None)
+Expect.isTrue (labels |> List.exists (fun s -> s.Contains "--mode local")) "bump cannot push"
+```
+
+- [ ] Run tests red. Build inputs using existing Input.option/def/description/validation and applicative input CE. Use optional string prefix (explicit beta supported); do not conflate absent prefix with beta. Bind token lazily per invocation from GITHUB_TOKEN with CLI precedence and mark input secret following existing NuGet input pattern. Stage construction is pure; runtime filesystem operations happen inside stage steps. Use existing process execution/cancellation/output paths for setup.
+- [ ] Compose `shipit generate|github|bump|version|conventions|setup|init changelog|workflows|github|project`. Project initialization accepts a changelog path and repeatable project paths, and delegates to Stages.configureProjects. Do not expose an inherited --mode on bump.
+- [ ] Run help/schema/explain through Command.root/invoke in an empty temporary repository. Assert zero created files/process calls, token masking, stable labels, separate release/setup inputs, and invocation exit codes. Test failure/cancellation through a fake/local child process fixture.
+- [ ] Run focused tests and commit `feat: expose ShipIt-backed bump and commands`.
+
+## Task 5: Repository integration, docs, and final verification
+
+**Files:** Build/Program.fs, root README.md, extension README.md, fsproj packed README; ShipItTests.fs; docs compiler fixtures as needed.
+
+- [ ] Register the extension in Project.allProjects so build/pack/bump include it; main suite project reference already makes tests part of existing execution. Validate generated project-path provider names after solution registration.
+- [ ] Write docs showing local tool setup, project configuration, independently versioned packages versus shared versions, and composing Stages.bump in a command:
+
+```fsharp
+open Partas.Build
+module ShipIt = Partas.Build.EasyBuild.ShipIt.Stages
+let bumpCommand = command "bump" {
+ Command.pipeline { ShipIt.bump }
+}
+```
+
+- [ ] Add a local fixture end-to-end test using real ShipIt only as an opt-in tool-contract check: temporary Git repo, initialized changelog with released baseline, project XML updater, feat commit, then local generation. Assert project Version equals new changelog version and AssemblyVersion stays fixed. Default offline suite uses a fake executor and tests XML configuration/command contract separately. Document exact opt-in command and require .NET 10/tool 3.1.0 without installing them as a test side effect.
+- [ ] Run opt-in contract verification in the implementation workspace if tool prerequisites are available; if unavailable explicitly report that coverage limitation. Confirm branch/default options against installed 3.1.0 help, not just upstream main.
+- [ ] Run `rtk proxy dotnet run --project Build.fsproj -- test` as final gate, all declared framework builds, compiled documentation examples, and `rtk git diff --check`. Check package via local pack and inspect nuspec dependencies/README. Do not publish or open a release PR.
+- [ ] Request whole-branch review, fix important findings, rerun only relevant tests then final gate for semantic fixes. Commit `docs: integrate ShipIt extension and usage` and report results. Preserve feature branch/worktree for user review; no merge or PR requested in this task.
diff --git a/docs/superpowers/specs/2026-10-07-easybuild-shipit-design.md b/docs/superpowers/specs/2026-10-07-easybuild-shipit-design.md
new file mode 100644
index 0000000..8c96bce
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-07-easybuild-shipit-design.md
@@ -0,0 +1,43 @@
+# EasyBuild.ShipIt extension
+
+## Intent
+
+Provide a separately packaged Partas.Build extension wrapping the EasyBuild.ShipIt workflow as reusable operations, inputs, stages, and a command tree. The user approved the CLI-wrapper approach. ShipIt owns conventional commit parsing, changelog configuration, version calculation, release branches, and release pull requests.
+
+## Package and dependencies
+
+Create `src/extensions/Partas.Build.EasyBuild.ShipIt/Partas.Build.EasyBuild.ShipIt.fsproj`, package version 0.1.0, assembly identity 0.0.0.0, targeting net10.0, net8.0, and netstandard2.0 consistently with Baked. Reference Partas.Build directly; do not reference ShipIt's executable as a library. Namespace is `Partas.Build.EasyBuild.ShipIt`. Register the project with the solution and repository build, test, pack, and bump workflows. The extension's target frameworks do not remove the installed tool's runtime requirements: upstream currently targets .NET 10.
+
+## Public surface
+
+- `ReleaseMode`: Local, PullRequest, Push. Defaults match upstream (PullRequest).
+- `ReleaseOptions`: allowed branches (main by default), mode, optional prerelease prefix, remote hostname/owner/repository, skip-invalid and skip-merge flags, and dry-run. Absence of a prerelease prefix disables prerelease; explicitly passing beta requests the upstream default prefix.
+- `Operations`: pure `Cmd` builders for generation, explicit GitHub generation with optional secret token, version, conventions, changelog initialization with optional path, workflow initialization, and GitHub initialization with organization and dry-run settings. Arguments use Cmd's argument APIs, not shell-concatenated user values. Tokens use secret argument support.
+- `Inputs`: typed inputs for each workflow option, shared by the prefabs and command tree. GitHub token supports GITHUB_TOKEN and CLI overrides. Boolean inputs default false. Typed modes reject invalid values during parsing.
+- `Stages`: prefab `InputSpec` values/functions and corresponding consumer-input variants, following Baked conventions. Working directories are supplied through normal stage composition. Each stage propagates child failures and cancellation through the existing runner.
+- `Stages.bump` and `Stages.bumpWith`: the extension's baked bump workflow delegates calculation and updates to ShipIt in local mode. It updates changelogs and configured project versions without creating a PR or pushing. It does not call Baked.SemVer or apply a second independent increment. Consumer inputs control allowed branches, prerelease, commit filters, remote details, and dry-run. A `shipit bump` command exposes it; generation/github commands separately expose remote release modes.
+- `Commands`: a ready-made `shipit` command containing generation (a named `generate` subcommand), `github`, `version`, `conventions`, `setup`, and `init changelog|workflows|github`. A named generation command keeps initialization isolated from release-only inputs. Consumers can compose these with their own root command.
+
+## Setup
+
+Setup is an explicit stage/command, never an effect of inspecting help/schema/explain or constructing operations. It manages a repository-local dotnet tool manifest. Create a manifest only if absent, install EasyBuild.ShipIt only if missing, and restore tools. Existing registrations retain their configured version. New installs default to the verified stable 3.1.0 tool version, with a caller/input override. Do not silently update registrations or install globally. Manifest resolution and all commands use the same working-directory boundary; ambiguous or invalid manifests fail clearly. Repeating setup succeeds without overwriting existing files.
+
+Changelog and workflow scaffolding delegate to upstream `init` commands, retaining upstream refusal to overwrite files. GitHub settings are a separate explicit init operation, including dry-run and optional organization changes. The extension does not run these effects against this repository during implementation/testing.
+
+## Configuration boundary
+
+ShipIt's CHANGELOG.md front matter remains the authoritative release configuration, including monorepo include/exclude rules and file updaters. Explain how to configure an XML updater for project versions in the extension README; do not invent a duplicate configuration model or automatically rewrite consumers' changelogs. Document Git/gh authentication, full Git history in CI, allowed release branches, .NET runtime requirements, and upstream setup side effects.
+
+## Verification
+
+## .NET project configuration
+
+Provide an explicit setup operation accepting a changelog path and project path(s). Register ShipIt XML updaters targeting `/Project/PropertyGroup/Version`, with paths relative to the changelog. Create a missing changelog through upstream initialization; preserve existing changelog content, front matter, and unrelated updaters. Repeated configuration does not duplicate equivalent entries. Validate projects have an unambiguous Version element before editing configuration; reject conditional/multiple or externally defined versions with actionable guidance instead of guessing. AssemblyVersion remains under the consumer's compatibility policy and is not rewritten by this operation. ShipIt is the release-version authority; document not chaining the agnostic Baked bump after it. Independent package versions use separate package changelogs; shared versioning explicitly registers multiple project updaters on one changelog.
+
+Test configuration preservation, relative paths, idempotency, ambiguous versions, and a local fixture release showing the changelog and project Version agree. Keep remote operations out of integration tests.
+
+Add extension tests covering mode validation, defaults, every supported argument, omitted optional arguments, paths/prefixes containing spaces, token masking, consumer-supplied inputs, and command help/schema/explain discovery. Test setup against temporary manifests and a fake process boundary: absent manifest, existing registered tool, missing registration, custom version, repeated setup, and malformed manifest. Tests must not create remote PRs, publish packages, change GitHub settings, or depend on network tool installation. Run the repository's final test command and builds across declared frameworks. Validate documentation examples compile.
+
+## Sources and exclusions
+
+Contract checked against https://github.com/easybuild-org/EasyBuild.ShipIt README, src/Types/Settings.fs, src/EasyBuild.ShipIt.fsproj, and CHANGELOG.md on 2026-10-07. Current upstream changelog reports 3.1.0. No upstream algorithm port, automatic repository adoption, CI authentication provisioning, or live release execution is included.
diff --git a/notes/ShipIt-Progress.md b/notes/ShipIt-Progress.md
new file mode 100644
index 0000000..e3c0f3c
--- /dev/null
+++ b/notes/ShipIt-Progress.md
@@ -0,0 +1,17 @@
+# Implementation ledger: docs/superpowers/plans/2026-10-07-easybuild-shipit.md
+
+Native execution approved. Base efd8e4f. No callable SageFs tools; CLAUDE.md CLI workflow applies.
+Pre-flight: Task 1 Cmd builders feed Task 4 stages; signatures consistent. Task 2 executor seam feeds setup stages; runtime-only execution. Task 3 configure feeds project-init stages; changelog must exist first. Task 4 command tree feeds Task 5 docs; names consistent.
+Ruling: Use this tracked ledger instead of shell-specific skill scripts; preserves evidence on Windows without requiring bash helpers. Cost: manual task bookkeeping.
+Task 1: complete; missing-API compile failure observed, then 5/5 operation tests passed. Argument list, optional flags, init commands and secrets verified.
+Task 2: complete; missing ToolSetup failure observed, then 8/8 tests passed. Existing tool version retained; nested boundary, explicit manifest, repeat setup and malformed JSON covered.
+Task 3: missing ProjectSetup failure observed; 11/11 tests passed after XML/YAML configuration. Preserves BOM/CRLF/comments/body, validates all projects before writes, idempotent.
+Task 4: missing stages failure followed by 14/14 tests passed. Optional F# options require explicit custom parser; direct command help requires root composition, now tested through Command.root.
+Ruling: Expose tool setup via Stages.setup instead of a context-free public ToolSetup.run; the stage runner supplies cancellation, environment and output. Cost: consumers compose the stage rather than calling a standalone installer.
+Ruling: Configuration accepts block-style updaters; flow/empty updaters fail with guidance rather than lossy YAML reserialization. Cost: consumers using compact YAML must normalize the updater key explicitly.
+Final review: corrected multiline YAML insertion, ambiguous tool boundaries, and validation before changelog initialization; each regression failed before the fix and passed afterward. README namespace alias corrected; example compilation pending final docs check.
+Tool contract: ShipIt 3.1.0 local release test passed, updating project and changelog to 1.3.0 and retaining AssemblyVersion; repeated branch arguments accepted.
+Setup template probe exposed that dotnet new writes dotnet-tools.json directly into --output. Added red regression and changed --output to repository .config, with --no-update-check.
+Task 5: complete. Final Build CLI test exit 0: 558 suite tests passed, 10 skipped; 22 Debug/Release compiler probes passed. Focused tool-enabled suite: 19 passed, including real ShipIt 3.1.0 local bump. Release library builds passed all three TFMs without warnings; README examples compiled with dotnet fsi; local package 0.1.0 includes README and correct dependency groups; diff check clean.
+Existing docs restore warning NU1608 remains: FSharp.Compiler.Service 43.12.400 requests FSharp.Core 10.1.400, repository resolves 10.1.401.
+No deferred review findings. Original checkout preserved; branch retained locally without publishing, pushing or merging.
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Commands.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/Commands.fs
new file mode 100644
index 0000000..6568b50
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Commands.fs
@@ -0,0 +1,34 @@
+/// Ready-made commands, composable into any Partas.Build root command.
+[]
+module Partas.Build.EasyBuild.ShipIt.Commands
+
+open Partas.Build
+
+let private operation name descriptionText (spec: InputSpec) = command name {
+ description descriptionText
+ Command.pipeline { spec }
+}
+
+let shipit = command "shipit" {
+ description "ShipIt release workflows and explicit setup"
+ addCommands [
+ operation "generate" "Generate a release using the configured mode" Stages.generate
+ operation "github" "Generate a GitHub release" Stages.github
+ operation "bump" "Calculate and apply versions locally using ShipIt" Stages.bump
+ operation "version" "Show installed ShipIt version" Stages.version
+ operation "conventions" "Show conventional commit types" Stages.conventions
+ operation "setup" "Install missing local tool registration and restore" Stages.setup
+ command "init" {
+ description "Explicitly initialize project files or GitHub settings"
+ addCommands [
+ operation "changelog" "Create a changelog without overwriting" Stages.initChangelog
+ operation "workflows" "Create upstream workflow templates" Stages.initWorkflows
+ operation "github" "Apply or preview GitHub settings" Stages.initGithub
+ operation "project" "Register project Version updaters" (
+ Stages.configureProjectsWith
+ (InputSpec.ofInput Inputs.changelogPath |> InputSpec.map (Option.defaultValue "CHANGELOG.md"))
+ (InputSpec.ofInput Inputs.projects))
+ ]
+ }
+ ]
+}
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Inputs.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/Inputs.fs
new file mode 100644
index 0000000..02e0e09
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Inputs.fs
@@ -0,0 +1,65 @@
+/// Reusable inputs registered only by the stages that read them.
+[]
+module Partas.Build.EasyBuild.ShipIt.Inputs
+
+open System
+open Partas.Build
+
+let private optional name description =
+ Input.option name |> Input.arity Arity.ExactlyOne |> Input.description description
+ |> Input.customParser (fun result -> result.Tokens |> Seq.tryLast |> Option.map _.Value)
+ |> Input.validate (function Some value when String.IsNullOrWhiteSpace value -> Error "Value must not be blank." | _ -> Ok ())
+
+let allowedBranches =
+ Input.option "--allow-branch" |> Input.arity Arity.OneOrMore |> Input.allowMultipleArgumentsPerToken
+ |> Input.def [ "main" ] |> Input.description "Branches allowed to generate releases"
+ |> Input.validate (fun branches -> if branches.IsEmpty || List.exists String.IsNullOrWhiteSpace branches then Error "Provide nonblank branches." else Ok ())
+let mode =
+ Input.option "--mode"
+ |> Input.mapFromAmong [ "local", Local; "pull-request", PullRequest; "push", Push ]
+ |> Input.def PullRequest |> Input.description "Apply locally, create a release PR, or push"
+let preRelease = optional "--pre-release" "Prerelease prefix; supply beta for the upstream default prefix"
+let remoteHostname = optional "--remote-hostname" "Git remote hostname"
+let remoteOwner = optional "--remote-owner" "Git remote owner"
+let remoteRepository = optional "--remote-repo" "Git remote repository"
+let skipInvalidCommit = Input.option "--skip-invalid-commit" |> Input.description "Skip invalid conventional commits"
+let skipMergeCommit = Input.option "--skip-merge-commit" |> Input.description "Skip merge commits"
+let dryRun = Input.option "--dry-run" |> Input.description "Preview without applying changes"
+let organization = Input.option "--org" |> Input.description "Also configure organization GitHub settings"
+let changelogPath = optional "--changelog" "Changelog path; defaults to CHANGELOG.md"
+let projects =
+ Input.option "--project" |> Input.arity Arity.OneOrMore |> Input.allowMultipleArgumentsPerToken
+ |> Input.description "Projects whose Version should follow this changelog"
+ |> Input.validate (fun paths -> if paths.IsEmpty then Error "Supply at least one --project." else Ok ())
+let toolVersion =
+ Input.option "--tool-version" |> Input.def "3.1.0" |> Input.description "Version installed only if ShipIt is missing"
+ |> Input.validate (fun v -> if String.IsNullOrWhiteSpace v then Error "Supply a tool version." else Ok ())
+ |> InputSpec.ofInput
+let token =
+ optional "--token" "GitHub token; defaults to GITHUB_TOKEN, otherwise upstream gh authentication"
+ |> Input.sensitive
+ |> Input.defaultValueFactory (fun _ ->
+ match Environment.GetEnvironmentVariable "GITHUB_TOKEN" with
+ | value when String.IsNullOrWhiteSpace value -> None
+ | value -> Some value)
+ |> InputSpec.ofInput
+
+let private options (modeSpec: InputSpec) = input {
+ let! branches = allowedBranches
+ and! mode = modeSpec
+ and! prerelease = preRelease
+ and! hostname = remoteHostname
+ and! owner = remoteOwner
+ and! repository = remoteRepository
+ and! invalid = skipInvalidCommit
+ and! merge = skipMergeCommit
+ and! preview = dryRun
+ return {
+ AllowedBranches = branches; Mode = mode; PreRelease = prerelease
+ RemoteHostname = hostname; RemoteOwner = owner; RemoteRepository = repository
+ SkipInvalidCommit = invalid; SkipMergeCommit = merge; DryRun = preview
+ }
+}
+let release = options (InputSpec.ofInput mode)
+/// Local-only options; no --mode is registered.
+let bump = options (InputSpec.ret Local)
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Operations.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/Operations.fs
new file mode 100644
index 0000000..c4d7a16
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Operations.fs
@@ -0,0 +1,53 @@
+/// Pure commands for the EasyBuild.ShipIt local tool. Constructing commands has no side effects.
+[]
+module Partas.Build.EasyBuild.ShipIt.Operations
+
+open System
+open Partas.Build
+
+let private nonblank name value =
+ if String.IsNullOrWhiteSpace value then invalidArg name "Value must not be blank."
+ value
+
+let private optional name value command =
+ command |> Cmd.argWhenSome value (fun value -> [ name; nonblank name value ])
+
+let private release prefix (options: ReleaseOptions) =
+ if options.AllowedBranches.IsEmpty then invalidArg "AllowedBranches" "At least one allowed branch is required."
+ let mode = match options.Mode with Local -> "local" | PullRequest -> "pull-request" | Push -> "push"
+ Cmd.ofList "dotnet" ("shipit" :: prefix)
+ |> Cmd.args (options.AllowedBranches |> List.collect (fun branch -> [ "--allow-branch"; nonblank "AllowedBranches" branch ]))
+ |> Cmd.args [ "--mode"; mode ]
+ |> optional "--pre-release" options.PreRelease
+ |> optional "--remote-hostname" options.RemoteHostname
+ |> optional "--remote-owner" options.RemoteOwner
+ |> optional "--remote-repo" options.RemoteRepository
+ |> Cmd.argIf options.SkipInvalidCommit [ "--skip-invalid-commit" ]
+ |> Cmd.argIf options.SkipMergeCommit [ "--skip-merge-commit" ]
+ |> Cmd.argIf options.DryRun [ "--dry-run" ]
+
+/// Generate changelogs and apply configured file updaters.
+let generate options = release [] options
+
+/// Explicit GitHub provider, with the supplied token masked in command labels.
+let github options token =
+ let command = release [ "github" ] options
+ match token with
+ | Some token -> command |> Cmd.secretOption "--token" (nonblank "token" token)
+ | None -> command
+
+/// Report the installed tool version.
+let version () = Cmd.ofList "dotnet" [ "shipit"; "version" ]
+/// Show supported conventional commit types.
+let conventions () = Cmd.ofList "dotnet" [ "shipit"; "conventions" ]
+/// Create a changelog. Upstream refuses to overwrite an existing file.
+let initChangelog path =
+ Cmd.ofList "dotnet" [ "shipit"; "init"; "changelog" ]
+ |> Cmd.argWhenSome path (fun path -> [ nonblank "path" path ])
+/// Scaffold upstream GitHub workflow templates, without overwriting existing files.
+let initWorkflows () = Cmd.ofList "dotnet" [ "shipit"; "init"; "workflows" ]
+/// Apply or preview GitHub settings, optionally including organization settings.
+let initGithub organization dryRun =
+ Cmd.ofList "dotnet" [ "shipit"; "init"; "github" ]
+ |> Cmd.argIf organization [ "--org" ]
+ |> Cmd.argIf dryRun [ "--dry-run" ]
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Partas.Build.EasyBuild.ShipIt.fsproj b/src/extensions/Partas.Build.EasyBuild.ShipIt/Partas.Build.EasyBuild.ShipIt.fsproj
new file mode 100644
index 0000000..5aa9ef1
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Partas.Build.EasyBuild.ShipIt.fsproj
@@ -0,0 +1,28 @@
+
+
+ net10.0;net8.0;netstandard2.0
+ Partas.Build.EasyBuild.ShipIt
+ 0.1.0
+ 0.0.0.0
+ Composable EasyBuild.ShipIt release workflows for Partas.Build
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>Partas.Build.Tests
+
+
+
+
+
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/ProjectSetup.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/ProjectSetup.fs
new file mode 100644
index 0000000..8e9be00
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/ProjectSetup.fs
@@ -0,0 +1,113 @@
+/// Explicitly register project-version updaters in existing ShipIt changelogs.
+[]
+module Partas.Build.EasyBuild.ShipIt.ProjectSetup
+
+open System
+open System.IO
+open System.Text
+open System.Text.RegularExpressions
+open System.Xml.Linq
+open YamlDotNet.RepresentationModel
+
+let private relativePath (directory: string) (path: string) =
+ let root = Uri(Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar)
+ Uri.UnescapeDataString(root.MakeRelativeUri(Uri(Path.GetFullPath path)).ToString())
+
+let private property name (mapping: YamlMappingNode) =
+ let mutable node = Unchecked.defaultof
+ if mapping.Children.TryGetValue(YamlScalarNode(name), &node) then Some node else None
+
+let private scalar name (node: YamlNode) =
+ match node with
+ | :? YamlScalarNode as value when not (String.IsNullOrWhiteSpace value.Value) -> value.Value
+ | _ -> invalidOp $"Expected a scalar {name} in ShipIt configuration."
+
+let private validateProject (path: string) =
+ let document = XDocument.Load path
+ let versions = document.Descendants(XName.Get "Version") |> Seq.toList
+ match versions with
+ | [ version ] when version.Parent.Name = XName.Get "PropertyGroup"
+ && version.Parent.Parent.Name = XName.Get "Project"
+ && isNull (version.Attribute(XName.Get "Condition"))
+ && isNull (version.Parent.Attribute(XName.Get "Condition"))
+ && not (String.IsNullOrWhiteSpace version.Value)
+ && not (version.Value.Contains "$" || version.Value.Contains "@(") -> ()
+ | _ -> invalidArg "projects" $"{path}: requires exactly one unconditional literal /Project/PropertyGroup/Version. Configure externally defined or conditional versions manually."
+
+let internal validateProjects (projects: string list) =
+ if projects.IsEmpty then invalidArg "projects" "At least one project is required."
+ projects |> List.iter validateProject
+
+/// Add missing XML updaters atomically, retaining the existing changelog bytes outside inserted YAML.
+let configure (changelog: string) (projects: string list) =
+ let changelog = Path.GetFullPath changelog
+ let directory = Path.GetDirectoryName changelog
+ let projects = projects |> List.map Path.GetFullPath |> List.distinct
+ validateProjects projects
+ let bytes = File.ReadAllBytes changelog
+ let hasBom = bytes.Length >= 3 && bytes[0] = 239uy && bytes[1] = 187uy && bytes[2] = 191uy
+ let offset = if hasBom then 3 else 0
+ let text = UTF8Encoding(false, true).GetString(bytes, offset, bytes.Length - offset)
+ let front = Regex.Match(text, "\\A---\\r?\\n(?[\\s\\S]*?)^---[ \\t]*(?:\\r?\\n|$)", RegexOptions.Multiline)
+ if not front.Success then invalidOp "Changelog must contain delimited YAML front matter; initialize it with ShipIt first."
+ let yaml = front.Groups["yaml"]
+ let stream = YamlStream()
+ use reader = new StringReader(yaml.Value)
+ stream.Load reader
+ if stream.Documents.Count <> 1 then invalidOp "ShipIt front matter must contain one YAML document."
+ let mapping =
+ match stream.Documents[0].RootNode with
+ | :? YamlMappingNode as value -> value
+ | _ -> invalidOp "ShipIt front matter must be a mapping."
+ let selector = "/Project/PropertyGroup/Version"
+ let existing, insertion, indent, header =
+ match property "updaters" mapping with
+ | None -> Set.empty, yaml.Length, 2, "updaters:"
+ | Some (:? YamlSequenceNode as sequence) ->
+ if sequence.Style = YamlDotNet.Core.Events.SequenceStyle.Flow || sequence.Children.Count = 0 then
+ invalidOp "Use a nonempty block-style updaters sequence, or remove the empty updaters key before configuring projects."
+ let registered =
+ sequence.Children |> Seq.choose (fun item ->
+ match item with
+ | :? YamlMappingNode as updater ->
+ match property "xml" updater with
+ | None -> None
+ | Some (:? YamlMappingNode as xml) ->
+ let file = property "file" xml |> Option.map (scalar "xml.file") |> Option.defaultWith (fun () -> invalidOp "XML updater lacks file.")
+ let selected = property "selector" xml |> Option.map (scalar "xml.selector") |> Option.defaultWith (fun () -> invalidOp "XML updater lacks selector.")
+ Some (Path.GetFullPath(Path.Combine(directory, file)), selected)
+ | _ -> invalidOp "XML updater must be a mapping."
+ | _ -> invalidOp "Each updater must be a mapping.") |> Set.ofSeq
+ // Representation mapping End marks do not span their contents. The next root
+ // key reliably bounds the entire sequence, including multiline updater mappings.
+ let insertion =
+ mapping.Children.Keys
+ |> Seq.filter (fun key -> key.Start.Index > sequence.Start.Index)
+ |> Seq.map (fun key -> int key.Start.Index - (int key.Start.Column - 1))
+ |> Seq.sort
+ |> Seq.tryHead
+ |> Option.defaultValue yaml.Length
+ let precedingComments = Regex.Match(yaml.Value.Substring(0, insertion), "(?:^#[^\\r\\n]*\\r?\\n)+\\z", RegexOptions.Multiline)
+ let insertion = insertion - precedingComments.Length
+ registered, insertion, int sequence.Start.Column - 1, ""
+ | _ -> invalidOp "ShipIt updaters must be a block sequence."
+ let missing = projects |> List.filter (fun path -> not (existing.Contains(path, selector)))
+ if not missing.IsEmpty then
+ let newline = if text.Contains "\r\n" then "\r\n" else "\n"
+ let padding = String(' ', indent)
+ let entries = missing |> List.map (fun path ->
+ let file = relativePath directory path |> fun value -> "'" + value.Replace("'", "''") + "'"
+ $"{padding}- xml:{newline}{padding} file: {file}{newline}{padding} selector: {selector}{newline}") |> String.concat ""
+ let prefix = if header = "" then "" else header + newline
+ let result = text.Insert(yaml.Index + insertion, prefix + entries)
+ // Parse the result before committing any change.
+ let check = YamlStream()
+ let checkedFront = Regex.Match(result, "\\A---\\r?\\n(?[\\s\\S]*?)^---[ \\t]*(?:\\r?\\n|$)", RegexOptions.Multiline)
+ use checkReader = new StringReader(checkedFront.Groups["yaml"].Value)
+ check.Load checkReader
+ let temporary = changelog + "." + Guid.NewGuid().ToString("N") + ".tmp"
+ try
+ File.WriteAllText(temporary, result, UTF8Encoding(hasBom))
+ File.Replace(temporary, changelog, null)
+ finally
+ if File.Exists temporary then File.Delete temporary
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/README.md b/src/extensions/Partas.Build.EasyBuild.ShipIt/README.md
new file mode 100644
index 0000000..3328055
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/README.md
@@ -0,0 +1,51 @@
+# Partas.Build.EasyBuild.ShipIt
+
+Composable operations, inputs, stages and commands for [EasyBuild.ShipIt](https://github.com/easybuild-org/EasyBuild.ShipIt).
+ShipIt calculates releases from conventional commits and updates changelogs and configured files. This extension delegates those algorithms to the local tool.
+
+```fsharp
+open Partas.Build
+open Partas.Build.EasyBuild.ShipIt
+
+let cli = Command.root {
+ addCommand Commands.shipit
+ addCommand (command "bump" {
+ Command.pipeline { Stages.bump }
+ })
+}
+cli |> Command.invoke [ "shipit"; "bump"; "--allow-branch"; "master"; "--dry-run" ]
+```
+
+## Explicit setup
+
+Run your build CLI's `shipit setup` to create a repository-local tool manifest if missing, install EasyBuild.ShipIt 3.1.0 if unregistered, and restore the tools. `--tool-version` changes the version for a new registration; existing registrations retain their version. Setup resolves the closest Git repository and uses its `.config/dotnet-tools.json`, including when called from a subdirectory. It never installs globally. The extension supports net10.0, net8.0 and netstandard2.0; ShipIt 3.1.0 itself needs .NET 10.
+
+Run `shipit init project --changelog src/MyLibrary/CHANGELOG.md --project src/MyLibrary/MyLibrary.fsproj` to initialize a missing changelog and register the project version updater. Initialization preserves existing YAML keys, comments, changelog body, newline style and UTF-8 BOM. Repeating it does not duplicate XML updaters. Project paths are relative to the stage working directory. The updater path is relative to the changelog:
+
+```yaml
+updaters:
+ - xml:
+ file: MyLibrary.fsproj
+ selector: /Project/PropertyGroup/Version
+```
+
+Each project must have exactly one unconditional literal `` under ``. Configure conditional, computed or externally defined versions yourself. Use a block-style `updaters` sequence; remove an empty key before setup. Configuration does not alter ``: manage assembly compatibility separately. Register multiple projects on one changelog for shared versions; use separate changelogs for independent package versions. When adopting an existing package, seed the changelog with the last released version and set `last_commit_released` appropriately before running a bump.
+
+## Bumping and releasing
+
+`Stages.bump`, `Stages.bumpWith` and `shipit bump` always use **local mode**, updating the changelog and its configured project files without pushing or opening a PR. They do not use `Baked.SemVer`. Do not chain a separate numeric bump afterward; ShipIt owns the release version. `bumpWith` also forces local mode for consumer-supplied options.
+
+`shipit generate` supports upstream modes `local`, `pull-request` (default) and `push`. `shipit github` explicitly selects GitHub and accepts `--token`, falling back to `GITHUB_TOKEN` or upstream gh authentication. Token values are marked sensitive in schema/explain and command labels. Upstream output itself remains upstream's responsibility.
+
+Release inputs: `--allow-branch` (default `main`, multiple values), `--pre-release PREFIX` (omit for stable; pass `beta` for the standard prefix), `--remote-hostname`, `--remote-owner`, `--remote-repo`, `--skip-invalid-commit`, `--skip-merge-commit`, `--dry-run`. Use `--allow-branch master` for this repository's branch naming. A prerelease configured in changelog front matter still applies when the CLI prefix is omitted.
+
+`shipit init changelog`, `shipit init workflows`, and `shipit init github` delegate upstream setup. Changelog/workflow creation refuses to overwrite existing files. GitHub initialization can change merge and workflow-permission settings; it requires authenticated `gh`. Use `--dry-run` to preview and `--org` only when organization settings should also change. No setup operation runs automatically during help/schema/explain or ordinary bumping.
+
+For custom workflows, use `Operations` to build pure `Cmd` values, `Inputs` to reuse options, or the `Stages.*With` functions to bind consumer `InputSpec` values. Stages inherit normal working directory, output, environment and cancellation settings. CI requires full Git history and appropriate GitHub permissions/authentication; ShipIt is not a package publisher, so compose pack/push stages separately after release changes land.
+
+## Verification
+
+Offline coverage runs with `dotnet run --project tests/Partas.Build.Tests -- --filter-test-list shipit --sequenced`.
+The opt-in tool contract probe uses a temporary Git repository and ShipIt 3.1.0 to verify a local release updates project and changelog consistently; it never creates remote releases or changes repository settings.
+
+In PowerShell, set `$env:SHIPIT_TEST_TOOL` to the absolute path of an already installed `shipit.exe`, then run `dotnet run --project tests/Partas.Build.Tests -- --filter-test-list "shipit contract" --sequenced`. On Unix, set the same environment variable to the installed `shipit` executable path.
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Stages.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/Stages.fs
new file mode 100644
index 0000000..5f9a05c
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Stages.fs
@@ -0,0 +1,90 @@
+/// Composable ShipIt stages. Setup effects occur only at execution time.
+[]
+module Partas.Build.EasyBuild.ShipIt.Stages
+
+open System.IO
+open System.Threading
+open Partas.Build
+open Partas.Build.Internal
+
+let private commandStage name (command: Cmd) =
+ let stage = stage name { run command }
+ // Boundary inspection is deferred until execution, so discovery is pure.
+ let steps = stage.Steps |> List.map (function
+ | Step.StepFn(label, execute) -> Step.StepFn(label, fun ctx index -> async {
+ ToolSetup.validateBoundary (StageContext.getWorkingDir ctx |> ValueOption.defaultWith Directory.GetCurrentDirectory) |> ignore
+ return! execute ctx index
+ })
+ | other -> other)
+ { stage with Steps = steps }
+
+let private operation name (build: 'T -> Cmd) (inputs: InputSpec<'T>) =
+ inputs |> InputSpec.map (build >> commandStage name)
+
+let generateWith options = operation "shipit generate" Operations.generate options
+let generate = generateWith Inputs.release
+let githubWith (options: InputSpec) (token: InputSpec) = input {
+ let! options = options
+ and! token = token
+ return commandStage "shipit github" (Operations.github options token)
+}
+let github = githubWith Inputs.release Inputs.token
+/// Calculate and apply versions locally, even if the consumer supplies another mode.
+let bumpWith options =
+ options |> InputSpec.map (fun options -> { options with Mode = Local })
+ |> operation "shipit bump" Operations.generate
+let bump = bumpWith Inputs.bump
+let version = operation "shipit version" Operations.version (InputSpec.ret ())
+let conventions = operation "shipit conventions" Operations.conventions (InputSpec.ret ())
+let initChangelogWith path = operation "shipit init changelog" Operations.initChangelog path
+let initChangelog = initChangelogWith (InputSpec.ofInput Inputs.changelogPath)
+let initWorkflows = operation "shipit init workflows" Operations.initWorkflows (InputSpec.ret ())
+let initGithubWith (organization: InputSpec) (dryRun: InputSpec) = input {
+ let! organization = organization
+ and! dryRun = dryRun
+ return commandStage "shipit init github" (Operations.initGithub organization dryRun)
+}
+let initGithub = initGithubWith (InputSpec.ofInput Inputs.organization) (InputSpec.ofInput Inputs.dryRun)
+
+let private directory ctx = StageContext.getWorkingDir ctx |> ValueOption.defaultWith Directory.GetCurrentDirectory
+
+let setupWith (version: InputSpec) = input {
+ let! version = version
+ return stage "shipit setup" {
+ run (fun (ctx: StageContext) -> async {
+ let root = ToolSetup.validateBoundary (directory ctx)
+ let executionContext = StageContext.setWorkingDir (ValueSome root) ctx
+ let execute command = async {
+ match! CmdRunner.run executionContext 0 CancellationToken.None command with
+ | Ok () -> return 0
+ | Error error -> return failwith error
+ }
+ do! ToolSetup.runWith execute root version
+ return Ok ()
+ })
+ }
+}
+let setup = setupWith Inputs.toolVersion
+
+/// Explicit project adoption. Creates a changelog only when absent, then registers XML updaters.
+let configureProjectsWith (changelog: InputSpec) (projects: InputSpec) = input {
+ let! changelog = changelog
+ and! projects = projects
+ return stage "shipit init project" {
+ run (fun (ctx: StageContext) -> async {
+ let root = directory ctx
+ ToolSetup.validateBoundary root |> ignore
+ let path = Path.GetFullPath(Path.Combine(root, changelog))
+ let projects = projects |> List.map (fun project -> Path.GetFullPath(Path.Combine(root, project)))
+ ProjectSetup.validateProjects projects
+ if not (File.Exists path) then
+ let! result = CmdRunner.run ctx 0 CancellationToken.None (Operations.initChangelog (Some path))
+ match result with
+ | Error error -> failwith error
+ | Ok () -> ()
+ ProjectSetup.configure path projects
+ return Ok ()
+ })
+ }
+}
+let configureProjects changelog projects = configureProjectsWith (InputSpec.ret changelog) (InputSpec.ret projects)
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/ToolSetup.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/ToolSetup.fs
new file mode 100644
index 0000000..176902f
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/ToolSetup.fs
@@ -0,0 +1,59 @@
+/// Repository-local tool registration. These operations execute only when explicitly invoked.
+module internal Partas.Build.EasyBuild.ShipIt.ToolSetup
+
+open System
+open System.IO
+open System.Text.Json
+open Partas.Build
+
+let rec repositoryRoot (directory: string) =
+ let directory = Path.GetFullPath directory
+ if not (Directory.Exists directory) then invalidArg "directory" "Working directory does not exist."
+ let marker = Path.Combine(directory, ".git")
+ if Directory.Exists marker || File.Exists marker then directory
+ else
+ let parent = Directory.GetParent directory
+ if isNull parent then invalidArg "directory" "ShipIt setup requires a Git repository."
+ repositoryRoot parent.FullName
+
+/// Reject manifests that would make bare dotnet shipit select a different tool boundary.
+let validateBoundary (directory: string) =
+ let root = repositoryRoot directory
+ let canonical = Path.Combine(root, ".config", "dotnet-tools.json")
+ let rec inspect current =
+ let alternate = Path.Combine(current, "dotnet-tools.json")
+ let nested = Path.Combine(current, ".config", "dotnet-tools.json")
+ if File.Exists alternate || (current <> root && File.Exists nested) then
+ invalidOp $"Ambiguous tool manifest at {current}; ShipIt uses {canonical}. Remove competing manifests or invoke from the repository root."
+ if current <> root then inspect (Directory.GetParent current).FullName
+ inspect (Path.GetFullPath directory)
+ root
+
+let private registered manifest =
+ use document = JsonDocument.Parse(File.ReadAllText manifest)
+ let root = document.RootElement
+ let tools = root.GetProperty "tools"
+ if tools.ValueKind <> JsonValueKind.Object then invalidOp $"Invalid tool manifest: {manifest}"
+ let mutable registration = Unchecked.defaultof
+ let present = tools.TryGetProperty("easybuild.shipit", ®istration)
+ if present then
+ if String.IsNullOrWhiteSpace(registration.GetProperty("version").GetString()) then invalidOp "ShipIt registration has no version."
+ let commands = registration.GetProperty "commands"
+ if commands.ValueKind <> JsonValueKind.Array || not (commands.EnumerateArray() |> Seq.exists (fun item -> item.GetString() = "shipit")) then
+ invalidOp "ShipIt tool registration must expose the shipit command."
+ present
+
+let runWith (execute: Cmd -> Async) (directory: string) (version: string) = async {
+ if String.IsNullOrWhiteSpace version then invalidArg "version" "Tool version must not be blank."
+ let root = validateBoundary directory
+ let manifest = Path.Combine(root, ".config", "dotnet-tools.json")
+ let run command = async {
+ let! code = execute command
+ if code <> 0 then invalidOp $"ShipIt setup command failed with exit code {code}: {Cmd.toLogString command}"
+ }
+ if not (File.Exists manifest) then
+ do! run (Cmd.ofList "dotnet" [ "new"; "tool-manifest"; "--output"; Path.GetDirectoryName manifest; "--no-update-check" ])
+ if not (registered manifest) then
+ do! run (Cmd.ofList "dotnet" [ "tool"; "install"; "EasyBuild.ShipIt"; "--version"; version; "--tool-manifest"; manifest ])
+ do! run (Cmd.ofList "dotnet" [ "tool"; "restore"; "--tool-manifest"; manifest ])
+}
diff --git a/src/extensions/Partas.Build.EasyBuild.ShipIt/Types.fs b/src/extensions/Partas.Build.EasyBuild.ShipIt/Types.fs
new file mode 100644
index 0000000..aeec898
--- /dev/null
+++ b/src/extensions/Partas.Build.EasyBuild.ShipIt/Types.fs
@@ -0,0 +1,27 @@
+namespace Partas.Build.EasyBuild.ShipIt
+
+/// Where ShipIt applies a calculated release.
+[]
+type ReleaseMode = Local | PullRequest | Push
+
+/// Upstream release options; changelog front matter owns file updaters.
+type ReleaseOptions = {
+ AllowedBranches: string list
+ Mode: ReleaseMode
+ PreRelease: string option
+ RemoteHostname: string option
+ RemoteOwner: string option
+ RemoteRepository: string option
+ SkipInvalidCommit: bool
+ SkipMergeCommit: bool
+ DryRun: bool
+}
+
+[]
+module ReleaseOptions =
+ /// Upstream defaults, with no prerelease requested.
+ let defaults = {
+ AllowedBranches = [ "main" ]; Mode = PullRequest; PreRelease = None
+ RemoteHostname = None; RemoteOwner = None; RemoteRepository = None
+ SkipInvalidCommit = false; SkipMergeCommit = false; DryRun = false
+ }
diff --git a/tests/Partas.Build.Tests/Partas.Build.Tests.fsproj b/tests/Partas.Build.Tests/Partas.Build.Tests.fsproj
index 630ec31..75969b2 100644
--- a/tests/Partas.Build.Tests/Partas.Build.Tests.fsproj
+++ b/tests/Partas.Build.Tests/Partas.Build.Tests.fsproj
@@ -26,6 +26,8 @@
+
+
@@ -42,6 +44,7 @@
+
diff --git a/tests/Partas.Build.Tests/ShipItContractTests.fs b/tests/Partas.Build.Tests/ShipItContractTests.fs
new file mode 100644
index 0000000..fc1020f
--- /dev/null
+++ b/tests/Partas.Build.Tests/ShipItContractTests.fs
@@ -0,0 +1,64 @@
+/// Opt-in real-tool contract checks. Requires SHIPIT_TEST_TOOL pointing to a preinstalled ShipIt 3.1.0 executable.
+module Partas.Build.Tests.ShipItContractTests
+
+open System
+open System.IO
+open System.Diagnostics
+open Expecto
+open Partas.Build
+open Partas.Build.EasyBuild.ShipIt
+
+let private execute directory executable arguments =
+ let start = ProcessStartInfo(executable)
+ start.WorkingDirectory <- directory
+ start.UseShellExecute <- false
+ start.RedirectStandardOutput <- true
+ start.RedirectStandardError <- true
+ for argument in arguments do start.ArgumentList.Add argument
+ use child = Process.Start start
+ let output = child.StandardOutput.ReadToEndAsync()
+ let error = child.StandardError.ReadToEndAsync()
+ if not (child.WaitForExit 30000) then
+ child.Kill true
+ failwith "Contract process timed out."
+ let text = output.Result + error.Result
+ if child.ExitCode <> 0 then failwith $"{executable} failed ({child.ExitCode}): {text}"
+ text
+
+[]
+let tests = testList "shipit contract" [
+ test "real tool local bump updates project and changelog together" {
+ let tool = Environment.GetEnvironmentVariable "SHIPIT_TEST_TOOL"
+ if String.IsNullOrWhiteSpace tool then skiptest "Set SHIPIT_TEST_TOOL to a preinstalled ShipIt 3.1.0 executable."
+ let directory = Directory.CreateTempSubdirectory "shipit-contract"
+ try
+ let root = directory.FullName
+ let git args = execute root "git" args
+ git [ "init"; "-b"; "main" ] |> ignore
+ git [ "config"; "user.email"; "shipit-test@example.test" ] |> ignore
+ git [ "config"; "user.name"; "ShipIt contract" ] |> ignore
+ git [ "remote"; "add"; "origin"; "https://github.com/example/fixture.git" ] |> ignore
+ let project = Path.Combine(root, "Library.fsproj")
+ let changelog = Path.Combine(root, "CHANGELOG.md")
+ File.WriteAllText(project, "1.2.30.0.0.0")
+ File.WriteAllText(changelog, "---\nname: fixture\n---\n# Changelog\n\n## 1.2.3 - 2026-01-01\n\nBaseline.\n")
+ ProjectSetup.configure changelog [ project ]
+ git [ "add"; "." ] |> ignore
+ git [ "commit"; "-m"; "chore: baseline" ] |> ignore
+ let baseline = (git [ "rev-parse"; "HEAD" ]).Trim()
+ File.WriteAllText(changelog, (File.ReadAllText changelog).Replace("name: fixture", $"name: fixture\nlast_commit_released: {baseline}"))
+ File.WriteAllText(Path.Combine(root, "feature.txt"), "feature")
+ git [ "add"; "." ] |> ignore
+ git [ "commit"; "-m"; "feat: add workflow" ] |> ignore
+ let command = Operations.generate { ReleaseOptions.defaults with Mode = Local; AllowedBranches = [ "main"; "release" ] }
+ execute root tool (command.Arguments |> List.tail) |> ignore
+ Expect.stringContains (File.ReadAllText project) "1.3.0" "calculated minor version"
+ Expect.stringContains (File.ReadAllText changelog) "## 1.3.0" "same changelog version"
+ Expect.stringContains (File.ReadAllText project) "0.0.0.0" "assembly compatibility retained"
+ Expect.equal ((git [ "log"; "-1"; "--format=%s" ]).Trim()) "feat: add workflow" "local mode never commits"
+ finally
+ for file in Directory.GetFiles(directory.FullName, "*", SearchOption.AllDirectories) do
+ File.SetAttributes(file, FileAttributes.Normal)
+ directory.Delete true
+ }
+]
diff --git a/tests/Partas.Build.Tests/ShipItTests.fs b/tests/Partas.Build.Tests/ShipItTests.fs
new file mode 100644
index 0000000..a01a203
--- /dev/null
+++ b/tests/Partas.Build.Tests/ShipItTests.fs
@@ -0,0 +1,243 @@
+module Partas.Build.Tests.ShipItTests
+
+open Expecto
+open System.IO
+open Partas.Build
+open Partas.Build.EasyBuild.ShipIt
+open Partas.Build.Tests.Helpers
+open Partas.Build.Internal
+
+let private labels (stage: StageContext) = stage.Steps |> List.choose (function
+ | Step.StepFn(ValueSome label, _) | Step.Operation(ValueSome label, _) -> Some label
+ | _ -> None)
+
+[]
+let tests = testList "shipit" [
+ test "setup and bump stages reject nested manifests at execution" {
+ let directory = Directory.CreateTempSubdirectory "shipit-boundary-stage"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".config")) |> ignore
+ File.WriteAllText(Path.Combine(directory.FullName, ".config", "dotnet-tools.json"), "{}")
+ let nested = Directory.CreateDirectory(Path.Combine(directory.FullName, "nested"))
+ Directory.CreateDirectory(Path.Combine(nested.FullName, ".config")) |> ignore
+ File.WriteAllText(Path.Combine(nested.FullName, ".config", "dotnet-tools.json"), "{}")
+ for spec in [ Stages.setup; Stages.bump ] do
+ let stage = spec.Read (parse spec.Inputs "") |> StageContext.setWorkingDir (ValueSome nested.FullName)
+ let result = quietly (fun () -> runStage stage)
+ let messages = match result with Error errors -> errors |> List.map _.Message |> String.concat "\n" | Ok () -> ""
+ Expect.stringContains messages "Ambiguous tool manifest" "same boundary on both stages"
+ finally directory.Delete true
+ }
+ test "project init rejects invalid versions before invoking changelog creation" {
+ let directory = Directory.CreateTempSubdirectory "shipit-project-init"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ let project = Path.Combine(directory.FullName, "Invalid.fsproj")
+ File.WriteAllText(project, "")
+ let spec = Stages.configureProjects "CHANGELOG.md" [ project ]
+ let stage = spec.Read (parse spec.Inputs "") |> StageContext.setWorkingDir (ValueSome directory.FullName)
+ let result, output = capturingOut (fun () -> runStage stage)
+ let errors = match result with Error errors -> errors |> List.map _.Message |> String.concat "\n" | Ok () -> ""
+ Expect.stringContains errors "unconditional literal" "validate before invoking dotnet"
+ Expect.isFalse (File.Exists(Path.Combine(directory.FullName, "CHANGELOG.md"))) "no changelog created"
+ finally directory.Delete true
+ }
+ test "project setup can add a second multiline XML updater" {
+ let directory = Directory.CreateTempSubdirectory "shipit-project"
+ try
+ let changelog = Path.Combine(directory.FullName, "CHANGELOG.md")
+ let a = Path.Combine(directory.FullName, "A.fsproj")
+ let b = Path.Combine(directory.FullName, "B.fsproj")
+ for project in [a; b] do File.WriteAllText(project, "1.0.0")
+ File.WriteAllText(changelog, "---\nname: shared\n---\nbody\n")
+ ProjectSetup.configure changelog [a]
+ ProjectSetup.configure changelog [b]
+ let once = File.ReadAllText changelog
+ Expect.stringContains once "file: 'A.fsproj'" "first survives"
+ Expect.stringContains once "file: 'B.fsproj'" "second added"
+ ProjectSetup.configure changelog [a; b]
+ Expect.equal (File.ReadAllText changelog) once "both idempotent"
+ finally directory.Delete true
+ }
+ test "setup rejects a nested tool manifest before any process" {
+ let directory = Directory.CreateTempSubdirectory "shipit-boundary"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ let nested = Directory.CreateDirectory(Path.Combine(directory.FullName, "nested"))
+ let config = Directory.CreateDirectory(Path.Combine(nested.FullName, ".config"))
+ File.WriteAllText(Path.Combine(config.FullName, "dotnet-tools.json"), """{"version":1,"isRoot":true,"tools":{}}""")
+ let mutable called = false
+ let execute _ = async { called <- true; return 0 }
+ Expect.throws (fun () -> ToolSetup.runWith execute nested.FullName "3.1.0" |> Async.RunSynchronously) "nested manifest is ambiguous"
+ Expect.isFalse called "no process"
+ finally directory.Delete true
+ }
+ test "bump is local even with consumer Push mode" {
+ let spec = Stages.bumpWith (InputSpec.ret { ReleaseOptions.defaults with Mode = Push })
+ let resolved = spec.Read (parse spec.Inputs "")
+ Expect.isTrue (labels resolved |> List.exists (fun label -> label.Contains "--mode local")) "local only"
+ Expect.isFalse (spec.Inputs |> List.exists (fun i -> match i.Source with ParsedOption o -> o.Name = "--mode" | _ -> false)) "no mode input"
+ }
+ test "release input parsing and validation" {
+ let spec = Inputs.release
+ let defaults = spec.Read (parse spec.Inputs "")
+ Expect.equal defaults ReleaseOptions.defaults "defaults"
+ let resolved = spec.Read (parse spec.Inputs "--mode local --pre-release beta --skip-merge-commit --dry-run")
+ Expect.equal resolved.Mode Local "mode"
+ Expect.equal resolved.PreRelease (Some "beta") "prefix"
+ Expect.isTrue resolved.SkipMergeCommit "filter"
+ Expect.isTrue resolved.DryRun "preview"
+ let command = command "release" { Command.pipeline { Stages.generate } }
+ Expect.isGreaterThan (command.Parse("--mode unknown").Errors.Count) 0 "invalid mode"
+ }
+ test "ready command tree exposes setup init and bump without executing" {
+ let tree = Command.root { addCommand Commands.shipit }
+ for args in [ "bump --help"; "generate --schema"; "setup --explain"; "init project --help"; "github --token SECRET --explain" ] do
+ let tokens = Cmd.ofString ("shipit " + args)
+ Expect.equal (quietly (fun () -> (tree |> Command.invoke (tokens.Executable :: tokens.Arguments)).ExitCode)) 0 "discovery succeeds"
+ }
+ test "project setup preserves comments body and identity and is idempotent" {
+ let directory = Directory.CreateTempSubdirectory "shipit-project"
+ try
+ let project = Path.Combine(directory.FullName, "My Library.fsproj")
+ let changelog = Path.Combine(directory.FullName, "CHANGELOG.md")
+ let xml = "1.2.30.0.0.0"
+ File.WriteAllText(project, xml)
+ File.WriteAllText(changelog, "---\r\n# retained comment\r\nlast_commit_released: abc\r\ncustom: {thing: yes}\r\n---\r\n# Changelog\r\nbody\r\n", System.Text.UTF8Encoding(true))
+ ProjectSetup.configure changelog [ project; project ]
+ let once = File.ReadAllBytes changelog
+ ProjectSetup.configure changelog [ project ]
+ Expect.sequenceEqual (File.ReadAllBytes changelog) once "idempotent bytes"
+ let text = File.ReadAllText changelog
+ Expect.stringContains text "# retained comment\r\n" "comment"
+ Expect.stringContains text "custom: {thing: yes}" "unknown mapping"
+ Expect.stringContains text "selector: /Project/PropertyGroup/Version" "selector"
+ Expect.stringContains text "# Changelog\r\nbody\r\n" "body"
+ Expect.equal (File.ReadAllText project) xml "project unchanged until bump"
+ Expect.sequenceEqual once[..2] [| 239uy; 187uy; 191uy |] "BOM"
+ finally directory.Delete true
+ }
+ test "project setup appends to existing updaters and validates all projects before writing" {
+ let directory = Directory.CreateTempSubdirectory "shipit-project"
+ try
+ let project = Path.Combine(directory.FullName, "A.fsproj")
+ let invalid = Path.Combine(directory.FullName, "Invalid.fsproj")
+ let changelog = Path.Combine(directory.FullName, "CHANGELOG.md")
+ let original = "---\nupdaters:\n - command: echo existing\n# retained\nname: A\n---\nbody\n"
+ File.WriteAllText(project, "1.2.3")
+ File.WriteAllText(invalid, "1")
+ File.WriteAllText(changelog, original)
+ Expect.throws (fun () -> ProjectSetup.configure changelog [ project; invalid ]) "conditional version"
+ Expect.equal (File.ReadAllText changelog) original "no partial update"
+ ProjectSetup.configure changelog [ project ]
+ let once = File.ReadAllText changelog
+ Expect.stringContains once " - command: echo existing\n" "existing updater"
+ Expect.stringContains once "# retained\nname: A\n" "neighboring key"
+ ProjectSetup.configure changelog [ project ]
+ Expect.equal (File.ReadAllText changelog) once "no duplicates"
+ finally directory.Delete true
+ }
+ test "project setup rejects missing duplicate or computed versions" {
+ let directory = Directory.CreateTempSubdirectory "shipit-project"
+ try
+ let project = Path.Combine(directory.FullName, "A.fsproj")
+ let changelog = Path.Combine(directory.FullName, "CHANGELOG.md")
+ File.WriteAllText(changelog, "---\nname: A\n---\nbody\n")
+ for xml in [ ""; "$(V)"
+ "12" ] do
+ File.WriteAllText(project, xml)
+ Expect.throws (fun () -> ProjectSetup.configure changelog [ project ]) "ambiguous version"
+ finally directory.Delete true
+ }
+ test "tool setup preserves registration and restores explicitly" {
+ let directory = Directory.CreateTempSubdirectory "shipit-setup"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ let config = Directory.CreateDirectory(Path.Combine(directory.FullName, ".config"))
+ let manifest = Path.Combine(config.FullName, "dotnet-tools.json")
+ File.WriteAllText(manifest, """{"version":1,"isRoot":true,"tools":{"easybuild.shipit":{"version":"3.0.0","commands":["shipit"]}}}""")
+ let calls = ResizeArray()
+ let execute operation = async { calls.Add operation; return 0 }
+ ToolSetup.runWith execute directory.FullName "3.1.0" |> Async.RunSynchronously
+ Expect.equal calls.Count 1 "restore only"
+ Expect.equal calls[0].Arguments [ "tool"; "restore"; "--tool-manifest"; manifest ] "explicit manifest"
+ Expect.stringContains (File.ReadAllText manifest) "3.0.0" "pinned registration retained"
+ finally directory.Delete true
+ }
+ test "tool setup creates then installs then restores and repeats safely" {
+ let directory = Directory.CreateTempSubdirectory "shipit-setup"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ let nested = Directory.CreateDirectory(Path.Combine(directory.FullName, "nested"))
+ let manifest = Path.Combine(directory.FullName, ".config", "dotnet-tools.json")
+ let calls = ResizeArray()
+ let execute operation = async {
+ calls.Add operation
+ if operation.Arguments |> List.contains "tool-manifest" then
+ Directory.CreateDirectory(Path.GetDirectoryName manifest) |> ignore
+ File.WriteAllText(manifest, """{"version":1,"isRoot":true,"tools":{}}""")
+ if operation.Arguments |> List.contains "install" then
+ File.WriteAllText(manifest, """{"version":1,"isRoot":true,"tools":{"easybuild.shipit":{"version":"3.1.0","commands":["shipit"]}}}""")
+ return 0
+ }
+ ToolSetup.runWith execute nested.FullName "3.1.0" |> Async.RunSynchronously
+ Expect.equal calls.Count 3 "create install restore"
+ Expect.sequenceEqual calls[0].Arguments
+ [ "new"; "tool-manifest"; "--output"; Path.Combine(directory.FullName, ".config"); "--no-update-check" ] "template output is the manifest directory"
+ Expect.isTrue (calls[1].Arguments |> List.contains manifest) "repository manifest"
+ ToolSetup.runWith execute nested.FullName "3.1.0" |> Async.RunSynchronously
+ Expect.equal calls.Count 4 "second run only restores"
+ finally directory.Delete true
+ }
+ test "malformed manifest fails before executing tools" {
+ let directory = Directory.CreateTempSubdirectory "shipit-setup"
+ try
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".git")) |> ignore
+ Directory.CreateDirectory(Path.Combine(directory.FullName, ".config")) |> ignore
+ File.WriteAllText(Path.Combine(directory.FullName, ".config", "dotnet-tools.json"), "{invalid}")
+ let mutable called = false
+ let execute _ = async { called <- true; return 0 }
+ Expect.throws (fun () -> ToolSetup.runWith execute directory.FullName "3.1.0" |> Async.RunSynchronously) "invalid manifest"
+ Expect.isFalse called "no process"
+ finally directory.Delete true
+ }
+ test "local generation retains argument boundaries" {
+ let options = { ReleaseOptions.defaults with Mode = Local; PreRelease = Some "beta candidate" }
+ let operation = Operations.generate options
+ Expect.equal operation.Executable "dotnet" "launcher"
+ Expect.sequenceEqual operation.Arguments
+ [ "shipit"; "--allow-branch"; "main"; "--mode"; "local"; "--pre-release"; "beta candidate" ] "literal prefix"
+ }
+ test "GitHub token is secret" {
+ let operation = Operations.github ReleaseOptions.defaults (Some "secret value")
+ let index = operation.Arguments |> List.findIndex ((=) "secret value")
+ Expect.isTrue (operation.Secrets.Contains index) "secret index"
+ Expect.isFalse ((Cmd.toLogString operation).Contains "secret value") "masked label"
+ }
+ test "all release inputs reach upstream" {
+ let operation = Operations.generate {
+ ReleaseOptions.defaults with
+ AllowedBranches = [ "main"; "release" ]; Mode = Push
+ RemoteHostname = Some "github.com"; RemoteOwner = Some "owner name"; RemoteRepository = Some "repo"
+ SkipInvalidCommit = true; SkipMergeCommit = true; DryRun = true
+ }
+ Expect.sequenceEqual operation.Arguments
+ [ "shipit"; "--allow-branch"; "main"; "--allow-branch"; "release"; "--mode"; "push"
+ "--remote-hostname"; "github.com"; "--remote-owner"; "owner name"; "--remote-repo"; "repo"
+ "--skip-invalid-commit"; "--skip-merge-commit"; "--dry-run" ] "arguments"
+ }
+ test "initialization and informational operations" {
+ Expect.equal (Operations.initChangelog (Some "docs/my changelog.md")).Arguments
+ [ "shipit"; "init"; "changelog"; "docs/my changelog.md" ] "path"
+ Expect.equal (Operations.initChangelog None).Arguments [ "shipit"; "init"; "changelog" ] "default"
+ Expect.equal (Operations.initWorkflows ()).Arguments [ "shipit"; "init"; "workflows" ] "workflows"
+ Expect.equal (Operations.initGithub true true).Arguments [ "shipit"; "init"; "github"; "--org"; "--dry-run" ] "settings"
+ Expect.equal (Operations.version ()).Arguments [ "shipit"; "version" ] "version"
+ Expect.equal (Operations.conventions ()).Arguments [ "shipit"; "conventions" ] "conventions"
+ }
+ test "invalid options fail before launching" {
+ Expect.throws (fun () -> Operations.generate { ReleaseOptions.defaults with AllowedBranches = [] } |> ignore) "no branches"
+ Expect.throws (fun () -> Operations.generate { ReleaseOptions.defaults with PreRelease = Some " " } |> ignore) "blank prefix"
+ }
+]