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
+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"