Skip to content

ci(aws): mirror AMIs to satellite regions and publish IDs on download… #408

ci(aws): mirror AMIs to satellite regions and publish IDs on download…

ci(aws): mirror AMIs to satellite regions and publish IDs on download… #408

Workflow file for this run

name: Build Images
on:
pull_request:
push:
branches: [main]
tags: ["v*"]
# Per-commit concurrency group: GitHub keeps at most ONE pending run per
# group, so a group keyed only on the ref evicts (cancels) older queued
# runs when new events fire — particularly noisy with stacked PRs, where
# pushing to one branch cascades synchronize events into the dependent
# PRs. Including the SHA gives each unique commit its own queue: same SHA
# retriggered cancels the older run (we want the latest result), different
# SHAs run independently.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: true
permissions:
contents: write
id-token: write
attestations: write
env:
UBUNTU_MIRROR: http://us.archive.ubuntu.com/ubuntu
UBUNTU_PORTS_MIRROR: http://ports.ubuntu.com/ubuntu-ports
AWS_REGION: ap-southeast-2
AWS_ROLE: arn:aws:iam::143295493206:role/gha-linux-images-upload
S3_BUCKET: bes-ops-tools
S3_PREFIX: linux-images
CLOUDFRONT_ID: EDAG0UBS1MN74
jobs:
# Single source of truth for the build datestamp. Every matrix leg picks
# this up via $BUILD_DATE and writes filenames containing it; downstream
# jobs that look up artifacts by basename use the same value. Without
# this, a long build crossing midnight UTC would produce inconsistent
# filenames between parallel matrix entries.
prep:
runs-on: ubuntu-24.04
outputs:
build_date: ${{ steps.d.outputs.value }}
steps:
- id: d
run: echo "value=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
images-cloud:
needs: [prep]
env:
BUILD_DATE: ${{ needs.prep.outputs.build_date }}
# The (arch × suite) matrix produces all four combinations.
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
debootstrap gdisk dosfstools e2fsprogs btrfs-progs \
cryptsetup parted util-linux rsync shellcheck \
qemu-utils genisoimage zstd squashfs-tools jq
- name: Run shellcheck
if: matrix.suite == 'noble'
run: just test-shellcheck
- name: Build raw image
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=cloud raw
timeout-minutes: 60
- name: Test image structure
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=cloud test-structure
- name: Produce final artifacts
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=cloud build
timeout-minutes: 30
- name: Verify outputs # r[verify image.output.raw] r[verify image.output.vmdk] r[verify image.output.qcow2] r[verify image.output.checksum]
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=cloud verify-outputs
- name: List outputs
run: ls -lh output/${{ matrix.arch }}/cloud/
# Raw image is consumed downstream by iso and register-ami — always
# uploaded as a GH artifact.
- name: Upload raw image
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/cloud/*.img.zst
if-no-files-found: error
retention-days: 1
archive: false
# VMDK / qcow2 aren't consumed in-workflow. On tagged builds the
# publish-shard tail puts them in S3, so the GH artifact upload would
# be redundant. On non-tagged builds (PRs / main) we keep them so
# developers can fetch via `gh run download`.
- name: Upload VMDK
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/cloud/*.vmdk
if-no-files-found: error
retention-days: 1
archive: false
- name: Upload qcow2
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/cloud/*.qcow2
if-no-files-found: error
retention-days: 1
archive: false
# Tag-gated tail: publish blobs straight from the producer to S3.
# Skips the multi-GB round-trip through a separate release job.
- name: Configure AWS credentials
if: startsWith(github.ref, 'refs/tags/')
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=Publish-cloud-${{ matrix.suite }}-${{ matrix.arch }}
- name: Attest provenance
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-path: |
output/${{ matrix.arch }}/cloud/*.img.zst
output/${{ matrix.arch }}/cloud/*.vmdk
output/${{ matrix.arch }}/cloud/*.qcow2
- name: Publish to S3 and emit manifest fragment # r[image.output.checksum]
if: startsWith(github.ref, 'refs/tags/')
run: scripts/publish-release-shard.sh cloud ${{ matrix.arch }} ${{ matrix.suite }} output/${{ matrix.arch }}/cloud
- name: Upload manifest fragment
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v7
with:
name: manifest-fragment-cloud-${{ matrix.suite }}-${{ matrix.arch }}
path: manifest-fragment.json
if-no-files-found: error
retention-days: 1
images-metal:
needs: [prep]
env:
BUILD_DATE: ${{ needs.prep.outputs.build_date }}
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
debootstrap gdisk dosfstools e2fsprogs btrfs-progs \
cryptsetup parted util-linux rsync \
qemu-utils genisoimage zstd squashfs-tools jq
- name: Build raw image
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=metal raw
timeout-minutes: 60
- name: Test image structure
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=metal test-structure
- name: Produce final artifacts
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=metal build
timeout-minutes: 30
- name: Verify outputs # r[verify image.output.raw] r[verify image.output.vmdk] r[verify image.output.qcow2] r[verify image.output.checksum]
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=metal verify-outputs
- name: List outputs
run: ls -lh output/${{ matrix.arch }}/metal/
# No in-workflow job consumes these. On tagged builds the publish
# tail puts them in S3, so the GH artifact upload is skipped. On
# non-tagged builds (PRs / main) we keep them for `gh run download`.
- name: Upload raw image
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/metal/*.img.zst
if-no-files-found: error
retention-days: 1
archive: false
- name: Upload VMDK
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/metal/*.vmdk
if-no-files-found: error
retention-days: 1
archive: false
- name: Upload qcow2
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/metal/*.qcow2
if-no-files-found: error
retention-days: 1
archive: false
- name: Configure AWS credentials
if: startsWith(github.ref, 'refs/tags/')
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=Publish-metal-${{ matrix.suite }}-${{ matrix.arch }}
- name: Attest provenance
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-path: |
output/${{ matrix.arch }}/metal/*.img.zst
output/${{ matrix.arch }}/metal/*.vmdk
output/${{ matrix.arch }}/metal/*.qcow2
- name: Publish to S3 and emit manifest fragment # r[image.output.checksum]
if: startsWith(github.ref, 'refs/tags/')
run: scripts/publish-release-shard.sh metal ${{ matrix.arch }} ${{ matrix.suite }} output/${{ matrix.arch }}/metal
- name: Upload manifest fragment
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v7
with:
name: manifest-fragment-metal-${{ matrix.suite }}-${{ matrix.arch }}
path: manifest-fragment.json
if-no-files-found: error
retention-days: 1
# Pi images are arm64-only and currently only built on resolute (26.04).
# The matrix is parameterised on suite for future-proofing — adding a
# `noble` value (when/if Pi 5 needs an LTS-on-24.04 image) is a one-line
# change.
images-pi:
needs: [prep]
env:
BUILD_DATE: ${{ needs.prep.outputs.build_date }}
strategy:
fail-fast: false
matrix:
suite: [resolute]
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
debootstrap gdisk dosfstools e2fsprogs btrfs-progs \
cryptsetup parted util-linux rsync \
qemu-utils genisoimage zstd squashfs-tools jq
- name: Build raw image
run: just ubuntu_suite=${{ matrix.suite }} arch=arm64 variant=pi raw
timeout-minutes: 60
- name: Test image structure
run: just ubuntu_suite=${{ matrix.suite }} arch=arm64 variant=pi test-structure
- name: Produce final artifacts
run: just ubuntu_suite=${{ matrix.suite }} arch=arm64 variant=pi pi-build
timeout-minutes: 30
- name: Verify outputs # r[verify image.output.raw] r[verify image.output.checksum]
run: just ubuntu_suite=${{ matrix.suite }} arch=arm64 variant=pi verify-outputs
- name: List outputs
run: ls -lh output/arm64/pi/
# No in-workflow job consumes this. Tagged builds publish to S3;
# non-tagged builds upload as a GH artifact for inspection.
- name: Upload raw image
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
path: output/arm64/pi/*.img.zst
if-no-files-found: error
retention-days: 1
archive: false
- name: Configure AWS credentials
if: startsWith(github.ref, 'refs/tags/')
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=Publish-pi-${{ matrix.suite }}
- name: Attest provenance
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-path: output/arm64/pi/*.img.zst
- name: Publish to S3 and emit manifest fragment # r[image.output.checksum]
if: startsWith(github.ref, 'refs/tags/')
run: scripts/publish-release-shard.sh pi arm64 ${{ matrix.suite }} output/arm64/pi
- name: Upload manifest fragment
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v7
with:
name: manifest-fragment-pi-${{ matrix.suite }}
path: manifest-fragment.json
if-no-files-found: error
retention-days: 1
# Standalone Pi 5 EEPROM-config SD artifact
pi-eeprom:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
git python3 dosfstools mtools util-linux zstd jq
- name: Build artifact
run: just pi-eeprom-img
timeout-minutes: 10
- name: Test artifact
run: just test-pi-eeprom
- name: List outputs
run: ls -lh output/pi-eeprom/
# Only the .img.zst is user-facing; the loose firmware files and
# SHA256SUMS are kept as a GH artifact on non-tagged builds for
# debugging. Tagged builds publish the .img.zst to S3 instead.
- name: Upload artifact
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: actions/upload-artifact@v7
with:
name: pi-eeprom-config
path: |
output/pi-eeprom/recovery.bin
output/pi-eeprom/pieeprom.upd
output/pi-eeprom/pieeprom.sig
output/pi-eeprom/bes-pi-eeprom-config.img.zst
output/pi-eeprom/SHA256SUMS
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Configure AWS credentials
if: startsWith(github.ref, 'refs/tags/')
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=Publish-pi-eeprom
- name: Attest provenance
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-path: output/pi-eeprom/bes-pi-eeprom-config.img.zst
- name: Publish to S3 and emit manifest fragment # r[image.output.checksum]
if: startsWith(github.ref, 'refs/tags/')
run: scripts/publish-release-shard.sh pi-eeprom "" "" output/pi-eeprom
- name: Upload manifest fragment
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v7
with:
name: manifest-fragment-pi-eeprom
path: manifest-fragment.json
if-no-files-found: error
retention-days: 1
iso:
needs: [prep, images-cloud]
env:
BUILD_DATE: ${{ needs.prep.outputs.build_date }}
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
# The installer links against the runner's glibc, which must be <= the
# glibc in the live ISO rootfs. ubuntu-24.04 (glibc 2.39) targets both
# noble (2.39) and resolute (≥2.41) cleanly. When bumping the runner
# image, verify its glibc does not exceed the lowest target suite's
# glibc before merging.
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
debootstrap gdisk dosfstools e2fsprogs squashfs-tools \
grub-efi-${{ matrix.arch }}-bin grub-common \
parted util-linux zstd cryptsetup xorriso jq
- name: Install Rust toolchain via rustup
run: |
rustup update stable
rustup default stable
rustup target add ${{ matrix.arch == 'amd64' && 'x86_64-unknown-linux-gnu' || 'aarch64-unknown-linux-gnu' }}
- uses: Swatinem/rust-cache@v2
# archive: false names the artifact after the file's basename. The
# date suffix comes from prep, so we can match by exact name.
- name: Download cloud raw image
uses: actions/download-artifact@v8
with:
name: ubuntu-${{ matrix.suite == 'noble' && '24.04' || '26.04' }}-bes-cloud-${{ matrix.arch }}-${{ needs.prep.outputs.build_date }}.img.zst
path: output/${{ matrix.arch }}/cloud/
- name: List inputs
run: |
echo "=== Cloud Image ==="
ls -lhR output/${{ matrix.arch }}/cloud/ || true
- name: Build ISO
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} iso
timeout-minutes: 30
- name: Test ISO structure
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} iso-test-structure
- name: Upload ISO
uses: actions/upload-artifact@v7
with:
path: output/${{ matrix.arch }}/bes-installer-*.iso
if-no-files-found: error
retention-days: 1
archive: false
- name: Configure AWS credentials
if: startsWith(github.ref, 'refs/tags/')
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=Publish-iso-${{ matrix.suite }}-${{ matrix.arch }}
- name: Attest provenance
if: startsWith(github.ref, 'refs/tags/')
uses: actions/attest-build-provenance@v4
with:
subject-path: output/${{ matrix.arch }}/bes-installer-*.iso
- name: Publish to S3 and emit manifest fragment # r[image.output.checksum]
if: startsWith(github.ref, 'refs/tags/')
run: scripts/publish-release-shard.sh installer ${{ matrix.arch }} ${{ matrix.suite }} output/${{ matrix.arch }}
- name: Upload manifest fragment
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v7
with:
name: manifest-fragment-iso-${{ matrix.suite }}-${{ matrix.arch }}
path: manifest-fragment.json
if-no-files-found: error
retention-days: 1
container-test:
needs: [iso]
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
shard: [1, 2, 3]
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
steps:
- uses: actions/checkout@v6
- uses: taiki-e/install-action@v2
with:
tool: just
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
systemd-container squashfs-tools xorriso \
cryptsetup btrfs-progs util-linux gdisk parted
- name: Load kernel modules
run: |
sudo modprobe loop
sudo modprobe btrfs
sudo modprobe dm-crypt
# ISO basenames are deterministic — bes-installer-{version}-{arch}.iso —
# so an exact name lookup works.
- name: Download ISO
uses: actions/download-artifact@v8
with:
name: bes-installer-${{ matrix.suite == 'noble' && '24.04' || '26.04' }}-${{ matrix.arch }}.iso
path: output/${{ matrix.arch }}/
- name: List ISO
run: ls -lh output/${{ matrix.arch }}/
- name: Run container isolation test # r[verify installer.container.isolation]
if: matrix.shard == 1
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} variant=metal test-container-isolation
timeout-minutes: 5
- name: Run container install test (shard ${{ matrix.shard }}/3, fake-LUKS auto-detected)
run: just ubuntu_suite=${{ matrix.suite }} arch=${{ matrix.arch }} container_test_filter=shard:${{ matrix.shard }}/3 test-container-install
timeout-minutes: 15
all-green:
name: All builds green
if: always()
needs: [images-cloud, images-metal, images-pi, pi-eeprom, iso, container-test]
runs-on: ubuntu-latest
steps:
- name: Check job results
run: |
result='${{ toJSON(needs) }}'
echo "$result" | jq .
echo "$result" | jq -e 'all(.result == "success")'
# Producer jobs publish each blob to S3 themselves (see their tag-gated
# tails). This job only collects the per-shard manifest fragments — KB,
# not GB — to assemble manifest.json + SHA256SUMS + index.html, then
# publishes those three metadata files and cuts the GH release.
#
# Deliberately does NOT need: container-test. Operators decide when to
# tag; failed tests on a tagged build are an operator-side abort, same
# as register-ami already presumes.
release-aggregate:
needs: [images-cloud, images-metal, images-pi, pi-eeprom, iso, register-ami, copy-amis]
# copy-amis is in needs to force a wait, but the AMI section is built from
# whatever fragments actually upload — a partial satellite-region failure
# shouldn't block the release. always() lets us run despite a failed
# copy-amis job, while explicit result checks gate on the truly required
# jobs.
if: |
always() &&
startsWith(github.ref, 'refs/tags/') &&
needs.images-cloud.result == 'success' &&
needs.images-metal.result == 'success' &&
needs.images-pi.result == 'success' &&
needs.pi-eeprom.result == 'success' &&
needs.iso.result == 'success' &&
needs.register-ami.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Derive version from tag
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Download manifest fragments
uses: actions/download-artifact@v8
with:
pattern: 'manifest-fragment-*'
path: fragments/
- name: Download AMI fragments
# Zero or more — copy-amis matrix entries are best-effort, so some
# regions may have failed to upload.
uses: actions/download-artifact@v8
with:
pattern: 'ami-fragment-*'
path: amis/
continue-on-error: true
- name: Assemble manifest.json + SHA256SUMS # r[image.output.checksum]
run: |
mkdir -p release
# Each fragment is a JSON array of file entries. Concatenate and
# sort by name for deterministic output.
jq -s 'add | sort_by(.name)' fragments/*/manifest-fragment.json > files.json
# AMI fragments are single JSON objects (one per registered/copied
# AMI). Some may be missing if a copy-amis matrix entry failed.
shopt -s nullglob
AMI_FILES=(amis/*/ami-fragment.json)
if [ "${#AMI_FILES[@]}" -gt 0 ]; then
jq -s 'sort_by(.ubuntu_version, .arch, .region)' "${AMI_FILES[@]}" > amis.json
else
echo '[]' > amis.json
fi
jq -n \
--arg version "$VERSION" \
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg repo "https://github.com/${{ github.repository }}" \
--arg tag "${{ github.ref_name }}" \
--slurpfile files files.json \
--slurpfile amis amis.json \
'{ version: $version, date: $date, repo: $repo, tag: $tag, files: $files[0], amis: $amis[0] }' \
> release/manifest.json
jq -r '.files[] | "\(.sha256) \(.name)"' release/manifest.json > release/SHA256SUMS
- name: Generate index.html
run: |
cd release
cat > index.html <<'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BES Linux Images — VERSION_PLACEHOLDER</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
a { color: #0060df; }
table { border-collapse: collapse; width: 100%; margin: 1.5rem 0; }
th, td { text-align: left; padding: 0.4rem 0.8rem; border-bottom: 1px solid #ddd; }
th { font-weight: 600; border-bottom: 2px solid #999; }
td.size { text-align: right; font-variant-numeric: tabular-nums; }
code { font-size: 0.85em; background: #f0f0f0; padding: 0.1em 0.3em; border-radius: 3px; }
.meta { color: #555; font-size: 0.9rem; margin-bottom: 1.5rem; }
</style>
</head>
<body>
<h1>BES Linux Images &mdash; VERSION_PLACEHOLDER</h1>
<p class="meta">
Source: <a href="REPO_PLACEHOLDER">REPO_PLACEHOLDER</a>
&middot; Tag: <a href="REPO_PLACEHOLDER/releases/tag/TAG_PLACEHOLDER"><code>TAG_PLACEHOLDER</code></a>
&middot; <a href="manifest.json">manifest.json</a>
</p>
<table>
<thead><tr><th>File</th><th>Variant</th><th>Suite</th><th>Arch</th><th>Format</th><th class="size">Size</th></tr></thead>
<tbody>
TABLE_ROWS_PLACEHOLDER
</tbody>
</table>
AMI_SECTION_PLACEHOLDER
</body>
</html>
HTMLEOF
repo="https://github.com/${{ github.repository }}"
tag="${{ github.ref_name }}"
human_size() {
local bytes=$1
if [ "$bytes" -ge 1073741824 ]; then
awk "BEGIN { printf \"%.1f GiB\", $bytes/1073741824 }"
elif [ "$bytes" -ge 1048576 ]; then
awk "BEGIN { printf \"%.1f MiB\", $bytes/1048576 }"
else
awk "BEGIN { printf \"%.0f KiB\", $bytes/1024 }"
fi
}
jq -c '.files[]' manifest.json | while IFS= read -r entry; do
name=$(echo "$entry" | jq -r '.name')
size=$(echo "$entry" | jq -r '.size')
variant=$(echo "$entry" | jq -r '.variant // ""')
suite=$(echo "$entry" | jq -r '.suite // ""')
arch=$(echo "$entry" | jq -r '.arch // ""')
format=$(echo "$entry" | jq -r '.format')
hsize=$(human_size "$size")
echo "<tr><td><a href=\"${name}\">${name}</a></td><td>${variant}</td><td>${suite}</td><td>${arch}</td><td>${format}</td><td class=\"size\">${hsize}</td></tr>"
done > table_rows.tmp
# AMI section: only render if there are any AMIs. The console
# quick-launch URL takes the AMI ID; the AMIs are public so any
# AWS account can hit it.
if [ "$(jq -r '.amis | length' manifest.json)" -gt 0 ]; then
{
echo '<h2 style="margin-top:2rem;">Launch on AWS</h2>'
echo '<p>These AMIs are public &mdash; use the Launch link below or run <code>aws ec2 run-instances --image-id &lt;ami-id&gt; --region &lt;region&gt; ...</code>.</p>'
echo '<table>'
echo '<thead><tr><th>Region</th><th>Ubuntu</th><th>Arch</th><th>AMI ID</th><th></th></tr></thead>'
echo '<tbody>'
jq -r '.amis[] | "<tr><td>\(.region)</td><td>\(.ubuntu_version)</td><td>\(.arch)</td><td><code>\(.ami_id)</code></td><td><a href=\"https://\(.region).console.aws.amazon.com/ec2/home?region=\(.region)#LaunchInstances:ami=\(.ami_id)\">Launch</a></td></tr>"' manifest.json
echo '</tbody>'
echo '</table>'
} > ami_section.tmp
else
: > ami_section.tmp
fi
sed -i "s|VERSION_PLACEHOLDER|${VERSION}|g" index.html
sed -i "s|REPO_PLACEHOLDER|${repo}|g" index.html
sed -i "s|TAG_PLACEHOLDER|${tag}|g" index.html
sed -i "/TABLE_ROWS_PLACEHOLDER/{
r table_rows.tmp
d
}" index.html
sed -i "/AMI_SECTION_PLACEHOLDER/{
r ami_section.tmp
d
}" index.html
rm -f table_rows.tmp ami_section.tmp
- run: ls -lh release/
- name: Attest metadata provenance
uses: actions/attest-build-provenance@v4
with:
subject-path: |
release/manifest.json
release/SHA256SUMS
release/index.html
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_ROLE }}
role-session-name: GHA@linux-images=ReleaseAggregate
- name: Upload metadata to S3
run: |
for f in release/manifest.json release/SHA256SUMS release/index.html; do
name=$(basename "$f")
case "$name" in
index.html) content_type="text/html" ;;
manifest.json) content_type="application/json" ;;
SHA256SUMS) content_type="text/plain" ;;
esac
aws s3 cp "$f" "s3://${S3_BUCKET}/${S3_PREFIX}/${VERSION}/${name}" --no-progress --content-type "$content_type"
done
- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id="${CLOUDFRONT_ID}" --paths '/linux-images/*'
- uses: softprops/action-gh-release@v3
with:
body: |
**Downloads: <https://tools.ops.tamanu.io/linux-images/${{ env.VERSION }}/>**
### Variants
| Variant | Use case |
|---------|----------|
| metal | Install directly on hardware |
| cloud | For cloud/VM deployments (including on-prem virtualisation) |
| pi | For Raspberry Pi 5 only |
| pi-eeprom | On Raspberry Pi 5: configure the firmware |
### Formats
| Format | Use case |
|--------|----------|
| iso | Boot from USB for interactive or automated install |
| raw | Write directly to server disk |
| vmdk | VMware / vSphere |
| qcow2 | KVM / libvirt / Proxmox |
files: release/SHA256SUMS
fail_on_unmatched_files: true
make_latest: true
register-ami:
needs: [prep, images-cloud]
if: startsWith(github.ref, 'refs/tags/')
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }}
steps:
- uses: actions/checkout@v6
- name: Derive version from tag
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Install zstd
run: sudo apt-get install -y --no-install-recommends zstd
- name: Download cloud raw image
uses: actions/download-artifact@v8
with:
name: ubuntu-${{ matrix.suite == 'noble' && '24.04' || '26.04' }}-bes-cloud-${{ matrix.arch }}-${{ needs.prep.outputs.build_date }}.img.zst
path: output/${{ matrix.arch }}/cloud/
# The AMI publishing account is dedicated: the only AWS account
# without EBS encryption-by-default enabled, so the imported snapshot
# lands unencrypted and the resulting AMI can be made public.
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ap-southeast-2
role-to-assume: arn:aws:iam::658427548944:role/gha-linux-images-upload
role-session-name: GHA@linux-images=RegisterAMI-${{ matrix.suite }}-${{ matrix.arch }}
- name: Register AMI
# Region positional matches the configure-aws-credentials region
# above. Bucket comes from vars.* because the actual name carries a
# terraform unique suffix that would otherwise need a code change
# every time the bucket is recreated.
run: scripts/register-ami-for-release.sh "${{ matrix.arch }}" "${{ matrix.suite }}" "${{ env.VERSION }}" "ap-southeast-2" "${{ vars.AWS_AMI_STAGING_BUCKET }}"
timeout-minutes: 60
- name: Upload AMI fragment
uses: actions/upload-artifact@v7
with:
name: ami-fragment-ap-southeast-2-${{ matrix.suite }}-${{ matrix.arch }}
path: ami-fragment.json
if-no-files-found: error
retention-days: 1
# Mirror the AMI registered above into the regions we have presence in
# (plus us-east-1 as the default consumer region). AMIs are region-scoped
# so without this consumers in other regions would have to copy-image
# themselves before launching.
copy-amis:
needs: [register-ami]
if: startsWith(github.ref, 'refs/tags/')
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
suite: [noble, resolute]
region: [ap-southeast-6, eu-central-2, ap-south-1, us-east-1]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Derive version from tag
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
# copy-image is called with both --source-region and --region, but
# configure-aws-credentials still needs a region for the session. Use
# the target region so describe-images / modify-image-attribute calls
# against the copy don't need an explicit --region argument.
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ matrix.region }}
role-to-assume: arn:aws:iam::658427548944:role/gha-linux-images-upload
role-session-name: GHA@linux-images=CopyAMI-${{ matrix.suite }}-${{ matrix.arch }}-${{ matrix.region }}
- name: Copy AMI to ${{ matrix.region }}
run: scripts/copy-ami-to-region.sh "${{ matrix.arch }}" "${{ matrix.suite }}" "${{ env.VERSION }}" "ap-southeast-2" "${{ matrix.region }}"
timeout-minutes: 60
- name: Upload AMI fragment
uses: actions/upload-artifact@v7
with:
name: ami-fragment-${{ matrix.region }}-${{ matrix.suite }}-${{ matrix.arch }}
path: ami-fragment.json
if-no-files-found: error
retention-days: 1