diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 398fcd465..2617f3e45 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -77,12 +77,53 @@ jobs: done exit 1 + build-production-runtime-images: + name: Build Production Runtime Images + runs-on: runtime-builder + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + shell: sh + run: | + set -eu + archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" + wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url" + tar -xzf /tmp/repo.tar.gz --strip-components=1 -C . + rm -f /tmp/repo.tar.gz + + - name: Build runtime image artifact + shell: sh + run: | + set -eu + chmod +x scripts/build_release_images.sh + scripts/build_release_images.sh "${GITHUB_REF_NAME}" + + - name: Upload runtime image artifact + shell: sh + env: + PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }} + PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }} + PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }} + run: | + set -eu + test -n "$PRODUCTION_SSH_HOST" + test -n "$PRODUCTION_SSH_USER" + test -n "$PRODUCTION_SSH_KEY" + mkdir -p ~/.ssh + printf '%s\n' "$PRODUCTION_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts + scp "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \ + "$PRODUCTION_SSH_USER@$PRODUCTION_SSH_HOST:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar" + deploy-production: name: Deploy Production runs-on: ubuntu-latest container: image: docker:27-cli if: startsWith(github.ref, 'refs/tags/v') + needs: build-production-runtime-images steps: - name: Checkout code diff --git a/docs/GITEA-RUNTIME-BUILDER-RUNNER.md b/docs/GITEA-RUNTIME-BUILDER-RUNNER.md new file mode 100644 index 000000000..c0e423350 --- /dev/null +++ b/docs/GITEA-RUNTIME-BUILDER-RUNNER.md @@ -0,0 +1,80 @@ +# Dedicated Gitea Runtime Builder Runner + +> Goal: build API/Worker runtime image tar on a machine that does not host production services. + +## Required machine + +Minimum recommended spec: + +- 2 vCPU minimum, 4 vCPU preferred. +- 4GiB RAM minimum, 8GiB preferred. +- 40GiB disk minimum. +- Docker Engine installed. +- Network access to Gitea and production SSH. + +Do not install this runner on the current production host. + +## Runner label + +The production runtime image workflow targets: + +```yaml +runs-on: runtime-builder +``` + +Register the new runner with a label named `runtime-builder`. Keep the existing production/staging runner separate. + +## Required Gitea secrets + +Configure repository or organization secrets: + +- `PRODUCTION_SSH_HOST` +- `PRODUCTION_SSH_USER` +- `PRODUCTION_SSH_KEY` + +The key must allow uploading to: + +```text +/var/lib/xiaoxia-saas-production/runtime-images-.tar +``` + +Do not store SSH private keys in the repository. + +## Workflow behavior + +On `v*` tag push: + +1. `build-production-runtime-images` runs on `runtime-builder`. +2. It executes `scripts/build_release_images.sh `. +3. It uploads `dist/release-images/xiaoxia-runtime-images-.tar` to production. +4. `deploy-production` runs after the build job succeeds. +5. `deploy-production` loads `/var/lib/xiaoxia-saas-production/runtime-images-.tar` and restarts API/Worker/Web. + +If the tar is missing, production deploy must fail. + +## Preflight checks on runner + +Run on the new runner before registration: + +```bash +docker version +docker ps --format '{{.Names}}' | grep -Eq '^(xiaoxia-(api|web|worker|postgres|redis)-production|gitea)$' && exit 1 || true +``` + +The second command must not find production containers. + +## Validation release + +After runner registration: + +1. Push a test tag only after `develop` is green. +2. Confirm runtime image build job runs on the dedicated runner. +3. Confirm production deploy waits for the image job. +4. Run: + +```bash +python scripts/smoke_public_auth_flow.py +python scripts/smoke_public_upload_flow.py +``` + +5. Record release result in the Phase progress document. diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index 5e044be28..ca8c90b6c 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -154,6 +154,18 @@ def test_subscription_api_is_not_wired_to_ui_until_backend_exists(): assert importers == [] +def test_gitea_production_deploy_requires_runtime_builder_job(): + workflow = Path(".gitea/workflows/deploy.yml").read_text(encoding="utf-8") + build_section = workflow.split("build-production-runtime-images:", 1)[1].split("deploy-production:", 1)[0] + production_section = workflow.split("deploy-production:", 1)[1] + + assert "runs-on: runtime-builder" in build_section + assert "scripts/build_release_images.sh \"${GITHUB_REF_NAME}\"" in build_section + assert "PRODUCTION_SSH_HOST" in build_section + assert "runtime-images-${GITHUB_REF_NAME}.tar" in build_section + assert "needs: build-production-runtime-images" in production_section + + def test_build_host_runbook_requires_off_production_runtime_builds(): runbook = Path("docs/BUILD-HOST-RUNBOOK.md").read_text(encoding="utf-8") @@ -164,6 +176,17 @@ def test_build_host_runbook_requires_off_production_runtime_builds(): assert "python scripts/smoke_public_upload_flow.py" in runbook +def test_runtime_builder_runner_runbook_matches_workflow(): + runbook = Path("docs/GITEA-RUNTIME-BUILDER-RUNNER.md").read_text(encoding="utf-8") + + assert "runs-on: runtime-builder" in runbook + assert "PRODUCTION_SSH_HOST" in runbook + assert "PRODUCTION_SSH_USER" in runbook + assert "PRODUCTION_SSH_KEY" in runbook + assert "Do not install this runner on the current production host" in runbook + assert "production deploy waits for the image job" in runbook + + def test_deployment_docs_forbid_production_runtime_builds(): docs = Path("docs/DEPLOYMENT.md").read_text(encoding="utf-8")