# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy # (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active". # # Which jobs run for which event: # PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck / # scripts (only the sides the PR touches) → ci-ok # PR merged into production → build-and-deploy (checks are skipped) # PR merged into Dev, or closed unmerged → nothing (every job skipped) # Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes # # Checks: `ci-ok` is the single required status check for branch protection. It passes when every # check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*. # # Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions → # start → verify), plus: only the side whose folder changed in the PR is deployed, the database is # backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback). # Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow". # The test environment is on hold (deploy/on-hold/). # # Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but # the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with # ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a # trusted team member. name: CI/CD - ERP Core on: pull_request: branches: - Dev - production types: - opened - synchronize - reopened - closed workflow_dispatch: inputs: deploy_backend: description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)" type: boolean default: false deploy_frontend: description: "Deploy the frontend" type: boolean default: false # One group for the whole file, never cancelled: a deploy (or its migration) must not be interrupted # by a PR push. Superseded check runs therefore queue instead of being cancelled; with the runner at # capacity 1 they would run one after another anyway. concurrency: group: erp-core-cicd cancel-in-progress: false jobs: # ════════════════════════════ Pull-request checks ════════════════════════════ detect: name: detect if: github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: linux outputs: backend: ${{ steps.detect.outputs.backend }} frontend: ${{ steps.detect.outputs.frontend }} e2e: ${{ steps.detect.outputs.e2e }} scripts: ${{ steps.detect.outputs.scripts }} steps: - name: Checkout PR head env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} SHA: ${{ github.event.pull_request.head.sha }} REF: refs/pull/${{ github.event.pull_request.number }}/head run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" git init -q . git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" git checkout -qf "$SHA" && git clean -ffdxq git log -1 --format='checked out %h %s' - name: Detect changed sides id: detect env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} BASE_REF: ${{ github.base_ref }} run: bash deploy/scripts/ci-detect.sh backend: name: backend needs: detect if: needs.detect.outputs.backend == 'true' runs-on: linux steps: - name: Checkout PR head env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} SHA: ${{ github.event.pull_request.head.sha }} REF: refs/pull/${{ github.event.pull_request.number }}/head run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" git init -q . git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" git checkout -qf "$SHA" && git clean -ffdxq - name: Build, test, pending model changes run: bash deploy/scripts/ci-backend.sh - name: Destructive migration scan env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} BASE_REF: ${{ github.base_ref }} PR_TITLE: ${{ github.event.pull_request.title }} run: bash deploy/scripts/migration-scan.sh frontend: name: frontend needs: detect if: needs.detect.outputs.frontend == 'true' runs-on: linux steps: - name: Checkout PR head env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} SHA: ${{ github.event.pull_request.head.sha }} REF: refs/pull/${{ github.event.pull_request.number }}/head run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" git init -q . git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" git checkout -qf "$SHA" && git clean -ffdxq # Checks only (no image): build-and-deploy builds its own image with `docker build` after the merge. - name: Type-check, lint, build run: | z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi docker run --rm --cpus=2 --memory=2500m \ -e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \ -v "$PWD/Frontend/erp-system:/app$z" -v erp-npm-cache:/root/.npm -w /app node:24-alpine \ sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit && npx eslint . && npm run build' e2e-typecheck: name: e2e-typecheck needs: detect if: needs.detect.outputs.e2e == 'true' runs-on: linux steps: - name: Checkout PR head env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} SHA: ${{ github.event.pull_request.head.sha }} REF: refs/pull/${{ github.event.pull_request.number }}/head run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" git init -q . git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" git checkout -qf "$SHA" && git clean -ffdxq - name: Type-check Playwright specs run: | z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi docker run --rm --cpus=1 --memory=768m \ -v "$PWD/Testing/e2e:/e2e$z" -v erp-npm-cache:/root/.npm -w /e2e node:24-alpine \ sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit' scripts: name: scripts needs: detect if: needs.detect.outputs.scripts == 'true' runs-on: linux steps: - name: Checkout PR head env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} SHA: ${{ github.event.pull_request.head.sha }} REF: refs/pull/${{ github.event.pull_request.number }}/head run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" git init -q . git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" git checkout -qf "$SHA" && git clean -ffdxq - name: bash -n + shellcheck run: | for f in deploy/scripts/*.sh; do bash -n "$f"; done z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi files=""; for f in deploy/scripts/*.sh; do files="$files /mnt/${f#deploy/}"; done # shellcheck disable=SC2086 docker run --rm -v "$PWD/deploy:/mnt$z" koalaman/shellcheck:stable \ --severity=warning --external-sources --source-path=SCRIPTDIR $files ci-ok: name: ci-ok needs: [detect, backend, frontend, e2e-typecheck, scripts] if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: linux steps: - name: All required checks passed or were skipped env: RESULTS: >- detect=${{ needs.detect.result }} backend=${{ needs.backend.result }} frontend=${{ needs.frontend.result }} e2e-typecheck=${{ needs.e2e-typecheck.result }} scripts=${{ needs.scripts.result }} BASE_REF: ${{ github.base_ref }} HEAD_REF: ${{ github.head_ref }} run: | failed=0 for r in $RESULTS; do case "${r#*=}" in success|skipped) echo "ok $r" ;; *) echo "FAILED $r"; failed=1 ;; esac done if [ "$BASE_REF" = production ]; then case "$HEAD_REF" in Dev|hotfix/*) ;; *) echo "PRs into production must come from Dev or hotfix/* (this one is from '$HEAD_REF')"; failed=1 ;; esac fi exit "$failed" # ════════════════════════════ Production deploy ════════════════════════════ # No `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge, so the # deploy would never run. The pre-merge gate is branch protection requiring ci-ok. build-and-deploy: name: Build & Deploy runs-on: linux # base.ref matters: without it a PR merged into Dev would deploy production. if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'production') env: PUBLISH_DIR: /home/erp/core/publish BUILD_DIR: /tmp/erp-deploy SERVICE: erpcore.service SERVICE_USER: erpuser BACKEND_PORT: "5220" DB_NAME: ERPCore DB_USER: erpcore BACKUP_DIR: /var/backups/erpcore WEB_IMAGE: erp-web:latest WEB_CONTAINER: erp-web WEB_PORT: "3020" # ── Backend runtime config ──────────────────────────────────────────────────────────── # There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the # "Install unit + write config" step writes them into a systemd drop-in on every backend # deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend. # The two secrets (DB password, GL API key) come from Gitea secrets, never from this file. AUTHHEX_BASE_URL: "http://127.0.0.1:5433" GL_BASE_URL: "http://127.0.0.1:5433/api/v1/" STORAGE_ROOT: /var/lib/erpcore/hr-documents LOG_PATH: /var/log/erpcore/erpcore-.log SEED_DEMO_DATA: "false" # Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to # restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs). AUTH_REQUIRED_USER_TYPE: "" AUTH_REQUIRED_ROLE: "" steps: - name: Checkout code env: ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" fetch() { git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$1"; } git init -q . # The exact merge commit, not the branch tip (a second merge may already have landed). if [ -n "$MERGE_SHA" ] && fetch "$MERGE_SHA"; then :; else fetch production; fi git checkout -qf FETCH_HEAD && git clean -ffdxq # production as it was before this PR — the detect step compares against it. if [ -n "$BASE_SHA" ]; then fetch "$BASE_SHA" || echo "could not fetch base $BASE_SHA — both sides will deploy"; fi git log -1 --format='Deploying %h %s' - name: Detect changes id: changes env: EVENT: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} INPUT_BACKEND: ${{ inputs.deploy_backend }} INPUT_FRONTEND: ${{ inputs.deploy_frontend }} run: | # A folder's git tree hash changes only when something inside it changed, so comparing # production-before-the-PR with the merge result works for merge, squash and rebase merges. changed() { if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo true; return; fi now="$(git rev-parse -q --verify "HEAD:$1" || true)" before="$(git rev-parse -q --verify "$BASE_SHA:$1" || true)" if [ "$now" != "$before" ]; then echo true; else echo false; fi } if [ "$EVENT" = workflow_dispatch ]; then backend="${INPUT_BACKEND:-false}" frontend="${INPUT_FRONTEND:-false}" migrations="$backend" # unknown on a manual run → take the backup else backend="$(changed Backend/ERPCore)" frontend="$(changed Frontend/erp-system)" migrations="$(changed Backend/ERPCore/Migrations)" fi { echo "backend=$backend" echo "frontend=$frontend" echo "migrations=$migrations" } >> "$GITHUB_OUTPUT" echo "backend=$backend frontend=$frontend migrations=$migrations" if [ "$backend" != true ] && [ "$frontend" != true ]; then echo "Nothing to deploy."; fi # ── Backend ────────────────────────────────────────────────────────────────────────────── - name: Restore if: steps.changes.outputs.backend == 'true' run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64 - name: Publish if: steps.changes.outputs.backend == 'true' run: | rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" dotnet publish Backend/ERPCore/ERPCore.csproj \ -c Release \ -r linux-x64 \ --self-contained true \ --no-restore \ -o "$BUILD_DIR" - name: Backup database if: steps.changes.outputs.backend == 'true' && steps.changes.outputs.migrations == 'true' run: | mkdir -p "$BACKUP_DIR" pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)" file="$BACKUP_DIR/$DB_NAME-$(date -u +%Y%m%dT%H%M%SZ).dump" runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" > "$file.partial" mv "$file.partial" "$file" echo "Backup: $file" find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete - name: Install unit + write config if: steps.changes.outputs.backend == 'true' env: DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }} GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }} run: | # Config lives in this workflow's env: block, not in a file on the server. It is written # to a systemd drop-in here, before the service is stopped, so a bad value fails the job # while the OLD release is still serving. case "$AUTHHEX_BASE_URL$GL_BASE_URL" in *CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;; esac [ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; } [ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; } case "$DB_PASSWORD" in *";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;; esac # "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere. publish="$(realpath -m "$PUBLISH_DIR")" for path in "$STORAGE_ROOT" "$LOG_PATH"; do case "$path" in /*) ;; *) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;; esac case "$(realpath -m "$path")/" in "$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;; esac echo "ok $path" done # The unit is installed from the repo, so editing deploy/systemd/erpcore.service and # deploying is enough — no manual copy step on the server. install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service install -d -m 755 /etc/systemd/system/erpcore.service.d conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf # Values are quoted: systemd would otherwise split a password containing spaces. umask 077 # Only the listening URL. Everything else is app configuration and lands in # appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host # configuration, the one setting appsettings cannot reliably provide. cat > "$conf" < "$conf" <<'PY' import json, os e = os.environ print(json.dumps({ "ConnectionStrings": { "DefaultConnection": "Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s" % (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]), }, "AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]}, "GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]}, "Auth": { "RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"], "RequiredRoleCode": e["AUTH_REQUIRED_ROLE"], }, "FileStorage": {"RootPath": e["STORAGE_ROOT"]}, "LogFile": {"Path": e["LOG_PATH"]}, "Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"}, }, indent=2)) PY chown "root:$SERVICE_USER" "$conf" chmod 640 "$conf" python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf" echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)" - name: Start service if: steps.changes.outputs.backend == 'true' run: | systemctl reset-failed "$SERVICE" 2>/dev/null || true systemctl start "$SERVICE" || echo "systemctl start returned an error — the verify step shows why" # Baseline for the crash check in the verify step. echo "RESTARTS_AT_START=$(systemctl show -p NRestarts --value "$SERVICE")" >> "$GITHUB_ENV" - name: Verify backend if: steps.changes.outputs.backend == 'true' env: HEALTH_TIMEOUT: "300" # migrations run at startup; a crash still fails within seconds run: bash deploy/scripts/wait-healthy.sh # ── Frontend (after the backend: a failed backend step stops the job here) ───────────── - name: Build frontend image if: steps.changes.outputs.frontend == 'true' run: docker build -t "$WEB_IMAGE" Frontend/erp-system - name: Stop & remove existing container if: steps.changes.outputs.frontend == 'true' run: | docker stop "$WEB_CONTAINER" 2>/dev/null || true docker rm "$WEB_CONTAINER" 2>/dev/null || true - name: Start container if: steps.changes.outputs.frontend == 'true' run: | docker run -d \ --name "$WEB_CONTAINER" \ -p "127.0.0.1:$WEB_PORT:3000" \ --memory=512m \ --restart unless-stopped \ "$WEB_IMAGE" - name: Verify frontend if: steps.changes.outputs.frontend == 'true' run: | for _ in $(seq 1 15); do if curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; then echo "✅ Frontend serving on 127.0.0.1:$WEB_PORT" exit 0 fi sleep 2 done echo "❌ Frontend did not serve /login within 30s" docker ps -a --filter "name=$WEB_CONTAINER" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" docker logs --tail 50 "$WEB_CONTAINER" || true exit 1 - name: Remove old images if: steps.changes.outputs.frontend == 'true' run: | docker image prune -f docker builder prune -f --filter until=168h