This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-932-unomi3-major-release-story
in repository https://gitbox.apache.org/repos/asf/unomi-site.git


The following commit(s) were added to 
refs/heads/UNOMI-932-unomi3-major-release-story by this push:
     new 1a3a68c  feat: add ASF publish script and harden Netlify preview 
tooling (UNOMI-932)
1a3a68c is described below

commit 1a3a68ca86a30c64a2dbf901022e11e009916d3f
Author: Serge Huber <[email protected]>
AuthorDate: Fri Jul 17 15:18:38 2026 +0200

    feat: add ASF publish script and harden Netlify preview tooling (UNOMI-932)
    
    Share site build/validation helpers, add credentialed publish.sh for
    scm-publish, and clarify draft vs Netlify --prod vs Apache production in
    the README.
---
 .gitignore                 |   1 +
 README.md                  |  97 ++++++++++---
 preview.sh                 | 200 +++++++++++++-------------
 publish.sh                 | 339 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/lib/site-common.sh | 201 +++++++++++++++++++++++++++
 5 files changed, 720 insertions(+), 118 deletions(-)

diff --git a/.gitignore b/.gitignore
index 60e1e40..006c214 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ target/
 /.mvn/wrapper/maven-wrapper.jar
 Gemfile.lock
 .bundle/
+vendor/bundle/
 .netlify/
diff --git a/README.md b/README.md
index 7d95ee7..492a857 100644
--- a/README.md
+++ b/README.md
@@ -33,11 +33,16 @@ git clone https://github.com/apache/unomi-site
 Using the [bretfisher/jekyll](https://hub.docker.com/r/bretfisher/jekyll) 
Docker image.
 No local Ruby or Jekyll installation required.
 
+Because this repo includes a `Gemfile`, run `bundle install` inside the 
container
+before building (otherwise Bundler may raise `GemNotFound` against a local 
`Gemfile.lock`):
+
 ```shell
 docker run --rm \
   --volume="$PWD:/site" \
+  -w /site \
+  --entrypoint bash \
   bretfisher/jekyll \
-  build
+  -lc 'bundle install && bundle exec jekyll build'
 ```
 
 The generated site will be in the folder `target/site`.
@@ -50,9 +55,13 @@ Serves the site at http://localhost:4000/ with live-reload 
on source changes:
 docker run --rm \
   --volume="$PWD:/site" \
   -p 4000:4000 \
-  bretfisher/jekyll-serve
+  -w /site \
+  --entrypoint bash \
+  bretfisher/jekyll-serve \
+  -lc 'bundle install && bundle exec jekyll serve --host 0.0.0.0 --port 4000'
 ```
 
+`./preview.sh` / `./preview.sh --local` use the same Docker + `bundle install` 
flow.
 ### Build with local Jekyll
 
 Requires Ruby 2.7+ and Bundler. Install dependencies once, then build:
@@ -68,11 +77,35 @@ Or serve locally with live-reload:
 bundle exec jekyll serve
 ```
 
+## Which command should I use?
+
+| Goal | Command | Resulting URL | Credentials |
+|---|---|---|---|
+| Quick review (throwaway link) | `./preview.sh` | Unique draft, e.g. 
`https://<deploy-id>--unomi-preview.netlify.app` | Netlify account |
+| Stable link to share with reviewers | `./preview.sh --prod` | 
`https://unomi-preview.netlify.app` | Netlify account |
+| Local live-reload | `./preview.sh --local` | `http://localhost:4000` | none |
+| **Official** Apache site | `./publish.sh` | `https://unomi.apache.org` | 
**ASF LDAP** (not Netlify) |
+
+Important distinctions:
+
+- **`./preview.sh --prod` ≠ Apache production.** “Prod” here only means 
Netlify’s primary URL for the **preview** project (`unomi-preview`). It never 
updates `unomi.apache.org`.
+- **`./publish.sh` ≠ Netlify.** It publishes via Maven scm-publish to ASF SVN. 
That is the only path for the official site.
+- Netlify staging is for personal/community review only. Do not commit Netlify 
tokens or `.netlify/` state into git.
+
 ## Staging preview (Netlify)
 
-`./preview.sh` builds the site with Docker Jekyll and can publish a 
**personal/community** staging URL on Netlify (same approach as the UNOMI-932 
preview at `https://unomi-v3-site.netlify.app/`).
+`./preview.sh` builds the site with Docker Jekyll and deploys a 
**personal/community** staging copy on Netlify (same idea as the earlier 
UNOMI-932 preview at `https://unomi-v3-site.netlify.app/`).
 
-This is **not** how `https://unomi.apache.org/` is published. Production still 
uses Maven scm-publish (see [Publish](#publish) below). Do not commit Netlify 
tokens or `.netlify/` state into git.
+### Draft vs `--prod` (both are Netlify-only)
+
+| | `./preview.sh` (draft) | `./preview.sh --prod` |
+|---|---|---|
+| Purpose | Safe experiment / PR-style preview | Cleaner URL for reviewers |
+| URL shape | `https://<id>--unomi-preview.netlify.app` (new each deploy) | 
`https://unomi-preview.netlify.app` (stable) |
+| Overwrites previous share link? | No — old drafts remain until 
expired/removed | Yes — replaces the site’s main Netlify URL |
+| Affects `unomi.apache.org`? | No | No |
+
+Use draft while iterating; switch to `--prod` when you want one stable link to 
paste in email/JIRA/Slack.
 
 ### Prerequisites
 
@@ -120,8 +153,8 @@ Verify tooling before building:
 
 | Variable / file | Purpose |
 |---|---|
-| (default) | Script creates/links a site named `unomi-v3-site` on first 
deploy |
-| `NETLIFY_SITE_NAME` | Override the Netlify site name (default: 
`unomi-v3-site`) |
+| (default) | Script creates/links a site named `unomi-preview` on first 
deploy |
+| `NETLIFY_SITE_NAME` | Override the Netlify site name (default: 
`unomi-preview`) |
 | `NETLIFY_SITE_ID` | Use an existing site id and skip interactive link |
 | `.netlify/state.json` | Created after a successful link; reused on later 
runs (gitignored) |
 
@@ -129,7 +162,7 @@ Example:
 
 ```shell
 export NETLIFY_AUTH_TOKEN=...          # optional if already logged in
-export NETLIFY_SITE_NAME=unomi-v3-site # optional
+export NETLIFY_SITE_NAME=unomi-preview # optional
 export NETLIFY_SITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  # optional
 ```
 
@@ -140,45 +173,71 @@ Other optional env vars:
 | `JEKYLL_IMAGE` | `bretfisher/jekyll` | Docker image used to build |
 | `JEKYLL_SERVE_IMAGE` | `bretfisher/jekyll-serve` | Docker image used for 
`--local` |
 
-`netlify.toml` in this repo sets `publish = "target/site"`. The script builds 
locally first; Netlify only hosts the static output.
+`netlify.toml` in this repo sets `publish = "target/site"`. The script builds 
locally first; Netlify only hosts the static output. It does **not** connect 
GitHub or configure ASF repo webhooks — decline any Netlify prompt asking for 
GitHub authorization.
 
 ### Usage
 
 From the repository root:
 
 ```shell
-chmod +x ./preview.sh   # once
+chmod +x ./preview.sh ./publish.sh   # once
 
 ./preview.sh --check-only   # validate Docker, layout, Netlify tooling/auth 
hints
 ./preview.sh                # build + Netlify *draft* deploy (unique preview 
URL)
-./preview.sh --prod         # build + deploy to the linked site’s production 
URL
+./preview.sh --prod         # build + Netlify *site* URL (e.g. 
https://unomi-preview.netlify.app)
 ./preview.sh --local        # build + serve at http://localhost:4000 (no 
Netlify)
 ./preview.sh --build-only   # build only → target/site
 ./preview.sh --open         # after a Netlify deploy, open the URL (macOS)
 ```
 
+`--prod` = Netlify site production for the preview project only. For Apache 
production see [Publish](#publish-production-unomiapacheorg) / `./publish.sh`.
+
 Flags can be combined where it makes sense, e.g. `./preview.sh --prod --open`.
 
 Typical first-time flow:
 
 1. Start Docker Desktop (or your Docker daemon).
 2. `./preview.sh --check-only` and fix any `[FAIL]` lines.
-3. `./preview.sh` — complete Netlify login / site link if prompted.
-4. Share the printed `https://….netlify.app/` URL for review.
-5. Later deploys to the same linked site: `./preview.sh --prod`.
+3. `./preview.sh` — complete Netlify login / site link if prompted; open the 
draft URL yourself.
+4. When ready to share one stable link: `./preview.sh --prod` → send 
`https://unomi-preview.netlify.app/`.
+5. When the content is approved for the official site: `./publish.sh` (ASF 
LDAP) → `https://unomi.apache.org/`.
 
 ### What the script does
 
 1. Runs prerequisite checks (`[ok]` / `[warn]` / `[FAIL]`).
-2. Builds with Docker Jekyll into `target/site`.
-3. For Netlify modes: authenticates, links/creates the site if needed, deploys 
`target/site`, prints the preview URL.
+2. Builds with Docker Jekyll into `target/site` and validates the output.
+3. Authenticates to Netlify, links/creates the site if needed, deploys a draft 
(or `--prod` site URL), prints the share link.
+
+Shared helpers live in `scripts/lib/site-common.sh` (also used by 
`./publish.sh`).
 
-## Publish
+## Publish (production: unomi.apache.org)
+
+This is the **official** Apache Unomi website. It is **not** Netlify and is 
unrelated to `./preview.sh --prod`.
+
+Use `./publish.sh`, which builds with Docker Jekyll, runs strong validations, 
then publishes via Maven `scm-publish` to ASF SVN 
(`https://svn.apache.org/repos/asf/unomi/website/`).
+
+Do **not** use the Maven `clean` goal — it would remove previously published 
trees that scm-publish intentionally preserves (`docs/`, `manual/`, etc.).
+
+Credentials: your **ASF LDAP username and password** (not Netlify). Pass them 
as parameters (or env vars); the password is never printed.
+
+```shell
+./publish.sh --check-only --username YOUR_APACHE_USERNAME
+./publish.sh --dry-run -u YOUR_APACHE_USERNAME -p 'YOUR_APACHE_PASSWORD'
+./publish.sh -u YOUR_APACHE_USERNAME -p 'YOUR_APACHE_PASSWORD'
+```
+
+| Flag / env | Purpose |
+|---|---|
+| `-u` / `--username` | ASF LDAP username (or `ASF_USERNAME` / 
`APACHE_USERNAME`) |
+| `-p` / `--password` | ASF LDAP password (or `ASF_PASSWORD` / 
`APACHE_PASSWORD`; prompts if omitted) |
+| `--check-only` | Run prerequisite checks only (no build/publish) |
+| `--dry-run` | Full build + validate + Maven scm-publish dry run (no SVN 
commit) |
+| `--skip-build` | Reuse an existing `target/site` (still validated) |
+| `--yes` | Skip typing `publish` to confirm (required for non-interactive 
runs) |
 
-To publish the local website to the production location 
(https://unomi.apache.org/), you have to use:
-Do not use the `clean` maven goal to not remove the previous generated site.
+Before publishing, the script checks project layout, Jekyll config, `pom.xml` 
scm-publish settings, Docker, Maven/Java, SVN reachability, optional live SVN 
auth (if `svn` is installed), git dirty-tree warnings, and the built site 
contents (required pages, file counts, no Jekyll source dirs).
 
-Credentials: your **ASF LDAP username and password** (not Netlify).
+Equivalent manual Maven command (prefer the script):
 
 ```shell
 mvn install scm-publish:publish-scm -Dusername=YOUR_APACHE_USERNAME 
-Dpassword=YOUR_APACHE_PASSWORD
diff --git a/preview.sh b/preview.sh
index f74eda0..4cf9c62 100755
--- a/preview.sh
+++ b/preview.sh
@@ -1,45 +1,45 @@
 #!/usr/bin/env bash
 # Build the Apache Unomi website and publish a Netlify staging preview.
 #
-# This mirrors the earlier UNOMI-932 staging flow (unomi-v3-site.netlify.app).
-# It is for personal/community review only — production remains:
-#   mvn install scm-publish:publish-scm ...
+# This is for personal/community review only — production uses ./publish.sh
+# (Maven scm-publish to https://unomi.apache.org/).
 #
 # Usage:
 #   ./preview.sh                 # Docker build + Netlify draft deploy (unique 
URL)
-#   ./preview.sh --prod          # Docker build + deploy to the linked site URL
+#   ./preview.sh --prod          # Same build, deploy to Netlify site URL 
(cleaner share link)
+#                                #   e.g. https://unomi-preview.netlify.app — 
NOT unomi.apache.org
 #   ./preview.sh --local         # Docker build + serve at 
http://localhost:4000
 #   ./preview.sh --build-only    # Docker build only (output: target/site)
-#   ./preview.sh --check-only  # Prerequisite checks only (no build/deploy)
-#   ./preview.sh --open        # After deploy, open the preview URL (macOS)
+#   ./preview.sh --check-only    # Prerequisite checks only (no build/deploy)
+#   ./preview.sh --open          # After deploy, open the preview URL (macOS)
 #
+# Apache production (https://unomi.apache.org/) uses ./publish.sh — not this 
script.
 # Optional environment:
 #   NETLIFY_AUTH_TOKEN   Personal access token (or run: npx netlify-cli login)
 #   NETLIFY_SITE_ID      Existing site id (skips interactive link)
-#   NETLIFY_SITE_NAME    Site name when creating/linking (default: 
unomi-v3-site)
+#   NETLIFY_SITE_NAME    Site name when creating/linking (default: 
unomi-preview)
 #   JEKYLL_IMAGE         Docker image (default: bretfisher/jekyll)
 #   JEKYLL_SERVE_IMAGE   Serve image (default: bretfisher/jekyll-serve)
 
 set -euo pipefail
 
 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=scripts/lib/site-common.sh
+source "${ROOT}/scripts/lib/site-common.sh"
 cd "$ROOT"
 
-MODE="draft"          # draft | prod | local | build-only | check-only
+MODE="draft"          # draft | netlify-prod | local | build-only | check-only
 OPEN_URL=0
-JEKYLL_IMAGE="${JEKYLL_IMAGE:-bretfisher/jekyll}"
-JEKYLL_SERVE_IMAGE="${JEKYLL_SERVE_IMAGE:-bretfisher/jekyll-serve}"
-NETLIFY_SITE_NAME="${NETLIFY_SITE_NAME:-unomi-v3-site}"
-SITE_DIR="target/site"
+NETLIFY_SITE_NAME="${NETLIFY_SITE_NAME:-unomi-preview}"
 
 usage() {
-  sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'
+  sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
   exit "${1:-0}"
 }
 
 while [[ $# -gt 0 ]]; do
   case "$1" in
-    --prod) MODE="prod"; shift ;;
+    --prod) MODE="netlify-prod"; shift ;;
     --local) MODE="local"; shift ;;
     --build-only) MODE="build-only"; shift ;;
     --check-only) MODE="check-only"; shift ;;
@@ -52,40 +52,12 @@ while [[ $# -gt 0 ]]; do
   esac
 done
 
-need_cmd() {
-  if ! command -v "$1" >/dev/null 2>&1; then
-    echo "Missing required command: $1" >&2
-    return 1
-  fi
-  return 0
-}
-
-have_cmd() {
-  command -v "$1" >/dev/null 2>&1
-}
-
-check_pass() { echo "  [ok] $1"; }
-check_fail() { echo "  [FAIL] $1" >&2; CHECKS_FAILED=1; }
-check_warn() { echo "  [warn] $1" >&2; }
-
-CHECKS_FAILED=0
-
 check_prerequisites() {
   echo "==> Prerequisite checks (mode: ${MODE})"
   CHECKS_FAILED=0
 
-  # --- Project layout ---
-  if [[ -f "${ROOT}/_config.yml" ]]; then
-    check_pass "_config.yml present"
-  else
-    check_fail "_config.yml missing — run from the unomi-site repo root"
-  fi
-
-  if [[ -d "${ROOT}/src/main/webapp" ]]; then
-    check_pass "src/main/webapp present"
-  else
-    check_fail "src/main/webapp missing — unexpected project layout"
-  fi
+  check_project_layout
+  check_docker_build_env
 
   if [[ -f "${ROOT}/netlify.toml" ]]; then
     check_pass "netlify.toml present"
@@ -93,28 +65,8 @@ check_prerequisites() {
     check_warn "netlify.toml missing (deploy still works with 
--dir=${SITE_DIR})"
   fi
 
-  # --- Docker (required for all modes that build) ---
-  if have_cmd docker; then
-    check_pass "docker found ($(command -v docker))"
-    if docker info >/dev/null 2>&1; then
-      check_pass "Docker daemon is running"
-    else
-      check_fail "Docker daemon is not running — start Docker Desktop / 
dockerd"
-    fi
-  else
-    check_fail "docker not found — install Docker (required to build with 
${JEKYLL_IMAGE})"
-  fi
-
-  # Writable output dir
-  if mkdir -p "${ROOT}/target" 2>/dev/null && [[ -w "${ROOT}/target" ]]; then
-    check_pass "target/ is writable"
-  else
-    check_fail "cannot create/write ${ROOT}/target"
-  fi
-
-  # --- Mode-specific ---
   case "${MODE}" in
-    draft|prod|check-only)
+    draft|netlify-prod|check-only)
       if have_cmd netlify; then
         check_pass "netlify CLI found ($(netlify --version 2>/dev/null | head 
-1 || echo ok))"
       elif have_cmd npx; then
@@ -172,29 +124,7 @@ check_prerequisites() {
     fi
   fi
 
-  if [[ "${CHECKS_FAILED}" -ne 0 ]]; then
-    echo "" >&2
-    echo "Prerequisite checks failed. Fix the items marked [FAIL] and re-run." 
>&2
-    exit 1
-  fi
-  echo "==> Prerequisites OK"
-  echo ""
-}
-
-build_site() {
-  echo "==> Building site with Docker (${JEKYLL_IMAGE})"
-  mkdir -p target
-  docker run --rm \
-    --volume="${ROOT}:/site" \
-    -w /site \
-    "${JEKYLL_IMAGE}" \
-    build
-
-  if [[ ! -d "${SITE_DIR}" ]] || [[ -z "$(ls -A "${SITE_DIR}" 2>/dev/null || 
true)" ]]; then
-    echo "Build failed: ${SITE_DIR} is missing or empty" >&2
-    exit 1
-  fi
-  echo "==> Build OK → ${SITE_DIR}"
+  finish_prerequisite_checks
 }
 
 netlify_cli() {
@@ -217,8 +147,11 @@ ensure_netlify_auth() {
 }
 
 link_or_create_site() {
+  # Manual static deploys only — never run `netlify init` (that asks for GitHub
+  # webhooks/deploy keys, which we do not need and should not grant for ASF 
repos).
   if [[ -n "${NETLIFY_SITE_ID:-}" ]]; then
-    echo "==> Using NETLIFY_SITE_ID=${NETLIFY_SITE_ID}"
+    echo "==> Linking by NETLIFY_SITE_ID=${NETLIFY_SITE_ID}"
+    netlify_cli link --id "${NETLIFY_SITE_ID}"
     return 0
   fi
   if [[ -f .netlify/state.json ]]; then
@@ -227,11 +160,51 @@ link_or_create_site() {
   fi
 
   echo "==> Linking/creating Netlify site '${NETLIFY_SITE_NAME}'"
+  echo "    (static dir deploy only — no GitHub continuous-deploy setup)"
+
+  local site_id=""
   if netlify_cli sites:list --json 2>/dev/null | grep -q 
"\"name\":\"${NETLIFY_SITE_NAME}\""; then
+    site_id="$(netlify_cli sites:list --json 2>/dev/null \
+      | tr ',' '\n' \
+      | grep -A2 "\"name\":\"${NETLIFY_SITE_NAME}\"" \
+      | grep '"id"' \
+      | head -1 \
+      | sed -E 's/.*"id":"([^"]+)".*/\1/' || true)"
+    if [[ -n "${site_id}" ]]; then
+      netlify_cli link --id "${site_id}"
+      return 0
+    fi
     netlify_cli link --name "${NETLIFY_SITE_NAME}"
+    return 0
+  fi
+
+  local create_log
+  create_log="$(mktemp)"
+  if ! netlify_cli sites:create --name "${NETLIFY_SITE_NAME}" 
--disable-linking 2>&1 | tee "${create_log}"; then
+    echo "Failed to create Netlify site '${NETLIFY_SITE_NAME}'" >&2
+    echo "Create it in the Netlify UI, then re-run with:" >&2
+    echo "  export NETLIFY_SITE_ID=<project-id>" >&2
+    rm -f "${create_log}"
+    exit 1
+  fi
+
+  site_id="$(grep -Eo 'Project ID:[[:space:]]*[0-9a-f-]+' "${create_log}" \
+    | awk '{print $3}' \
+    | head -1 || true)"
+  if [[ -z "${site_id}" ]]; then
+    site_id="$(grep -Eo 
'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' "${create_log}" 
| head -1 || true)"
+  fi
+  rm -f "${create_log}"
+
+  if [[ -n "${site_id}" ]]; then
+    netlify_cli link --id "${site_id}"
   else
-    netlify_cli sites:create --name "${NETLIFY_SITE_NAME}" --disable-linking 
|| true
-    netlify_cli link --name "${NETLIFY_SITE_NAME}" || netlify_cli init
+    netlify_cli link --name "${NETLIFY_SITE_NAME}"
+  fi
+
+  if [[ ! -f .netlify/state.json ]]; then
+    echo "Site link failed. Set NETLIFY_SITE_ID and re-run (do not use netlify 
init)." >&2
+    exit 1
   fi
 }
 
@@ -240,9 +213,10 @@ deploy_netlify() {
   link_or_create_site
 
   local args=(deploy --dir="${SITE_DIR}" --message "unomi-site preview $(date 
-u +%Y-%m-%dT%H:%MZ)")
-  if [[ "${MODE}" == "prod" ]]; then
+  if [[ "${MODE}" == "netlify-prod" ]]; then
     args+=(--prod)
-    echo "==> Deploying to Netlify production URL for linked site"
+    echo "==> Deploying to Netlify site production URL (cleaner share link)"
+    echo "    This is NOT https://unomi.apache.org/ — use ./publish.sh for ASF 
production"
   else
     echo "==> Deploying Netlify draft preview (unique URL)"
   fi
@@ -255,14 +229,40 @@ deploy_netlify() {
     exit 1
   fi
 
-  local url
-  url="$(grep -Eo 'https://[^ ]+\.netlify\.app/?[^ ]*' "${log}" | tail -1 || 
true)"
+  local url=""
+  # Prefer the labelled URL for the mode; Netlify prints angle-bracketed links.
+  if [[ "${MODE}" == "netlify-prod" ]]; then
+    url="$(grep -E 'Production URL:' "${log}" \
+      | grep -Eo 'https://[^ >]+' \
+      | head -1 \
+      | tr -d '<>' || true)"
+  else
+    url="$(grep -E 'Draft URL:' "${log}" \
+      | grep -Eo 'https://[^ >]+' \
+      | head -1 \
+      | tr -d '<>' || true)"
+  fi
+  if [[ -z "${url}" ]]; then
+    # Fallback: first *.netlify.app that is NOT a unique deploy subdomain (has 
--)
+    url="$(grep -Eo 'https://[^ >]+\.netlify\.app' "${log}" \
+      | tr -d '<>' \
+      | grep -v -- '--' \
+      | head -1 || true)"
+  fi
+  if [[ -z "${url}" ]]; then
+    url="$(grep -Eo 'https://[^ >]+\.netlify\.app' "${log}" | tr -d '<>' | 
tail -1 || true)"
+  fi
   rm -f "${log}"
 
   if [[ -n "${url}" ]]; then
     echo ""
-    echo "==> Preview URL: ${url}"
-    if [[ "${OPEN_URL}" -eq 1 ]] && command -v open >/dev/null 2>&1; then
+    if [[ "${MODE}" == "netlify-prod" ]]; then
+      echo "==> Share URL: ${url}"
+    else
+      echo "==> Preview URL: ${url}"
+      echo "    Tip: ./preview.sh --prod for the stable 
https://${NETLIFY_SITE_NAME}.netlify.app link"
+    fi
+    if [[ "${OPEN_URL}" -eq 1 ]] && have_cmd open; then
       open "${url}"
     fi
   else
@@ -277,7 +277,9 @@ serve_local() {
     --volume="${ROOT}:/site" \
     -p 4000:4000 \
     -w /site \
-    "${JEKYLL_SERVE_IMAGE}"
+    --entrypoint bash \
+    "${JEKYLL_SERVE_IMAGE}" \
+    -lc 'bundle install && bundle exec jekyll serve --host 0.0.0.0 --port 4000'
 }
 
 check_prerequisites
@@ -296,7 +298,7 @@ case "${MODE}" in
   local)
     serve_local
     ;;
-  draft|prod)
+  draft|netlify-prod)
     deploy_netlify
     ;;
   *)
diff --git a/publish.sh b/publish.sh
new file mode 100755
index 0000000..a24af64
--- /dev/null
+++ b/publish.sh
@@ -0,0 +1,339 @@
+#!/usr/bin/env bash
+# Build the Apache Unomi website and publish to production 
(https://unomi.apache.org/).
+#
+# Uses Maven scm-publish to SVN (see pom.xml). This is NOT Netlify — use 
./preview.sh
+# for personal/community staging previews.
+#
+# Usage:
+#   ./publish.sh --username ASF_ID --password 'SECRET'
+#   ./publish.sh -u ASF_ID -p 'SECRET'
+#   ./publish.sh --username ASF_ID          # prompts for password (no echo)
+#   ./publish.sh --check-only -u ASF_ID -p 'SECRET'
+#   ./publish.sh --dry-run -u ASF_ID -p 'SECRET'
+#   ./publish.sh --skip-build -u ASF_ID -p 'SECRET'   # reuse existing 
target/site
+#
+# Credentials may also come from the environment (never commit these):
+#   ASF_USERNAME / ASF_PASSWORD
+#   or APACHE_USERNAME / APACHE_PASSWORD
+#
+# Optional:
+#   JEKYLL_IMAGE   Docker image (default: bretfisher/jekyll)
+#   --yes          Skip the interactive confirmation prompt
+#
+# Important: do not run Maven clean — it would wipe previously published trees
+# that scm-publish intentionally preserves under docs/, manual/, etc.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=scripts/lib/site-common.sh
+source "${ROOT}/scripts/lib/site-common.sh"
+cd "$ROOT"
+
+ASF_USERNAME="${ASF_USERNAME:-${APACHE_USERNAME:-}}"
+ASF_PASSWORD="${ASF_PASSWORD:-${APACHE_PASSWORD:-}}"
+MODE="publish"          # publish | check-only | dry-run
+SKIP_BUILD=0
+ASSUME_YES=0
+SVN_URL="https://svn.apache.org/repos/asf/unomi/website/";
+PROD_URL="https://unomi.apache.org/";
+
+usage() {
+  sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
+  exit "${1:-0}"
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    -u|--username)
+      ASF_USERNAME="${2:-}"
+      shift 2
+      ;;
+    -p|--password)
+      ASF_PASSWORD="${2:-}"
+      shift 2
+      ;;
+    --check-only) MODE="check-only"; shift ;;
+    --dry-run) MODE="dry-run"; shift ;;
+    --skip-build) SKIP_BUILD=1; shift ;;
+    -y|--yes) ASSUME_YES=1; shift ;;
+    -h|--help) usage 0 ;;
+    *)
+      echo "Unknown option: $1" >&2
+      usage 1
+      ;;
+  esac
+done
+
+prompt_password_if_needed() {
+  if [[ -n "${ASF_PASSWORD}" ]]; then
+    return 0
+  fi
+  if [[ ! -t 0 ]]; then
+    echo "Password required: pass --password, or set ASF_PASSWORD (stdin is 
not a TTY)" >&2
+    exit 1
+  fi
+  echo -n "ASF LDAP password for '${ASF_USERNAME}': " >&2
+  # shellcheck disable=SC2162
+  read -s ASF_PASSWORD
+  echo "" >&2
+}
+
+check_pom_publish_config() {
+  if [[ ! -f "${ROOT}/pom.xml" ]]; then
+    check_fail "pom.xml missing"
+    return
+  fi
+  check_pass "pom.xml present"
+
+  if grep -q 'maven-scm-publish-plugin' "${ROOT}/pom.xml"; then
+    check_pass "maven-scm-publish-plugin configured"
+  else
+    check_fail "pom.xml missing maven-scm-publish-plugin"
+  fi
+
+  if grep -q 'scm:svn:https://svn.apache.org/repos/asf/unomi/website/' 
"${ROOT}/pom.xml"; then
+    check_pass "pubScmUrl points at ${SVN_URL}"
+  else
+    check_fail "pom.xml pubScmUrl is not the Unomi website SVN URL"
+  fi
+
+  if grep -q 'project.build.directory}/site' "${ROOT}/pom.xml"; then
+    check_pass "scm-publish content is target/site"
+  else
+    check_warn "could not confirm scm-publish <content> is target/site"
+  fi
+
+  if grep -q 'ignorePathsToDelete' "${ROOT}/pom.xml"; then
+    check_pass "ignorePathsToDelete present (docs/manual/api trees preserved)"
+  else
+    check_warn "ignorePathsToDelete missing — publish may delete docs/manual 
trees"
+  fi
+}
+
+check_maven_java() {
+  if have_cmd mvn; then
+    check_pass "mvn found ($(mvn -v 2>/dev/null | head -1 || echo ok))"
+  else
+    check_fail "mvn not found — install Apache Maven"
+  fi
+
+  if have_cmd java; then
+    check_pass "java found ($(java -version 2>&1 | head -1 || echo ok))"
+  else
+    check_fail "java not found — install a JDK for Maven"
+  fi
+
+  if [[ -n "${JAVA_HOME:-}" ]]; then
+    if [[ -x "${JAVA_HOME}/bin/java" ]]; then
+      check_pass "JAVA_HOME is set (${JAVA_HOME})"
+    else
+      check_warn "JAVA_HOME is set but ${JAVA_HOME}/bin/java is not executable"
+    fi
+  else
+    check_warn "JAVA_HOME unset — Maven may still work via PATH"
+  fi
+}
+
+check_credentials() {
+  if [[ -z "${ASF_USERNAME}" ]]; then
+    check_fail "ASF username missing — pass --username / -u (or ASF_USERNAME)"
+  elif [[ "${ASF_USERNAME}" =~ ^[a-z][a-z0-9_-]*$ ]]; then
+    check_pass "ASF username looks plausible (${ASF_USERNAME})"
+  else
+    check_fail "ASF username '${ASF_USERNAME}' looks invalid (expected 
lowercase ASF id)"
+  fi
+
+  if [[ -z "${ASF_PASSWORD}" ]]; then
+    if [[ "${MODE}" == "check-only" ]] && [[ ! -t 0 ]]; then
+      check_warn "password not set yet (check-only without TTY — will not 
prompt)"
+    elif [[ "${MODE}" == "check-only" ]]; then
+      check_warn "password not set yet (will prompt before publish unless 
provided)"
+    else
+      check_warn "password not set yet — will prompt securely before publish"
+    fi
+  else
+    if [[ "${#ASF_PASSWORD}" -lt 8 ]]; then
+      check_fail "password looks too short (< 8 chars)"
+    else
+      check_pass "ASF password is set (${#ASF_PASSWORD} chars, value not 
printed)"
+    fi
+  fi
+}
+
+check_network_svn() {
+  if ! have_cmd curl; then
+    check_warn "curl not found — skipping SVN reachability check"
+    return
+  fi
+  if curl -fsS --connect-timeout 8 -o /dev/null -I "${SVN_URL}" 2>/dev/null \
+    || curl -fsS --connect-timeout 8 -o /dev/null "https://svn.apache.org/"; 
2>/dev/null; then
+    check_pass "network reachability to svn.apache.org"
+  else
+    check_fail "cannot reach svn.apache.org — publish will fail offline"
+  fi
+}
+
+check_svn_auth_if_possible() {
+  if [[ -z "${ASF_USERNAME}" ]] || [[ -z "${ASF_PASSWORD}" ]]; then
+    check_warn "skipping SVN credential probe (username/password incomplete)"
+    return
+  fi
+  if ! have_cmd svn; then
+    check_warn "svn CLI not installed — skipping live credential probe (Maven 
will still auth)"
+    return
+  fi
+
+  # Non-interactive auth against the publish URL. Failure here almost always
+  # means bad credentials or missing commit karma on the Unomi website tree.
+  local svn_out
+  svn_out="$(mktemp)"
+  if SVN_SSH="" svn info "${SVN_URL}" \
+      --non-interactive \
+      --username "${ASF_USERNAME}" \
+      --password "${ASF_PASSWORD}" \
+      --no-auth-cache \
+      >"${svn_out}" 2>&1; then
+    check_pass "SVN credentials accepted for ${SVN_URL}"
+  else
+    check_fail "SVN credential probe failed for ${SVN_URL}"
+    echo "         (wrong password, or account lacks commit access to the 
Unomi website)" >&2
+    head -5 "${svn_out}" >&2 || true
+  fi
+  rm -f "${svn_out}"
+}
+
+check_git_hygiene() {
+  if ! have_cmd git || [[ ! -d "${ROOT}/.git" ]]; then
+    check_warn "not a git checkout — skipping dirty-tree warning"
+    return
+  fi
+  if git -C "${ROOT}" status --porcelain 2>/dev/null | grep -q .; then
+    check_warn "working tree has uncommitted changes — confirm you intend to 
publish them"
+  else
+    check_pass "git working tree is clean"
+  fi
+}
+
+check_no_clean_trap() {
+  # Soft guard: refuse if someone aliased/wrapped this script oddly; real
+  # protection is never invoking `mvn clean` in publish_site().
+  check_pass "publish will not run Maven clean (preserves docs/manual on SVN)"
+}
+
+check_prerequisites() {
+  echo "==> Prerequisite checks (mode: ${MODE})"
+  CHECKS_FAILED=0
+
+  check_project_layout
+  check_jekyll_config
+  check_pom_publish_config
+  check_docker_build_env
+  check_maven_java
+  check_credentials
+  check_network_svn
+  check_svn_auth_if_possible
+  check_git_hygiene
+  check_no_clean_trap
+
+  if [[ "${SKIP_BUILD}" -eq 1 ]]; then
+    if [[ -d "${ROOT}/${SITE_DIR}" ]]; then
+      check_pass "--skip-build: ${SITE_DIR} exists (will validate before 
publish)"
+    else
+      check_fail "--skip-build set but ${SITE_DIR} is missing — build first"
+    fi
+  fi
+
+  finish_prerequisite_checks
+}
+
+confirm_production_publish() {
+  if [[ "${ASSUME_YES}" -eq 1 ]]; then
+    echo "==> Confirmation skipped (--yes)"
+    return 0
+  fi
+  if [[ ! -t 0 ]]; then
+    echo "Refusing non-interactive production publish without --yes" >&2
+    exit 1
+  fi
+  echo ""
+  echo "About to publish ${SITE_DIR} to production:"
+  echo "  SVN : ${SVN_URL}"
+  echo "  URL : ${PROD_URL}"
+  echo "  User: ${ASF_USERNAME}"
+  if [[ "${MODE}" == "dry-run" ]]; then
+    echo "  Mode: DRY RUN (no commit)"
+  else
+    echo "  Mode: LIVE commit to ASF SVN"
+  fi
+  echo ""
+  echo -n "Type 'publish' to continue: " >&2
+  local answer
+  # shellcheck disable=SC2162
+  read answer
+  if [[ "${answer}" != "publish" ]]; then
+    echo "Aborted." >&2
+    exit 1
+  fi
+}
+
+publish_site() {
+  local mvn_args=(
+    install
+    scm-publish:publish-scm
+    "-Dusername=${ASF_USERNAME}"
+    "-Dpassword=${ASF_PASSWORD}"
+  )
+
+  if [[ "${MODE}" == "dry-run" ]]; then
+    mvn_args+=("-Dscmpublish.dryRun=true")
+    echo "==> Maven scm-publish DRY RUN (no SVN commit)"
+  else
+    echo "==> Publishing to ${SVN_URL}"
+  fi
+
+  # Intentionally never: mvn clean
+  mvn "${mvn_args[@]}"
+
+  echo ""
+  if [[ "${MODE}" == "dry-run" ]]; then
+    echo "==> Dry run finished (nothing committed)"
+  else
+    echo "==> Publish finished"
+    echo "    Live site: ${PROD_URL}"
+    echo "    (CDN/cache may take a few minutes to refresh)"
+  fi
+}
+
+check_prerequisites
+
+if [[ "${MODE}" == "check-only" ]]; then
+  if [[ "${SKIP_BUILD}" -eq 1 ]] && [[ -d "${ROOT}/${SITE_DIR}" ]]; then
+    validate_built_site
+  fi
+  echo "==> Done (check only) — nothing published"
+  exit 0
+fi
+
+prompt_password_if_needed
+
+# Re-run credential presence + optional SVN probe now that password is 
available.
+CHECKS_FAILED=0
+echo "==> Post-prompt credential checks"
+if [[ -z "${ASF_PASSWORD}" ]]; then
+  check_fail "password still empty after prompt"
+else
+  check_pass "password available for Maven/SVN"
+fi
+check_svn_auth_if_possible
+finish_prerequisite_checks
+
+if [[ "${SKIP_BUILD}" -eq 1 ]]; then
+  echo "==> Skipping Jekyll build (--skip-build)"
+  validate_built_site
+else
+  build_site
+fi
+
+confirm_production_publish
+publish_site
diff --git a/scripts/lib/site-common.sh b/scripts/lib/site-common.sh
new file mode 100644
index 0000000..b52f980
--- /dev/null
+++ b/scripts/lib/site-common.sh
@@ -0,0 +1,201 @@
+#!/usr/bin/env bash
+# Shared helpers for preview.sh and publish.sh (Apache Unomi website).
+# shellcheck disable=SC2034
+
+# Caller must set ROOT to the repository root before sourcing, or we infer it.
+if [[ -z "${ROOT:-}" ]]; then
+  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+fi
+
+JEKYLL_IMAGE="${JEKYLL_IMAGE:-bretfisher/jekyll}"
+JEKYLL_SERVE_IMAGE="${JEKYLL_SERVE_IMAGE:-bretfisher/jekyll-serve}"
+SITE_DIR="${SITE_DIR:-target/site}"
+CHECKS_FAILED=0
+
+have_cmd() {
+  command -v "$1" >/dev/null 2>&1
+}
+
+need_cmd() {
+  if ! have_cmd "$1"; then
+    echo "Missing required command: $1" >&2
+    return 1
+  fi
+  return 0
+}
+
+check_pass() { echo "  [ok] $1"; }
+check_fail() { echo "  [FAIL] $1" >&2; CHECKS_FAILED=1; }
+check_warn() { echo "  [warn] $1" >&2; }
+
+finish_prerequisite_checks() {
+  if [[ "${CHECKS_FAILED}" -ne 0 ]]; then
+    echo "" >&2
+    echo "Prerequisite checks failed. Fix the items marked [FAIL] and re-run." 
>&2
+    exit 1
+  fi
+  echo "==> Prerequisites OK"
+  echo ""
+}
+
+check_project_layout() {
+  if [[ -f "${ROOT}/_config.yml" ]]; then
+    check_pass "_config.yml present"
+  else
+    check_fail "_config.yml missing — run from the unomi-site repo root"
+  fi
+
+  if [[ -d "${ROOT}/src/main/webapp" ]]; then
+    check_pass "src/main/webapp present"
+  else
+    check_fail "src/main/webapp missing — unexpected project layout"
+  fi
+
+  if [[ -f "${ROOT}/Gemfile" ]]; then
+    check_pass "Gemfile present"
+  else
+    check_fail "Gemfile missing — Docker Jekyll build needs it"
+  fi
+}
+
+check_docker_build_env() {
+  if have_cmd docker; then
+    check_pass "docker found ($(command -v docker))"
+    if docker info >/dev/null 2>&1; then
+      check_pass "Docker daemon is running"
+    else
+      check_fail "Docker daemon is not running — start Docker Desktop / 
dockerd"
+    fi
+  else
+    check_fail "docker not found — install Docker (required to build with 
${JEKYLL_IMAGE})"
+  fi
+
+  if mkdir -p "${ROOT}/target" 2>/dev/null && [[ -w "${ROOT}/target" ]]; then
+    check_pass "target/ is writable"
+  else
+    check_fail "cannot create/write ${ROOT}/target"
+  fi
+}
+
+check_jekyll_config() {
+  local dest url
+  dest="$(grep -E '^destination:' "${ROOT}/_config.yml" 2>/dev/null | awk 
'{print $2}' | tr -d '"' || true)"
+  url="$(grep -E '^url:' "${ROOT}/_config.yml" 2>/dev/null | awk '{print $2}' 
| tr -d '"' || true)"
+
+  if [[ "${dest}" == "target/site" ]]; then
+    check_pass "_config.yml destination is target/site"
+  elif [[ -n "${dest}" ]]; then
+    check_fail "_config.yml destination is '${dest}' (expected target/site)"
+  else
+    check_warn "could not parse destination from _config.yml"
+  fi
+
+  if [[ "${url}" == "https://unomi.apache.org"; ]]; then
+    check_pass "_config.yml url is https://unomi.apache.org";
+  elif [[ -n "${url}" ]]; then
+    check_warn "_config.yml url is '${url}' (expected https://unomi.apache.org 
for production)"
+  fi
+}
+
+build_site() {
+  echo "==> Building site with Docker (${JEKYLL_IMAGE})"
+  mkdir -p "${ROOT}/target"
+  # The repo Gemfile is mounted into the image, so Bundler must install gems
+  # into the project before jekyll build. A bare `jekyll build` fails with
+  # GemNotFound when Gemfile.lock exists locally.
+  docker run --rm \
+    --volume="${ROOT}:/site" \
+    -w /site \
+    --entrypoint bash \
+    "${JEKYLL_IMAGE}" \
+    -lc 'bundle install && bundle exec jekyll build'
+
+  validate_built_site
+  echo "==> Build OK → ${SITE_DIR}"
+}
+
+# Strong checks on target/site before any deploy/publish.
+validate_built_site() {
+  local site_path="${ROOT}/${SITE_DIR}"
+  local file_count html_count
+  local required=(
+    "index.html"
+    "download.html"
+    "documentation.html"
+    "get-started.html"
+    "sitemap.xml"
+    "robots.txt"
+    "assets"
+    "blog"
+  )
+
+  echo "==> Validating built site (${SITE_DIR})"
+
+  if [[ ! -d "${site_path}" ]]; then
+    echo "Validation failed: ${SITE_DIR} directory missing" >&2
+    exit 1
+  fi
+
+  if [[ -z "$(ls -A "${site_path}" 2>/dev/null || true)" ]]; then
+    echo "Validation failed: ${SITE_DIR} is empty" >&2
+    exit 1
+  fi
+
+  local missing=0
+  local item
+  for item in "${required[@]}"; do
+    if [[ ! -e "${site_path}/${item}" ]]; then
+      echo "  [FAIL] missing required path: ${SITE_DIR}/${item}" >&2
+      missing=1
+    else
+      echo "  [ok] ${SITE_DIR}/${item}"
+    fi
+  done
+  if [[ "${missing}" -ne 0 ]]; then
+    echo "Validation failed: required site paths missing" >&2
+    exit 1
+  fi
+
+  if ! grep -qi 'Apache Unomi' "${site_path}/index.html"; then
+    echo "Validation failed: index.html does not look like the Unomi site" >&2
+    exit 1
+  fi
+  echo "  [ok] index.html contains 'Apache Unomi'"
+
+  # Unrendered Liquid is a strong signal the build was wrong.
+  if grep -R -E -l '\{\{|\{\%' \
+      --include='*.html' --include='*.xml' --include='*.txt' \
+      "${site_path}" 2>/dev/null \
+      | grep -v '/assets/' \
+      | head -5 \
+      | grep -q .; then
+    echo "  [warn] possible unrendered Liquid tags under ${SITE_DIR} 
(spot-check output)" >&2
+    grep -R -n -E '\{\{|\{\%' \
+      --include='*.html' --include='*.xml' --include='*.txt' \
+      "${site_path}" 2>/dev/null \
+      | grep -v '/assets/' \
+      | head -10 >&2 || true
+  else
+    echo "  [ok] no obvious unrendered Liquid tags in HTML/XML/TXT"
+  fi
+
+  file_count="$(find "${site_path}" -type f | wc -l | tr -d ' ')"
+  html_count="$(find "${site_path}" -type f -name '*.html' | wc -l | tr -d ' 
')"
+  if [[ "${file_count}" -lt 50 ]]; then
+    echo "Validation failed: only ${file_count} files under ${SITE_DIR} 
(expected ≥ 50)" >&2
+    exit 1
+  fi
+  if [[ "${html_count}" -lt 20 ]]; then
+    echo "Validation failed: only ${html_count} HTML files (expected ≥ 20)" >&2
+    exit 1
+  fi
+  echo "  [ok] ${file_count} files (${html_count} HTML)"
+
+  # Refuse to publish a tree that still looks like a Jekyll source tree.
+  if [[ -d "${site_path}/_layouts" ]] || [[ -d "${site_path}/_includes" ]]; 
then
+    echo "Validation failed: ${SITE_DIR} contains Jekyll source dirs 
(_layouts/_includes)" >&2
+    exit 1
+  fi
+  echo "  [ok] output does not look like Jekyll source"
+  echo "==> Site validation OK"
+}


Reply via email to