diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..64e9033 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,228 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + # Cheap, dotnet-free sanity check, run first so a bad tag fails fast before any + # publish work starts. Also the single source of truth for "version" (a job output) + # every later job consumes - always the value read back from the repo's own + # AssemblyInfo.cs, not the raw tag text, once the two are confirmed equal below. + verify-version: + name: Verify release version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.check.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + # WitcherScriptMerger (the WinForms host) has GenerateAssemblyInfo=false and + # hand-maintains its version in Properties/AssemblyInfo.cs instead (see that + # project's CLAUDE.md's "Compatibility constraint: TFM must keep the explicit 7.0 + # OS-version suffix" section) - a `-p:Version=` passed to `dotnet publish` can't + # reach it, unlike WitcherScriptMerger.Headless (see the build job below). + # WitcherScriptMerger.Headless.csproj's own property is a second, + # independently hand-maintained copy of the same value (see its own comment) with + # nothing else enforcing the two stay in sync. Nothing else enforces either against + # a pushed release tag. Check all three here and fail loudly on any mismatch, + # instead of silently shipping a release with a wrong/inconsistent --version. + - name: Check AssemblyInfo.cs / csproj / tag versions agree + id: check + shell: pwsh + run: | + $tagVersion = $env:GITHUB_REF_NAME -replace '^v', '' + + # Select-String matches per-line, so the commented-out SDK boilerplate example + # two lines above the real attribute ("// [assembly: AssemblyVersion(...") + # can never match this ^-anchored pattern - only a line that actually starts + # with "[assembly:" can. + $asmMatch = Select-String -Path 'WitcherScriptMerger/Properties/AssemblyInfo.cs' ` + -Pattern '^\[assembly:\s*AssemblyVersion\("([^"]+)"\)\]' | Select-Object -First 1 + if (-not $asmMatch) { + throw "Could not find an uncommented [assembly: AssemblyVersion(...)] line in WitcherScriptMerger/Properties/AssemblyInfo.cs" + } + $assemblyVersion = $asmMatch.Matches[0].Groups[1].Value + + $csprojMatch = Select-String -Path 'WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj' ` + -Pattern '([^<]+)' | Select-Object -First 1 + if (-not $csprojMatch) { + throw "Could not find a element in WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj" + } + $csprojVersion = $csprojMatch.Matches[0].Groups[1].Value + + if ($csprojVersion -ne $assemblyVersion) { + throw "WitcherScriptMerger.Headless.csproj's ('$csprojVersion') does not match WitcherScriptMerger/Properties/AssemblyInfo.cs's AssemblyVersion ('$assemblyVersion') - keep them in sync before tagging a release." + } + if ($assemblyVersion -ne $tagVersion) { + throw "Tag '$env:GITHUB_REF_NAME' (version '$tagVersion') does not match WitcherScriptMerger/Properties/AssemblyInfo.cs's AssemblyVersion ('$assemblyVersion'). Bump AssemblyVersion/AssemblyFileVersion there (and WitcherScriptMerger.Headless.csproj's ) before tagging a release." + } + + "version=$assemblyVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # build.yml (dotnet build + dotnet format whitespace --verify-no-changes) only runs on + # pull_request - a tag pushed directly, without going through a PR, would otherwise + # reach the publish/release steps below with zero build or test verification anywhere + # in this path. Mirrors build.yml's own build step (windows-latest, Release + # configuration) and adds dotnet test, gating the publish matrix job on both passing. + test: + name: Build & test + needs: verify-version + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: WitcherScriptMerger/WitcherScriptMerger.csproj + + - name: Restore + run: dotnet restore WitcherScriptMerger.sln + + - name: Build + run: dotnet build WitcherScriptMerger.sln --no-restore --configuration Release + + - name: Test + run: dotnet test WitcherScriptMerger.sln --no-build --configuration Release + + # Each matrix entry publishes one host/RID combination via the matching checked-in + # Properties/PublishProfiles/.pubxml (RuntimeIdentifier, SelfContained, + # PublishSingleFile - see each host's own CLAUDE.md's publish section) and uploads the + # result as a build artifact for the packaging job below. Runs on windows-latest for + # all three (matching build.yml's own convention) even though the linux-x64 leg is a + # cross-compile that doesn't strictly require it - producing that binary doesn't need + # a Linux machine, only running it does (see WitcherScriptMerger.Headless/CLAUDE.md). + build: + name: Publish ${{ matrix.name }} + needs: [verify-version, test] + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - name: WitcherScriptMerger (win-x64) + project: WitcherScriptMerger/WitcherScriptMerger.csproj + profile: win-x64 + publish-dir: WitcherScriptMerger/bin/Release/net10.0-windows7.0/win-x64/publish + artifact-name: WitcherScriptMerger-win-x64 + pass-version: 'false' + - name: WitcherScriptMerger.Headless (win-x64) + project: WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj + profile: win-x64 + publish-dir: WitcherScriptMerger.Headless/bin/Release/net10.0/win-x64/publish + artifact-name: WitcherScriptMerger.Headless-win-x64 + pass-version: 'true' + - name: WitcherScriptMerger.Headless (linux-x64) + project: WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj + profile: linux-x64 + publish-dir: WitcherScriptMerger.Headless/bin/Release/net10.0/linux-x64/publish + artifact-name: WitcherScriptMerger.Headless-linux-x64 + pass-version: 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + cache: true + cache-dependency-path: WitcherScriptMerger/WitcherScriptMerger.csproj + + # Not passed --no-restore: a RID-specific self-contained publish needs runtime + # packages a plain solution-wide restore wouldn't fetch, so each publish does its + # own implicit restore for its own RID. + # + # -p:Version (WitcherScriptMerger.Headless legs only - matrix.pass-version) sets + # this build's version from verify-version's checked value. GenerateAssemblyInfo + # is off for the WinForms host (matrix.pass-version: 'false' there), so passing it + # there would be a silent no-op - omitted rather than included-but-ignored. + - name: Publish + shell: pwsh + env: + RELEASE_VERSION: ${{ needs.verify-version.outputs.version }} + run: | + $versionArg = @() + if ("${{ matrix.pass-version }}" -eq 'true') { + $versionArg = @("-p:Version=$env:RELEASE_VERSION") + } + dotnet publish "${{ matrix.project }}" -c Release -p:PublishProfile=${{ matrix.profile }} @versionArg + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: ${{ matrix.publish-dir }} + if-no-files-found: error + + # Runs on ubuntu-latest specifically for the linux-x64 asset: `tar` needs to run on a + # filesystem with real Unix permission bits to set/preserve the executable bit on the + # WitcherScriptMerger.Headless binary before archiving - building the tarball on + # windows-latest (NTFS has no such bit) was tried and confirmed to produce a + # non-executable entry, defeating the entire reason tar (not zip) was chosen for this + # asset. `chmod +x` here is unconditional and explicit rather than relying on the + # download-artifact transfer having preserved any prior mode bit. + package-release: + name: Package & create release + needs: [verify-version, build] + runs-on: ubuntu-latest + steps: + - name: Download WitcherScriptMerger (win-x64) + uses: actions/download-artifact@v4 + with: + name: WitcherScriptMerger-win-x64 + path: publish/WitcherScriptMerger-win-x64 + + - name: Download WitcherScriptMerger.Headless (win-x64) + uses: actions/download-artifact@v4 + with: + name: WitcherScriptMerger.Headless-win-x64 + path: publish/WitcherScriptMerger.Headless-win-x64 + + - name: Download WitcherScriptMerger.Headless (linux-x64) + uses: actions/download-artifact@v4 + with: + name: WitcherScriptMerger.Headless-linux-x64 + path: publish/WitcherScriptMerger.Headless-linux-x64 + + # Packages each publish output (including the .dll.config the SDK + # already copies in next to the exe - see each host's CLAUDE.md's "Publishing" + # section for why that file, not App.config itself, is what + # ConfigurationManager actually reads at runtime) as one archive per host/RID + # combination. zip for the two win-x64 outputs; tar.gz (not zip) for linux-x64 + # specifically - zip doesn't preserve the Unix executable bit, so an unzipped + # Linux binary wouldn't be runnable. + - name: Package release assets + env: + RELEASE_VERSION: ${{ needs.verify-version.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + + chmod +x "publish/WitcherScriptMerger.Headless-linux-x64/WitcherScriptMerger.Headless" + + ( cd publish/WitcherScriptMerger-win-x64 && zip -r "../../dist/WitcherScriptMerger-${RELEASE_VERSION}-win-x64.zip" . ) + ( cd publish/WitcherScriptMerger.Headless-win-x64 && zip -r "../../dist/WitcherScriptMerger.Headless-${RELEASE_VERSION}-win-x64.zip" . ) + ( cd publish/WitcherScriptMerger.Headless-linux-x64 && tar -czf "../../dist/WitcherScriptMerger.Headless-${RELEASE_VERSION}-linux-x64.tar.gz" . ) + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.verify-version.outputs.version }} + run: | + set -euo pipefail + gh release create "$GITHUB_REF_NAME" \ + "dist/WitcherScriptMerger-${RELEASE_VERSION}-win-x64.zip" \ + "dist/WitcherScriptMerger.Headless-${RELEASE_VERSION}-win-x64.zip" \ + "dist/WitcherScriptMerger.Headless-${RELEASE_VERSION}-linux-x64.tar.gz" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes diff --git a/.gitignore b/.gitignore index 9500e48..059652d 100644 --- a/.gitignore +++ b/.gitignore @@ -132,11 +132,19 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings +# TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj +# ...except this repo's own release publish profiles, checked in deliberately: they +# hold no credentials (just RuntimeIdentifier/SelfContained/PublishSingleFile), and +# .github/workflows/release.yml depends on them being present via `dotnet publish +# -p:PublishProfile=`. See WitcherScriptMerger/CLAUDE.md and +# WitcherScriptMerger.Headless/CLAUDE.md's "Publish"/"Publishing" sections. +!WitcherScriptMerger/Properties/PublishProfiles/*.pubxml +!WitcherScriptMerger.Headless/Properties/PublishProfiles/*.pubxml + # NuGet Packages *.nupkg # The packages folder can be ignored because of Package Restore diff --git a/WitcherScriptMerger.Core/CLAUDE.md b/WitcherScriptMerger.Core/CLAUDE.md index 9c76ab9..1633cdc 100644 --- a/WitcherScriptMerger.Core/CLAUDE.md +++ b/WitcherScriptMerger.Core/CLAUDE.md @@ -34,7 +34,12 @@ still external dependencies rather than an in-process replacement. `Mcp/CLAUDE.md`). - Root: `AppState.cs` (shared mutable state — see below), `AppSettings.cs`, `Paths.cs`, `StringExtensions.cs`, `IMergeNotifier.cs`, `NotifyTypes.cs` (the neutral - `NotifyResult`/`NotifyButtons`/`DialogIcon` enums), `HeadlessMergeNotifier.cs`. + `NotifyResult`/`NotifyButtons`/`DialogIcon` enums), `HeadlessMergeNotifier.cs`, + `VersionInfo.cs` (`GetVersion(Assembly)` — the shared implementation behind both + hosts' `--version` CLI flag and their MCP server's `ServerInfo.Version`; not + duplicated per host despite the two hosts' differing assembly-versioning setups — see + each host's own `CLAUDE.md` for the call sites and why the fallback chain handles both + uniformly). ## AppState & IMergeNotifier diff --git a/WitcherScriptMerger.Core/VersionInfo.cs b/WitcherScriptMerger.Core/VersionInfo.cs new file mode 100644 index 0000000..51309d0 --- /dev/null +++ b/WitcherScriptMerger.Core/VersionInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; + +namespace WitcherScriptMerger +{ + // Backs both hosts' "--version" CLI flag and their MCP server's ServerInfo.Version + // (see each host's own Program.cs) - shared here, not duplicated per host, even + // though the two hosts' underlying assembly-attribute setups differ: + // WitcherScriptMerger.csproj has GenerateAssemblyInfo=false and hand-maintains its + // version in Properties/AssemblyInfo.cs (no AssemblyInformationalVersionAttribute is + // ever emitted there), while WitcherScriptMerger.Headless.csproj drives it from its + // own property (GenerateAssemblyInfo left on, so the SDK does emit one). + // GetVersion's fallback chain handles both uniformly. + public static class VersionInfo + { + public static string GetVersion(Assembly assembly) + { + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrEmpty(informational)) + return informational; + + var version = assembly.GetName().Version; + if (version == null) + return "unknown"; + + // System.Version always round-trips through ToString() as 4 dot-separated + // parts, padding an unset Revision to 0 - trim that back off when it's the + // default, so a 3-part hand-maintained AssemblyVersion (e.g. "0.6.2", as + // WitcherScriptMerger/Properties/AssemblyInfo.cs currently has it) prints + // back out as "0.6.2", not "0.6.2.0". + return version.Revision == 0 + ? $"{version.Major}.{version.Minor}.{version.Build}" + : version.ToString(); + } + } +} diff --git a/WitcherScriptMerger.Headless/CLAUDE.md b/WitcherScriptMerger.Headless/CLAUDE.md index e0462a5..cdd1157 100644 --- a/WitcherScriptMerger.Headless/CLAUDE.md +++ b/WitcherScriptMerger.Headless/CLAUDE.md @@ -18,14 +18,41 @@ Mirrors `WitcherScriptMerger/Program.cs`'s `args[0] == "merge"` / `args[0] == "m dispatch, but with no third (no-args-launches-GUI) branch — no args, or an unrecognized first argument, prints usage to stderr and exits 1. -`Environment.CurrentDirectory = AppContext.BaseDirectory` is set as the very first -statement in `Main`, before touching `AppState.Settings`/`Paths` at all — several Core +`args[0] == "--version"` is checked before anything else in `Main`, including the +`Environment.CurrentDirectory` reassignment below — prints the assembly version and +exits 0. Mirrors the WinForms host's `RunCli` checking `--version` first for the same +reason: `AppState.Settings`'s construction (first touched inside `RunMerge`/`RunMcp`) +calls `Environment.Exit(1)` when it can't find a config file (Core's `CLAUDE.md`), and +`--version` must still work against a freshly-extracted publish directory with no +`WitcherScriptMerger.Headless.dll.config` copied beside the exe yet. Unlike the WinForms +host, this project doesn't set `GenerateAssemblyInfo=false`, so its csproj's `` +property (kept in sync with the WinForms host's hand-maintained `AssemblyInfo.cs` — see +"Publishing" below for the release build's automated check of that — and overridable +per-build via `-p:Version=`) drives a real `AssemblyInformationalVersionAttribute` that +`WitcherScriptMerger.VersionInfo.GetVersion()` (Core; shared with the WinForms host, not +duplicated here — see its own `CLAUDE.md`) reads. This project also sets +`false` +in its csproj, suppressing the SDK's default `+` suffix on that attribute, so +`--version`/`ServerInfo.Version` print exactly the ``/`-p:Version=` value (e.g. +`"0.6.2"`) rather than `"0.6.2+ab12cd3..."` — matching a release tag's own version text +once "v" is stripped, and matching the WinForms host's own (unsuffixed) output shape. + +`Environment.CurrentDirectory = AppContext.BaseDirectory` is set right after the +`--version` check, before touching `AppState.Settings`/`Paths` at all — several Core paths are relative to it (`Paths.Inventory`, `Paths.TempBundleContent`, `Paths.DiffPlexConflictsDirectory`, `Paths.MergedBundleContentAbsolute`'s field initializer; see Core's `CLAUDE.md`). This mirrors the WinForms host's `Program.RunCli` -doing the same as its own first statement, except unconditionally as the very first -thing here — this host has no no-args-launches-GUI branch to worry about leaving -unreset. +doing the same as its own first statement after its own `--version` check — this host +has no no-args-launches-GUI branch to worry about leaving unreset. + +`RunMcp`'s `AddMcpServer(...)` call sets `options.ServerInfo = new Implementation { +Name = "WitcherScriptMerger.Headless", Version = +VersionInfo.GetVersion(typeof(Program).Assembly) }` — the SDK's own, +standard mechanism (`ModelContextProtocol.Protocol.Implementation`) for surfacing a +server's name/version during the `initialize` handshake, not a custom side-channel. +Mirrors the WinForms host's identical wiring in its own `RunMcp` (see +`WitcherScriptMerger/CLAUDE.md`'s "MCP mode" section), with a distinct `Name` so a +client can tell the two hosts' servers apart. ## What this host deliberately omits @@ -116,15 +143,33 @@ these two; no other occurrences remained. ## Publishing -No existing `.pubxml` profiles in this repo — these commands are the documented -convention instead. Cross-compiling for `linux-x64` works fine from Windows — producing -the binary doesn't require a Linux machine, only *running* it does: +Two checked-in publish profiles, `Properties/PublishProfiles/win-x64.pubxml` and +`.../linux-x64.pubxml`, each self-contained/single-file for their `RuntimeIdentifier`. +They only take effect when explicitly selected this way (or via Visual Studio's Publish +UI) — a plain `dotnet build`/`dotnet publish` with no profile is unaffected. Cross- +compiling for `linux-x64` works fine from Windows — producing the binary doesn't require +a Linux machine, only *running* it does: ``` -dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -r win-x64 --self-contained -p:PublishSingleFile=true -c Release -dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -r linux-x64 --self-contained -p:PublishSingleFile=true -c Release +dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -p:PublishProfile=win-x64 +dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -p:PublishProfile=linux-x64 ``` +`.github/workflows/release.yml` runs these same two commands (plus the WinForms host's +own `win-x64` profile), tag-triggered, for release builds, each as its own leg of a +3-entry build matrix — each of this project's two legs also passed `-p:Version=` +there (sourced from that workflow's own `verify-version` job, which fails the whole +release before any publish starts if the tag, `AssemblyInfo.cs`'s `AssemblyVersion`, and +this project's own csproj `` don't all three agree — see +`WitcherScriptMerger/CLAUDE.md`'s "CLI mode" section), which this project's on-by-default +`GenerateAssemblyInfo` picks up (unlike the WinForms host — see +`WitcherScriptMerger/CLAUDE.md`'s "Compatibility constraint" section). A separate job in +that workflow, running on `ubuntu-latest` rather than `windows-latest`, packages all +three publish outputs and creates the GitHub Release — needed specifically so the +`linux-x64` asset's `tar.gz` gets built with a real Unix executable bit set on the +`WitcherScriptMerger.Headless` binary (confirmed empirically: building that same archive +on Windows/NTFS, which has no such bit, silently produces a non-executable entry). + Each publish's `.dll.config` (the `App.config` copy `System.Configuration.ConfigurationManager` actually reads) lands next to the executable — copy it there if deploying the exe on its own. Confirmed empirically that this diff --git a/WitcherScriptMerger.Headless/Program.cs b/WitcherScriptMerger.Headless/Program.cs index 3e8bb5a..19a1268 100644 --- a/WitcherScriptMerger.Headless/Program.cs +++ b/WitcherScriptMerger.Headless/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; using WitcherScriptMerger.Cli; using WitcherScriptMerger.Inventory; using WitcherScriptMerger.LoadOrder; @@ -27,6 +28,19 @@ static class Program { static int Main(string[] args) { + // Checked before anything else, including the CurrentDirectory reassignment + // below - mirrors WitcherScriptMerger/Program.cs's RunCli doing the same + // ahead of its own config-file check. AppState.Settings's construction (first + // touched inside RunMerge/RunMcp) calls Environment.Exit(1) when it can't find + // a config file (see Core's CLAUDE.md), so a freshly-extracted publish dir + // with no App.config/.dll.config copied beside the exe yet must + // never reach that path just to answer "--version". + if (args.Length > 0 && args[0] == "--version") + { + Console.WriteLine(VersionInfo.GetVersion(typeof(Program).Assembly)); + return 0; + } + // Several Core paths are relative to Environment.CurrentDirectory // (Paths.MergedBundleContentAbsolute's field initializer, Paths.Inventory, // Paths.DiffPlexConflictsDirectory, Paths.TempBundleContent) - must be set @@ -56,7 +70,7 @@ static int Main(string[] args) if (args[0] == "merge") return RunMerge(args); - Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp"); + Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp, --version"); PrintUsage(); return 1; } @@ -68,6 +82,7 @@ static void PrintUsage() Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" WitcherScriptMerger.Headless merge [--order-file ]"); Console.Error.WriteLine(" WitcherScriptMerger.Headless mcp"); + Console.Error.WriteLine(" WitcherScriptMerger.Headless --version"); Console.Error.WriteLine(); Console.Error.WriteLine("Supports flat-file (.ws/.xml) conflicts only - bundle-content conflicts"); Console.Error.WriteLine("require QuickBMS/wcc_lite, which this host doesn't bundle. See CLAUDE.md."); @@ -195,8 +210,17 @@ static int RunMcp() // `initialize` succeeds, `tools/list` returns an empty array) if left as-is. // Pass the Core assembly explicitly - same fix WitcherScriptMerger/Program.cs // needed for the identical reason. + // + // ServerInfo is the SDK's standard mechanism for identifying this server (name + // + version) to a connecting client during the initialize handshake - not a + // custom side-channel. "WitcherScriptMerger.Headless" distinguishes this host + // from the WinForms host's own MCP server in a client's logs. builder.Services - .AddMcpServer() + .AddMcpServer(options => options.ServerInfo = new Implementation + { + Name = "WitcherScriptMerger.Headless", + Version = VersionInfo.GetVersion(typeof(Program).Assembly), + }) .WithStdioServerTransport() .WithToolsFromAssembly(typeof(WsmMcpTools).Assembly); diff --git a/WitcherScriptMerger.Headless/Properties/PublishProfiles/linux-x64.pubxml b/WitcherScriptMerger.Headless/Properties/PublishProfiles/linux-x64.pubxml new file mode 100644 index 0000000..0b06805 --- /dev/null +++ b/WitcherScriptMerger.Headless/Properties/PublishProfiles/linux-x64.pubxml @@ -0,0 +1,17 @@ + + + + Release + linux-x64 + true + true + false + + diff --git a/WitcherScriptMerger.Headless/Properties/PublishProfiles/win-x64.pubxml b/WitcherScriptMerger.Headless/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..59991a6 --- /dev/null +++ b/WitcherScriptMerger.Headless/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,16 @@ + + + + Release + win-x64 + true + true + false + + diff --git a/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj b/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj index 111ddfd..c117d2b 100644 --- a/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj +++ b/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj @@ -5,6 +5,18 @@ net10.0 WitcherScriptMerger.Headless WitcherScriptMerger.Headless + + 0.6.2 + + false disable disable ..\WitcherScriptMerger\DeadCodeDetection.ruleset diff --git a/WitcherScriptMerger/CLAUDE.md b/WitcherScriptMerger/CLAUDE.md index 053a295..e846f20 100644 --- a/WitcherScriptMerger/CLAUDE.md +++ b/WitcherScriptMerger/CLAUDE.md @@ -18,11 +18,22 @@ merges via `InteractiveMergeRunner`, and wires up async callbacks. `dotnet run --project WitcherScriptMerger/WitcherScriptMerger.csproj`. - Run (CLI/MCP): see "CLI mode" / "MCP mode" below. - **Publish** (self-contained single-file, `win-x64` only — it's a WinForms app, never - makes sense on Linux; no existing `.pubxml` profile in this repo, this command is the - documented convention instead): + makes sense on Linux) via the checked-in publish profile, + `Properties/PublishProfiles/win-x64.pubxml`: ``` - dotnet publish WitcherScriptMerger/WitcherScriptMerger.csproj -r win-x64 --self-contained -p:PublishSingleFile=true -c Release + dotnet publish WitcherScriptMerger/WitcherScriptMerger.csproj -p:PublishProfile=win-x64 ``` + The profile only takes effect when explicitly selected this way (or via Visual + Studio's Publish UI) — it sets `RuntimeIdentifier`/`SelfContained`/`PublishSingleFile` + itself, so a plain `dotnet build`/`dotnet publish` with no profile is unaffected (no + `win-x64` subfolder, no self-contained runtime files). `.github/workflows/release.yml` + runs this same command, tag-triggered, for release builds (one leg of a 3-way build + matrix, gated on a `dotnet build`/`dotnet test` job — build.yml's own checks only run + on a PR, which a direct tag push bypasses — see that file's own comments; a separate + job packages the three publish outputs and creates the GitHub Release on + `ubuntu-latest`, needed so the `linux-x64` asset — see + `WitcherScriptMerger.Headless/CLAUDE.md` — gets a real Unix executable bit, which + building the archive on Windows/NTFS cannot set). The publish's `.dll.config` (the `App.config` copy `System.Configuration.ConfigurationManager` actually reads, via Core's `AppSettings.cs`) lands next to the executable — copy it there if deploying the exe on @@ -155,8 +166,27 @@ resolution opens its conflict-marker sidecar in the default editor instead — s `CLAUDE.md`). No-args still launches the GUI unchanged; passing `merge` (or any argument) is what selects the CLI path. -`RunCli` sets `Environment.CurrentDirectory = AppContext.BaseDirectory` as its first -statement (several Core paths are relative to it — see Core's `CLAUDE.md`'s +`WitcherScriptMerger.exe --version` prints the assembly version and exits 0 — checked as +the very first statement in `RunCli`, ahead of `Environment.CurrentDirectory` and the +`Settings.HasConfigFile` check, since `AppSettings`'s constructor calls +`Environment.Exit(1)` when no config file is found (Core's `CLAUDE.md`) and `--version` +must still work against a freshly-extracted publish directory that hasn't had +`WitcherScriptMerger.dll.config` copied beside the exe yet. Because this project has +`GenerateAssemblyInfo=false` (see "Compatibility constraint" below), there's no +`AssemblyInformationalVersionAttribute` to read — `WitcherScriptMerger.VersionInfo. +GetVersion()` (Core; shared with the Headless host, see its own `CLAUDE.md`) falls back +to `Assembly.GetName().Version`, trimming its always-4-part `ToString()` back to 3 parts +when the unset `Revision` component defaults to 0, so a hand-maintained 3-part +`AssemblyVersion` (e.g. `"0.6.2"`) round-trips back out as `"0.6.2"`, not `"0.6.2.0"`. +Bump both `AssemblyVersion`/`AssemblyFileVersion` in `Properties/AssemblyInfo.cs` per +release (and `WitcherScriptMerger.Headless.csproj`'s `` — see that project's own +`CLAUDE.md`, kept in sync by convention, not by anything in this project itself); +`.github/workflows/release.yml`'s `verify-version` job fails the whole release before any +publish work starts if a pushed tag doesn't match `AssemblyVersion`, or if +`WitcherScriptMerger.Headless.csproj`'s `` doesn't match it either. + +`RunCli` sets `Environment.CurrentDirectory = AppContext.BaseDirectory` right after the +`--version` check (several Core paths are relative to it — see Core's `CLAUDE.md`'s `DiffPlexConflictsDirectory` note), then dispatches on `args[0]`. **The `merge` verb requires the full combined `Paths.ValidateDependencyPaths()`** (QuickBMS *and* wcc_lite, not just the text-merge engine) before doing anything else — this host refuses to start @@ -181,12 +211,18 @@ args/config/deps), 2 = ran, but one or more conflicts were skipped. ### MCP mode (this host) `WitcherScriptMerger.exe mcp` runs an MCP server over stdio (`ModelContextProtocol` -NuGet package, `Host.CreateApplicationBuilder().Services.AddMcpServer() -.WithStdioServerTransport().WithToolsFromAssembly(typeof(WsmMcpTools).Assembly)` — the -assembly must be passed explicitly since `WsmMcpTools` lives in Core, not this calling -assembly; the parameterless overload only scans the calling assembly and would silently -register zero tools). `RunMcp` **also gates on the full combined -`Paths.ValidateDependencyPaths()`** before starting the server at all — this host won't +NuGet package, `Host.CreateApplicationBuilder().Services.AddMcpServer(options => +options.ServerInfo = new Implementation { Name = "WitcherScriptMerger", Version = +VersionInfo.GetVersion(typeof(Program).Assembly) }).WithStdioServerTransport() +.WithToolsFromAssembly(typeof(WsmMcpTools).Assembly)` — the assembly must be passed +explicitly since `WsmMcpTools` lives in Core, not this calling assembly; the +parameterless overload only scans the calling assembly +and would silently register zero tools). `ServerInfo` (the SDK's own +`ModelContextProtocol.Protocol.Implementation` type — the standard, not a custom, +mechanism) surfaces the same version `--version` prints, in the `initialize` handshake, +so an MCP client can tell which build it's talking to and distinguish this host from +`WitcherScriptMerger.Headless`'s own server by name. `RunMcp` **also gates on the full +combined `Paths.ValidateDependencyPaths()`** before starting the server at all — this host won't even start an MCP server without QuickBMS/wcc_lite configured, regardless of whether the client ever calls a bundle-touching tool. This is a stricter gate than `WsmMcpTools.RequireDependenciesAndModsDirectory` itself applies per-call (text-merge diff --git a/WitcherScriptMerger/Program.cs b/WitcherScriptMerger/Program.cs index 24ddfd6..f7b6c20 100644 --- a/WitcherScriptMerger/Program.cs +++ b/WitcherScriptMerger/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using WitcherScriptMerger.Cli; using WitcherScriptMerger.Forms; @@ -148,6 +149,17 @@ public static bool TryOpenDirectory(string dirPath) // one or more conflicts were skipped. static int RunCli(string[] args) { + // Checked before anything else touches AppState.Settings/Paths - + // AppSettings's constructor calls Environment.Exit(1) when it can't find a + // config file (see Core's CLAUDE.md), so a freshly-extracted publish dir with + // no App.config/.dll.config copied beside the exe yet would + // otherwise kill the process before "--version" ever got to print anything. + if (args[0] == "--version") + { + Console.WriteLine(VersionInfo.GetVersion(typeof(Program).Assembly)); + return 0; + } + Environment.CurrentDirectory = AppContext.BaseDirectory; if (!Settings.HasConfigFile) @@ -161,7 +173,7 @@ static int RunCli(string[] args) if (args[0] != "merge") { - Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp"); + Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp, --version"); return 1; } @@ -255,8 +267,17 @@ static int RunMcp() // the calling assembly, which would silently register zero tools (server // starts, `initialize` succeeds, `tools/list` returns an empty array) if // left as-is. Pass the Core assembly explicitly. + // + // ServerInfo is the SDK's standard mechanism for identifying this server (name + // + version) to a connecting client during the initialize handshake - not a + // custom side-channel. "WitcherScriptMerger" distinguishes this (WinForms) + // host from WitcherScriptMerger.Headless's own MCP server in a client's logs. builder.Services - .AddMcpServer() + .AddMcpServer(options => options.ServerInfo = new Implementation + { + Name = "WitcherScriptMerger", + Version = VersionInfo.GetVersion(typeof(Program).Assembly), + }) .WithStdioServerTransport() .WithToolsFromAssembly(typeof(WsmMcpTools).Assembly); diff --git a/WitcherScriptMerger/Properties/PublishProfiles/win-x64.pubxml b/WitcherScriptMerger/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..fd07525 --- /dev/null +++ b/WitcherScriptMerger/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,16 @@ + + + + Release + win-x64 + true + true + false + +