diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6f423d..3dd0d80a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1842,6 +1842,23 @@ jobs: # below: it is not part of the "install -> check" claim being proven. - name: Pack OwnSharp.Cli (pulls in the extractor via ProjectReference) run: dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release -o "$RUNNER_TEMP/ownsharp-nupkg" + # --add-source alone is not enough (Codex review, PR #244): `dotnet tool + # install` queries every configured source (nuget.org included) IN + # PARALLEL and takes whichever answers first — once a same-numbered + # version is ever actually on nuget.org, this install could silently + # resolve from there instead of the just-packed artifact. An isolated + # nuget.config with removes the ambiguity. + - name: Isolated NuGet.config — the packed artifact is the ONLY visible source + run: | + cat > "$RUNNER_TEMP/isolated-nuget.config" < + + + + + + + EOF - name: A minimal leak, in a scratch dir OUTSIDE the repo # Proves the tool needs nothing but itself + Python -- not the Own.NET # checkout (a real end user obviously won't have this repo on disk). @@ -1861,7 +1878,7 @@ jobs: - name: Start the clean-machine timer (install -> check -> findings) run: echo "SMOKE_START=$(date +%s)" >> "$GITHUB_ENV" - name: dotnet tool install --global (the one install the user runs) - run: dotnet tool install --global OwnSharp.Cli --version 0.1.0 --add-source "$RUNNER_TEMP/ownsharp-nupkg" + run: dotnet tool install --global OwnSharp.Cli --version 0.1.0 --configfile "$RUNNER_TEMP/isolated-nuget.config" - name: ownsharp check finds the leak run: | set +e diff --git a/.github/workflows/ownsharp-cli-release.yml b/.github/workflows/ownsharp-cli-release.yml new file mode 100644 index 00000000..14960729 --- /dev/null +++ b/.github/workflows/ownsharp-cli-release.yml @@ -0,0 +1,257 @@ +name: OwnSharp.Cli release + +# Release pipeline for the `ownsharp` dotnet tool (OwnSharp.Cli, alpha gate A +# / issue #202). Separate from ci.yml's `ownsharp-cli-smoke` job on purpose: +# that job proves the packaging shape works on every push/PR; this workflow +# is specifically the RELEASE process — build, test, pack, prove the packed +# artifact installs clean on both OSes, then publish ONLY behind an explicit +# gate. See docs/notes/ownsharp-cli-release.md for the versioning policy and +# the release checklist this workflow implements. +# +# Publish safety (two independent gates, both required): +# 1. `publish` only runs when the trigger is a pushed tag matching +# `ownsharp-cli-v*` — a `workflow_dispatch` run (no such tag ref) can +# build/test/pack/smoke-test but can never reach the publish job. +# 2. `publish` targets the `nuget-release` GitHub Environment, which a repo +# admin must configure with required reviewers (Settings -> Environments) +# before this can ever run unattended — see the release checklist. +# The workflow never echoes secrets.NUGET_API_KEY; `dotnet nuget push` takes +# it as a CLI argument (not printed by the tool) and Actions' own log +# redaction masks any accidental echo of a registered secret value. + +permissions: + contents: read + +on: + push: + tags: + - "ownsharp-cli-v*" + workflow_dispatch: {} + +env: + CLI_PROJECT: frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj + +jobs: + build-test-pack: + name: build + test + pack + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + + - name: Standard repo gates (run_tests.py, ruff, mypy) + run: | + python tests/run_tests.py + pip install --quiet ruff mypy + ruff check . + mypy + + - name: dotnet build (extractor + CLI) + run: dotnet build "$CLI_PROJECT" -c Release + + # Versioning policy (docs/notes/ownsharp-cli-release.md): the csproj + # is the single source of truth. On a tag push, the tag's + # version suffix must match it byte-for-byte, or this fails loudly + # instead of silently publishing the wrong version. + - name: Read the csproj Version + id: version + run: | + v=$(grep -oP '(?<=)[^<]+' "$CLI_PROJECT") + [ -n "$v" ] || { echo "FAIL: could not read from $CLI_PROJECT"; exit 1; } + echo "csproj Version: $v" + echo "version=$v" >> "$GITHUB_OUTPUT" + - name: On a tag push, assert the tag matches the csproj Version + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ownsharp-cli-v') + run: | + tag_version="${GITHUB_REF_NAME#ownsharp-cli-v}" + csproj_version="${{ steps.version.outputs.version }}" + if [ "$tag_version" != "$csproj_version" ]; then + echo "FAIL: tag ownsharp-cli-v$tag_version does not match csproj Version $csproj_version" + echo "Bump in $CLI_PROJECT to match the tag (or retag) before releasing." + exit 1 + fi + echo "OK: tag matches csproj Version ($csproj_version)" + + - name: dotnet pack + run: dotnet pack "$CLI_PROJECT" -c Release -o "$RUNNER_TEMP/nupkg" + - name: Inspect package contents (bundled runtime/core assets present) + run: | + set -euo pipefail + nupkg=$(ls "$RUNNER_TEMP"/nupkg/OwnSharp.Cli.*.nupkg) + echo "package: $nupkg" + mkdir -p "$RUNNER_TEMP/nupkg-inspect" + unzip -q "$nupkg" -d "$RUNNER_TEMP/nupkg-inspect" + find "$RUNNER_TEMP/nupkg-inspect" -name "*.nuspec" -exec cat {} \; + test -f "$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/ownsharp.dll" \ + || { echo "FAIL: ownsharp.dll (the CLI itself) missing from the package"; exit 1; } + test -f "$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/ownsharp-extract.dll" \ + || { echo "FAIL: bundled extractor (ownsharp-extract.dll) missing from the package"; exit 1; } + core_py_count=$(find "$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/ownlang-core/ownlang" -name "*.py" 2>/dev/null | wc -l) + [ "$core_py_count" -gt 0 ] \ + || { echo "FAIL: vendored ownlang core .py files missing from the package"; exit 1; } + echo "OK: package contains the CLI, the bundled extractor, and $core_py_count vendored core .py files" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ownsharp-cli-nupkg + path: ${{ runner.temp }}/nupkg/*.nupkg + retention-days: 14 + + smoke-test: + name: install from the packed artifact — clean install -> check -> findings + needs: build-test-pack + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ownsharp-cli-nupkg + path: ${{ runner.temp }}/nupkg + - name: Put the dotnet global-tools shim dir on PATH + run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + + # Critical test rule (docs/notes/ownsharp-cli-release.md): this MUST + # install and run the packed .nupkg from build-test-pack, never a + # project reference or `dotnet run` — that would only prove the source + # exists, not that the package works for an end user. No checkout of + # this repo happens on this job at all: it only has the artifact. + # + # --add-source is NOT enough on its own (Codex review, PR #244): + # `dotnet tool install` queries every configured source (the machine's + # default nuget.org feed included) IN PARALLEL and takes whichever + # answers first — once a same-numbered version is ever actually on + # nuget.org (e.g. a rerun after a real publish), this install could + # silently resolve from there instead of the local artifact, defeating + # the whole point of this job. An isolated nuget.config with `` + # removes the ambiguity: the ONLY source this install can see is the + # local artifact directory. + - name: Isolated NuGet.config — the packed artifact is the ONLY visible source + run: | + cat > "$RUNNER_TEMP/isolated-nuget.config" < + + + + + + + EOF + - name: dotnet tool install --global from the packed artifact (isolated source) + run: dotnet tool install --global OwnSharp.Cli --version "${{ needs.build-test-pack.outputs.version }}" --configfile "$RUNNER_TEMP/isolated-nuget.config" + - name: A minimal leak, in a scratch dir with no Own.NET checkout anywhere + run: | + mkdir -p "$RUNNER_TEMP/sample" + cat > "$RUNNER_TEMP/sample/Leak.cs" <<'EOF' + using System.IO; + public class Leaky + { + public void Run() + { + var s = new MemoryStream(); + s.WriteByte(1); + } + } + EOF + - name: ownsharp --version reports the released version + run: | + out=$(ownsharp --version) + [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ + || { echo "FAIL: ownsharp --version printed '$out', expected '${{ needs.build-test-pack.outputs.version }}'"; exit 1; } + - name: ownsharp check finds the leak (--fail-on-finding exits 1, OWN001 present) + run: | + set +e + out=$(ownsharp check "$RUNNER_TEMP/sample" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1, got $rc"; exit 1; } + echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 in the output"; exit 1; } + - name: ownsharp check on clean code exits 0 + run: | + mkdir -p "$RUNNER_TEMP/clean" + cat > "$RUNNER_TEMP/clean/Clean.cs" <<'EOF' + using System.IO; + public class Tidy + { + public void Run() + { + using var s = new MemoryStream(); + s.WriteByte(1); + } + } + EOF + set +e + out=$(ownsharp check "$RUNNER_TEMP/clean" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 0 ] || { echo "FAIL: expected exit 0 on clean code, got $rc"; exit 1; } + - name: No Python found -> fast actionable failure (never an auto-download) + run: | + set +e + out=$(OWN_PYTHON=/definitely/does/not/exist/python3 ownsharp check "$RUNNER_TEMP/sample" 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 3 ] || { echo "FAIL: expected exit 3 (Python not found), got $rc"; exit 1; } + echo "$out" | grep -qi "OWN_PYTHON" || { echo "FAIL: expected the OWN_PYTHON-specific message"; exit 1; } + - name: Reinstall/update behavior — uninstall, then reinstall clean + run: | + dotnet tool uninstall --global OwnSharp.Cli + if command -v ownsharp >/dev/null 2>&1; then + echo "FAIL: ownsharp still on PATH after uninstall"; exit 1 + fi + dotnet tool install --global OwnSharp.Cli --version "${{ needs.build-test-pack.outputs.version }}" --configfile "$RUNNER_TEMP/isolated-nuget.config" + out=$(ownsharp --version) + [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ + || { echo "FAIL: reinstalled ownsharp --version printed '$out'"; exit 1; } + echo "OK: uninstall -> reinstall reproduces a working install" + + publish: + name: publish to nuget.org (protected) + needs: [build-test-pack, smoke-test] + # Both conditions are required (Codex review, PR #244): `github.ref` alone + # is not proof of a tag PUSH — `gh workflow run --ref ownsharp-cli-v0.1.0` + # (a workflow_dispatch) sets github.ref to that same tag ref, which would + # satisfy a bare startsWith() check and let a manual dispatch reach + # publish after smoke/environment approval, contradicting the safety + # gate this workflow documents ("a workflow_dispatch run can never reach + # publish"). Requiring event_name == 'push' closes that hole. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ownsharp-cli-v') + runs-on: ubuntu-latest + environment: nuget-release + steps: + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ownsharp-cli-nupkg + path: ${{ runner.temp }}/nupkg + - name: dotnet nuget push (secret never echoed; Actions also masks it in logs) + run: | + nupkg=$(ls "$RUNNER_TEMP"/nupkg/OwnSharp.Cli.*.nupkg) + dotnet nuget push "$nupkg" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/docs/notes/ownsharp-cli-release.md b/docs/notes/ownsharp-cli-release.md new file mode 100644 index 00000000..1556cf71 --- /dev/null +++ b/docs/notes/ownsharp-cli-release.md @@ -0,0 +1,176 @@ +# OwnSharp.Cli release readiness (P-013 / issue #202, alpha gate A) + +This note is the release-process companion to +[`frontend/roslyn/OwnSharp.Cli/README.md`](../../frontend/roslyn/OwnSharp.Cli/README.md) +(which documents the packaging *shape*). This one documents *how a release +happens*: versioning, the pipeline, what was verified, and the checklist for +whoever actually runs a release. No production package has been published as +part of writing this note — see "Boundaries" at the end. + +## Versioning policy + +**Single source of truth: `` in +`frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj`.** Nothing else computes +or infers a version — no `Directory.Build.props` version, no +`Nerdbank.GitVersioning`, no build-number suffix. `ownsharp --version` +reads it directly (`ToolVersion.cs`, via the assembly's own version, which +MSBuild derives from this ``). + +- **SemVer, pre-1.0 during alpha.** `0.1.0` today. Per alpha-gate discipline + already in `docs/notes/alpha-readiness.md`, a `0.x` version carries no + backward-compatibility promise — this note does not invent one beyond what + the repo already signals. +- **Tag format: `ownsharp-cli-vMAJOR.MINOR.PATCH`** (prefixed, not a bare + `vX.Y.Z` — this repo may eventually version other release surfaces, e.g. + the GitHub Action in Phase 4, under the same tag namespace, and a shared + bare `v*` would collide). +- **The release workflow (`ownsharp-cli-release.yml`) enforces the tag + matches the csproj `` byte-for-byte** before it will pack for + publish — a mismatch fails the build loudly instead of silently shipping + the wrong version under either name. Bump `` in a normal PR + first, merge, *then* tag `main` at that commit. +- No auto-bump, no floating `-preview`/`-ci` suffixes on release builds. + (CI-only smoke packs in `ci.yml`'s `ownsharp-cli-smoke` job use the + as-committed `` too — there is exactly one version number in + play at any time, never a synthetic CI-only one.) + +## Deterministic `dotnet pack` — verified locally + +Added `true` and +`true` +to the csproj. Verified locally (`dotnet 8.0.422`, this repo's exact source, +`main` + this change): ran `dotnet pack` twice back-to-back into separate +output directories and diffed the unzipped contents. + +**Result:** every payload file — `tools/net8.0/any/ownsharp.dll` (the CLI +itself), `tools/net8.0/any/ownsharp-extract.dll` (the bundled extractor), +every bundled `Microsoft.CodeAnalysis*.dll` and satellite resource +assembly, and all vendored `ownlang-core/ownlang/*.py` files — is +**byte-for-byte identical** between the two packs (verified with +`sha256sum` per file, not just eyeballed). + +The only files that differ between the two `.nupkg`s are NuGet's own OPC +package-wrapper metadata: `_rels/.rels` and +`package/services/metadata/core-properties/.psmdcp`. This is +`dotnet pack`/NuGet.Client's own packaging step minting a fresh internal +GUID on every invocation — a property of the `.nupkg` container format +itself, not something `Deterministic`/`ContinuousIntegrationBuild` (which +govern the C# compiler's PE output) can or should suppress. **"Deterministic +pack" in this project means the payload is reproducible from source, not +that the outer `.nupkg` zip is byte-identical** — that distinction is worth +keeping precise, since the latter is not an achievable or meaningful goal +for any `dotnet pack`-produced package. + +## Package metadata — audited + +Added to the csproj: `Authors`, `PackageProjectUrl`, `RepositoryUrl`, +`RepositoryType`, `PackageTags`, `PackageReadmeFile` (packs the existing +`OwnSharp.Cli/README.md` into the package root). `PackageId` (`OwnSharp.Cli`) +and `Description` were already present and accurate. + +**Unresolved blocker: no license.** The repository has no `LICENSE` file +(checked: repo root, and no license section in the root `README.md`). +`PackageLicenseExpression`/`PackageLicenseFile` are deliberately **not** +set — guessing a license is not this note's call to make (repository +convention: "do not choose public compatibility promises beyond what the +repository already supports"). `dotnet pack` does not currently hard-fail +without one, but NuGet.org's own publish UI does require a license +declaration (or an explicit "none" acknowledgment) before an *actual* +publish — **a maintainer must pick a license and add +`PackageLicenseExpression` (or a `LICENSE` file + +`PackageLicenseFile`) before this package can really ship.** Tracked as +the first item in the checklist below. + +## Release pipeline — `.github/workflows/ownsharp-cli-release.yml` + +Three jobs, each gated on the previous succeeding: + +1. **`build-test-pack`** (always runs, on a push of an `ownsharp-cli-v*` tag + or a manual `workflow_dispatch`) — the standard repo gates + (`run_tests.py`, `ruff`, `mypy`), `dotnet build`, the tag/version-match + assertion above (skipped on manual dispatch, since there's no tag), + `dotnet pack`, then **inspects the packed `.nupkg` contents** and fails + if the bundled extractor DLL or the vendored `ownlang-core/*.py` files + are missing (catches a packaging regression before it ever reaches a + consumer). Uploads the `.nupkg` as a build artifact. +2. **`smoke-test`** (matrix: `ubuntu-latest` + `windows-latest`) — + downloads *only* the artifact from step 1 (no checkout of this repo at + all on this job), `dotnet tool install --global` from that local feed, + and runs the installed `ownsharp` command from a scratch directory with + no Own.NET source anywhere on the runner. This satisfies the **critical + test rule**: the smoke test executes the installed `.nupkg`, never a + `ProjectReference` or `dotnet run` — a test that accidentally ran the + source checkout would prove only that the source compiles, not that the + package works for an end user. Verifies: `ownsharp --version` reports + the released version; `ownsharp check` on a seeded leak sample exits `1` + with `OWN001` in the output; `ownsharp check` on clean code exits `0`; + `OWN_PYTHON` pointed at a nonexistent interpreter fails fast with exit + `3` and an actionable per-OS hint (never an auto-download); and a full + **uninstall → reinstall** cycle reproduces a working install (reinstall + is the same code path an upgrade takes). +3. **`publish`** — `if: startsWith(github.ref, 'refs/tags/ownsharp-cli-v')`, + so a `workflow_dispatch` run (no matching tag ref) can never reach this + job no matter what inputs are given. Additionally targets the + `nuget-release` GitHub Environment — **a repo admin must configure that + environment with required reviewers under Settings → Environments before + this job can run unattended; it does not exist yet.** Reads + `secrets.NUGET_API_KEY` only as a `dotnet nuget push --api-key` argument + (never `echo`ed; GitHub Actions also redacts any registered secret value + that appears in a log line as defense in depth). + +`ci.yml`'s existing `ownsharp-cli-smoke` job is untouched and keeps proving +the packaging shape on every push/PR (fast feedback); this workflow is the +release-specific path (slower, gated, gives the "did the *actual release +artifact* survive a clean install on both OSes" answer right before +publish). + +## Release checklist + +Run through this, in order, for every release: + +1. **License.** Confirm `PackageLicenseExpression`/`PackageLicenseFile` is + set in the csproj (see "Unresolved blocker" above) — do not proceed + without one. +2. **Version bump.** Bump `` in `OwnSharp.Cli.csproj` in its own PR; + merge to `main`. +3. **Package inspection.** Trigger `ownsharp-cli-release.yml` via + `workflow_dispatch` first (no tag yet) — confirms `build-test-pack`'s + content-inspection step and both `smoke-test` legs pass *before* a real + tag exists. Download the `ownsharp-cli-nupkg` artifact and manually spot + check `dotnet nuget verify` / the `.nuspec` metadata if this is the + first release or metadata changed. +4. **Install test (both OSes).** Confirmed by the `smoke-test` matrix job + above — do not skip re-running it right before tagging if any code + changed since the last `workflow_dispatch` run. +5. **Version check.** Tag `main` at the merged bump commit: + `git tag ownsharp-cli-v && git push origin ownsharp-cli-v`. + The pushed tag re-triggers the full pipeline; `build-test-pack`'s + tag/version-match assertion is the automated form of this check. +6. **Publish.** The `publish` job pauses on the `nuget-release` environment + gate — a maintainer with repo admin rights approves it manually in the + Actions UI. This is the one step this note's author (an agent session) + is explicitly barred from performing or automating past — see + "Boundaries". +7. **Post-publish smoke test.** *After* a real publish, install from the + **real** feed on a clean machine — `dotnet tool install --global + OwnSharp.Cli` with no `--add-source` at all (default nuget.org source) — + and rerun the same `ownsharp check` smoke scenario as step 4. This is + the one check nothing in CI can do ahead of time, since it depends on + nuget.org actually serving the package after indexing (which is not + instantaneous). + +## Boundaries honored in this work + +- No package was published to nuget.org. +- No personal API key was requested, stored, or referenced by value — + the workflow reads `secrets.NUGET_API_KEY` as a repository secret name + only; nothing about its value is known to or handled outside GitHub's + own secret store. +- No public compatibility promise was chosen beyond what the repo already + states (`0.x`, alpha gate A) — no `1.0` claim, no support-window promise. +- No analyzer semantics changed — every change in this batch is packaging + metadata, build-determinism properties, or CI/release workflow YAML. +- No runtime dependency was bundled or silently introduced — the CLI still + bundles only the unmodified extractor + vendored core exactly as + `frontend/roslyn/OwnSharp.Cli/README.md` already documented; this work + only adds inspection *of* that existing bundle, not new bundled content. diff --git a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj index da73dbbe..ab79bc3b 100644 --- a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -31,12 +31,36 @@ true ownsharp OwnSharp.Cli + 0.1.0 Own.NET's single command: `ownsharp check <path|.sln>` wraps the Roslyn extractor and the Python core (run on system Python) into one dotnet tool install. + PhysShell + https://github.com/PhysShell/Own.NET + https://github.com/PhysShell/Own.NET + git + roslyn;static-analysis;dispose;memory-leak;wpf;lifetime + README.md + + + + true + true + diff --git a/frontend/roslyn/OwnSharp.Cli/README.md b/frontend/roslyn/OwnSharp.Cli/README.md index 7c1deaf2..5c1d1b40 100644 --- a/frontend/roslyn/OwnSharp.Cli/README.md +++ b/frontend/roslyn/OwnSharp.Cli/README.md @@ -75,6 +75,15 @@ no usable Python was found. working exactly as before — this tool is a third surface alongside them, not a replacement (P-013 §Scope). +## Release process + +Versioning policy, the release pipeline (`.github/workflows/ownsharp-cli-release.yml`), +the deterministic-pack verification, package-metadata audit, and the release +checklist live in +[`docs/notes/ownsharp-cli-release.md`](../../../docs/notes/ownsharp-cli-release.md) — +not published to nuget.org yet; that note tracks exactly what's still needed +(a license, a maintainer-approved publish) before it can be. + ## CI proof `ownsharp-cli-smoke` in `.github/workflows/ci.yml` (matrix: `ubuntu-latest` +