initialising for ci-cd pipeline
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

This commit is contained in:
2026-09-16 11:53:55 +05:30
parent 666979cf22
commit 8e472ac996
48 changed files with 2224 additions and 62 deletions
+33
View File
@@ -0,0 +1,33 @@
# ERP-Core RELEASE environment (deployed from the production branch) — docs/40-DEPLOYMENT.md §4.
#
# Install: cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/
# apachectl configtest && systemctl reload httpd
# certbot --apache -d erp.hexdive.com
# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https.
# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS.
# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on.
<VirtualHost *:80>
ServerName erp.hexdive.com
ProxyRequests Off
ProxyPreserveHost On
ProxyTimeout 120
RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
Header always set X-Content-Type-Options "nosniff"
# HR documents are capped at 10 MB by the API (FileStorage:MaxSizeBytes); allow a little overhead.
LimitRequestBody 12582912
# Order matters — the first matching ProxyPass wins, so /api/ must come before /.
# /health and /swagger are deliberately NOT proxied (they fall through to Next and 404).
ProxyPass /api/ http://127.0.0.1:5220/api/
ProxyPassReverse /api/ http://127.0.0.1:5220/api/
ProxyPass / http://127.0.0.1:3020/
ProxyPassReverse / http://127.0.0.1:3020/
ErrorLog /var/log/httpd/erp.hexdive.com-error.log
CustomLog /var/log/httpd/erp.hexdive.com-access.log combined
</VirtualHost>
+34
View File
@@ -0,0 +1,34 @@
# /etc/erpcore/erpcore.env — settings + secrets for erpcore.service (production, erp.hexdive.com).
#
# Create once (docs/40-DEPLOYMENT.md, "Active" A.2):
# install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env
# then replace every CHANGE_ME / <port>. Never commit real values.
#
# Format rules (systemd EnvironmentFile, not a shell):
# • comments only on their own line — a trailing "# ..." becomes part of the value
# • ':' in a .NET config key becomes '__'
# • wrap a value in double quotes if it contains spaces, quotes or backslashes
# After editing: Actions → "CI/CD - ERP Core" → Run workflow with deploy_backend.
# (The test environment is on hold — see deploy/on-hold/README.md.)
ASPNETCORE_ENVIRONMENT=Production
# Must match deploy/apache/erp.hexdive.com.conf and BACKEND_PORT in .gitea/workflows/ci-cd.yml
ASPNETCORE_URLS=http://127.0.0.1:5220
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Port=5432;Database=ERPCore;Username=erpcore;Password=CHANGE_ME
AuthHex__BaseUrl=http://127.0.0.1:<authhex-port>
# Door policy — set the real AuthHex user type / role codes.
Auth__RequiredUserTypeCode=
Auth__RequiredRoleCode=
GeneralLedgerService__BaseUrl=http://127.0.0.1:<gl-port>/api/v1/
GeneralLedgerService__ApiKey=CHANGE_ME
# MUST be absolute and OUTSIDE /home/erp/core/publish — every backend deploy empties that folder.
# The workflow's "Guard data paths" step refuses to deploy otherwise.
FileStorage__RootPath=/var/lib/erpcore/hr-documents
LogFile__Path=/var/log/erpcore/erpcore-.log
# Production never seeds demo data.
Seed__DemoData=false
+34
View File
@@ -0,0 +1,34 @@
# On hold — test environment + two-environment pipeline
Only `erp.hexdive.com` exists today, so since 2026-09-15 production is deployed Fitness-style by the
`Build & Deploy` job in `.gitea/workflows/ci-cd.yml`, the one workflow that also runs the PR checks
(docs/40-DEPLOYMENT.md, "Active"). Everything in this folder is the
**test + release** design built on 2026-09-14, kept intact but **inactive**: Gitea only runs workflows
from `.gitea/workflows/`, and nothing active calls these scripts.
| File | What it did |
|---|---|
| `deploy.two-env.yml` | The two-environment workflow: push to `Dev` → test, push to `production` → release; tree-hash change detection against the last successful deploy; nightly Playwright against test; Gitea release per production deploy |
| `scripts/detect.sh` | Decided which side to deploy by comparing tree hashes with `/opt/erpcore/<env>/.deployed` |
| `scripts/build-backend.sh`, `scripts/build-frontend.sh` | Capped container builds into versioned `/opt/erpcore/releases/backend/<hash>` and `erp-web:<hash>`, reused across environments |
| `scripts/deploy-backend.sh`, `scripts/deploy-frontend.sh` | Backup → symlink switch → health check → automatic rollback to the previous release |
| `scripts/release.sh` | Gitea release + `release-YYYY.MM.DD-N` tag with rollback hashes |
| `scripts/e2e.sh` | Playwright suite against test |
| `scripts/prune.sh` | Weekly cleanup of releases, images, backups, reports |
| `apache/test-erp.hexdive.com.conf` | Apache vhost for the test environment |
| `systemd/erpcore@.service` | Unit template for `erpcore@test` / `erpcore@release` |
The full design is documented under **"On hold: two-environment design"** in `docs/40-DEPLOYMENT.md`.
## Bringing it back
1. Create `test-erp.hexdive.com` (DNS + certificate) and do the server bootstrap in that doc section.
2. Move `scripts/*.sh` back to `deploy/scripts/` (they source `common.sh` from their own folder), `apache/` and `systemd/` files back
to `deploy/apache/` and `deploy/systemd/`, and `deploy.two-env.yml` to `.gitea/workflows/deploy.yml`.
Then **delete the `build-and-deploy` job from `.gitea/workflows/ci-cd.yml`** (or two workflows will deploy production), remove
`closed` from its `pull_request` types and the `workflow_dispatch` block, and give it back a per-PR concurrency group
(`ci-${{ github.event.pull_request.number }}`, `cancel-in-progress: true`) — at that point it is the old `ci.yml`.
`deploy.two-env.yml` triggers on `push`, so it cannot share one file with the PR checks the way the production-only job does.
3. In `deploy/scripts/common.sh`, uncomment the `test)` and `release)` branches of `load_env` and the `Dev) echo test` mapping.
4. In the checks' `frontend` job, point it back at `build-frontend.sh` with `WITH_CHECKS=true` if you want PR-built images reused by the deploy.
5. Decide how to migrate production from `/home/erp/core/publish` + `erpcore.service` to the versioned
`/opt/erpcore/release/backend/current` layout + `erpcore@release` (or adjust the scripts to keep the production layout).
@@ -0,0 +1,35 @@
# ERP-Core TEST environment (deployed from the Dev branch) — docs/40-DEPLOYMENT.md §4.
#
# Install: cp deploy/apache/test-erp.hexdive.com.conf /etc/httpd/conf.d/
# apachectl configtest && systemctl reload httpd
# certbot --apache -d test-erp.hexdive.com
# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https.
# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS.
# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on.
<VirtualHost *:80>
ServerName test-erp.hexdive.com
ProxyRequests Off
ProxyPreserveHost On
ProxyTimeout 120
RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
Header always set X-Content-Type-Options "nosniff"
# Test data must never end up in search results.
Header always set X-Robots-Tag "noindex, nofollow"
# HR documents are capped at 10 MB by the API (FileStorage:MaxSizeBytes); allow a little overhead.
LimitRequestBody 12582912
# Order matters — the first matching ProxyPass wins, so /api/ must come before /.
# /health and /swagger are deliberately NOT proxied (they fall through to Next and 404).
ProxyPass /api/ http://127.0.0.1:5230/api/
ProxyPassReverse /api/ http://127.0.0.1:5230/api/
ProxyPass / http://127.0.0.1:3030/
ProxyPassReverse / http://127.0.0.1:3030/
ErrorLog /var/log/httpd/test-erp.hexdive.com-error.log
CustomLog /var/log/httpd/test-erp.hexdive.com-access.log combined
</VirtualHost>
+205
View File
@@ -0,0 +1,205 @@
# Deployments — one workflow for both environments (docs/40-DEPLOYMENT.md §6):
# push to Dev → test (https://test-erp.hexdive.com)
# push to production → release (https://erp.hexdive.com)
# Only the side(s) whose folder changed since the last successful deploy are built and deployed,
# backend (and its migrations) always before frontend. Nightly/manual Playwright runs hit test only.
name: Deploy
on:
push:
branches: [Dev, production]
schedule:
- cron: "30 20 * * *" # 02:00 Asia/Colombo. Gitea reads schedules from the default branch (production).
workflow_dispatch:
inputs:
force_backend:
description: "Redeploy the backend even if unchanged (e.g. after editing /etc/erpcore/<env>.env)"
type: boolean
default: false
force_frontend:
description: "Redeploy the frontend even if unchanged"
type: boolean
default: false
backend_hash:
description: "Rollback: backend tree hash from a previous Gitea release (leave empty normally)"
type: string
default: ""
frontend_hash:
description: "Rollback: frontend tree hash from a previous Gitea release (leave empty normally)"
type: string
default: ""
run_e2e:
description: "Run the Playwright suite against test after this run"
type: boolean
default: false
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false # never interrupt a deploy (or a migration) half-way
jobs:
detect:
name: detect
if: github.event_name != 'schedule'
runs-on: linux
outputs:
env: ${{ steps.detect.outputs.env }}
backend: ${{ steps.detect.outputs.backend }}
frontend: ${{ steps.detect.outputs.frontend }}
backend_hash: ${{ steps.detect.outputs.backend_hash }}
frontend_hash: ${{ steps.detect.outputs.frontend_hash }}
backend_rollback: ${{ steps.detect.outputs.backend_rollback }}
frontend_rollback: ${{ steps.detect.outputs.frontend_rollback }}
migrations_hash: ${{ steps.detect.outputs.migrations_hash }}
migrations_changed: ${{ steps.detect.outputs.migrations_changed }}
record_release: ${{ steps.detect.outputs.record_release }}
steps:
- name: Checkout pushed commit
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
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: Decide what to deploy
id: detect
env:
REF_NAME: ${{ github.ref_name }}
FORCE_BACKEND: ${{ inputs.force_backend }}
FORCE_FRONTEND: ${{ inputs.force_frontend }}
BACKEND_HASH_INPUT: ${{ inputs.backend_hash }}
FRONTEND_HASH_INPUT: ${{ inputs.frontend_hash }}
run: bash deploy/scripts/detect.sh
backend:
name: backend
needs: detect
if: needs.detect.outputs.backend == 'true'
runs-on: linux
outputs:
reused: ${{ steps.build.outputs.reused }}
steps:
- name: Checkout pushed commit
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
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 (or reuse) release
id: build
env:
BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }}
run: bash deploy/scripts/build-backend.sh
- name: Backup, switch, health check
env:
ERP_ENV: ${{ needs.detect.outputs.env }}
BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }}
IS_ROLLBACK: ${{ needs.detect.outputs.backend_rollback }}
MIGRATIONS_HASH: ${{ needs.detect.outputs.migrations_hash }}
MIGRATIONS_CHANGED: ${{ needs.detect.outputs.migrations_changed }}
run: bash deploy/scripts/deploy-backend.sh
frontend:
name: frontend
needs: [detect, backend]
# Runs after the backend succeeded or was skipped — a new UI must never meet an old API.
if: >-
always() && needs.detect.result == 'success' && needs.detect.outputs.frontend == 'true'
&& needs.backend.result != 'failure' && needs.backend.result != 'cancelled'
runs-on: linux
outputs:
reused: ${{ steps.build.outputs.reused }}
steps:
- name: Checkout pushed commit
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
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 (or reuse) image
id: build
env:
FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }}
run: bash deploy/scripts/build-frontend.sh
- name: Swap container, HTTP check
env:
ERP_ENV: ${{ needs.detect.outputs.env }}
FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }}
IS_ROLLBACK: ${{ needs.detect.outputs.frontend_rollback }}
run: bash deploy/scripts/deploy-frontend.sh
release:
name: release
needs: [detect, backend, frontend]
if: >-
always() && needs.detect.outputs.record_release == 'true'
&& needs.backend.result != 'failure' && needs.backend.result != 'cancelled'
&& needs.frontend.result != 'failure' && needs.frontend.result != 'cancelled'
runs-on: linux
steps:
- name: Checkout pushed commit
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
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: Create Gitea release
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }}
FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }}
MIGRATIONS_HASH: ${{ needs.detect.outputs.migrations_hash }}
MIGRATIONS_CHANGED: ${{ needs.detect.outputs.migrations_changed }}
BACKEND_RESULT: ${{ needs.backend.result }}
BACKEND_REUSED: ${{ needs.backend.outputs.reused }}
FRONTEND_RESULT: ${{ needs.frontend.result }}
FRONTEND_REUSED: ${{ needs.frontend.outputs.reused }}
run: bash deploy/scripts/release.sh
e2e:
name: e2e
needs: [backend, frontend]
if: >-
always() && (github.event_name == 'schedule' || format('{0}', inputs.run_e2e) == 'true')
&& needs.backend.result != 'failure' && needs.frontend.result != 'failure'
runs-on: linux
steps:
# Specs come from Dev: the test environment runs Dev, even when a schedule fires on production.
- name: Checkout Dev
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 \
"https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" Dev
git checkout -qf FETCH_HEAD && git clean -ffdxq
- name: Playwright against test
env:
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
run: bash deploy/scripts/e2e.sh
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# deploy.yml › backend — build (or reuse) the self-contained release for BACKEND_HASH into
# /opt/erpcore/releases/backend/<hash>. Releases are shared by test and release, so a
# Dev → production merge that carries the same Backend/ERPCore tree skips the build entirely.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${BACKEND_HASH:?BACKEND_HASH is required}"
target="$RELEASES_DIR/$BACKEND_HASH"
reuse_if_built() {
if [[ -x "$target/ERPCore" ]]; then
log "backend $BACKEND_HASH already built → reused ($target)"
set_output reused true
exit 0
fi
}
reuse_if_built
with_build_lock
reuse_if_built # another job may have built it while we waited for the lock
# Only the exact tree can be built. A rollback hash whose release was pruned cannot be recreated
# from a different checkout — fail instead of deploying something else under that name.
[[ "$(tree_hash "$BACKEND_PATH")" == "$BACKEND_HASH" ]] ||
fail "no release for $BACKEND_HASH, and this checkout's $BACKEND_PATH is a different tree (pruned rollback target?)"
mkdir -p "$RELEASES_DIR"
tmp="$target.tmp"
rm -rf "$tmp"
mkdir -p "$tmp"
z="$(selinux_mount_suffix)"
log "building backend $BACKEND_HASH (capped: 2 CPU / 1500 MB)"
docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m \
-e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 \
-v "$PWD/Backend:/src$z" \
-v "$tmp:/out$z" \
-v erp-nuget:/root/.nuget/packages \
-w /src "$DOTNET_SDK_IMAGE" \
dotnet publish ERPCore/ERPCore.csproj -c Release -r linux-x64 --self-contained true -o /out
[[ -x "$tmp/ERPCore" ]] || fail "publish finished but $tmp/ERPCore is missing"
# Root-owned and read-only for the service user: the app never writes into its own release
# (uploads and logs live under /var/lib/erpcore and /var/log/erpcore).
chown -R root:root "$tmp"
chmod -R u=rwX,go=rX "$tmp"
chmod 755 "$tmp/ERPCore"
mv "$tmp" "$target"
if command -v restorecon >/dev/null 2>&1; then restorecon -R "$target"; fi
log "backend release ready: $target"
set_output reused false
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# ci.yml › frontend and deploy.yml › frontend — build (or reuse) image erp-web:<FRONTEND_HASH>.
#
# `npm ci` + `next build` run in a capped node container; the image itself is runtime-only
# (Frontend/erp-system/Dockerfile). The PR check builds the same tag with WITH_CHECKS=true
# (type-check + lint first), so the deploy after a merge normally just reuses that image.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${FRONTEND_HASH:?FRONTEND_HASH is required}"
image="$WEB_IMAGE_REPO:$FRONTEND_HASH"
with_checks="${WITH_CHECKS:-false}"
app="$PWD/$FRONTEND_PATH"
checks="npx tsc --noEmit && npx eslint ."
image_exists() { docker image inspect "$image" >/dev/null 2>&1; }
image_checked() { [[ "$(docker image inspect -f '{{ index .Config.Labels "erp.checked" }}' "$image" 2>/dev/null)" == true ]]; }
# run_node <shell steps> — run steps in the frontend folder inside a capped node container.
run_node() {
local z
z="$(selinux_mount_suffix)"
docker run --rm --cpus=2 --cpu-shares=512 --memory=2500m \
-e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \
-v "$app:/app$z" \
-v erp-npm-cache:/root/.npm \
-v erp-next-cache:/app/.next/cache \
-w /app "$NODE_IMAGE" sh -ec "$1"
}
with_build_lock
if image_exists; then
if [[ "$with_checks" == true ]] && ! image_checked; then
# Same content was built earlier by a deploy (no checks) — the PR still has to pass them.
log "$image exists but was built without checks → running type-check + lint only"
run_node "npm ci --no-audit --no-fund && $checks"
fi
log "$image already built → reused"
set_output reused true
exit 0
fi
[[ "$(tree_hash "$FRONTEND_PATH")" == "$FRONTEND_HASH" ]] ||
fail "no image for $FRONTEND_HASH, and this checkout's $FRONTEND_PATH is a different tree (pruned rollback target?)"
steps="npm ci --no-audit --no-fund"
if [[ "$with_checks" == true ]]; then steps="$steps && $checks"; fi
steps="$steps && npm run build"
log "building frontend $FRONTEND_HASH (checks=$with_checks, capped: 2 CPU / 2500 MB)"
run_node "$steps"
[[ -f "$app/.next/standalone/server.js" ]] ||
fail "next build did not produce .next/standalone/server.js (is output: \"standalone\" set in next.config.ts?)"
docker build \
--label "erp.frontend-tree=$FRONTEND_HASH" \
--label "erp.source-sha=${GITHUB_SHA:-local}" \
--label "erp.checked=$with_checks" \
-t "$image" "$app"
log "frontend image ready: $image"
set_output reused false
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# deploy.yml › backend — switch an environment to BACKEND_HASH:
# pg_dump (policy) → stop → repoint `current` → start → wait for /health
# On failure the previous release is restored and the job fails (red).
# The DATABASE is never rolled back — hence add-only migrations (docs/40-DEPLOYMENT.md §8).
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${ERP_ENV:?ERP_ENV is required}" "${BACKEND_HASH:?BACKEND_HASH is required}"
load_env "$ERP_ENV"
is_rollback="${IS_ROLLBACK:-false}"
migrations_changed="${MIGRATIONS_CHANGED:-false}"
target="$RELEASES_DIR/$BACKEND_HASH"
[[ -x "$target/ERPCore" ]] || fail "backend release not found: $target"
backup_database() {
local pg_dump file
pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)"
[[ -x "$pg_dump" ]] || fail "pg_dump not found (install postgresql16 client tools or fix PATH)"
mkdir -p "$BACKUP_DIR"
file="$BACKUP_DIR/$ERP_ENV-$(date -u +%Y%m%dT%H%M%SZ)-${BACKEND_HASH:0:12}.dump"
log "backing up database $DB_NAME → $file"
runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" >"$file.partial"
mv "$file.partial" "$file"
}
# wait_healthy — succeeds once /health answers 200. EF migrations + seeding run BEFORE the app
# starts listening, so a slow start is normal. Only a crash (unit failed / auto-restarted) or the
# generous per-environment ceiling counts as failure: never cut a live migration on a tight timer.
wait_healthy() {
local deadline=$((SECONDS + HEALTH_TIMEOUT)) restarts_at_start state restarts
restarts_at_start="$(systemctl show -p NRestarts --value "$SERVICE")"
while ((SECONDS < deadline)); do
if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$BACKEND_PORT/health" 2>/dev/null; then
return 0
fi
state="$(systemctl show -p ActiveState --value "$SERVICE")"
restarts="$(systemctl show -p NRestarts --value "$SERVICE")"
if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then
warn "$SERVICE crashed during startup (state=$state, restarts $restarts_at_start → $restarts)"
return 1
fi
sleep 3
done
warn "$SERVICE was not healthy within ${HEALTH_TIMEOUT}s"
return 1
}
switch_to() {
systemctl stop "$SERVICE" || true
mkdir -p "$(dirname "$CURRENT_LINK")"
ln -sfn "$1" "$CURRENT_LINK"
systemctl reset-failed "$SERVICE" 2>/dev/null || true
systemctl start "$SERVICE" || warn "systemctl start $SERVICE returned an error"
}
if [[ "$is_rollback" != true ]] && [[ "$BACKUP_POLICY" == always || "$migrations_changed" == true ]]; then
backup_database
else
log "no database backup (policy=$BACKUP_POLICY, migrations changed=$migrations_changed, rollback=$is_rollback)"
fi
previous="$(readlink "$CURRENT_LINK" 2>/dev/null || true)"
log "deploying backend $BACKEND_HASH to $ERP_ENV (previous: ${previous:-none})"
switch_to "$target"
if wait_healthy; then
write_state backend "$BACKEND_HASH"
if [[ "$is_rollback" != true && -n "${MIGRATIONS_HASH:-}" ]]; then write_state migrations "$MIGRATIONS_HASH"; fi
if [[ "$is_rollback" == true ]]; then result=rollback; else result=deployed; fi
record_history backend "$BACKEND_HASH" "$result"
log "✅ backend $BACKEND_HASH is healthy on $ERP_ENV"
exit 0
fi
journalctl -u "$SERVICE" -n 80 --no-pager || true
record_history backend "$BACKEND_HASH" failed
if [[ -n "$previous" && "$previous" != "$target" && -x "$previous/ERPCore" ]]; then
warn "restoring previous release $previous"
switch_to "$previous"
if wait_healthy; then
log "previous release is serving again"
else
warn "the previous release is not healthy either — investigate now"
fi
else
warn "no previous release to restore; stopping $SERVICE"
systemctl stop "$SERVICE" || true
fi
fail "backend deploy of $BACKEND_HASH to $ERP_ENV failed"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# deploy.yml › frontend — replace the environment's web container with erp-web:<FRONTEND_HASH>
# and wait for it to serve /login. On failure the previously deployed image is started again
# and the job fails.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${ERP_ENV:?ERP_ENV is required}" "${FRONTEND_HASH:?FRONTEND_HASH is required}"
load_env "$ERP_ENV"
is_rollback="${IS_ROLLBACK:-false}"
docker image inspect "$WEB_IMAGE_REPO:$FRONTEND_HASH" >/dev/null 2>&1 ||
fail "image $WEB_IMAGE_REPO:$FRONTEND_HASH not found (pruned?)"
start_web() {
docker rm -f "$WEB_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$WEB_CONTAINER" \
-p "127.0.0.1:$WEB_PORT:3000" \
--memory=512m --cpus=1 \
--restart unless-stopped \
--label "erp.env=$ERP_ENV" \
"$WEB_IMAGE_REPO:$1" >/dev/null
}
wait_web() {
local deadline=$((SECONDS + 30))
while ((SECONDS < deadline)); do
if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$WEB_PORT/login" 2>/dev/null; then return 0; fi
if [[ "$(docker inspect -f '{{.State.Running}}' "$WEB_CONTAINER" 2>/dev/null)" != true ]]; then
warn "$WEB_CONTAINER is not running"
return 1
fi
sleep 2
done
warn "$WEB_CONTAINER did not serve /login within 30s"
return 1
}
previous="$(read_state frontend)"
log "deploying frontend $FRONTEND_HASH to $ERP_ENV (previous: ${previous:-none})"
start_web "$FRONTEND_HASH"
if wait_web; then
write_state frontend "$FRONTEND_HASH"
if [[ "$is_rollback" == true ]]; then result=rollback; else result=deployed; fi
record_history frontend "$FRONTEND_HASH" "$result"
log "✅ frontend $FRONTEND_HASH is serving on $ERP_ENV"
exit 0
fi
docker logs --tail 80 "$WEB_CONTAINER" || true
record_history frontend "$FRONTEND_HASH" failed
if [[ -n "$previous" && "$previous" != "$FRONTEND_HASH" ]] &&
docker image inspect "$WEB_IMAGE_REPO:$previous" >/dev/null 2>&1; then
warn "restoring previous frontend $previous"
start_web "$previous"
wait_web || warn "the previous frontend is not serving either — investigate now"
fi
fail "frontend deploy of $FRONTEND_HASH to $ERP_ENV failed"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# deploy.yml › detect — decide what this push deploys.
#
# A side deploys when its folder's tree hash differs from the hash last deployed SUCCESSFULLY to
# this environment (stored on the server in /opt/erpcore/<env>/.deployed). So:
# • docs / PROGRESS.md / smoke-script pushes deploy nothing,
# • a failed deploy is retried by the next run (its stored hash never moved),
# • force-pushes, multi-commit pushes and merges need no special handling.
# Manual runs can force a side (config change) or pin an older hash (rollback).
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${REF_NAME:?REF_NAME (the pushed branch) is required}"
env_name="$(env_for_branch "$REF_NAME")" ||
fail "branch '$REF_NAME' is not deployable (Dev → test, production → release)"
load_env "$env_name"
be_hash="$(tree_hash "$BACKEND_PATH")"
fe_hash="$(tree_hash "$FRONTEND_PATH")"
mig_hash="$(tree_hash "$MIGRATIONS_PATH")"
[[ -n "$be_hash" && -n "$fe_hash" ]] || fail "could not compute tree hashes — is this an ERP-core checkout?"
be_rollback=false
fe_rollback=false
if [[ -n "${BACKEND_HASH_INPUT:-}" ]]; then be_hash="$BACKEND_HASH_INPUT" && be_rollback=true; fi
if [[ -n "${FRONTEND_HASH_INPUT:-}" ]]; then fe_hash="$FRONTEND_HASH_INPUT" && fe_rollback=true; fi
be_deployed="$(read_state backend)"
fe_deployed="$(read_state frontend)"
be_changed=false
fe_changed=false
if [[ "$be_hash" != "$be_deployed" ]]; then be_changed=true; fi
if [[ "$fe_hash" != "$fe_deployed" ]]; then fe_changed=true; fi
deploy_backend=$be_changed
deploy_frontend=$fe_changed
if [[ "${FORCE_BACKEND:-}" == true ]]; then deploy_backend=true; fi
if [[ "${FORCE_FRONTEND:-}" == true ]]; then deploy_frontend=true; fi
migrations_changed=false
if ! $be_rollback && [[ "$mig_hash" != "$(read_state migrations)" ]]; then migrations_changed=true; fi
# A Gitea release records real content changes on release only — not forced restarts or
# rollbacks (those are in .deployed/history.log on the server).
record_release=false
if [[ "$env_name" == release ]] && ! $be_rollback && ! $fe_rollback && { $be_changed || $fe_changed; }; then
record_release=true
fi
log "environment $env_name — deployed now: backend ${be_deployed:-none}, frontend ${fe_deployed:-none}"
set_output env "$env_name"
set_output backend "$deploy_backend"
set_output frontend "$deploy_frontend"
set_output backend_hash "$be_hash"
set_output frontend_hash "$fe_hash"
set_output backend_rollback "$be_rollback"
set_output frontend_rollback "$fe_rollback"
set_output migrations_hash "$mig_hash"
set_output migrations_changed "$migrations_changed"
set_output record_release "$record_release"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# deploy.yml › e2e — Playwright suite against the TEST environment. Never release: the suite
# creates vendors, items and POs on every run.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${E2E_ADMIN_EMAIL:?secrets.E2E_ADMIN_EMAIL is required}" "${E2E_ADMIN_PASSWORD:?secrets.E2E_ADMIN_PASSWORD is required}"
load_env test
base_url="${E2E_BASE_URL:-$PUBLIC_URL}"
# The container's browsers must match the locked @playwright/test version exactly.
pw_version="$(grep -A1 '"node_modules/@playwright/test"' "$E2E_PATH/package-lock.json" |
sed -n 's/.*"version": "\([^"]*\)".*/\1/p')"
[[ -n "$pw_version" ]] || fail "could not read the locked @playwright/test version"
image="mcr.microsoft.com/playwright:v$pw_version-noble"
z="$(selinux_mount_suffix)"
with_build_lock # Chromium is the second-heaviest workload on the box — never overlap a build
log "E2E against $base_url with $image (capped: 2 CPU / 1500 MB)"
status=0
docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m --ipc=host \
-e CI=true -e E2E_BASE_URL="$base_url" -e E2E_API_URL="$base_url" \
-e E2E_ADMIN_EMAIL -e E2E_ADMIN_PASSWORD \
-v "$PWD/$E2E_PATH:/e2e$z" -w /e2e "$image" \
bash -ec 'npm ci --no-audit --no-fund && npx playwright test' || status=$?
if [[ -d "$E2E_PATH/playwright-report" ]]; then
report_dir="$E2E_REPORTS_DIR/$(date -u +%Y%m%dT%H%M%SZ)-run${GITHUB_RUN_NUMBER:-manual}"
mkdir -p "$report_dir"
cp -r "$E2E_PATH/playwright-report" "$report_dir/"
if [[ -d "$E2E_PATH/test-results" ]]; then cp -r "$E2E_PATH/test-results" "$report_dir/"; fi
log "report saved on the server: $report_dir/playwright-report/index.html"
fi
exit "$status"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Weekly housekeeping — root cron on the VPS (docs/40-DEPLOYMENT.md §4):
# 0 4 * * 0 /opt/erpcore/prune.sh >> /var/log/erpcore/prune.log 2>&1
# Keeps the newest KEEP backend releases and frontend images (anything currently deployed in
# either environment is always kept), prunes build cache, old database backups and E2E reports.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
KEEP="${KEEP:-5}"
with_build_lock # never delete a release or image that a running job is producing
live=()
for e in test release; do
load_env "$e"
live+=("$(read_state backend)" "$(read_state frontend)")
if [[ -L "$CURRENT_LINK" ]]; then live+=("$(basename "$(readlink "$CURRENT_LINK")")"); fi
done
is_live() {
local h
for h in "${live[@]}"; do
if [[ -n "$h" && "$h" == "$1" ]]; then return 0; fi
done
return 1
}
if [[ -d "$RELEASES_DIR" ]]; then
n=0
while IFS= read -r dir; do
name="$(basename "$dir")"
if [[ "$name" == *.tmp ]]; then
log "removing abandoned build $name"
rm -rf "$dir"
continue
fi
n=$((n + 1))
if ((n > KEEP)) && ! is_live "$name"; then
log "removing backend release $name"
rm -rf "$dir"
fi
done < <(find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | cut -d' ' -f2-)
fi
n=0
while IFS= read -r tag; do
if [[ -z "$tag" || "$tag" == "<none>" ]]; then continue; fi
n=$((n + 1))
if ((n > KEEP)) && ! is_live "$tag"; then
log "removing image $WEB_IMAGE_REPO:$tag"
docker rmi "$WEB_IMAGE_REPO:$tag" >/dev/null || warn "could not remove $WEB_IMAGE_REPO:$tag"
fi
done < <(docker image ls "$WEB_IMAGE_REPO" --format '{{.Tag}}') # newest first
docker image prune -f >/dev/null
docker builder prune -f --filter until=168h >/dev/null
find "$BACKUP_DIR" -maxdepth 1 -name 'test-*.dump' -mtime +7 -delete 2>/dev/null || true
find "$BACKUP_DIR" -maxdepth 1 -name 'release-*.dump' -mtime +30 -delete 2>/dev/null || true
find "$E2E_REPORTS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true
log "prune complete"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# deploy.yml › release — record a successful release deploy as a Gitea release + tag
# (release-YYYY.MM.DD-N on the deployed commit), including the tree hashes to roll back to.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${GITHUB_SHA:?}" "${ERP_GIT_TOKEN:?}" "${BACKEND_HASH:?}" "${FRONTEND_HASH:?}"
command -v python3 >/dev/null 2>&1 || fail "python3 is required to build the release JSON"
load_env release
# side_result <job result> <reused> — human-readable outcome for the notes table.
side_result() {
case "$1" in
success) if [[ "$2" == true ]]; then echo "deployed (build reused)"; else echo "deployed (built)"; fi ;;
skipped) echo "unchanged" ;;
*) echo "$1" ;;
esac
}
today="$(TZ=Asia/Colombo date +%Y.%m.%d)"
mapfile -t tags < <(git_remote ls-remote --tags --refs "$REPO_GIT_URL" 'release-*' |
awk '{ sub("refs/tags/", "", $2); print $2 }' | sort -V)
previous="${tags[${#tags[@]}-1]:-}"
same_day=0
for t in "${tags[@]}"; do [[ "$t" == "release-$today-"* ]] && same_day=$((same_day + 1)); done
tag="release-$today-$((same_day + 1))"
# Pull requests since the previous release: fetch only the history after that tag.
if [[ -n "$previous" ]]; then
git_remote fetch -q --shallow-exclude="$previous" "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" ||
git_remote fetch -q --deepen=200 "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" || true
else
git_remote fetch -q --deepen=200 "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" || true
fi
prs="$(git log --merges --format='- %s' "$GITHUB_SHA" 2>/dev/null | grep -i ' into Dev' || true)"
body="Deployed to **release** ($PUBLIC_URL) from \`${GITHUB_SHA:0:12}\`.
| Side | Result | Tree hash — rollback input |
|---|---|---|
| Backend | $(side_result "${BACKEND_RESULT:-}" "${BACKEND_REUSED:-}") | \`$BACKEND_HASH\` |
| Frontend | $(side_result "${FRONTEND_RESULT:-}" "${FRONTEND_REUSED:-}") | \`$FRONTEND_HASH\` |
| Migrations | $([[ "${MIGRATIONS_CHANGED:-}" == true ]] && echo "changed (backup taken)" || echo "unchanged") | \`${MIGRATIONS_HASH:-}\` |
### Pull requests since ${previous:-the beginning}
${prs:-_(none found)_}
Rollback: Actions → **Deploy** → Run workflow on \`production\` with \`backend_hash\` / \`frontend_hash\` from a previous release (docs/40-DEPLOYMENT.md §9)."
payload="$(TAG="$tag" SHA="$GITHUB_SHA" BODY="$body" python3 -c '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "target_commitish": os.environ["SHA"],
"name": os.environ["TAG"], "body": os.environ["BODY"]}))')"
log "creating Gitea release $tag"
curl -sS --fail-with-body -o /dev/null -X POST \
-H "Authorization: token $ERP_GIT_TOKEN" -H "Content-Type: application/json" \
--data "$payload" "$GITEA_URL/api/v1/repos/$REPO_SLUG/releases"
log "✅ $GITEA_URL/$REPO_SLUG/releases/tag/$tag"
+40
View File
@@ -0,0 +1,40 @@
# ERP-Core API — one template for both environments: erpcore@test and erpcore@release.
#
# Install once (docs/40-DEPLOYMENT.md §4):
# cp deploy/systemd/erpcore@.service /etc/systemd/system/
# systemctl daemon-reload && systemctl enable erpcore@test erpcore@release
# The pipeline never edits this file: a deploy only repoints /opt/erpcore/<env>/backend/current
# and restarts the unit (deploy/scripts/deploy-backend.sh).
[Unit]
Description=ERP-Core API (%i)
After=network-online.target postgresql-16.service
Wants=network-online.target
# Stop hammering the database if a release crash-loops; the deploy script rolls back anyway.
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=erpcore
Group=erpcore
WorkingDirectory=/opt/erpcore/%i/backend/current
ExecStart=/opt/erpcore/%i/backend/current/ERPCore
# Secrets + per-environment settings (template: deploy/env/erpcore.env.example).
EnvironmentFile=/etc/erpcore/%i.env
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
# Shared 8 GB VPS: a runaway instance must not take Gitea/Postgres down with it.
MemoryMax=768M
# Hardening. The app writes only to /var/lib/erpcore/%i and /var/log/erpcore/%i (owned by erpcore).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# ci-cd.yml › backend — build, unit tests and the "entity changed but no migration" check.
# The dotnet work runs inside a capped SDK container. ASPNETCORE_ENVIRONMENT=Development is set
# explicitly for the EF step: `dotnet ef` boots Program.cs, and outside Development the startup
# config check would (correctly) refuse to run without the deployment secrets CI doesn't have.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
z="$(selinux_mount_suffix)"
with_build_lock
log "backend checks (capped: 2 CPU / 1500 MB)"
# The inner script is single-quoted on purpose: it expands inside the container, not here.
# shellcheck disable=SC2016
docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m \
-e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 -e EF_VERSION="$DOTNET_EF_VERSION" \
-e ASPNETCORE_ENVIRONMENT=Development \
-v "$PWD/Backend:/src$z" \
-v erp-nuget:/root/.nuget/packages \
-v erp-dotnet-tools:/root/.dotnet/tools \
-w /src "$DOTNET_SDK_IMAGE" bash -ec '
dotnet restore ERPCore.sln
dotnet build ERPCore.sln -c Release --no-restore
dotnet test ERPCore.Tests/ERPCore.Tests.csproj -c Release --no-build
# Program.cs suppresses PendingModelChangesWarning at runtime, so this is the only guard
# against an entity/configuration change merged without its migration. --no-build is safe
# here: the assembly was built from this exact source two commands ago.
dotnet tool update --global dotnet-ef --version "$EF_VERSION" >/dev/null
export PATH="$PATH:/root/.dotnet/tools"
dotnet ef migrations has-pending-model-changes \
--project ERPCore/ERPCore.csproj --configuration Release --no-build
'
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# ci-cd.yml › detect — which checks does this pull request need?
# Compares the PR head's folder tree hashes with the target branch's. Tree hashes are content
# addressed, so this needs no merge-base and works on depth-1 clones.
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${BASE_REF:?BASE_REF (the PR target branch) is required}"
base="refs/remotes/origin/$BASE_REF"
git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base"
# changed <path...> — succeeds if any path differs between the target branch and HEAD.
changed() {
local p
for p in "$@"; do
if [[ "$(tree_hash "$p")" != "$(tree_hash "$p" "$base")" ]]; then return 0; fi
done
return 1
}
flag() { if changed "$@"; then echo true; else echo false; fi; }
set_output backend "$(flag "$BACKEND_PATH" "$BACKEND_TESTS_PATH" Backend/ERPCore.sln)"
set_output frontend "$(flag "$FRONTEND_PATH")"
set_output e2e "$(flag "$E2E_PATH")"
set_output scripts "$(flag deploy .gitea)"
set_output frontend_hash "$(tree_hash "$FRONTEND_PATH")"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Shared settings + helpers for the ERP-Core CI/CD scripts. Sourced by the other scripts, never
# run directly. Runbook: docs/40-DEPLOYMENT.md (values here must match the server bootstrap, §4).
# shellcheck disable=SC2034 # variables are consumed by the scripts that source this file
set -euo pipefail
# ── Repository (folders whose git tree hash decides what is checked / built / deployed) ─────
BACKEND_PATH="Backend/ERPCore"
BACKEND_TESTS_PATH="Backend/ERPCore.Tests"
MIGRATIONS_PATH="Backend/ERPCore/Migrations"
FRONTEND_PATH="Frontend/erp-system"
E2E_PATH="Testing/e2e"
GITEA_URL="${GITEA_URL:-https://gitea.hexdive.com}"
REPO_SLUG="New_REP_SYSTEM/ERP-core"
REPO_GIT_URL="$GITEA_URL/$REPO_SLUG.git"
# ── Server layout of the ON-HOLD two-environment design (deploy/on-hold/). Active production does
# not use these paths — see .gitea/workflows/ci-cd.yml (erpcore.service, /home/erp/core/publish).
# ERP_ROOT still hosts the build lock taken by ci-backend.sh. ─────────────────────────────────
ERP_ROOT="${ERP_ROOT:-/opt/erpcore}"
RELEASES_DIR="$ERP_ROOT/releases/backend"
BUILD_LOCK="$ERP_ROOT/.build.lock"
BACKUP_DIR="${ERP_BACKUP_DIR:-/var/backups/erpcore}"
E2E_REPORTS_DIR="${ERP_E2E_REPORTS_DIR:-/var/lib/erpcore/e2e-reports}"
# ── Build images (builds run in CPU/memory-capped containers on the shared 8 GB VPS) ───────
DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:10.0"
NODE_IMAGE="node:24-alpine"
WEB_IMAGE_REPO="erp-web"
DOTNET_EF_VERSION="10.0.9" # keep in step with Microsoft.EntityFrameworkCore.* in ERPCore.csproj
log() { printf '[erp] %s\n' "$*"; }
warn() { printf '[erp] WARNING: %s\n' "$*" >&2; }
fail() {
printf '[erp] ERROR: %s\n' "$*" >&2
exit 1
}
# load_env <test|release> — per-environment values. Ports, unit/container names and database
# names must match the server (systemd unit, Apache vhosts, /etc/erpcore/<env>.env).
# While both branches are on hold every call fails, so the lines after the case are unreachable.
# shellcheck disable=SC2317
load_env() {
ERP_ENV="$1"
case "$ERP_ENV" in
# ON HOLD — test environment (deploy/on-hold/README.md, docs/40-DEPLOYMENT.md "On hold").
# test)
# SERVICE="erpcore@test"
# BACKEND_PORT=5230
# WEB_CONTAINER="erp-web-test"
# WEB_PORT=3030
# DB_NAME="ERPCoreStaging"
# BACKUP_POLICY="on-migration"
# HEALTH_TIMEOUT=90
# PUBLIC_URL="https://test-erp.hexdive.com"
# ;;
# ON HOLD — the two-environment layout for release (/opt/erpcore, erpcore@release). NOT how
# production runs today: the active deploy is .gitea/workflows/ci-cd.yml → erpcore.service +
# /home/erp/core/publish. Nothing active calls load_env.
# release)
# SERVICE="erpcore@release"
# BACKEND_PORT=5220
# WEB_CONTAINER="erp-web"
# WEB_PORT=3020
# DB_NAME="ERPCore"
# BACKUP_POLICY="always"
# HEALTH_TIMEOUT=300
# PUBLIC_URL="https://erp.hexdive.com"
# ;;
*) fail "unknown environment '$ERP_ENV' (expected test or release)" ;;
esac
ENV_DIR="$ERP_ROOT/$ERP_ENV"
STATE_DIR="$ENV_DIR/.deployed"
CURRENT_LINK="$ENV_DIR/backend/current"
}
# env_for_branch <branch> — the environment a push to <branch> deploys to.
env_for_branch() {
case "$1" in
# ON HOLD — test environment: Dev) echo test ;;
production) echo release ;;
*) return 1 ;;
esac
}
# tree_hash <path> [rev] — git tree (or blob) id of a path. Identical folder content gives an
# identical hash no matter which commit, branch or merge produced it — that is what lets a
# Dev → production release reuse the build test already made. Empty if the path is absent.
tree_hash() { git rev-parse --verify --quiet "${2:-HEAD}:$1" 2>/dev/null || true; }
read_state() { cat "$STATE_DIR/$1" 2>/dev/null || true; }
write_state() {
mkdir -p "$STATE_DIR"
printf '%s\n' "$2" >"$STATE_DIR/$1.tmp"
mv -f "$STATE_DIR/$1.tmp" "$STATE_DIR/$1"
}
# record_history <side> <hash> <result> — append-only audit of every deploy attempt.
record_history() {
mkdir -p "$STATE_DIR"
printf '%s\t%s\t%s\t%s\tsha=%s\trun=%s\n' "$(date -u +%FT%TZ)" "$1" "$2" "$3" \
"${GITHUB_SHA:-manual}" "${GITHUB_RUN_NUMBER:-}" >>"$STATE_DIR/history.log"
}
# set_output <name> <value> — Gitea Actions step output (only logged when run by hand).
set_output() {
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT"; fi
log "$1=$2"
}
# with_build_lock — serialize heavy work on this host. The runner's capacity is 1 today; this
# keeps two jobs from writing the same releases/<hash>.tmp or image tag if that is ever raised.
with_build_lock() {
mkdir -p "$ERP_ROOT"
exec 9>"$BUILD_LOCK"
flock 9
}
# git_remote <git args...> — run git with the ERPSECRET token as an HTTP header (never echoed,
# never written to .git/config).
git_remote() {
: "${ERP_GIT_TOKEN:?ERP_GIT_TOKEN (secrets.ERPSECRET) is required}"
local basic
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
git -c "http.extraHeader=Authorization: Basic $basic" "$@"
}
# selinux_mount_suffix — ":z" relabels bind mounts on SELinux hosts (AlmaLinux) so containers
# can read them; empty elsewhere (e.g. a developer laptop).
selinux_mount_suffix() {
if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then echo ":z"; fi
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# ci-cd.yml › backend — flag destructive EF migrations introduced by this pull request.
#
# A failed deploy rolls the backend binaries back but never the database, so every release must
# leave the PREVIOUS backend working on the NEW schema: add tables/columns now, drop or rename
# them in a later release (expand → contract, docs/40-DEPLOYMENT.md §8).
# into Dev → warning
# into production → failure, unless the PR title contains [allow-destructive-migration]
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
: "${BASE_REF:?BASE_REF (the PR target branch) is required}"
base="refs/remotes/origin/$BASE_REF"
git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base"
# Two-dot tree diff: needs only the two commits, not their history (depth-1 friendly).
mapfile -t files < <(
git diff --name-only --diff-filter=AM "$base" HEAD -- "$MIGRATIONS_PATH" |
grep -E '\.cs$' | grep -vE '\.Designer\.cs$|ModelSnapshot\.cs$' || true
)
if ((${#files[@]} == 0)); then
log "no new or changed migrations"
exit 0
fi
hits=""
for f in "${files[@]}"; do
# Only Up() matters — every Down() legitimately drops what its Up() created.
found="$(awk '
/override void Up\(/ { up = 1 }
/override void Down\(/ { up = 0 }
up && /migrationBuilder\.(DropTable|DropColumn|RenameColumn|RenameTable|DropSchema|DropSequence)\(/ {
printf " %s:%d: %s\n", FILENAME, FNR, $0
}' "$f")"
hits+="$found"
done
if [[ -z "$hits" ]]; then
log "checked ${#files[@]} migration file(s): additive only"
exit 0
fi
printf '%s\n' "$hits"
msg="destructive operation in a migration's Up() — keep the previous release working on the new schema: expand now, contract in a later release (docs/40-DEPLOYMENT.md §8)"
if [[ "$BASE_REF" == production ]]; then
if [[ "${PR_TITLE:-}" == *"[allow-destructive-migration]"* ]]; then
warn "$msg — allowed by the [allow-destructive-migration] PR title override"
exit 0
fi
fail "$msg"
fi
warn "$msg"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# ci-cd.yml › Build & Deploy › Verify backend — wait until erpcore.service answers /health, and fail the job otherwise.
#
# EF migrations + seeding run BEFORE the app starts listening, so a slow start is normal. Only a
# crash (unit failed, or systemd auto-restarted it) or the ceiling counts as failure — a crashed
# service fails within seconds, and a slow but live migration is never cut short by a tight timer.
#
# Env: SERVICE (erpcore.service) · BACKEND_PORT (5220) · HEALTH_TIMEOUT seconds (300)
# RESTARTS_AT_START — NRestarts captured right after `systemctl start` (the Start step exports it)
set -euo pipefail
service="${SERVICE:-erpcore.service}"
port="${BACKEND_PORT:-5220}"
timeout="${HEALTH_TIMEOUT:-300}"
restarts_at_start="${RESTARTS_AT_START:-$(systemctl show -p NRestarts --value "$service")}"
show_journal() { journalctl -u "$service" -n 80 --no-pager || true; }
deadline=$((SECONDS + timeout))
while ((SECONDS < deadline)); do
if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$port/health" 2>/dev/null; then
echo "✅ $service is healthy (after ${SECONDS}s)"
exit 0
fi
state="$(systemctl show -p ActiveState --value "$service")"
restarts="$(systemctl show -p NRestarts --value "$service")"
if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then
echo "❌ $service crashed during startup (state=$state, restarts $restarts_at_start → $restarts)" >&2
show_journal
exit 1
fi
sleep 3
done
echo "❌ $service did not answer /health within ${timeout}s (still starting? DB reachable?)" >&2
show_journal
exit 1
+38
View File
@@ -0,0 +1,38 @@
# ERP-Core API — production (erp.hexdive.com).
#
# Install once (docs/40-DEPLOYMENT.md, "Active" A.2):
# cp deploy/systemd/erpcore.service /etc/systemd/system/
# systemctl daemon-reload && systemctl enable erpcore.service
# .gitea/workflows/ci-cd.yml stops it, replaces /home/erp/core/publish, then starts it again.
[Unit]
Description=ERP-Core API (production)
After=network-online.target postgresql-16.service
Wants=network-online.target
# Stop hammering the database if a release crash-loops.
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=erp
Group=erp
WorkingDirectory=/home/erp/core/publish
ExecStart=/home/erp/core/publish/ERPCore
# Secrets + settings (template: deploy/env/erpcore.env.example).
EnvironmentFile=/etc/erpcore/erpcore.env
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres down with it.
MemoryMax=768M
# Hardening. No ProtectHome: the app itself lives under /home/erp.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target