Skip to content

Deploy lik-ui @ 122f2eafd66d035304886bbe5c2f954ce49d9aa8 by @yoomlam #64

Deploy lik-ui @ 122f2eafd66d035304886bbe5c2f954ce49d9aa8 by @yoomlam

Deploy lik-ui @ 122f2eafd66d035304886bbe5c2f954ce49d9aa8 by @yoomlam #64

Workflow file for this run

# Build each service image, push it to its Lightsail registry, then deploy it.
# The build/push runs in CI so the large image upload does not run from a maintainer's
# (slow) uplink. The `apply` job then runs `terraform apply` — but ONLY when the plan is a
# clean image swap (`Plan: N to add, 0 to change, N to destroy.`). Any other plan (config
# drift, anything with "to change") is left for the maintainer to review and apply locally
# with `./tf.sh apply` (the step summary echoes the exact -var image refs to use).
#
# Prerequisites (set once in repo Settings):
# - Variable AWS_DEPLOY_ROLE_ARN = the github_image_push_role_arn Terraform output.
# - Variable AWS_APPLY_ROLE_ARN = the github_apply_role_arn Terraform output.
# - Variable AWS_REGION = us-east-1.
# Auth is GitHub OIDC (no stored AWS keys). Both IAM roles' trust is scoped to this repo's
# `prod` environment (see infra/iam_github_oidc.tf).
name: Build and deploy images
run-name: Deploy ${{ inputs.service }} @ ${{ github.sha }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
service:
description: Which service(s) to build and push
type: choice
options: [lik-ui, lik-mcp, both]
default: lik-ui
permissions:
id-token: write # required for OIDC
contents: read
# Shared serialization group with scheduled-runs.yml (SAME group name — that is the point). A deploy
# replaces a prod container via a rolling cutover Lightsail can't connection-drain, so deploying while
# a scheduled agent run is in flight severs its long-lived lik-mcp MCP session and fails the sync
# mid-run. Sharing the group makes a deploy wait for any running scan to finish first (and a scan wait
# for a running deploy). Conservative: this also serializes lik-ui-only deploys, which don't affect a
# sync — kept simple over a per-service group. cancel-in-progress: false so a queued deploy waits
# rather than being dropped.
concurrency:
group: lik-prod-mutations
cancel-in-progress: false
jobs:
push:
runs-on: ubuntu-latest
# The prod environment scopes AWS_DEPLOY_ROLE_ARN / AWS_REGION (and enables optional
# deployment protection rules). Env-scoped vars only resolve when the job declares this.
environment: prod
strategy:
matrix:
include:
- name: lik-mcp
service: lik-mcp-prod
- name: lik-ui
service: lik-ui-prod
steps:
- name: Skip services not selected
id: gate
run: |
if [ "${{ inputs.service }}" != "both" ] && [ "${{ inputs.service }}" != "${{ matrix.name }}" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v6
if: steps.gate.outputs.skip != 'true'
- name: Configure AWS credentials (OIDC)
if: steps.gate.outputs.skip != 'true'
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Install lightsailctl (required by push-container-image)
if: steps.gate.outputs.skip != 'true'
run: |
sudo curl -sL "https://s3.us-west-2.amazonaws.com/lightsailctl/latest/linux-amd64/lightsailctl" \
-o /usr/local/bin/lightsailctl
sudo chmod +x /usr/local/bin/lightsailctl
- name: Build image
if: steps.gate.outputs.skip != 'true'
run: docker build -t ${{ matrix.name }}:${{ github.sha }} ./${{ matrix.name }}
# Boot the freshly built image and confirm it serves its health endpoint BEFORE pushing.
# This catches an import/boot break — like the mcp-2.0 crash that removed a module imported
# at startup — in CI, so a broken image never consumes a Lightsail deploy/rollback cycle.
# Neither app needs a reachable DB to answer its health probe (the connection pool opens
# lazily in the background), so a standalone container is sufficient — no DB sidecar.
- name: Smoke boot
if: steps.gate.outputs.skip != 'true'
run: |
set -euo pipefail
name="${{ matrix.name }}"
img="${name}:${{ github.sha }}"
cname="smoke-${name}"
if [ "$name" = "lik-mcp" ]; then
# Boot in the prod auth mode the deploy uses; dummy OAuth values satisfy the
# fail-closed startup guard. /mcp answers 401 under auth — the Lightsail health
# check treats 200-499 as alive (see infra/lik_mcp.tf), so mirror that range.
docker run -d --name "$cname" -p 8000:8000 \
-e LIK_OAUTH_CLIENT_ID=smoke \
-e LIK_RESOURCE_SERVER_URL=https://mcp.example.test/mcp \
-e LIK_HTTP_ALLOWED_HOSTS="localhost,localhost:*,127.0.0.1,127.0.0.1:*,0.0.0.0,0.0.0.0:*" \
"$img"
url="http://127.0.0.1:8000/mcp"; lo=200; hi=499
else
docker run -d --name "$cname" -p 8001:8001 \
-e LIK_UI_ENV=local \
-e LIK_UI_HTTP_ALLOWED_HOSTS="localhost,localhost:*,127.0.0.1,127.0.0.1:*" \
"$img"
url="http://127.0.0.1:8001/healthz"; lo=200; hi=200
fi
for i in $(seq 1 30); do
# `|| code=000` (assignment, not echo) keeps $code a clean 3-digit value even when
# curl hits connection-refused during the boot window — avoids a set -e arithmetic abort.
code=$(curl -s -o /dev/null -w '%{http_code}' "$url" 2>/dev/null) || code=000
if [ "$code" -ge "$lo" ] && [ "$code" -le "$hi" ]; then
echo "healthy: $url -> $code (after ${i} probe(s))"
exit 0
fi
sleep 2
done
echo "::error::${name} image did not become healthy ($url, last HTTP code=$code) — see container logs below"
docker logs "$cname" 2>&1 | tail -50
exit 1
- name: Smoke boot teardown
if: always() && steps.gate.outputs.skip != 'true'
run: docker rm -f "smoke-${{ matrix.name }}" >/dev/null 2>&1 || true
- name: Push to Lightsail registry
if: steps.gate.outputs.skip != 'true'
run: |
out=$(aws lightsail push-container-image \
--region "${{ vars.AWS_REGION }}" \
--service-name "${{ matrix.service }}" \
--label app \
--image "${{ matrix.name }}:${{ github.sha }}")
echo "$out"
# push-container-image prints: Refer to this image as ":svc.app.N" in deployments.
ref=$(echo "$out" | grep -oE '":[^"]+"' | tr -d '"' | tail -1)
# Hand the ref to the apply job via an artifact (matrix job outputs collide).
mkdir -p image-ref
printf '%s' "$ref" > "image-ref/${{ matrix.name }}.txt"
{
echo "### ${{ matrix.name }} pushed"
echo ""
echo "Image ref for \`terraform apply\`:"
echo ""
echo '```'
echo "$ref"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload image ref
if: steps.gate.outputs.skip != 'true'
uses: actions/upload-artifact@v7
with:
name: image-ref-${{ matrix.name }}
path: image-ref/${{ matrix.name }}.txt
if-no-files-found: error
# Deploy the pushed image(s) — but auto-apply only for a routine image swap. The gate is
# the plan summary: a per-service deployment_version replacement reads as "N to add,
# 0 to change, N to destroy" (see infra/lik_mcp.tf / lik_ui.tf). Anything else is left
# for the maintainer to review and apply locally.
apply:
needs: push
runs-on: ubuntu-latest
environment: prod
env:
AWS_REGION: ${{ vars.AWS_REGION }}
# Custom-domain URLs come from the prod GitHub environment (Settings → Environments →
# prod → Variables). They MUST be set, or the empty Terraform default detaches the live
# domain — see infra/lik_ui.tf. infra/tf.sh supplies the same values for local applies.
MCP_CUSTOM_DOMAIN_URL: ${{ vars.MCP_CUSTOM_DOMAIN_URL }}
UI_CUSTOM_DOMAIN_URL: ${{ vars.UI_CUSTOM_DOMAIN_URL }}
steps:
- uses: actions/checkout@v6
- name: Show deployed commit
run: |
{
echo "### Commit"
echo ""
echo "\`${{ github.sha }}\`"
echo ""
git log -1 --pretty='format:%s%n%n%b' "${{ github.sha }}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_APPLY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.10.5"
terraform_wrapper: false
- name: Download image refs
uses: actions/download-artifact@v8
with:
pattern: image-ref-*
path: image-refs
merge-multiple: true
- name: Resolve image refs (built this run, else currently deployed)
run: |
set -euo pipefail
# For each service: use the ref just pushed (artifact) if present, otherwise read
# the currently-deployed ref from Lightsail. Both must be non-empty — passing an
# empty image var would DESTROY that service's deployment (count guard).
resolve() {
local name="$1" svc="$2" file ref
file="image-refs/${name}.txt"
if [ -f "$file" ]; then
ref=$(cat "$file")
else
ref=$(aws lightsail get-container-services --region "$AWS_REGION" \
--service-name "$svc" \
--query "containerServices[0].currentDeployment.containers.\"${name}\".image" \
--output text)
fi
if [ -z "$ref" ] || [ "$ref" = "None" ]; then
echo "::error::could not resolve image ref for ${name} (service ${svc})"
exit 1
fi
printf '%s' "$ref"
}
MCP_REF=$(resolve lik-mcp lik-mcp-prod)
UI_REF=$(resolve lik-ui lik-ui-prod)
echo "MCP_REF=$MCP_REF" >> "$GITHUB_ENV"
echo "UI_REF=$UI_REF" >> "$GITHUB_ENV"
echo "Resolved refs: lik-mcp=$MCP_REF lik-ui=$UI_REF"
- name: Terraform init
run: terraform -chdir=infra init -input=false
- name: Require custom-domain URLs
run: |
# Unset GitHub vars resolve to "", which Terraform reads as "detach the live domain".
# Fail loud here rather than let a routine image swap silently drop the custom domain.
: "${MCP_CUSTOM_DOMAIN_URL:?set vars.MCP_CUSTOM_DOMAIN_URL on the prod GitHub environment}"
: "${UI_CUSTOM_DOMAIN_URL:?set vars.UI_CUSTOM_DOMAIN_URL on the prod GitHub environment}"
- name: Terraform plan
run: |
set -o pipefail # else `| tee` masks a plan failure and it leaks into the gate
terraform -chdir=infra plan -input=false -no-color -out=tfplan \
-var "lik_mcp_image=$MCP_REF" \
-var "lik_ui_image=$UI_REF" \
-var "mcp_custom_domain_url=$MCP_CUSTOM_DOMAIN_URL" \
-var "ui_custom_domain_url=$UI_CUSTOM_DOMAIN_URL" | tee plan.out
- name: Apply only a clean image swap
run: |
set -euo pipefail
summary=$(grep -E '^(Plan:|No changes\.)' plan.out | tail -1 || true)
echo "Plan summary: ${summary:-<none>}"
case "$summary" in
"Plan: 1 to add, 0 to change, 1 to destroy."|"Plan: 2 to add, 0 to change, 2 to destroy.")
echo "Clean image swap — applying."
terraform -chdir=infra apply -input=false tfplan
{
echo "### ✅ Deployed"
echo ""
echo "- lik-mcp: \`$MCP_REF\`"
echo "- lik-ui: \`$UI_REF\`"
} >> "$GITHUB_STEP_SUMMARY"
;;
"No changes."*)
{
echo "### Nothing to deploy"
echo ""
echo "Terraform reports no changes — the running deployment already matches."
} >> "$GITHUB_STEP_SUMMARY"
;;
*)
{
echo "### ⚠️ Not a clean image swap — apply skipped"
echo ""
echo "The plan below was not a routine image swap, so it was left for review:"
echo ""
echo '```'
echo "${summary:-see the Terraform plan step log}"
echo '```'
echo ""
echo "Review and apply locally with the image refs from this run:"
echo ""
echo '```'
echo "cd infra && ./tf.sh apply -var \"lik_mcp_image=$MCP_REF\" -var \"lik_ui_image=$UI_REF\""
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::Plan is not a clean image swap — apply skipped, review and apply locally."
exit 1
;;
esac