/
/
/
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 "EXPECTED_APP_SLUG": "musicassistant-bot",
502 "EXPECTED_APP_INSTALLATION_ID": "146062122",
503 }
504
505 job = workflow["jobs"]["auto-merge"]
506 steps = {step["name"]: step for step in job["steps"]}
507 source_step = steps["Verify PR is from trusted source"]
508 assert source_step["env"] == {
509 "GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}",
510 "BASE_REPOSITORY": "${{ github.repository }}",
511 "HEAD_REPOSITORY": "${{ github.event.pull_request.head.repo.full_name }}",
512 "PR_AUTHOR": "${{ github.event.pull_request.user.login }}",
513 "PR_AUTHOR_ID": "${{ github.event.pull_request.user.id }}",
514 "PR_AUTHOR_TYPE": "${{ github.event.pull_request.user.type }}",
515 }
516 source_check = source_step["run"]
517 for required_check in (
518 'if [ "$PR_AUTHOR" != "$EXPECTED_APP_BOT_LOGIN" ] ||',
519 '[ "$PR_AUTHOR_TYPE" != "Bot" ] ||',
520 '[ "$PR_AUTHOR_ID" != "$EXPECTED_APP_BOT_ID" ] ||',
521 '[ "$HEAD_REPOSITORY" != "$BASE_REPOSITORY" ]; then',
522 'gh api "/users/$EXPECTED_APP_BOT_LOGIN_ENCODED"',
523 ):
524 assert required_check in source_check
525 assert "collaborators/" not in source_check
526
527 author_check = steps["Verify commit authors"]["run"]
528 assert '--arg login "$EXPECTED_APP_BOT_LOGIN"' in author_check
529 assert '--argjson id "$EXPECTED_APP_BOT_ID"' in author_check
530 assert ".author.login != $login" in author_check
531 assert 'author.type != "Bot"' in author_check
532 assert ".author.id != $id" in author_check
533 assert "UNTRUSTED_AUTHORS" in author_check
534 assert "UNATTRIBUTED" in author_check
535 assert "COMMIT_COUNT" in author_check
536 assert "collaborators/" not in author_check
537
538 assert "auto-update-frontend-" in job["if"]
539 assert "auto-update-models-" in job["if"]
540 labels_check = steps["Verify PR labels and source"]["run"]
541 assert '"dependencies"' in labels_check
542 assert "auto-update-frontend-*" in labels_check
543 assert "auto-update-models-*" in labels_check
544 files_check = steps["Verify only dependency files were changed"]["run"]
545 assert '"pyproject.toml"' in files_check
546 assert '"requirements_all.txt"' in files_check
547 diff_check = steps["Verify changes are version bumps"]["run"]
548 assert "UNEXPECTED=" in diff_check
549 assert "No added version pin found" in diff_check
550 availability_check = steps["Wait for package availability on PyPI"]["run"]
551 assert "python3 -m pip download --no-deps" in availability_check
552 assert "--approve" in steps["Auto-approve PR"]["run"]
553 assert "--auto --squash" in steps["Enable auto-merge"]["run"]
554
555 refresh_steps = {step["name"]: step for step in workflow["jobs"]["refresh-stale"]["steps"]}
556 identity_check = refresh_steps["Verify GitHub App identity"]["run"]
557 assert '[ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] ||' in identity_check
558 assert '[ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then' in identity_check
559
560
561def test_release_workflow_uses_minimum_preflight_permissions_and_expected_app() -> None:
562 """Resolve can discover drafts while preflight and App tokens stay minimal."""
563 workflow_path = ROOT / ".github" / "workflows" / "release.yml"
564 workflow = workflow_path.read_text(encoding="utf-8")
565 jobs = yaml.safe_load(workflow)["jobs"]
566
567 assert jobs["resolve"]["permissions"] == {"contents": "write"}
568 assert jobs["preflight"]["permissions"] == {
569 "contents": "read",
570 "pull-requests": "read",
571 }
572 assert jobs["build_artifacts"]["permissions"] == {"contents": "write"}
573 assert workflow.count("actions/create-github-app-token@") == 5
574 assert workflow.count("outputs.installation-id") == 5
575 assert 'EXPECTED_APP_INSTALLATION_ID: "146062122"' in workflow
576 assert set(re.findall(r"secrets\.([A-Z0-9_]+)", workflow)) == {
577 "MUSIC_ASSISTANT_BOT_PRIVATE_KEY"
578 }
579 assert set(re.findall(r"vars\.([A-Z0-9_]+)", workflow)) == {"MUSIC_ASSISTANT_BOT_CLIENT_ID"}
580 assert "ref: main" not in workflow
581 assert "HEAD:main" not in workflow
582 assert "compare-release-versions" in workflow
583 assert "compare-frontend-versions" in workflow
584 assert "idempotency_key" in workflow
585 assert "repos/music-assistant/home-assistant-addon" in workflow
586 assert "'.default_branch'" in workflow
587
588
589def test_release_workflow_dispatch_source_sha_is_optional_for_recovery() -> None:
590 """Direct recovery keeps source_sha optional while workflow_call stays required."""
591 workflow = cast(
592 "Mapping[object, Any]",
593 yaml.safe_load(
594 (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
595 ),
596 )
597 triggers = _workflow_triggers(workflow)
598
599 assert triggers["workflow_dispatch"]["inputs"]["source_sha"] == {
600 "description": (
601 "Exact full commit SHA for draft/published recovery; leave empty to "
602 "resolve the current channel branch head"
603 ),
604 "required": False,
605 "type": "string",
606 }
607 assert triggers["workflow_call"]["inputs"]["source_sha"] == {
608 "description": "Exact source commit to release",
609 "required": True,
610 "type": "string",
611 }
612
613
614def test_release_workflow_exact_source_resolution_uses_requested_sha() -> None:
615 """Resolve exact source commit honors recovery SHAs and rejects bad ones."""
616 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
617 resolve_step = _workflow_step(
618 cast("dict[str, Any]", yaml.safe_load(workflow)),
619 "resolve",
620 "Resolve exact source commit",
621 )
622
623 assert resolve_step["env"] == {"REQUESTED_SHA": "${{ inputs.source_sha }}"}
624 resolve_run = str(resolve_step["run"])
625
626 expected_resolution = """\
627if [ -n "$REQUESTED_SHA" ]; then
628 requested_sha=$(printf '%s' "$REQUESTED_SHA" |
629 tr '[:upper:]' '[:lower:]')
630 if ! [[ "$requested_sha" =~ ^[0-9a-f]{40}$ ]]; then
631 echo "source_sha must be a full commit SHA" >&2
632 exit 1
633 fi
634 source_sha=$(git -C source rev-parse "$requested_sha^{commit}")
635 if ! git -C source merge-base --is-ancestor "$source_sha" "$branch_sha"; then
636 echo "$source_sha is not part of ${{ steps.branch.outputs.branch }}" >&2
637 exit 1
638 fi
639else
640 source_sha="$branch_sha"
641fi
642echo "sha=$source_sha" >> "$GITHUB_OUTPUT"
643"""
644 assert expected_resolution in resolve_run
645
646
647def test_release_workflow_discovers_exact_drafts_from_paginated_releases() -> None:
648 """Resolve discovers draft releases through the paginated releases endpoint."""
649 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
650 resolve_run = _workflow_step_run(
651 workflow,
652 job_name="resolve",
653 step_name="Inspect existing tag and release",
654 )
655
656 assert "gh api --paginate --slurp" in resolve_run
657 assert '"repos/$GITHUB_REPOSITORY/releases?per_page=100"' in resolve_run
658 assert "release_workflow.py select-release" in resolve_run
659 assert '--release-json "$release_json"' in resolve_run
660 assert "release_id=$(sed -n 's/^release_id=//p' \"$release_lookup\")" in resolve_run
661
662
663def test_release_workflow_reuses_resolved_draft_id() -> None:
664 """Draft recovery and updates revalidate and reuse the resolved release id."""
665 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
666 parsed_workflow = cast("dict[str, Any]", yaml.safe_load(workflow))
667 recover_step = _workflow_step(
668 parsed_workflow,
669 "build_artifacts",
670 "Recover matching draft assets",
671 )
672 assert recover_step["env"]["RELEASE_ID"] == "${{ needs.resolve.outputs.release_id }}"
673 recover_run = str(recover_step["run"])
674 assert 'if [ -z "$RELEASE_ID" ]; then' in recover_run
675 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in recover_run
676 assert "'.tag_name // empty'" in recover_run
677 assert "'.draft'" in recover_run
678 assert "'.immutable'" in recover_run
679 assert "'.target_commitish'" in recover_run
680 assert "gh release download" not in recover_run
681 assert "Draft must contain exactly one $asset_name asset" in recover_run
682 assert '"repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"' in recover_run
683
684 draft_step = _workflow_step(
685 parsed_workflow,
686 "prepare_draft",
687 "Create or update matching draft",
688 )
689 assert draft_step["env"]["RELEASE_ID"] == "${{ needs.resolve.outputs.release_id }}"
690 draft_run = str(draft_step["run"])
691 assert 'if [ -n "$RELEASE_ID" ]; then' in draft_run
692 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in draft_run
693 assert '"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \\\n' in draft_run
694 assert '"repos/$GITHUB_REPOSITORY/releases" \\\n' in draft_run
695 assert "release_exists=true" in draft_run
696 assert "release_exists=false" in draft_run
697
698 replace_run = str(
699 _workflow_step(
700 parsed_workflow,
701 "prepare_draft",
702 "Replace draft assets",
703 )["run"]
704 )
705 assert "gh release upload" not in replace_run
706 assert (
707 "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/"
708 "$RELEASE_ID/assets?name=$asset_name"
709 ) in replace_run
710
711
712def test_release_workflow_avoids_prepublication_tag_release_lookups() -> None:
713 """Only post-publication work may use GitHub's published tag endpoint."""
714 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
715 parsed_workflow = cast("dict[str, Any]", yaml.safe_load(workflow))
716 tag_endpoint = "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION"
717
718 for job_name in ("resolve", "build_artifacts", "prepare_draft"):
719 for step in parsed_workflow["jobs"][job_name]["steps"]:
720 assert tag_endpoint not in str(step.get("run", ""))
721
722 publication_run = _workflow_step_run(
723 workflow,
724 job_name="publish_release",
725 step_name="Verify immutable release and assets",
726 )
727 assert tag_endpoint in publication_run
728 assert workflow.count(tag_endpoint) == 2
729
730
731def test_release_workflow_publication_state_uses_release_ids() -> None:
732 """Publication lookup must use release IDs and fail closed on mismatches."""
733 workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
734 assert "DRAFT_RELEASE_ID: ${{ needs.prepare_draft.outputs.release_id }}" in workflow
735 assert "PUBLISHED_RELEASE_ID: ${{ needs.resolve.outputs.release_id }}" in workflow
736 assert "RELEASE_STATE: ${{ needs.resolve.outputs.release_state }}" in workflow
737
738 publication_step = _workflow_step_run(
739 workflow,
740 job_name="publish_release",
741 step_name="Detect live publication state",
742 )
743
744 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION"' not in publication_step
745 assert 'gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"' in publication_step
746 assert 'case "$RELEASE_STATE" in' in publication_step
747 assert "new|draft)" in publication_step
748 assert "Missing release id for $RELEASE_STATE release $VERSION" in publication_step
749 assert "tag_name" in publication_step
750 assert "Release $VERSION resolves to tag $live_tag_name" in publication_step
751 assert "Release $VERSION is neither a mutable draft nor immutable" in publication_step
752
753
754def _api_asset(path: Path) -> dict[str, str | int]:
755 return {
756 "name": path.name,
757 "size": path.stat().st_size,
758 "state": "uploaded",
759 "digest": f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}",
760 }
761
762
763def _commit(path: Path, message: str) -> str:
764 state = path / "state"
765 previous = state.read_text(encoding="utf-8") if state.exists() else ""
766 state.write_text(f"{previous}{message}\n", encoding="utf-8")
767 _git(path, "add", "state")
768 _git(path, "commit", "-m", message)
769 return _git(path, "rev-parse", "HEAD")
770
771
772def _git(path: Path, *args: str) -> str:
773 result = subprocess.run( # noqa: S603
774 ["git", "-C", str(path), *args], # noqa: S607
775 check=True,
776 capture_output=True,
777 text=True,
778 )
779 return result.stdout.strip()
780
781
782def _workflow_triggers(workflow: Mapping[object, Any]) -> dict[str, Any]:
783 trigger = workflow.get("on")
784 if trigger is None:
785 trigger = workflow[True]
786 assert isinstance(trigger, dict)
787 return cast("dict[str, Any]", trigger)
788
789
790def _workflow_step(workflow: Mapping[str, Any], job_name: str, step_name: str) -> dict[str, Any]:
791 jobs = workflow["jobs"]
792 assert isinstance(jobs, dict)
793 for step in jobs[job_name]["steps"]:
794 if step.get("name") == step_name:
795 return cast("dict[str, Any]", step)
796 msg = f"Step {step_name!r} not found in job {job_name!r}"
797 raise AssertionError(msg)
798
799
800def _workflow_step_run(workflow: str, job_name: str, step_name: str) -> str:
801 return str(_workflow_step(yaml.safe_load(workflow), job_name, step_name)["run"])
802