Files
ERP-core/.gitea/workflows/ci-cd.yml
T
ImanThiyanga 8e472ac996
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
initialising for ci-cd pipeline
2026-09-16 11:53:55 +05:30

420 lines
19 KiB
YAML

# 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