Skip to content

RELEASE — Publish (preview + production) #159

RELEASE — Publish (preview + production)

RELEASE — Publish (preview + production) #159

Workflow file for this run

name: RELEASE — Publish (preview + production)
on:
workflow_dispatch:
inputs:
checks_profile:
description: "Checks — Profile (full=all standard checks, fast=skip slow builds/smoke, none=skip all checks, custom=use toggles below)"
required: true
type: choice
default: full
options:
- full
- fast
- none
- custom
run_providers:
description: "Checks — Run providers contract suite (requires provider secrets)"
required: true
default: false
type: boolean
providers_preset:
description: "Checks (PROVIDERS) — Preset"
required: true
default: all
type: choice
options:
- all
- claude
- codex
- opencode
providers_tier:
description: "Checks (PROVIDERS) — Tier"
required: true
default: smoke
type: choice
options:
- smoke
- extended
custom_checks:
description: "Checks (CUST) — Comma-separated toggles used only when checks_profile=custom: e2e_core,e2e_core_slow,server_db_contract,stress,cli_smoke_linux,build_website,build_docs"
required: true
default: e2e_core,server_db_contract,cli_smoke_linux,build_website,build_docs
type: string
release_verify_profile:
description: "Verify — Post-build release verification (none=skip, smoke=installers+binaries, full=include self-host E2E). Preview-only; runs best-effort after publishing."
required: true
type: choice
default: none
options:
- none
- smoke
- full
dry_run:
description: "Safety — Run checks and show plan, but do not push/deploy"
required: true
default: false
type: boolean
environment:
description: "Deploy — Target environment (deploy/<env>/*)"
required: true
type: choice
default: preview
options:
- preview
- production
deploy_targets:
description: "Deploy — Comma-separated targets: ui,server,website,docs,cli,stack,server_runner"
required: true
default: ui,server,website,docs
type: string
force_deploy:
description: "Deploy — Force promote + deploy for enabled targets even if no relevant changes are detected (useful after infra failures)"
required: true
default: false
type: boolean
ui_expo_action:
description: "Deploy — UI Expo action (none|ota|native|native_submit) for the selected environment"
required: true
default: none
type: choice
options:
- none
- ota
- native
- native_submit
ui_expo_builder:
description: "Deploy — UI native build runner (eas_cloud uses Expo build servers; eas_local builds on GitHub runners)"
required: true
default: eas_cloud
type: choice
options:
- eas_cloud
- eas_local
ui_expo_profile:
description: "Deploy — UI EAS build profile (auto recommended). Use *-apk profiles to produce installable Android APKs and publish to GitHub Releases."
required: true
default: auto
type: choice
options:
- auto
- preview
- preview-apk
- production
- production-apk
ui_expo_platform:
description: "Deploy — UI Expo native platform"
required: true
default: all
type: choice
options:
- ios
- android
- all
desktop_mode:
description: "Deploy — Desktop artifacts mode"
required: true
type: choice
default: none
options:
- none
- build_only
- build_and_publish
bump:
description: "Version bump (Preset) — Default bump for changed components. Preview bumps commit to dev; production bumps commit to dev then promote dev->main."
required: true
type: choice
default: none
options:
- none
- patch
- minor
- major
bump_app_override:
description: "Version bump override (App) — preset=use bump preset; none=disable bump"
required: true
type: choice
default: preset
options:
- preset
- none
- patch
- minor
- major
bump_cli_override:
description: "Version bump override (CLI) — preset=use bump preset; none=disable bump"
required: true
type: choice
default: preset
options:
- preset
- none
- patch
- minor
- major
bump_stack_override:
description: "Version bump override (Stack) — preset=use bump preset; none=disable bump"
required: true
type: choice
default: preset
options:
- preset
- none
- patch
- minor
- major
confirm:
description: "Confirm — Choose the exact action for this environment"
required: true
type: choice
options:
- release preview from dev
- release dev to main
- reset main from dev
release_message:
description: "Release message — Optional note to include in Expo OTA metadata and GitHub rolling release notes"
required: false
default: ""
type: string
permissions:
contents: write
actions: read
concurrency:
group: release-unified
cancel-in-progress: false
jobs:
release_actor_guard:
name: Release actor guard
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Authorize release actor
uses: ./.github/actions/release-actor-guard
with:
team_slug: release-admins
ci:
name: CI Checks (tests.yml)
needs: [release_actor_guard]
if: inputs.checks_profile != 'none'
uses: ./.github/workflows/tests.yml
with:
run_ui: true
run_server: true
run_cli: true
run_stack: true
run_typecheck: true
run_cli_daemon_e2e: true
run_e2e_core: ${{ inputs.checks_profile == 'full' || (inputs.checks_profile == 'custom' && (contains(format(',{0},', inputs.custom_checks), ',e2e_core,') || contains(format(',{0},', inputs.custom_checks), ',e2e_core_slow,'))) }}
run_e2e_core_slow: ${{ inputs.checks_profile == 'full' || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',e2e_core_slow,')) }}
run_server_db_contract: ${{ inputs.checks_profile == 'full' || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',server_db_contract,')) }}
run_providers: false
providers_preset: ${{ inputs.providers_preset }}
providers_tier: ${{ inputs.providers_tier }}
run_stress: ${{ inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',stress,') }}
run_release_contracts: true
run_installers_smoke: false
run_binary_smoke: false
run_self_host_systemd: false
run_self_host_launchd: false
run_self_host_schtasks: false
run_self_host_daemon: false
providers:
name: Providers Checks (providers-contracts.yml)
needs: [release_actor_guard]
if: inputs.checks_profile != 'none' && inputs.run_providers == true
uses: ./.github/workflows/providers-contracts.yml
secrets: inherit
with:
preset: ${{ inputs.providers_preset }}
tier: ${{ inputs.providers_tier }}
strict_keys: false
flake_retry: true
plan:
name: Release Plan (inputs + changes + bump/publish decisions)
needs: [release_actor_guard, ci, providers]
if: ${{ always() && needs.release_actor_guard.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') && (needs.providers.result == 'success' || needs.providers.result == 'skipped') }}
runs-on: ubuntu-latest
outputs:
changed_ui: ${{ steps.plan.outputs.changed_ui }}
changed_cli: ${{ steps.plan.outputs.changed_cli }}
changed_server: ${{ steps.plan.outputs.changed_server }}
changed_website: ${{ steps.plan.outputs.changed_website }}
changed_docs: ${{ steps.plan.outputs.changed_docs }}
changed_shared: ${{ steps.plan.outputs.changed_shared }}
changed_stack: ${{ steps.plan.outputs.changed_stack }}
bump_app: ${{ steps.bump_plan.outputs.bump_app }}
bump_cli: ${{ steps.bump_plan.outputs.bump_cli }}
bump_stack: ${{ steps.bump_plan.outputs.bump_stack }}
bump_server: ${{ steps.bump_plan.outputs.bump_server }}
bump_website: ${{ steps.bump_plan.outputs.bump_website }}
should_bump: ${{ steps.bump_plan.outputs.should_bump }}
publish_cli: ${{ steps.bump_plan.outputs.publish_cli }}
publish_stack: ${{ steps.bump_plan.outputs.publish_stack }}
publish_server: ${{ steps.bump_plan.outputs.publish_server }}
steps:
- name: Validate inputs
run: |
set -euo pipefail
confirm="${{ inputs.confirm }}"
env_name="${{ inputs.environment }}"
mode=""
if [ "$env_name" = "preview" ]; then
if [ "$confirm" != "release preview from dev" ]; then
echo "Confirmation mismatch for preview releases." >&2
echo "Expected: release preview from dev" >&2
echo "Got: $confirm" >&2
exit 1
fi
mode="preview_release"
elif [ "$confirm" = "release dev to main" ]; then
mode="fast_forward"
elif [ "$confirm" = "reset main from dev" ]; then
mode="reset"
else
echo "Unknown confirmation phrase: $confirm" >&2
exit 1
fi
# Validate deploy_targets extras (cli/stack are publishable targets).
raw_targets="${{ inputs.deploy_targets }}"
norm="$(printf '%s' "${raw_targets}" | tr '[:upper:]' '[:lower:]' | tr -d ' \t\r\n')"
if [ -n "${norm:-}" ]; then
IFS=',' read -r -a parts <<< "${norm}"
for p in "${parts[@]}"; do
[ -z "${p:-}" ] && continue
case "${p}" in
ui|server|website|docs|cli|stack|server_runner) ;;
*)
echo "Unknown deploy_targets entry: '${p}'. Expected: ui,server,website,docs,cli,stack,server_runner" >&2
exit 1
;;
esac
done
fi
- name: Enforce trusted refs for manual dispatch
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
exit 0
fi
case "${GITHUB_REF_NAME}" in
dev|main) ;;
*)
echo "Refusing workflow_dispatch from untrusted ref '${GITHUB_REF_NAME}'. Use dev or main." >&2
exit 1
;;
esac
- name: Checkout dev
uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: yarn
cache-dependency-path: yarn.lock
- name: Enable Corepack (Yarn)
if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && (contains(format(',{0},', inputs.custom_checks), ',build_website,') || contains(format(',{0},', inputs.custom_checks), ',build_docs,') || contains(format(',{0},', inputs.custom_checks), ',cli_smoke_linux,'))) }}
run: |
corepack enable
corepack prepare yarn@1.22.22 --activate
- name: Install dependencies
if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && (contains(format(',{0},', inputs.custom_checks), ',build_website,') || contains(format(',{0},', inputs.custom_checks), ',build_docs,') || contains(format(',{0},', inputs.custom_checks), ',cli_smoke_linux,'))) }}
env:
YARN_PRODUCTION: "false"
npm_config_production: "false"
run: yarn install --frozen-lockfile --ignore-engines
- name: Test + typecheck
if: inputs.checks_profile != 'none'
run: |
set -euo pipefail
echo "CI checks ran in job 'ci' (tests/typecheck/e2e/providers/stress as configured)."
- name: Build website
if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',build_website,')) }}
run: yarn website:build
- name: Build docs
if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',build_docs,')) }}
run: yarn docs:build
- name: CLI smoke test (Linux)
if: ${{ (inputs.checks_profile == 'full') || (inputs.checks_profile == 'custom' && contains(format(',{0},', inputs.custom_checks), ',cli_smoke_linux,')) }}
run: |
set -euo pipefail
yarn workspace @happier-dev/cli build
PACKAGE_NAME="$(cd apps/cli && npm pack --silent --pack-destination /tmp)"
PACKAGE_FILE="/tmp/${PACKAGE_NAME}"
if [ ! -f "${PACKAGE_FILE}" ]; then
echo "Unable to find packed CLI tarball at ${PACKAGE_FILE}" >&2
exit 1
fi
SMOKE_PREFIX="$(mktemp -d)"
SMOKE_HOME="$(mktemp -d)"
npm install -g --prefix "${SMOKE_PREFIX}" "${PACKAGE_FILE}"
HAPPIER_BIN="${SMOKE_PREFIX}/bin/happier"
if [ ! -x "${HAPPIER_BIN}" ]; then
echo "Expected CLI binary not found at ${HAPPIER_BIN}" >&2
exit 1
fi
HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 30s "${HAPPIER_BIN}" --help
HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" --version
DOCTOR_OUTPUT="$(HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" doctor --help)"
printf '%s\n' "${DOCTOR_OUTPUT}"
DAEMON_HELP_OUTPUT="$(HAPPIER_HOME_DIR="${SMOKE_HOME}" timeout 10s "${HAPPIER_BIN}" daemon --help)"
printf '%s\n' "${DAEMON_HELP_OUTPUT}"
printf '%s' "${DAEMON_HELP_OUTPUT}" | grep -q 'Daemon management'
- name: Compute changed components (main..dev)
id: plan
run: |
set -euo pipefail
git fetch origin main dev --prune --tags
dev_sha="$(git rev-parse HEAD)"
main_sha="$(git rev-parse origin/main)"
node scripts/release/compute-changed-components.mjs \
--base "$main_sha" \
--head "$dev_sha" \
--out "$GITHUB_OUTPUT"
- name: Release plan summary
run: |
set -euo pipefail
{
echo "## Release plan (dev → main)"
echo ""
echo "- checks_profile: \`${{ inputs.checks_profile }}\`"
echo "- custom_checks: \`${{ inputs.custom_checks }}\`"
echo "- environment: \`${{ inputs.environment }}\`"
echo "- confirm: \`${{ inputs.confirm }}\`"
echo "- dry_run: \`${{ inputs.dry_run }}\`"
echo "- deploy_targets: \`${{ inputs.deploy_targets }}\`"
echo "- bump: \`${{ inputs.bump }}\`"
echo "- bump_app_override: \`${{ inputs.bump_app_override }}\`"
echo "- bump_cli_override: \`${{ inputs.bump_cli_override }}\`"
echo "- bump_stack_override: \`${{ inputs.bump_stack_override }}\`"
echo "- ui_expo_action: \`${{ inputs.ui_expo_action }}\` (builder=\`${{ inputs.ui_expo_builder }}\`, platform=\`${{ inputs.ui_expo_platform }}\`, profile=\`${{ inputs.ui_expo_profile }}\`)"
echo "- desktop_mode: \`${{ inputs.desktop_mode }}\`"
echo ""
echo "- commits to release (main..dev): \`${{ steps.plan.outputs.commit_count }}\`"
echo ""
echo "### Changed components (main..dev)"
echo "- ui: \`${{ steps.plan.outputs.changed_ui }}\`"
echo "- cli: \`${{ steps.plan.outputs.changed_cli }}\`"
echo "- server: \`${{ steps.plan.outputs.changed_server }}\`"
echo "- stack: \`${{ steps.plan.outputs.changed_stack }}\`"
echo "- website: \`${{ steps.plan.outputs.changed_website }}\`"
echo "- docs: \`${{ steps.plan.outputs.changed_docs }}\`"
echo "- shared (agents/protocol): \`${{ steps.plan.outputs.changed_shared }}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve publish + bump plan
id: bump_plan
env:
ENVIRONMENT: ${{ inputs.environment }}
BUMP_PRESET: ${{ inputs.bump }}
BUMP_APP_OVERRIDE: ${{ inputs.bump_app_override }}
BUMP_CLI_OVERRIDE: ${{ inputs.bump_cli_override }}
BUMP_STACK_OVERRIDE: ${{ inputs.bump_stack_override }}
DEPLOY_TARGETS: ${{ inputs.deploy_targets }}
CHANGED_UI: ${{ steps.plan.outputs.changed_ui }}
CHANGED_CLI: ${{ steps.plan.outputs.changed_cli }}
CHANGED_SERVER: ${{ steps.plan.outputs.changed_server }}
CHANGED_WEBSITE: ${{ steps.plan.outputs.changed_website }}
CHANGED_STACK: ${{ steps.plan.outputs.changed_stack }}
CHANGED_SHARED: ${{ steps.plan.outputs.changed_shared }}
run: |
set -euo pipefail
normalize_csv() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d ' \t\r\n'
}
resolve_override() {
local override="$1"
local preset="$2"
if [ "$override" = "preset" ]; then
printf '%s' "$preset"
return 0
fi
printf '%s' "$override"
}
should_bump_component() {
local changed="$1"
local bump="$2"
if [ "$changed" != "true" ]; then
printf '%s' "none"
return 0
fi
printf '%s' "$bump"
}
bump_preset="${BUMP_PRESET}"
publish_cli="false"
publish_stack="false"
publish_server="false"
pt="$(normalize_csv "${DEPLOY_TARGETS}")"
if [ -n "${pt:-}" ]; then
IFS=',' read -r -a parts <<< "${pt}"
for p in "${parts[@]}"; do
[ -z "${p:-}" ] && continue
if [ "$p" = "cli" ]; then publish_cli="true"; fi
if [ "$p" = "stack" ]; then publish_stack="true"; fi
if [ "$p" = "server_runner" ]; then publish_server="true"; fi
done
fi
app_override="$(resolve_override "${BUMP_APP_OVERRIDE}" "${bump_preset}")"
cli_override="$(resolve_override "${BUMP_CLI_OVERRIDE}" "${bump_preset}")"
stack_override="$(resolve_override "${BUMP_STACK_OVERRIDE}" "${bump_preset}")"
changed_app="false"
if [ "${CHANGED_UI}" = "true" ] || [ "${CHANGED_SHARED}" = "true" ]; then
changed_app="true"
fi
changed_cli="false"
if [ "${CHANGED_CLI}" = "true" ] || [ "${CHANGED_SHARED}" = "true" ]; then
changed_cli="true"
fi
changed_stack="false"
if [ "${CHANGED_STACK}" = "true" ] || [ "${CHANGED_SHARED}" = "true" ]; then
changed_stack="true"
fi
changed_server="false"
if [ "${CHANGED_SERVER}" = "true" ] || [ "${CHANGED_SHARED}" = "true" ]; then
changed_server="true"
fi
bump_app="$(should_bump_component "${changed_app}" "${app_override}")"
bump_cli="$(should_bump_component "${changed_cli}" "${cli_override}")"
bump_stack="$(should_bump_component "${changed_stack}" "${stack_override}")"
bump_server="$(should_bump_component "${changed_server}" "${bump_preset}")"
bump_website="$(should_bump_component "${CHANGED_WEBSITE}" "${bump_preset}")"
if [ "${ENVIRONMENT}" = "production" ]; then
if [ "${publish_cli}" = "true" ] && [ "${bump_cli}" = "none" ]; then
dev_version="$(node -p 'require("./apps/cli/package.json").version' | tr -d '\n' || true)"
main_version="$(git show origin/main:apps/cli/package.json | node -e 'const fs=require("node:fs"); const raw=fs.readFileSync(0,"utf8"); process.stdout.write(String(JSON.parse(raw).version||""))')"
if [ -z "${dev_version:-}" ] || [ -z "${main_version:-}" ]; then
echo "Unable to resolve cli versions for production validation." >&2
exit 1
fi
if [ "${dev_version}" = "${main_version}" ]; then
echo "Refusing production deploy_targets includes cli without a version change (dev and main both at ${dev_version}). Set bump!=none or bump_cli_override!=none." >&2
exit 1
fi
fi
if [ "${publish_stack}" = "true" ] && [ "${bump_stack}" = "none" ]; then
dev_version="$(node -p 'require("./apps/stack/package.json").version' | tr -d '\n' || true)"
main_version="$(git show origin/main:apps/stack/package.json | node -e 'const fs=require("node:fs"); const raw=fs.readFileSync(0,"utf8"); process.stdout.write(String(JSON.parse(raw).version||""))')"
if [ -z "${dev_version:-}" ] || [ -z "${main_version:-}" ]; then
echo "Unable to resolve stack versions for production validation." >&2
exit 1
fi
if [ "${dev_version}" = "${main_version}" ]; then
echo "Refusing production deploy_targets includes stack without a version change (dev and main both at ${dev_version}). Set bump!=none or bump_stack_override!=none." >&2
exit 1
fi
fi
if [ "${publish_server}" = "true" ] && [ "${bump_server}" = "none" ]; then
runner_dev_pkg="./packages/relay-server/package.json"
if [ ! -f "${runner_dev_pkg}" ]; then
echo "Unable to resolve server runner package.json (expected ${runner_dev_pkg})." >&2
exit 1
fi
dev_version="$(node -p "require('${runner_dev_pkg}').version" 2>/dev/null | tr -d '\n' || true)"
main_version="$(git show 'origin/main:packages/relay-server/package.json' 2>/dev/null | node -e 'const fs=require("node:fs"); const raw=fs.readFileSync(0,"utf8"); try { process.stdout.write(String(JSON.parse(raw).version||"")) } catch { process.stdout.write("") }' || true)"
if [ -n "${main_version:-}" ] && [ -n "${dev_version:-}" ] && [ "${dev_version}" = "${main_version}" ]; then
echo "Refusing production deploy_targets includes server without a version change (dev and main both at ${dev_version}). Set bump!=none." >&2
exit 1
fi
fi
fi
should_bump="false"
for v in "${bump_app}" "${bump_cli}" "${bump_stack}" "${bump_server}" "${bump_website}"; do
if [ "${v}" != "none" ]; then
should_bump="true"
break
fi
done
echo "publish_cli=${publish_cli}" >> "$GITHUB_OUTPUT"
echo "publish_stack=${publish_stack}" >> "$GITHUB_OUTPUT"
echo "publish_server=${publish_server}" >> "$GITHUB_OUTPUT"
echo "bump_app=${bump_app}" >> "$GITHUB_OUTPUT"
echo "bump_cli=${bump_cli}" >> "$GITHUB_OUTPUT"
echo "bump_stack=${bump_stack}" >> "$GITHUB_OUTPUT"
echo "bump_server=${bump_server}" >> "$GITHUB_OUTPUT"
echo "bump_website=${bump_website}" >> "$GITHUB_OUTPUT"
echo "should_bump=${should_bump}" >> "$GITHUB_OUTPUT"
{
echo ""
echo "### Effective bump plan"
echo "- publish_cli: \`${publish_cli}\`"
echo "- publish_stack: \`${publish_stack}\`"
echo "- publish_server: \`${publish_server}\`"
echo "- bump preset: \`${bump_preset}\`"
echo "- bump_app: \`${bump_app}\`"
echo "- bump_cli: \`${bump_cli}\`"
echo "- bump_stack: \`${bump_stack}\`"
echo "- bump_server: \`${bump_server}\`"
echo "- bump_website: \`${bump_website}\`"
} >> "$GITHUB_STEP_SUMMARY"
deploy_plan:
name: deploy_plan (source → deploy branches)
runs-on: ubuntu-latest
environment: release-shared
if: >-
always() && needs.plan.result == 'success' && (
contains(format(',{0},', inputs.deploy_targets), ',server,') ||
contains(format(',{0},', inputs.deploy_targets), ',website,') ||
contains(format(',{0},', inputs.deploy_targets), ',docs,') ||
(inputs.environment == 'production' && contains(format(',{0},', inputs.deploy_targets), ',ui,'))
)
needs: [plan, bump_versions_dev, promote_main]
outputs:
deploy_ui_needed: ${{ steps.plan.outputs.deploy_ui_needed }}
deploy_server_needed: ${{ steps.plan.outputs.deploy_server_needed }}
deploy_website_needed: ${{ steps.plan.outputs.deploy_website_needed }}
deploy_docs_needed: ${{ steps.plan.outputs.deploy_docs_needed }}
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Checkout release source
uses: actions/checkout@v4
with:
ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Compute deploy plan (deploy/<env>/* behind release source?)
id: plan
env:
ENVIRONMENT: ${{ inputs.environment }}
SOURCE_REF: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
FORCE_DEPLOY: ${{ inputs.force_deploy }}
DEPLOY_UI: ${{ contains(format(',{0},', inputs.deploy_targets), ',ui,') }}
DEPLOY_SERVER: ${{ contains(format(',{0},', inputs.deploy_targets), ',server,') }}
DEPLOY_WEBSITE: ${{ contains(format(',{0},', inputs.deploy_targets), ',website,') }}
DEPLOY_DOCS: ${{ contains(format(',{0},', inputs.deploy_targets), ',docs,') }}
run: |
set -euo pipefail
env_name="${ENVIRONMENT}"
force_deploy="${FORCE_DEPLOY}"
source_ref="${SOURCE_REF}"
source_sha="$(git rev-parse HEAD)"
git fetch origin "${source_ref}" --prune --tags
source_sha="$(git rev-parse "origin/${source_ref}")"
ui_ref="deploy/${env_name}/ui"
server_ref="deploy/${env_name}/server"
website_ref="deploy/${env_name}/website"
docs_ref="deploy/${env_name}/docs"
git fetch origin "${ui_ref}" "${server_ref}" "${website_ref}" "${docs_ref}" --prune || true
plan_one() {
local key="$1"
local deploy_ref="$2"
local enabled="$3"
shift 3
local -a patterns=("$@")
local deploy_sha=""
deploy_sha="$(git rev-parse "origin/${deploy_ref}" 2>/dev/null || true)"
if [ -z "${deploy_sha:-}" ]; then
local needed=false
if [ "${enabled}" = "true" ] && [ "${force_deploy}" = "true" ]; then
needed=true
fi
printf -v "${key}_needed" '%s' "${needed}"
printf -v "${key}_commits" '%s' "0"
printf -v "${key}_relevant_changes" '%s' "false"
echo "${key}_needed=${needed}" >> "$GITHUB_OUTPUT"
echo "${key}_commits=0" >> "$GITHUB_OUTPUT"
echo "${key}_relevant_changes=false" >> "$GITHUB_OUTPUT"
return 0
fi
local commits=""
commits="$(git rev-list --count "${deploy_sha}..${source_sha}" 2>/dev/null || echo 0)"
local base=""
base="$(git merge-base "${deploy_sha}" "${source_sha}" 2>/dev/null || true)"
if [ -z "${base:-}" ]; then
base="${deploy_sha}"
fi
local changed=false
if [ "${commits}" != "0" ]; then
git diff --name-only "${base}..${source_sha}" > "/tmp/changed_${key}.txt" || true
while IFS= read -r p; do
for pat in "${patterns[@]}"; do
case "$p" in
$pat) changed=true ;;
*) ;;
esac
if [ "${changed}" = "true" ]; then
break
fi
done
if [ "${changed}" = "true" ]; then
break
fi
done < "/tmp/changed_${key}.txt"
fi
local needed=false
if [ "${enabled}" = "true" ] && [ "${force_deploy}" = "true" ]; then
needed=true
elif [ "${enabled}" = "true" ] && [ "${commits}" != "0" ] && [ "${changed}" = "true" ]; then
needed=true
fi
printf -v "${key}_needed" '%s' "${needed}"
printf -v "${key}_commits" '%s' "${commits}"
printf -v "${key}_relevant_changes" '%s' "${changed}"
echo "${key}_needed=${needed}" >> "$GITHUB_OUTPUT"
echo "${key}_commits=${commits}" >> "$GITHUB_OUTPUT"
echo "${key}_relevant_changes=${changed}" >> "$GITHUB_OUTPUT"
}
plan_one "deploy_ui" "${ui_ref}" "${DEPLOY_UI}" "apps/ui/*" "packages/agents/*" "packages/protocol/*"
plan_one "deploy_server" "${server_ref}" "${DEPLOY_SERVER}" "apps/server/*" "packages/relay-server/*" "packages/agents/*" "packages/protocol/*"
plan_one "deploy_website" "${website_ref}" "${DEPLOY_WEBSITE}" "apps/website/*" "scripts/release/installers/*" "scripts/release/sync-installers.mjs"
plan_one "deploy_docs" "${docs_ref}" "${DEPLOY_DOCS}" "apps/docs/*"
{
echo "## Deploy plan (source → deploy branches)"
echo ""
echo "- environment: \`${env_name}\`"
echo "- source ref: \`${source_ref}\`"
echo "- origin/${source_ref}: \`${source_sha}\`"
echo ""
echo "| component | enabled | deploy ref | commits behind | relevant changes | will run |"
echo "|---|---:|---|---:|---:|---:|"
echo "| ui | \`${DEPLOY_UI}\` | \`${ui_ref}\` | \`${deploy_ui_commits}\` | \`${deploy_ui_relevant_changes}\` | \`${deploy_ui_needed}\` |"
echo "| server | \`${DEPLOY_SERVER}\` | \`${server_ref}\` | \`${deploy_server_commits}\` | \`${deploy_server_relevant_changes}\` | \`${deploy_server_needed}\` |"
echo "| website | \`${DEPLOY_WEBSITE}\` | \`${website_ref}\` | \`${deploy_website_commits}\` | \`${deploy_website_relevant_changes}\` | \`${deploy_website_needed}\` |"
echo "| docs | \`${DEPLOY_DOCS}\` | \`${docs_ref}\` | \`${deploy_docs_commits}\` | \`${deploy_docs_relevant_changes}\` | \`${deploy_docs_needed}\` |"
} >> "$GITHUB_STEP_SUMMARY"
promote_main:
# GitHub skips dependent jobs when a needed job is skipped unless we use always().
# In production, bump_versions_dev is frequently skipped (bump=none), but we still
# must promote dev -> main so downstream deploy/publish jobs can run.
if: always() && needs.plan.result == 'success' && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && inputs.dry_run != true && inputs.environment == 'production'
needs: [plan, bump_versions_dev]
uses: ./.github/workflows/promote-branch.yml
secrets: inherit
with:
source: dev
target: main
mode: ${{ inputs.confirm == 'reset main from dev' && 'reset' || 'fast_forward' }}
dry_run: ${{ inputs.dry_run }}
allow_reset: ${{ inputs.confirm == 'reset main from dev' }}
confirm: ${{ inputs.confirm == 'reset main from dev' && 'reset main from dev' || 'promote main from dev' }}
bump_versions_dev:
if: inputs.dry_run != true && needs.plan.outputs.should_bump == 'true'
needs: [plan]
runs-on: ubuntu-latest
environment: release-shared
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Checkout dev
uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: yarn
cache-dependency-path: yarn.lock
- name: Enable Corepack (Yarn)
run: |
corepack enable
corepack prepare yarn@1.22.22 --activate
- name: Install dependencies
env:
YARN_PRODUCTION: "false"
npm_config_production: "false"
run: yarn install --frozen-lockfile --ignore-engines
- name: Apply version bumps (single commit)
id: bumps
run: |
set -euo pipefail
bumped=()
if [ "${{ needs.plan.outputs.bump_app }}" != "none" ]; then
node scripts/release/bump-version.mjs --component app --bump "${{ needs.plan.outputs.bump_app }}" >/tmp/bump_app.txt
bumped+=("app")
fi
if [ "${{ needs.plan.outputs.bump_server }}" != "none" ]; then
node scripts/release/bump-version.mjs --component server --bump "${{ needs.plan.outputs.bump_server }}" >/tmp/bump_server.txt
bumped+=("server")
fi
if [ "${{ needs.plan.outputs.bump_website }}" != "none" ]; then
node scripts/release/bump-version.mjs --component website --bump "${{ needs.plan.outputs.bump_website }}" >/tmp/bump_website.txt
bumped+=("website")
fi
if [ "${{ needs.plan.outputs.bump_cli }}" != "none" ]; then
node scripts/release/bump-version.mjs --component cli --bump "${{ needs.plan.outputs.bump_cli }}" >/tmp/bump_cli.txt
bumped+=("cli")
fi
if [ "${{ needs.plan.outputs.bump_stack }}" != "none" ]; then
node scripts/release/bump-version.mjs --component stack --bump "${{ needs.plan.outputs.bump_stack }}" >/tmp/bump_stack.txt
bumped+=("stack")
fi
if [ "${#bumped[@]}" -eq 0 ]; then
echo "No version bumps requested." >> "$GITHUB_STEP_SUMMARY"
echo "did_bump=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "did_bump=true" >> "$GITHUB_OUTPUT"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps/ui/package.json apps/ui/app.config.js apps/server/package.json apps/website/package.json apps/cli/package.json apps/stack/package.json packages/relay-server/package.json || true
# Also pick up any tauri version edits if present.
if [ -d apps/ui/src-tauri ]; then
git add apps/ui/src-tauri || true
fi
git commit -m "chore(release): bump versions (${bumped[*]})"
git push origin HEAD:dev
deploy_ui:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && contains(format(',{0},', inputs.deploy_targets), ',ui,') && (needs.deploy_plan.outputs.deploy_ui_needed == 'true' || needs.plan.outputs.bump_app != 'none' || inputs.force_deploy == true || inputs.ui_expo_action != 'none') && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success')
needs: [plan, bump_versions_dev, promote_main, deploy_plan]
permissions:
contents: write
actions: read
uses: ./.github/workflows/promote-ui.yml
secrets: inherit
with:
environment: ${{ inputs.environment }}
force_deploy: ${{ inputs.force_deploy }}
source_ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
allow_cross_promote: false
bump: none
deploy_web: ${{ inputs.environment == 'production' }}
expo_action: ${{ inputs.ui_expo_action }}
expo_builder: ${{ inputs.ui_expo_builder }}
expo_profile: ${{ inputs.ui_expo_profile }}
expo_platform: ${{ inputs.ui_expo_platform }}
desktop_build: ${{ inputs.desktop_mode != 'none' }}
desktop_publish_release: ${{ inputs.desktop_mode == 'build_and_publish' }}
run_tests: false
expo_update_message: ${{ inputs.release_message }}
deploy_server:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && contains(format(',{0},', inputs.deploy_targets), ',server,') && (needs.deploy_plan.outputs.deploy_server_needed == 'true' || needs.plan.outputs.bump_server != 'none' || inputs.force_deploy == true) && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success')
needs: [plan, bump_versions_dev, promote_main, deploy_plan]
permissions:
contents: write
actions: read
uses: ./.github/workflows/promote-server.yml
secrets: inherit
with:
environment: ${{ inputs.environment }}
force_deploy: ${{ inputs.force_deploy }}
source_ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
allow_cross_promote: false
bump: none
publish_runtime_release: false
run_tests: false
publish_server_runtime:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && inputs.environment == 'preview' && needs.plan.outputs.publish_server == 'true' && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped')
needs: [plan, bump_versions_dev]
permissions:
contents: write
uses: ./.github/workflows/publish-server-runtime.yml
secrets: inherit
with:
channel: preview
source_ref: dev
allow_stable: false
release_message: ${{ inputs.release_message }}
publish_ui_web:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && inputs.environment == 'preview' && contains(format(',{0},', inputs.deploy_targets), ',ui,') && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped')
needs: [plan, bump_versions_dev]
permissions:
contents: write
uses: ./.github/workflows/publish-ui-web.yml
secrets: inherit
with:
channel: preview
source_ref: dev
allow_stable: false
release_message: ${{ inputs.release_message }}
publish_docker:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && inputs.environment == 'preview' && (inputs.force_deploy == true || needs.plan.outputs.changed_ui == 'true' || needs.plan.outputs.changed_server == 'true' || needs.plan.outputs.changed_cli == 'true' || needs.plan.outputs.changed_stack == 'true' || needs.plan.outputs.changed_shared == 'true') && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped')
needs: [plan, bump_versions_dev]
permissions:
contents: read
uses: ./.github/workflows/publish-docker.yml
secrets: inherit
with:
channel: preview
source_ref: dev
push_latest: true
build_relay: ${{ inputs.force_deploy == true || needs.plan.outputs.changed_ui == 'true' || needs.plan.outputs.changed_server == 'true' || needs.plan.outputs.changed_shared == 'true' }}
build_devcontainer: ${{ inputs.force_deploy == true || needs.plan.outputs.changed_cli == 'true' || needs.plan.outputs.changed_stack == 'true' || needs.plan.outputs.changed_shared == 'true' }}
deploy_website:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && contains(format(',{0},', inputs.deploy_targets), ',website,') && (needs.deploy_plan.outputs.deploy_website_needed == 'true' || needs.plan.outputs.bump_website != 'none' || inputs.force_deploy == true) && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success')
needs: [plan, bump_versions_dev, promote_main, deploy_plan]
permissions:
contents: read
actions: read
uses: ./.github/workflows/promote-website.yml
secrets: inherit
with:
environment: ${{ inputs.environment }}
force_deploy: ${{ inputs.force_deploy }}
source_ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
allow_cross_promote: false
bump: none
run_build: true
deploy_docs:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && contains(format(',{0},', inputs.deploy_targets), ',docs,') && (needs.deploy_plan.outputs.deploy_docs_needed == 'true' || inputs.force_deploy == true) && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success')
needs: [plan, bump_versions_dev, promote_main, deploy_plan]
permissions:
contents: read
actions: read
uses: ./.github/workflows/promote-docs.yml
secrets: inherit
with:
environment: ${{ inputs.environment }}
force_deploy: ${{ inputs.force_deploy }}
source_ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
allow_cross_promote: false
run_build: true
publish_npm:
if: always() && needs.plan.result == 'success' && inputs.dry_run != true && (needs.plan.outputs.publish_cli == 'true' || needs.plan.outputs.publish_stack == 'true' || needs.plan.outputs.publish_server == 'true') && (needs.bump_versions_dev.result == 'success' || needs.bump_versions_dev.result == 'skipped') && (inputs.environment != 'production' || needs.promote_main.result == 'success')
needs: [plan, bump_versions_dev, promote_main]
permissions:
contents: write
id-token: write
uses: ./.github/workflows/release-npm.yml
secrets: inherit
with:
publish_cli: ${{ needs.plan.outputs.publish_cli == 'true' }}
publish_stack: ${{ needs.plan.outputs.publish_stack == 'true' }}
publish_server: ${{ needs.plan.outputs.publish_server == 'true' }}
channel: ${{ inputs.environment }}
source_ref: ${{ inputs.environment == 'production' && 'main' || 'dev' }}
npm_tag: ${{ inputs.environment == 'production' && 'latest' || 'next' }}
run_tests: false
release_message: ${{ inputs.release_message }}
release_verify:
if: always() && needs.publish_npm.result == 'success' && inputs.environment == 'preview' && inputs.release_verify_profile != 'none'
needs: [publish_npm]
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Trigger release verify workflow (best-effort)
env:
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
TOKEN: ${{ github.token }}
RUN_SELF_HOST: ${{ inputs.release_verify_profile == 'full' }}
run: |
set -euo pipefail
payload="$(
cat <<JSON
{
"ref": "${REF}",
"inputs": {
"channel": "preview",
"run_installers_smoke": "true",
"run_binary_smoke": "true",
"run_self_host_systemd": "${RUN_SELF_HOST}",
"run_self_host_launchd": "${RUN_SELF_HOST}",
"run_self_host_schtasks": "${RUN_SELF_HOST}",
"run_self_host_daemon": "${RUN_SELF_HOST}"
}
}
JSON
)"
echo "Dispatching release-verify.yml for preview (profile: ${{ inputs.release_verify_profile }})"
if ! curl -fsSL \
-X POST \
-H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/actions/workflows/release-verify.yml/dispatches" \
-d "${payload}"; then
echo "::warning::Failed to dispatch release-verify.yml (continuing)"
fi
sync_dev:
if: inputs.dry_run != true && inputs.environment == 'production' && (vars.RELEASE_SYNC_DEV_FROM_MAIN == '' || vars.RELEASE_SYNC_DEV_FROM_MAIN == 'true')
needs: [plan, bump_versions_dev, promote_main]
uses: ./.github/workflows/promote-branch.yml
secrets: inherit
with:
source: main
target: dev
mode: fast_forward
dry_run: false
allow_reset: false
confirm: promote dev from main