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