Skip to content

chore(ci): bump actions/github-script from 8 to 9 (#2172) #228

chore(ci): bump actions/github-script from 8 to 9 (#2172)

chore(ci): bump actions/github-script from 8 to 9 (#2172) #228

name: Deploy Develop
# Required secrets:
# DEPLOY_SSH_KEY - Private SSH key (Ed25519 or RSA 4096+) for the deploy user on the Droplet
# DROPLET_IP - IP address of the DigitalOcean Droplet (e.g. 143.198.xxx.xxx)
#
# The deploy user is created by deploy/demo/provision.sh with docker group membership
# and ownership of /opt/meridian-develop/. Run provision.sh once as root to set up the server.
on:
push:
branches: [develop]
workflow_dispatch:
concurrency:
group: deploy-develop
cancel-in-progress: true
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # Required for cosign keyless signing via OIDC
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26.2'
cache: true
- name: Set up buf
uses: bufbuild/buf-action@v1
with:
setup_only: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate protobuf files
run: buf generate
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=develop
type=sha,prefix=develop-,format=short
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ./cmd/meridian/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp || github.event.repository.updated_at }}
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Sign container image
run: |
cosign sign --yes \
ghcr.io/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Set up buf
uses: bufbuild/buf-action@v1
with:
setup_only: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Generate protobuf TypeScript clients
working-directory: frontend
run: |
export PATH="$PWD/node_modules/.bin:$PATH"
buf generate --template buf.gen.yaml ../api/proto
- name: Build frontend
working-directory: frontend
env:
VITE_API_BASE_URL: https://develop.meridianhub.cloud
VITE_BASE_DOMAIN: develop.meridianhub.cloud
VITE_BUILD_VERSION: ${{ github.ref_name }}
VITE_BUILD_COMMIT: ${{ github.sha }}
run: npx vite build
- name: Upload frontend artifact
uses: actions/upload-artifact@v7
with:
name: frontend-dist-develop
path: frontend/dist/
retention-days: 1
deploy:
name: Deploy to Develop Environment
needs: [build-and-push, build-frontend]
runs-on: ubuntu-latest
environment:
name: develop
url: https://develop.meridianhub.cloud
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Verify container image signature
run: |
cosign verify \
--certificate-identity="https://github.com/${{ github.repository }}/.github/workflows/deploy-develop.yml@refs/heads/develop" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/${{ env.IMAGE_NAME }}@${{ needs.build-and-push.outputs.image-digest }}
- name: Download frontend artifact
uses: actions/download-artifact@v8
with:
name: frontend-dist-develop
path: frontend-dist/
- name: Deploy frontend via SCP
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "frontend-dist/*"
target: /opt/meridian-develop/frontend/
strip_components: 1
overwrite: true
- name: Deploy config files via SCP
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "deploy/develop/docker-compose.develop.yml,deploy/develop/init-databases-develop.sql"
target: /opt/meridian-develop/
strip_components: 2
overwrite: true
- name: Deploy Caddyfile to demo stack
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "deploy/demo/Caddyfile"
target: /opt/meridian/
strip_components: 2
overwrite: true
- name: Deploy via SSH
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
IMAGE_DIGEST: ${{ needs.build-and-push.outputs.image-digest }}
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: IMAGE_DIGEST
script: |
cd /opt/meridian-develop
# Pull the exact verified digest to prevent TOCTOU between verify and deploy
docker pull ghcr.io/meridianhub/meridian@${IMAGE_DIGEST}
docker tag ghcr.io/meridianhub/meridian@${IMAGE_DIGEST} ghcr.io/meridianhub/meridian:develop
docker compose -f docker-compose.develop.yml up -d --remove-orphans
# Verify all services came up (see deploy-demo.yml for rationale)
sleep 5
failed=$(docker compose -f docker-compose.develop.yml ps --status exited --format '{{.Service}}')
if [ -n "$failed" ]; then
echo "Services failed to start: $failed - retrying"
docker compose -f docker-compose.develop.yml up -d $failed
sleep 3
still_failed=$(docker compose -f docker-compose.develop.yml ps --status exited --format '{{.Service}}')
if [ -n "$still_failed" ]; then
echo "Services still down after retry: $still_failed"
exit 1
fi
fi
echo "All services running"
# Reload Caddy from the demo stack (Caddy runs there, serves both environments)
docker compose -f /opt/meridian/docker-compose.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile
docker image prune -f
- name: Ensure databases exist
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Docker init scripts only run on first volume creation. When new
# databases are added to init-databases-develop.sql, existing volumes
# won't have them. Create any missing databases so the app can start.
cd /opt/meridian-develop
created=0
for db in $(grep -oP 'CREATE DATABASE \K\w+' init-databases-develop.sql); do
exists=$(docker exec postgres-develop psql -U meridian -tAc \
"SELECT 1 FROM pg_database WHERE datname = '${db}'")
if [ "$exists" != "1" ]; then
docker exec postgres-develop createdb -U meridian "${db}"
echo "Created missing database: ${db}"
created=1
fi
done
if [ "$created" = "1" ]; then
echo "New databases created - restarting app to pick up connections"
docker compose -f docker-compose.develop.yml restart meridian-develop
fi
# Wait for the container to be running (not restarting) before migrations.
# The app image is distroless - no shell utilities - so use docker inspect.
for i in $(seq 1 30); do
status=$(docker inspect --format='{{.State.Status}}' meridian-develop 2>/dev/null || echo "missing")
if [ "$status" = "running" ]; then
echo "Container is running"
exit 0
fi
echo "Waiting for container (status: $status)... ($i/30)"
sleep 2
done
echo "Container failed to start after 60s"
exit 1
- name: Run migrations
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker exec meridian-develop /meridian --migrate
- name: Restart meridian-develop post-migration
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/meridian-develop
# Reset tenant provisioning state and drop tenant schemas so every
# deploy re-provisions from scratch. Three states must be reconciled:
#
# 1. tenant.status - the provisioning worker polls this via
# ListByStatus(StatusProvisioningPending) (provisioning_worker.go).
# Without resetting, the worker never claims the tenant.
#
# 2. tenant_provisioning.service_schemas - the provisioner checks
# this JSONB per-service and short-circuits via "service already
# provisioned, skipping" (postgres_provisioner.go). Clearing to
# '[]' forces a full re-run of the loop.
#
# 3. Physical org_<tenant> schemas in each service database - if
# left in place they may contain partial/stale tables from a
# previous broken run, causing migrations to fail with "relation
# does not exist" when they reference objects that got renamed
# or dropped earlier. DROP SCHEMA CASCADE ensures migrations
# run against completely empty schemas, matching the E2E path
# which is known to pass.
#
# Stop meridian-develop BEFORE resetting state. The worker polls
# tenant.status every 10s, so if the app is still running when we
# flip tenants to provisioning_pending it will race with this
# script: start provisioning against half-cleaned databases and
# then get interrupted when we issue the restart. Stopping first
# eliminates the race, and starting (not restarting) afterwards
# lets the worker observe the fully-reset state on boot.
#
# Must run AFTER migrations so the provisioner uses up-to-date DDL.
docker compose -f docker-compose.develop.yml stop meridian-develop
SERVICE_DBS="meridian_party meridian_current_account meridian_position_keeping meridian_financial_accounting meridian_payment_order meridian_market_information meridian_reference_data meridian_internal_account meridian_reconciliation meridian_identity meridian_control_plane"
for DB in $SERVICE_DBS; do
docker exec postgres-develop psql -U meridian -d $DB -tA -c "
SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', nspname)
FROM pg_namespace WHERE nspname LIKE 'org\_%' ESCAPE '\\'
" | while read stmt; do
[ -z "$stmt" ] && continue
docker exec postgres-develop psql -U meridian -d $DB -c "$stmt"
done
done
docker exec postgres-develop psql -U meridian -d meridian_platform -c "
UPDATE tenant SET status = 'provisioning_pending', updated_at = NOW() WHERE status != 'deprovisioned';
UPDATE tenant_provisioning SET state = 'pending', service_schemas = '[]'::jsonb, error_message = '' WHERE state != 'deprovisioned';
"
# Start the app back up with the reset state in place. The
# provisioning worker will claim the pending tenants on its first
# poll and re-run all migrations against the newly empty schemas.
docker compose -f docker-compose.develop.yml start meridian-develop
- name: Seed develop data
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# seed-dev has its own gateway health polling (60s timeout, 2s interval)
# so no extra wait needed between restart and seed.
docker exec meridian-develop /seed-dev \
--gateway-url=http://localhost:8090 \
--grpc-addr=localhost:50051 \
--tenant-id=volterra_energy \
--tenant-slug=volterra-energy \
--display-name='Volterra Energy' \
--subdomain=volterra-energy.develop.meridianhub.cloud \
--manifest=/app/examples/manifests/energy.json \
--with-fixtures
- name: Verify deployment health
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.DROPLET_IP }}
username: deploy
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
max_attempts=30
attempt=0
until [ $attempt -ge $max_attempts ]; do
attempt=$((attempt+1))
echo "Health check attempt $attempt/$max_attempts"
if curl -sf http://localhost:80/healthz -H 'Host: develop.meridianhub.cloud' > /dev/null 2>&1; then
echo "Service is healthy"
exit 0
fi
sleep 5
done
echo "Health check failed after $max_attempts attempts"
cd /opt/meridian-develop && docker compose -f docker-compose.develop.yml logs --tail=50
exit 1
notify-failure:
name: Notify Deployment Failure
needs: [build-and-push, build-frontend, deploy]
if: failure()
runs-on: ubuntu-latest
permissions:
statuses: write
steps:
- name: Report deployment failure
uses: actions/github-script@v9
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.sha,
state: 'failure',
context: 'deploy/develop',
description: 'Develop environment deployment failed',
target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});