2a7a44cc27
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
510 lines
24 KiB
YAML
510 lines
24 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 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" <<EOF
|
|
[Service]
|
|
Environment="ASPNETCORE_URLS=http://127.0.0.1:$BACKEND_PORT"
|
|
EOF
|
|
chmod 600 "$conf"
|
|
systemctl daemon-reload
|
|
# Idempotent: creates the multi-user.target.wants symlink so the app survives a reboot.
|
|
systemctl enable "$SERVICE"
|
|
echo "✅ unit + drop-in installed ($(grep -c '^Environment=' "$conf") settings; values not printed)"
|
|
|
|
# Data dirs must exist and belong to the service user (Serilog fails silently otherwise).
|
|
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STORAGE_ROOT" "$(dirname "$LOG_PATH")"
|
|
|
|
- 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"
|
|
|
|
# Written AFTER "Fix ownership" so it keeps mode 640 (root:erpuser) instead of being
|
|
# flattened to 644 — it holds the DB password and the GL API key.
|
|
- name: Write appsettings.Production.json
|
|
if: steps.changes.outputs.backend == 'true'
|
|
env:
|
|
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
|
|
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
|
|
run: |
|
|
# Deployed config lives in appsettings.Production.json, generated here from this
|
|
# workflow's env: block + the Gitea secrets. It is NOT in git (.gitignore ignores
|
|
# appsettings.*.json) and is rewritten on every backend deploy, which is why the
|
|
# publish folder being wiped does not matter. It MERGES over appsettings.json, so
|
|
# Auth:RsaPublicKeyXml, Issuer, Audience and FileStorage:MaxSizeBytes still come
|
|
# from the committed base file.
|
|
conf="$PUBLISH_DIR/appsettings.Production.json"
|
|
# python3 emits the JSON so a password containing " or \ is escaped correctly.
|
|
python3 - > "$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
|