diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml new file mode 100644 index 00000000..3fee3c0b --- /dev/null +++ b/.gitea/workflows/backend-quality-gate.yml @@ -0,0 +1,208 @@ +name: Staging Backend Quality Gate + +on: + pull_request: + paths: + - '.gitea/workflows/**' + - 'deploy/**' + - 'frontend/**' + - 'jyotish_vedic/**' + - 'scripts/**' + - 'tests/**' + - 'mcp_server.py' + - 'pyproject.toml' + - 'requirements*.txt' + push: + branches: [staging] + workflow_dispatch: + +concurrency: + group: staging-quality-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: runner-win + timeout-minutes: 30 + defaults: + run: + shell: powershell + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/actions/setup-python@v5 + with: + python-version: '3.12' + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: | + $ErrorActionPreference = 'Stop' + 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' + 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 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: runner-win + 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 + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + steps: + - uses: https://github.com/actions/checkout@v4 + with: { fetch-depth: 0, persist-credentials: false } + - name: Build, publish, and deploy immutable staging images + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + 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 } + } + function Invoke-Ssh([string]$Command) { + & ssh @script:SshOptions "$env:DEPLOY_USER@$env:DEPLOY_HOST" $Command + Assert-Exit 'remote SSH command failed' + } + + 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 + } diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 3d69f44b..056a8898 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -14,7 +14,7 @@ for key in "${required[@]}"; do done sha_pattern='^[0-9a-f]{40}$' -digest_pattern='^ghcr\.io/jesse-ux/jyotisha-(api|web)@sha256:[0-9a-f]{64}$' +digest_pattern='^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[1-9][0-9]{0,4})?(/[a-z0-9]+([._-][a-z0-9]+)*)+@sha256:[0-9a-f]{64}$' image_id_pattern='^sha256:[0-9a-f]{64}$' if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] || [[ ! "$API_IMAGE" =~ $digest_pattern ]] || @@ -22,6 +22,8 @@ if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] || echo "unsafe staging image identity" >&2 exit 1 fi +api_repository="${API_IMAGE%@sha256:*}" +web_repository="${WEB_IMAGE%@sha256:*}" if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then echo "invalid rollback authorization" >&2 exit 1 @@ -85,8 +87,8 @@ repo_digest_for_container() { awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }' } -previous_api_image="$(repo_digest_for_container api ghcr.io/jesse-ux/jyotisha-api)" -previous_web_image="$(repo_digest_for_container web ghcr.io/jesse-ux/jyotisha-web)" +previous_api_image="$(repo_digest_for_container api "$api_repository")" +previous_web_image="$(repo_digest_for_container web "$web_repository")" previous_api_id="" previous_web_id="" if [ -n "$(container_id api)" ]; then diff --git a/frontend/scripts/staging-image-manifest.mjs b/frontend/scripts/staging-image-manifest.mjs index bb27f13d..767c5644 100644 --- a/frontend/scripts/staging-image-manifest.mjs +++ b/frontend/scripts/staging-image-manifest.mjs @@ -5,8 +5,14 @@ import { pathToFileURL } from "node:url"; const shaPattern = /^[0-9a-f]{40}$/; const digestPattern = /^sha256:[0-9a-f]{64}$/; const expectedKeys = ["git_sha", "api_digest", "web_digest"]; +const defaultRegistry = "ghcr.io/jesse-ux"; +const acrRepository = "crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha"; +const registryPattern = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/; -export function parseStagingImageManifest(text, expectedSha) { +export function parseStagingImageManifest(text, expectedSha, registry = defaultRegistry) { + if (!registryPattern.test(registry)) { + throw new Error("invalid staging image registry"); + } if (!shaPattern.test(expectedSha)) { throw new Error("invalid expected staging revision"); } @@ -37,12 +43,13 @@ export function parseStagingImageManifest(text, expectedSha) { } } + const sharedRepository = registry === acrRepository; return { gitSha: expectedSha, apiDigest: values.get("api_digest"), webDigest: values.get("web_digest"), - apiImage: `ghcr.io/jesse-ux/jyotisha-api@${values.get("api_digest")}`, - webImage: `ghcr.io/jesse-ux/jyotisha-web@${values.get("web_digest")}`, + apiImage: `${sharedRepository ? registry : `${registry}/jyotisha-api`}@${values.get("api_digest")}`, + webImage: `${sharedRepository ? registry : `${registry}/jyotisha-web`}@${values.get("web_digest")}`, }; } @@ -52,13 +59,14 @@ const invokedPath = process.argv[1] if (invokedPath === import.meta.url) { try { - const [manifestPath, expectedSha] = process.argv.slice(2); + const [manifestPath, expectedSha, registry] = process.argv.slice(2); if (!manifestPath || !expectedSha) { throw new Error("manifest path and expected revision are required"); } const manifest = parseStagingImageManifest( await readFile(manifestPath, "utf8"), expectedSha, + registry, ); process.stdout.write( [ diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 6d356411..7d1ee915 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -30,6 +30,10 @@ const syncScript = new URL( "../../deploy/sync-staging-tree.sh", import.meta.url, ); +const giteaQualityWorkflow = new URL( + "../../.gitea/workflows/backend-quality-gate.yml", + import.meta.url, +); function read(url: URL): string { return readFileSync(url, "utf8"); @@ -376,6 +380,21 @@ 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", () => { + const workflow = read(giteaQualityWorkflow); + assert.match(workflow, /runs-on: runner-win/); + assert.match(workflow, /shell: powershell/); + 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/); +}); + test("staging scripts pass shell syntax validation", () => { for (const script of [deployScript, migrationScript, syncScript]) { const path = fileURLToPath(script); diff --git a/frontend/tests/staging-image-manifest.test.ts b/frontend/tests/staging-image-manifest.test.ts index 2fc9e656..4494002e 100644 --- a/frontend/tests/staging-image-manifest.test.ts +++ b/frontend/tests/staging-image-manifest.test.ts @@ -25,6 +25,18 @@ test("manifest produces immutable GHCR digest references", () => { }); }); +test("manifest produces immutable shared ACR repository references", () => { + const repository = + "crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha"; + assert.deepEqual(parseStagingImageManifest(validManifest(), gitSha, repository), { + gitSha, + apiDigest, + webDigest, + apiImage: `${repository}@${apiDigest}`, + webImage: `${repository}@${webDigest}`, + }); +}); + test("manifest rejects revision drift, mutable tags, duplicates, extras, and malformed digests", () => { const invalid = [ validManifest().replace(gitSha, "f".repeat(40)), @@ -41,4 +53,14 @@ test("manifest rejects revision drift, mutable tags, duplicates, extras, and mal for (const contents of invalid) { assert.throws(() => parseStagingImageManifest(contents, gitSha)); } + for (const registry of [ + "https://git.copse.top/root", + "git.copse.top/root;touch /tmp/pwned", + "git.copse.top/Root", + "git.copse.top/root@evil.example", + "git.copse.top/root/../evil", + "", + ]) { + assert.throws(() => parseStagingImageManifest(validManifest(), gitSha, registry)); + } });