ci(aws): mirror AMIs to satellite regions and publish IDs on download… #408
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
| 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 — VERSION_PLACEHOLDER</h1> | |
| <p class="meta"> | |
| Source: <a href="REPO_PLACEHOLDER">REPO_PLACEHOLDER</a> | |
| · Tag: <a href="REPO_PLACEHOLDER/releases/tag/TAG_PLACEHOLDER"><code>TAG_PLACEHOLDER</code></a> | |
| · <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 — use the Launch link below or run <code>aws ec2 run-instances --image-id <ami-id> --region <region> ...</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 |