Merge pull request #34 from airframesio/dependabot/cargo/containers/a… #112
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 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 |