feat(bridge): #259 cp5 — the full Finding, the refusal text and the rendered surfaces, proven #76
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| # This job runs the whole Python suite below, which includes | |
| # tests/test_checkpoint_status.py: it verifies that a recorded | |
| # mutation campaign names a commit that exists and is an ancestor of | |
| # HEAD, and a depth-1 checkout cannot answer that. ci.yml's tests job | |
| # takes the history for the same reason. | |
| fetch-depth: 0 | |
| - 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 | |
| # <Version> 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 '(?<=<Version>)[^<]+' "$CLI_PROJECT") | |
| [ -n "$v" ] || { echo "FAIL: could not read <Version> 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 <Version> 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 `<clear/>` | |
| # 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 | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <configuration> | |
| <packageSources> | |
| <clear /> | |
| <add key="local-artifact" value="$RUNNER_TEMP/nupkg" /> | |
| </packageSources> | |
| </configuration> | |
| 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 |