diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index db27b2a..3456242 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -18,6 +18,14 @@ on: push: branches: [main] workflow_dispatch: + +# Never let two deploys race the same Coolify app. Unlike CI we do NOT cancel an +# in-flight deploy (it may already be restarting the container); the newest +# commit still wins because Coolify builds from the branch head. +concurrency: + group: deploy-0pt-main + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8127607..f8b363f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,10 +17,16 @@ name: CI (SIAX Cloud) # push/PR/weekly scanner (this file is push/PR-only). on: push: - branches: ["**"] + branches: ["main"] pull_request: workflow_dispatch: +# One run per ref; a branch push that also updates a PR used to start two full +# quality runs (and, on main, two deploy chains). Cancel the superseded one. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-latest @@ -30,6 +36,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Enable corepack + pin pnpm@9 (pnpm-lock.yaml is lockfileVersion 9.0) run: | corepack enable @@ -126,6 +134,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Enable corepack + pin pnpm@9 run: | corepack enable diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index f8a9fd5..9492ffd 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -8,14 +8,21 @@ on: schedule: - cron: "0 4 * * *" # daily 04:00 UTC +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml - name: Enable corepack + pin pnpm@9 run: | corepack enable @@ -34,6 +41,7 @@ jobs: security: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Install Trivy diff --git a/.gitea/workflows/prod-smoke.yml b/.gitea/workflows/prod-smoke.yml new file mode 100644 index 0000000..4357861 --- /dev/null +++ b/.gitea/workflows/prod-smoke.yml @@ -0,0 +1,77 @@ +name: Production smoke (0pt.app) + +# Automated end-to-end delivery verification. Every 30 minutes (and on demand) +# this checks that the live service is actually ready and that the running +# container is the commit that main points at — so a green deploy is never +# confused with a healthy, current service. +on: + workflow_dispatch: + schedule: + - cron: "*/30 * * * *" + +concurrency: + group: prod-smoke + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Readiness (retry on a fresh deploy) + run: | + set -eu + for attempt in 1 2 3 4 5 6; do + body="$(curl -fsS --max-time 15 https://0pt.app/api/ready || true)" + echo "attempt $attempt: ${body:-}" + if printf '%s' "$body" | grep -q '"ready":true'; then + exit 0 + fi + sleep 20 + done + echo "::error::0pt.app/api/ready never reported ready:true" + exit 1 + + - name: Health envelope + run: | + set -eu + body="$(curl -fsS --max-time 15 https://0pt.app/api/health)" + echo "$body" + printf '%s' "$body" | grep -q '"db":"ok"' || { echo "::error::database is not ok"; exit 1; } + printf '%s' "$body" | grep -q '"stripe":"ok"' || { echo "::error::stripe is not ok"; exit 1; } + + - name: Deployed commit equals main + env: + SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }} + run: | + set -eu + version="$(curl -fsS --max-time 15 https://0pt.app/api/version)" + echo "$version" + running="$(printf '%s' "$version" | sed -n 's/.*"commit":"\([0-9a-f]*\)".*/\1/p')" + [ -n "$running" ] || { echo "::error::/api/version returned no commit"; exit 1; } + if [ -z "${SIAX_CI_TOKEN:-}" ]; then + echo "::warning::SIAX_CI_TOKEN is not set — cannot compare with main; commit $running is live" + exit 0 + fi + expected="$(curl -fsS --max-time 15 \ + -H "Authorization: token ${SIAX_CI_TOKEN}" \ + https://git.siax.io/api/v1/repos/admin/0pt/commits/main \ + | sed -n 's/.*"sha":"\([0-9a-f]*\)".*/\1/p')" + echo "running=$running expected(main)=$expected" + [ -n "$expected" ] || { echo "::error::could not read main SHA from Gitea"; exit 1; } + [ "$running" = "$expected" ] || { + echo "::error::production is $running but main is $expected — deploy did not land" + exit 1 + } + + - name: Document pipeline surface responds (auth required, never 404/5xx) + run: | + set -eu + for path in /api/0pt/documents/upload /api/0pt/documents/structure /api/0pt/documents/models /api/0pt/credits/balance; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "https://0pt.app${path}")" + echo "$path -> $code" + case "$code" in + 401|405) ;; # unauthenticated or method guard: the route exists + *) echo "::error::$path returned $code (expected 401/405)"; exit 1 ;; + esac + done diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b86713d..ca96919 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -39,6 +39,11 @@ on: branches: [main] workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + env: IMAGE_REPO: git.cloud.siax.io/sax3l/0pt DOCKERFILE: Dockerfile diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index f652a66..c5ce869 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -19,12 +19,16 @@ name: Security Scan (SIAX Cloud) # and docs/compliance/GOLDEN-STANDARD-REPORT.md control C6 for full context. on: push: - branches: ["**"] + branches: ["main"] pull_request: workflow_dispatch: schedule: - cron: "0 5 * * 1" # weekly Monday re-scan + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true jobs: security: runs-on: ubuntu-latest diff --git a/app/api/version/route.ts b/app/api/version/route.ts new file mode 100644 index 0000000..3bbd809 --- /dev/null +++ b/app/api/version/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/version — deployment identity for automated verification. + * + * Returns only non-sensitive build identity (no env values, no config). The + * scheduled prod-smoke workflow compares `commit` with the current main SHA so + * "the container is up" is never confused with "the intended commit is live". + */ +export async function GET() { + const commit = + process.env.SOURCE_COMMIT ?? process.env.GIT_COMMIT_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? null; + return NextResponse.json( + { + service: '0pt-web', + commit, + built_at: process.env.BUILD_TIME ?? null, + }, + { headers: { 'cache-control': 'no-store' } }, + ); +}