Skip to content

Merge pull request #34 from airframesio/dependabot/cargo/containers/a… #112

Merge pull request #34 from airframesio/dependabot/cargo/containers/a…

Merge pull request #34 from airframesio/dependabot/cargo/containers/a… #112

name: Build Containers
# Channel model:
# - push to main -> dev channel
# - push to beta branch -> beta channel
# - push tag v* -> stable channel
# - workflow_dispatch -> pick channel (auto = infer from ref)
#
# Tags published per build:
# - sha-<sha> (every build)
# - <version> (every build; from Cargo.toml / package.json)
# - <version>-<channel>.<run#> (dev/beta only; concrete, immutable, identifiable)
# - <channel> (dev|beta) (dev/beta only; convenience moving alias)
# - stable, latest (stable only)
#
# For dev/beta, the pin-manifest job writes the concrete <version>-<channel>.<run#>
# tag back into releases/<channel>.json so devices pull a specific image.
on:
push:
branches: [main, beta, modernize-base-os]
paths:
- "containers/**"
tags:
- "v*"
workflow_dispatch:
inputs:
container:
description: "Container to build (or 'all')"
required: true
default: "all"
type: choice
options:
- all
- airwaves-gateway
- airwaves-manager
channel:
description: "Channel to publish (auto = infer from ref)"
required: true
default: "auto"
type: choice
options:
- auto
- stable
- beta
- dev
control_ref:
description: "Optional airwaves-os-control ref for gateway builds"
required: false
default: ""
type: string
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/airframesio
CONTROL_APP_REPO: airframesio/airwaves-os-control
jobs:
setup:
name: Setup (channel + changes)
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.chan.outputs.channel }}
is_stable: ${{ steps.chan.outputs.is_stable }}
gateway: ${{ steps.filter.outputs.gateway }}
manager: ${{ steps.filter.outputs.manager }}
# For a release (tag) build, the single source of truth for the version
# is the tag itself, so BOTH images stamp the same version and can never
# split-brain (manager 1.0.10 / gateway 1.0.8). Empty for non-tag builds.
release_version: ${{ steps.chan.outputs.release_version }}
control_ref: ${{ steps.chan.outputs.control_ref }}
steps:
- uses: actions/checkout@v4
- name: Determine channel
id: chan
env:
REF: ${{ github.ref }}
INPUT_CHANNEL: ${{ github.event.inputs.channel }}
INPUT_CONTROL_REF: ${{ github.event.inputs.control_ref }}
run: |
channel="dev"
if [ -n "${INPUT_CHANNEL}" ] && [ "${INPUT_CHANNEL}" != "auto" ]; then
channel="${INPUT_CHANNEL}"
elif [[ "${REF}" == refs/tags/v* ]]; then
channel="stable"
elif [ "${REF}" = "refs/heads/beta" ]; then
channel="beta"
else
channel="dev"
fi
is_stable="false"
[ "${channel}" = "stable" ] && is_stable="true"
release_version=""
control_ref=""
if [[ "${REF}" == refs/tags/v* ]]; then
release_version="${REF#refs/tags/v}"
control_ref="${REF#refs/tags/}"
elif [ -n "${INPUT_CONTROL_REF}" ]; then
control_ref="${INPUT_CONTROL_REF}"
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "is_stable=${is_stable}" >> "$GITHUB_OUTPUT"
echo "release_version=${release_version}" >> "$GITHUB_OUTPUT"
echo "control_ref=${control_ref}" >> "$GITHUB_OUTPUT"
echo "Resolved channel: ${channel} (is_stable=${is_stable}) release_version='${release_version}' control_ref='${control_ref}'"
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
gateway:
- 'containers/airwaves-gateway/**'
manager:
- 'containers/airwaves-manager/**'
build-gateway:
name: Build airwaves-gateway
needs: [setup]
outputs:
version: ${{ steps.ctrlver.outputs.version }}
tag: ${{ steps.ctrlver.outputs.version }}-${{ needs.setup.outputs.channel }}.${{ github.run_number }}
# Build on every push/tag (NOT gated by paths): the dev/beta channels pin
# BOTH images to a concrete <version>-<channel>.<run#> tag, so both images
# must exist for every run or the updater pins a phantom gateway tag and
# `docker compose pull` fails -> rollback. Manual dispatch still respects
# the container choice.
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.container == 'all' || github.event.inputs.container == 'airwaves-gateway' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Clone control app at requested ref
if: ${{ needs.setup.outputs.control_ref != '' }}
continue-on-error: true
uses: actions/checkout@v4
with:
repository: ${{ env.CONTROL_APP_REPO }}
path: containers/airwaves-gateway/control-app
ref: ${{ needs.setup.outputs.control_ref }}
token: ${{ secrets.CONTROL_APP_TOKEN || secrets.GITHUB_TOKEN }}
- name: Clone control app
if: ${{ needs.setup.outputs.control_ref == '' }}
continue-on-error: true
uses: actions/checkout@v4
with:
repository: ${{ env.CONTROL_APP_REPO }}
path: containers/airwaves-gateway/control-app
token: ${{ secrets.CONTROL_APP_TOKEN || secrets.GITHUB_TOKEN }}
- name: Read control-app version
id: ctrlver
env:
RELEASE_VERSION: ${{ needs.setup.outputs.release_version }}
run: |
SRC="unknown"
if [ -f containers/airwaves-gateway/control-app/package.json ]; then
SRC="$(jq -r '.version // "unknown"' containers/airwaves-gateway/control-app/package.json)"
fi
if [ -n "${RELEASE_VERSION}" ]; then
# Release build: the tag is authoritative. Refuse to publish if the
# control-app source disagrees, so a release is never assembled from
# mismatched component versions.
if [ "${SRC}" != "${RELEASE_VERSION}" ]; then
echo "::error::Release ${RELEASE_VERSION} but control-app package.json is ${SRC}. Tag the control-app repo at v${RELEASE_VERSION} (matching version) before releasing."
exit 1
fi
VERSION="${RELEASE_VERSION}"
else
VERSION="${SRC}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Control-app version: ${VERSION} (source ${SRC}, release '${RELEASE_VERSION}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_PREFIX }}/airwaves-gateway
tags: |
type=sha
type=raw,value=${{ steps.ctrlver.outputs.version }},enable=${{ steps.ctrlver.outputs.version != 'unknown' }}
type=raw,value=${{ steps.ctrlver.outputs.version }}-${{ needs.setup.outputs.channel }}.${{ github.run_number }},enable=${{ needs.setup.outputs.is_stable != 'true' && steps.ctrlver.outputs.version != 'unknown' }}
type=raw,value=${{ needs.setup.outputs.channel }},enable=${{ needs.setup.outputs.is_stable != 'true' }}
type=raw,value=stable,enable=${{ needs.setup.outputs.is_stable == 'true' }}
type=raw,value=latest,enable=${{ needs.setup.outputs.is_stable == 'true' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: containers/airwaves-gateway
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CONTROL_APP_VERSION=${{ steps.ctrlver.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-manager:
name: Build airwaves-manager
needs: [setup]
outputs:
version: ${{ steps.mgrver.outputs.version }}
tag: ${{ steps.mgrver.outputs.version }}-${{ needs.setup.outputs.channel }}.${{ github.run_number }}
# Build on every push/tag (see build-gateway note): both images must exist
# for every dev/beta run so concrete channel tags never point at a phantom.
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.container == 'all' || github.event.inputs.container == 'airwaves-manager' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Read manager version
id: mgrver
env:
RELEASE_VERSION: ${{ needs.setup.outputs.release_version }}
run: |
SRC="$(grep -m1 '^version' containers/airwaves-manager/Cargo.toml | cut -d'"' -f2)"
SRC="${SRC:-unknown}"
if [ -n "${RELEASE_VERSION}" ]; then
# Release build: the tag is authoritative; refuse a version mismatch.
if [ "${SRC}" != "${RELEASE_VERSION}" ]; then
echo "::error::Release ${RELEASE_VERSION} but manager Cargo.toml is ${SRC}. Bump Cargo.toml to ${RELEASE_VERSION} before tagging."
exit 1
fi
VERSION="${RELEASE_VERSION}"
else
VERSION="${SRC}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Manager version: ${VERSION} (source ${SRC}, release '${RELEASE_VERSION}')"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_PREFIX }}/airwaves-manager
tags: |
type=sha
type=raw,value=${{ steps.mgrver.outputs.version }},enable=${{ steps.mgrver.outputs.version != 'unknown' }}
type=raw,value=${{ steps.mgrver.outputs.version }}-${{ needs.setup.outputs.channel }}.${{ github.run_number }},enable=${{ needs.setup.outputs.is_stable != 'true' && steps.mgrver.outputs.version != 'unknown' }}
type=raw,value=${{ needs.setup.outputs.channel }},enable=${{ needs.setup.outputs.is_stable != 'true' }}
type=raw,value=stable,enable=${{ needs.setup.outputs.is_stable == 'true' }}
type=raw,value=latest,enable=${{ needs.setup.outputs.is_stable == 'true' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: containers/airwaves-manager
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Release-integrity gate: a release is only "real" if BOTH images exist at
# the SAME version tag. This catches any partial publish (one image built,
# the other skipped/failed/version-mismatched) and fails the run loudly so a
# half-published release never gets advertised to devices.
verify-release:
name: Verify both images published at ${{ needs.build-manager.outputs.version }}
needs: [setup, build-gateway, build-manager]
if: ${{ !failure() && !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Assert both images exist at matching version + channel tags
env:
MGR_VER: ${{ needs.build-manager.outputs.version }}
GW_VER: ${{ needs.build-gateway.outputs.version }}
MGR_TAG: ${{ needs.build-manager.outputs.tag }}
GW_TAG: ${{ needs.build-gateway.outputs.tag }}
IS_STABLE: ${{ needs.setup.outputs.is_stable }}
run: |
set -euo pipefail
fail=0
if [ "${MGR_VER}" != "${GW_VER}" ]; then
echo "::error::Component version mismatch: manager=${MGR_VER} gateway=${GW_VER}. A release must share one version."
fail=1
fi
check() { # check <image> <tag>
echo "Checking $1:$2 ..."
docker manifest inspect "${{ env.IMAGE_PREFIX }}/$1:$2" >/dev/null \
|| { echo "::error::Missing image $1:$2"; return 1; }
}
if [ "${IS_STABLE}" = "true" ]; then
check airwaves-manager "${MGR_VER}" || fail=1
check airwaves-gateway "${GW_VER}" || fail=1
check airwaves-manager stable || fail=1
check airwaves-gateway stable || fail=1
else
check airwaves-manager "${MGR_TAG}" || fail=1
check airwaves-gateway "${GW_TAG}" || fail=1
fi
if [ "${fail}" != "0" ]; then
echo "::error::Release integrity check failed — not a complete release."
exit 1
fi
echo "Both images verified at matching versions."
export-images:
name: Export container tarballs (${{ matrix.arch }})
needs: [setup, build-gateway, build-manager, verify-release]
if: ${{ !failure() && !cancelled() }}
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull and export images
env:
CHANNEL: ${{ needs.setup.outputs.channel }}
run: |
for image in airwaves-gateway airwaves-manager; do
docker pull --platform "linux/${{ matrix.arch }}" "${{ env.IMAGE_PREFIX }}/${image}:${CHANNEL}" || \
docker pull --platform "linux/${{ matrix.arch }}" "${{ env.IMAGE_PREFIX }}/${image}:latest" || true
docker save "${{ env.IMAGE_PREFIX }}/${image}:${CHANNEL}" -o "${image}-${{ matrix.arch }}.tar" 2>/dev/null || \
docker save "${{ env.IMAGE_PREFIX }}/${image}:latest" -o "${image}-${{ matrix.arch }}.tar" 2>/dev/null || true
done
- name: Upload tarballs
uses: actions/upload-artifact@v4
with:
name: container-images-${{ matrix.arch }}-${{ needs.setup.outputs.channel }}
path: "*.tar"
retention-days: 30
# Non-stable channels: pin the channel manifest to the concrete, immutable
# image tag just built and commit it back to main, so dev/beta devices pull a
# specific, identifiable image (never a moving "dev"/"beta" tag).
pin-manifest:
name: Pin ${{ needs.setup.outputs.channel }} manifest to concrete tag
# Depends on verify-release so a dev/beta manifest is only pinned once both
# images are confirmed published — never advertise a tag that isn't there.
needs: [setup, build-gateway, build-manager, verify-release]
if: ${{ !failure() && !cancelled() && needs.setup.outputs.is_stable != 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Pin concrete image tag in channel manifest
env:
CHANNEL: ${{ needs.setup.outputs.channel }}
# Use the tags the build jobs ACTUALLY produced (derived from the
# source versions in Cargo.toml / package.json at build time), not a
# tag reconstructed from the manifest's version field. Those two can
# drift (manifest bumped ahead of source), which previously pinned a
# tag that was never pushed -> compose pull fails -> rollback.
MGR_TAG: ${{ needs.build-manager.outputs.tag }}
GW_TAG: ${{ needs.build-gateway.outputs.tag }}
MGR_VER: ${{ needs.build-manager.outputs.version }}
GW_VER: ${{ needs.build-gateway.outputs.version }}
run: |
set -euo pipefail
f="releases/${CHANNEL}.json"
if [ ! -f "$f" ]; then echo "No manifest $f; skipping"; exit 0; fi
if [ -z "${MGR_TAG}" ] || [ -z "${GW_TAG}" ] \
|| [ "${MGR_VER}" = "unknown" ] || [ "${GW_VER}" = "unknown" ]; then
echo "Build outputs missing/unknown (mgr='${MGR_TAG}' gw='${GW_TAG}'); not pinning."
exit 0
fi
# Keep the advertised base version in lockstep with the built images,
# and pin both image tags to the exact tags just pushed.
tmp="$(mktemp)"
jq --arg m "$MGR_TAG" --arg g "$GW_TAG" \
--arg mv "$MGR_VER" --arg gv "$GW_VER" \
'.components.manager.tag=$m | .components.gateway.tag=$g
| .components.manager.version=$mv | .components.gateway.version=$gv
| .components.gateway.control_app_version=$gv' "$f" > "$tmp"
mv "$tmp" "$f"
echo "Pinned ${CHANNEL}: manager -> ${MGR_TAG}, gateway -> ${GW_TAG}"
if git diff --quiet -- "$f"; then echo "No change"; exit 0; fi
git config user.name "airwaves-ci"
git config user.email "ci@airframes.io"
git add "$f"
git commit -m "ci: pin ${CHANNEL} channel to concrete tags [skip ci]"
git push origin main