Publish loreserver image #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # SPDX-FileCopyrightText: 2026 Epic Games, Inc. | |
| # SPDX-License-Identifier: MIT | |
| name: Publish loreserver image | |
| # Builds lore-server/Dockerfile for linux/amd64 and linux/arm64, publishes | |
| # multi-arch images to ghcr.io/epicgames/lore/loreserver, and signs them with | |
| # keyless cosign so the community Helm chart can reference a verifiable tag. | |
| # | |
| # Two variants ship, differing only in how arm64 is compiled: | |
| # | |
| # :X.Y.Z baseline armv8-a arm64 — runs on any arm64 host | |
| # :X.Y.Z-graviton arm64 tuned for Graviton3+, as Lore is deployed | |
| # | |
| # The default is the portable one, so a community chart works everywhere; the | |
| # tuned build is opt-in for Graviton deployments. amd64 is baseline in both and | |
| # is therefore built once, with both manifest lists pointing at that one digest. | |
| # | |
| # `meta` resolves tags and labels once, so the later jobs cannot disagree and | |
| # a ref with no tags fails early. Each variant then builds on its own native | |
| # runner (QEMU is far too slow for a release Rust build) and is pushed by | |
| # digest; `merge` stitches the digests into manifest lists and signs them. | |
| # | |
| # No secrets: GITHUB_TOKEN authenticates to GHCR, and cosign signs keylessly | |
| # against Fulcio via the job's OIDC token. | |
| on: | |
| push: | |
| tags: ["v*"] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: >- | |
| Extra tag to publish, for proving the workflow from a branch | |
| (for example "edge"). Blank tags from the ref alone. | |
| type: string | |
| required: false | |
| permissions: {} | |
| concurrency: | |
| group: publish-loreserver-image-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| IMAGE: ghcr.io/epicgames/lore/loreserver | |
| # Passed to the Dockerfile for the tuned arm64 leg only. | |
| GRAVITON_TARGET_CPU: neoverse-512tvb | |
| jobs: | |
| meta: | |
| name: resolve tags | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # metadata-action reads repository metadata for OCI labels | |
| outputs: | |
| json: ${{ steps.meta.outputs.json }} | |
| graviton-json: ${{ steps.meta-graviton.outputs.json }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| annotations: ${{ steps.meta.outputs.annotations }} | |
| primary-tag: ${{ steps.primary.outputs.tag }} | |
| graviton-primary-tag: ${{ steps.primary.outputs.graviton-tag }} | |
| steps: | |
| # The input lands verbatim in the `tags` list below, where a newline | |
| # would smuggle in a further directive. Hold it to Docker's grammar. | |
| - name: Validate the tag input | |
| env: | |
| TAG: ${{ inputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| case "${TAG}" in | |
| "") exit 0 ;; | |
| [!A-Za-z0-9_]* | *[!A-Za-z0-9._-]*) | |
| echo "::error::the 'tag' input is not a valid image tag: expected [A-Za-z0-9_][A-Za-z0-9._-]*" | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "${#TAG}" -gt 128 ]; then | |
| echo "::error::the 'tag' input is longer than the 128 characters a tag allows" | |
| exit 1 | |
| fi | |
| - id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: ${{ env.IMAGE }} | |
| # Only a stable semver tag moves `latest`; a prerelease publishes | |
| # its own tag and nothing else. | |
| flavor: latest=auto | |
| tags: | | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=ref,event=branch | |
| type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} | |
| # The same tag set, suffixed. onlatest carries the suffix onto `latest` | |
| # so the tuned stream has its own moving tag rather than fighting for the | |
| # shared one. | |
| - id: meta-graviton | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: ${{ env.IMAGE }} | |
| flavor: | | |
| latest=auto | |
| suffix=-graviton,onlatest=true | |
| tags: | | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=ref,event=branch | |
| type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} | |
| # An empty list has to stop the run here, not surface as an invalid | |
| # reference in the merge job once every architecture is pushed. | |
| - id: primary | |
| name: Require at least one tag | |
| env: | |
| JSON: ${{ steps.meta.outputs.json }} | |
| GRAVITON_JSON: ${{ steps.meta-graviton.outputs.json }} | |
| run: | | |
| set -euo pipefail | |
| tag=$(jq -r '.tags[0] // empty' <<< "$JSON") | |
| graviton_tag=$(jq -r '.tags[0] // empty' <<< "$GRAVITON_JSON") | |
| if [ -z "${tag}" ] || [ -z "${graviton_tag}" ]; then | |
| echo "::error::no image tags resolved for ${GITHUB_REF}; push a semver tag or pass the 'tag' input" | |
| exit 1 | |
| fi | |
| echo "tag=${tag}" >> "$GITHUB_OUTPUT" | |
| echo "graviton-tag=${graviton_tag}" >> "$GITHUB_OUTPUT" | |
| build: | |
| name: build (${{ matrix.arch }}${{ matrix.variant == 'graviton' && ', graviton' || '' }}) | |
| needs: meta | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: read # Check out the source the image is built from | |
| packages: write # Push the per-arch manifest, addressed by digest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # amd64 is baseline for both variants, so it is built once and both | |
| # manifest lists reference this digest. | |
| - { arch: amd64, variant: base, platform: linux/amd64, runner: ubuntu-latest, target_cpu: "" } | |
| - { arch: arm64, variant: base, platform: linux/arm64, runner: ubuntu-24.04-arm, target_cpu: "" } | |
| - { arch: arm64, variant: graviton, platform: linux/arm64, runner: ubuntu-24.04-arm, target_cpu: neoverse-512tvb } | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - id: build | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| file: lore-server/Dockerfile | |
| platforms: ${{ matrix.platform }} | |
| build-args: ARM64_TARGET_CPU=${{ matrix.target_cpu }} | |
| labels: ${{ needs.meta.outputs.labels }} | |
| annotations: ${{ needs.meta.outputs.annotations }} | |
| outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true | |
| # Only the apt and toolchain layers survive a source change. The | |
| # cargo registry and target directory live in BuildKit cache mounts, | |
| # which the gha backend does not carry between runs, so the compile | |
| # itself starts cold every time. Scoped per variant so the two arm64 | |
| # legs, built with different codegen flags, cannot share layers. | |
| cache-from: type=gha,scope=loreserver-${{ matrix.arch }}-${{ matrix.variant }} | |
| cache-to: type=gha,mode=max,scope=loreserver-${{ matrix.arch }}-${{ matrix.variant }} | |
| # A clean build proves only that the image compiles, so start the binary | |
| # too. Know the limit: this arm64 runner is Neoverse-N2 and reports SVE, | |
| # so it executes the Graviton-tuned build quite happily. This catches a | |
| # broken entrypoint or a missing shared library — it does NOT catch | |
| # codegen aimed at a CPU the eventual host lacks, which is exactly how a | |
| # SIGILL-ing arm64 image once passed a fully green run. | |
| - name: Smoke test the pushed image | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: docker run --rm "${IMAGE}@${DIGEST}" --version | |
| # The merge job addresses each build by digest. Hand the digests over as | |
| # empty files named after themselves, one artifact per matrix leg so the | |
| # merge job can pick which legs belong in which manifest list. | |
| - name: Export digest | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| mkdir -p "${{ runner.temp }}/digests" | |
| touch "${{ runner.temp }}/digests/${DIGEST#sha256:}" | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: digests-${{ matrix.arch }}-${{ matrix.variant }} | |
| path: ${{ runner.temp }}/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| merge: | |
| name: merge and sign (${{ matrix.variant }}) | |
| needs: [meta, build] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| packages: write # Push the manifest list and the cosign signature | |
| id-token: write # Federate to Fulcio for keyless signing | |
| strategy: | |
| # One variant failing should not withhold the other; both are | |
| # independently useful once published. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - variant: default | |
| legs: digests-amd64-base digests-arm64-base | |
| - variant: graviton | |
| legs: digests-amd64-base digests-arm64-graviton | |
| env: | |
| JSON: ${{ matrix.variant == 'graviton' && needs.meta.outputs.graviton-json || needs.meta.outputs.json }} | |
| PRIMARY_TAG: ${{ matrix.variant == 'graviton' && needs.meta.outputs.graviton-primary-tag || needs.meta.outputs.primary-tag }} | |
| LEGS: ${{ matrix.legs }} | |
| # Anchored on this workflow's path: a bare repository prefix would | |
| # accept a certificate minted by any workflow here. | |
| IDENTITY_REGEXP: '^https://github\.com/${{ github.repository }}/\.github/workflows/publish-loreserver-image\.yml@' | |
| steps: | |
| # No merge-multiple: each leg keeps its own subdirectory so the right | |
| # subset can be selected below. | |
| - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ${{ runner.temp }}/digests | |
| pattern: digests-* | |
| - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create the multi-arch manifest list | |
| working-directory: ${{ runner.temp }}/digests | |
| run: | | |
| set -euo pipefail | |
| args=() | |
| while IFS= read -r tag; do | |
| args+=(--tag "${tag}") | |
| done < <(jq -r '.tags[]' <<< "$JSON") | |
| # The build job annotated each per-arch manifest; the index is | |
| # created here, so it needs the same annotations applying to it. | |
| while IFS= read -r annotation; do | |
| args+=(--annotation "index:${annotation}") | |
| done < <(jq -r '.labels | to_entries[] | "\(.key)=\(.value)"' <<< "$JSON") | |
| for leg in ${LEGS}; do | |
| if [ ! -d "${leg}" ]; then | |
| echo "::error::${leg} did not produce a digest; refusing to publish a partial manifest list" | |
| exit 1 | |
| fi | |
| for digest in "${leg}"/*; do | |
| args+=("${IMAGE}@sha256:$(basename "${digest}")") | |
| done | |
| done | |
| docker buildx imagetools create "${args[@]}" | |
| - name: Inspect the manifest list | |
| run: docker buildx imagetools inspect "${PRIMARY_TAG}" | |
| - id: digest | |
| name: Resolve the manifest list digest | |
| run: | | |
| set -euo pipefail | |
| digest=$(docker buildx imagetools inspect "${PRIMARY_TAG}" \ | |
| --format '{{json .Manifest}}' | jq -r .digest) | |
| echo "digest=${digest}" >> "$GITHUB_OUTPUT" | |
| - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| # Sign the manifest list by digest, not by tag: a tag can later be moved | |
| # to point at something else, a digest cannot. | |
| - name: Sign the image with cosign | |
| env: | |
| DIGEST: ${{ steps.digest.outputs.digest }} | |
| run: cosign sign --yes "${IMAGE}@${DIGEST}" | |
| - name: Verify the signature | |
| env: | |
| DIGEST: ${{ steps.digest.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| cosign verify "${IMAGE}@${DIGEST}" \ | |
| --certificate-identity-regexp "${IDENTITY_REGEXP}" \ | |
| --certificate-oidc-issuer https://token.actions.githubusercontent.com \ | |
| > /dev/null | |
| echo "signature verified" | |
| - name: Summarise what was published | |
| env: | |
| DIGEST: ${{ steps.digest.outputs.digest }} | |
| VARIANT: ${{ matrix.variant }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Published \`${IMAGE}\` (${VARIANT})" | |
| echo "" | |
| echo "Digest: \`${DIGEST}\`" | |
| echo "" | |
| if [ "${VARIANT}" = "graviton" ]; then | |
| echo "Platforms: linux/amd64 (baseline), linux/arm64 (tuned for Graviton3+ — will not run on older arm64)" | |
| else | |
| echo "Platforms: linux/amd64, linux/arm64 (baseline armv8-a)" | |
| fi | |
| echo "" | |
| echo "Tags:" | |
| jq -r '.tags[] | "- `" + . + "`"' <<< "$JSON" | |
| echo "" | |
| echo "Verify the signature with:" | |
| echo "" | |
| echo '```sh' | |
| echo "cosign verify ${IMAGE}@${DIGEST} \\" | |
| echo " --certificate-identity-regexp '${IDENTITY_REGEXP}' \\" | |
| echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" |