music-assistant-server

64.2 KBYML
release.yml
64.2 KB1,631 lines • yaml
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