Skip to content

Commit 76cdf62

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw
1 parent 05e79a5 commit 76cdf62

9 files changed

Lines changed: 214 additions & 13 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,44 @@ jobs:
9797
python audit/static/run_static.py --selftest
9898
python audit/runtime/ingest.py --selftest
9999
100+
environment-protection-selftest:
101+
name: release-workflow environment-protection predicate (fixture-driven)
102+
runs-on: ubuntu-latest
103+
# No GitHub API call, no real Environment needed here -- this tests only
104+
# the accept/reject PREDICATE the owen-cli-release.yml `publish` job and
105+
# action-marketplace-readiness.yml `move-major-tag` job both call
106+
# (scripts/check_environment_protection.sh) against fixture "Get an
107+
# environment" API responses, entirely offline. Review: a bare
108+
# `.protection_rules | length` check would have accepted a wait_timer-
109+
# or branch_policy-only environment, or a required_reviewers rule with
110+
# zero actual reviewers, as if it were a real human-approval gate.
111+
steps:
112+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
113+
- name: zero protection rules -> reject
114+
run: |
115+
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/zero-rules.json; then
116+
echo "FAIL: expected rejection (zero rules)"; exit 1
117+
fi
118+
- name: wait_timer only -> reject
119+
run: |
120+
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/wait-timer-only.json; then
121+
echo "FAIL: expected rejection (wait_timer only)"; exit 1
122+
fi
123+
- name: branch_policy only -> reject
124+
run: |
125+
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/branch-policy-only.json; then
126+
echo "FAIL: expected rejection (branch_policy only)"; exit 1
127+
fi
128+
- name: required_reviewers with zero users -> reject
129+
run: |
130+
if ./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-empty.json; then
131+
echo "FAIL: expected rejection (required_reviewers, zero reviewers)"; exit 1
132+
fi
133+
- name: required_reviewers with a reviewer -> accept
134+
run: |
135+
./scripts/check_environment_protection.sh scripts/fixtures/environment-protection/required-reviewers-with-reviewer.json \
136+
|| { echo "FAIL: expected acceptance (required_reviewers with a reviewer)"; exit 1; }
137+
100138
tests:
101139
name: tests (py${{ matrix.python-version }})
102140
runs-on: ubuntu-latest

‎.github/workflows/owen-cli-release.yml‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@ name: Owen.Cli release
1212
# Publish safety (two independent gates, both required):
1313
# 1. `publish` only runs when the trigger is a pushed tag matching
1414
# `owen-cli-v*` — a `workflow_dispatch` run (no such tag ref) can
15-
# build/test/pack/smoke-test but can never reach the publish job.
15+
# build/test/pack/smoke-test but can never reach the publish job. A
16+
# `pull_request` run (below) has neither a tag ref nor `push` as its
17+
# event_name, so the same condition also skips it there.
1618
# 2. `publish` targets the `nuget-release` GitHub Environment, which a repo
1719
# admin must configure with required reviewers (Settings -> Environments)
18-
# before this can ever run unattended — see the release checklist.
20+
# before this can ever run unattended — see the release checklist. The
21+
# job additionally self-checks that the environment actually has a
22+
# required_reviewers rule (scripts/check_environment_protection.sh)
23+
# before doing anything else, since GitHub auto-creates a referenced-
24+
# but-never-configured environment with zero protection rules.
1925
# The workflow never echoes secrets.NUGET_API_KEY; `dotnet nuget push` takes
2026
# it as a CLI argument (not printed by the tool) and Actions' own log
2127
# redaction masks any accidental echo of a registered secret value.
@@ -27,6 +33,18 @@ on:
2733
push:
2834
tags:
2935
- "owen-cli-v*"
36+
# Review: the green PR check on this workflow's own PRs never actually ran
37+
# build-test-pack -> smoke-test (ubuntu + windows) -- only the tag-push
38+
# trigger did, and no tag is ever pushed from a PR. `publish` stays fully
39+
# skipped on a pull_request event (gate 1 above), so this only exercises
40+
# the pack/inspect/install/smoke path, never the publish path.
41+
pull_request:
42+
paths:
43+
- "frontend/roslyn/OwnSharp.Cli/**"
44+
- "frontend/roslyn/OwnSharp.Extractor/**"
45+
- "ownlang/**"
46+
- ".github/workflows/owen-cli-release.yml"
47+
- "scripts/check_environment_protection.sh"
3048
workflow_dispatch: {}
3149

3250
env:
@@ -246,7 +264,17 @@ jobs:
246264
environment: nuget-release
247265
permissions:
248266
contents: read
267+
# Required to call GET /repos/.../environments/{name} below (Codex
268+
# review: "the environment-read API requires Actions read permission
269+
# for fine-grained repository tokens") -- contents:read alone is not
270+
# enough for that specific endpoint.
271+
actions: read
249272
steps:
273+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
274+
with:
275+
persist-credentials: false
276+
sparse-checkout: |
277+
scripts
250278
# GitHub auto-creates a REFERENCED-BUT-NEVER-CONFIGURED environment on
251279
# first use, with NO protection rules (Codex review: "referencing a
252280
# missing environment creates it and the newly created environment has
@@ -256,19 +284,19 @@ jobs:
256284
# any step runs already let this run through if the environment was
257285
# never actually configured with required reviewers. This step is the
258286
# loud, fail-closed check for that: it runs first, before the artifact
259-
# is even downloaded, and refuses to publish if the environment
260-
# currently has zero protection rules, rather than silently letting an
261-
# unconfigured "protected" environment behave like no gate at all.
287+
# is even downloaded, and refuses to publish unless the environment
288+
# has a REQUIRED_REVIEWERS rule with at least one reviewer (Codex
289+
# review: a bare protection_rules count also accepts a wait_timer- or
290+
# branch_policy-only environment, neither of which waits for a
291+
# human) -- scripts/check_environment_protection.sh is the single
292+
# source of truth for that predicate, fixture-tested in ci.yml.
262293
- name: Refuse to publish unless nuget-release actually has protection rules
263294
env:
264295
GH_TOKEN: ${{ github.token }}
265296
run: |
266-
rules=$(gh api "repos/${{ github.repository }}/environments/nuget-release" --jq '.protection_rules | length')
267-
if [ "$rules" -eq 0 ]; then
268-
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."
269-
exit 1
270-
fi
271-
echo "OK: nuget-release environment has $rules protection rule(s) configured"
297+
gh api "repos/${{ github.repository }}/environments/nuget-release" > "$RUNNER_TEMP/nuget-release-env.json"
298+
./scripts/check_environment_protection.sh "$RUNNER_TEMP/nuget-release-env.json" \
299+
|| { 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; }
272300
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
273301
with:
274302
dotnet-version: "8.0.x"

‎docs/notes/owen-cli-release.md‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ cannot be missed.
9797

9898
Three jobs, each gated on the previous succeeding:
9999

100-
1. **`build-test-pack`** (always runs, on a push of an `owen-cli-v*` tag
101-
or a manual `workflow_dispatch`) — the standard repo gates
100+
1. **`build-test-pack`** (runs on a push of an `owen-cli-v*` tag, a manual
101+
`workflow_dispatch`, **or a pull request** touching
102+
`frontend/roslyn/OwnSharp.Cli/**`, `frontend/roslyn/OwnSharp.Extractor/**`,
103+
`ownlang/**`, this workflow file, or the environment-protection script —
104+
**correction (review):** the `pull_request` trigger was missing entirely
105+
at first, so a green PR check on *this workflow's own PRs* never actually
106+
ran `build-test-pack` → `smoke-test` — only a real tag push did, and no
107+
PR ever pushes a tag. The `publish` job's existing tag-push condition
108+
(item 3 below) is untouched, so it stays skipped on a `pull_request`
109+
event exactly as it already was on `workflow_dispatch`.) — the standard repo gates
102110
(`run_tests.py`, `ruff`, `mypy`), `dotnet build`, the tag/version-match
103111
assertion above (skipped on manual dispatch, since there's no tag),
104112
`dotnet pack`, then **inspects the packed `.nupkg` contents** and fails
@@ -142,6 +150,18 @@ Three jobs, each gated on the previous succeeding:
142150
fails loudly, before the artifact is even downloaded — unless
143151
`protection_rules` is non-empty. This converts "never configured" from a
144152
silent bypass into a loud failure.
153+
**Second correction (review):** a bare `protection_rules` count is too
154+
permissive — GitHub's `protection_rules` array can also hold `wait_timer`
155+
and `branch_policy` rules, neither of which waits for a human, and a
156+
`required_reviewers` rule can itself be saved with zero reviewers (also
157+
not a real gate). The check now calls
158+
`scripts/check_environment_protection.sh` — a small, shared, fixture-
159+
tested predicate (also used by `action-marketplace-readiness.yml`'s
160+
`move-major-tag` job) that only accepts a `required_reviewers` rule with
161+
at least one actual reviewer. The job's `permissions:` also gained
162+
`actions: read`, which the environment-read endpoint requires alongside
163+
`contents: read`. See "Testing the environment-protection predicate"
164+
below.
145165

146166
`ci.yml`'s existing `ownsharp-cli-smoke` job (job key kept, content already
147167
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
150170
artifact* survive a clean install on both OSes" answer right before
151171
publish).
152172

173+
## Testing the environment-protection predicate
174+
175+
`scripts/check_environment_protection.sh <environment-json-file>` is the
176+
single source of truth both release workflows' environment-gate checks call
177+
(`owen-cli-release.yml`'s `publish` job here; `action-marketplace-readiness.yml`'s
178+
`move-major-tag` job for the Action's own `action-major-tag-move`
179+
environment). It takes a GitHub "Get an environment" API response and exits
180+
0 only if `protection_rules` contains a `required_reviewers` rule with at
181+
least one reviewer — rejecting a `wait_timer`-only or `branch_policy`-only
182+
environment, and rejecting a `required_reviewers` rule saved with zero
183+
reviewers, both of which a bare `protection_rules | length` check would
184+
have wrongly accepted as "protected."
185+
186+
Tested entirely offline, no GitHub API call and no real Environment needed:
187+
`ci.yml`'s `environment-protection-selftest` job runs the script against
188+
five fixtures under `scripts/fixtures/environment-protection/` and asserts
189+
the expected accept/reject outcome for each —
190+
191+
| Fixture | Expected |
192+
|---|---|
193+
| `zero-rules.json` | reject |
194+
| `wait-timer-only.json` | reject |
195+
| `branch-policy-only.json` | reject |
196+
| `required-reviewers-empty.json` | reject |
197+
| `required-reviewers-with-reviewer.json` | accept |
198+
199+
— so this predicate is exercised on every ordinary push/PR, not only when a
200+
real release or major-tag move actually runs.
201+
153202
## Release checklist
154203

155204
Run through this, in order, for every release:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
# Decides whether a GitHub Environment's "Get an environment" API response
3+
# proves an actual human-reviewer gate is configured -- the ONLY protection
4+
# rule type that means "this job pauses for a person to approve it".
5+
#
6+
# GitHub's `protection_rules` array can hold three rule types (wait_timer,
7+
# required_reviewers, branch_policy); a bare `.protection_rules | length`
8+
# check accepts any of them, including a wait_timer-only or
9+
# branch_policy-only environment that never actually waits for a human
10+
# (review: two release workflows -- owen-cli-release.yml's `publish` job,
11+
# action-marketplace-readiness.yml's `move-major-tag` job -- both once made
12+
# this mistake). A `required_reviewers` rule with an EMPTY `reviewers` array
13+
# is likewise not a real gate -- GitHub allows saving one, and it approves
14+
# nothing.
15+
#
16+
# Usage: check_environment_protection.sh <environment-json-file>
17+
# Exit 0 (ACCEPT) only if at least one required_reviewers rule has >=1
18+
# reviewer. Exit 1 (REJECT) for every other case, with a reason on stderr.
19+
set -euo pipefail
20+
21+
file="${1:?usage: check_environment_protection.sh <environment-json-file>}"
22+
23+
count=$(jq '[
24+
.protection_rules[]?
25+
| select(.type == "required_reviewers")
26+
| select((.reviewers // []) | length > 0)
27+
] | length' "$file")
28+
29+
if [ "$count" -eq 0 ]; then
30+
echo "REJECT: no required_reviewers protection rule with at least one reviewer found" >&2
31+
exit 1
32+
fi
33+
34+
echo "ACCEPT: $count required_reviewers rule(s) with at least one reviewer configured"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"protection_rules": [
3+
{
4+
"id": 2,
5+
"node_id": "MDQ6R2F0ZTI=",
6+
"type": "branch_policy"
7+
}
8+
]
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"protection_rules": [
3+
{
4+
"id": 3,
5+
"node_id": "MDQ6R2F0ZTM=",
6+
"type": "required_reviewers",
7+
"prevent_self_review": true,
8+
"reviewers": []
9+
}
10+
]
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"protection_rules": [
3+
{
4+
"id": 4,
5+
"node_id": "MDQ6R2F0ZTQ=",
6+
"type": "required_reviewers",
7+
"prevent_self_review": true,
8+
"reviewers": [
9+
{
10+
"type": "User",
11+
"reviewer": {
12+
"login": "octocat",
13+
"id": 1
14+
}
15+
}
16+
]
17+
}
18+
]
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"protection_rules": [
3+
{
4+
"id": 1,
5+
"node_id": "MDQ6R2F0ZTE=",
6+
"type": "wait_timer",
7+
"wait_timer": 30
8+
}
9+
]
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"protection_rules": []
3+
}

0 commit comments

Comments
 (0)