Skip to content

feat(bridge): #259 cp5 — the full Finding, the refusal text and the rendered surfaces, proven #76

feat(bridge): #259 cp5 — the full Finding, the refusal text and the rendered surfaces, proven

feat(bridge): #259 cp5 — the full Finding, the refusal text and the rendered surfaces, proven #76

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