From 3a9236148aefd2ed11e32d890ea76f1006c45432 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 7 Jul 2026 22:09:53 +0200 Subject: [PATCH] Add Forgejo T05 verify, operator bootstrap, and security hardening Introduce forgejo-verify acceptance checks, idempotent operator bootstrap, runner registration SOPS capture helper, and session/security Helm values. --- Makefile | 5 +- docs/forgejo-on-railiance01.md | 32 +++++ docs/forgejo-package-registry.md | 81 +++++++++++ helm/forgejo-values.yaml | 4 + tools/forgejo-npm-smoke.sh | 34 +++++ tools/forgejo-operator-bootstrap.sh | 104 ++++++++++++++ ...gejo-runner-registration-sops-bootstrap.sh | 44 ++++++ tools/forgejo-verify.sh | 133 ++++++++++++++++++ 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 docs/forgejo-package-registry.md create mode 100755 tools/forgejo-npm-smoke.sh create mode 100755 tools/forgejo-operator-bootstrap.sh create mode 100755 tools/forgejo-runner-registration-sops-bootstrap.sh create mode 100755 tools/forgejo-verify.sh diff --git a/Makefile b/Makefile index 43de42b..84835a4 100644 --- a/Makefile +++ b/Makefile @@ -246,6 +246,9 @@ forgejo-status: check-railiance01-kubeconfig ## Show Forgejo pods, svc, ingress, forgejo-smoke: ## Verify Forgejo web and OCI registry challenge FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" tools/forgejo-smoke.sh +forgejo-npm-smoke: ## T07 npm install smoke (@coulomb/forgejo-npm-probe) + tools/forgejo-npm-smoke.sh + forgejo-verify: check-railiance01-kubeconfig ## T05 acceptance: smoke, TLS, DB, mailer ESO, SSH operator, SOPS sentinels FORGEJO_BASE_URL="$(FORGEJO_BASE_URL)" KUBECONFIG="$(FORGEJO_KUBECONFIG)" tools/forgejo-verify.sh @@ -320,4 +323,4 @@ help: ## Show this help /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs help +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs help diff --git a/docs/forgejo-on-railiance01.md b/docs/forgejo-on-railiance01.md index b68ba5c..1661731 100644 --- a/docs/forgejo-on-railiance01.md +++ b/docs/forgejo-on-railiance01.md @@ -79,6 +79,29 @@ make forgejo-runner-status make forgejo-status make forgejo-logs make forgejo-smoke +make forgejo-verify # T05 acceptance (smoke, TLS, DB, mailer, SSH operator) +make forgejo-secrets-check # SOPS sentinels present +``` + +## Operator accounts (T05) + +Bootstrap is automated and does not commit secrets: + +```bash +# Requires admin API token at /tmp/forgejo-tegwick-api-token (or FORGEJO_ADMIN_TOKEN) +make forgejo-operator-bootstrap +``` + +Default operator: `tegwick` (site admin, `coulomb` Owners team, SSH via +`forgejo-remote` / `~/.ssh/id_gitea`). `forgejo_admin` remains the Helm chart +bootstrap account; day-2 git+ssh should use the operator user. + +Runner registration token: if the cluster secret was created with `kubectl create +secret` (not SOPS), capture it once on a host with the age key: + +```bash +make forgejo-runner-registration-sops-bootstrap +make check-sops SOPS_SENTINEL=helm/forgejo-runner-registration.sops.yaml ``` ## SMTP (IONOS) — password reset / notifications @@ -135,6 +158,15 @@ Do not put the SMTP password in `helm/forgejo-secrets.sops.yaml`. Gitea on coulombcore remains canonical until `RAIL-HO-WP-0005` migration drills and cutover pass. Do not repoint repo remotes until Wave 1 cutover is approved. +## Package registry (OCI, npm, generic) + +See `docs/forgejo-package-registry.md`. Smoke targets: + +```bash +make forgejo-smoke +make forgejo-npm-smoke +``` + ## Related - Gitea reference: `~/railiance-forge/Makefile` (`gitea-deploy`) diff --git a/docs/forgejo-package-registry.md b/docs/forgejo-package-registry.md new file mode 100644 index 0000000..961ebb6 --- /dev/null +++ b/docs/forgejo-package-registry.md @@ -0,0 +1,81 @@ +# Forgejo Package Registry (railiance01) + +Workplan: `RAIL-HO-WP-0005` T07 · Decision: Option C (OCI + npm + generic) + +Public host: `https://forgejo.coulomb.social` + +Helm values: `helm/forgejo-registry-values.yaml` (`packages.ENABLED`, `ROOT_URL`). + +## OCI (container images) + +Registry API: `GET /v2/` (expect `401` + `Docker-Distribution-Api-Version`). + +```bash +docker login forgejo.coulomb.social -u tegwick +docker tag IMAGE forgejo.coulomb.social/coulomb/REPO:TAG +docker push forgejo.coulomb.social/coulomb/REPO:TAG +``` + +Cluster pull (railiance01): + +```bash +KUBECONFIG=~/.kube/config-hosteurope crictl pull forgejo.coulomb.social/coulomb/REPO:TAG +``` + +Smoke: `make forgejo-smoke` + +## npm + +Org registry: `https://forgejo.coulomb.social/api/packages/coulomb/npm/` + +Workstation config (use a PAT with `read:package` / `write:package`; do not commit): + +```bash +npm config set @coulomb:registry https://forgejo.coulomb.social/api/packages/coulomb/npm/ +npm config set -- '//forgejo.coulomb.social/api/packages/coulomb/npm/:_authToken' "${FORGEJO_NPM_TOKEN}" +``` + +Publish (scoped package under `@coulomb/`): + +```bash +npm publish --scope=@coulomb +# or explicit: +npm publish --scope=@coulomb --registry=https://forgejo.coulomb.social/api/packages/coulomb/npm/ +``` + +Install: + +```bash +npm install @coulomb/PACKAGE@VERSION +``` + +Probe package (T07 evidence): `@coulomb/forgejo-npm-probe@0.0.1` + +Smoke: `make forgejo-npm-smoke` (requires `FORGEJO_NPM_TOKEN` or `/tmp/forgejo-tegwick-api-token`) + +CI template: `railiance-enablement/workflows/npm-publish.yaml` + +## Generic packages + +Upload release artifacts via Forgejo UI or API (`/api/packages/{owner}/generic/...`). +Use for binaries, checksum files, and non-OCI release assets. + +## Retention and backup + +| Type | Storage | Backup | Cleanup | +| --- | --- | --- | --- | +| OCI | `/data/packages` in Forgejo PVC | `make forgejo-backup` (`forgejo dump`) | Delete unused tags in UI; no auto-prune yet | +| npm | same | same | `npm unpublish @coulomb/pkg@version` or UI package settings | +| generic | same | same | per-package UI delete | + +Package blobs are included in daily `forgejo dump` (~686M with 14 repos, 2026-07-07). +Restore drills must verify package metadata after isolated restore (T09). + +RPO/RTO for package data matches Forgejo backup policy: 24h / 4h +(`the-custodian/docs/forgejo-production-decisions.md` § Backup). + +## Related + +- `docs/forgejo-on-railiance01.md` +- `railiance-enablement/docs/forgejo-actions-workflow-templates.md` +- Gitea legacy reference: `~/railiance-forge/docs/gitea-package-registry.md` \ No newline at end of file diff --git a/helm/forgejo-values.yaml b/helm/forgejo-values.yaml index 29f1283..44861c9 100644 --- a/helm/forgejo-values.yaml +++ b/helm/forgejo-values.yaml @@ -56,6 +56,10 @@ gitea: DISABLE_SSH: false service: DISABLE_REGISTRATION: true + security: + INSTALL_LOCK: true + session: + COOKIE_SECURE: true mailer: ENABLED: true FROM: forgejo@coulomb.social diff --git a/tools/forgejo-npm-smoke.sh b/tools/forgejo-npm-smoke.sh new file mode 100755 index 0000000..ac6451e --- /dev/null +++ b/tools/forgejo-npm-smoke.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# T07 npm registry smoke — install published @coulomb/forgejo-npm-probe (no publish). +set -euo pipefail + +REGISTRY="${FORGEJO_NPM_REGISTRY:-https://forgejo.coulomb.social/api/packages/coulomb/npm/}" +TOKEN_FILE="${FORGEJO_NPM_TOKEN_FILE:-${FORGEJO_TOKEN_FILE:-/tmp/forgejo-tegwick-api-token}}" +PROBE_PKG="${FORGEJO_NPM_PROBE_PKG:-@coulomb/forgejo-npm-probe@0.0.1}" + +if [[ -n "${FORGEJO_NPM_TOKEN:-}" ]]; then + TOKEN="${FORGEJO_NPM_TOKEN}" +elif [[ -f "${TOKEN_FILE}" ]]; then + TOKEN="$(cat "${TOKEN_FILE}")" +else + echo "Set FORGEJO_NPM_TOKEN or create ${TOKEN_FILE}" >&2 + exit 1 +fi + +command -v npm >/dev/null 2>&1 || { echo "npm required" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +cd "${work}" + +npm config set @coulomb:registry "${REGISTRY}" +npm config set -- '//forgejo.coulomb.social/api/packages/coulomb/npm/:_authToken' "${TOKEN}" + +npm install "${PROBE_PKG}" >/dev/null +out="$(node -e "console.log(require('@coulomb/forgejo-npm-probe')())")" +if [[ "${out}" == "forgejo-npm-probe-ok" ]]; then + echo "npm smoke ok: ${PROBE_PKG}" +else + echo "npm smoke fail: unexpected output '${out}'" >&2 + exit 1 +fi \ No newline at end of file diff --git a/tools/forgejo-operator-bootstrap.sh b/tools/forgejo-operator-bootstrap.sh new file mode 100755 index 0000000..f31db6c --- /dev/null +++ b/tools/forgejo-operator-bootstrap.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Idempotent operator account bootstrap for Forgejo (RAIL-HO-WP-0005 T05). +# Does not commit secrets — uses API token from file or env. +set -euo pipefail + +FORGEJO_API="${FORGEJO_API:-https://forgejo.coulomb.social/api/v1}" +FORGEJO_ORG="${FORGEJO_ORG:-coulomb}" +FORGEJO_OPERATOR_USER="${FORGEJO_OPERATOR_USER:-tegwick}" +FORGEJO_OPERATOR_EMAIL="${FORGEJO_OPERATOR_EMAIL:-bernd.worsch@gmail.com}" +FORGEJO_OPERATOR_SSH_KEY="${FORGEJO_OPERATOR_SSH_KEY:-$HOME/.ssh/id_gitea.pub}" +FORGEJO_OPERATOR_SSH_TITLE="${FORGEJO_OPERATOR_SSH_TITLE:-workstation-automation}" +TOKEN_FILE="${FORGEJO_TOKEN_FILE:-/tmp/forgejo-tegwick-api-token}" + +if [[ -n "${FORGEJO_ADMIN_TOKEN:-}" ]]; then + TOKEN="${FORGEJO_ADMIN_TOKEN}" +elif [[ -f "${TOKEN_FILE}" ]]; then + TOKEN="$(cat "${TOKEN_FILE}")" +else + echo "Set FORGEJO_ADMIN_TOKEN or create ${TOKEN_FILE}" >&2 + exit 1 +fi + +AUTH=(-H "Authorization: token ${TOKEN}") + +user_status() { + curl -sS -o /dev/null -w '%{http_code}' "${AUTH[@]}" "${FORGEJO_API}/users/${FORGEJO_OPERATOR_USER}" +} + +echo "==> Forgejo operator bootstrap: ${FORGEJO_OPERATOR_USER}" + +case "$(user_status)" in + 200) + echo " user exists" + ;; + 404) + echo " creating user ${FORGEJO_OPERATOR_USER}" + FORGEJO_OPERATOR_USER="${FORGEJO_OPERATOR_USER}" \ + FORGEJO_OPERATOR_EMAIL="${FORGEJO_OPERATOR_EMAIL}" \ + curl -fsS -X POST "${FORGEJO_API}/admin/users" "${AUTH[@]}" \ + -H "Content-Type: application/json" \ + -d "$(python3 -c " +import json, os +print(json.dumps({ + 'username': os.environ['FORGEJO_OPERATOR_USER'], + 'email': os.environ['FORGEJO_OPERATOR_EMAIL'], + 'password': os.urandom(18).hex(), + 'must_change_password': False, + 'send_notify': False, +})) +")" >/dev/null + ;; + *) + echo "Unexpected user lookup status for ${FORGEJO_OPERATOR_USER}" >&2 + exit 1 + ;; +esac + +# Promote to site admin +curl -fsS -X PATCH "${FORGEJO_API}/admin/users/${FORGEJO_OPERATOR_USER}" "${AUTH[@]}" \ + -H "Content-Type: application/json" \ + -d '{"admin":true,"prohibit_login":false}' >/dev/null +echo " admin=true" + +# Org Owners team membership +team_id="$(curl -fsS "${AUTH[@]}" "${FORGEJO_API}/orgs/${FORGEJO_ORG}/teams" \ + | python3 -c "import sys,json; teams=json.load(sys.stdin); print(next((t['id'] for t in teams if t.get('name')=='Owners'), ''))")" +if [[ -n "${team_id}" ]]; then + curl -fsS -X PUT "${AUTH[@]}" \ + "${FORGEJO_API}/teams/${team_id}/members/${FORGEJO_OPERATOR_USER}" >/dev/null 2>&1 \ + && echo " org ${FORGEJO_ORG} Owners team" \ + || echo " (already in Owners team or API skipped)" +fi + +# SSH key for git+ssh +if [[ ! -f "${FORGEJO_OPERATOR_SSH_KEY}" ]]; then + echo "Missing SSH public key: ${FORGEJO_OPERATOR_SSH_KEY}" >&2 + exit 1 +fi +existing="$(curl -fsS "${AUTH[@]}" "${FORGEJO_API}/users/${FORGEJO_OPERATOR_USER}/keys" \ + | FORGEJO_OPERATOR_SSH_KEY="${FORGEJO_OPERATOR_SSH_KEY}" python3 -c " +import json, os, sys +want = open(os.environ['FORGEJO_OPERATOR_SSH_KEY']).read().strip() +keys = json.load(sys.stdin) +print(any((k.get('key') or '').strip() == want for k in keys)) +")" +if [[ "${existing}" == "True" ]]; then + echo " SSH key already present" +else + FORGEJO_OPERATOR_SSH_TITLE="${FORGEJO_OPERATOR_SSH_TITLE}" \ + FORGEJO_OPERATOR_SSH_KEY="${FORGEJO_OPERATOR_SSH_KEY}" \ + curl -fsS -X POST "${FORGEJO_API}/admin/users/${FORGEJO_OPERATOR_USER}/keys" "${AUTH[@]}" \ + -H "Content-Type: application/json" \ + -d "$(python3 -c " +import json, os +print(json.dumps({ + 'title': os.environ['FORGEJO_OPERATOR_SSH_TITLE'], + 'key': open(os.environ['FORGEJO_OPERATOR_SSH_KEY']).read().strip(), +})) +")" >/dev/null + echo " SSH key added (${FORGEJO_OPERATOR_SSH_TITLE})" +fi + +echo "==> Operator bootstrap complete: ${FORGEJO_OPERATOR_USER}" +echo " Test: ssh forgejo-remote # expect Hi there, ${FORGEJO_OPERATOR_USER}!" \ No newline at end of file diff --git a/tools/forgejo-runner-registration-sops-bootstrap.sh b/tools/forgejo-runner-registration-sops-bootstrap.sh new file mode 100755 index 0000000..b639013 --- /dev/null +++ b/tools/forgejo-runner-registration-sops-bootstrap.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# One-time: capture cluster runner registration token into SOPS (operator host with age key). +# RAIL-HO-WP-0005 T05 — closes runner secret gap when bootstrap used kubectl create secret. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${ROOT}/helm/forgejo-runner-registration.sops.yaml" +KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-hosteurope}" +FORGEJO_NAMESPACE="${FORGEJO_NAMESPACE:-forgejo}" + +export KUBECONFIG + +if ! command -v sops >/dev/null 2>&1; then + echo "ERROR: sops required" >&2 + exit 1 +fi + +if [[ -f "${OUT}" ]]; then + echo "Already exists: ${OUT}" >&2 + echo "Remove it first if you intend to re-capture from cluster." >&2 + exit 1 +fi + +token="$(kubectl get secret forgejo-runner-registration -n "${FORGEJO_NAMESPACE}" \ + -o jsonpath='{.data.token}' | base64 -d)" +if [[ -z "${token}" ]]; then + echo "ERROR: forgejo-runner-registration secret missing or empty in ${FORGEJO_NAMESPACE}" >&2 + exit 1 +fi + +cat > "${OUT}" <&2; fail=$((fail + 1)); } +note() { echo " WARN $*"; warn=$((warn + 1)); } + +echo "Forgejo T05 verify — ${BASE_URL}" +echo "kubeconfig: ${KUBECONFIG}" +echo + +# Web + OCI registry smoke +if FORGEJO_BASE_URL="${BASE_URL}" "${ROOT}/tools/forgejo-smoke.sh" >/dev/null 2>&1; then + ok "web + OCI registry smoke" +else + bad "web + OCI registry smoke" +fi + +# Kubernetes workload +if kubectl get pod -n "${FORGEJO_NAMESPACE}" -l app.kubernetes.io/instance=forgejo \ + -o jsonpath='{.items[0].status.phase}' 2>/dev/null | grep -q Running; then + ok "forgejo app pod Running" +else + bad "forgejo app pod not Running" +fi + +if kubectl get deploy forgejo-runner -n "${FORGEJO_NAMESPACE}" >/dev/null 2>&1; then + ready="$(kubectl get deploy forgejo-runner -n "${FORGEJO_NAMESPACE}" \ + -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo 0/0)" + if [[ "${ready}" == "1/1" ]]; then + ok "forgejo-runner ready (${ready})" + else + note "forgejo-runner replicas ${ready}" + fi +else + bad "forgejo-runner deployment missing" +fi + +# TLS ingress +if kubectl get certificate forgejo-tls -n "${FORGEJO_NAMESPACE}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q True; then + ok "TLS certificate Ready" +else + bad "TLS certificate not Ready" +fi + +# Database +if kubectl get cluster "${FORGEJO_DB_CLUSTER}" -n "${FORGEJO_DB_NAMESPACE}" \ + -o jsonpath='{.status.phase}' 2>/dev/null | grep -qi healthy; then + ok "forgejo-db cluster healthy" +else + bad "forgejo-db cluster not healthy" +fi + +# Mailer via OpenBao/ESO (not SOPS) +if kubectl get externalsecret forgejo-mailer -n "${FORGEJO_NAMESPACE}" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q True; then + ok "forgejo-mailer ExternalSecret Ready" +else + bad "forgejo-mailer ExternalSecret not Ready" +fi + +# SOPS secret files (encrypted sentinel — decrypt optional) +for f in helm/forgejo-secrets.sops.yaml helm/forgejo-runner-registration.sops.yaml; do + if [[ -f "${ROOT}/${f}" ]] && grep -q '^sops:' "${ROOT}/${f}"; then + ok "SOPS file present: ${f}" + if command -v sops >/dev/null 2>&1 && sops -d "${ROOT}/${f}" >/dev/null 2>&1; then + ok "SOPS decrypt: ${f}" + else + note "SOPS decrypt skipped (no sops or age key): ${f}" + fi + elif [[ "${f}" == *runner-registration* ]]; then + if kubectl get secret forgejo-runner-registration -n "${FORGEJO_NAMESPACE}" >/dev/null 2>&1; then + note "runner registration in cluster only — encrypt helm/forgejo-runner-registration.sops.yaml on operator host with age key" + else + bad "missing runner registration secret" + fi + else + bad "missing SOPS file: ${f}" + fi +done + +# Operator SSH (tegwick, not forgejo_admin) +if ssh -o BatchMode=yes -o ConnectTimeout=8 forgejo-remote 2>&1 | grep -q "Hi there, ${FORGEJO_OPERATOR_USER}!"; then + ok "SSH forgejo-remote authenticates as ${FORGEJO_OPERATOR_USER}" +else + bad "SSH forgejo-remote does not authenticate as ${FORGEJO_OPERATOR_USER}" +fi + +# Operator API account +if [[ -f "${TOKEN_FILE}" ]]; then + token="$(cat "${TOKEN_FILE}")" + admin="$(curl -fsS -H "Authorization: token ${token}" \ + "${BASE_URL}/api/v1/users/${FORGEJO_OPERATOR_USER}" | python3 -c \ + "import sys,json; print(json.load(sys.stdin).get('is_admin', False))" 2>/dev/null || echo false)" + if [[ "${admin}" == "True" ]]; then + ok "operator user ${FORGEJO_OPERATOR_USER} is admin (API)" + else + bad "operator user ${FORGEJO_OPERATOR_USER} is not admin" + fi +else + note "no API token at ${TOKEN_FILE} — skip operator admin API check" +fi + +# Git HTTPS reachability (no creds — expect 401/200 on repo HEAD) +code="$(curl -sS -o /dev/null -w '%{http_code}' "${BASE_URL}/coulomb/glas-harness")" +if [[ "${code}" == "200" ]]; then + ok "public repo HTTPS (${code})" +else + bad "public repo HTTPS unexpected (${code})" +fi + +echo +echo "Summary: ${pass} pass, ${fail} fail, ${warn} warn" +if [[ "${fail}" -gt 0 ]]; then + exit 1 +fi \ No newline at end of file