/
/
/
1name: Create Release
2
3on:
4 workflow_dispatch:
5 inputs:
6 version:
7 description: "Version number (e.g., 1.2.3, 1.2.3b1, or 1.2.3.dev1)"
8 required: true
9 type: string
10 channel:
11 description: "Release channel"
12 required: true
13 type: choice
14 options:
15 - stable
16 - beta
17 - rc
18 - nightly
19 source_sha:
20 description: "Exact full commit SHA for draft/published recovery; leave empty to resolve the current channel branch head"
21 required: false
22 type: string
23 important_notes:
24 description: "Important notes (breaking changes, critical info, etc.)"
25 required: false
26 workflow_call:
27 inputs:
28 version:
29 description: "Version number (e.g., 1.2.3, 1.2.3b1, or 1.2.3.dev1)"
30 required: true
31 type: string
32 channel:
33 description: "Release channel"
34 required: true
35 type: string
36 source_sha:
37 description: "Exact source commit to release"
38 required: true
39 type: string
40 important_notes:
41 description: "Important notes (breaking changes, critical info, etc.)"
42 required: false
43 type: string
44 secrets:
45 MUSIC_ASSISTANT_BOT_PRIVATE_KEY:
46 required: true
47
48env:
49 BASE_IMAGE_VERSION_STABLE: "1.6.3"
50 BASE_IMAGE_VERSION_BETA: "1.6.3"
51 BASE_IMAGE_VERSION_NIGHTLY: "1.6.3"
52 EXPECTED_APP_SLUG: musicassistant-bot
53 EXPECTED_APP_INSTALLATION_ID: "146062122"
54
55permissions:
56 contents: read
57
58concurrency:
59 group: immutable-release
60 cancel-in-progress: false
61
62jobs:
63 resolve:
64 name: Resolve and validate release state
65 runs-on: ubuntu-latest
66 permissions:
67 # GitHub only includes draft releases for tokens with push access.
68 contents: write
69 outputs:
70 branch: ${{ steps.branch.outputs.branch }}
71 source_sha: ${{ steps.source.outputs.sha }}
72 is_prerelease: ${{ steps.version.outputs.is_prerelease }}
73 base_image_version: ${{ steps.version.outputs.base_image_version }}
74 release_state: ${{ steps.state.outputs.release_state }}
75 is_current: ${{ steps.state.outputs.is_current }}
76 release_id: ${{ steps.state.outputs.release_id }}
77 assets_ready: ${{ steps.state.outputs.assets_ready }}
78 wheel_name: ${{ steps.state.outputs.wheel_name }}
79 wheel_size: ${{ steps.state.outputs.wheel_size }}
80 wheel_sha256: ${{ steps.state.outputs.wheel_sha256 }}
81 sdist_name: ${{ steps.state.outputs.sdist_name }}
82 sdist_size: ${{ steps.state.outputs.sdist_size }}
83 sdist_sha256: ${{ steps.state.outputs.sdist_sha256 }}
84 steps:
85 - name: Check out release workflow
86 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
87 with:
88 ref: ${{ github.workflow_sha }}
89 path: .release-workflow
90 persist-credentials: false
91
92 - name: Resolve source branch
93 id: branch
94 run: >-
95 python3 .release-workflow/scripts/release_workflow.py branch
96 --channel "${{ inputs.channel }}"
97 --github-output "$GITHUB_OUTPUT"
98
99 - name: Check out source branch
100 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
101 with:
102 ref: ${{ steps.branch.outputs.branch }}
103 fetch-depth: 0
104 path: source
105 persist-credentials: false
106
107 - name: Resolve exact source commit
108 id: source
109 env:
110 REQUESTED_SHA: ${{ inputs.source_sha }}
111 run: |
112 branch_sha=$(git -C source rev-parse HEAD)
113 if [ -n "$REQUESTED_SHA" ]; then
114 requested_sha=$(printf '%s' "$REQUESTED_SHA" |
115 tr '[:upper:]' '[:lower:]')
116 if ! [[ "$requested_sha" =~ ^[0-9a-f]{40}$ ]]; then
117 echo "source_sha must be a full commit SHA" >&2
118 exit 1
119 fi
120 source_sha=$(git -C source rev-parse "$requested_sha^{commit}")
121 if ! git -C source merge-base --is-ancestor "$source_sha" "$branch_sha"; then
122 echo "$source_sha is not part of ${{ steps.branch.outputs.branch }}" >&2
123 exit 1
124 fi
125 else
126 source_sha="$branch_sha"
127 fi
128 echo "sha=$source_sha" >> "$GITHUB_OUTPUT"
129 echo "Release source: ${{ steps.branch.outputs.branch }}@$source_sha"
130
131 - name: Validate version
132 id: version
133 env:
134 CHANNEL: ${{ inputs.channel }}
135 VERSION: ${{ inputs.version }}
136 run: |
137 python3 .release-workflow/scripts/release_workflow.py validate-version \
138 --channel "$CHANNEL" \
139 --version "$VERSION"
140 case "$CHANNEL" in
141 stable)
142 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
143 echo "base_image_version=${{ env.BASE_IMAGE_VERSION_STABLE }}" >> "$GITHUB_OUTPUT"
144 ;;
145 beta)
146 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
147 echo "base_image_version=${{ env.BASE_IMAGE_VERSION_BETA }}" >> "$GITHUB_OUTPUT"
148 ;;
149 rc)
150 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
151 echo "base_image_version=${{ env.BASE_IMAGE_VERSION_STABLE }}" >> "$GITHUB_OUTPUT"
152 ;;
153 nightly)
154 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
155 echo "base_image_version=${{ env.BASE_IMAGE_VERSION_NIGHTLY }}" >> "$GITHUB_OUTPUT"
156 ;;
157 esac
158
159 - name: Create immutable-settings token
160 id: immutable_token
161 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
162 with:
163 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
164 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
165 owner: ${{ github.repository_owner }}
166 repositories: ${{ github.repository }}
167 permission-administration: read
168
169 - name: Verify GitHub App identity
170 env:
171 APP_SLUG: ${{ steps.immutable_token.outputs.app-slug }}
172 INSTALLATION_ID: ${{ steps.immutable_token.outputs.installation-id }}
173 run: |
174 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
175 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
176 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
177 exit 1
178 fi
179
180 - name: Require immutable releases
181 env:
182 GH_TOKEN: ${{ steps.immutable_token.outputs.token }}
183 run: |
184 enabled=$(gh api \
185 -H "X-GitHub-Api-Version: 2026-03-10" \
186 "repos/$GITHUB_REPOSITORY/immutable-releases" \
187 --jq '.enabled')
188 if [ "$enabled" != "true" ]; then
189 echo "Immutable releases must be enabled before a release can run" >&2
190 exit 1
191 fi
192
193 - name: Inspect existing tag and release
194 id: state
195 env:
196 GH_TOKEN: ${{ github.token }}
197 IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }}
198 SOURCE_SHA: ${{ steps.source.outputs.sha }}
199 VERSION: ${{ inputs.version }}
200 run: |
201 release_json="$RUNNER_TEMP/release.json"
202 release_lookup="$RUNNER_TEMP/release-lookup.txt"
203 gh api --paginate --slurp \
204 "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
205 > "$RUNNER_TEMP/releases.json"
206 python3 .release-workflow/scripts/release_workflow.py select-release \
207 --version "$VERSION" \
208 --releases-json "$RUNNER_TEMP/releases.json" \
209 --release-json "$release_json" \
210 --github-output "$release_lookup"
211 release_exists=$(sed -n 's/^release_exists=//p' "$release_lookup")
212 release_id=$(sed -n 's/^release_id=//p' "$release_lookup")
213
214 tag_sha=""
215 tag_is_resumable=false
216 if git -C source show-ref --verify --quiet "refs/tags/$VERSION"; then
217 tag_sha=$(git -C source rev-parse "refs/tags/$VERSION^{commit}")
218 gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" \
219 > "$RUNNER_TEMP/tag-ref.json"
220 if [ "$(jq -r '.object.type' "$RUNNER_TEMP/tag-ref.json")" = "tag" ]; then
221 tag_object_sha=$(jq -r '.object.sha' "$RUNNER_TEMP/tag-ref.json")
222 gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha" \
223 > "$RUNNER_TEMP/tag-object.json"
224 tag_message="Music Assistant release $VERSION from $SOURCE_SHA"
225 if jq -e \
226 --arg version "$VERSION" \
227 --arg source "$SOURCE_SHA" \
228 --arg message "$tag_message" \
229 '.tag == $version and
230 .message == $message and
231 .object.type == "commit" and
232 .object.sha == $source and
233 .tagger.name == "github-actions[bot]"' \
234 "$RUNNER_TEMP/tag-object.json" > /dev/null; then
235 tag_is_resumable=true
236 fi
237 fi
238 fi
239 current_outputs="$RUNNER_TEMP/current-version.txt"
240 python3 .release-workflow/scripts/release_workflow.py current-version \
241 --channel "${{ inputs.channel }}" \
242 --version "$VERSION" \
243 --repository source \
244 --github-output "$current_outputs"
245 cat "$current_outputs" >> "$GITHUB_OUTPUT"
246 is_current=$(sed -n 's/^is_current=//p' "$current_outputs")
247
248 if [ "$release_exists" = "false" ]; then
249 if [ -n "$tag_sha" ] && [ "$tag_is_resumable" != "true" ]; then
250 echo "Tag $VERSION already exists without a resumable workflow marker" >&2
251 exit 1
252 fi
253 if [ "$is_current" != "true" ]; then
254 echo "Version $VERSION is older than an existing channel tag" >&2
255 exit 1
256 fi
257 {
258 echo "release_state=new"
259 echo "release_id="
260 echo "assets_ready=false"
261 } >> "$GITHUB_OUTPUT"
262 if [ "$tag_is_resumable" = "true" ]; then
263 echo "Resuming the matching release tag $VERSION"
264 fi
265 exit 0
266 fi
267
268 draft=$(jq -r '.draft' "$release_json")
269 immutable=$(jq -r '.immutable' "$release_json")
270 target=$(jq -r '.target_commitish' "$release_json")
271 prerelease=$(jq -r '.prerelease' "$release_json")
272 echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
273
274 if [ "$draft" = "true" ]; then
275 if [ "$immutable" != "false" ] || [ "$target" != "$SOURCE_SHA" ]; then
276 echo "Existing draft does not match source commit $SOURCE_SHA" >&2
277 exit 1
278 fi
279 if [ "$tag_is_resumable" != "true" ] || [ "$tag_sha" != "$SOURCE_SHA" ]; then
280 echo "Existing draft does not have its matching workflow-created tag" >&2
281 exit 1
282 fi
283 if [ "$is_current" != "true" ]; then
284 echo "Draft $VERSION was superseded by a newer channel tag" >&2
285 exit 1
286 fi
287 echo "release_state=draft" >> "$GITHUB_OUTPUT"
288 asset_outputs="$RUNNER_TEMP/draft-assets.txt"
289 if python3 .release-workflow/scripts/release_workflow.py assets \
290 --version "$VERSION" \
291 --release-json "$release_json" \
292 --github-output "$asset_outputs"; then
293 cat "$asset_outputs" >> "$GITHUB_OUTPUT"
294 echo "assets_ready=true" >> "$GITHUB_OUTPUT"
295 echo "Reusing the verified assets from draft $VERSION"
296 else
297 echo "assets_ready=false" >> "$GITHUB_OUTPUT"
298 echo "Draft assets are incomplete or inconsistent and will be replaced"
299 fi
300 exit 0
301 fi
302
303 if [ "$immutable" != "true" ]; then
304 echo "Published release $VERSION is mutable and cannot be resumed" >&2
305 exit 1
306 fi
307 if [ -z "$tag_sha" ] || [ "$tag_sha" != "$SOURCE_SHA" ]; then
308 echo "Immutable release tag $VERSION does not match $SOURCE_SHA" >&2
309 exit 1
310 fi
311 if [ "$prerelease" != "$IS_PRERELEASE" ]; then
312 echo "Immutable release $VERSION has the wrong prerelease state" >&2
313 exit 1
314 fi
315 python3 .release-workflow/scripts/release_workflow.py assets \
316 --version "$VERSION" \
317 --release-json "$release_json" \
318 --github-output "$GITHUB_OUTPUT"
319 {
320 echo "release_state=published"
321 echo "assets_ready=true"
322 } >> "$GITHUB_OUTPUT"
323 echo "Immutable release $VERSION is already published; resuming downstream work"
324 if [ "$is_current" != "true" ]; then
325 echo "A newer channel release exists; rolling downstream state will not be changed"
326 fi
327
328 preflight:
329 name: Test exact release source
330 needs: resolve
331 if: needs.resolve.outputs.release_state != 'published'
332 permissions:
333 contents: read
334 pull-requests: read
335 uses: ./.github/workflows/test.yml
336 with:
337 ref: ${{ needs.resolve.outputs.source_sha }}
338
339 build_artifacts:
340 name: Build or recover release assets
341 runs-on: ubuntu-latest
342 needs: [resolve, preflight]
343 if: needs.resolve.outputs.release_state != 'published'
344 permissions:
345 # GitHub requires push access to download assets from draft releases.
346 contents: write
347 outputs:
348 reused_assets: ${{ steps.mode.outputs.reused_assets }}
349 wheel_name: ${{ steps.assets.outputs.wheel_name }}
350 wheel_size: ${{ steps.assets.outputs.wheel_size }}
351 wheel_sha256: ${{ steps.assets.outputs.wheel_sha256 }}
352 sdist_name: ${{ steps.assets.outputs.sdist_name }}
353 sdist_size: ${{ steps.assets.outputs.sdist_size }}
354 sdist_sha256: ${{ steps.assets.outputs.sdist_sha256 }}
355 steps:
356 - name: Check out release workflow
357 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
358 with:
359 ref: ${{ github.workflow_sha }}
360 path: .release-workflow
361 persist-credentials: false
362
363 - name: Check out release source
364 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
365 with:
366 ref: ${{ needs.resolve.outputs.source_sha }}
367 path: source
368 persist-credentials: false
369
370 - name: Select asset source
371 id: mode
372 env:
373 ASSETS_READY: ${{ needs.resolve.outputs.assets_ready }}
374 run: |
375 echo "reused_assets=$ASSETS_READY" >> "$GITHUB_OUTPUT"
376 mkdir -p source/dist
377
378 - name: Recover matching draft assets
379 if: needs.resolve.outputs.assets_ready == 'true'
380 env:
381 GH_TOKEN: ${{ github.token }}
382 RELEASE_ID: ${{ needs.resolve.outputs.release_id }}
383 SDIST_NAME: ${{ needs.resolve.outputs.sdist_name }}
384 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
385 VERSION: ${{ inputs.version }}
386 WHEEL_NAME: ${{ needs.resolve.outputs.wheel_name }}
387 run: |
388 if [ -z "$RELEASE_ID" ]; then
389 echo "Missing release id for reusable draft assets" >&2
390 exit 1
391 fi
392 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
393 > "$RUNNER_TEMP/release.json"
394 if [ "$(jq -r '.id // empty' "$RUNNER_TEMP/release.json")" != "$RELEASE_ID" ] || \
395 [ "$(jq -r '.tag_name // empty' "$RUNNER_TEMP/release.json")" != "$VERSION" ] || \
396 [ "$(jq -r '.draft' "$RUNNER_TEMP/release.json")" != "true" ] || \
397 [ "$(jq -r '.immutable' "$RUNNER_TEMP/release.json")" != "false" ] || \
398 [ "$(jq -r '.target_commitish' "$RUNNER_TEMP/release.json")" != "$SOURCE_SHA" ]; then
399 echo "Resolved release $RELEASE_ID is no longer the matching mutable draft" >&2
400 exit 1
401 fi
402 for asset_name in "$WHEEL_NAME" "$SDIST_NAME"; do
403 asset_count=$(jq \
404 --arg name "$asset_name" \
405 '[.assets[] | select(.name == $name)] | length' \
406 "$RUNNER_TEMP/release.json")
407 if [ "$asset_count" -ne 1 ]; then
408 echo "Draft must contain exactly one $asset_name asset" >&2
409 exit 1
410 fi
411 asset_id=$(jq -r \
412 --arg name "$asset_name" \
413 '.assets[] | select(.name == $name) | .id' \
414 "$RUNNER_TEMP/release.json")
415 gh api \
416 -H "Accept: application/octet-stream" \
417 "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" \
418 > "source/dist/$asset_name"
419 done
420
421 - name: Create appvars token
422 if: needs.resolve.outputs.assets_ready != 'true'
423 id: appvars_token
424 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
425 with:
426 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
427 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
428 owner: ${{ github.repository_owner }}
429 repositories: appvars
430 permission-contents: read
431
432 - name: Verify GitHub App identity
433 if: needs.resolve.outputs.assets_ready != 'true'
434 env:
435 APP_SLUG: ${{ steps.appvars_token.outputs.app-slug }}
436 INSTALLATION_ID: ${{ steps.appvars_token.outputs.installation-id }}
437 run: |
438 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
439 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
440 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
441 exit 1
442 fi
443
444 - name: Set up Python
445 if: needs.resolve.outputs.assets_ready != 'true'
446 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
447 with:
448 python-version-file: "source/.python-version"
449 check-latest: true
450
451 - name: Install build dependencies
452 if: needs.resolve.outputs.assets_ready != 'true'
453 run: python3 -m pip install build tomli tomli-w
454
455 - name: Provision app secrets
456 if: needs.resolve.outputs.assets_ready != 'true'
457 env:
458 GH_TOKEN: ${{ steps.appvars_token.outputs.token }}
459 run: |
460 gh api \
461 -H "Accept: application/vnd.github.raw" \
462 "repos/music-assistant/appvars/contents/app_secrets.json" \
463 > source/music_assistant/helpers/app_secrets.json
464
465 - name: Set package version
466 if: needs.resolve.outputs.assets_ready != 'true'
467 working-directory: source
468 shell: python
469 env:
470 VERSION: ${{ inputs.version }}
471 run: |
472 import os
473 import tomli
474 import tomli_w
475
476 with open("pyproject.toml", "rb") as file_handle:
477 pyproject = tomli.load(file_handle)
478 pyproject["project"]["version"] = os.environ["VERSION"]
479 with open("pyproject.toml", "wb") as file_handle:
480 tomli_w.dump(pyproject, file_handle)
481
482 - name: Build wheel and source distribution
483 if: needs.resolve.outputs.assets_ready != 'true'
484 working-directory: source
485 run: python3 -m build
486
487 - name: Verify exact assets
488 id: assets
489 env:
490 REUSED_ASSETS: ${{ steps.mode.outputs.reused_assets }}
491 VERSION: ${{ inputs.version }}
492 run: |
493 extra_args=()
494 if [ "$REUSED_ASSETS" = "true" ]; then
495 extra_args=(--release-json "$RUNNER_TEMP/release.json")
496 fi
497 python3 .release-workflow/scripts/release_workflow.py assets \
498 --version "$VERSION" \
499 --directory source/dist \
500 "${extra_args[@]}" \
501 --github-output "$GITHUB_OUTPUT"
502
503 - name: Preserve release assets
504 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
505 with:
506 name: release-dists-${{ github.run_id }}
507 path: source/dist/
508 if-no-files-found: error
509 compression-level: 0
510 retention-days: 1
511 overwrite: true
512
513 prepare_draft:
514 name: Prepare and verify draft release
515 runs-on: ubuntu-latest
516 needs: [resolve, build_artifacts]
517 if: needs.resolve.outputs.release_state != 'published'
518 permissions:
519 contents: write
520 pull-requests: read
521 outputs:
522 release_id: ${{ steps.draft.outputs.release_id }}
523 wheel_name: ${{ steps.assets.outputs.wheel_name }}
524 wheel_size: ${{ steps.assets.outputs.wheel_size }}
525 wheel_sha256: ${{ steps.assets.outputs.wheel_sha256 }}
526 sdist_name: ${{ steps.assets.outputs.sdist_name }}
527 sdist_size: ${{ steps.assets.outputs.sdist_size }}
528 sdist_sha256: ${{ steps.assets.outputs.sdist_sha256 }}
529 steps:
530 - name: Check out release workflow
531 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
532 with:
533 ref: ${{ github.workflow_sha }}
534 path: .release-workflow
535 persist-credentials: false
536
537 - name: Check out release source
538 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
539 with:
540 ref: ${{ needs.resolve.outputs.source_sha }}
541 fetch-depth: 0
542 path: source
543 persist-credentials: false
544
545 - name: Restore release assets
546 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
547 with:
548 name: release-dists-${{ github.run_id }}
549 path: source/dist/
550
551 - name: Determine previous release tag
552 id: previous
553 working-directory: source
554 run: >-
555 python3 ../.release-workflow/scripts/release_workflow.py previous-tag
556 --channel "${{ inputs.channel }}"
557 --version "${{ inputs.version }}"
558 --github-output "$GITHUB_OUTPUT"
559
560 - name: Generate release notes
561 id: notes
562 uses: ./.release-workflow/.github/actions/generate-release-notes
563 with:
564 version: ${{ inputs.version }}
565 previous-tag: ${{ steps.previous.outputs.previous_tag }}
566 head-sha: ${{ needs.resolve.outputs.source_sha }}
567 repository-path: source
568 channel: ${{ inputs.channel }}
569 github-token: ${{ github.token }}
570 important-notes: ${{ inputs.important_notes }}
571
572 - name: Format release title
573 id: title
574 env:
575 CHANNEL: ${{ inputs.channel }}
576 VERSION: ${{ inputs.version }}
577 run: |
578 case "$CHANNEL" in
579 nightly)
580 if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)\.dev([0-9]+)$ ]]; then
581 title="${BASH_REMATCH[1]} NIGHTLY ${BASH_REMATCH[2]}"
582 else
583 title="$VERSION NIGHTLY"
584 fi
585 ;;
586 beta)
587 if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)b([0-9]+)$ ]]; then
588 title="${BASH_REMATCH[1]} BETA ${BASH_REMATCH[2]}"
589 else
590 title="$VERSION BETA"
591 fi
592 ;;
593 rc)
594 if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)rc([0-9]+)$ ]]; then
595 title="${BASH_REMATCH[1]} RC ${BASH_REMATCH[2]}"
596 else
597 title="$VERSION RC"
598 fi
599 ;;
600 stable)
601 title="$VERSION"
602 ;;
603 esac
604 echo "title=$title" >> "$GITHUB_OUTPUT"
605
606 - name: Create or update matching draft
607 id: draft
608 env:
609 GH_TOKEN: ${{ github.token }}
610 IS_PRERELEASE: ${{ needs.resolve.outputs.is_prerelease }}
611 RELEASE_ID: ${{ needs.resolve.outputs.release_id }}
612 RELEASE_NOTES_FILE: ${{ steps.notes.outputs.release-notes-file }}
613 RELEASE_TITLE: ${{ steps.title.outputs.title }}
614 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
615 VERSION: ${{ inputs.version }}
616 run: |
617 release_json="$RUNNER_TEMP/release.json"
618 if [ -n "$RELEASE_ID" ]; then
619 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
620 > "$release_json"
621 if [ "$(jq -r '.id // empty' "$release_json")" != "$RELEASE_ID" ] || \
622 [ "$(jq -r '.tag_name // empty' "$release_json")" != "$VERSION" ] || \
623 [ "$(jq -r '.draft' "$release_json")" != "true" ] || \
624 [ "$(jq -r '.immutable' "$release_json")" != "false" ] || \
625 [ "$(jq -r '.target_commitish' "$release_json")" != "$SOURCE_SHA" ]; then
626 echo "Resolved release $RELEASE_ID is no longer the matching mutable draft" >&2
627 exit 1
628 fi
629 release_exists=true
630 else
631 release_exists=false
632 fi
633
634 jq -n \
635 --arg tag "$VERSION" \
636 --arg target "$SOURCE_SHA" \
637 --arg name "$RELEASE_TITLE" \
638 --rawfile body "$RELEASE_NOTES_FILE" \
639 --argjson prerelease "$IS_PRERELEASE" \
640 '{
641 tag_name: $tag,
642 target_commitish: $target,
643 name: $name,
644 body: $body,
645 draft: true,
646 prerelease: $prerelease
647 }' > "$RUNNER_TEMP/release-payload.json"
648
649 tag_ref="$RUNNER_TEMP/tag-ref.json"
650 tag_error="$RUNNER_TEMP/tag-error.txt"
651 if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" \
652 > "$tag_ref" 2> "$tag_error"; then
653 tag_exists=true
654 elif grep -q "HTTP 404" "$tag_error"; then
655 tag_exists=false
656 else
657 cat "$tag_error" >&2
658 exit 1
659 fi
660
661 tag_message="Music Assistant release $VERSION from $SOURCE_SHA"
662 if [ "$tag_exists" = "true" ]; then
663 if [ "$(jq -r '.object.type' "$tag_ref")" != "tag" ]; then
664 echo "Existing tag $VERSION was not created by this workflow" >&2
665 exit 1
666 fi
667 tag_object_sha=$(jq -r '.object.sha' "$tag_ref")
668 gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha" \
669 > "$RUNNER_TEMP/tag-object.json"
670 jq -e \
671 --arg version "$VERSION" \
672 --arg source "$SOURCE_SHA" \
673 --arg message "$tag_message" \
674 '.tag == $version and
675 .message == $message and
676 .object.type == "commit" and
677 .object.sha == $source and
678 .tagger.name == "github-actions[bot]"' \
679 "$RUNNER_TEMP/tag-object.json" > /dev/null
680 elif [ "$release_exists" = "true" ]; then
681 echo "Existing draft $VERSION has no matching release tag" >&2
682 exit 1
683 else
684 tag_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
685 jq -n \
686 --arg tag "$VERSION" \
687 --arg message "$tag_message" \
688 --arg object "$SOURCE_SHA" \
689 --arg date "$tag_date" \
690 '{
691 tag: $tag,
692 message: $message,
693 object: $object,
694 type: "commit",
695 tagger: {
696 name: "github-actions[bot]",
697 email: "41898282+github-actions[bot]@users.noreply.github.com",
698 date: $date
699 }
700 }' > "$RUNNER_TEMP/tag-payload.json"
701 gh api --method POST \
702 "repos/$GITHUB_REPOSITORY/git/tags" \
703 --input "$RUNNER_TEMP/tag-payload.json" \
704 > "$RUNNER_TEMP/tag-object.json"
705 tag_object_sha=$(jq -r '.sha' "$RUNNER_TEMP/tag-object.json")
706 jq -n \
707 --arg ref "refs/tags/$VERSION" \
708 --arg sha "$tag_object_sha" \
709 '{ref: $ref, sha: $sha}' > "$RUNNER_TEMP/tag-ref-payload.json"
710 gh api --method POST \
711 "repos/$GITHUB_REPOSITORY/git/refs" \
712 --input "$RUNNER_TEMP/tag-ref-payload.json" \
713 > "$tag_ref"
714 fi
715
716 git -C source fetch origin \
717 "refs/tags/$VERSION:refs/tags/$VERSION" \
718 --quiet
719 tag_sha=$(git -C source rev-parse "refs/tags/$VERSION^{commit}")
720 if [ "$tag_sha" != "$SOURCE_SHA" ]; then
721 echo "Release tag $VERSION does not point to $SOURCE_SHA" >&2
722 exit 1
723 fi
724
725 if [ "$release_exists" = "true" ]; then
726 gh api --method PATCH \
727 "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
728 --input "$RUNNER_TEMP/release-payload.json" \
729 > "$release_json"
730 else
731 gh api --method POST \
732 "repos/$GITHUB_REPOSITORY/releases" \
733 --input "$RUNNER_TEMP/release-payload.json" \
734 > "$release_json"
735 fi
736 release_id=$(jq -r '.id // empty' "$release_json")
737 if [ -z "$release_id" ] || \
738 { [ -n "$RELEASE_ID" ] && [ "$release_id" != "$RELEASE_ID" ]; } || \
739 [ "$(jq -r '.tag_name // empty' "$release_json")" != "$VERSION" ] || \
740 [ "$(jq -r '.draft' "$release_json")" != "true" ] || \
741 [ "$(jq -r '.immutable' "$release_json")" != "false" ] || \
742 [ "$(jq -r '.target_commitish' "$release_json")" != "$SOURCE_SHA" ]; then
743 echo "GitHub did not return the matching mutable draft for $VERSION" >&2
744 exit 1
745 fi
746 echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
747
748 - name: Replace draft assets
749 if: needs.build_artifacts.outputs.reused_assets != 'true'
750 env:
751 GH_TOKEN: ${{ github.token }}
752 RELEASE_ID: ${{ steps.draft.outputs.release_id }}
753 SDIST_NAME: ${{ needs.build_artifacts.outputs.sdist_name }}
754 VERSION: ${{ inputs.version }}
755 WHEEL_NAME: ${{ needs.build_artifacts.outputs.wheel_name }}
756 run: |
757 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
758 --jq '.assets[].id' |
759 while read -r asset_id; do
760 gh api --method DELETE \
761 "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"
762 done
763 for asset_path in \
764 "source/dist/$WHEEL_NAME" \
765 "source/dist/$SDIST_NAME"; do
766 asset_name=$(basename "$asset_path")
767 curl --fail-with-body --silent --show-error \
768 --request POST \
769 --header "Accept: application/vnd.github+json" \
770 --header "Authorization: Bearer $GH_TOKEN" \
771 --header "Content-Type: application/octet-stream" \
772 --header "X-GitHub-Api-Version: 2026-03-10" \
773 --data-binary "@$asset_path" \
774 "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$asset_name" \
775 > "$RUNNER_TEMP/upload-$asset_name.json"
776 done
777
778 - name: Verify draft assets
779 id: assets
780 env:
781 GH_TOKEN: ${{ github.token }}
782 RELEASE_ID: ${{ steps.draft.outputs.release_id }}
783 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
784 VERSION: ${{ inputs.version }}
785 run: |
786 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
787 > "$RUNNER_TEMP/release.json"
788 if [ "$(jq -r '.draft' "$RUNNER_TEMP/release.json")" != "true" ] || \
789 [ "$(jq -r '.target_commitish' "$RUNNER_TEMP/release.json")" != "$SOURCE_SHA" ]; then
790 echo "Draft release changed while its assets were prepared" >&2
791 exit 1
792 fi
793 python3 .release-workflow/scripts/release_workflow.py assets \
794 --version "$VERSION" \
795 --directory source/dist \
796 --release-json "$RUNNER_TEMP/release.json" \
797 --github-output "$GITHUB_OUTPUT"
798
799 exact_image:
800 name: Build or verify exact container image
801 runs-on: ubuntu-latest
802 needs: [resolve, prepare_draft]
803 if: >-
804 ${{
805 always() &&
806 !cancelled() &&
807 needs.resolve.result == 'success' &&
808 (
809 needs.prepare_draft.result == 'success' ||
810 (
811 needs.resolve.outputs.release_state == 'published' &&
812 needs.prepare_draft.result == 'skipped'
813 )
814 )
815 }}
816 permissions:
817 contents: read
818 packages: write
819 outputs:
820 digest: ${{ steps.verify.outputs.digest }}
821 steps:
822 - name: Check out release workflow
823 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
824 with:
825 ref: ${{ github.workflow_sha }}
826 path: .release-workflow
827 persist-credentials: false
828
829 - name: Check out release source
830 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
831 with:
832 ref: ${{ needs.resolve.outputs.source_sha }}
833 path: source
834 persist-credentials: false
835
836 - name: Restore release assets
837 if: needs.resolve.outputs.release_state != 'published'
838 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
839 with:
840 name: release-dists-${{ github.run_id }}
841 path: source/dist/
842
843 - name: Log in to GitHub Container Registry
844 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
845 with:
846 registry: ghcr.io
847 username: ${{ github.repository_owner }}
848 password: ${{ github.token }}
849
850 - name: Set up Docker Buildx
851 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
852
853 - name: Inspect exact image
854 id: existing
855 env:
856 IMAGE: ghcr.io/${{ github.repository_owner }}/server:${{ inputs.version }}
857 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
858 WHEEL_SHA256: ${{ needs.resolve.outputs.release_state == 'published' && needs.resolve.outputs.wheel_sha256 || needs.prepare_draft.outputs.wheel_sha256 }}
859 run: |
860 error_file="$RUNNER_TEMP/image-inspect-error.txt"
861 for attempt in 1 2 3; do
862 if docker buildx imagetools inspect "$IMAGE" \
863 --format '{{json .Manifest}}' \
864 > "$RUNNER_TEMP/exact-manifest.json" 2> "$error_file"; then
865 echo "exists=true" >> "$GITHUB_OUTPUT"
866 python3 .release-workflow/scripts/release_workflow.py verify-manifest \
867 --manifest-json "$RUNNER_TEMP/exact-manifest.json" \
868 --source-sha "$SOURCE_SHA" \
869 --wheel-sha256 "$WHEEL_SHA256"
870 exit 0
871 fi
872 if grep -Eqi 'not found|manifest unknown|status.*404' "$error_file"; then
873 echo "exists=false" >> "$GITHUB_OUTPUT"
874 if [ "${{ needs.resolve.outputs.release_state }}" = "published" ]; then
875 echo "Immutable release ${{ inputs.version }} has no exact image" >&2
876 exit 1
877 fi
878 exit 0
879 fi
880 if [ "$attempt" -lt 3 ]; then
881 sleep 5
882 fi
883 done
884 cat "$error_file" >&2
885 exit 1
886
887 - name: Build and push exact image
888 if: steps.existing.outputs.exists != 'true'
889 id: build
890 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
891 with:
892 context: source
893 platforms: linux/amd64,linux/arm64
894 file: source/Dockerfile
895 tags: ghcr.io/${{ github.repository_owner }}/server:${{ inputs.version }}
896 push: true
897 provenance: true
898 build-args: |
899 MASS_VERSION=${{ inputs.version }}
900 BASE_IMAGE_VERSION=${{ needs.resolve.outputs.base_image_version }}
901 labels: |
902 org.opencontainers.image.version=${{ inputs.version }}
903 org.opencontainers.image.revision=${{ needs.resolve.outputs.source_sha }}
904 io.music-assistant.wheel.sha256=${{ needs.prepare_draft.outputs.wheel_sha256 }}
905 annotations: |
906 index:org.opencontainers.image.version=${{ inputs.version }}
907 index:org.opencontainers.image.revision=${{ needs.resolve.outputs.source_sha }}
908 index:io.music-assistant.wheel.sha256=${{ needs.prepare_draft.outputs.wheel_sha256 }}
909
910 - name: Verify exact image
911 id: verify
912 env:
913 BUILT_DIGEST: ${{ steps.build.outputs.digest }}
914 IMAGE: ghcr.io/${{ github.repository_owner }}/server:${{ inputs.version }}
915 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
916 WHEEL_SHA256: ${{ needs.resolve.outputs.release_state == 'published' && needs.resolve.outputs.wheel_sha256 || needs.prepare_draft.outputs.wheel_sha256 }}
917 run: |
918 manifest_outputs=""
919 runtime_digests=""
920 for attempt in {1..12}; do
921 if docker buildx imagetools inspect "$IMAGE" \
922 --format '{{json .Manifest}}' \
923 > "$RUNNER_TEMP/exact-manifest.json"; then
924 if manifest_outputs=$(python3 \
925 .release-workflow/scripts/release_workflow.py verify-manifest \
926 --manifest-json "$RUNNER_TEMP/exact-manifest.json" \
927 --source-sha "$SOURCE_SHA" \
928 --wheel-sha256 "$WHEEL_SHA256"); then
929 printf '%s\n' "$manifest_outputs" >> "$GITHUB_OUTPUT"
930 runtime_digests=$(printf '%s\n' "$manifest_outputs" |
931 sed -n 's/^runtime_digests=//p')
932 break
933 fi
934 fi
935 if [ "$attempt" -eq 12 ]; then
936 echo "Exact image did not become verifiable" >&2
937 exit 1
938 fi
939 sleep 5
940 done
941
942 digest=$(jq -r '.digest' "$RUNNER_TEMP/exact-manifest.json")
943 if [ -n "$BUILT_DIGEST" ] && [ "$BUILT_DIGEST" != "$digest" ]; then
944 echo "Build output digest $BUILT_DIGEST does not match registry digest $digest" >&2
945 exit 1
946 fi
947
948 for runtime_digest in $runtime_digests; do
949 docker buildx imagetools inspect \
950 "ghcr.io/${{ github.repository_owner }}/server@$runtime_digest" \
951 --format '{{json .Image}}' > "$RUNNER_TEMP/image-config.json"
952 jq -e \
953 --arg source "$SOURCE_SHA" \
954 --arg wheel "$WHEEL_SHA256" \
955 '.config.Labels["org.opencontainers.image.revision"] == $source and
956 .config.Labels["io.music-assistant.wheel.sha256"] == $wheel' \
957 "$RUNNER_TEMP/image-config.json" > /dev/null
958 done
959
960 publish_release:
961 name: Publish and verify immutable release
962 runs-on: ubuntu-latest
963 needs: [resolve, prepare_draft, exact_image]
964 if: >-
965 ${{
966 always() &&
967 !cancelled() &&
968 needs.resolve.result == 'success' &&
969 needs.exact_image.result == 'success' &&
970 (
971 needs.prepare_draft.result == 'success' ||
972 (
973 needs.resolve.outputs.release_state == 'published' &&
974 needs.prepare_draft.result == 'skipped'
975 )
976 )
977 }}
978 permissions:
979 contents: write
980 packages: read
981 outputs:
982 wheel_sha256: ${{ steps.verify_assets.outputs.wheel_sha256 }}
983 steps:
984 - name: Check out release workflow
985 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
986 with:
987 ref: ${{ github.workflow_sha }}
988 path: .release-workflow
989 persist-credentials: false
990
991 - name: Check out release source
992 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
993 with:
994 ref: ${{ needs.resolve.outputs.source_sha }}
995 fetch-depth: 0
996 path: source
997 persist-credentials: false
998
999 - name: Detect live publication state
1000 id: publication_state
1001 env:
1002 GH_TOKEN: ${{ github.token }}
1003 DRAFT_RELEASE_ID: ${{ needs.prepare_draft.outputs.release_id }}
1004 PUBLISHED_RELEASE_ID: ${{ needs.resolve.outputs.release_id }}
1005 RELEASE_STATE: ${{ needs.resolve.outputs.release_state }}
1006 VERSION: ${{ inputs.version }}
1007 run: |
1008 case "$RELEASE_STATE" in
1009 new|draft)
1010 RELEASE_ID="$DRAFT_RELEASE_ID"
1011 ;;
1012 published)
1013 RELEASE_ID="$PUBLISHED_RELEASE_ID"
1014 ;;
1015 *)
1016 echo "Unsupported release state: $RELEASE_STATE" >&2
1017 exit 1
1018 ;;
1019 esac
1020 if [ -z "$RELEASE_ID" ]; then
1021 echo "Missing release id for $RELEASE_STATE release $VERSION" >&2
1022 exit 1
1023 fi
1024 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
1025 > "$RUNNER_TEMP/live-release.json"
1026 live_tag_name=$(jq -r '.tag_name' "$RUNNER_TEMP/live-release.json")
1027 if [ "$live_tag_name" != "$VERSION" ]; then
1028 echo "Release $VERSION resolves to tag $live_tag_name" >&2
1029 exit 1
1030 fi
1031 draft=$(jq -r '.draft' "$RUNNER_TEMP/live-release.json")
1032 immutable=$(jq -r '.immutable' "$RUNNER_TEMP/live-release.json")
1033 if [ "$draft" = "true" ] && [ "$immutable" = "false" ]; then
1034 echo "already_published=false" >> "$GITHUB_OUTPUT"
1035 elif [ "$draft" = "false" ] && [ "$immutable" = "true" ]; then
1036 echo "already_published=true" >> "$GITHUB_OUTPUT"
1037 echo "Release $VERSION is already immutable; skipping publication"
1038 else
1039 echo "Release $VERSION is neither a mutable draft nor immutable" >&2
1040 exit 1
1041 fi
1042
1043 - name: Restore draft assets
1044 if: steps.publication_state.outputs.already_published != 'true'
1045 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
1046 with:
1047 name: release-dists-${{ github.run_id }}
1048 path: source/dist/
1049
1050 - name: Revalidate draft
1051 if: steps.publication_state.outputs.already_published != 'true'
1052 env:
1053 GH_TOKEN: ${{ github.token }}
1054 RELEASE_ID: ${{ needs.prepare_draft.outputs.release_id }}
1055 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
1056 VERSION: ${{ inputs.version }}
1057 run: |
1058 gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
1059 > "$RUNNER_TEMP/release.json"
1060 if [ "$(jq -r '.draft' "$RUNNER_TEMP/release.json")" != "true" ] || \
1061 [ "$(jq -r '.immutable' "$RUNNER_TEMP/release.json")" != "false" ] || \
1062 [ "$(jq -r '.target_commitish' "$RUNNER_TEMP/release.json")" != "$SOURCE_SHA" ]; then
1063 echo "Release $VERSION is no longer the prepared draft" >&2
1064 exit 1
1065 fi
1066 python3 .release-workflow/scripts/release_workflow.py assets \
1067 --version "$VERSION" \
1068 --directory source/dist \
1069 --release-json "$RUNNER_TEMP/release.json"
1070
1071 - name: Create publication settings token
1072 if: steps.publication_state.outputs.already_published != 'true'
1073 id: immutable_token
1074 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1075 with:
1076 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
1077 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
1078 owner: ${{ github.repository_owner }}
1079 repositories: ${{ github.repository }}
1080 permission-administration: read
1081
1082 - name: Verify GitHub App identity
1083 if: steps.publication_state.outputs.already_published != 'true'
1084 env:
1085 APP_SLUG: ${{ steps.immutable_token.outputs.app-slug }}
1086 INSTALLATION_ID: ${{ steps.immutable_token.outputs.installation-id }}
1087 run: |
1088 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
1089 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
1090 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
1091 exit 1
1092 fi
1093
1094 - name: Recheck immutable releases
1095 if: steps.publication_state.outputs.already_published != 'true'
1096 env:
1097 GH_TOKEN: ${{ steps.immutable_token.outputs.token }}
1098 run: |
1099 enabled=$(gh api \
1100 -H "X-GitHub-Api-Version: 2026-03-10" \
1101 "repos/$GITHUB_REPOSITORY/immutable-releases" \
1102 --jq '.enabled')
1103 if [ "$enabled" != "true" ]; then
1104 echo "Immutable releases were disabled before publication" >&2
1105 exit 1
1106 fi
1107
1108 - name: Publish release once
1109 if: steps.publication_state.outputs.already_published != 'true'
1110 env:
1111 GH_TOKEN: ${{ github.token }}
1112 RELEASE_ID: ${{ needs.prepare_draft.outputs.release_id }}
1113 VERSION: ${{ inputs.version }}
1114 run: |
1115 gh api --method PATCH \
1116 "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
1117 -F draft=false > "$RUNNER_TEMP/published-release.json"
1118 if [ "$(jq -r '.immutable' "$RUNNER_TEMP/published-release.json")" != "true" ]; then
1119 echo "GitHub published $VERSION without making it immutable" >&2
1120 exit 1
1121 fi
1122
1123 - name: Verify immutable release and assets
1124 id: verify_assets
1125 env:
1126 GH_TOKEN: ${{ github.token }}
1127 IS_PRERELEASE: ${{ needs.resolve.outputs.is_prerelease }}
1128 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
1129 VERSION: ${{ inputs.version }}
1130 run: |
1131 for attempt in {1..12}; do
1132 if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION" \
1133 > "$RUNNER_TEMP/release.json" &&
1134 [ "$(jq -r '.immutable' "$RUNNER_TEMP/release.json")" = "true" ]; then
1135 break
1136 fi
1137 if [ "$attempt" -eq 12 ]; then
1138 echo "Release $VERSION did not become immutable" >&2
1139 exit 1
1140 fi
1141 sleep 5
1142 done
1143
1144 if [ "$(jq -r '.draft' "$RUNNER_TEMP/release.json")" != "false" ] || \
1145 [ "$(jq -r '.prerelease' "$RUNNER_TEMP/release.json")" != "$IS_PRERELEASE" ]; then
1146 echo "Published release metadata does not match the requested channel" >&2
1147 exit 1
1148 fi
1149 if [ "$(gh release view "$VERSION" \
1150 --repo "$GITHUB_REPOSITORY" \
1151 --json isImmutable \
1152 --jq '.isImmutable')" != "true" ]; then
1153 echo "GitHub CLI does not report $VERSION as immutable" >&2
1154 exit 1
1155 fi
1156
1157 git -C source fetch origin \
1158 "refs/tags/$VERSION:refs/tags/$VERSION" \
1159 --quiet
1160 tag_sha=$(git -C source rev-parse "refs/tags/$VERSION^{commit}")
1161 if [ "$tag_sha" != "$SOURCE_SHA" ]; then
1162 echo "Published tag $VERSION points to $tag_sha, not $SOURCE_SHA" >&2
1163 exit 1
1164 fi
1165
1166 rm -rf "$RUNNER_TEMP/verified-assets"
1167 mkdir -p "$RUNNER_TEMP/verified-assets"
1168 python3 .release-workflow/scripts/release_workflow.py assets \
1169 --version "$VERSION" \
1170 --release-json "$RUNNER_TEMP/release.json"
1171 gh release download "$VERSION" \
1172 --repo "$GITHUB_REPOSITORY" \
1173 --dir "$RUNNER_TEMP/verified-assets" \
1174 --pattern "music_assistant-$VERSION*"
1175 python3 .release-workflow/scripts/release_workflow.py assets \
1176 --version "$VERSION" \
1177 --directory "$RUNNER_TEMP/verified-assets" \
1178 --release-json "$RUNNER_TEMP/release.json" \
1179 --github-output "$GITHUB_OUTPUT"
1180
1181 for attempt in {1..12}; do
1182 if gh release verify "$VERSION" --repo "$GITHUB_REPOSITORY"; then
1183 break
1184 fi
1185 if [ "$attempt" -eq 12 ]; then
1186 echo "Release attestation verification failed" >&2
1187 exit 1
1188 fi
1189 sleep 5
1190 done
1191 for asset in "$RUNNER_TEMP/verified-assets"/*; do
1192 gh release verify-asset "$VERSION" "$asset" --repo "$GITHUB_REPOSITORY"
1193 done
1194
1195 - name: Log in to GitHub Container Registry
1196 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1197 with:
1198 registry: ghcr.io
1199 username: ${{ github.repository_owner }}
1200 password: ${{ github.token }}
1201
1202 - name: Set up Docker Buildx
1203 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
1204
1205 - name: Verify published exact image
1206 env:
1207 EXPECTED_DIGEST: ${{ needs.exact_image.outputs.digest }}
1208 IMAGE: ghcr.io/${{ github.repository_owner }}/server:${{ inputs.version }}
1209 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
1210 WHEEL_SHA256: ${{ steps.verify_assets.outputs.wheel_sha256 }}
1211 run: |
1212 docker buildx imagetools inspect "$IMAGE" \
1213 --format '{{json .Manifest}}' \
1214 > "$RUNNER_TEMP/exact-manifest.json"
1215 python3 .release-workflow/scripts/release_workflow.py verify-manifest \
1216 --manifest-json "$RUNNER_TEMP/exact-manifest.json" \
1217 --source-sha "$SOURCE_SHA" \
1218 --wheel-sha256 "$WHEEL_SHA256"
1219 digest=$(jq -r '.digest' "$RUNNER_TEMP/exact-manifest.json")
1220 if [ "$digest" != "$EXPECTED_DIGEST" ]; then
1221 echo "Exact image digest changed before release verification" >&2
1222 exit 1
1223 fi
1224
1225 promote_image:
1226 name: Promote verified image digest
1227 runs-on: ubuntu-latest
1228 needs: [resolve, exact_image, publish_release]
1229 if: needs.resolve.outputs.is_current == 'true'
1230 permissions:
1231 contents: read
1232 packages: write
1233 steps:
1234 - name: Check out release workflow
1235 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1236 with:
1237 ref: ${{ github.workflow_sha }}
1238 path: .release-workflow
1239 persist-credentials: false
1240
1241 - name: Log in to GitHub Container Registry
1242 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1243 with:
1244 registry: ghcr.io
1245 username: ${{ github.repository_owner }}
1246 password: ${{ github.token }}
1247
1248 - name: Set up Docker Buildx
1249 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
1250
1251 - name: Promote exact digest to rolling aliases
1252 env:
1253 CHANNEL: ${{ inputs.channel }}
1254 DIGEST: ${{ needs.exact_image.outputs.digest }}
1255 IMAGE_REPOSITORY: ghcr.io/${{ github.repository_owner }}/server
1256 VERSION: ${{ inputs.version }}
1257 run: |
1258 aliases=()
1259 case "$CHANNEL" in
1260 stable)
1261 if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
1262 echo "Unable to parse stable version $VERSION" >&2
1263 exit 1
1264 fi
1265 aliases=(
1266 "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
1267 "${BASH_REMATCH[1]}"
1268 "stable"
1269 "latest"
1270 )
1271 ;;
1272 beta|rc)
1273 aliases=("beta")
1274 ;;
1275 nightly)
1276 aliases=("nightly")
1277 ;;
1278 esac
1279
1280 promotion_aliases=()
1281 for alias in "${aliases[@]}"; do
1282 target="$IMAGE_REPOSITORY:$alias"
1283 error_file="$RUNNER_TEMP/alias-$alias-error.txt"
1284 manifest_file="$RUNNER_TEMP/alias-$alias-manifest.json"
1285 if docker buildx imagetools inspect "$target" \
1286 --format '{{json .Manifest}}' \
1287 > "$manifest_file" 2> "$error_file"; then
1288 platforms=$(jq -r \
1289 '.manifests[]
1290 | select(.platform.os != "unknown")
1291 | "\(.platform.os)/\(.platform.architecture)"' \
1292 "$manifest_file" | sort)
1293 if [ "$platforms" != $'linux/amd64\nlinux/arm64' ]; then
1294 echo "$target has an unexpected platform set: $platforms" >&2
1295 exit 1
1296 fi
1297
1298 versions_file="$RUNNER_TEMP/alias-$alias-versions.txt"
1299 : > "$versions_file"
1300 while read -r runtime_digest; do
1301 docker buildx imagetools inspect \
1302 "$IMAGE_REPOSITORY@$runtime_digest" \
1303 --format '{{json .Image}}' > "$RUNNER_TEMP/alias-image.json"
1304 current_version=$(jq -r \
1305 '.config.Labels["org.opencontainers.image.version"]
1306 // .config.Labels["io.hass.version"]
1307 // empty' \
1308 "$RUNNER_TEMP/alias-image.json")
1309 if [ -z "$current_version" ]; then
1310 echo "$target has no release version label" >&2
1311 exit 1
1312 fi
1313 echo "$current_version" >> "$versions_file"
1314 done < <(jq -r \
1315 '.manifests[]
1316 | select(.platform.os == "linux")
1317 | .digest' \
1318 "$manifest_file")
1319
1320 mapfile -t current_versions < <(sort -u "$versions_file")
1321 if [ "${#current_versions[@]}" -ne 1 ]; then
1322 echo "$target has inconsistent release version labels" >&2
1323 exit 1
1324 fi
1325 relation=$(python3 \
1326 .release-workflow/scripts/release_workflow.py \
1327 compare-release-versions \
1328 --current "${current_versions[0]}" \
1329 --requested "$VERSION" |
1330 sed -n 's/^current_relation=//p')
1331 if [ "$relation" = "newer" ]; then
1332 echo "$target already points to newer version ${current_versions[0]}" >&2
1333 exit 1
1334 fi
1335 current_digest=$(jq -r '.digest' "$manifest_file")
1336 if [ "$relation" = "equal" ] && [ "$current_digest" = "$DIGEST" ]; then
1337 echo "$target already points to $VERSION@$DIGEST"
1338 continue
1339 fi
1340 elif ! grep -Eqi 'not found|manifest unknown|status.*404' "$error_file"; then
1341 cat "$error_file" >&2
1342 echo "Unable to inspect $target" >&2
1343 exit 1
1344 fi
1345 promotion_aliases+=("$alias")
1346 done
1347
1348 for alias in "${promotion_aliases[@]}"; do
1349 target="$IMAGE_REPOSITORY:$alias"
1350 docker buildx imagetools create \
1351 --tag "$target" \
1352 "$IMAGE_REPOSITORY@$DIGEST"
1353 for attempt in {1..6}; do
1354 alias_digest=$(docker buildx imagetools inspect "$target" \
1355 --format '{{json .Manifest}}' | jq -r '.digest')
1356 if [ "$alias_digest" = "$DIGEST" ]; then
1357 break
1358 fi
1359 if [ "$attempt" -eq 6 ]; then
1360 echo "$target does not point to $DIGEST" >&2
1361 exit 1
1362 fi
1363 sleep 5
1364 done
1365 done
1366
1367 update_addon:
1368 name: Update Home Assistant add-on
1369 runs-on: ubuntu-latest
1370 needs: [resolve, publish_release, promote_image]
1371 permissions:
1372 contents: read
1373 steps:
1374 - name: Check out release workflow
1375 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1376 with:
1377 ref: ${{ github.workflow_sha }}
1378 path: .release-workflow
1379 persist-credentials: false
1380
1381 - name: Create add-on repository token
1382 id: addon_token
1383 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1384 with:
1385 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
1386 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
1387 owner: ${{ github.repository_owner }}
1388 repositories: home-assistant-addon
1389 permission-contents: write
1390
1391 - name: Verify GitHub App identity
1392 id: app
1393 env:
1394 APP_SLUG: ${{ steps.addon_token.outputs.app-slug }}
1395 GH_TOKEN: ${{ steps.addon_token.outputs.token }}
1396 INSTALLATION_ID: ${{ steps.addon_token.outputs.installation-id }}
1397 run: |
1398 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
1399 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
1400 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
1401 exit 1
1402 fi
1403 user_id=$(gh api "/users/$APP_SLUG%5Bbot%5D" --jq '.id')
1404 echo "slug=$APP_SLUG" >> "$GITHUB_OUTPUT"
1405 echo "user_id=$user_id" >> "$GITHUB_OUTPUT"
1406
1407 - name: Resolve add-on default branch
1408 id: addon_repository
1409 env:
1410 GH_TOKEN: ${{ steps.addon_token.outputs.token }}
1411 run: |
1412 default_branch=$(gh api \
1413 repos/music-assistant/home-assistant-addon \
1414 --jq '.default_branch')
1415 if [ -z "$default_branch" ]; then
1416 echo "Unable to resolve the add-on default branch" >&2
1417 exit 1
1418 fi
1419 echo "default_branch=$default_branch" >> "$GITHUB_OUTPUT"
1420
1421 - name: Check out add-on repository
1422 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1423 with:
1424 repository: music-assistant/home-assistant-addon
1425 ref: ${{ steps.addon_repository.outputs.default_branch }}
1426 token: ${{ steps.addon_token.outputs.token }}
1427 path: addon-repo
1428 fetch-depth: 0
1429
1430 - name: Resolve add-on channel
1431 id: channel
1432 env:
1433 CHANNEL: ${{ inputs.channel }}
1434 run: |
1435 dev_folder=""
1436 case "$CHANNEL" in
1437 stable)
1438 folder="music_assistant"
1439 ;;
1440 beta|rc)
1441 folder="music_assistant_beta"
1442 ;;
1443 nightly)
1444 folder="music_assistant_nightly"
1445 # the dev add-on is built from the nightly image, so it follows this release
1446 dev_folder="music_assistant_dev"
1447 ;;
1448 esac
1449 echo "folder=$folder" >> "$GITHUB_OUTPUT"
1450 echo "dev_folder=$dev_folder" >> "$GITHUB_OUTPUT"
1451
1452 - name: Update add-on release
1453 env:
1454 ADDON_FOLDER: ${{ steps.channel.outputs.folder }}
1455 GH_TOKEN: ${{ github.token }}
1456 VERSION: ${{ inputs.version }}
1457 run: |
1458 gh api "repos/$GITHUB_REPOSITORY/releases/tags/$VERSION" \
1459 > "$RUNNER_TEMP/release.json"
1460 jq -r '.body' "$RUNNER_TEMP/release.json" > "$RUNNER_TEMP/release-notes.md"
1461 release_date=$(jq -r \
1462 '.published_at | fromdateiso8601 | strftime("%d.%m.%Y")' \
1463 "$RUNNER_TEMP/release.json")
1464 python3 .release-workflow/scripts/release_workflow.py update-addon \
1465 --config "addon-repo/$ADDON_FOLDER/config.yaml" \
1466 --changelog "addon-repo/$ADDON_FOLDER/CHANGELOG.md" \
1467 --version "$VERSION" \
1468 --release-date "$release_date" \
1469 --notes "$RUNNER_TEMP/release-notes.md" \
1470 --retain 3
1471
1472 - name: Update dev add-on version
1473 if: steps.channel.outputs.dev_folder != ''
1474 env:
1475 DEV_FOLDER: ${{ steps.channel.outputs.dev_folder }}
1476 VERSION: ${{ inputs.version }}
1477 run: |
1478 python3 .release-workflow/scripts/release_workflow.py set-addon-version \
1479 --config "addon-repo/$DEV_FOLDER/config.yaml" \
1480 --version "$VERSION"
1481
1482 - name: Commit add-on update
1483 env:
1484 ADDON_FOLDER: ${{ steps.channel.outputs.folder }}
1485 DEV_FOLDER: ${{ steps.channel.outputs.dev_folder }}
1486 APP_SLUG: ${{ steps.app.outputs.slug }}
1487 APP_USER_ID: ${{ steps.app.outputs.user_id }}
1488 ADDON_BRANCH: ${{ steps.addon_repository.outputs.default_branch }}
1489 CHANNEL: ${{ inputs.channel }}
1490 VERSION: ${{ inputs.version }}
1491 run: |
1492 cd addon-repo
1493 git add "$ADDON_FOLDER/config.yaml" "$ADDON_FOLDER/CHANGELOG.md"
1494 if [ -n "$DEV_FOLDER" ]; then
1495 git add "$DEV_FOLDER/config.yaml"
1496 fi
1497 if git diff --cached --quiet; then
1498 echo "Add-on $ADDON_FOLDER already references $VERSION"
1499 exit 0
1500 fi
1501 git config user.name "${APP_SLUG}[bot]"
1502 git config user.email \
1503 "${APP_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
1504 git commit -m "ð¤ Bump $CHANNEL add-on to version $VERSION"
1505 for attempt in {1..3}; do
1506 if git pull --rebase origin "$ADDON_BRANCH" &&
1507 git push origin "HEAD:$ADDON_BRANCH"; then
1508 exit 0
1509 fi
1510 git rebase --abort 2>/dev/null || true
1511 if [ "$attempt" -eq 3 ]; then
1512 echo "Unable to push the add-on update after three attempts" >&2
1513 exit 1
1514 fi
1515 sleep 5
1516 done
1517
1518 update_remote_app:
1519 name: Update app.music-assistant.io
1520 runs-on: ubuntu-latest
1521 needs: [resolve, exact_image, publish_release, promote_image]
1522 permissions:
1523 contents: read
1524 steps:
1525 - name: Check out release workflow
1526 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1527 with:
1528 ref: ${{ github.workflow_sha }}
1529 path: .release-workflow
1530 persist-credentials: false
1531
1532 - name: Check out exact release source
1533 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1534 with:
1535 ref: ${{ needs.resolve.outputs.source_sha }}
1536 path: source
1537 persist-credentials: false
1538
1539 - name: Extract frontend version
1540 id: frontend
1541 working-directory: source
1542 shell: python
1543 run: |
1544 import os
1545 import tomllib
1546
1547 with open("pyproject.toml", "rb") as file_handle:
1548 dependencies = tomllib.load(file_handle)["project"]["dependencies"]
1549 matches = [
1550 dependency.removeprefix("music-assistant-frontend==")
1551 for dependency in dependencies
1552 if dependency.startswith("music-assistant-frontend==")
1553 ]
1554 if len(matches) != 1:
1555 raise RuntimeError("Expected one pinned music-assistant-frontend dependency")
1556 with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
1557 output.write(f"version={matches[0]}\n")
1558
1559 - name: Create app repository token
1560 id: app_token
1561 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
1562 with:
1563 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
1564 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
1565 owner: ${{ github.repository_owner }}
1566 repositories: app.music-assistant.io
1567 permission-contents: write
1568
1569 - name: Verify GitHub App identity
1570 env:
1571 APP_SLUG: ${{ steps.app_token.outputs.app-slug }}
1572 INSTALLATION_ID: ${{ steps.app_token.outputs.installation-id }}
1573 run: |
1574 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
1575 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
1576 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
1577 exit 1
1578 fi
1579
1580 - name: Dispatch frontend update
1581 env:
1582 CHANNEL: ${{ inputs.channel == 'rc' && 'beta' || inputs.channel }}
1583 FRONTEND_VERSION: ${{ steps.frontend.outputs.version }}
1584 GH_TOKEN: ${{ steps.app_token.outputs.token }}
1585 IMAGE_DIGEST: ${{ needs.exact_image.outputs.digest }}
1586 SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
1587 VERSION: ${{ inputs.version }}
1588 run: |
1589 gh api \
1590 -H "Accept: application/vnd.github.raw" \
1591 repos/music-assistant/app.music-assistant.io/contents/channels.json \
1592 > "$RUNNER_TEMP/channels.json"
1593 current_version=$(jq -r \
1594 --arg channel "$CHANNEL" \
1595 '.[$channel] // empty' \
1596 "$RUNNER_TEMP/channels.json")
1597 if [ -n "$current_version" ]; then
1598 relation=$(python3 \
1599 .release-workflow/scripts/release_workflow.py \
1600 compare-frontend-versions \
1601 --current "$current_version" \
1602 --requested "$FRONTEND_VERSION" |
1603 sed -n 's/^current_relation=//p')
1604 if [ "$relation" != "older" ]; then
1605 echo "Skipping frontend dispatch: $CHANNEL already uses $current_version"
1606 exit 0
1607 fi
1608 fi
1609
1610 jq -n \
1611 --arg channel "$CHANNEL" \
1612 --arg frontend_version "$FRONTEND_VERSION" \
1613 --arg server_version "$VERSION" \
1614 --arg source_sha "$SOURCE_SHA" \
1615 --arg image_digest "$IMAGE_DIGEST" \
1616 --arg idempotency_key "$GITHUB_REPOSITORY@$VERSION" \
1617 '{
1618 event_type: "frontend-update",
1619 client_payload: {
1620 channel: $channel,
1621 frontend_version: $frontend_version,
1622 server_version: $server_version,
1623 source_sha: $source_sha,
1624 image_digest: $image_digest,
1625 idempotency_key: $idempotency_key
1626 }
1627 }' > "$RUNNER_TEMP/dispatch.json"
1628 gh api --method POST \
1629 "repos/music-assistant/app.music-assistant.io/dispatches" \
1630 --input "$RUNNER_TEMP/dispatch.json"
1631