Route triage and audit automations to any comms destination (#291) #280
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: 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}" |