diff --git a/.gitea/workflows/build-deploy-game.yml b/.gitea/workflows/build-deploy-game.yml index bddd894..34df2e3 100644 --- a/.gitea/workflows/build-deploy-game.yml +++ b/.gitea/workflows/build-deploy-game.yml @@ -22,15 +22,22 @@ name: Build & Deploy played game (reusable) # Notes on reliability: # - act keys its remote-action cache by repo URL alone (not by full # ref), so every concurrent game build on home-runner-1 shares the -# same /root/.cache/act/ dir for setup-buildx-action + -# build-push-action. Two builds racing on that dir corrupt the -# checked-out tree ("worktree contains unstaged changes") and the -# next read of dist/index.js throws → step exits 1. Pinning to -# patch tags didn't help because the cache key ignores the ref. -# Fix: do buildx setup + build via inline `docker buildx ...` -# shell, so nothing needs to be cloned from GitHub at runtime. -# - Registry login is also an inline shell step. One fewer remote- -# action download = one fewer failure point per job. +# same /root/.cache/act/ dir for any `uses:` remote action. +# Two jobs racing on that dir corrupt the checked-out tree +# ("worktree contains unstaged changes") and the next read of +# dist/index.js throws → step exits 1. Pinning to patch tags didn't +# help because the cache key ignores the ref. THE FIX, applied to +# BOTH build and deploy jobs: no `uses:` of any third-party action +# at all — buildx setup/build/login is inline `docker buildx ...`, +# and the SSH deploy is inline `ssh`/`scp` (the deploy jobs used to +# use appleboy/ssh-action, which raced exactly like the build +# actions and broke concurrent multi-game deploys). The only +# `uses:` left is actions/checkout in the build jobs. +# - Secrets are written to the server as FILES via scp (never piped +# through a step that lets `docker compose up` auto-create a missing +# bind-mount source as a root-owned directory — that poisons +# ~/-secrets/step-provisioner-password.txt and breaks cert-init +# with "is a directory" on every subsequent deploy). on: workflow_call: @@ -114,68 +121,96 @@ jobs: deploy-api-core: runs-on: ubuntu-24.04 needs: build-api-core + env: + PLAYED_HOST: ${{ secrets.PLAYED_HOST }} + PLAYED_USER: ${{ secrets.PLAYED_USER }} + PLAYED_PORT: ${{ secrets.PLAYED_PORT }} + GAME_ID: ${{ inputs.game-id }} steps: + - name: Set up SSH + # Writes the deploy key + tiny ssh/scp wrappers. No remote action + # is used anywhere in this job, so there is nothing to race on + # home-runner-1's shared act remote-action cache. + env: + PLAYED_SSH_KEY: ${{ secrets.PLAYED_SSH_KEY }} + run: | + install -m 700 -d /tmp/ssh + printf '%s\n' "$PLAYED_SSH_KEY" > /tmp/ssh/key + chmod 600 /tmp/ssh/key + cat > /tmp/ssh/run < /tmp/ssh/put < ~/${GAME_ID}-secrets/.env - printf '%s' "$STEP_CA_PROVISIONER_PASSWORD" > ~/${GAME_ID}-secrets/step-provisioner-password.txt - chmod 644 ~/${GAME_ID}-secrets/.env - chmod 600 ~/${GAME_ID}-secrets/step-provisioner-password.txt env: BUILD_ENV: ${{ secrets.BUILD_ENV }} STEP_CA_PROVISIONER_PASSWORD: ${{ secrets.STEP_CA_PROVISIONER_PASSWORD }} - GAME_ID: ${{ inputs.game-id }} + run: | + test -n "$BUILD_ENV" || { echo "::error::BUILD_ENV secret is empty — refusing to deploy"; exit 1; } + test -n "$STEP_CA_PROVISIONER_PASSWORD" || { echo "::error::STEP_CA_PROVISIONER_PASSWORD secret is empty"; exit 1; } + printf '%s' "$BUILD_ENV" > /tmp/env.prod + printf '%s' "$STEP_CA_PROVISIONER_PASSWORD" > /tmp/prov.txt + # Pre-create the secrets dir, then push the two secrets as FILES. + # This guarantees the bind-mount sources exist as files before any + # `compose up`, so Docker never auto-creates them as root-owned + # directories (which breaks cert-init + blocks future deploys). + /tmp/ssh/run "mkdir -p ~/${GAME_ID}-secrets" + /tmp/ssh/put /tmp/env.prod "${GAME_ID}-secrets/.env" + /tmp/ssh/put /tmp/prov.txt "${GAME_ID}-secrets/step-provisioner-password.txt" + /tmp/ssh/run "chmod 644 ~/${GAME_ID}-secrets/.env && chmod 600 ~/${GAME_ID}-secrets/step-provisioner-password.txt" + rm -f /tmp/env.prod /tmp/prov.txt - name: Pull and start api-core - uses: appleboy/ssh-action@v1.2.5 - with: - host: ${{ secrets.PLAYED_HOST }} - username: ${{ secrets.PLAYED_USER }} - port: ${{ secrets.PLAYED_PORT }} - key: ${{ secrets.PLAYED_SSH_KEY }} - envs: GAME_ID - script: | - docker login git.unom.io -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }} - [ -d ~/${GAME_ID}/.git ] || git clone "https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}@git.unom.io/played/${GAME_ID}.git" ~/${GAME_ID} - cd ~/${GAME_ID} - git fetch origin main - git reset --hard origin/main - docker compose -f compose.production.yml --env-file ~/${GAME_ID}-secrets/.env pull api-core - docker compose -f compose.production.yml --env-file ~/${GAME_ID}-secrets/.env up -d --no-build api-core env: - GAME_ID: ${{ inputs.game-id }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + # Hand the registry token to the box via a transient 0600 file + # (kept out of process args / the run log); the remote script + # reads it then deletes it. + printf '%s' "$REGISTRY_TOKEN" > /tmp/reg_token + /tmp/ssh/put /tmp/reg_token ".played_reg_token" + rm -f /tmp/reg_token + /tmp/ssh/run "chmod 600 ~/.played_reg_token" + /tmp/ssh/run "GAME_ID='${GAME_ID}' REGISTRY_USER='${REGISTRY_USER}' bash -s" <<'REMOTE' + set -euo pipefail + TOKEN="$(cat ~/.played_reg_token)"; rm -f ~/.played_reg_token + printf '%s' "$TOKEN" | docker login git.unom.io -u "$REGISTRY_USER" --password-stdin + # Clone-if-absent so a freshly provisioned box self-installs the repo. + [ -d "$HOME/$GAME_ID/.git" ] || git clone "https://${REGISTRY_USER}:${TOKEN}@git.unom.io/played/${GAME_ID}.git" "$HOME/$GAME_ID" + cd "$HOME/$GAME_ID" + git fetch origin main + git reset --hard origin/main + EF="--env-file $HOME/${GAME_ID}-secrets/.env" + docker compose -f compose.production.yml $EF pull api-core + docker compose -f compose.production.yml $EF up -d --no-build api-core + REMOTE - name: Wait for api-core to be healthy - uses: appleboy/ssh-action@v1.2.5 - with: - host: ${{ secrets.PLAYED_HOST }} - username: ${{ secrets.PLAYED_USER }} - port: ${{ secrets.PLAYED_PORT }} - key: ${{ secrets.PLAYED_SSH_KEY }} - envs: GAME_ID - script: | - cd ~/${GAME_ID} - echo "Waiting for api-core to be ready..." - for i in $(seq 1 30); do - if docker compose -f compose.production.yml --env-file ~/${GAME_ID}-secrets/.env ps api-core | grep -q "(healthy)"; then - echo "api-core is healthy" - exit 0 - fi - echo "Attempt $i/30..." - sleep 5 - done - echo "api-core did not become healthy in time" - exit 1 - env: - GAME_ID: ${{ inputs.game-id }} + run: | + /tmp/ssh/run "GAME_ID='${GAME_ID}' bash -s" <<'REMOTE' + set -euo pipefail + cd "$HOME/$GAME_ID" + EF="--env-file $HOME/${GAME_ID}-secrets/.env" + echo "Waiting for api-core to be ready..." + for i in $(seq 1 30); do + if docker compose -f compose.production.yml $EF ps api-core | grep -q "(healthy)"; then + echo "api-core is healthy" + exit 0 + fi + echo "Attempt $i/30..." + sleep 5 + done + echo "api-core did not become healthy in time" + docker compose -f compose.production.yml $EF logs api-core --tail 80 || true + exit 1 + REMOTE build-web: runs-on: ubuntu-24.04 @@ -250,19 +285,34 @@ jobs: runs-on: ubuntu-24.04 # Both gates: image must be built AND api-core must be live before web flips. needs: [build-web, deploy-api-core] + env: + PLAYED_HOST: ${{ secrets.PLAYED_HOST }} + PLAYED_USER: ${{ secrets.PLAYED_USER }} + PLAYED_PORT: ${{ secrets.PLAYED_PORT }} + GAME_ID: ${{ inputs.game-id }} steps: - - name: Pull and start web - uses: appleboy/ssh-action@v1.2.5 - with: - host: ${{ secrets.PLAYED_HOST }} - username: ${{ secrets.PLAYED_USER }} - port: ${{ secrets.PLAYED_PORT }} - key: ${{ secrets.PLAYED_SSH_KEY }} - envs: GAME_ID - script: | - docker login git.unom.io -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }} - cd ~/${GAME_ID} - docker compose -f compose.production.yml --env-file ~/${GAME_ID}-secrets/.env pull web - docker compose -f compose.production.yml --env-file ~/${GAME_ID}-secrets/.env up -d --no-build web + - name: Set up SSH env: - GAME_ID: ${{ inputs.game-id }} + PLAYED_SSH_KEY: ${{ secrets.PLAYED_SSH_KEY }} + run: | + install -m 700 -d /tmp/ssh + printf '%s\n' "$PLAYED_SSH_KEY" > /tmp/ssh/key + chmod 600 /tmp/ssh/key + cat > /tmp/ssh/run <