Skip to content

Commit 2a64002

Browse files
authored
fix(release): require validation for canonical beta PRs (#1032)
Require canonical beta release PRs to provide release-candidate validation evidence before merge, even when release-managed version metadata is already unchanged against main. Also preserves unrelated PR no-op bypasses for partially inconsistent release states and malformed beta-prefixed maintenance branches.
1 parent 1c367a5 commit 2a64002

5 files changed

Lines changed: 172 additions & 3 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Require validation for canonical beta PRs
2+
3+
## Problem
4+
5+
The beta publish workflow correctly refuses to create release artifacts when a merged canonical `release/beta-*` PR lacks release-candidate validation evidence. However, the pre-merge `Beta release guard` can pass a canonical beta PR when release-managed metadata is already identical to `main`, because it treats the PR as a no-op diff and skips validation evidence checks.
6+
7+
That leaves a green PR that can be merged, only for publish CD to fail after merge.
8+
9+
## Solution
10+
11+
Treat canonical `release/beta-*` PRs as publish intent whenever the checked-out tree already contains the matching beta version, even if release-managed metadata is unchanged relative to the base branch. Require the same release-candidate evidence before merge that the publish workflow requires after merge.
12+
13+
## Changes
14+
15+
- Update the PR guard to identify canonical beta release PRs before no-op release metadata short-circuiting
16+
- Require validation evidence for canonical beta PRs even when release-managed version files are unchanged against the base branch
17+
- Preserve the existing no-op bypass for non-canonical dependency or maintenance edits on beta-version bases
18+
- Add regression coverage for canonical beta PRs with unchanged metadata and missing evidence
19+
20+
## Out of scope
21+
22+
- Creating or rerunning release tags/artifacts
23+
- Changing the release-candidate validation checklist labels
24+
- Changing stable release behavior
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Merged beta release PRs publish GitHub prereleases
4+
5+
When a pull request from a `release/beta-*` branch is merged into `main`, the release automation SHALL require `RELEASE_PLEASE_TOKEN` rather than falling back to `GITHUB_TOKEN`, verify that all release-managed version files agree on a beta version, create the matching `vX.Y.Z-beta.N` tag at the merge commit, and publish a GitHub prerelease for that tag. Re-running the workflow after the tag already exists SHALL be safe and SHALL NOT create a second tag. Before merge, the beta release guard SHALL require release-candidate validation evidence for canonical `release/beta-X.Y.Z-beta.N` pull requests whose checked-out tree already contains the matching beta version, even when the release-managed version files are unchanged relative to the base branch.
6+
7+
#### Scenario: beta PR merge publishes a prerelease tag
8+
9+
- **GIVEN** a merged pull request from `release/beta-1.19.0-beta.1`
10+
- **AND** release-managed files all contain `1.19.0-beta.1`
11+
- **AND** `RELEASE_PLEASE_TOKEN` is configured
12+
- **WHEN** the beta publish workflow runs
13+
- **THEN** it creates tag `v1.19.0-beta.1` at the merge commit
14+
- **AND** it creates a GitHub prerelease for `v1.19.0-beta.1`
15+
16+
#### Scenario: canonical beta PR with unchanged metadata still requires validation
17+
18+
- **GIVEN** `main` already contains release-managed files set to `1.20.0-beta.3`
19+
- **AND** a pull request from `release/beta-1.20.0-beta.3` targets `main`
20+
- **WHEN** the beta release guard evaluates the pull request before merge
21+
- **THEN** it requires release-candidate validation evidence for the pull request head SHA
22+
- **AND** it fails while that evidence is missing, even though the release-managed version files are unchanged relative to `main`
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Tasks
2+
3+
- [x] Add regression coverage for a canonical beta PR whose release metadata is unchanged against the base branch but whose validation evidence is missing
4+
- [x] Update `scripts.guard_beta_release` so canonical beta PRs require validation evidence before no-op metadata short-circuiting
5+
- [x] Document the release-management requirement delta
6+
- [x] Run `openspec validate require-canonical-beta-validation --strict`
7+
- [x] Run targeted guard tests
8+
- [x] Run lint/type gates for the touched Python code

scripts/guard_beta_release.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,14 +267,26 @@ def guard_pull_request(root: Path, event: dict[str, Any], base_ref: str, head_re
267267
expected_sha = run_git(root, "rev-parse", "HEAD").stdout.strip()
268268
body = os.environ.get("BETA_RELEASE_PR_BODY", "")
269269

270+
current_versions = _canonical_release_versions(read_project_versions(root))
271+
release_from_head: ReleaseVersion | None = None
272+
if head_ref.startswith("release/beta-"):
273+
try:
274+
release_from_head = parse_version(head_ref.removeprefix("release/beta-"))
275+
except ValueError:
276+
release_from_head = None
277+
is_canonical_beta_pr = bool(
278+
release_from_head is not None
279+
and release_from_head.channel == "beta"
280+
and all(version == release_from_head.version for version in current_versions.values())
281+
)
282+
270283
changed = changed_release_version_files(root, base_ref)
271-
if not changed:
284+
if not changed and not is_canonical_beta_pr:
272285
print("No release-managed version files changed; beta release PR guard passed.")
273286
return
274287

275-
current_versions = _canonical_release_versions(read_project_versions(root))
276288
base_versions = _canonical_release_versions(_read_project_versions_at_ref(root, base_ref))
277-
if current_versions == base_versions:
289+
if current_versions == base_versions and not is_canonical_beta_pr:
278290
print("No release-managed version files changed; release metadata is unchanged; beta release PR guard passed.")
279291
return
280292

tests/unit/test_guard_beta_release.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,77 @@ def test_pr_guard_accepts_dependency_only_release_managed_file_edits(tmp_path: P
171171
assert "No release-managed version files changed" in result.stdout
172172

173173

174+
def test_pr_guard_accepts_noncanonical_noop_on_inconsistent_release_metadata_base(tmp_path: Path) -> None:
175+
repo = tmp_path / "repo"
176+
repo.mkdir()
177+
write_minimal_release_files(repo)
178+
(repo / "app" / "__init__.py").write_text('__version__ = "1.20.0-beta.3"\n', encoding="utf-8")
179+
git(repo, "init")
180+
git(repo, "config", "user.email", "test@example.com")
181+
git(repo, "config", "user.name", "Test")
182+
git(repo, "add", ".")
183+
git(repo, "commit", "-m", "chore: inconsistent partial release state")
184+
git(repo, "branch", "-M", "main")
185+
(repo / "pyproject.toml").write_text(
186+
'[project]\nname = "codex-lb"\nversion = "1.20.0"\ndependencies = ["aiohttp-socks>=0.10.1"]\n',
187+
encoding="utf-8",
188+
)
189+
git(repo, "add", "pyproject.toml")
190+
git(repo, "commit", "-m", "deps: add aiohttp socks adapter")
191+
sha = git(repo, "rev-parse", "HEAD")
192+
branch = "fix/aiohttp-socks-adapter"
193+
event = event_file(tmp_path, head_ref=branch, head_sha=sha, body="")
194+
195+
result = run_guard(
196+
Path(__file__).resolve().parents[2],
197+
repo,
198+
"--base-ref",
199+
"HEAD~1",
200+
"--head-ref",
201+
branch,
202+
"--event-path",
203+
str(event),
204+
)
205+
206+
assert result.returncode == 0, result.stderr
207+
assert "No release-managed version files changed" in result.stdout
208+
209+
210+
def test_pr_guard_accepts_invalid_beta_prefixed_branch_when_release_metadata_is_unchanged(tmp_path: Path) -> None:
211+
repo = tmp_path / "repo"
212+
repo.mkdir()
213+
write_minimal_release_files(repo, version="1.20.0-beta.3")
214+
git(repo, "init")
215+
git(repo, "config", "user.email", "test@example.com")
216+
git(repo, "config", "user.name", "Test")
217+
git(repo, "add", ".")
218+
git(repo, "commit", "-m", "chore: release v1.20.0-beta.3")
219+
git(repo, "branch", "-M", "main")
220+
(repo / "pyproject.toml").write_text(
221+
'[project]\nname = "codex-lb"\nversion = "1.20.0-beta.3"\ndependencies = ["aiohttp-socks>=0.10.1"]\n',
222+
encoding="utf-8",
223+
)
224+
git(repo, "add", "pyproject.toml")
225+
git(repo, "commit", "-m", "deps: add aiohttp socks adapter")
226+
sha = git(repo, "rev-parse", "HEAD")
227+
branch = "release/beta-doc-fix"
228+
event = event_file(tmp_path, head_ref=branch, head_sha=sha, body="")
229+
230+
result = run_guard(
231+
Path(__file__).resolve().parents[2],
232+
repo,
233+
"--base-ref",
234+
"HEAD~1",
235+
"--head-ref",
236+
branch,
237+
"--event-path",
238+
str(event),
239+
)
240+
241+
assert result.returncode == 0, result.stderr
242+
assert "No release-managed version files changed" in result.stdout
243+
244+
174245
def test_pr_guard_rejects_inconsistent_release_managed_beta_metadata(tmp_path: Path) -> None:
175246
repo = tmp_path / "repo"
176247
repo.mkdir()
@@ -360,6 +431,38 @@ def test_pr_guard_rejects_canonical_beta_pr_without_validation_evidence(tmp_path
360431
assert sha in result.stderr
361432

362433

434+
def test_pr_guard_rejects_canonical_beta_pr_without_validation_evidence_when_metadata_unchanged(
435+
tmp_path: Path,
436+
) -> None:
437+
repo = tmp_path / "repo"
438+
repo.mkdir()
439+
write_minimal_release_files(repo, version="1.20.0-beta.3")
440+
git(repo, "init")
441+
git(repo, "config", "user.email", "test@example.com")
442+
git(repo, "config", "user.name", "Test")
443+
git(repo, "add", ".")
444+
git(repo, "commit", "-m", "chore: release v1.20.0-beta.3")
445+
git(repo, "branch", "-M", "main")
446+
sha = git(repo, "rev-parse", "HEAD")
447+
branch = "release/beta-1.20.0-beta.3"
448+
event = event_file(tmp_path, head_ref=branch, head_sha=sha, body="## Summary\nRelease beta3")
449+
450+
result = run_guard(
451+
Path(__file__).resolve().parents[2],
452+
repo,
453+
"--base-ref",
454+
"HEAD",
455+
"--head-ref",
456+
branch,
457+
"--event-path",
458+
str(event),
459+
)
460+
461+
assert result.returncode == 1
462+
assert "release-candidate validation evidence" in result.stderr
463+
assert sha in result.stderr
464+
465+
363466
def test_pr_guard_accepts_validated_canonical_beta_pr(tmp_path: Path) -> None:
364467
repo = tmp_path / "repo"
365468
repo.mkdir()

0 commit comments

Comments
 (0)