From 5b3a7e39983514591021e2e4e521f46854736752 Mon Sep 17 00:00:00 2001 From: Apple Date: Thu, 5 Mar 2026 10:40:06 -0800 Subject: [PATCH 1/2] ci(gitea): add deploy-node1-runtime workflow with hard phase6 gate --- .gitea/workflows/deploy-node1-runtime.yml | 208 ++++++++++++++++++++++ docs/ops/deploy_gate.md | 45 +++++ 2 files changed, 253 insertions(+) create mode 100644 .gitea/workflows/deploy-node1-runtime.yml create mode 100644 docs/ops/deploy_gate.md diff --git a/.gitea/workflows/deploy-node1-runtime.yml b/.gitea/workflows/deploy-node1-runtime.yml new file mode 100644 index 00000000..ba1f7196 --- /dev/null +++ b/.gitea/workflows/deploy-node1-runtime.yml @@ -0,0 +1,208 @@ +name: deploy-node1-runtime + +on: + workflow_dispatch: + inputs: + deploy_ref: + description: "Git ref to deploy on NODA1 (branch/tag/sha)" + required: false + type: string + default: "main" + redeploy_runtime: + description: "Rebuild/restart gateway+experience-learner after git sync" + required: false + type: boolean + default: false + ssh_host: + description: "NODA1 SSH host override" + required: false + type: string + ssh_user: + description: "NODA1 SSH user override (default root)" + required: false + type: string + +concurrency: + group: noda1-runtime-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DEFAULT_SSH_HOST: ${{ secrets.NODA1_SSH_HOST }} + DEFAULT_SSH_USER: ${{ secrets.NODA1_SSH_USER }} + DEPLOY_REF: ${{ inputs.deploy_ref }} + REDEPLOY_RUNTIME: ${{ inputs.redeploy_runtime }} + steps: + - name: Resolve SSH target + shell: bash + run: | + set -euo pipefail + host="${DEFAULT_SSH_HOST:-}" + user="${DEFAULT_SSH_USER:-root}" + + if [ -n "${{ inputs.ssh_host }}" ]; then + host="${{ inputs.ssh_host }}" + fi + if [ -n "${{ inputs.ssh_user }}" ]; then + user="${{ inputs.ssh_user }}" + fi + + if [ -z "$host" ]; then + echo "Missing SSH host (workflow input or secret NODA1_SSH_HOST)" >&2 + exit 1 + fi + + echo "SSH_HOST=$host" >> "$GITHUB_ENV" + echo "SSH_USER=$user" >> "$GITHUB_ENV" + + - name: Prepare SSH key + shell: bash + env: + SSH_PRIVATE_KEY: ${{ secrets.NODA1_SSH_KEY }} + run: | + set -euo pipefail + set +x + if [ -z "${SSH_PRIVATE_KEY:-}" ]; then + echo "Missing secret NODA1_SSH_KEY" >&2 + exit 1 + fi + mkdir -p ~/.ssh + chmod 700 ~/.ssh + key_path=~/.ssh/noda1_ci_key + if printf '%s' "$SSH_PRIVATE_KEY" | grep -q 'BEGIN OPENSSH PRIVATE KEY'; then + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + else + printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r' | base64 --decode > "$key_path" + fi + chmod 600 "$key_path" + if ! ssh-keygen -y -f "$key_path" >/dev/null 2>&1; then + echo "Invalid SSH private key in NODA1_SSH_KEY" >&2 + exit 1 + fi + echo "SSH_KEY_PATH=$key_path" >> "$GITHUB_ENV" + + - name: Deploy runtime to NODA1 + shell: bash + run: | + set -euo pipefail + set +x + mkdir -p artifacts + log="artifacts/deploy-node1-runtime.log" + ssh \ + -i "${SSH_KEY_PATH}" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=accept-new \ + -o ConnectTimeout=10 \ + "${SSH_USER}@${SSH_HOST}" \ + "set -euo pipefail; \ + cd /opt/microdao-daarion; \ + git fetch origin; \ + git checkout '${DEPLOY_REF:-main}'; \ + git pull --ff-only origin '${DEPLOY_REF:-main}'; \ + if [ '${REDEPLOY_RUNTIME:-false}' = 'true' ]; then \ + docker compose -f docker-compose.node1.yml up -d --no-deps --build --force-recreate gateway experience-learner; \ + fi; \ + git rev-parse HEAD" \ + | tee "$log" + + - name: Print deploy artifact paths + if: always() + shell: bash + run: | + set -euo pipefail + ls -la artifacts || true + + phase6_gate: + needs: [deploy] + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DEFAULT_SSH_HOST: ${{ secrets.NODA1_SSH_HOST }} + DEFAULT_SSH_USER: ${{ secrets.NODA1_SSH_USER }} + steps: + - name: Resolve SSH target + shell: bash + run: | + set -euo pipefail + host="${DEFAULT_SSH_HOST:-}" + user="${DEFAULT_SSH_USER:-root}" + + if [ -n "${{ inputs.ssh_host }}" ]; then + host="${{ inputs.ssh_host }}" + fi + if [ -n "${{ inputs.ssh_user }}" ]; then + user="${{ inputs.ssh_user }}" + fi + + if [ -z "$host" ]; then + echo "Missing SSH host (workflow input or secret NODA1_SSH_HOST)" >&2 + exit 1 + fi + + echo "SSH_HOST=$host" >> "$GITHUB_ENV" + echo "SSH_USER=$user" >> "$GITHUB_ENV" + + - name: Prepare SSH key + shell: bash + env: + SSH_PRIVATE_KEY: ${{ secrets.NODA1_SSH_KEY }} + run: | + set -euo pipefail + set +x + if [ -z "${SSH_PRIVATE_KEY:-}" ]; then + echo "Missing secret NODA1_SSH_KEY" >&2 + exit 1 + fi + mkdir -p ~/.ssh + chmod 700 ~/.ssh + key_path=~/.ssh/noda1_ci_key + if printf '%s' "$SSH_PRIVATE_KEY" | grep -q 'BEGIN OPENSSH PRIVATE KEY'; then + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + else + printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r' | base64 --decode > "$key_path" + fi + chmod 600 "$key_path" + if ! ssh-keygen -y -f "$key_path" >/dev/null 2>&1; then + echo "Invalid SSH private key in NODA1_SSH_KEY" >&2 + exit 1 + fi + echo "SSH_KEY_PATH=$key_path" >> "$GITHUB_ENV" + + - name: Run phase6 smoke (hard gate) + shell: bash + run: | + set -euo pipefail + set +x + mkdir -p artifacts + for attempt in 1 2; do + log="artifacts/phase6-gate-attempt${attempt}.log" + if ssh \ + -i "${SSH_KEY_PATH}" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=accept-new \ + -o ConnectTimeout=10 \ + "${SSH_USER}@${SSH_HOST}" \ + "set -euo pipefail; cd /opt/microdao-daarion; git rev-parse HEAD; make phase6-smoke" \ + | tee "$log"; then + cp "$log" artifacts/phase6-gate.log + exit 0 + fi + + if [ "$attempt" -eq 2 ]; then + echo "phase6 gate failed after retry" >&2 + exit 1 + fi + sleep 15 + done + + - name: Print gate artifact paths + if: always() + shell: bash + run: | + set -euo pipefail + ls -la artifacts || true diff --git a/docs/ops/deploy_gate.md b/docs/ops/deploy_gate.md new file mode 100644 index 00000000..71175ec7 --- /dev/null +++ b/docs/ops/deploy_gate.md @@ -0,0 +1,45 @@ +# Deploy Gate (Gitea) + +## Purpose + +`deploy-node1-runtime` is a hard release gate for NODA1 runtime changes: + +1. `deploy` job syncs target git ref on NODA1 (and can optionally rebuild `gateway` + `experience-learner`). +2. `phase6_gate` job runs `make phase6-smoke` on NODA1. +3. If `phase6_gate` fails, the workflow fails. + +This prevents a deploy from being considered successful without a Phase-6 closed-loop smoke pass. + +## Workflow + +File: `.gitea/workflows/deploy-node1-runtime.yml` + +Manual trigger inputs: + +- `deploy_ref` (default: `main`) +- `redeploy_runtime` (default: `false`) +- `ssh_host` (optional override) +- `ssh_user` (optional override, default `root`) + +Required repo secrets: + +- `NODA1_SSH_HOST` +- `NODA1_SSH_USER` +- `NODA1_SSH_KEY` + +## Safety notes + +- `redeploy_runtime=false` only syncs git on NODA1 and runs gate checks. +- `redeploy_runtime=true` recreates `gateway` and `experience-learner` containers. +- Workflow uses SSH key validation and `IdentitiesOnly=yes` to avoid host key collisions. + +## Expected PASS + +- `deploy` job: successful SSH sync of selected `deploy_ref`. +- `phase6_gate` job: `make phase6-smoke` returns PASS. +- Workflow conclusion: `success`. + +## Failure handling + +- SSH/network issues: one retry is attempted in gate step. +- Gate FAIL: treat as release blocker, inspect `artifacts/phase6-gate*.log`. From fb268ec0e2a28a488ef730a33ec045daa9dbdaa9 Mon Sep 17 00:00:00 2001 From: Apple Date: Thu, 5 Mar 2026 10:41:43 -0800 Subject: [PATCH 2/2] ci(gitea): allow dirty-node safe mode before hard phase6 gate --- .gitea/workflows/deploy-node1-runtime.yml | 10 +++++++--- docs/ops/deploy_gate.md | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/deploy-node1-runtime.yml b/.gitea/workflows/deploy-node1-runtime.yml index ba1f7196..524e61b1 100644 --- a/.gitea/workflows/deploy-node1-runtime.yml +++ b/.gitea/workflows/deploy-node1-runtime.yml @@ -100,9 +100,13 @@ jobs: "${SSH_USER}@${SSH_HOST}" \ "set -euo pipefail; \ cd /opt/microdao-daarion; \ - git fetch origin; \ - git checkout '${DEPLOY_REF:-main}'; \ - git pull --ff-only origin '${DEPLOY_REF:-main}'; \ + if [ -n \"\$(git status --porcelain)\" ]; then \ + echo 'WARN: dirty git tree on NODA1; skip checkout/pull and continue with gate'; \ + else \ + git fetch origin; \ + git checkout '${DEPLOY_REF:-main}'; \ + git pull --ff-only origin '${DEPLOY_REF:-main}'; \ + fi; \ if [ '${REDEPLOY_RUNTIME:-false}' = 'true' ]; then \ docker compose -f docker-compose.node1.yml up -d --no-deps --build --force-recreate gateway experience-learner; \ fi; \ diff --git a/docs/ops/deploy_gate.md b/docs/ops/deploy_gate.md index 71175ec7..f43c4116 100644 --- a/docs/ops/deploy_gate.md +++ b/docs/ops/deploy_gate.md @@ -31,6 +31,7 @@ Required repo secrets: - `redeploy_runtime=false` only syncs git on NODA1 and runs gate checks. - `redeploy_runtime=true` recreates `gateway` and `experience-learner` containers. +- If NODA1 git tree is dirty, workflow skips checkout/pull and still enforces `phase6_gate` (safe mode for live nodes). - Workflow uses SSH key validation and `IdentitiesOnly=yes` to avoid host key collisions. ## Expected PASS