Skip to content

Route triage and audit automations to any comms destination (#291) #280

Route triage and audit automations to any comms destination (#291)

Route triage and audit automations to any comms destination (#291) #280

Workflow file for this run

name: Publish GHCR Images
on:
push:
branches:
- develop
- main
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: Immutable image tag to publish, for example v0.1.0
required: true
type: string
env:
REGISTRY: ghcr.io
# Serialize runs per ref without cancellation: every push still publishes its
# immutable develop-<sha> tag, and the newest run always moves the mutable
# channel alias last, so the alias cannot end up on a stale image after a race.
concurrency:
group: publish-ghcr-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare:
name: Resolve build metadata
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
docs_only: ${{ steps.changed-paths.outputs.docs_only }}
owner: ${{ steps.vars.outputs.owner }}
version: ${{ steps.vars.outputs.version }}
app_env: ${{ steps.vars.outputs.app_env }}
extra_tag: ${{ steps.vars.outputs.extra_tag }}
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Classify changed paths
id: changed-paths
shell: bash
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != "push" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$GITHUB_REF" in
refs/heads/develop|refs/heads/main) ;;
*)
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
before="${{ github.event.before }}"
if [ "$before" = "0000000000000000000000000000000000000000" ]; then
changed_files="$(git diff-tree --no-commit-id --name-only -r "$GITHUB_SHA")"
elif git cat-file -e "$before" 2>/dev/null; then
changed_files="$(git diff --name-only "$before" "$GITHUB_SHA")"
else
# Force-pushed over the previous tip; the base is gone, so build everything.
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$changed_files" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi
docs_only=true
while IFS= read -r path; do
case "$path" in
apps/docs/*|*.md|*.mdx)
;;
*)
docs_only=false
break
;;
esac
done <<< "$changed_files"
echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"
- name: Resolve image metadata
id: vars
shell: bash
run: |
set -euo pipefail
owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
app_env="production"
if [ "${GITHUB_REF_TYPE:-}" = "branch" ] && [ "${GITHUB_REF_NAME:-}" = "develop" ]; then
app_env="preview"
fi
# Every build publishes one immutable tag (develop-<sha>, main-<sha>,
# or v*). Branch and release builds additionally move a mutable
# channel alias (develop, main, latest) so deployments can track a
# channel without editing image references on every build. Manual
# dispatch publishes no alias, so re-publishing an old version never
# moves a channel backwards.
extra_tag=""
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
version="${{ inputs.version }}"
elif [ "${GITHUB_REF_TYPE:-}" = "branch" ] && [ "${GITHUB_REF_NAME:-}" = "develop" ]; then
version="develop-${GITHUB_SHA:0:8}"
extra_tag="develop"
elif [ "${GITHUB_REF_TYPE:-}" = "branch" ] && [ "${GITHUB_REF_NAME:-}" = "main" ]; then
version="main-${GITHUB_SHA:0:8}"
extra_tag="main"
else
version="${GITHUB_REF_NAME:-}"
extra_tag="latest"
fi
case "$version" in
latest|develop|main|'')
echo "Refusing to publish mutable or empty version tag: $version" >&2
exit 1
;;
esac
case "$version" in
*[!A-Za-z0-9._-]*)
echo "Refusing to publish invalid image tag: $version" >&2
exit 1
;;
esac
echo "owner=$owner" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "app_env=$app_env" >> "$GITHUB_OUTPUT"
echo "extra_tag=$extra_tag" >> "$GITHUB_OUTPUT"
deployment-acceptance:
name: Deployment acceptance (amd64)
needs: prepare
if: ${{ needs.prepare.outputs.docs_only != 'true' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
packages: read
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup environment
uses: ./.github/actions/setup-environment
with:
frozen-lockfile: 'true'
node-version: 24.13.1
pnpm-version: 10.29.3
- name: Set up Docker builder
uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1
# The smoke test pulls the previous published release from GHCR as its
# upgrade baseline. The packages are public today, but authenticate
# anyway so a visibility change cannot silently break the release gate.
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve previous published channel
id: baseline
shell: bash
env:
EXTRA_TAG: ${{ needs.prepare.outputs.extra_tag }}
CANDIDATE_VERSION: ${{ needs.prepare.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
baseline="$EXTRA_TAG"
if [ "$baseline" != 'develop' ]; then
# Release-channel runs (tag, main, manual dispatch) validate the
# upgrade path from the previous published release. gh prints the
# 404 error body to stdout, so only trust the output when the call
# succeeds; capturing it with `|| true` is how a JSON blob once
# became a docker tag.
if ! baseline="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2>/dev/null)"; then
baseline=''
fi
# v0.0.1 predates the release pipeline and has no images. With no
# usable previous release, skip upgrade validation entirely: the
# smoke test still verifies a fresh install of the candidate.
if [ "$baseline" = 'v0.0.1' ]; then
echo "Latest release is legacy v0.0.1 (no images); skipping upgrade validation." >&2
baseline=''
fi
# Manual dispatch can re-publish an older version while a newer
# release is latest. Validating baseline→candidate would then be
# a downgrade dressed up as an upgrade (newer migrations, older
# code), so only keep the upgrade leg when the candidate is
# strictly newer than the baseline.
if [ -n "$baseline" ]; then
case "$CANDIDATE_VERSION" in
v[0-9]*)
newest="$(printf '%s\n%s\n' "$baseline" "$CANDIDATE_VERSION" | sort -V | tail -n1)"
if [ "$CANDIDATE_VERSION" = "$baseline" ] || [ "$newest" != "$CANDIDATE_VERSION" ]; then
echo "Candidate $CANDIDATE_VERSION is not newer than latest release $baseline; skipping upgrade validation for re-publish." >&2
baseline=''
fi
;;
esac
fi
fi
# Never let anything that is not a known channel or v* release tag
# reach `docker pull`.
case "$baseline" in
'' | develop | v[0-9]*) ;;
*)
echo "Ignoring unexpected baseline '$baseline'; skipping upgrade validation." >&2
baseline=''
;;
esac
echo "version=$baseline" >> "$GITHUB_OUTPUT"
- name: Exercise deployment lifecycle
env:
BASELINE_VERSION: ${{ steps.baseline.outputs.version }}
CANDIDATE_VERSION: ${{ needs.prepare.outputs.version }}
run: pnpm deployment:smoke
# Builds run concurrently with the acceptance gate: the gate builds its own
# local images for the smoke test, so nothing here depends on it. The gate
# instead blocks `publish` — builds only push untagged per-arch digests, and
# nothing is consumable until publish assembles the tagged manifests, so a
# failed gate still prevents any image from shipping.
build:
name: Build ${{ matrix.app }} (${{ matrix.arch }})
needs: prepare
if: ${{ needs.prepare.outputs.docs_only != 'true' }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
# Each arch builds natively (no QEMU: rustc segfaults under emulation,
# rust-lang/rust#147026) and pushes by digest; the merge job below
# assembles the digests into one multi-arch manifest per image.
matrix:
include:
- app: app
dockerfile: .docker/app/Dockerfile
target: runtime-app
image: roomote-app
arch: amd64
runner: blacksmith-4vcpu-ubuntu-2404
- app: app
dockerfile: .docker/app/Dockerfile
target: runtime-app
image: roomote-app
arch: arm64
runner: blacksmith-4vcpu-ubuntu-2404-arm
- app: worker
dockerfile: apps/worker/Dockerfile
target: runtime
image: roomote-worker
arch: amd64
runner: blacksmith-4vcpu-ubuntu-2404
- app: worker
dockerfile: apps/worker/Dockerfile
target: runtime
image: roomote-worker
arch: arm64
runner: blacksmith-4vcpu-ubuntu-2404-arm
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# Blacksmith's builder mounts a persistent NVMe layer cache into the
# runner (per repo/Dockerfile/arch). Unlike type=gha caching this has
# no 10GB repo cap or per-ref scoping, and skips the multi-minute
# cache export upload at the end of every build.
- name: Set up Docker builder
uses: useblacksmith/setup-docker-builder@47a5d0102cc44712a17a633c2599f755008cc40e # v1
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ matrix.app }} (${{ matrix.arch }}) by digest
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
platforms: linux/${{ matrix.arch }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.owner }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
build-args: |
R_APP_ENV=${{ needs.prepare.outputs.app_env }}
RELEASE_VERSION=${{ needs.prepare.outputs.version }}
- name: Export digest
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digests-${{ matrix.app }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish ${{ matrix.app }} manifest
needs: [prepare, build, deployment-acceptance]
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- app: app
image: roomote-app
- app: worker
image: roomote-worker
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: digests-${{ matrix.app }}-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create multi-arch manifest
shell: bash
env:
IMAGE: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.owner }}/${{ matrix.image }}
VERSION: ${{ needs.prepare.outputs.version }}
EXTRA_TAG: ${{ needs.prepare.outputs.extra_tag }}
run: |
set -euo pipefail
cd /tmp/digests
tag_args=(-t "$IMAGE:$VERSION")
if [ -n "$EXTRA_TAG" ]; then
tag_args+=(-t "$IMAGE:$EXTRA_TAG")
fi
digest_refs=()
for digest in *; do
digest_refs+=("$IMAGE@sha256:$digest")
done
[ "${#digest_refs[@]}" -eq 2 ] || {
echo "Expected 2 arch digests, found ${#digest_refs[@]}" >&2
exit 1
}
docker buildx imagetools create "${tag_args[@]}" "${digest_refs[@]}"
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Product v* tags: only publish the GitHub Release after multi-arch images for
# both roomote-app and roomote-worker exist at :$version, so self-host install
# (releases/latest) never points at a tag whose images are not ready yet.
create-github-release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [prepare, publish]
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.prepare.outputs.docs_only != 'true' }}
permissions:
contents: write
packages: read
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish GitHub Release for product tag
env:
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
OWNER: ${{ needs.prepare.outputs.owner }}
shell: bash
run: |
set -euo pipefail
: "${VERSION:?version is required}"
tag="$VERSION"
case "$tag" in
v*) ;;
*)
echo "Expected a v* product tag, got: $tag" >&2
exit 1
;;
esac
version_no_v="${tag#v}"
for image in roomote-app roomote-worker; do
ref="${REGISTRY}/${OWNER}/${image}:${VERSION}"
echo "Verifying image exists: $ref"
docker buildx imagetools inspect "$ref" >/dev/null
done
if gh release view "$tag" >/dev/null 2>&1; then
echo "GitHub Release $tag already exists; skip."
exit 0
fi
notes_file="$(mktemp)"
node scripts/release/extract-changelog-section.mjs "$version_no_v" > "$notes_file" || true
if [ ! -s "$notes_file" ]; then
printf 'Roomote %s\n' "$tag" > "$notes_file"
fi
gh release create "$tag" \
--title "Roomote $tag" \
--notes-file "$notes_file" \
--target "$(git rev-parse HEAD)" \
--latest
rm -f "$notes_file"
echo "Published GitHub Release $tag (images ready)"
notify-ops:
name: Notify ops repository
runs-on: ubuntu-latest
needs: publish
if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') && vars.ROOMOTE_OPS_REPO != '' }}
permissions:
contents: read
steps:
- name: Send repository dispatch
env:
GH_TOKEN: ${{ secrets.ROOMOTE_OPS_DISPATCH_TOKEN }}
OPS_REPO: ${{ vars.ROOMOTE_OPS_REPO }}
run: |
set -euo pipefail
: "${GH_TOKEN:?ROOMOTE_OPS_DISPATCH_TOKEN is required}"
case "$GITHUB_REF" in
refs/heads/develop)
event_type=roomote-develop-build
image_tag="develop-${GITHUB_SHA:0:8}"
;;
refs/heads/main)
event_type=roomote-main-build
image_tag="main-${GITHUB_SHA:0:8}"
;;
*)
echo "Unexpected ref for notify-ops: $GITHUB_REF" >&2
exit 1
;;
esac
gh api "repos/${OPS_REPO}/dispatches" \
-f "event_type=${event_type}" \
-f "client_payload[image_tag]=${image_tag}" \
-f "client_payload[sha]=${GITHUB_SHA}" \
-f "client_payload[ref]=${GITHUB_REF}"