diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index b889177a..6223c778 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -22,11 +22,8 @@ concurrency: jobs: validate: - runs-on: manman-win + runs-on: xiaoxin timeout-minutes: 30 - defaults: - run: - shell: powershell steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-python@v5 @@ -39,49 +36,30 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: | - $ErrorActionPreference = 'Stop' + set -euo pipefail python -m pip install --upgrade pip - if ($LASTEXITCODE -ne 0) { throw 'pip upgrade failed' } python -m pip install -r requirements.txt -r requirements-dev.txt - if ($LASTEXITCODE -ne 0) { throw 'Python dependency installation failed' } npm ci --prefix frontend - if ($LASTEXITCODE -ne 0) { throw 'frontend dependency installation failed' } - name: Validate backend, package, frontend, and database contracts env: NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder run: | - $ErrorActionPreference = 'Stop' + set -euo pipefail ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py - if ($LASTEXITCODE -ne 0) { throw 'ruff validation failed' } - $pythonFiles = @( - Get-ChildItem scripts -Filter '*.py' -File | ForEach-Object FullName - Get-ChildItem jyotish_vedic -Filter '*.py' -File | ForEach-Object FullName - (Resolve-Path mcp_server.py).Path - ) - python -m py_compile @pythonFiles - if ($LASTEXITCODE -ne 0) { throw 'Python compilation failed' } + python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime - if ($LASTEXITCODE -ne 0) { throw 'quick quality gate failed' } python scripts/commercial_privacy_artifact_scan.py --json - if ($LASTEXITCODE -ne 0) { throw 'privacy scan failed' } python -m build - if ($LASTEXITCODE -ne 0) { throw 'package build failed' } npm test --prefix frontend - if ($LASTEXITCODE -ne 0) { throw 'frontend tests failed' } npm run lint --prefix frontend - if ($LASTEXITCODE -ne 0) { throw 'frontend lint failed' } npm run build --prefix frontend - if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' } publish-and-deploy: if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging' needs: validate - runs-on: manman-win + runs-on: xiaoxin timeout-minutes: 45 - defaults: - run: - shell: powershell env: REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha @@ -100,109 +78,90 @@ jobs: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} run: | - $ErrorActionPreference = 'Stop' - function Assert-Exit([string]$Message) { - if ($LASTEXITCODE -ne 0) { throw $Message } + set -euo pipefail + + [[ "${GITEA_EVENT_NAME:-}" == "push" && "${GITEA_REF:-}" == "refs/heads/staging" ]] || { + echo "not an exact staging push" >&2 + exit 1 } - function Invoke-Ssh([string]$Command) { - & ssh @script:SshOptions "$env:DEPLOY_USER@$env:DEPLOY_HOST" $Command - Assert-Exit 'remote SSH command failed' + [[ "${GITEA_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid staging commit SHA" >&2; exit 1; } + [[ "${DEPLOY_HOST:-}" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "invalid staging host" >&2; exit 1; } + [[ "${DEPLOY_PORT:-}" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "invalid staging port" >&2; exit 1; } + [[ "${DEPLOY_USER:-}" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "invalid staging user" >&2; exit 1; } + [[ "${DEPLOY_PATH:-}" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo "invalid staging path" >&2; exit 1; } + [[ "${STAGING_URL:-}" =~ ^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?$ ]] || { echo "invalid staging URL" >&2; exit 1; } + [[ -n "${STAGING_KNOWN_HOSTS:-}" && -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" && -n "${SSH_PRIVATE_KEY:-}" ]] || { + echo "required staging credentials or configuration are missing" >&2 + exit 1 } - if ($env:GITEA_EVENT_NAME -ne 'push' -or $env:GITEA_REF -ne 'refs/heads/staging') { throw 'not an exact staging push' } - if ($env:GITEA_SHA -notmatch '^[0-9a-f]{40}$') { throw 'invalid staging commit SHA' } - if ($env:DEPLOY_HOST -notmatch '^[A-Za-z0-9.-]+$' -or $env:DEPLOY_PORT -notmatch '^[1-9][0-9]{0,4}$' -or $env:DEPLOY_USER -notmatch '^[a-z_][a-z0-9_-]*$' -or $env:DEPLOY_PATH -notmatch '^/[A-Za-z0-9._/-]+$') { throw 'invalid staging SSH target' } - if ($env:STAGING_URL -notmatch '^https://[A-Za-z0-9.-]+(?::[1-9][0-9]{0,4})?$' -or [string]::IsNullOrWhiteSpace($env:STAGING_KNOWN_HOSTS)) { throw 'invalid staging endpoint configuration' } - $remoteLine = (& git ls-remote origin refs/heads/staging) - Assert-Exit 'unable to resolve remote staging head' - $remoteSha = (($remoteLine -split '\s+')[0]) - if ($remoteSha -ne $env:GITEA_SHA) { throw 'staging head changed before publication' } - & git fetch origin main - Assert-Exit 'unable to fetch reviewed main history' - & git merge-base --is-ancestor $env:GITEA_SHA origin/main - Assert-Exit 'staging revision is not in reviewed main history' - - if ([string]::IsNullOrWhiteSpace($env:REGISTRY_USERNAME) -or [string]::IsNullOrWhiteSpace($env:REGISTRY_PASSWORD)) { throw 'registry credentials are missing' } - $env:REGISTRY_PASSWORD | & docker login $env:REGISTRY_HOST --username $env:REGISTRY_USERNAME --password-stdin - Assert-Exit 'registry login failed' - - $sshRoot = Join-Path $env:RUNNER_TEMP 'jyotisha-staging-ssh' - $keyPath = Join-Path $sshRoot 'id_ed25519' - $knownHostsPath = Join-Path $sshRoot 'known_hosts' - $archivePath = Join-Path $env:RUNNER_TEMP "deploy-$($env:GITEA_RUN_NUMBER)-$($env:GITEA_RUN_ATTEMPT).tar" - $incoming = "$env:DEPLOY_PATH/.incoming/$env:GITEA_RUN_NUMBER-$env:GITEA_RUN_ATTEMPT" - $script:SshOptions = @('-i', $keyPath, '-p', $env:DEPLOY_PORT, '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes', '-o', "UserKnownHostsFile=$knownHostsPath") - $scpOptions = @('-i', $keyPath, '-P', $env:DEPLOY_PORT, '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes', '-o', "UserKnownHostsFile=$knownHostsPath") - $remotePrepared = $false - try { - $apiTag = "$env:IMAGE_REPOSITORY`:api-$env:GITEA_SHA" - $webTag = "$env:IMAGE_REPOSITORY`:web-$env:GITEA_SHA" - & docker build --file deploy/railway-api.Dockerfile --tag $apiTag . - Assert-Exit 'API image build failed' - & docker push $apiTag - Assert-Exit 'API image push failed' - & docker build --file deploy/railway-web.Dockerfile --tag $webTag . - Assert-Exit 'web image build failed' - & docker push $webTag - Assert-Exit 'web image push failed' - - $apiDigests = (& docker image inspect --format '{{json .RepoDigests}}' $apiTag | ConvertFrom-Json) - Assert-Exit 'unable to inspect API image digests' - $webDigests = (& docker image inspect --format '{{json .RepoDigests}}' $webTag | ConvertFrom-Json) - Assert-Exit 'unable to inspect web image digests' - $apiRef = @($apiDigests | Where-Object { $_ -match "^$([regex]::Escape($env:IMAGE_REPOSITORY))@sha256:[0-9a-f]{64}$" })[0] - $webRef = @($webDigests | Where-Object { $_ -match "^$([regex]::Escape($env:IMAGE_REPOSITORY))@sha256:[0-9a-f]{64}$" })[0] - if (-not $apiRef -or -not $webRef) { throw 'immutable image digest was not published' } - $apiDigest = ($apiRef -split '@', 2)[1] - $webDigest = ($webRef -split '@', 2)[1] - $manifestPath = Join-Path $env:RUNNER_TEMP 'staging-image-manifest.env' - [IO.File]::WriteAllText($manifestPath, "git_sha=$env:GITEA_SHA`napi_digest=$apiDigest`nweb_digest=$webDigest`n", [Text.UTF8Encoding]::new($false)) - $manifestOutput = & node frontend/scripts/staging-image-manifest.mjs $manifestPath $env:GITEA_SHA $env:IMAGE_REPOSITORY - Assert-Exit 'immutable image manifest validation failed' - $images = @{} - foreach ($line in $manifestOutput) { - $parts = $line -split '=', 2 - if ($parts.Count -eq 2) { $images[$parts[0]] = $parts[1] } - } - if (-not $images.api_image -or -not $images.web_image) { throw 'image manifest output is incomplete' } - - New-Item -ItemType Directory -Path $sshRoot -Force | Out-Null - $normalizedKey = ($env:SSH_PRIVATE_KEY -replace "`r`n", "`n" -replace "`r", "`n").TrimEnd("`n") + "`n" - [IO.File]::WriteAllText($keyPath, $normalizedKey, [Text.UTF8Encoding]::new($false)) - $normalizedHosts = ($env:STAGING_KNOWN_HOSTS -replace "`r`n", "`n" -replace "`r", "`n").TrimEnd("`n") + "`n" - [IO.File]::WriteAllText($knownHostsPath, $normalizedHosts, [Text.UTF8Encoding]::new($false)) - - & tar -cf $archivePath deploy - Assert-Exit 'deploy archive creation failed' - Invoke-Ssh "install -d -m 700 '$incoming/.docker'" - $remotePrepared = $true - & scp @scpOptions $archivePath "$env:DEPLOY_USER@$env:DEPLOY_HOST`:$incoming/deploy.tar" - Assert-Exit 'deploy archive upload failed' - Invoke-Ssh "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" - - $previousSha = (& ssh @script:SshOptions "$env:DEPLOY_USER@$env:DEPLOY_HOST" "state='$env:DEPLOY_PATH/.state/deployed-revision'; if [ -f \"`$state\" ]; then cat \"`$state\"; else id=`$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"`$id\" ]; then value=`$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"`$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"`${value:-not-deployed}\"; else printf not-deployed; fi; fi").Trim() - Assert-Exit 'unable to read deployed staging revision' - if ($previousSha -ne 'not-deployed' -and $previousSha -notmatch '^[0-9a-f]{40}$') { throw 'invalid deployed staging revision state' } - $forwardVerified = 'false' - if ($previousSha -ne 'not-deployed' -and $previousSha -ne $env:GITEA_SHA) { - & git cat-file -e "$previousSha^{commit}" - if ($LASTEXITCODE -ne 0) { - & git fetch origin $previousSha - Assert-Exit 'unable to fetch deployed staging revision' - } - & git merge-base --is-ancestor $previousSha $env:GITEA_SHA - Assert-Exit 'automatic rollback or divergent staging deployment refused' - $forwardVerified = 'true' - } - - $env:REGISTRY_PASSWORD | & ssh @script:SshOptions "$env:DEPLOY_USER@$env:DEPLOY_HOST" "DOCKER_CONFIG='$incoming/.docker' docker login '$env:REGISTRY_HOST' --username '$env:REGISTRY_USERNAME' --password-stdin" - Assert-Exit 'remote registry login failed' - Invoke-Ssh "INCOMING_PATH='$incoming' DEPLOY_PATH='$env:DEPLOY_PATH' API_IMAGE='$($images.api_image)' WEB_IMAGE='$($images.web_image)' DEPLOY_SHA='$env:GITEA_SHA' EXPECTED_PREVIOUS_SHA='$previousSha' ALLOW_ROLLBACK='false' FORWARD_REVISION_VERIFIED='$forwardVerified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$env:STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'" - } finally { - if ($remotePrepared) { - & ssh @script:SshOptions "$env:DEPLOY_USER@$env:DEPLOY_HOST" "DOCKER_CONFIG='$incoming/.docker' docker logout '$env:REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" 2>$null - } - & docker logout $env:REGISTRY_HOST 2>$null | Out-Null - Remove-Item $archivePath -Force -ErrorAction SilentlyContinue - Remove-Item $sshRoot -Recurse -Force -ErrorAction SilentlyContinue + remote_sha="$(git ls-remote origin refs/heads/staging | awk '{print $1}')" + [[ "$remote_sha" == "$GITEA_SHA" ]] || { echo "staging head changed before publication" >&2; exit 1; } + git fetch origin main + git merge-base --is-ancestor "$GITEA_SHA" origin/main || { + echo "staging revision is not in reviewed main history" >&2 + exit 1 } + + ssh_root="${RUNNER_TEMP}/jyotisha-staging-ssh" + key_path="${ssh_root}/id_ed25519" + known_hosts_path="${ssh_root}/known_hosts" + archive_path="${RUNNER_TEMP}/deploy-${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}.tar" + incoming="${DEPLOY_PATH}/.incoming/${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}" + remote_prepared=false + mkdir -p "$ssh_root" + umask 077 + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" + chmod 600 "$key_path" "$known_hosts_path" + ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + scp_options=(-i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + remote="${DEPLOY_USER}@${DEPLOY_HOST}" + + cleanup() { + if [[ "$remote_prepared" == true ]]; then + ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true + fi + docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + rm -rf -- "$ssh_root" "$archive_path" + } + trap cleanup EXIT + + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + api_tag="${IMAGE_REPOSITORY}:api-${GITEA_SHA}" + web_tag="${IMAGE_REPOSITORY}:web-${GITEA_SHA}" + docker build --file deploy/railway-api.Dockerfile --tag "$api_tag" . + docker push "$api_tag" + docker build --file deploy/railway-web.Dockerfile --tag "$web_tag" . + docker push "$web_tag" + + api_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$api_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" + web_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$web_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" + [[ -n "$api_ref" && -n "$web_ref" ]] || { echo "immutable image digest was not published" >&2; exit 1; } + manifest_path="${RUNNER_TEMP}/staging-image-manifest.env" + printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' "$GITEA_SHA" "${api_ref#*@}" "${web_ref#*@}" > "$manifest_path" + manifest_output="$(node frontend/scripts/staging-image-manifest.mjs "$manifest_path" "$GITEA_SHA" "$IMAGE_REPOSITORY")" + api_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^api_image=//p')" + web_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^web_image=//p')" + [[ -n "$api_image" && -n "$web_image" ]] || { echo "image manifest output is incomplete" >&2; exit 1; } + + tar -cf "$archive_path" deploy + ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" + remote_prepared=true + scp "${scp_options[@]}" "$archive_path" "${remote}:${incoming}/deploy.tar" + ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" + + previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")" + [[ "$previous_sha" == "not-deployed" || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid deployed staging revision state" >&2; exit 1; } + forward_verified=false + if [[ "$previous_sha" != "not-deployed" && "$previous_sha" != "$GITEA_SHA" ]]; then + git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha" + git merge-base --is-ancestor "$previous_sha" "$GITEA_SHA" || { + echo "automatic rollback or divergent staging deployment refused" >&2 + exit 1 + } + forward_verified=true + fi + + printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$api_image' WEB_IMAGE='$web_image' DEPLOY_SHA='$GITEA_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='false' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'" diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index a08129e6..dd16b252 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -380,19 +380,19 @@ test("production remains manual-only and separate from staging database automati assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/); }); -test("Gitea staging push uses the Windows runner and immutable ACR images", () => { +test("Gitea staging push uses the xiaoxin Linux runner and immutable ACR images", () => { const workflow = read(giteaQualityWorkflow); - assert.match(workflow, /runs-on: manman-win/); - assert.match(workflow, /shell: powershell/); + assert.equal(workflow.match(/runs-on: xiaoxin/g)?.length, 2); + assert.match(workflow, /set -euo pipefail/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); assert.match(workflow, /secrets\.REGISTRY_USERNAME/); assert.match(workflow, /secrets\.REGISTRY_PASSWORD/); - assert.match(workflow, /:api-\$env:GITEA_SHA/); - assert.match(workflow, /:web-\$env:GITEA_SHA/); - assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previousSha'/); - assert.match(workflow, /git merge-base --is-ancestor \$previousSha \$env:GITEA_SHA/); - assert.match(workflow, /\$scpOptions = @\([^\n]*'-P'/); - assert.doesNotMatch(workflow, /17631000304|copse\.ai\.2026/); + assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/); + assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/); + assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/); + assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/); + assert.match(workflow, /scp_options=\(-i "\$key_path" -P "\$DEPLOY_PORT"/); + assert.doesNotMatch(workflow, /shell: powershell|17631000304|copse\.ai\.2026/); }); test("staging scripts pass shell syntax validation", () => {