Skip to content

Production Deploy

Production Deploy #324

name: Production Deploy
on:
workflow_dispatch:
env:
RUST_VERSION: "1.99.0"
permissions: {}
jobs:
authorize:
name: Authorize deployment operator
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require repository owner
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.actor }}" != "iamwavecut" || "${{ github.triggering_actor }}" != "iamwavecut" ]]; then
echo "Production deploy can only be started or re-run by iamwavecut" >&2
exit 1
fi
build-and-deploy:
name: Build image and deploy
needs: authorize
runs-on: ubuntu-22.04
permissions:
actions: read
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Select GHCR image tag
shell: bash
run: |
set -euo pipefail
image_name="ghcr.io/${GITHUB_REPOSITORY}"
image_name="$(printf '%s' "$image_name" | tr '[:upper:]' '[:lower:]')"
echo "OPENPLOTVA_IMAGE=${image_name}:${GITHUB_SHA}" >> "$GITHUB_ENV"
echo "OPENPLOTVA_SANDBOX_IMAGE=${image_name}-sandbox:${GITHUB_SHA}" >> "$GITHUB_ENV"
- name: Install Rust
run: |
rustup toolchain install "$RUST_VERSION" --profile minimal
rustup default "$RUST_VERSION"
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
shared-key: rust-${{ runner.os }}-ubuntu-22.04-${{ env.RUST_VERSION }}-deploy
- name: Build release binaries
run: cargo build --locked --release -p openplotva-app -p openplotva-sandbox
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build and push GHCR image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
target: runtime-prebuilt
build-args: RUST_VERSION=${{ env.RUST_VERSION }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
tags: ${{ env.OPENPLOTVA_IMAGE }}
push: true
cache-from: type=gha,scope=openplotva-runtime-prebuilt
cache-to: type=gha,mode=min,scope=openplotva-runtime-prebuilt
- name: Build and push sandbox image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
file: sandbox/Dockerfile
target: runtime-prebuilt
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
tags: ${{ env.OPENPLOTVA_SANDBOX_IMAGE }}
push: true
# mode=max keeps the nsjail and package layers between deploys.
cache-from: type=gha,scope=openplotva-sandbox
cache-to: type=gha,mode=max,scope=openplotva-sandbox
- name: Wait for green CI on this commit
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
started=$SECONDS
while :; do
run="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?event=push&head_sha=${GITHUB_SHA}&per_page=1" \
--jq '.workflow_runs[0] // empty | "\(.status) \(.conclusion // "none") \(.html_url)"')"
elapsed=$((SECONDS - started))
if [[ -z "$run" ]]; then
echo "No push CI run for ${GITHUB_SHA} after ${elapsed}s"
(( elapsed < 180 )) || { echo "Only commits that CI ran on after a push can be deployed" >&2; exit 1; }
else
read -r status conclusion url <<<"$run"
echo "CI ${status}/${conclusion} after ${elapsed}s: ${url}"
if [[ "$status" == completed ]]; then
[[ "$conclusion" == success ]] && exit 0
echo "CI for ${GITHUB_SHA} concluded ${conclusion}; not deploying" >&2
exit 1
fi
(( elapsed < 1800 )) || { echo "CI for ${GITHUB_SHA} did not finish within 30 minutes" >&2; exit 1; }
fi
sleep 20
done
- name: Configure SSH
shell: bash
env:
GETA_SSH_PRIVATE_KEY: ${{ secrets.GETA_SSH_PRIVATE_KEY }}
GETA_SSH_KNOWN_HOSTS: ${{ secrets.GETA_SSH_KNOWN_HOSTS }}
GETA_SSH_PORT: ${{ vars.GETA_SSH_PORT || '22022' }}
run: |
set -euo pipefail
test -n "$GETA_SSH_PRIVATE_KEY"
test -n "$GETA_SSH_KNOWN_HOSTS"
install -m 700 -d "$HOME/.ssh"
printf '%s\n' "$GETA_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
printf 'Host geta.moe 207.244.239.212 2605:a140:2260:6080::1\n Port %s\n' "$GETA_SSH_PORT" > "$HOME/.ssh/config"
chmod 600 "$HOME/.ssh/config"
eval "$(ssh-agent -s)"
printf '%s\n' "$GETA_SSH_PRIVATE_KEY" | ssh-add -
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Upload deploy assets
shell: bash
env:
GETA_SSH_TARGET: ${{ secrets.GETA_SSH_TARGET }}
GETA_DEPLOY_ROOT: ${{ secrets.GETA_DEPLOY_ROOT }}
run: |
set -euo pipefail
# shellcheck disable=SC2029
ssh "$GETA_SSH_TARGET" "install -d -m 755 '$GETA_DEPLOY_ROOT'"
scp deploy/production/compose.production.yml "$GETA_SSH_TARGET:$GETA_DEPLOY_ROOT/compose.production.yml"
scp deploy/maintenance/compose.maintenance.yml "$GETA_SSH_TARGET:$GETA_DEPLOY_ROOT/compose.maintenance.yml"
scp deploy/production/configure-gradius-generation.py "$GETA_SSH_TARGET:$GETA_DEPLOY_ROOT/configure-gradius-generation.py"
scp deploy/production/deploy-production.sh "$GETA_SSH_TARGET:$GETA_DEPLOY_ROOT/deploy-production.sh"
# shellcheck disable=SC2029
ssh "$GETA_SSH_TARGET" "chmod 644 '$GETA_DEPLOY_ROOT/compose.maintenance.yml'; chmod 755 '$GETA_DEPLOY_ROOT/deploy-production.sh'"
- name: Install generation advertising key
shell: bash
env:
GETA_SSH_TARGET: ${{ secrets.GETA_SSH_TARGET }}
GETA_DEPLOY_ROOT: ${{ secrets.GETA_DEPLOY_ROOT }}
GRADIUS_GENERATION_API_KEY: ${{ secrets.GRADIUS_GENERATION_API_KEY }}
OPENPLOTVA_PRODUCTION_ENV_B64: ${{ secrets.OPENPLOTVA_PRODUCTION_ENV_B64 }}
run: |
set -euo pipefail
if [[ -n "${GRADIUS_GENERATION_API_KEY:-}" ]]; then
# shellcheck disable=SC2029
python3 -c 'import json, os; print(json.dumps({"key": os.environ["GRADIUS_GENERATION_API_KEY"], "production_env_b64": os.environ.get("OPENPLOTVA_PRODUCTION_ENV_B64", "")}))' |
ssh "$GETA_SSH_TARGET" "python3 '$GETA_DEPLOY_ROOT/configure-gradius-generation.py' '$GETA_DEPLOY_ROOT/.env.production'"
fi
- name: Apply production deployment
shell: bash
env:
GETA_SSH_TARGET: ${{ secrets.GETA_SSH_TARGET }}
GETA_DEPLOY_ROOT: ${{ secrets.GETA_DEPLOY_ROOT }}
GHCR_PULL_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }}
OPENPLOTVA_PRODUCTION_ENV_B64: ${{ secrets.OPENPLOTVA_PRODUCTION_ENV_B64 }}
run: |
set -euo pipefail
test -n "$GHCR_PULL_TOKEN"
printf -v image_q '%q' "$OPENPLOTVA_IMAGE"
printf -v sandbox_image_q '%q' "$OPENPLOTVA_SANDBOX_IMAGE"
printf -v token_q '%q' "$GHCR_PULL_TOKEN"
printf -v actor_q '%q' "$GITHUB_ACTOR"
printf -v env_q '%q' "${OPENPLOTVA_PRODUCTION_ENV_B64:-}"
ssh \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=20 \
"$GETA_SSH_TARGET" \
"OPENPLOTVA_DEPLOY_IMAGE=$image_q OPENPLOTVA_SANDBOX_IMAGE=$sandbox_image_q GHCR_USERNAME=$actor_q GHCR_PULL_TOKEN=$token_q OPENPLOTVA_PRODUCTION_ENV_B64=$env_q '$GETA_DEPLOY_ROOT/deploy-production.sh'"
- name: Remove non-current GHCR images
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
test -n "${GH_TOKEN:-}" || {
echo "GITHUB_TOKEN is required for post-deploy package cleanup" >&2
exit 1
}
owner="${GITHUB_REPOSITORY_OWNER,,}"
repository="${GITHUB_REPOSITORY#*/}"
repository="${repository,,}"
current_tag="${GITHUB_SHA}"
for package in "$repository" "${repository}-sandbox"; do
versions_file="$(mktemp)"
echo "Cleaning GHCR package ${owner}/${package}; keeping only current tag ${current_tag}"
gh api --paginate --slurp "/users/${owner}/packages/container/${package}/versions?per_page=100" > "$versions_file"
deleted=0
while IFS= read -r version; do
id="$(jq -r '.id' <<<"$version")"
created_at="$(jq -r '.created_at' <<<"$version")"
tags="$(jq -r '[.metadata.container.tags[]?] | join(",")' <<<"$version")"
if jq -e --arg tag "$current_tag" '.metadata.container.tags // [] | index($tag)' <<<"$version" >/dev/null; then
continue
fi
echo "Deleting GHCR version id=${id} created_at=${created_at} tags=${tags:-<none>}"
gh api -X DELETE "/users/${owner}/packages/container/${package}/versions/${id}"
deleted=$((deleted + 1))
done < <(jq -c '.[][]' "$versions_file")
echo "Deleted ${deleted} old version(s) of ${package}"
done