From 9c79c19d63dfd056e50906174e0b8457b3258b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:28:07 +0000 Subject: [PATCH 1/4] feat(owen-cli): release-readiness pipeline, rebuilt against the Owen facade (P-013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild of PR #244's release-prep work directly against post-#246 main, using the Owen public identity throughout instead of the pre-rebrand ownsharp/OwnSharp.Cli names PR #244 used (per review: do not merge the old-brand version, rebuild it against the merged facade). Package metadata (OwnSharp.Cli.csproj) — added Authors, PackageProjectUrl, RepositoryUrl, RepositoryType, PackageTags, PackageReadmeFile, and deterministic-pack properties (Deterministic, ContinuousIntegrationBuild) on top of #246's already-Owen-branded PackageId/ToolCommandName/Description -- these are additive metadata, not a rebrand of what #246 already set. PackageLicenseExpression is deliberately left unset: the repository has no LICENSE file, and choosing one is the repository owner's call, not this session's -- flagged as the first blocker in the release checklist, not resolved unilaterally. New release workflow (.github/workflows/owen-cli-release.yml, replacing PR #244's ownsharp-cli-release.yml under the Owen tag namespace owen-cli-v*): build-test-pack -> smoke-test (ubuntu + windows) -> publish. The smoke-test job installs and exercises the already-packed Owen.Cli artifact from build-test-pack's upload -- no checkout, no rebuild, no invocation of the pre-rebrand `ownsharp` command -- verifying `owen --version`, a leak sample (exit 1, OWN001), a clean sample (exit 0), the OWEN_PYTHON-not-found actionable-failure path (exit 3), and a full uninstall/reinstall cycle. Publish still requires both a pushed owen-cli-v* tag (workflow_dispatch can never reach it) and a `nuget-release` GitHub Environment a repo admin has to configure with required reviewers -- it doesn't exist yet. Internal filenames (ownsharp.dll, ownsharp-extract.dll -- the real, unmodified AssemblyName-derived output, per #246's established internal/ public split) are intentionally left as-is in the package-content inspection step; only the public package ID, command name, tag namespace, and env var are Owen-branded. docs/notes/owen-cli-release.md (replacing PR #244's ownsharp-cli-release.md) documents the versioning policy, the verified determinism findings (re-verified against this exact source: two packs, byte-for-byte identical payload, only NuGet's own per-pack OPC wrapper GUID differs), the metadata audit, and the release checklist -- including the license blocker addressed explicitly to the repository owner. Every step in the new workflow was manually replicated and verified locally before being encoded: dotnet pack (twice, for determinism) -> diff unzipped payload -> package-content inspection -> isolated-feed install -> owen --version / leak / clean / OWEN_PYTHON-not-found / uninstall-reinstall, all matching what the workflow asserts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw --- .github/workflows/owen-cli-release.yml | 261 ++++++++++++++++++ docs/notes/owen-cli-release.md | 193 +++++++++++++ .../roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj | 25 ++ frontend/roslyn/OwnSharp.Cli/README.md | 9 + 4 files changed, 488 insertions(+) create mode 100644 .github/workflows/owen-cli-release.yml create mode 100644 docs/notes/owen-cli-release.md diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml new file mode 100644 index 0000000..72461dd --- /dev/null +++ b/.github/workflows/owen-cli-release.yml @@ -0,0 +1,261 @@ +name: Owen.Cli release + +# Release pipeline for the `owen` dotnet tool (public package Owen.Cli, +# internal project OwnSharp.Cli — public facade rebrand, PR #246; 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/owen-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 +# `owen-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: + - "owen-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/owen-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/owen-cli-v') + run: | + tag_version="${GITHUB_REF_NAME#owen-cli-v}" + csproj_version="${{ steps.version.outputs.version }}" + if [ "$tag_version" != "$csproj_version" ]; then + echo "FAIL: tag owen-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/Owen.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 {} \; + # "ownsharp.dll"/"ownsharp-extract.dll" are the real, unrenamed + # internal filenames (AssemblyName, public facade rebrand PR #246) + # -- what actually ships, not a stale pre-rebrand reference. + 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: owen-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: owen-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/owen-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 Owen.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: owen --version reports the released version + run: | + out=$(owen --version) + [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ + || { echo "FAIL: owen --version printed '$out', expected '${{ needs.build-test-pack.outputs.version }}'"; exit 1; } + - name: owen check finds the leak (--fail-on-finding exits 1, OWN001 present) + run: | + set +e + out=$(owen 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: owen 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=$(owen 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=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen 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 "OWEN_PYTHON" || { echo "FAIL: expected the OWEN_PYTHON-specific message"; exit 1; } + - name: Reinstall/update behavior — uninstall, then reinstall clean + run: | + dotnet tool uninstall --global Owen.Cli + if command -v owen >/dev/null 2>&1; then + echo "FAIL: owen still on PATH after uninstall"; exit 1 + fi + dotnet tool install --global Owen.Cli --version "${{ needs.build-test-pack.outputs.version }}" --configfile "$RUNNER_TEMP/isolated-nuget.config" + out=$(owen --version) + [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ + || { echo "FAIL: reinstalled owen --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 owen-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/owen-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: owen-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/Owen.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/owen-cli-release.md b/docs/notes/owen-cli-release.md new file mode 100644 index 0000000..4a651f7 --- /dev/null +++ b/docs/notes/owen-cli-release.md @@ -0,0 +1,193 @@ +# Owen.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. + +Public identity note: the NuGet package is **`Owen.Cli`**, the command is +**`owen`**, and release tags live under the **`owen-cli-v*`** namespace — the +public facade rebrand, [`docs/notes/owen-public-facade.md`](owen-public-facade.md) +/ PR #246. The underlying project stays `OwnSharp.Cli` internally (not +mass-renamed — that's a deliberate scope boundary, not an oversight); this +note uses the public names throughout since it is entirely about the release +surface a consumer sees. + +## 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. `owen --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: `owen-cli-vMAJOR.MINOR.PATCH`** (prefixed, not a bare + `vX.Y.Z` — that bare namespace belongs to the GitHub Action's own release + tags, `docs/notes/action-marketplace-readiness.md`; a shared bare `v*` + would collide between the two release surfaces this repo ships). +- **The release workflow (`owen-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, +post-#246 `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; internal filename, unchanged by the public facade rebrand), +`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` (`Owen.Cli`) +and `Description` were already set by the public facade rebrand (PR #246) +and are accurate. + +**Unresolved blocker: no license — this is a decision for the repository +owner, not something to resolve here.** The repository has no `LICENSE` +file (checked: repo root, and no license section in the root `README.md`). +`PackageLicenseExpression`/`PackageLicenseFile` are deliberately **not** +set — picking 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, and called out again here so it +cannot be missed. + +## Release pipeline — `.github/workflows/owen-cli-release.yml` + +Three jobs, each gated on the previous succeeding: + +1. **`build-test-pack`** (always runs, on a push of an `owen-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 (`owen-cli-nupkg`). +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 Owen.Cli` from that + local feed, and runs the installed `owen` 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 already-packed + `.nupkg` artifact, never a `ProjectReference`, `dotnet run`, or the old + `ownsharp` command — a test that accidentally ran the source checkout, + or invoked the pre-rebrand command name, would prove only that the + source compiles, not that the *published Owen package* works for an end + user. Verifies: `owen --version` reports the released version; `owen + check` on a seeded leak sample exits `1` with `OWN001` in the output; + `owen check` on clean code exits `0`; `OWEN_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/owen-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 (job key kept, content already +Owen-branded by PR #246) is untouched by this workflow 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. **This step is the repository owner's decision to make.** +2. **Version bump.** Bump `` in `OwnSharp.Cli.csproj` in its own PR; + merge to `main`. +3. **Package inspection.** Trigger `owen-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 `owen-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 owen-cli-v && git push origin owen-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 + Owen.Cli` with no `--add-source` at all (default nuget.org source) — + and rerun the same `owen 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 license was chosen on the repository owner's behalf — flagged as an + open blocker for them to decide, not resolved unilaterally. +- 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 9288075..ea5991d 100644 --- a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -45,12 +45,37 @@ internal" case). --> owen Owen.Cli + 0.1.0 Owen finds lifetime and resource-contract bugs. This distribution currently includes the .NET/C# frontend: `owen 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 2a89829..c8d48c6 100644 --- a/frontend/roslyn/OwnSharp.Cli/README.md +++ b/frontend/roslyn/OwnSharp.Cli/README.md @@ -79,6 +79,15 @@ Exit codes: `0` clean, `1` findings (only with `--fail-on-finding`), `>=2` a core hard error (bad facts, a drifted contract), `3` no usable Python found, `4` no supported input found (nothing matching the included frontend). +## Release process + +Versioning policy, the release pipeline (`.github/workflows/owen-cli-release.yml`), +the deterministic-pack verification, package-metadata audit, and the release +checklist live in +[`docs/notes/owen-cli-release.md`](../../../docs/notes/owen-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` + From 05e79a5fb846f5345213dfb5ea724530cc7f2c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:41:19 +0000 Subject: [PATCH 2/4] owen-cli-release: fail loudly if nuget-release lacks protection rules Codex review (P1): GitHub auto-creates a referenced-but-never-configured environment on first use, with zero protection rules. `environment: nuget-release` alone is therefore not proof a human ever approves this job -- if the environment is never actually set up under Settings -> Environments and NUGET_API_KEY already exists as a repository secret, a tag push could reach `dotnet nuget push` with no approval at all, silently defeating the safety story the workflow documents. The publish job's first step now queries the GitHub API for the environment's protection_rules and refuses to publish -- fails loudly, before the artifact is even downloaded -- when that array is empty, converting "never configured" from a silent bypass into a loud failure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw --- .github/workflows/owen-cli-release.yml | 24 ++++++++++++++++++++++++ docs/notes/owen-cli-release.md | 11 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml index 72461dd..5ea885b 100644 --- a/.github/workflows/owen-cli-release.yml +++ b/.github/workflows/owen-cli-release.yml @@ -244,7 +244,31 @@ jobs: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/owen-cli-v') runs-on: ubuntu-latest environment: nuget-release + permissions: + contents: read steps: + # GitHub auto-creates a REFERENCED-BUT-NEVER-CONFIGURED environment on + # first use, with NO protection rules (Codex review: "referencing a + # missing environment creates it and the newly created environment has + # 'no protection rules or secrets configured'"). `environment: + # nuget-release` above is therefore not itself proof a human ever + # approves this job -- the job-dispatch gate GitHub evaluates BEFORE + # any step runs already let this run through if the environment was + # never actually configured with required reviewers. This step is the + # loud, fail-closed check for that: it runs first, before the artifact + # is even downloaded, and refuses to publish if the environment + # currently has zero protection rules, rather than silently letting an + # unconfigured "protected" environment behave like no gate at all. + - name: Refuse to publish unless nuget-release actually has protection rules + env: + GH_TOKEN: ${{ github.token }} + run: | + rules=$(gh api "repos/${{ github.repository }}/environments/nuget-release" --jq '.protection_rules | length') + if [ "$rules" -eq 0 ]; then + echo "::error::the 'nuget-release' GitHub Environment has no protection rules (required reviewers) configured -- a repo admin must set that up under Settings -> Environments before a tag push can safely reach 'dotnet nuget push'. Refusing to publish." + exit 1 + fi + echo "OK: nuget-release environment has $rules protection rule(s) configured" - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: "8.0.x" diff --git a/docs/notes/owen-cli-release.md b/docs/notes/owen-cli-release.md index 4a651f7..ea29fb4 100644 --- a/docs/notes/owen-cli-release.md +++ b/docs/notes/owen-cli-release.md @@ -131,6 +131,17 @@ Three jobs, each gated on the previous succeeding: `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). + **Correction (Codex review):** GitHub auto-creates a referenced-but- + never-configured environment on first use, with zero protection rules — + `environment: nuget-release` alone is not proof a human ever approves + this job; if the environment is never actually set up and + `NUGET_API_KEY` already exists as a repository secret, a tag push could + reach `dotnet nuget push` with no approval at all, silently defeating + the safety story above. The job's first step now calls the GitHub API + (`gh api repos/.../environments/nuget-release`) and refuses to publish — + fails loudly, before the artifact is even downloaded — unless + `protection_rules` is non-empty. This converts "never configured" from a + silent bypass into a loud failure. `ci.yml`'s existing `ownsharp-cli-smoke` job (job key kept, content already Owen-branded by PR #246) is untouched by this workflow and keeps proving the From 76cdf6212c06a5d8235683b65d7a5645ce79b00f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:27:02 +0000 Subject: [PATCH 3/4] owen-cli-release: exercise the workflow on PRs, require actual reviewers Two independent review findings, both closed: 1. owen-cli-release.yml only triggered on tag push / workflow_dispatch, so a green PR check never actually ran build-test-pack -> smoke-test on either OS -- only a real tag push did, and PRs never push tags. Added a pull_request trigger scoped to the paths that matter (the CLI/extractor projects, ownlang, the workflow file itself, the shared protection script). The publish job's existing tag-push condition is unchanged, so it stays skipped on pull_request exactly as it already was on workflow_dispatch. 2. The environment-protection check (`.protection_rules | length`) accepted ANY protection rule -- a wait_timer-only or branch_policy-only environment, or a required_reviewers rule saved with zero reviewers, none of which actually gate on a human. Extracted the real predicate into scripts/check_environment_protection.sh: accepts only a required_reviewers rule with >=1 reviewer. The publish job now checks out just scripts/ (sparse), fetches the environment via `gh api` into a file, and calls the script. Its permissions gained `actions: read`, which the environment-read endpoint requires alongside `contents: read`. Fixture-driven, offline test in ci.yml's new environment-protection-selftest job (runs on every ordinary push/PR, no GitHub API or real Environment needed): zero rules, wait_timer-only, branch_policy-only, and required_reviewers-with-zero-reviewers all reject; required_reviewers with one reviewer accepts. The same script and fixtures will also back action-marketplace-readiness.yml's analogous move-major-tag check (PR #248, stacked on this branch for now -- duplication is temporary until #247 merges and #248 rebases cleanly onto main). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw --- .github/workflows/ci.yml | 38 +++++++++++++ .github/workflows/owen-cli-release.yml | 50 +++++++++++++---- docs/notes/owen-cli-release.md | 53 ++++++++++++++++++- scripts/check_environment_protection.sh | 34 ++++++++++++ .../branch-policy-only.json | 9 ++++ .../required-reviewers-empty.json | 11 ++++ .../required-reviewers-with-reviewer.json | 19 +++++++ .../wait-timer-only.json | 10 ++++ .../environment-protection/zero-rules.json | 3 ++ 9 files changed, 214 insertions(+), 13 deletions(-) create mode 100755 scripts/check_environment_protection.sh create mode 100644 scripts/fixtures/environment-protection/branch-policy-only.json create mode 100644 scripts/fixtures/environment-protection/required-reviewers-empty.json create mode 100644 scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json create mode 100644 scripts/fixtures/environment-protection/wait-timer-only.json create mode 100644 scripts/fixtures/environment-protection/zero-rules.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6d13f7..9f6a73f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,44 @@ jobs: python audit/static/run_static.py --selftest python audit/runtime/ingest.py --selftest + environment-protection-selftest: + name: release-workflow environment-protection predicate (fixture-driven) + runs-on: ubuntu-latest + # No GitHub API call, no real Environment needed here -- this tests only + # the accept/reject PREDICATE the owen-cli-release.yml `publish` job and + # action-marketplace-readiness.yml `move-major-tag` job both call + # (scripts/check_environment_protection.sh) against fixture "Get an + # environment" API responses, entirely offline. Review: a bare + # `.protection_rules | length` check would have accepted a wait_timer- + # or branch_policy-only environment, or a required_reviewers rule with + # zero actual reviewers, as if it were a real human-approval gate. + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: zero protection rules -> reject + run: | + if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/zero-rules.json; then + echo "FAIL: expected rejection (zero rules)"; exit 1 + fi + - name: wait_timer only -> reject + run: | + if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/wait-timer-only.json; then + echo "FAIL: expected rejection (wait_timer only)"; exit 1 + fi + - name: branch_policy only -> reject + run: | + if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/branch-policy-only.json; then + echo "FAIL: expected rejection (branch_policy only)"; exit 1 + fi + - name: required_reviewers with zero users -> reject + run: | + if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-empty.json; then + echo "FAIL: expected rejection (required_reviewers, zero reviewers)"; exit 1 + fi + - name: required_reviewers with a reviewer -> accept + run: | + ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json \ + || { echo "FAIL: expected acceptance (required_reviewers with a reviewer)"; exit 1; } + tests: name: tests (py${{ matrix.python-version }}) runs-on: ubuntu-latest diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml index 5ea885b..9093a5b 100644 --- a/.github/workflows/owen-cli-release.yml +++ b/.github/workflows/owen-cli-release.yml @@ -12,10 +12,16 @@ name: Owen.Cli release # Publish safety (two independent gates, both required): # 1. `publish` only runs when the trigger is a pushed tag matching # `owen-cli-v*` — a `workflow_dispatch` run (no such tag ref) can -# build/test/pack/smoke-test but can never reach the publish job. +# build/test/pack/smoke-test but can never reach the publish job. A +# `pull_request` run (below) has neither a tag ref nor `push` as its +# event_name, so the same condition also skips it there. # 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. +# before this can ever run unattended — see the release checklist. The +# job additionally self-checks that the environment actually has a +# required_reviewers rule (scripts/check_environment_protection.sh) +# before doing anything else, since GitHub auto-creates a referenced- +# but-never-configured environment with zero protection rules. # 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. @@ -27,6 +33,18 @@ on: push: tags: - "owen-cli-v*" + # Review: the green PR check on this workflow's own PRs never actually ran + # build-test-pack -> smoke-test (ubuntu + windows) -- only the tag-push + # trigger did, and no tag is ever pushed from a PR. `publish` stays fully + # skipped on a pull_request event (gate 1 above), so this only exercises + # the pack/inspect/install/smoke path, never the publish path. + pull_request: + paths: + - "frontend/roslyn/OwnSharp.Cli/**" + - "frontend/roslyn/OwnSharp.Extractor/**" + - "ownlang/**" + - ".github/workflows/owen-cli-release.yml" + - "scripts/check_environment_protection.sh" workflow_dispatch: {} env: @@ -246,7 +264,17 @@ jobs: environment: nuget-release permissions: contents: read + # Required to call GET /repos/.../environments/{name} below (Codex + # review: "the environment-read API requires Actions read permission + # for fine-grained repository tokens") -- contents:read alone is not + # enough for that specific endpoint. + actions: read steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + sparse-checkout: | + scripts # GitHub auto-creates a REFERENCED-BUT-NEVER-CONFIGURED environment on # first use, with NO protection rules (Codex review: "referencing a # missing environment creates it and the newly created environment has @@ -256,19 +284,19 @@ jobs: # any step runs already let this run through if the environment was # never actually configured with required reviewers. This step is the # loud, fail-closed check for that: it runs first, before the artifact - # is even downloaded, and refuses to publish if the environment - # currently has zero protection rules, rather than silently letting an - # unconfigured "protected" environment behave like no gate at all. + # is even downloaded, and refuses to publish unless the environment + # has a REQUIRED_REVIEWERS rule with at least one reviewer (Codex + # review: a bare protection_rules count also accepts a wait_timer- or + # branch_policy-only environment, neither of which waits for a + # human) -- scripts/check_environment_protection.sh is the single + # source of truth for that predicate, fixture-tested in ci.yml. - name: Refuse to publish unless nuget-release actually has protection rules env: GH_TOKEN: ${{ github.token }} run: | - rules=$(gh api "repos/${{ github.repository }}/environments/nuget-release" --jq '.protection_rules | length') - if [ "$rules" -eq 0 ]; then - echo "::error::the 'nuget-release' GitHub Environment has no protection rules (required reviewers) configured -- a repo admin must set that up under Settings -> Environments before a tag push can safely reach 'dotnet nuget push'. Refusing to publish." - exit 1 - fi - echo "OK: nuget-release environment has $rules protection rule(s) configured" + gh api "repos/${{ github.repository }}/environments/nuget-release" > "$RUNNER_TEMP/nuget-release-env.json" + ./scripts/check_environment_protection.sh "$RUNNER_TEMP/nuget-release-env.json" \ + || { echo "::error::the 'nuget-release' GitHub Environment does not have a required_reviewers rule with at least one reviewer -- a repo admin must set that up under Settings -> Environments before a tag push can safely reach 'dotnet nuget push'. Refusing to publish."; exit 1; } - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: "8.0.x" diff --git a/docs/notes/owen-cli-release.md b/docs/notes/owen-cli-release.md index ea29fb4..348767b 100644 --- a/docs/notes/owen-cli-release.md +++ b/docs/notes/owen-cli-release.md @@ -97,8 +97,16 @@ cannot be missed. Three jobs, each gated on the previous succeeding: -1. **`build-test-pack`** (always runs, on a push of an `owen-cli-v*` tag - or a manual `workflow_dispatch`) — the standard repo gates +1. **`build-test-pack`** (runs on a push of an `owen-cli-v*` tag, a manual + `workflow_dispatch`, **or a pull request** touching + `frontend/roslyn/OwnSharp.Cli/**`, `frontend/roslyn/OwnSharp.Extractor/**`, + `ownlang/**`, this workflow file, or the environment-protection script — + **correction (review):** the `pull_request` trigger was missing entirely + at first, so a green PR check on *this workflow's own PRs* never actually + ran `build-test-pack` → `smoke-test` — only a real tag push did, and no + PR ever pushes a tag. The `publish` job's existing tag-push condition + (item 3 below) is untouched, so it stays skipped on a `pull_request` + event exactly as it already was on `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 @@ -142,6 +150,18 @@ Three jobs, each gated on the previous succeeding: fails loudly, before the artifact is even downloaded — unless `protection_rules` is non-empty. This converts "never configured" from a silent bypass into a loud failure. + **Second correction (review):** a bare `protection_rules` count is too + permissive — GitHub's `protection_rules` array can also hold `wait_timer` + and `branch_policy` rules, neither of which waits for a human, and a + `required_reviewers` rule can itself be saved with zero reviewers (also + not a real gate). The check now calls + `scripts/check_environment_protection.sh` — a small, shared, fixture- + tested predicate (also used by `action-marketplace-readiness.yml`'s + `move-major-tag` job) that only accepts a `required_reviewers` rule with + at least one actual reviewer. The job's `permissions:` also gained + `actions: read`, which the environment-read endpoint requires alongside + `contents: read`. See "Testing the environment-protection predicate" + below. `ci.yml`'s existing `ownsharp-cli-smoke` job (job key kept, content already Owen-branded by PR #246) is untouched by this workflow and keeps proving the @@ -150,6 +170,35 @@ release-specific path (slower, gated, gives the "did the *actual release artifact* survive a clean install on both OSes" answer right before publish). +## Testing the environment-protection predicate + +`scripts/check_environment_protection.sh ` is the +single source of truth both release workflows' environment-gate checks call +(`owen-cli-release.yml`'s `publish` job here; `action-marketplace-readiness.yml`'s +`move-major-tag` job for the Action's own `action-major-tag-move` +environment). It takes a GitHub "Get an environment" API response and exits +0 only if `protection_rules` contains a `required_reviewers` rule with at +least one reviewer — rejecting a `wait_timer`-only or `branch_policy`-only +environment, and rejecting a `required_reviewers` rule saved with zero +reviewers, both of which a bare `protection_rules | length` check would +have wrongly accepted as "protected." + +Tested entirely offline, no GitHub API call and no real Environment needed: +`ci.yml`'s `environment-protection-selftest` job runs the script against +five fixtures under `scripts/fixtures/environment-protection/` and asserts +the expected accept/reject outcome for each — + +| Fixture | Expected | +|---|---| +| `zero-rules.json` | reject | +| `wait-timer-only.json` | reject | +| `branch-policy-only.json` | reject | +| `required-reviewers-empty.json` | reject | +| `required-reviewers-with-reviewer.json` | accept | + +— so this predicate is exercised on every ordinary push/PR, not only when a +real release or major-tag move actually runs. + ## Release checklist Run through this, in order, for every release: diff --git a/scripts/check_environment_protection.sh b/scripts/check_environment_protection.sh new file mode 100755 index 0000000..6309a2b --- /dev/null +++ b/scripts/check_environment_protection.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Decides whether a GitHub Environment's "Get an environment" API response +# proves an actual human-reviewer gate is configured -- the ONLY protection +# rule type that means "this job pauses for a person to approve it". +# +# GitHub's `protection_rules` array can hold three rule types (wait_timer, +# required_reviewers, branch_policy); a bare `.protection_rules | length` +# check accepts any of them, including a wait_timer-only or +# branch_policy-only environment that never actually waits for a human +# (review: two release workflows -- owen-cli-release.yml's `publish` job, +# action-marketplace-readiness.yml's `move-major-tag` job -- both once made +# this mistake). A `required_reviewers` rule with an EMPTY `reviewers` array +# is likewise not a real gate -- GitHub allows saving one, and it approves +# nothing. +# +# Usage: check_environment_protection.sh +# Exit 0 (ACCEPT) only if at least one required_reviewers rule has >=1 +# reviewer. Exit 1 (REJECT) for every other case, with a reason on stderr. +set -euo pipefail + +file="${1:?usage: check_environment_protection.sh }" + +count=$(jq '[ + .protection_rules[]? + | select(.type == "required_reviewers") + | select((.reviewers // []) | length > 0) +] | length' "$file") + +if [ "$count" -eq 0 ]; then + echo "REJECT: no required_reviewers protection rule with at least one reviewer found" >&2 + exit 1 +fi + +echo "ACCEPT: $count required_reviewers rule(s) with at least one reviewer configured" diff --git a/scripts/fixtures/environment-protection/branch-policy-only.json b/scripts/fixtures/environment-protection/branch-policy-only.json new file mode 100644 index 0000000..aaefc72 --- /dev/null +++ b/scripts/fixtures/environment-protection/branch-policy-only.json @@ -0,0 +1,9 @@ +{ + "protection_rules": [ + { + "id": 2, + "node_id": "MDQ6R2F0ZTI=", + "type": "branch_policy" + } + ] +} diff --git a/scripts/fixtures/environment-protection/required-reviewers-empty.json b/scripts/fixtures/environment-protection/required-reviewers-empty.json new file mode 100644 index 0000000..3b6f58c --- /dev/null +++ b/scripts/fixtures/environment-protection/required-reviewers-empty.json @@ -0,0 +1,11 @@ +{ + "protection_rules": [ + { + "id": 3, + "node_id": "MDQ6R2F0ZTM=", + "type": "required_reviewers", + "prevent_self_review": true, + "reviewers": [] + } + ] +} diff --git a/scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json b/scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json new file mode 100644 index 0000000..becaa4c --- /dev/null +++ b/scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json @@ -0,0 +1,19 @@ +{ + "protection_rules": [ + { + "id": 4, + "node_id": "MDQ6R2F0ZTQ=", + "type": "required_reviewers", + "prevent_self_review": true, + "reviewers": [ + { + "type": "User", + "reviewer": { + "login": "octocat", + "id": 1 + } + } + ] + } + ] +} diff --git a/scripts/fixtures/environment-protection/wait-timer-only.json b/scripts/fixtures/environment-protection/wait-timer-only.json new file mode 100644 index 0000000..d7f08c3 --- /dev/null +++ b/scripts/fixtures/environment-protection/wait-timer-only.json @@ -0,0 +1,10 @@ +{ + "protection_rules": [ + { + "id": 1, + "node_id": "MDQ6R2F0ZTE=", + "type": "wait_timer", + "wait_timer": 30 + } + ] +} diff --git a/scripts/fixtures/environment-protection/zero-rules.json b/scripts/fixtures/environment-protection/zero-rules.json new file mode 100644 index 0000000..50a33f6 --- /dev/null +++ b/scripts/fixtures/environment-protection/zero-rules.json @@ -0,0 +1,3 @@ +{ + "protection_rules": [] +} From 5559872658666ad16df402e76e2a3b9c7218e1bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:39:33 +0000 Subject: [PATCH 4/4] owen-cli-release: fix doc drift, close two script-injection nitpicks CodeRabbit review: - docs/notes/owen-cli-release.md quoted the publish gate as just `startsWith(github.ref, 'refs/tags/owen-cli-v')`, dropping the `github.event_name == 'push'` half the workflow's own comment explains is required to block a `workflow_dispatch --ref owen-cli-vX` bypass -- the exact scenario the doc's next sentence claimed was already impossible. Quoted the real condition in full. - `${{ steps.version.outputs.version }}` (grep'd from the csproj, which this workflow now also builds on pull_request) and `${{ needs.build-test-pack.outputs.version }}` were both inlined directly into run: scripts at five sites -- the same expression-into-shell-text pattern zizmor flags elsewhere in this repo. Both now flow through env: (a job-level OWEN_CLI_VERSION for smoke-test's four sites, a step-level CSPROJ_VERSION for the tag/version-match assertion) instead of raw interpolation. Not implemented: CodeRabbit's NuGet Trusted Publishing (OIDC) suggestion for the publish step. Real publication is already blocked on the license and Environment decisions the docs already flag as the repository owner's call, not this session's; swapping the auth mechanism before either of those is resolved is the same category of decision, not a code fix. Re-verified locally end-to-end after the env: refactor: pack, isolated-feed install, `owen --version`, and the full uninstall/reinstall cycle all still pass with the version flowing through the environment instead of inline interpolation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw --- .github/workflows/owen-cli-release.yml | 25 +++++++++++++++++++------ docs/notes/owen-cli-release.md | 10 +++++++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml index 9093a5b..76b4f49 100644 --- a/.github/workflows/owen-cli-release.yml +++ b/.github/workflows/owen-cli-release.yml @@ -90,9 +90,16 @@ jobs: 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/owen-cli-v') + # env: indirection (CodeRabbit review), not `csproj_version="${{ ... }}"` + # inlined into the script -- this workflow also triggers on pull_request + # for paths that include the csproj this value is grep'd from, so a + # version string containing shell metacharacters must never be + # re-parsed as script text by bash. + env: + CSPROJ_VERSION: ${{ steps.version.outputs.version }} run: | tag_version="${GITHUB_REF_NAME#owen-cli-v}" - csproj_version="${{ steps.version.outputs.version }}" + csproj_version="$CSPROJ_VERSION" if [ "$tag_version" != "$csproj_version" ]; then echo "FAIL: tag owen-cli-v$tag_version does not match csproj Version $csproj_version" echo "Bump in $CLI_PROJECT to match the tag (or retag) before releasing." @@ -138,6 +145,12 @@ jobs: defaults: run: shell: bash + # env: indirection (CodeRabbit review), not `${{ needs.build-test-pack.outputs.version }}` + # inlined directly into each run: script -- a job-level env var reads the + # same way everywhere below without bash ever re-parsing the expression + # as script text. + env: + OWEN_CLI_VERSION: ${{ needs.build-test-pack.outputs.version }} steps: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: @@ -179,7 +192,7 @@ jobs: EOF - name: dotnet tool install --global from the packed artifact (isolated source) - run: dotnet tool install --global Owen.Cli --version "${{ needs.build-test-pack.outputs.version }}" --configfile "$RUNNER_TEMP/isolated-nuget.config" + run: dotnet tool install --global Owen.Cli --version "$OWEN_CLI_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" @@ -197,8 +210,8 @@ jobs: - name: owen --version reports the released version run: | out=$(owen --version) - [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ - || { echo "FAIL: owen --version printed '$out', expected '${{ needs.build-test-pack.outputs.version }}'"; exit 1; } + [ "$out" = "$OWEN_CLI_VERSION" ] \ + || { echo "FAIL: owen --version printed '$out', expected '$OWEN_CLI_VERSION'"; exit 1; } - name: owen check finds the leak (--fail-on-finding exits 1, OWN001 present) run: | set +e @@ -243,9 +256,9 @@ jobs: if command -v owen >/dev/null 2>&1; then echo "FAIL: owen still on PATH after uninstall"; exit 1 fi - dotnet tool install --global Owen.Cli --version "${{ needs.build-test-pack.outputs.version }}" --configfile "$RUNNER_TEMP/isolated-nuget.config" + dotnet tool install --global Owen.Cli --version "$OWEN_CLI_VERSION" --configfile "$RUNNER_TEMP/isolated-nuget.config" out=$(owen --version) - [ "$out" = "${{ needs.build-test-pack.outputs.version }}" ] \ + [ "$out" = "$OWEN_CLI_VERSION" ] \ || { echo "FAIL: reinstalled owen --version printed '$out'"; exit 1; } echo "OK: uninstall -> reinstall reproduces a working install" diff --git a/docs/notes/owen-cli-release.md b/docs/notes/owen-cli-release.md index 348767b..c9a2c8e 100644 --- a/docs/notes/owen-cli-release.md +++ b/docs/notes/owen-cli-release.md @@ -130,9 +130,13 @@ Three jobs, each gated on the previous succeeding: 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/owen-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 +3. **`publish`** — `if: github.event_name == 'push' && startsWith(github.ref, + 'refs/tags/owen-cli-v')`, so neither a `workflow_dispatch` run nor a + `pull_request` run can ever reach this job no matter what inputs/ref are + given — a bare `startsWith(github.ref, ...)` alone would let + `workflow_dispatch --ref owen-cli-v0.1.0` through, since a manual + dispatch can be pointed at an existing tag ref too (Codex review, PR + #244). 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