Skip to content

Deploy Sluice

Deploy Sluice #175

Workflow file for this run

name: Deploy Sluice
# Triggers
#
# - workflow_run on `Build and Push Dashboard Image` success (main): the
# normal flow — image is published, then we deploy with that exact digest.
# - push to main with paths-ignore on dashboard image inputs: terraform-only
# commits still auto-deploy without waiting for a dashboard build.
# - workflow_dispatch: manual prod deploy / rollback.
#
# `vars.DASHBOARD_CONTAINER_IMAGE` is now an *override* (not a required pin):
# set it to roll back to a specific digest; leave unset for the auto-resolve
# path that pins the current `:latest` digest at deploy time.
on:
push:
branches:
- main
paths-ignore:
- "dashboard/**"
- "state-service/**"
- ".github/workflows/build-dashboard-image.yaml"
- ".github/workflows/build-state-service-image.yaml"
workflow_run:
workflows:
- Build and Push Dashboard Image
types: [completed]
branches: [main]
workflow_dispatch:
# Serialize prod deploys so a mixed commit (terraform + dashboard) that fires
# both `push` and `workflow_run` queues instead of double-applying. The second
# run becomes a no-op terraform plan against the same state.
concurrency:
group: deploy-sluice-prod
cancel-in-progress: false
# Read access to GHCR is needed for the dashboard digest-resolve step.
# `actions: write` is required by `actions/upload-artifact@v4` (tfplan).
permissions:
contents: read
packages: read
actions: write
env:
TF_BACKEND_RG: ${{ secrets.TF_BACKEND_RG }}
TF_BACKEND_SA: ${{ secrets.TF_BACKEND_SA }}
TF_BACKEND_CONTAINER: ${{ secrets.TF_BACKEND_CONTAINER }}
TF_VAR_secrets_expiration_date: "2027-03-31T00:00:00Z"
# `TF_VAR_dashboard_container_image` is set per-job by the "Resolve dashboard
# image digest" step below — see plan job.
# Dashboard auth (Item 11). Default 'key-paste'; flip DASHBOARD_AUTH_MODE
# GH var to 'entra' to enable OIDC sign-in (requires entra-client-secret +
# dashboard-session-secret KV secrets to be set out-of-band).
TF_VAR_dashboard_auth_mode: ${{ vars.DASHBOARD_AUTH_MODE || 'key-paste' }}
TF_VAR_entra_tenant_id: ${{ vars.ENTRA_TENANT_ID || '' }}
TF_VAR_entra_client_id: ${{ vars.ENTRA_CLIENT_ID || '' }}
TF_VAR_state_service_container_image: ${{ vars.STATE_SERVICE_CONTAINER_IMAGE || '' }}
TF_VAR_state_service_shared_token: ${{ secrets.STATE_SERVICE_SHARED_TOKEN || '' }}
TF_VAR_state_service_registry_username: ${{ vars.STATE_SERVICE_REGISTRY_USERNAME || github.repository_owner }}
TF_VAR_state_service_registry_password: ${{ secrets.STATE_SERVICE_REGISTRY_PASSWORD || '' }}
TF_VAR_grafana_url: ${{ secrets.GRAFANA_URL || '' }}
TF_VAR_enable_router_shim: ${{ vars.ENABLE_ROUTER_SHIM || 'false' }}
jobs:
plan:
# sluice runs prod-only on the Mystira tenant via the shared MYSTIRA_AZURE_CREDENTIALS
# service principal. dev/staging environments are not deployed.
name: Plan prod
runs-on: ubuntu-latest
# Filter workflow_run to successful main runs only — without this we'd
# also fire on failed/cancelled builds and try to deploy a missing image.
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'push' && github.ref == 'refs/heads/main')
|| (github.event_name == 'workflow_run'
&& github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.head_branch == 'main')
environment: prod
outputs:
# Resolved dashboard image (either the override or `repo@sha256:...`).
# deploy-prod feeds this into the reusable workflow so plan and apply
# use the exact same image reference.
dashboard_image: ${{ steps.resolve_dashboard_image.outputs.image }}
# SHA this plan was built against. deploy-prod passes it to the
# reusable workflow so apply checks out the same commit — without
# this, on workflow_run triggers the apply job's default checkout
# would land on default-branch HEAD and could drift from plan.
checkout_ref: ${{ github.event.workflow_run.head_sha || github.sha }}
defaults:
run:
working-directory: infra/env/prod
env:
TF_VAR_env: "prod"
TF_VAR_projname: "sluice"
TF_VAR_location: "southafricanorth"
# Per-environment GitHub secrets
TF_VAR_azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
TF_VAR_azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
TF_VAR_azure_openai_embedding_endpoint: ${{ secrets.AZURE_OPENAI_EMBEDDING_ENDPOINT }}
TF_VAR_azure_openai_embedding_api_key: ${{ secrets.AZURE_OPENAI_EMBEDDING_API_KEY }}
TF_VAR_gateway_key: ${{ secrets.AIGATEWAY_KEY }}
TF_VAR_codex_model: "gpt-4o"
TF_VAR_codex_api_version: "2025-04-01-preview"
TF_VAR_embedding_deployment: "text-embedding-3-large"
TF_VAR_embeddings_api_version: "2024-02-01"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
# When triggered by workflow_run, default checkout would land on the
# default branch HEAD instead of the SHA the build was for. Pin to
# the build's head_sha so terraform applies from the same commit.
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Quickcheck required secrets and config
shell: bash
run: |
set -euo pipefail
missing=0
required=(
TF_BACKEND_RG
TF_BACKEND_SA
TF_BACKEND_CONTAINER
TF_VAR_azure_openai_endpoint
TF_VAR_azure_openai_api_key
TF_VAR_gateway_key
)
for v in "${required[@]}"; do
if [ -z "${!v:-}" ]; then
echo "::error::Missing required value: ${v}"
missing=1
else
echo "${v}=SET"
fi
done
if [ -z "${MYSTIRA_AZURE_CREDENTIALS_SET:-}" ]; then
echo "::error::Missing required secret: MYSTIRA_AZURE_CREDENTIALS"
missing=1
else
echo "MYSTIRA_AZURE_CREDENTIALS=SET"
fi
echo "TF_VAR_env=${TF_VAR_env:-unset}"
echo "TF_VAR_projname=${TF_VAR_projname:-unset}"
echo "TF_VAR_codex_model=${TF_VAR_codex_model:-unset}"
if [ -n "${TF_VAR_azure_openai_endpoint:-}" ]; then
echo "Azure OpenAI endpoint=${TF_VAR_azure_openai_endpoint}"
endpoint_host=$(echo "${TF_VAR_azure_openai_endpoint}" | sed -E 's#^https?://([^/]+)/?.*$#\1#')
echo "Azure OpenAI endpoint host=${endpoint_host}"
fi
if [ "${missing}" -ne 0 ]; then
exit 1
fi
env:
# Empty fallback so the unbound check above can detect missing secret
# without leaking the credential blob.
MYSTIRA_AZURE_CREDENTIALS_SET: ${{ secrets.MYSTIRA_AZURE_CREDENTIALS != '' && '1' || '' }}
- name: Configure optional model providers
shell: bash
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY || '' }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY || '' }}
TOGETHERAI_API_KEY: ${{ secrets.TOGETHERAI_API_KEY || '' }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || '' }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY || '' }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY || '' }}
run: |
set -euo pipefail
providers='{}'
add_provider() {
local slug="$1"
local env_name="$2"
local api_key="$3"
if [ -n "${api_key}" ]; then
providers=$(jq -c \
--arg slug "${slug}" \
--arg env_name "${env_name}" \
--arg api_key "${api_key}" \
'. + {($slug): {api_key: $api_key, env_name: $env_name}}' \
<<<"${providers}")
echo "Optional provider enabled: ${slug}"
fi
}
add_provider "groq" "GROQ_API_KEY" "${GROQ_API_KEY}"
add_provider "gemini" "GEMINI_API_KEY" "${GEMINI_API_KEY}"
add_provider "together" "TOGETHERAI_API_KEY" "${TOGETHERAI_API_KEY}"
add_provider "openrouter" "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY}"
add_provider "fireworks" "FIREWORKS_API_KEY" "${FIREWORKS_API_KEY}"
add_provider "moonshot" "MOONSHOT_API_KEY" "${MOONSHOT_API_KEY}"
if [ "${providers}" != "{}" ]; then
echo "TF_VAR_extra_providers=${providers}" >> "$GITHUB_ENV"
else
echo "No optional model provider secrets configured; planning Azure-only gateway."
fi
- name: Azure Login
uses: azure/login@v2
with:
creds: ${{ secrets.MYSTIRA_AZURE_CREDENTIALS }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.14.6
- name: Log in to GHCR
# Required so `docker buildx imagetools inspect` can resolve the
# private dashboard package digest below.
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
# `imagetools inspect` lives under buildx — set it up explicitly
# rather than relying on the runner image bundling it.
uses: docker/setup-buildx-action@v3
- name: Resolve dashboard image digest
# Pin the dashboard image to a content-addressable digest. Precedence:
# 1. `vars.DASHBOARD_CONTAINER_IMAGE` — explicit override (rollback,
# pin to a specific digest, etc).
# 2. SHA tag of the relevant commit. build-dashboard-image publishes
# `<short-sha>` for both main and dev pushes, so this works for
# workflow_run (the build's head_sha) and workflow_dispatch
# (the deploying branch's HEAD).
# 3. workflow_run only: hard-fail if SHA tag missing — never deploy
# a different image than the one that triggered the run.
# 4. push / workflow_dispatch: fall back to resolving `:latest` to a
# digest (handles terraform-only pushes that don't rebuild the
# dashboard, and dispatches from branches that haven't built
# recently). Hard-fail if neither resolves; never ship a literal
# `:latest` tag — that risks a stale or GC'd manifest, which is
# what burned us before.
id: resolve_dashboard_image
shell: bash
env:
OVERRIDE: ${{ vars.DASHBOARD_CONTAINER_IMAGE }}
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/sluice-dashboard
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
inspect_digest() {
# Returns the manifest digest for a registry ref, or empty string
# if the registry has no manifest there. Never errors out — caller
# decides whether empty is fatal.
docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' 2>/dev/null \
| jq -r '.digest // empty' \
|| true
}
if [ -n "${OVERRIDE:-}" ]; then
IMAGE="${OVERRIDE}"
echo "Using DASHBOARD_CONTAINER_IMAGE override: ${IMAGE}"
else
# workflow_run: build's head_sha. push/dispatch: this commit.
commit_sha="${WORKFLOW_RUN_SHA:-${GITHUB_SHA}}"
short_sha="${commit_sha:0:7}"
tag_ref="${IMAGE_REPO}:${short_sha}"
digest=$(inspect_digest "${tag_ref}")
if [ -n "${digest}" ]; then
IMAGE="${IMAGE_REPO}@${digest}"
echo "Resolved ${tag_ref} -> ${digest}"
elif [ "${EVENT_NAME}" = "workflow_run" ]; then
echo "::error::Could not resolve digest for ${tag_ref} — refusing to deploy a different image than the one this workflow_run was for."
exit 1
else
digest=$(inspect_digest "${IMAGE_REPO}:latest")
if [ -z "${digest}" ]; then
echo "::error::Could not resolve digest for ${tag_ref} or ${IMAGE_REPO}:latest."
echo "::error::Either rebuild the dashboard image on the deploy branch, or set vars.DASHBOARD_CONTAINER_IMAGE to a known-good digest."
exit 1
fi
IMAGE="${IMAGE_REPO}@${digest}"
echo "Resolved ${IMAGE_REPO}:latest -> ${digest} (no SHA tag for ${short_sha})"
fi
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "TF_VAR_dashboard_container_image=${IMAGE}" >> "$GITHUB_ENV"
{
echo "## Dashboard image"
echo ""
echo "Pinned to: \`${IMAGE}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Terraform Init
run: |
terraform init \
-backend-config="resource_group_name=${TF_BACKEND_RG}" \
-backend-config="storage_account_name=${TF_BACKEND_SA}" \
-backend-config="container_name=${TF_BACKEND_CONTAINER}" \
-backend-config="key=prod.terraform.tfstate"
- name: Terraform Plan
run: |
terraform plan -out=tfplan
- name: Upload Plan
uses: actions/upload-artifact@v4
with:
name: tfplan-prod
path: infra/env/prod/tfplan
retention-days: 1
deploy-prod:
name: Deploy prod
needs: plan
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'push' && github.ref == 'refs/heads/main')
|| (github.event_name == 'workflow_run'
&& github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.head_branch == 'main')
uses: ./.github/workflows/deploy-environment.yaml
with:
env_name: prod
tf_state_key: prod.terraform.tfstate
codex_model: gpt-4o
codex_api_version: 2025-04-01-preview
terraform_working_directory: infra/env/prod
smoke_retry_sleep: "15"
smoke_models_wait_sleep: "30"
smoke_models_wait_attempts: "3"
include_aoai_host_check: true
environment: prod
enable_router_shim: ${{ vars.ENABLE_ROUTER_SHIM || 'false' }}
# Pin apply to the same SHA plan ran against — see plan.outputs.checkout_ref.
checkout_ref: ${{ needs.plan.outputs.checkout_ref }}
secrets:
MYSTIRA_AZURE_CREDENTIALS: ${{ secrets.MYSTIRA_AZURE_CREDENTIALS }}
TF_BACKEND_RG: ${{ secrets.TF_BACKEND_RG }}
TF_BACKEND_SA: ${{ secrets.TF_BACKEND_SA }}
TF_BACKEND_CONTAINER: ${{ secrets.TF_BACKEND_CONTAINER }}
EXPECTED_AOAI_ENDPOINT_HOST: ${{ secrets.EXPECTED_AOAI_ENDPOINT_HOST }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_EMBEDDING_ENDPOINT: ${{ secrets.AZURE_OPENAI_EMBEDDING_ENDPOINT }}
AZURE_OPENAI_EMBEDDING_API_KEY: ${{ secrets.AZURE_OPENAI_EMBEDDING_API_KEY }}
AIGATEWAY_KEY: ${{ secrets.AIGATEWAY_KEY }}
STATE_SERVICE_CONTAINER_IMAGE: ${{ vars.STATE_SERVICE_CONTAINER_IMAGE }}
STATE_SERVICE_SHARED_TOKEN: ${{ secrets.STATE_SERVICE_SHARED_TOKEN }}
STATE_SERVICE_REGISTRY_PASSWORD: ${{ secrets.STATE_SERVICE_REGISTRY_PASSWORD }}
# Pass the digest resolved by the plan job's resolve_dashboard_image
# step so plan and apply use the exact same image reference.
DASHBOARD_CONTAINER_IMAGE: ${{ needs.plan.outputs.dashboard_image }}
DASHBOARD_AUTH_MODE: ${{ vars.DASHBOARD_AUTH_MODE }}
ENTRA_TENANT_ID: ${{ vars.ENTRA_TENANT_ID }}
ENTRA_CLIENT_ID: ${{ vars.ENTRA_CLIENT_ID }}
GRAFANA_URL: ${{ secrets.GRAFANA_URL }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
TOGETHERAI_API_KEY: ${{ secrets.TOGETHERAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}