diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6d13f7c..9f6a73fc 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 new file mode 100644 index 00000000..76b4f494 --- /dev/null +++ b/.github/workflows/owen-cli-release.yml @@ -0,0 +1,326 @@ +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. 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. 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. + +permissions: + contents: read + +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: + 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') + # 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="$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." + 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 + # 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: + 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 "$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" + 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" = "$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 + 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 "$OWEN_CLI_VERSION" --configfile "$RUNNER_TEMP/isolated-nuget.config" + out=$(owen --version) + [ "$out" = "$OWEN_CLI_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 + 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 + # '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 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: | + 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" + - 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 00000000..c9a2c8e4 --- /dev/null +++ b/docs/notes/owen-cli-release.md @@ -0,0 +1,257 @@ +# 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`** (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 + 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: 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 + `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. + **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 +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). + +## 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: + +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 92880756..ea5991d2 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 2a898297..c8d48c6b 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` + diff --git a/scripts/check_environment_protection.sh b/scripts/check_environment_protection.sh new file mode 100755 index 00000000..6309a2b5 --- /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 00000000..aaefc725 --- /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 00000000..3b6f58ca --- /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 00000000..becaa4c6 --- /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 00000000..d7f08c3a --- /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 00000000..50a33f68 --- /dev/null +++ b/scripts/fixtures/environment-protection/zero-rules.json @@ -0,0 +1,3 @@ +{ + "protection_rules": [] +}