# Publish the container image to GHCR. # # The README and the docs site have told people to run # `ghcr.io/inbuxa/inbuxa-admin:latest` for a long time, and nothing ever # pushed it: `docker pull` answered `denied`, because the package did not # exist. This is the workflow that makes those instructions true. It is also # the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid # both install by pulling an image and neither builds from source. # # FIRST RUN: a package GHCR creates for the first time is **private**, even in # a public repository, and an anonymous `docker pull` will still answer # `denied`. Nothing in a workflow can change that -- the visibility is set once # by hand under the package's settings, and until it is, this looks like it # worked while the docs stay just as wrong as before. Check with a logged-out # pull, not with one from a machine that has credentials. # # Two architectures, each built on its own native runner rather than under # QEMU. Emulated arm64 has to run `npm ci` and the Vite build through # instruction translation, which takes tens of minutes and occasionally runs # out of memory; `ubuntu-24.04-arm` is free for public repositories and does # the same work at native speed. The cost is the by-digest dance below: each # runner pushes an untagged image, and a final job joins the two digests into # one multi-arch tag. name: Publish image on: release: types: [published] # Callable, so release.yml can build the release it just cut. This is not a # stylistic choice: a release created with GITHUB_TOKEN does **not** raise a # `release` event -- GitHub refuses to let a token trigger another workflow, # to stop a workflow looping on its own output. A scheduled job that cut a # release and expected this file to notice would silently never publish. The # alternatives are a personal access token kept as a secret, or calling the # workflow directly. This is the one that needs no credential. workflow_call: inputs: ref: description: "Tag, branch or SHA to build" required: true type: string tag_latest: description: "Also move :latest to this build" type: boolean default: false # Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then # orphans can be neither rerun nor canceled, and this workflow otherwise # only fires on a release -- which is not something to cut twice because a # runner died. `ref` also allows publishing an image for a tag that predates # this workflow, which is how the first one gets built. workflow_dispatch: inputs: ref: description: "Tag, branch or SHA to build" required: true default: main tag_latest: description: "Also move :latest to this build" type: boolean default: false env: # Hardcoded rather than derived from github.repository, which would have to # be lowercased to be a legal registry path. This is the string the docs name. IMAGE: ghcr.io/inbuxa/inbuxa-admin jobs: # The version is read once and handed to both builds, so the two # architectures cannot disagree about what they are. It comes from the file # the interface itself reads, which the weekly release commits before this # runs -- so the image is tagged with the version it will report. version: runs-on: ubuntu-latest outputs: version: ${{ steps.v.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} - id: v run: | set -euo pipefail V="$(jq -er .version inbuxa-version.json)" # A date version carries nothing a Docker tag objects to, so there is # no second, sanitized form of it here. echo "version=$V" >> "$GITHUB_OUTPUT" echo "version $V" build: needs: version runs-on: ${{ matrix.runner }} permissions: contents: read packages: write strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push by digest id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: ${{ matrix.platform }} # Attestations are off deliberately: they add manifests of their own # to the index, and `imagetools create` below expects the two entries # it pushed rather than four. provenance: false sbom: false cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - name: Save the digest run: | mkdir -p /tmp/digests # The prefix is stripped here and put back in the merge job, so the # filename is the bare hash. Leaving it on produces # `image@sha256:sha256:...` when the reference is rebuilt. digest="${{ steps.push.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: # One artifact per platform; the merge job globs them back together. name: digest-${{ strategy.job-index }} path: /tmp/digests/* retention-days: 1 if-no-files-found: error # Joins the per-architecture digests into a single tagged manifest, so # `docker pull ghcr.io/inbuxa/inbuxa-admin:` resolves on both. publish: needs: [version, build] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digest-* merge-multiple: true - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create the manifest run: | # Arrays rather than a string: the tags and the digest references # have to reach docker as separate arguments, and building them by # word-splitting an unquoted variable is the version of this that # breaks the day a value contains a space. tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") # :latest follows real releases only. A prerelease that moved it # would hand every `:latest` deployment an unfinished build, and a # dispatch run has to ask for it on purpose. if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then tags+=(-t "${IMAGE}:latest") elif [ "${{ inputs.tag_latest }}" = "true" ]; then tags+=(-t "${IMAGE}:latest") fi refs=() for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")") done echo "tags: ${tags[*]}" echo "refs: ${refs[*]}" docker buildx imagetools create "${tags[@]}" "${refs[@]}" - name: Show what landed run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}" # Runs only after a successful publish, because that is the only moment the # package grows. See cleanup.yml for why this is not the obvious one-liner. prune: needs: publish permissions: packages: write uses: ./.github/workflows/cleanup.yml