Skip to content

[Fix] Surface API errors in logs and return MCP validation failures a… #908

[Fix] Surface API errors in logs and return MCP validation failures a…

[Fix] Surface API errors in logs and return MCP validation failures a… #908

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
permissions:
contents: read
outputs:
owner: ${{ steps.vars.outputs.owner }}
version: ${{ steps.vars.outputs.version }}
product_version: ${{ steps.vars.outputs.product_version }}
app_env: ${{ steps.vars.outputs.app_env }}
extra_tag: ${{ steps.vars.outputs.extra_tag }}
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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
# The product (changelog) version is baked alongside the image tag
# so channel builds (develop-<sha>/main-<sha>) still know which
# product release they contain (used by the in-app release notices).
product_version="$(jq -r .version package.json)"
case "$product_version" in
''|null)
echo "Failed to read product version from package.json" >&2
exit 1
;;
esac
echo "owner=$owner" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "product_version=$product_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
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
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 }}
RELEASE_PRODUCT_VERSION=${{ needs.prepare.outputs.product_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') }}
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
id: publish_release
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."
echo "created=false" >> "$GITHUB_OUTPUT"
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 "created=true" >> "$GITHUB_OUTPUT"
echo "Published GitHub Release $tag (images ready)"
- name: Announce GitHub Release in Discord
if: ${{ steps.publish_release.outputs.created == 'true' }}
continue-on-error: true
env:
DISCORD_MAIN_WEBHOOK_URL: ${{ secrets.DISCORD_MAIN_WEBHOOK_URL }}
GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
shell: bash
run: |
set -euo pipefail
if [ -z "${DISCORD_MAIN_WEBHOOK_URL:-}" ]; then
echo "::notice::DISCORD_MAIN_WEBHOOK_URL is not configured; skipping the Discord release announcement."
exit 0
fi
# Custom Discord payloads use the base webhook endpoint. Also accept
# URLs copied from the native GitHub integration setup for convenience.
webhook_url="${DISCORD_MAIN_WEBHOOK_URL%/github}"
payload_file="$(mktemp)"
trap 'rm -f "$payload_file"' EXIT
gh release view "$VERSION" \
--json name,body,url,publishedAt,tagName \
| node scripts/release/build-discord-release-payload.mjs \
> "$payload_file"
curl \
--fail-with-body \
--silent \
--show-error \
--retry 3 \
--retry-all-errors \
--connect-timeout 10 \
--max-time 30 \
--header 'Content-Type: application/json' \
--data-binary "@$payload_file" \
"$webhook_url"
echo "Announced GitHub Release $VERSION in Discord"
# This job only follows successful develop image publishes. (The old
# notify-ops job that dispatched hand-built fleet deploys to Roomote-Ops
# was removed 2026-08-01 with that repo's retirement: every tenant is
# Cloud-managed, and the dispatch had been redeploying quiesced legacy
# projects on every main build.)
notify-staging-cloud:
name: Notify staging Cloud
runs-on: ubuntu-latest
needs:
- prepare
- publish
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' && vars.ROOMOTE_CLOUD_REPO != '' }}
permissions:
contents: read
steps:
- name: Send staging repository dispatch
env:
GH_TOKEN: ${{ secrets.ROOMOTE_CLOUD_DISPATCH_TOKEN }}
CLOUD_REPO: ${{ vars.ROOMOTE_CLOUD_REPO }}
IMAGE_TAG: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
: "${GH_TOKEN:?ROOMOTE_CLOUD_DISPATCH_TOKEN is required}"
case "$CLOUD_REPO" in
RooCodeInc/Roomote-Cloud) ;;
*)
echo "Refusing unexpected ROOMOTE_CLOUD_REPO: $CLOUD_REPO" >&2
exit 1
;;
esac
gh api "repos/${CLOUD_REPO}/dispatches" \
-f event_type=roomote-develop-build \
-f "client_payload[image_tag]=${IMAGE_TAG}" \
-f "client_payload[sha]=${GITHUB_SHA}" \
-f "client_payload[ref]=${GITHUB_REF}"
# Production Cloud rolls its tenant fleet from immutable main builds the
# same way staging follows develop builds. The Cloud-side workflow
# validates the tag against the commit before touching anything.
notify-production-cloud:
name: Notify production Cloud
runs-on: ubuntu-latest
needs:
- prepare
- publish
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.ROOMOTE_CLOUD_REPO != '' }}
permissions:
contents: read
steps:
- name: Send production repository dispatch
env:
GH_TOKEN: ${{ secrets.ROOMOTE_CLOUD_DISPATCH_TOKEN }}
CLOUD_REPO: ${{ vars.ROOMOTE_CLOUD_REPO }}
IMAGE_TAG: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
: "${GH_TOKEN:?ROOMOTE_CLOUD_DISPATCH_TOKEN is required}"
case "$CLOUD_REPO" in
RooCodeInc/Roomote-Cloud) ;;
*)
echo "Refusing unexpected ROOMOTE_CLOUD_REPO: $CLOUD_REPO" >&2
exit 1
;;
esac
gh api "repos/${CLOUD_REPO}/dispatches" \
-f event_type=roomote-main-build \
-f "client_payload[image_tag]=${IMAGE_TAG}" \
-f "client_payload[sha]=${GITHUB_SHA}" \
-f "client_payload[ref]=${GITHUB_REF}"