feat: automate staging deploy from Windows Gitea runner

Build immutable API and web images in Aliyun ACR after staging validation, then deploy them safely to the staging host.
This commit is contained in:
linmeng
2026-07-27 11:54:45 +08:00
parent d5334ebecb
commit 742f8f48b2
5 changed files with 266 additions and 7 deletions
+208
View File
@@ -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
}
+5 -3
View File
@@ -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
+12 -4
View File
@@ -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(
[
@@ -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);
@@ -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));
}
});