Skip to content

Publish loreserver image #3

Publish loreserver image

Publish loreserver image #3

# 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"