# 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 editing /etc/erpcore/erpcore.env, 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: erp BACKEND_PORT: "5220" ENV_FILE: /etc/erpcore/erpcore.env DB_NAME: ERPCore BACKUP_DIR: /var/backups/erpcore WEB_IMAGE: erp-web:latest WEB_CONTAINER: erp-web WEB_PORT: "3020" 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: Guard data paths if: steps.changes.outputs.backend == 'true' run: | # "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere. [ -r "$ENV_FILE" ] || { echo "❌ $ENV_FILE not found"; exit 1; } publish="$(realpath -m "$PUBLISH_DIR")" for key in FileStorage__RootPath LogFile__Path; do value="$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1 | tr -d '"')" [ -n "$value" ] || { echo "❌ $key is not set in $ENV_FILE"; exit 1; } case "$value" in /*) ;; *) echo "❌ $key=$value is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;; esac case "$(realpath -m "$value")/" in "$publish"/*) echo "❌ $key=$value is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;; esac echo "ok $key=$value" done - name: Stop service if: steps.changes.outputs.backend == 'true' run: systemctl stop "$SERVICE" || true - name: Copy to deploy folder if: steps.changes.outputs.backend == 'true' run: | mkdir -p "$PUBLISH_DIR" rm -rf "${PUBLISH_DIR:?}"/* cp -r "$BUILD_DIR"/. "$PUBLISH_DIR"/ - name: Fix ownership and permissions if: steps.changes.outputs.backend == 'true' run: | chown -R "$SERVICE_USER:$SERVICE_USER" "$PUBLISH_DIR" find "$PUBLISH_DIR" -type d -exec chmod 755 {} + find "$PUBLISH_DIR" -type f -exec chmod 644 {} + chmod +x "$PUBLISH_DIR/ERPCore" - 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