/
/
/
1"""Tests for immutable-safe release workflow helpers."""
2
3from __future__ import annotations
4
5import hashlib
6import json
7import re
8import subprocess
9from collections.abc import Mapping
10from datetime import UTC, datetime
11from pathlib import Path
12from typing import Any, cast
13
14import pytest
15import yaml
16
17from scripts.release_workflow import (
18 OCI_REVISION_ANNOTATION,
19 OCI_WHEEL_ANNOTATION,
20 GitRepository,
21 ReleaseWorkflowError,
22 channel_branch,
23 compare_frontend_versions,
24 compare_release_versions,
25 determine_auto_release,
26 inspect_assets,
27 is_current_release,
28 select_release,
29 update_addon_release,
30 verify_oci_manifest,
31)
32
33ROOT = Path(__file__).parents[2]
34DEPENDENCY_AUTO_MERGE_WORKFLOW = (
35 ROOT / ".github" / "workflows" / "auto-merge-dependency-updates.yml"
36)
37PINNED_ACTION_FILES = (
38 ROOT / ".github" / "workflows" / "release.yml",
39 ROOT / ".github" / "workflows" / "auto-release.yml",
40 ROOT / ".github" / "workflows" / "build-base-image.yml",
41 ROOT / ".github" / "workflows" / "dependabot-sync-manifests.yml",
42 ROOT / ".github" / "workflows" / "pr-labels.yaml",
43 ROOT / ".github" / "actions" / "generate-release-notes" / "action.yml",
44)
45LEGACY_AUTH_FILES = (
46 *PINNED_ACTION_FILES,
47 DEPENDENCY_AUTO_MERGE_WORKFLOW,
48 ROOT / ".github" / "release-notes-config.yml",
49)
50FORBIDDEN_LEGACY_IDENTIFIERS = tuple(
51 separator.join(parts)
52 for separator, parts in (
53 ("_", ("PRIVILEGED", "GITHUB", "TOKEN")),
54 ("_", ("TRIAGE", "GITHUB", "TOKEN")),
55 ("_", ("TRIAGE", "APP", "ID")),
56 ("_", ("TRIAGE", "APP", "PRIVATE", "KEY")),
57 ("-", ("music", "assistant", "machine")),
58 )
59)
60
61
62@pytest.fixture(name="repository")
63def repository_fixture(tmp_path: Path) -> tuple[Path, GitRepository]:
64 """Create a Git repository with one initial commit."""
65 _git(tmp_path, "init", "-b", "dev")
66 _git(tmp_path, "config", "user.name", "Release Test")
67 _git(tmp_path, "config", "user.email", "[email protected]")
68 _commit(tmp_path, "initial")
69 return tmp_path, GitRepository(tmp_path)
70
71
72def test_channel_branch_is_explicit() -> None:
73 """Release channels map to their intended source branches."""
74 assert channel_branch("stable") == "stable"
75 assert channel_branch("rc") == "stable"
76 assert channel_branch("beta") == "dev"
77 assert channel_branch("nightly") == "dev"
78
79
80def test_nightly_uses_tag_commit_range_and_avoids_tag_collision(
81 repository: tuple[Path, GitRepository],
82) -> None:
83 """Nightly discovery counts commits from tags and never reuses a failed tag."""
84 path, git_repository = repository
85 _git(path, "tag", "2.9.9")
86 _git(path, "tag", "2.10.0.dev2026072501")
87 _commit(path, "change one")
88 _commit(path, "change two")
89
90 decision = determine_auto_release(
91 git_repository,
92 "nightly",
93 "HEAD",
94 now=datetime(2026, 7, 25, 1, tzinfo=UTC),
95 )
96
97 assert decision.version == "2.10.0.dev2026072502"
98 assert decision.previous_tag == "2.10.0.dev2026072501"
99 assert decision.commits_since == 2
100 assert decision.should_release is True
101
102
103def test_nightly_requires_two_commits(repository: tuple[Path, GitRepository]) -> None:
104 """A nightly is skipped when fewer than two commits follow its latest tag."""
105 path, git_repository = repository
106 _git(path, "tag", "2.9.9")
107 _git(path, "tag", "2.10.0.dev2026072405")
108 _commit(path, "only change")
109
110 decision = determine_auto_release(
111 git_repository,
112 "nightly",
113 "HEAD",
114 now=datetime(2026, 7, 25, 5, tzinfo=UTC),
115 )
116
117 assert decision.commits_since == 1
118 assert decision.should_release is False
119
120
121def test_beta_rejects_latest_tag_outside_source_history(
122 repository: tuple[Path, GitRepository],
123) -> None:
124 """A latest beta tag on another history cannot define a release range."""
125 path, git_repository = repository
126 source_sha = _git(path, "rev-parse", "HEAD")
127 _git(path, "checkout", "--orphan", "unrelated")
128 (path / "state").unlink()
129 _commit(path, "unrelated")
130 _git(path, "tag", "2.10.0b1")
131
132 with pytest.raises(ReleaseWorkflowError, match="not an ancestor"):
133 determine_auto_release(git_repository, "beta", source_sha)
134
135
136def test_stable_preserves_patch_versioning_across_diverged_branches(
137 repository: tuple[Path, GitRepository],
138) -> None:
139 """Stable release discovery allows a branch cut but still increments patch."""
140 path, git_repository = repository
141 _git(path, "branch", "stable")
142 _git(path, "checkout", "stable")
143 _commit(path, "stable patch")
144 _git(path, "tag", "2.9.9")
145 _git(path, "checkout", "dev")
146 _commit(path, "development")
147
148 decision = determine_auto_release(git_repository, "stable", "HEAD")
149
150 assert decision.version == "2.9.10"
151 assert decision.previous_tag == "2.9.9"
152 assert decision.commits_since == 1
153 assert decision.should_release is True
154
155
156def test_current_release_combines_beta_and_rc_channels(
157 repository: tuple[Path, GitRepository],
158) -> None:
159 """An older beta retry cannot move the shared beta channel behind an RC."""
160 path, git_repository = repository
161 _git(path, "tag", "2.10.0b8")
162 _git(path, "tag", "2.10.0rc1")
163
164 assert is_current_release(git_repository, "rc", "2.10.0rc1") == (
165 True,
166 "2.10.0rc1",
167 )
168 assert is_current_release(git_repository, "beta", "2.10.0b8") == (
169 False,
170 "2.10.0rc1",
171 )
172
173
174@pytest.mark.parametrize(
175 ("current", "requested", "relation"),
176 [
177 ("2.10.0.dev2026072502", "2.10.0.dev2026072501", "newer"),
178 ("2.10.0b1", "2.10.0.dev2026072502", "newer"),
179 ("2.10.0rc1", "2.10.0b20", "newer"),
180 ("2.10.0", "2.10.0rc4", "newer"),
181 ("2.9.10", "2.10.0", "older"),
182 ("2.10.0b8", "2.10.0b8", "equal"),
183 ],
184)
185def test_release_version_order(
186 current: str,
187 requested: str,
188 relation: str,
189) -> None:
190 """Rolling aliases use release ordering across all supported stages."""
191 assert compare_release_versions(current, requested) == relation
192
193
194@pytest.mark.parametrize(
195 ("current", "requested", "relation"),
196 [
197 ("2.17.235", "2.17.234", "newer"),
198 ("2.17.186.post3", "2.17.186", "newer"),
199 ("2.17.186", "2.17.186.post1", "older"),
200 ("2.17.228", "2.17.228.0", "equal"),
201 ],
202)
203def test_frontend_version_order(
204 current: str,
205 requested: str,
206 relation: str,
207) -> None:
208 """Frontend dispatches compare numeric and post-release versions."""
209 assert compare_frontend_versions(current, requested) == relation
210
211
212def test_select_release_returns_none_without_an_exact_tag() -> None:
213 """Release selection ignores nonmatching tags across every API page."""
214 release_pages = [
215 [{"id": 10, "tag_name": "2.10.0b7"}],
216 [],
217 [{"id": 11, "tag_name": "2.10.0B8"}],
218 ]
219
220 assert select_release(release_pages, "2.10.0b8") is None
221
222
223def test_select_release_finds_one_exact_draft_across_pages() -> None:
224 """Release selection returns the exact draft and its existing id."""
225 expected = {
226 "id": 359740600,
227 "tag_name": "2.10.0b8",
228 "draft": True,
229 "immutable": False,
230 }
231 release_pages = [
232 [{"id": 10, "tag_name": "2.10.0b7"}],
233 [expected],
234 [{"id": 12, "tag_name": "2.10.0b80"}],
235 ]
236
237 assert select_release(release_pages, "2.10.0b8") is expected
238
239
240def test_select_release_rejects_duplicate_exact_tags() -> None:
241 """Release selection fails closed when multiple releases use the exact tag."""
242 release_pages = [
243 [{"id": 359740600, "tag_name": "2.10.0b8"}],
244 [{"id": 359752771, "tag_name": "2.10.0b8"}],
245 ]
246
247 with pytest.raises(
248 ReleaseWorkflowError,
249 match=re.escape("Multiple releases match exact tag 2.10.0b8: 359740600, 359752771"),
250 ):
251 select_release(release_pages, "2.10.0b8")
252
253
254def test_release_assets_match_names_sizes_and_digests(tmp_path: Path) -> None:
255 """Local distributions must exactly match GitHub's two release assets."""
256 version = "2.10.0b8"
257 assets_directory = tmp_path / "assets"
258 assets_directory.mkdir()
259 wheel = assets_directory / f"music_assistant-{version}-py3-none-any.whl"
260 source = assets_directory / f"music_assistant-{version}.tar.gz"
261 wheel.write_bytes(b"wheel")
262 source.write_bytes(b"source")
263 release_json = tmp_path / "release.json"
264 release_json.write_text(
265 json.dumps(
266 {
267 "assets": [
268 _api_asset(wheel),
269 _api_asset(source),
270 ]
271 }
272 ),
273 encoding="utf-8",
274 )
275
276 assets = inspect_assets(
277 version,
278 directory=assets_directory,
279 release_json=release_json,
280 )
281
282 assert assets[0].sha256 == hashlib.sha256(b"wheel").hexdigest()
283 assert assets[1].sha256 == hashlib.sha256(b"source").hexdigest()
284
285
286def test_release_assets_reject_extras(tmp_path: Path) -> None:
287 """A draft with any extra asset is not safe to publish."""
288 version = "2.10.0b8"
289 wheel = tmp_path / f"music_assistant-{version}-py3-none-any.whl"
290 source = tmp_path / f"music_assistant-{version}.tar.gz"
291 wheel.write_bytes(b"wheel")
292 source.write_bytes(b"source")
293 (tmp_path / "unexpected.txt").write_text("unexpected", encoding="utf-8")
294
295 with pytest.raises(ReleaseWorkflowError, match="exactly"):
296 inspect_assets(version, directory=tmp_path)
297
298
299def test_release_assets_reject_duplicate_api_entries(tmp_path: Path) -> None:
300 """Two API entries with the same expected name are not two exact assets."""
301 version = "2.10.0b8"
302 wheel = tmp_path / f"music_assistant-{version}-py3-none-any.whl"
303 wheel.write_bytes(b"wheel")
304 release_json = tmp_path / "release.json"
305 release_json.write_text(
306 json.dumps({"assets": [_api_asset(wheel), _api_asset(wheel)]}),
307 encoding="utf-8",
308 )
309
310 with pytest.raises(ReleaseWorkflowError, match="duplicate"):
311 inspect_assets(version, release_json=release_json)
312
313
314def test_oci_manifest_requires_exact_platforms_and_provenance() -> None:
315 """The exact image identifies its source and wheel on both target platforms."""
316 source_sha = "a" * 40
317 wheel_sha = "b" * 64
318 manifest = {
319 "digest": f"sha256:{'c' * 64}",
320 "annotations": {
321 OCI_REVISION_ANNOTATION: source_sha,
322 OCI_WHEEL_ANNOTATION: wheel_sha,
323 },
324 "manifests": [
325 {
326 "digest": f"sha256:{'d' * 64}",
327 "platform": {"os": "linux", "architecture": "amd64"},
328 },
329 {
330 "digest": f"sha256:{'e' * 64}",
331 "platform": {"os": "linux", "architecture": "arm64"},
332 },
333 {
334 "digest": f"sha256:{'f' * 64}",
335 "platform": {"os": "unknown", "architecture": "unknown"},
336 },
337 ],
338 }
339
340 digest, runtime_digests = verify_oci_manifest(manifest, source_sha, wheel_sha)
341
342 assert digest == f"sha256:{'c' * 64}"
343 assert runtime_digests == [f"sha256:{'d' * 64}", f"sha256:{'e' * 64}"]
344
345
346def test_addon_update_replaces_duplicate_version_and_retains_three(tmp_path: Path) -> None:
347 """Repeated downstream runs converge on one canonical three-release changelog."""
348 config = tmp_path / "config.yaml"
349 changelog = tmp_path / "CHANGELOG.md"
350 config.write_text("name: Test\nversion: old\nstage: stable\n", encoding="utf-8")
351 changelog.write_text(
352 "# [new] - 01.01.2026\n\nold duplicate\n\n\n"
353 "# [older] - 31.12.2025\n\nolder notes\n\n\n"
354 "# [new] - 30.12.2025\n\nsecond duplicate\n\n\n"
355 "# [older] - 30.12.2025\n\nolder duplicate\n\n\n"
356 "# [oldest] - 29.12.2025\n\noldest notes\n",
357 encoding="utf-8",
358 )
359
360 update_addon_release(
361 config,
362 changelog,
363 version="new",
364 release_date="02.01.2026",
365 notes="canonical notes",
366 )
367 first_result = changelog.read_text(encoding="utf-8")
368 update_addon_release(
369 config,
370 changelog,
371 version="new",
372 release_date="02.01.2026",
373 notes="canonical notes",
374 )
375
376 assert config.read_text(encoding="utf-8").splitlines()[1] == "version: new"
377 assert changelog.read_text(encoding="utf-8") == first_result
378 assert first_result.count("# [new]") == 1
379 assert first_result.count("# [older]") == 1
380 assert "# [older]" in first_result
381 assert "# [oldest]" in first_result
382
383
384def test_automation_drops_legacy_credentials() -> None:
385 """Ensure migrated automation does not reference retired bot credentials."""
386 for automation_path in LEGACY_AUTH_FILES:
387 automation = automation_path.read_text(encoding="utf-8")
388 for forbidden in FORBIDDEN_LEGACY_IDENTIFIERS:
389 assert forbidden not in automation
390
391
392def test_automation_pins_external_actions() -> None:
393 """Ensure migrated automation pins external actions to immutable commits."""
394 action_pattern = re.compile(
395 r"^\s*(?:-\s+)?uses:\s+([^./][^@]+)@(\S+)(?:\s+#\s+(.+))?$",
396 re.MULTILINE,
397 )
398 for workflow_path in PINNED_ACTION_FILES:
399 workflow = workflow_path.read_text(encoding="utf-8")
400 matches = action_pattern.findall(workflow)
401 assert matches
402 for _action, ref, comment in matches:
403 assert re.fullmatch(r"[0-9a-f]{40}", ref)
404 assert re.fullmatch(r"v\d+(?:\.\d+){0,2}", comment)
405
406
407@pytest.mark.parametrize(
408 ("login", "user_type", "user_id", "head_repository", "trusted"),
409 [
410 (
411 "musicassistant-bot[bot]",
412 "Bot",
413 "304008617",
414 "music-assistant/server",
415 True,
416 ),
417 ("musicassistant-bot", "Bot", "304008617", "music-assistant/server", False),
418 (
419 "musicassistant-bot[bot]",
420 "User",
421 "304008617",
422 "music-assistant/server",
423 False,
424 ),
425 ("marcelveldt", "User", "6389780", "music-assistant/server", False),
426 (
427 "musicassistant-bot[bot]",
428 "Bot",
429 "304008617",
430 "untrusted/server",
431 False,
432 ),
433 (
434 "musicassistant-bot[bot]",
435 "Bot",
436 "123456789",
437 "music-assistant/server",
438 False,
439 ),
440 ],
441)
442def test_dependency_auto_merge_app_bot_identity_contract(
443 login: str,
444 user_type: str,
445 user_id: str,
446 head_repository: str,
447 trusted: bool,
448) -> None:
449 """Accept only the expected same-repository GitHub App bot identity."""
450 workflow = yaml.safe_load(DEPENDENCY_AUTO_MERGE_WORKFLOW.read_text(encoding="utf-8"))
451 expected = workflow["env"]
452
453 is_trusted_app = (
454 login == expected["EXPECTED_APP_BOT_LOGIN"]
455 and user_type == "Bot"
456 and user_id == expected["EXPECTED_APP_BOT_ID"]
457 and head_repository == "music-assistant/server"
458 )
459
460 assert is_trusted_app is trusted
461
462
463@pytest.mark.parametrize(
464 ("login", "user_type", "user_id", "trusted"),
465 [
466 ("musicassistant-bot[bot]", "Bot", "304008617", True),
467 ("marcelveldt", "User", "6389780", False),
468 ("musicassistant-bot", "Bot", "304008617", False),
469 ("musicassistant-bot[bot]", "User", "304008617", False),
470 ("musicassistant-bot[bot]", "Bot", "123456789", False),
471 ],
472)
473def test_dependency_auto_merge_commit_author_contract(
474 login: str,
475 user_type: str,
476 user_id: str,
477 trusted: bool,
478) -> None:
479 """Accept commits only from the expected GitHub App bot identity."""
480 workflow = yaml.safe_load(DEPENDENCY_AUTO_MERGE_WORKFLOW.read_text(encoding="utf-8"))
481 expected = workflow["env"]
482
483 is_trusted_author = (
484 login == expected["EXPECTED_APP_BOT_LOGIN"]
485 and user_type == "Bot"
486 and user_id == expected["EXPECTED_APP_BOT_ID"]
487 )
488
489 assert is_trusted_author is trusted
490
491
492def test_dependency_auto_merge_enforces_app_bot_identity_contract() -> None:
493 """Ensure the workflow enforces the tested App bot identity contract."""
494 workflow_text = DEPENDENCY_AUTO_MERGE_WORKFLOW.read_text(encoding="utf-8")
495 workflow = yaml.safe_load(workflow_text)
496 assert workflow_text.count("musicassistant-bot[bot]") == 1
497 assert workflow["env"] == {
498 "EXPECTED_APP_BOT_LOGIN": "musicassistant-bot[bot]",
499 "EXPECTED_APP_BOT_LOGIN_ENCODED": "musicassistant-bot%5Bbot%5D",
500 "EXPECTED_APP_BOT_ID": "304008617",
501 }
502
503 job = workflow["jobs"]["auto-merge"]
504 steps = {step["name"]: step for step in job["steps"]}
505 source_step = steps["Verify PR is from trusted source"]
506 assert source_step["env"] == {
507 "GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}",
508 "BASE_REPOSITORY": "${{ github.repository }}",
509 "HEAD_REPOSITORY": "${{ github.event.pull_request.head.repo.full_name }}",
510 "PR_AUTHOR": "${{ github.event.pull_request.user.login }}",
511 "PR_AUTHOR_ID": "${{ github.event.pull_request.user.id }}",
512 "PR_AUTHOR_TYPE": "${{ github.event.pull_request.user.type }}",
513 }
514 source_check = source_step["run"]
515 for required_check in (
516 'if [ "$PR_AUTHOR" != "$EXPECTED_APP_BOT_LOGIN" ] ||',
517 '[ "$PR_AUTHOR_TYPE" != "Bot" ] ||',
518 '[ "$PR_AUTHOR_ID" != "$EXPECTED_APP_BOT_ID" ] ||',
519 '[ "$HEAD_REPOSITORY" != "$BASE_REPOSITORY" ]; then',
520 'gh api "/users/$EXPECTED_APP_BOT_LOGIN_ENCODED"',
521 ):
522 assert required_check in source_check
523 assert "collaborators/" not in source_check
524
525 author_check = steps["Verify commit authors"]["run"]
526 assert '--arg login "$EXPECTED_APP_BOT_LOGIN"' in author_check
527 assert '--argjson id "$EXPECTED_APP_BOT_ID"' in author_check
528 assert ".author.login != $login" in author_check
529 assert 'author.type != "Bot"' in author_check
530 assert ".author.id != $id" in author_check
531 assert "UNTRUSTED_AUTHORS" in author_check
532 assert "UNATTRIBUTED" in author_check
533 assert "COMMIT_COUNT" in author_check
534 assert "collaborators/" not in author_check
535
536 assert "auto-update-frontend-" in job["if"]
537 assert "auto-update-models-" in job["if"]
538 labels_check = steps["Verify PR labels and source"]["run"]
539 assert '"dependencies"' in labels_check
540 assert "auto-update-frontend-*" in labels_check
541 assert "auto-update-models-*" in labels_check
542 files_check = steps["Verify only dependency files were changed"]["run"]
543 assert '"pyproject.toml"' in files_check
544 assert '"requirements_all.txt"' in files_check
545 diff_check = steps["Verify changes are version bumps"]["run"]
546 assert "UNEXPECTED=" in diff_check
547 assert "No added version pin found" in diff_check
548 availability_check = steps["Wait for package availability on PyPI"]["run"]
549 assert "python3 -m pip download --no-deps" in availability_check
550 assert "--approve" in steps["Auto-approve PR"]["run"]
551 assert "--auto --squash" in steps["Enable auto-merge"]["run"]
552
553
554def test_release_workflow_uses_minimum_preflight_permissions_and_expected_app() -> None:
555 """Resolve can discover drafts while preflight and App tokens stay minimal."""
556 workflow_path = ROOT / ".github" / "workflows" / "release.yml"
557 workflow = workflow_path.read_text(encoding="utf-8")
558 jobs = yaml.safe_load(workflow)["jobs"]
559
560 assert jobs["resolve"]["permissions"] == {"contents": "write"}
561 assert jobs["preflight"]["permissions"] == {
562 "contents": "read",
563 "pull-requests": "read",
564 }
565 assert jobs["build_artifacts"]["permissions"] == {"contents": "write"}
566 assert workflow.count("actions/create-github-app-token@") == 5
567 assert workflow.count("outputs.installation-id") == 5
568 assert 'EXPECTED_APP_INSTALLATION_ID: "146062122"' in workflow
569 assert set(re.findall(r"secrets\.([A-Z0-9_]+)", workflow)) == {
570 "MUSIC_ASSISTANT_BOT_PRIVATE_KEY"
571 }
572 assert set(re.findall(r"vars\.([A-Z0-9_]+)", workflow)) == {"MUSIC_ASSISTANT_BOT_CLIENT_ID"}
573 assert "ref: main" not in workflow
574 assert "HEAD:main" not in workflow
575 assert "compare-release-versions" in workflow
576 assert "compare-frontend-versions" in workflow
577 assert "idempotency_key" in workflow
578 assert "repos/music-assistant/home-assistant-addon" in workflow
579 assert "'.default_branch'" in workflow
580
581
582def test_release_workflow_dispatch_source_sha_is_optional_for_recovery() -> None:
583 """Direct recovery keeps source_sha optional while workflow_call stays required."""
584 workflow = cast(
585 "Mapping[object, Any]",
586 yaml.safe_load(
587 (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
588 ),
589 )
590 triggers = _workflow_triggers(workflow)
591
592 assert triggers["workflow_dispatch"]["inputs"]["source_sha"] == {
593 "description": (
594 "Exact full commit SHA for draft/published recovery; leave empty to "
595 "resolve the current channel branch head"
596 ),
597 "required": False,
598 "type": "string",
599 }
600 assert triggers["workflow_call"]["inputs"]["source_sha"] == {
601 "description": "Exact source commit to release",
602 "required": True,
603 "type": "string",
604 }
605
606
607def test_release_workflow_exact_source_resolution_uses_requested_sha() -> None:
608 """Resolve exact source commit honors recovery SHAs and rejects bad ones."""
609 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
610 resolve_step = _workflow_step(
611 cast("dict[str, Any]", yaml.safe_load(workflow)),
612 "resolve",
613 "Resolve exact source commit",
614 )
615
616 assert resolve_step["env"] == {"REQUESTED_SHA": "${{ inputs.source_sha }}"}
617 resolve_run = str(resolve_step["run"])
618
619 expected_resolution = """\
620if [ -n "$REQUESTED_SHA" ]; then
621 requested_sha=$(printf '%s' "$REQUESTED_SHA" |
622 tr '[:upper:]' '[:lower:]')
623 if ! [[ "$requested_sha" =~ ^[0-9a-f]{40}$ ]]; then
624 echo "source_sha must be a full commit SHA" >&2
625 exit 1
626 fi
627 source_sha=$(git -C source rev-parse "$requested_sha^{commit}")
628 if ! git -C source merge-base --is-ancestor "$source_sha" "$branch_sha"; then
629 echo "$source_sha is not part of ${{ steps.branch.outputs.branch }}" >&2
630 exit 1
631 fi
632else
633 source_sha="$branch_sha"
634fi
635echo "sha=$source_sha" >> "$GITHUB_OUTPUT"
636"""
637 assert expected_resolution in resolve_run
638
639
640def test_release_workflow_discovers_exact_drafts_from_paginated_releases() -> None:
641 """Resolve discovers draft releases through the paginated releases endpoint."""
642 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
643 resolve_run = _workflow_step_run(
644 workflow,
645 job_name="resolve",
646 step_name="Inspect existing tag and release",
647 )
648
649 assert "gh api --paginate --slurp" in resolve_run
650 assert '"repos/$GITHUB_REPOSITORY/releases?per_page=100"' in resolve_run
651 assert "release_workflow.py select-release" in resolve_run
652 assert '--release-json "$release_json"' in resolve_run
653 assert "release_id=$(sed -n 's/^release_id=//p' \"$release_lookup\")" in resolve_run
654
655
656def test_release_workflow_reuses_resolved_draft_id() -> None:
657 """Draft recovery and updates revalidate and reuse the resolved release id."""
658 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
659 parsed_workflow = cast("dict[str, Any]", yaml.safe_load(workflow))
660 recover_step = _workflow_step(
661 parsed_workflow,
662 "build_artifacts",
663 "Recover matching draft assets",
664 )
665 assert recover_step["env"]["RELEASE_ID"] == "${{ needs.resolve.outputs.release_id }}"
666 recover_run = str(recover_step["run"])
667 assert 'if [ -z "$RELEASE_ID" ]; then' in recover_run
668 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in recover_run
669 assert "'.tag_name // empty'" in recover_run
670 assert "'.draft'" in recover_run
671 assert "'.immutable'" in recover_run
672 assert "'.target_commitish'" in recover_run
673 assert "gh release download" not in recover_run
674 assert "Draft must contain exactly one $asset_name asset" in recover_run
675 assert '"repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"' in recover_run
676
677 draft_step = _workflow_step(
678 parsed_workflow,
679 "prepare_draft",
680 "Create or update matching draft",
681 )
682 assert draft_step["env"]["RELEASE_ID"] == "${{ needs.resolve.outputs.release_id }}"
683 draft_run = str(draft_step["run"])
684 assert 'if [ -n "$RELEASE_ID" ]; then' in draft_run
685 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in draft_run
686 assert '"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \\\n' in draft_run
687 assert '"repos/$GITHUB_REPOSITORY/releases" \\\n' in draft_run
688 assert "release_exists=true" in draft_run
689 assert "release_exists=false" in draft_run
690
691 replace_run = str(
692 _workflow_step(
693 parsed_workflow,
694 "prepare_draft",
695 "Replace draft assets",
696 )["run"]
697 )
698 assert "gh release upload" not in replace_run
699 assert (
700 "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/"
701 "$RELEASE_ID/assets?name=$asset_name"
702 ) in replace_run
703
704
705def test_release_workflow_avoids_prepublication_tag_release_lookups() -> None:
706 """Only post-publication work may use GitHub's published tag endpoint."""
707 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
708 parsed_workflow = cast("dict[str, Any]", yaml.safe_load(workflow))
709 tag_endpoint = "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION"
710
711 for job_name in ("resolve", "build_artifacts", "prepare_draft"):
712 for step in parsed_workflow["jobs"][job_name]["steps"]:
713 assert tag_endpoint not in str(step.get("run", ""))
714
715 publication_run = _workflow_step_run(
716 workflow,
717 job_name="publish_release",
718 step_name="Verify immutable release and assets",
719 )
720 assert tag_endpoint in publication_run
721 assert workflow.count(tag_endpoint) == 2
722
723
724def test_release_workflow_publication_state_uses_release_ids() -> None:
725 """Publication lookup must use release IDs and fail closed on mismatches."""
726 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
727 assert "DRAFT_RELEASE_ID: ${{ needs.prepare_draft.outputs.release_id }}" in workflow
728 assert "PUBLISHED_RELEASE_ID: ${{ needs.resolve.outputs.release_id }}" in workflow
729 assert "RELEASE_STATE: ${{ needs.resolve.outputs.release_state }}" in workflow
730
731 publication_step = _workflow_step_run(
732 workflow,
733 job_name="publish_release",
734 step_name="Detect live publication state",
735 )
736
737 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION"' not in publication_step
738 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in publication_step
739 assert 'case "$RELEASE_STATE" in' in publication_step
740 assert "new|draft)" in publication_step
741 assert "Missing release id for $RELEASE_STATE release $VERSION" in publication_step
742 assert "tag_name" in publication_step
743 assert "Release $VERSION resolves to tag $live_tag_name" in publication_step
744 assert "Release $VERSION is neither a mutable draft nor immutable" in publication_step
745
746
747def _api_asset(path: Path) -> dict[str, str | int]:
748 return {
749 "name": path.name,
750 "size": path.stat().st_size,
751 "state": "uploaded",
752 "digest": f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}",
753 }
754
755
756def _commit(path: Path, message: str) -> str:
757 state = path / "state"
758 previous = state.read_text(encoding="utf-8") if state.exists() else ""
759 state.write_text(f"{previous}{message}\n", encoding="utf-8")
760 _git(path, "add", "state")
761 _git(path, "commit", "-m", message)
762 return _git(path, "rev-parse", "HEAD")
763
764
765def _git(path: Path, *args: str) -> str:
766 result = subprocess.run( # noqa: S603
767 ["git", "-C", str(path), *args], # noqa: S607
768 check=True,
769 capture_output=True,
770 text=True,
771 )
772 return result.stdout.strip()
773
774
775def _workflow_triggers(workflow: Mapping[object, Any]) -> dict[str, Any]:
776 trigger = workflow.get("on")
777 if trigger is None:
778 trigger = workflow[True]
779 assert isinstance(trigger, dict)
780 return cast("dict[str, Any]", trigger)
781
782
783def _workflow_step(workflow: Mapping[str, Any], job_name: str, step_name: str) -> dict[str, Any]:
784 jobs = workflow["jobs"]
785 assert isinstance(jobs, dict)
786 for step in jobs[job_name]["steps"]:
787 if step.get("name") == step_name:
788 return cast("dict[str, Any]", step)
789 msg = f"Step {step_name!r} not found in job {job_name!r}"
790 raise AssertionError(msg)
791
792
793def _workflow_step_run(workflow: str, job_name: str, step_name: str) -> str:
794 return str(_workflow_step(yaml.safe_load(workflow), job_name, step_name)["run"])
795