-
Notifications
You must be signed in to change notification settings - Fork 0
457 lines (444 loc) · 22.5 KB
/
Copy pathowen-cli-release.yml
File metadata and controls
457 lines (444 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
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:
# P-022 Stage 3 (#262), D6: the Rust core ships INSIDE the package, so it has
# to be built before the package is made, and built for every platform the
# package supports.
#
# Natively, on each platform's own runner, rather than cross-compiled from
# one. Cross-compiling would be cheaper and would prove less: the binary a
# Windows user runs would then be one no Windows machine had ever executed,
# and #262's whole distribution section exists because "it built" and "it
# works where it lands" are different claims. The platform key each leg
# stages under is the one RustCoreLocator.PlatformKey() computes at runtime,
# so the two sides cannot drift into disagreeing about where the binary is.
build-rust-core:
name: build own-cli (${{ matrix.platform_key }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform_key: linux-x64
binary: own-cli
- os: windows-latest
platform_key: win-x64
binary: own-cli.exe
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
with:
toolchain: stable
- name: cargo build -p own-cli --release
working-directory: rust
run: cargo build -p own-cli --release
# The binary is exercised HERE, on the machine that built it, before it is
# allowed anywhere near a package. A candidate that cannot answer
# --version is not one to ship, and finding that out at pack time beats
# finding it out from a user.
- name: The built candidate runs on its own platform
run: |
set -euo pipefail
"rust/target/release/${{ matrix.binary }}" --version
- name: Stage it under the platform key the locator computes
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/rust-stage/${{ matrix.platform_key }}"
cp "rust/target/release/${{ matrix.binary }}" \
"$RUNNER_TEMP/rust-stage/${{ matrix.platform_key }}/${{ matrix.binary }}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: own-cli-${{ matrix.platform_key }}
path: ${{ runner.temp }}/rust-stage/${{ matrix.platform_key }}/${{ matrix.binary }}
retention-days: 14
if-no-files-found: error
build-test-pack:
name: build + test + pack
runs-on: ubuntu-latest
needs: build-rust-core
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)"
# D6: collect every platform's candidate, then pack with them.
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: own-cli-*
path: ${{ runner.temp }}/rust-stage
- name: Stage the Rust cores for packing
run: |
set -euo pipefail
# download-artifact lands each artifact in a directory named after
# itself (own-cli-linux-x64/own-cli), and the locator looks under the
# PLATFORM KEY (linux-x64/own-cli). Rename rather than reshape the
# artifact names: the platform key is the contract, and the artifact
# name is just how CI moves bytes between jobs.
cd "$RUNNER_TEMP/rust-stage"
for d in own-cli-*; do
mv "$d" "${d#own-cli-}"
done
chmod +x linux-x64/own-cli
ls -laR .
# Both platforms, named. A pack that quietly lost a leg would produce
# a package whose default engine is missing on exactly one OS, and
# the smoke test on the OTHER leg would still be green.
test -f linux-x64/own-cli || { echo "FAIL: no linux-x64 own-cli staged"; exit 1; }
test -f win-x64/own-cli.exe || { echo "FAIL: no win-x64 own-cli.exe staged"; exit 1; }
- name: dotnet pack
run: |
dotnet pack "$CLI_PROJECT" -c Release -o "$RUNNER_TEMP/nupkg" \
-p:OwenRustCoreDir="$RUNNER_TEMP/rust-stage"
- 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; }
# D6 (#262 Stage 3): the engine the package makes DEFAULT must be in
# the package, for every supported platform. Asserted per platform
# and by size: a zero-byte or truncated payload would satisfy a bare
# existence test and fail at the only moment that matters.
rc_root="$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/rust-core"
for leg in "linux-x64/own-cli" "win-x64/own-cli.exe"; do
test -s "$rc_root/$leg" \
|| { echo "FAIL: packed Rust core missing or empty: rust-core/$leg"; exit 1; }
echo " rust-core/$leg: $(wc -c < "$rc_root/$leg") bytes"
done
# And nothing ELSE is under rust-core/: a stray file there is either a
# platform nobody supports or a payload packed twice, and both have
# happened while this was being built.
extra=$(find "$rc_root" -type f | wc -l)
[ "$extra" -eq 2 ] \
|| { echo "FAIL: rust-core/ holds $extra files, expected exactly 2"; \
find "$rc_root" -type f; exit 1; }
echo "OK: package contains the CLI, the bundled extractor, $core_py_count vendored core .py files, and both platforms' Rust core"
- 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; }
# #262 Stage 3: the packed artifact's DEFAULT engine is Rust and carries
# its own binary (D6), so a bare check must work with no usable Python.
- name: "Stage 3: the packed artifact's default engine needs no Python"
run: |
set +e
out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/sample" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: bare owen check with an unusable OWEN_PYTHON exited $rc, expected 1 (findings) — the packed default still needs Python"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 from the default (Rust) engine"; exit 1; }
- name: "Stage 3: --engine python is the tested ROLLBACK, on the same install"
run: |
set +e
out=$(owen check --engine python "$RUNNER_TEMP/sample" --fail-on-finding 2>&1)
rc=$?
set -e
echo "$out"
[ "$rc" -eq 1 ] || { echo "FAIL: the documented rollback exited $rc, expected 1 (findings)"; exit 1; }
echo "$out" | grep -q "OWN001" || { echo "FAIL: the rollback engine produced no finding"; exit 1; }
echo "OK: Python remains selectable and working on the shipped package"
- name: No Python found -> fast actionable failure (never an auto-download)
run: |
set +e
# --engine python is EXPLICIT (#262 Stage 3): a bare invocation now
# runs Rust and never consults OWEN_PYTHON.
out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check --engine python "$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