Start Magent beta overhaul
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\\.."
|
||||
Set-Location $repoRoot
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "scripts/run_backend_quality_gate.ps1 failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$now = Get-Date
|
||||
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
|
||||
|
||||
Write-Host "Build number: $buildNumber"
|
||||
|
||||
git tag $buildNumber
|
||||
git push origin $buildNumber
|
||||
|
||||
$backendImage = "rephl3xnz/magent-backend:$buildNumber"
|
||||
$frontendImage = "rephl3xnz/magent-frontend:$buildNumber"
|
||||
|
||||
docker build -f backend/Dockerfile -t $backendImage --build-arg BUILD_NUMBER=$buildNumber .
|
||||
docker build -f frontend/Dockerfile -t $frontendImage frontend
|
||||
|
||||
docker tag $backendImage rephl3xnz/magent-backend:latest
|
||||
docker tag $frontendImage rephl3xnz/magent-frontend:latest
|
||||
|
||||
docker push $backendImage
|
||||
docker push $frontendImage
|
||||
docker push rephl3xnz/magent-backend:latest
|
||||
docker push rephl3xnz/magent-frontend:latest
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
python_bin="${PYTHON_BIN:-python3}"
|
||||
|
||||
echo "Installing backend Python requirements"
|
||||
"$python_bin" -m pip install -r backend/requirements.txt
|
||||
|
||||
echo "Running Python dependency integrity check"
|
||||
"$python_bin" -m pip check
|
||||
|
||||
echo "Running backend unit tests"
|
||||
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||
|
||||
echo "Backend quality gate passed"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||
deploy_user="${DEPLOY_USER:-zak}"
|
||||
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
|
||||
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
mkdir -p '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
cp -a \"\$path\" \"\${backup_root}/\"
|
||||
fi
|
||||
done
|
||||
tar -xf - -C '${deploy_path}'
|
||||
docker compose up -d --build
|
||||
"
|
||||
|
||||
echo "Running remote smoke checks"
|
||||
ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
from urllib import request
|
||||
|
||||
checks = [
|
||||
('http://127.0.0.1:8000/health', 200),
|
||||
('http://127.0.0.1:3000/login', 200),
|
||||
]
|
||||
|
||||
for url, expected in checks:
|
||||
with request.urlopen(url, timeout=20) as response:
|
||||
if response.status != expected:
|
||||
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||
print(url, response.status)
|
||||
PY
|
||||
"
|
||||
|
||||
echo "Deployment completed successfully"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||
deploy_user="${DEPLOY_USER:-zak}"
|
||||
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
|
||||
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
|
||||
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
mkdir -p '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
cp -a \"\$path\" \"\${backup_root}/\"
|
||||
fi
|
||||
done
|
||||
tar -xf - -C '${deploy_path}'
|
||||
|
||||
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
|
||||
cp '${prod_path}/.env' '${deploy_path}/.env'
|
||||
fi
|
||||
|
||||
mkdir -p '${deploy_path}/data'
|
||||
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
|
||||
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
|
||||
fi
|
||||
|
||||
cd '${deploy_path}'
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml build
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml up -d
|
||||
"
|
||||
|
||||
echo "Running remote beta smoke checks"
|
||||
ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
from urllib import request
|
||||
|
||||
checks = [
|
||||
('http://127.0.0.1:8100/health', 200),
|
||||
('http://${beta_frontend_bind}:3100/login', 200),
|
||||
]
|
||||
|
||||
for url, expected in checks:
|
||||
with request.urlopen(url, timeout=20) as response:
|
||||
if response.status != expected:
|
||||
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||
print(url, response.status)
|
||||
PY
|
||||
"
|
||||
|
||||
echo "Beta deployment completed successfully"
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CSV_PATH = ROOT / "data" / "jellyfin_users_normalized.csv"
|
||||
DEFAULT_DB_PATH = ROOT / "data" / "magent.db"
|
||||
|
||||
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate or "@" not in candidate:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
|
||||
with csv_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
return [dict(row) for row in csv.DictReader(handle)]
|
||||
|
||||
|
||||
def _ensure_email_column(conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
|
||||
ON users (email COLLATE NOCASE)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _lookup_user(conn: sqlite3.Connection, username: str) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT id, username, email
|
||||
FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE WHEN username = ? THEN 0 ELSE 1 END,
|
||||
id ASC
|
||||
""",
|
||||
(username, username),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def import_user_emails(csv_path: Path, db_path: Path) -> dict[str, object]:
|
||||
rows = _load_rows(csv_path)
|
||||
username_counts = Counter(
|
||||
str(row.get("Username") or "").strip().lower()
|
||||
for row in rows
|
||||
if str(row.get("Username") or "").strip()
|
||||
)
|
||||
duplicate_usernames = {
|
||||
username for username, count in username_counts.items() if username and count > 1
|
||||
}
|
||||
|
||||
summary: dict[str, object] = {
|
||||
"csv_path": str(csv_path),
|
||||
"db_path": str(db_path),
|
||||
"source_rows": len(rows),
|
||||
"updated": 0,
|
||||
"unchanged": 0,
|
||||
"missing_email": [],
|
||||
"missing_user": [],
|
||||
"duplicate_source_username": [],
|
||||
}
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
_ensure_email_column(conn)
|
||||
|
||||
for row in rows:
|
||||
username = str(row.get("Username") or "").strip()
|
||||
if not username:
|
||||
continue
|
||||
username_key = username.lower()
|
||||
if username_key in duplicate_usernames:
|
||||
cast_list = summary["duplicate_source_username"]
|
||||
assert isinstance(cast_list, list)
|
||||
if username not in cast_list:
|
||||
cast_list.append(username)
|
||||
continue
|
||||
|
||||
email = _normalize_email(row.get("Email"))
|
||||
if not email:
|
||||
cast_list = summary["missing_email"]
|
||||
assert isinstance(cast_list, list)
|
||||
cast_list.append(username)
|
||||
continue
|
||||
|
||||
matches = _lookup_user(conn, username)
|
||||
if not matches:
|
||||
cast_list = summary["missing_user"]
|
||||
assert isinstance(cast_list, list)
|
||||
cast_list.append(username)
|
||||
continue
|
||||
|
||||
current_emails = {
|
||||
normalized.lower()
|
||||
for normalized in (_normalize_email(row["email"]) for row in matches)
|
||||
if normalized
|
||||
}
|
||||
if current_emails == {email.lower()}:
|
||||
summary["unchanged"] = int(summary["unchanged"]) + 1
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET email = ?
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
""",
|
||||
(email, username),
|
||||
)
|
||||
summary["updated"] = int(summary["updated"]) + 1
|
||||
|
||||
summary["missing_email_count"] = len(summary["missing_email"]) # type: ignore[arg-type]
|
||||
summary["missing_user_count"] = len(summary["missing_user"]) # type: ignore[arg-type]
|
||||
summary["duplicate_source_username_count"] = len(summary["duplicate_source_username"]) # type: ignore[arg-type]
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Import user email addresses into Magent users.")
|
||||
parser.add_argument(
|
||||
"csv_path",
|
||||
nargs="?",
|
||||
default=str(DEFAULT_CSV_PATH),
|
||||
help="CSV file containing Username and Email columns",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db-path",
|
||||
default=str(DEFAULT_DB_PATH),
|
||||
help="Path to the Magent SQLite database",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
summary = import_user_emails(Path(args.csv_path), Path(args.db_path))
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,316 @@
|
||||
param(
|
||||
[string]$CommitMessage,
|
||||
[switch]$SkipCommit,
|
||||
[switch]$SkipDiscord
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||
Set-Location $repoRoot
|
||||
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
$script:CurrentStep = "initializing"
|
||||
|
||||
function Write-TextFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
|
||||
$fullPath = Join-Path $repoRoot $Path
|
||||
$normalized = $Content -replace "`r`n", "`n"
|
||||
[System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$CommandName)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$CommandName failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Read-TextFile {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$fullPath = Join-Path $repoRoot $Path
|
||||
return [System.IO.File]::ReadAllText($fullPath)
|
||||
}
|
||||
|
||||
function Get-EnvFileValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $match.Matches[0].Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
function Get-DiscordWebhookUrl {
|
||||
$candidateNames = @(
|
||||
"PROCESS_DISCORD_WEBHOOK_URL",
|
||||
"MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
|
||||
"DISCORD_WEBHOOK_URL"
|
||||
)
|
||||
|
||||
foreach ($name in $candidateNames) {
|
||||
$value = [System.Environment]::GetEnvironmentVariable($name)
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($name in $candidateNames) {
|
||||
$value = Get-EnvFileValue -Path ".env" -Name $name
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
return $value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
$configPath = Join-Path $repoRoot "backend/app/config.py"
|
||||
if (Test-Path $configPath) {
|
||||
$configContent = Read-TextFile -Path "backend/app/config.py"
|
||||
$match = [regex]::Match(
|
||||
$configContent,
|
||||
'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
|
||||
[System.Text.RegularExpressions.RegexOptions]::Singleline
|
||||
)
|
||||
if ($match.Success) {
|
||||
return $match.Groups[1].Value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Send-DiscordUpdate {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Title,
|
||||
[Parameter(Mandatory = $true)][string]$Body
|
||||
)
|
||||
|
||||
if ($SkipDiscord) {
|
||||
Write-Host "Skipping Discord notification."
|
||||
return
|
||||
}
|
||||
|
||||
$webhookUrl = Get-DiscordWebhookUrl
|
||||
if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
|
||||
Write-Warning "Discord webhook not configured for Process 1."
|
||||
return
|
||||
}
|
||||
|
||||
$content = "**$Title**`n$Body"
|
||||
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
|
||||
}
|
||||
|
||||
function Get-BuildNumber {
|
||||
$current = ""
|
||||
if (Test-Path ".build_number") {
|
||||
$current = (Get-Content ".build_number" -Raw).Trim()
|
||||
}
|
||||
|
||||
$candidate = Get-Date
|
||||
$buildNumber = $candidate.ToString("ddMMyyHHmm")
|
||||
if ($buildNumber -eq $current) {
|
||||
$buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
|
||||
}
|
||||
|
||||
return $buildNumber
|
||||
}
|
||||
|
||||
function Wait-ForHttp {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Url,
|
||||
[int]$Attempts = 30,
|
||||
[int]$DelaySeconds = 2
|
||||
)
|
||||
|
||||
$lastError = $null
|
||||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $Url -TimeoutSec 10
|
||||
} catch {
|
||||
$lastError = $_
|
||||
Start-Sleep -Seconds $DelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastError
|
||||
}
|
||||
|
||||
function Get-GitChangelogLiteral {
|
||||
$scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py"
|
||||
$literal = python $scriptPath --python-literal
|
||||
Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal"
|
||||
return ($literal | Out-String).Trim()
|
||||
}
|
||||
|
||||
function Update-BuildFiles {
|
||||
param([Parameter(Mandatory = $true)][string]$BuildNumber)
|
||||
|
||||
Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
|
||||
|
||||
$changelogLiteral = Get-GitChangelogLiteral
|
||||
$buildInfoContent = @(
|
||||
"BUILD_NUMBER = `"$BuildNumber`""
|
||||
"CHANGELOG = $changelogLiteral"
|
||||
""
|
||||
) -join "`n"
|
||||
Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent
|
||||
|
||||
$envPath = Join-Path $repoRoot ".env"
|
||||
if (Test-Path $envPath) {
|
||||
$envContent = Read-TextFile -Path ".env"
|
||||
if ($envContent -match '^BUILD_NUMBER=.*$') {
|
||||
$updatedEnv = [regex]::Replace(
|
||||
$envContent,
|
||||
'^BUILD_NUMBER=.*$',
|
||||
"BUILD_NUMBER=$BuildNumber",
|
||||
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
||||
)
|
||||
} else {
|
||||
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
|
||||
}
|
||||
Write-TextFile -Path ".env" -Content $updatedEnv
|
||||
}
|
||||
|
||||
$packageJson = Read-TextFile -Path "frontend/package.json"
|
||||
$packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"')
|
||||
$updatedPackageJson = $packageJsonRegex.Replace(
|
||||
$packageJson,
|
||||
"`"version`": `"$BuildNumber`"",
|
||||
1
|
||||
)
|
||||
Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson
|
||||
|
||||
$packageLock = Read-TextFile -Path "frontend/package-lock.json"
|
||||
$packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"')
|
||||
$updatedPackageLock = $packageLockVersionRegex.Replace(
|
||||
$packageLock,
|
||||
"`"version`": `"$BuildNumber`"",
|
||||
1
|
||||
)
|
||||
$packageLockRootRegex = [regex]::new(
|
||||
'(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"',
|
||||
[System.Text.RegularExpressions.RegexOptions]::Singleline
|
||||
)
|
||||
$updatedPackageLock = $packageLockRootRegex.Replace(
|
||||
$updatedPackageLock,
|
||||
'$1"' + $BuildNumber + '"',
|
||||
1
|
||||
)
|
||||
Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock
|
||||
}
|
||||
|
||||
function Get-ChangedFilesSummary {
|
||||
$files = git diff --cached --name-only
|
||||
if (-not $files) {
|
||||
return "No staged files"
|
||||
}
|
||||
|
||||
$count = ($files | Measure-Object).Count
|
||||
$sample = $files | Select-Object -First 8
|
||||
$summary = ($sample -join ", ")
|
||||
if ($count -gt $sample.Count) {
|
||||
$summary = "$summary, +$($count - $sample.Count) more"
|
||||
}
|
||||
|
||||
return "$count files: $summary"
|
||||
}
|
||||
|
||||
$buildNumber = $null
|
||||
$branch = $null
|
||||
$commit = $null
|
||||
$publicInfo = $null
|
||||
$changedFiles = "No staged files"
|
||||
|
||||
try {
|
||||
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
||||
$buildNumber = Get-BuildNumber
|
||||
Write-Host "Process 1 build number: $buildNumber"
|
||||
|
||||
$script:CurrentStep = "updating build metadata"
|
||||
Update-BuildFiles -BuildNumber $buildNumber
|
||||
|
||||
$script:CurrentStep = "running backend quality gate"
|
||||
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1")
|
||||
Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1"
|
||||
|
||||
$script:CurrentStep = "rebuilding local docker stack"
|
||||
docker compose up -d --build
|
||||
Assert-LastExitCode -CommandName "docker compose up -d --build"
|
||||
|
||||
$script:CurrentStep = "verifying backend health"
|
||||
$health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
|
||||
if ($health.status -ne "ok") {
|
||||
throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
$script:CurrentStep = "verifying public build metadata"
|
||||
$publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
|
||||
if ($publicInfo.buildNumber -ne $buildNumber) {
|
||||
throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
|
||||
}
|
||||
|
||||
$script:CurrentStep = "committing changes"
|
||||
git add -A
|
||||
Assert-LastExitCode -CommandName "git add -A"
|
||||
$changedFiles = Get-ChangedFilesSummary
|
||||
if ((git status --short).Trim()) {
|
||||
if (-not $SkipCommit) {
|
||||
if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
|
||||
$CommitMessage = "Process 1 build $buildNumber"
|
||||
}
|
||||
git commit -m $CommitMessage
|
||||
Assert-LastExitCode -CommandName "git commit"
|
||||
}
|
||||
}
|
||||
|
||||
$commit = (git rev-parse --short HEAD).Trim()
|
||||
|
||||
$body = @(
|
||||
"Build: $buildNumber"
|
||||
"Branch: $branch"
|
||||
"Commit: $commit"
|
||||
"Health: ok"
|
||||
"Public build: $($publicInfo.buildNumber)"
|
||||
"Changes: $changedFiles"
|
||||
) -join "`n"
|
||||
Send-DiscordUpdate -Title "Process 1 complete" -Body $body
|
||||
|
||||
Write-Host "Process 1 completed successfully."
|
||||
} catch {
|
||||
$failureCommit = ""
|
||||
try {
|
||||
$failureCommit = (git rev-parse --short HEAD).Trim()
|
||||
} catch {
|
||||
$failureCommit = "unknown"
|
||||
}
|
||||
|
||||
$failureBody = @(
|
||||
"Build: $buildNumber"
|
||||
"Branch: $branch"
|
||||
"Commit: $failureCommit"
|
||||
"Step: $script:CurrentStep"
|
||||
"Error: $($_.Exception.Message)"
|
||||
) -join "`n"
|
||||
|
||||
try {
|
||||
Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
|
||||
} catch {
|
||||
Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
throw
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_git_changelog(repo_root: Path, max_count: int) -> str:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
f"--max-count={max_count}",
|
||||
"--date=short",
|
||||
"--pretty=format:%cs|%s",
|
||||
"--",
|
||||
".",
|
||||
],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--max-count", type=int, default=200)
|
||||
parser.add_argument("--python-literal", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
changelog = build_git_changelog(repo_root, max_count=args.max_count)
|
||||
if args.python_literal:
|
||||
print(repr(changelog))
|
||||
else:
|
||||
sys.stdout.write(changelog)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,60 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||
Set-Location $repoRoot
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([Parameter(Mandatory = $true)][string]$CommandName)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$CommandName failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PythonCommand {
|
||||
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPython) {
|
||||
return $venvPython
|
||||
}
|
||||
return "python"
|
||||
}
|
||||
|
||||
function Ensure-PythonModule {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$ModuleName,
|
||||
[Parameter(Mandatory = $true)][string]$PackageName
|
||||
)
|
||||
|
||||
& $PythonExe -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('$ModuleName') else 1)"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Installing missing Python package: $PackageName"
|
||||
& $PythonExe -m pip install $PackageName
|
||||
Assert-LastExitCode -CommandName "python -m pip install $PackageName"
|
||||
}
|
||||
|
||||
$pythonExe = Get-PythonCommand
|
||||
|
||||
Write-Host "Installing backend Python requirements"
|
||||
& $pythonExe -m pip install -r (Join-Path $repoRoot "backend\requirements.txt")
|
||||
Assert-LastExitCode -CommandName "python -m pip install -r backend/requirements.txt"
|
||||
|
||||
Write-Host "Running Python dependency integrity check"
|
||||
& $pythonExe -m pip check
|
||||
Assert-LastExitCode -CommandName "python -m pip check"
|
||||
|
||||
Ensure-PythonModule -PythonExe $pythonExe -ModuleName "pip_audit" -PackageName "pip-audit"
|
||||
|
||||
Write-Host "Running Python vulnerability scan"
|
||||
& $pythonExe -m pip_audit -r (Join-Path $repoRoot "backend\requirements.txt") --progress-spinner off --desc
|
||||
Assert-LastExitCode -CommandName "python -m pip_audit"
|
||||
|
||||
Write-Host "Running backend unit tests"
|
||||
& $pythonExe -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||
Assert-LastExitCode -CommandName "python -m unittest discover"
|
||||
|
||||
Write-Host "Backend quality gate passed"
|
||||
Reference in New Issue
Block a user