Repository navigation
Production Deploy #324
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: 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 |