Deploy Sluice #175
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: 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 }} |