Files
ci-actions/pipeline/scripts/lib.sh
T
2026-09-26 05:57:32 +00:00

236 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Shared helpers for the DealSage custom-app pipeline scripts. Sourced, not run.
#
# Conventions:
# * Every script is `set -euo pipefail` and sources this file first.
# * DealSage is reached only through DEALSAGE_FUNCTIONS_URL with the app's
# PUBLISH_TOKEN (appId-scoped, minted by DealSage). Nothing here holds a
# Supabase service key or an R2 account key.
# * Output meant for the job log goes through log(); machine-readable state
# is exchanged through $GITHUB_ENV / $GITHUB_OUTPUT like any other action.
log() { printf '::group::%s\n' "$*" >/dev/null; printf '\n== %s\n' "$*"; }
warn() { printf '::warning::%s\n' "$*"; }
# die MESSAGE — stop the job; the running step (see "Pipeline steps") fails with MESSAGE.
die() { printf '::error::%s\n' "$*"; step_fail_running "$*"; exit 1; }
# now_ms — wall clock in milliseconds (GNU date; falls back to seconds*1000
# where %N is not supported and comes back literally).
now_ms() {
local ms; ms="$(date +%s%3N 2>/dev/null || true)"
case "$ms" in ''|*[!0-9]*) echo $(( $(date +%s) * 1000 )) ;; *) echo "$ms" ;; esac
}
# iso_now — UTC timestamp for API payloads.
iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# require_env NAME... — fail loudly when a required variable is missing.
require_env() {
local missing=() v
for v in "$@"; do [ -n "${!v:-}" ] || missing+=("$v"); done
[ "${#missing[@]}" = 0 ] || die "${missing[*]} $([ "${#missing[@]}" = 1 ] && echo is || echo are) not set"
}
# json_escape STRING — JSON string literal (with quotes) via jq.
json_escape() { jq -Rn --arg s "$1" '$s'; }
# tail_file FILE [LINES] — last LINES lines of FILE (default 80), for log tails.
tail_file() { tail -n "${2:-80}" "$1" 2>/dev/null || true; }
# ds_post PATH JSON — POST JSON to a DealSage function with the publish token.
# Prints the response body; returns non-zero on HTTP failure (body still printed
# to stderr so the job log shows why). DS_POST_MAX_TIME caps the request in
# seconds (default 0: no cap).
ds_post() {
local path="$1" body="$2" out code
out="$(mktemp)"
code="$(curl -sS --max-time "${DS_POST_MAX_TIME:-0}" -o "$out" -w '%{http_code}' -X POST \
"${DEALSAGE_FUNCTIONS_URL%/}/${path}" \
-H "Authorization: Bearer ${PUBLISH_TOKEN}" \
-H "apikey: ${SUPABASE_ANON_KEY}" \
-H "content-type: application/json" \
--data-binary "$body")" || { cat "$out" >&2; rm -f "$out"; return 1; }
if [ "${code:0:1}" != "2" ]; then
printf 'DealSage %s returned HTTP %s: ' "$path" "$code" >&2; cat "$out" >&2; echo >&2
rm -f "$out"; return 1
fi
cat "$out"; rm -f "$out"
}
# git_context — export the commit/branch/author the pipeline is publishing.
# Gitea Actions provides the GITHUB_* variables; the event payload carries the
# head commit. Falls back to git itself (the checkout has the commit).
git_context() {
local ev="${GITHUB_EVENT_PATH:-}"
COMMIT_SHA="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}"
BRANCH="${GITHUB_REF_NAME:-}"
if [ -z "$BRANCH" ]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
fi
COMMIT_MESSAGE=""; COMMIT_AUTHOR=""
if [ -n "$ev" ] && [ -f "$ev" ]; then
COMMIT_MESSAGE="$(jq -r '.head_commit.message // .commits[-1].message // empty' "$ev" 2>/dev/null || true)"
COMMIT_AUTHOR="$(jq -r '.head_commit.author.name // .pusher.login // .pusher.username // empty' "$ev" 2>/dev/null || true)"
fi
[ -n "$COMMIT_MESSAGE" ] || COMMIT_MESSAGE="$(git log -1 --pretty=%B 2>/dev/null | head -c 2000 || true)"
[ -n "$COMMIT_AUTHOR" ] || COMMIT_AUTHOR="$(git log -1 --pretty=%an 2>/dev/null || echo "${GITHUB_ACTOR:-}")"
# First line only for the summary field; the full message travels separately.
COMMIT_SUBJECT="$(printf '%s' "$COMMIT_MESSAGE" | head -n 1 | head -c 200)"
export COMMIT_SHA BRANCH COMMIT_MESSAGE COMMIT_AUTHOR COMMIT_SUBJECT
}
# persist_env NAME VALUE — make a value visible to later composite steps.
persist_env() {
if [ -n "${GITHUB_ENV:-}" ]; then
# Heredoc form handles multi-line values safely.
{ printf '%s<<__DS_EOF__\n' "$1"; printf '%s\n' "$2"; printf '__DS_EOF__\n'; } >> "$GITHUB_ENV"
fi
export "$1=$2"
}
# summary MARKDOWN — append to the run's step summary when the runner supports it.
summary() { [ -n "${GITHUB_STEP_SUMMARY:-}" ] && printf '%s\n' "$*" >> "$GITHUB_STEP_SUMMARY" || true; }
# ---------------------------------------------------------------------------
# Build records (record-app-build): one row per pipeline run in DealSage, so a
# run that never reaches a version (a failed required check, a broken build)
# still shows up in the app hub. Best effort throughout: a DealSage outage must
# not fail the build.
# ---------------------------------------------------------------------------
# ci_run_id — Gitea's run id plus attempt, so a retried "start" is idempotent.
ci_run_id() {
if [ -n "${GITHUB_RUN_ID:-}" ]; then printf '%s-%s' "$GITHUB_RUN_ID" "${GITHUB_RUN_ATTEMPT:-1}"; fi
}
# report_build_start — POST start; persists BUILD_ID for the later steps.
report_build_start() {
local body resp id
body="$(jq -n --arg appId "$APP_ID" --arg sha "$COMMIT_SHA" --arg branch "$BRANCH" \
--arg msg "$COMMIT_MESSAGE" --arg author "$COMMIT_AUTHOR" --arg run "$(ci_run_id)" --arg started "$(iso_now)" \
'{appId:$appId, action:"start", commitSha:$sha, branch:$branch, commitMessage:$msg, author:$author,
ciRunId:(if $run == "" then null else $run end), reportedBy:"ci", startedAt:$started}')"
if resp="$(ds_post record-app-build "$body" 2>/dev/null)"; then
id="$(jq -r '.buildId // empty' <<<"$resp")"
if [ -n "$id" ]; then persist_env BUILD_ID "$id"; log "Build recorded in DealSage ($id)"; return 0; fi
fi
warn "could not record the build start in DealSage; the run continues without a build record"
}
# report_build_finish OUTCOME [REASON] [VERSION] [PROMOTED] — close the record.
# Leaves a marker beside the step list, so finish-steps.sh knows it was closed.
report_build_finish() {
[ -n "${BUILD_ID:-}" ] || return 0
printf '%s\n' "$1" > "$(steps_file).finished" 2>/dev/null || true
local body
body="$(jq -n --arg appId "$APP_ID" --arg id "$BUILD_ID" --arg outcome "$1" --arg reason "${2:-}" \
--arg v "${3:-}" --arg promoted "${4:-false}" --arg finished "$(iso_now)" \
'{appId:$appId, action:"finish", buildId:$id, outcome:$outcome,
failureReason:(if $reason == "" then null else $reason end),
version:(if $v == "" then null else ($v|tonumber) end),
promoted:($promoted == "true"), finishedAt:$finished}')"
ds_post record-app-build "$body" >/dev/null 2>&1 || warn "could not record the build outcome in DealSage"
}
# fail_build OUTCOME MESSAGE [LOGFILE] — fail the running step (with the tail
# of LOGFILE), record the outcome, then stop the job.
fail_build() { step_fail_running "$2" "${3:-}"; report_build_finish "$1" "$2"; die "$2"; }
# ---------------------------------------------------------------------------
# Pipeline steps (record-app-build, action "steps"). Gitea sees this whole
# action as one step of the job, so the pipeline reports its own steps and the
# build page in the app hub lists them while they run: status, duration, a
# short summary and, for a step that failed, the tail of its output.
#
# The list lives in a file the composite steps share, so whatever a dying step
# wrote survives it, and finish-steps.sh (the always() step at the end)
# settles it with how the run ended. Best effort, like the build record:
# reporting never fails a step, and once DealSage refuses a post the run stops
# trying. The list is only sent once the build is recorded (BUILD_ID); the
# steps before that are sent with the first post.
# ---------------------------------------------------------------------------
# id|name of every step, in the order action.yml runs them (keep the two in step).
PIPELINE_STEPS="tooling|Tooling
manifest|Read the app manifest
record|Record the build
node|Set up Node.js
install|Install dependencies
checks|Checks
build|Build
publish|Publish"
# steps_file — this run's step list, one per run attempt and job.
steps_file() {
printf '%s/dealsage-steps-%s-%s-%s.json' "${RUNNER_TEMP:-/tmp}" \
"${GITHUB_RUN_ID:-local}" "${GITHUB_RUN_ATTEMPT:-1}" "${GITHUB_JOB:-job}"
}
# _steps_edit JQ_ARGS... — rewrite the list with a jq program; the first edit
# of a run creates it with every step pending.
_steps_edit() {
local f; f="$(steps_file)"
if [ ! -s "$f" ]; then
jq -cn --arg steps "$PIPELINE_STEPS" \
'$steps | split("\n") | map(select(length > 0) | split("|") | {id: .[0], name: .[1], status: "pending"})' \
> "$f" || return 1
fi
jq -c "$@" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
}
# _step_begin ID [STARTED_ISO STARTED_MS] — ID is running (since the given time).
_step_begin() {
_steps_edit --arg id "$1" --arg at "${2:-$(iso_now)}" --argjson ms "${3:-$(now_ms)}" \
'map(if .id == $id then {id, name, status: "running", startedAt: $at, startedMs: $ms} else . end)'
}
# _step_end ID STATUS [SUMMARY] [LOGFILE] — ID ended; a step that started gets
# its finish time and duration.
_step_end() {
local log=""
[ -z "${4:-}" ] || log="$(tail_file "$4")"
_steps_edit --arg id "$1" --arg status "$2" --arg summary "${3:-}" --arg log "$log" \
--arg at "$(iso_now)" --argjson ms "$(now_ms)" '
map(if .id != $id then . else
.status = $status
| (if .startedMs then .finishedAt = $at | .durationMs = ($ms - .startedMs) else . end)
| (if $summary != "" then .summary = $summary else . end)
| (if $log != "" then .logTail = $log else . end)
end)'
}
# steps_post — send the list to DealSage (a no-op until the build is recorded).
steps_post() {
[ -n "${BUILD_ID:-}" ] || return 0
local f body; f="$(steps_file)"
[ -s "$f" ] && [ ! -e "$f.off" ] || return 0
body="$(jq -c --arg appId "${APP_ID:-}" --arg id "$BUILD_ID" \
'{appId: $appId, action: "steps", buildId: $id, steps: map(del(.startedMs))}' "$f")" || return 0
if ! DS_POST_MAX_TIME=20 ds_post record-app-build "$body" >/dev/null 2>&1; then
: > "$f.off"
warn "could not record the pipeline steps in DealSage; the build page will not list this run's steps"
fi
}
# step_start ID [STARTED_ISO STARTED_MS] — ID is running. The optional start
# time is for the Tooling step, which can only record itself once jq is there.
step_start() { ( set +e; _step_begin "$@" && steps_post ) || true; }
# step_done ID STATUS [SUMMARY] [LOGFILE] — ID ended: passed, failed or
# skipped, with a short summary and, when given, the tail of LOGFILE.
step_done() { ( set +e; _step_end "$@" && steps_post ) || true; }
# step_next DONE NEXT — DONE passed and NEXT is running, in one post. This is
# how Set up Node.js (a stock action, nothing of ours runs inside it) is
# tracked: the step before starts it, the step after ends it.
step_next() { ( set +e; _step_end "$1" passed && _step_begin "$2" && steps_post ) || true; }
# step_fail_running SUMMARY [LOGFILE] — the step that is running failed.
step_fail_running() {
( set +e
local id
id="$(jq -r 'map(select(.status == "running"))[0].id // empty' "$(steps_file)" 2>/dev/null)"
[ -z "$id" ] || { _step_end "$id" failed "$1" "${2:-}" && steps_post; }
) || true
}