efb37fce7a
This reverts commit01fc2582b6, reversing changes made to821e96e011.
420 lines
19 KiB
YAML
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
|