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

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 7ff0ffab7777695cf589423c1683eff648ffc937
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 22:15:21 2026 +0800

    release: post-vote finalize script + single from-source Dockerfile
    
    Add scripts/release-finalize.sh for the post-[VOTE] half of the release:
    SVN move dev->release (auto-creating the release/ path, pruning the prior
    release), GitHub release attaching the voted artifacts, and a multi-arch
    Docker Hub push to apache/skywalking-ui under horizon-<ver>/horizon-latest
    (+ optional latest). Harden release.sh to create the dev/ SVN folder too.
    
    Collapse the two Dockerfiles into one from-source multi-stage build (the
    copy-in image cannot go multi-arch from a single host: dist/node_modules
    carries an arch-specific argon2 binding). CI builds each arch natively, so
    the runner-side pnpm package step is dropped. Flip .dockerignore from a
    copy-in allowlist to a from-source denylist. Final shipped image is
    unchanged (~201MB; the build stage is discarded).
    
    Bump Node base + CI to 24, engines floor to >=22. Add a Docker Hub README
    documenting the shared-repo tag split (booster = 9.x/10.x/latest, Horizon
    = horizon-*).
---
 .dockerignore                        |  27 ++-
 .github/workflows/ci.yaml            |  10 +-
 .github/workflows/publish-image.yaml |  48 ++---
 Dockerfile                           |  73 ++++----
 README.md                            |   3 +-
 dist-material/docker-hub/README.md   |  76 ++++++++
 docs/setup/container-image.md        |  33 ++--
 package.json                         |   2 +-
 scripts/package.mjs                  |   4 +-
 scripts/release-finalize.sh          | 328 +++++++++++++++++++++++++++++++++++
 scripts/release.sh                   |  26 ++-
 11 files changed, 521 insertions(+), 109 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index b37888f..d3ea2be 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,13 +13,22 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# Allowlist style: the image is a pure copy-in of `./dist/`, so the
-# Docker context never needs anything else. Excluding everything by
-# default keeps the context tiny, the build deterministic, and
-# eliminates any temptation to reach back into source from a future
-# Dockerfile change.
+# Denylist style: the image builds FROM SOURCE (see Dockerfile), so the
+# context is the source tree. Exclude only what must NOT enter the build —
+# host-built artifacts (arch-specific node_modules + dist), VCS, CI config,
+# local secrets, and scratch dirs. Everything else (source, lockfile,
+# horizon.example.yaml) is needed by `pnpm install` + `pnpm package`.
 
-*
-!dist
-!dist/**
-!Dockerfile
+**/node_modules
+**/dist
+.git
+.github
+.pnpm-store
+**/.DS_Store
+scripts/.release-work
+scripts/.finalize-work
+.claude
+
+# Local runtime config may carry secrets — never ship it. The committed
+# horizon.example.yaml stays (package.mjs reads it into dist/).
+horizon.yaml
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index d7f22a4..d9781fe 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -58,7 +58,7 @@ jobs:
       - uses: pnpm/action-setup@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
           cache: 'pnpm'
       - run: pnpm install --frozen-lockfile
       - name: Verify binary LICENSE/NOTICE (drift + ASF allow/deny)
@@ -102,7 +102,7 @@ jobs:
       - uses: pnpm/action-setup@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
           cache: 'pnpm'
       - run: pnpm install --frozen-lockfile
       - run: pnpm -r run type-check
@@ -118,7 +118,7 @@ jobs:
       - uses: pnpm/action-setup@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
           cache: 'pnpm'
       - run: pnpm install --frozen-lockfile
       - run: pnpm --filter @skywalking-horizon-ui/ui build
@@ -134,7 +134,7 @@ jobs:
       - uses: pnpm/action-setup@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
           cache: 'pnpm'
       - run: pnpm install --frozen-lockfile
       - run: pnpm --filter @skywalking-horizon-ui/bff build
@@ -150,7 +150,7 @@ jobs:
       - uses: pnpm/action-setup@v4
       - uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '24'
           cache: 'pnpm'
       - run: pnpm install --frozen-lockfile
       # `pnpm -r` walks every workspace package and runs `test:unit` —
diff --git a/.github/workflows/publish-image.yaml 
b/.github/workflows/publish-image.yaml
index 4387d5c..baaeae2 100644
--- a/.github/workflows/publish-image.yaml
+++ b/.github/workflows/publish-image.yaml
@@ -19,17 +19,16 @@
 #   - any `v*` tag    (tagged with the version + the full commit SHA)
 #
 # Architecture story
-#   The image is a pure copy-in of `./dist/` (no compile or network
-#   inside `docker build`). The `dist/node_modules/` carries `argon2`'s
-#   native binding — that binding is platform-specific, so a single
-#   amd64-built dist/ cannot be packaged into an arm64-correct image.
+#   The Dockerfile builds `dist/` from source inside the image. The
+#   resulting `node_modules/` carries `argon2`'s native binding — that
+#   binding is platform-specific, so the build must run per-architecture.
 #   We build each architecture on its own native GitHub runner
-#   (`ubuntu-latest` for amd64, `ubuntu-24.04-arm` for arm64), push a
-#   per-arch SHA-suffix tag, and a final `manifest` job stitches them
-#   into a single SHA-canonical OCI manifest list. Pulling the canonical
-#   tag from any host (Apple Silicon, AWS Graviton, plain x86_64)
-#   selects the right arch automatically — no Rosetta / QEMU emulation
-#   needed at run time.
+#   (`ubuntu-latest` for amd64, `ubuntu-24.04-arm` for arm64) so the
+#   compile is native (no QEMU), push a per-arch SHA-suffix tag, and a
+#   final `manifest` job stitches them into a single SHA-canonical OCI
+#   manifest list. Pulling the canonical tag from any host (Apple Silicon,
+#   AWS Graviton, plain x86_64) selects the right arch automatically — no
+#   Rosetta / QEMU emulation needed at run time.
 #
 # Tagging
 #   - `:<40-char-sha>`           always (canonical, immutable)
@@ -42,7 +41,7 @@
 #     pull a specific arch directly. The manifest job is the only thing
 #     that exposes the canonical tag without an arch suffix.
 #
-# Only login / setup-buildx / setup-node are pulled from third-party
+# Only login / setup-buildx / checkout are pulled from third-party
 # actions — everything else (tag computation, buildx build+push,
 # manifest stitching) is shell-driven to stay within ASF infra's
 # third-party-action allow-list. SHAs are vetted at the org level.
@@ -104,10 +103,11 @@ jobs:
           echo "::notice::Canonical SHA: ${sha}"
           echo "::notice::Moving tags: ${moving}"
 
-  # ── Per-arch native build. Each runner produces its own `./dist/`
-  # ── (with the correct argon2 native binding) and pushes
-  # ── `<base>:<sha>-<arch>`. The manifest job below stitches the
-  # ── `:<sha>-amd64` + `:<sha>-arm64` pair into the canonical tag set.
+  # ── Per-arch native build. The Dockerfile builds `dist/` from source
+  # ── inside the image, so each native runner compiles the correct
+  # ── argon2 binding for its arch and pushes `<base>:<sha>-<arch>`. The
+  # ── manifest job below stitches the `:<sha>-amd64` + `:<sha>-arm64`
+  # ── pair into the canonical tag set.
   build:
     if: github.repository == 'apache/skywalking-horizon-ui'
     needs: tags
@@ -135,24 +135,6 @@ jobs:
         with:
           persist-credentials: false
 
-      - name: Set up Node
-        uses: actions/setup-node@v4
-        with:
-          node-version: 20
-
-      - name: Activate pnpm
-        run: corepack enable && corepack prepare [email protected] --activate
-
-      - name: Install workspace deps
-        run: pnpm install --frozen-lockfile
-
-      - name: Build self-contained ./dist/ (native ${{ matrix.arch }})
-        # `pnpm package` runs `pnpm deploy` whose `node_modules` contains
-        # the argon2 binding for the current runner's architecture.
-        # Running this on `ubuntu-24.04-arm` produces an arm64-correct
-        # tree; on `ubuntu-latest` it's amd64.
-        run: pnpm package
-
       - name: Set up Docker Buildx
         uses: 
docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
 
diff --git a/Dockerfile b/Dockerfile
index 336b28a..b54ffb2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,48 +13,55 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# Copy-in image. The image does NOT compile anything and does NOT run
-# `pnpm install` — it consumes the pre-built `./dist/` produced by
-# `pnpm package` at the repo root and lays it out under `/app/`. Build
-# the artifact first:
+# From-source, multi-arch image. Single source of truth for every image
+# build: CI (GHCR), local dev, and the manual Docker Hub release push.
 #
-#     pnpm install            # one-time / on lockfile changes
-#     pnpm package            # produces ./dist/ (server.js + node_modules
-#                             # + bundled_templates + static + example yaml)
-#     docker build -t horizon-ui:local .
+# Why from-source (not copy-in): `dist/node_modules` carries argon2's
+# native binding, which is architecture-specific. Building `dist/` INSIDE
+# the image — once per target platform — lets one `buildx --platform
+# linux/amd64,linux/arm64` invocation compile the correct binding for each
+# arch (native on a matching runner, QEMU-emulated otherwise). A copy-in
+# image cannot do this from a single host.
 #
-# Net effect: tiny image (no Node toolchain, no devDeps, no pnpm store,
-# no source), reproducible (the dist/ tarball is the contract), and
-# air-gap-friendly (image build needs zero network).
+#     docker build -t horizon-ui:local .          # single-arch dev build
+#     docker buildx build --platform linux/amd64,linux/arm64 -t … --push .
+#
+# Trade-off: the build needs the network (`pnpm install`) — but only in the
+# throwaway build stage. The final shipped stage is network-free.
+
+# ---- build stage: compile the self-contained dist for THIS platform ----
+# Throwaway BUILD ENVIRONMENT — uses the network (`pnpm install`) and
+# carries the compiler toolchain. Discarded; only the final stage ships.
+FROM node:24-alpine AS build
+WORKDIR /src
+
+# Toolchain for argon2's native build (node-gyp needs python3 + make + g++).
+RUN apk add --no-cache python3 make g++
+
+# corepack ships with node:24; pin pnpm to the workspace version.
+RUN corepack enable && corepack prepare [email protected] --activate
 
-FROM node:20-alpine
+# Copy the whole source tree. `.dockerignore` keeps host-built artifacts
+# (node_modules, dist) out of the context so nothing arch-specific leaks in.
+COPY . .
+
+RUN pnpm install --frozen-lockfile
+RUN pnpm package
+
+# ---- runtime stage: the shipped image — no toolchain, no network ----
+FROM node:24-alpine
 WORKDIR /app
 
-# Run as a non-root user — the BFF doesn't need any privileged access.
 RUN addgroup -S horizon && adduser -S -G horizon horizon
 
-# Pre-built artifact. Layout matches what `node server.js` expects:
-# server.js + bundled_templates + node_modules + static all siblings
-# under /app/. The bundled-template loader probes `__dirname/bundled_
-# templates` first (see apps/bff/src/logic/layers/loader.ts).
-#
-# Read-only artifacts owned by root:
-COPY dist/server.js              ./server.js
-COPY dist/package.json           ./package.json
-COPY dist/node_modules           ./node_modules
-COPY dist/static                 ./static
-COPY dist/horizon.example.yaml   ./horizon.example.yaml
+COPY --from=build /src/dist/server.js              ./server.js
+COPY --from=build /src/dist/package.json           ./package.json
+COPY --from=build /src/dist/node_modules           ./node_modules
+COPY --from=build /src/dist/static                 ./static
+COPY --from=build /src/dist/horizon.example.yaml   ./horizon.example.yaml
 
-# `bundled_templates/` is writable: the admin Layer-Templates and
-# Overview-Templates editors `writeFileSync` into per-key / per-id JSON
-# files. Owned by the `horizon` user so saves don't EACCES.
-COPY --chown=horizon:horizon dist/bundled_templates  ./bundled_templates
+COPY --from=build --chown=horizon:horizon /src/dist/bundled_templates  
./bundled_templates
 
-# `/data` is the writable state directory the BFF writes its runtime
-# files into (audit log, setup state, alarm state, wire debug log).
-# Operators mount a PVC / named volume / host bind here for durable
-# storage. Without a mount the writes go to the container's writable
-# layer (ephemeral).
 RUN mkdir -p /data && chown horizon:horizon /data
 VOLUME ["/data"]
 
diff --git a/README.md b/README.md
index cffc23b..0bfa0a7 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,9 @@ pnpm -r test:unit
 pnpm license:check               # CI gate via skywalking-eyes
 ```
 
-Container build (zero-compile-in-image — Dockerfile just copies in the 
pre-built `dist/`):
+Container build (multi-stage — the Dockerfile builds `dist/` from source 
inside the image, no host pre-step):
 
 ```bash
-pnpm package                                                        # produces 
./dist/
 docker build -t horizon-ui:local .
 docker run --rm -p 8081:8081 -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" 
horizon-ui:local
 ```
diff --git a/dist-material/docker-hub/README.md 
b/dist-material/docker-hub/README.md
new file mode 100644
index 0000000..579341e
--- /dev/null
+++ b/dist-material/docker-hub/README.md
@@ -0,0 +1,76 @@
+# Apache SkyWalking Horizon UI
+
+> This repository (`apache/skywalking-ui`) is **shared** between two UIs.
+> Booster UI (the current production UI) owns the `9.x` / `10.x` tags.
+> **Horizon UI** (the next-generation UI) is published under **`horizon-`
+> prefixed tags**. Pick your tags accordingly.
+
+**Horizon UI** is the next-generation web UI for [Apache 
SkyWalking](https://skywalking.apache.org/) — a modernized, dense, dark-first 
dashboard built on the same OAP GraphQL query-protocol and MQE. It bundles both 
the backend-for-frontend (BFF) and the built UI in a single image; there is no 
separate frontend container.
+
+## Tags
+
+| Tag | Points at | Use |
+|---|---|---|
+| `horizon-X.Y.Z` | A specific Horizon release (e.g. `horizon-0.5.0`). 
Immutable. | **Production.** Pin to an exact version. |
+| `horizon-latest` | The newest Horizon release. Moves over time. | Track the 
latest Horizon line. |
+| `latest` | Newest image published to this repo. | Demos / dev only — do not 
pin production to `latest`. |
+
+```sh
+docker pull apache/skywalking-ui:horizon-0.5.0
+```
+
+A SHA-pinned, GHCR-hosted variant is also published at 
`ghcr.io/apache/skywalking-horizon-ui` for fully reproducible deploys.
+
+## Architectures
+
+Multi-arch manifest: `linux/amd64` and `linux/arm64`. Pulling the tag from any 
host selects the right architecture automatically.
+
+## Quick start
+
+The image expects a `horizon.yaml` config at `/app/horizon.yaml`. It is 
**not** baked into the image — provide it via bind-mount or your own layer. A 
reference config ships at `/app/horizon.example.yaml`.
+
+```sh
+# 1. Get the example config to start from
+docker run --rm apache/skywalking-ui:horizon-0.5.0 \
+  cat /app/horizon.example.yaml > horizon.yaml
+
+# 2. Edit horizon.yaml — point it at your OAP and set auth.
+
+# 3. Run, mounting your config + a durable data volume
+docker run -d --name horizon-ui \
+  -p 8081:8081 \
+  -v "$(pwd)/horizon.yaml:/app/horizon.yaml:ro" \
+  -v horizon-data:/data \
+  apache/skywalking-ui:horizon-0.5.0
+```
+
+Open <http://localhost:8081>.
+
+## Configuration
+
+| Path / Variable | Purpose |
+|---|---|
+| `/app/horizon.yaml` | Active config. Mount it here (`HORIZON_CONFIG` to 
relocate). |
+| `/app/horizon.example.yaml` | Read-only reference config. Copy from it. |
+| `/data/` | Declared `VOLUME`. Audit log, setup state, alarm state, wire 
debug log land here. Mount for durable storage. |
+| `/app/bundled_templates/` | Layer + overview dashboard templates. Writable 
by the admin template editors. |
+
+Key environment variables (all have sensible defaults in the image):
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `HORIZON_CONFIG` | `/app/horizon.yaml` | Where the BFF reads its config. |
+| `HORIZON_SERVER_HOST` / `HORIZON_SERVER_PORT` | `0.0.0.0` / `8081` | Bind 
address. The image `EXPOSE`s `8081`. |
+| `LOG_LEVEL` | `error` (production) | 
`trace`/`debug`/`info`/`warn`/`error`/`fatal`. |
+
+The container runs as the non-root user `horizon`.
+
+## Documentation
+
+* Project: <https://github.com/apache/skywalking-horizon-ui>
+* SkyWalking: <https://skywalking.apache.org/>
+* Issues: <https://github.com/apache/skywalking/issues>
+
+## License
+
+[Apache License 
2.0](https://github.com/apache/skywalking-horizon-ui/blob/main/LICENSE).
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 9035650..a98e293 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -15,7 +15,7 @@ Registry: **GitHub Container Registry (GHCR)** at 
`ghcr.io/apache/skywalking-hor
 | `main` | Head of `main`. Moves on every merge. | Smoke-test the development 
branch. |
 
 ```sh
-docker pull ghcr.io/apache/skywalking-horizon-ui:0.4.0
+docker pull ghcr.io/apache/skywalking-horizon-ui:0.5.0
 docker pull ghcr.io/apache/skywalking-horizon-ui:<sha>
 ```
 
@@ -65,7 +65,7 @@ docker run -d \
   --name horizon \
   -p 8081:8081 \
   -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
-  ghcr.io/apache/skywalking-horizon-ui:0.4.0
+  ghcr.io/apache/skywalking-horizon-ui:0.5.0
 ```
 
 Notes:
@@ -78,7 +78,7 @@ Notes:
 For immutable single-tenant deployments, build a child image that includes 
your config:
 
 ```dockerfile
-FROM ghcr.io/apache/skywalking-horizon-ui:0.4.0
+FROM ghcr.io/apache/skywalking-horizon-ui:0.5.0
 COPY horizon.yaml /app/horizon.yaml
 ```
 
@@ -146,7 +146,7 @@ spec:
         fsGroup: 101
       containers:
         - name: horizon
-          image: ghcr.io/apache/skywalking-horizon-ui:0.4.0
+          image: ghcr.io/apache/skywalking-horizon-ui:0.5.0
           ports:
             - containerPort: 8081
           envFrom:
@@ -188,7 +188,7 @@ docker run -d --name horizon \
   -p 8081:8081 \
   -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
   -v horizon-state:/data \
-  ghcr.io/apache/skywalking-horizon-ui:0.4.0
+  ghcr.io/apache/skywalking-horizon-ui:0.5.0
 ```
 
 Without a mounted volume the writes still land in the container's writable 
layer at `/data/` (ephemeral, but at least non-failing). Mounting a volume is 
what makes them durable.
@@ -239,7 +239,7 @@ NODE_ENV=production LOG_LEVEL=info node dist/server.js
 
 ### Per-request logging
 
-Fastify's request logger is on by default and emits one `incoming request` 
line + one `request completed` line per HTTP request, both tagged with a stable 
`reqId`. These are level-`info` (30) events — **suppressed under the production 
default `error`**. Bump to `LOG_LEVEL=info` to surface them; example pair under 
that level:
+The server request logger is on by default and emits one `incoming request` 
line + one `request completed` line per HTTP request, both tagged with a stable 
`reqId`. These are level-`info` (30) events — **suppressed under the production 
default `error`**. Bump to `LOG_LEVEL=info` to surface them; example pair under 
that level:
 
 ```json
 
{"level":30,"time":1779109372598,"pid":1,"hostname":"...","reqId":"req-1","req":{"method":"GET","url":"/api/auth/health","host":"127.0.0.1:8081","remoteAddress":"192.168.65.1","remotePort":60655},"msg":"incoming
 request"}
@@ -299,14 +299,18 @@ so session cookies are flagged `Secure` and the browser 
refuses to send them ove
 
 ## Building locally
 
-The image is built from the pre-packaged `./dist/` directory in the repo root. 
Build that artifact first:
+The image is a multi-stage build: it compiles the app from source inside the 
build stage, so there is no host pre-step — `docker build` is self-contained 
(it only needs network for `pnpm install` during the build).
+
+Single-arch dev build:
 
 ```sh
-pnpm install
-pnpm package
+docker build -t horizon:local .
+docker run --rm -it -p 8081:8081 \
+  -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
+  horizon:local
 ```
 
-Then use the same `docker buildx` invocation shape as CI:
+Multi-arch build (same shape as CI — needs a `docker-container` buildx builder 
and QEMU for the non-native arch):
 
 ```sh
 docker buildx build \
@@ -315,15 +319,6 @@ docker buildx build \
   .
 ```
 
-For a single-arch dev build (faster):
-
-```sh
-docker build -t horizon:local .
-docker run --rm -it -p 8081:8081 \
-  -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" \
-  horizon:local
-```
-
 ## Health probes
 
 Wire your platform's readiness probe to one of:
diff --git a/package.json b/package.json
index 608b36e..c91f1c2 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
   "license": "Apache-2.0",
   "type": "module",
   "engines": {
-    "node": ">=20"
+    "node": ">=22"
   },
   "packageManager": "[email protected]",
   "scripts": {
diff --git a/scripts/package.mjs b/scripts/package.mjs
index 8ec251b..7355445 100644
--- a/scripts/package.mjs
+++ b/scripts/package.mjs
@@ -78,8 +78,8 @@ step('Building UI (vite production build)');
 run('pnpm --filter @skywalking-horizon-ui/ui build');
 
 step('Materializing production install tree (pnpm deploy)');
-// `--legacy` is required under pnpm 10+ for non-injected workspaces; see
-// the matching note in the Dockerfile. We deploy directly into ./dist
+// `--legacy` is required under pnpm 10+ for non-injected workspaces.
+// We deploy directly into ./dist
 // (renaming through an intermediate) rather than copying out, because
 // the produced node_modules contains pnpm-style symlinks into an
 // in-tree `.pnpm/` store — copying would either preserve broken
diff --git a/scripts/release-finalize.sh b/scripts/release-finalize.sh
new file mode 100755
index 0000000..d282dbe
--- /dev/null
+++ b/scripts/release-finalize.sh
@@ -0,0 +1,328 @@
+#!/usr/bin/env bash
+
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Apache SkyWalking Horizon UI — POST-VOTE release finalization.
+#
+# Run this AFTER the [VOTE] on [email protected] passes. It is the
+# second half of the release flow; `scripts/release.sh` is the first half
+# (build, sign, upload RC to SVN dev, vote email, next-dev PR).
+#
+# What it does, in order:
+#
+#   1. Promote the voted artifacts on SVN: server-side move from
+#        dist/dev/skywalking/horizon-ui/<v>/   (release candidate)
+#      to
+#        dist/release/skywalking/horizon-ui/<v>/   (official release)
+#      and remove the PREVIOUS release from release/ (ASF keeps only the
+#      current release live; older ones are auto-archived).
+#
+#   2. Cut a GitHub release on tag v<v>, attaching the SAME voted bytes
+#      (src + bin tarballs + .asc + .sha512) fetched back from SVN release,
+#      with the CHANGELOG section for <v> as the body.
+#
+#   3. Build + push the multi-arch (amd64 + arm64) container image to
+#      Docker Hub apache/skywalking-ui, tagged:
+#         :horizon-<v>     immutable, this release
+#         :horizon-latest  moving Horizon pointer
+#         :latest          moving repo pointer  (overrides booster-ui's —
+#                          confirmed interactively before pushing)
+#
+# Usage:  bash scripts/release-finalize.sh
+#
+# The script is idempotent-ish and confirms before every irreversible step
+# (SVN move, SVN delete, gh release, each image push). Nothing destructive
+# happens without a y/N.
+
+set -e -o pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+PROJECT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
+PRODUCT_NAME="apache-skywalking-horizon-ui"
+
+SVN_DEV_URL="https://dist.apache.org/repos/dist/dev/skywalking/horizon-ui";
+SVN_RELEASE_URL="https://dist.apache.org/repos/dist/release/skywalking/horizon-ui";
+
+DOCKERHUB_REPO="apache/skywalking-ui"
+WORK_DIR="${SCRIPT_DIR}/.finalize-work"
+BUILDER_NAME="horizon-release-builder"
+
+# ========================== Helpers ==========================
+
+err() { echo "ERROR: $*" >&2; }
+note() { echo ""; echo "=== $* ==="; }
+
+confirm() {
+    local prompt="$1"
+    read -r -p "${prompt} [y/N] " ans
+    [[ "$ans" == "y" || "$ans" == "Y" ]]
+}
+
+svn_exists() {
+    svn ls "$1" >/dev/null 2>&1
+}
+
+# ========================== Step 1: Tool + auth preflight 
==========================
+note "Step 1 — Tool + auth preflight"
+
+MISSING=()
+for t in svn gh git docker shasum curl node; do
+    command -v "$t" >/dev/null || MISSING+=("$t")
+done
+if [ ${#MISSING[@]} -gt 0 ]; then
+    err "Missing required tools: ${MISSING[*]}"
+    exit 1
+fi
+
+if ! docker buildx version >/dev/null 2>&1; then
+    err "docker buildx is required for the multi-arch image build."
+    exit 1
+fi
+
+# gh must be logged in with repo scope to cut a release.
+if ! gh auth status >/dev/null 2>&1; then
+    err "gh is not authenticated. Run: gh auth login"
+    exit 1
+fi
+echo "gh: $(gh auth status 2>&1 | grep -m1 'Logged in' | sed 
's/^[[:space:]]*//')"
+
+# Docker Hub: confirm a stored login. The push itself will 403 if the
+# logged-in account lacks push rights to the apache org — surface the
+# identity now so a wrong account is caught before the long build.
+DOCKER_USER=$(printf 'https://index.docker.io/v1/' | docker-credential-desktop 
get 2>/dev/null \
+    | node -e "let 
s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(s).Username||'')}catch(e){}})"
 2>/dev/null || true)
+if [ -z "${DOCKER_USER}" ]; then
+    echo "Could not read a Docker Hub login from the credential store."
+    echo "If you are not logged in, run:  docker login"
+    confirm "Continue anyway (the push will fail if not authorized)?" || { 
echo "Aborted."; exit 1; }
+else
+    echo "Docker Hub login: ${DOCKER_USER}"
+    echo "  NOTE: pushing to ${DOCKERHUB_REPO} needs this account to have push 
rights"
+    echo "        in the 'apache' Docker Hub org. The push 403s otherwise."
+fi
+
+# ========================== Step 2: Detect version ==========================
+note "Step 2 — Detect release version"
+
+DETECTED=$(cd "${PROJECT_DIR}" && git tag --list 'v*' --sort=-version:refname 
| head -1 | sed 's/^v//')
+echo "Most recent git tag: v${DETECTED:-<none>}"
+read -r -p "Release version to finalize [${DETECTED}]: " RELEASE_VERSION
+RELEASE_VERSION="${RELEASE_VERSION:-${DETECTED}}"
+if [ -z "${RELEASE_VERSION}" ]; then
+    err "No release version provided."
+    exit 1
+fi
+TAG="v${RELEASE_VERSION}"
+
+if ! (cd "${PROJECT_DIR}" && git rev-parse "${TAG}" >/dev/null 2>&1); then
+    err "Git tag ${TAG} does not exist locally. Fetch tags first: git fetch 
--tags"
+    exit 1
+fi
+echo "Finalizing ${RELEASE_VERSION} (tag ${TAG})."
+confirm "Proceed?" || { echo "Aborted."; exit 1; }
+
+rm -rf "${WORK_DIR}"
+mkdir -p "${WORK_DIR}"
+
+# ========================== Step 3: SVN move dev -> release 
==========================
+note "Step 3 — Promote on SVN: dev (RC) -> release (official)"
+
+echo "  FROM (release candidate): ${SVN_DEV_URL}/${RELEASE_VERSION}/"
+echo "  TO   (official release):  ${SVN_RELEASE_URL}/${RELEASE_VERSION}/"
+
+read -r -p "Apache SVN username: " SVN_USER
+read -r -s -p "Apache SVN password: " SVN_PASS
+echo ""
+SVN_AUTH=(--username "${SVN_USER}" --password "${SVN_PASS}" --non-interactive 
--no-auth-cache)
+
+if ! svn_exists "${SVN_DEV_URL}/${RELEASE_VERSION}"; then
+    err "Release candidate not found at ${SVN_DEV_URL}/${RELEASE_VERSION}/."
+    err "Did scripts/release.sh upload it? (Step 13)"
+    exit 1
+fi
+
+if svn_exists "${SVN_RELEASE_URL}/${RELEASE_VERSION}"; then
+    echo "Already present at release/${RELEASE_VERSION} — skipping the move 
(idempotent)."
+else
+    # The parent dir release/skywalking/horizon-ui may not exist yet (first
+    # SVN-published Horizon release). svn mv into a missing parent fails, so
+    # create the parent chain first.
+    if ! svn_exists "${SVN_RELEASE_URL}"; then
+        echo "Creating ${SVN_RELEASE_URL}/ (first Horizon release here)…"
+        svn mkdir --parents "${SVN_AUTH[@]}" \
+            -m "Create Horizon UI release directory" \
+            "${SVN_RELEASE_URL}"
+    fi
+    if confirm "Run the server-side svn mv now?"; then
+        svn mv "${SVN_AUTH[@]}" \
+            -m "Release Apache SkyWalking Horizon UI ${RELEASE_VERSION}" \
+            "${SVN_DEV_URL}/${RELEASE_VERSION}" \
+            "${SVN_RELEASE_URL}/${RELEASE_VERSION}"
+        echo "Moved to ${SVN_RELEASE_URL}/${RELEASE_VERSION}/"
+    else
+        err "SVN move skipped — cannot continue without the official 
artifacts."
+        exit 1
+    fi
+fi
+
+# Remove the PREVIOUS release from release/ (ASF policy: only the current
+# release stays live; older versions are auto-archived to archive.apache.org).
+PREV_RELEASE=$(svn ls "${SVN_RELEASE_URL}/" 2>/dev/null \
+    | sed 's,/$,,' \
+    | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
+    | grep -vx "${RELEASE_VERSION}" \
+    | sort -t. -k1,1n -k2,2n -k3,3n \
+    | tail -1 || true)
+if [ -n "${PREV_RELEASE}" ]; then
+    echo "Previous release in release/: ${PREV_RELEASE}"
+    if confirm "Remove release/${PREV_RELEASE}/ (auto-archived, still 
downloadable from archive.apache.org)?"; then
+        svn rm "${SVN_AUTH[@]}" \
+            -m "Remove superseded release ${PREV_RELEASE} (archived)" \
+            "${SVN_RELEASE_URL}/${PREV_RELEASE}"
+        echo "Removed release/${PREV_RELEASE}/."
+    else
+        echo "Left release/${PREV_RELEASE}/ in place."
+    fi
+fi
+unset SVN_PASS
+
+# ========================== Step 4: GitHub release ==========================
+note "Step 4 — GitHub release ${TAG}"
+
+# Pull the VOTED artifacts back from release/ so the GitHub release attaches
+# byte-identical files to what the PMC voted on (not a fresh rebuild).
+ART_DIR="${WORK_DIR}/artifacts"
+mkdir -p "${ART_DIR}"
+SRC_BASE="${PRODUCT_NAME}-${RELEASE_VERSION}-src.tar.gz"
+BIN_BASE="${PRODUCT_NAME}-${RELEASE_VERSION}-bin.tar.gz"
+for f in \
+    "${SRC_BASE}" "${SRC_BASE}.asc" "${SRC_BASE}.sha512" \
+    "${BIN_BASE}" "${BIN_BASE}.asc" "${BIN_BASE}.sha512"; do
+    echo "Fetching ${f}…"
+    curl -fSL -o "${ART_DIR}/${f}" "${SVN_RELEASE_URL}/${RELEASE_VERSION}/${f}"
+done
+
+# Re-verify the checksums locally before attaching.
+(cd "${ART_DIR}" && shasum -a 512 -c "${SRC_BASE}.sha512" && shasum -a 512 -c 
"${BIN_BASE}.sha512")
+echo "Checksums verified."
+
+# Extract the CHANGELOG section for this version as the release body.
+NOTES_FILE="${WORK_DIR}/release-notes.md"
+awk -v v="${RELEASE_VERSION}" '
+    $0 == "## " v   { in_sec=1; next }
+    in_sec && /^## / { in_sec=0 }
+    in_sec           { print }
+' "${PROJECT_DIR}/CHANGELOG.md" > "${NOTES_FILE}"
+{
+    echo ""
+    echo "---"
+    echo ""
+    echo "Source & binary releases (with signatures and checksums):"
+    echo "* ${SVN_RELEASE_URL}/${RELEASE_VERSION}/"
+    echo "* KEYS: https://dist.apache.org/repos/dist/release/skywalking/KEYS";
+    echo ""
+    echo "Container image: \`docker pull 
${DOCKERHUB_REPO}:horizon-${RELEASE_VERSION}\`"
+} >> "${NOTES_FILE}"
+
+if gh release view "${TAG}" --repo apache/skywalking-horizon-ui >/dev/null 
2>&1; then
+    echo "GitHub release ${TAG} already exists — skipping create."
+else
+    echo "Release notes preview:"
+    echo "------------------------------------------------------------"
+    cat "${NOTES_FILE}"
+    echo "------------------------------------------------------------"
+    if confirm "Create the GitHub release ${TAG} and attach the 6 artifacts?"; 
then
+        gh release create "${TAG}" \
+            --repo apache/skywalking-horizon-ui \
+            --title "Apache SkyWalking Horizon UI ${RELEASE_VERSION}" \
+            --notes-file "${NOTES_FILE}" \
+            "${ART_DIR}/${SRC_BASE}" \
+            "${ART_DIR}/${SRC_BASE}.asc" \
+            "${ART_DIR}/${SRC_BASE}.sha512" \
+            "${ART_DIR}/${BIN_BASE}" \
+            "${ART_DIR}/${BIN_BASE}.asc" \
+            "${ART_DIR}/${BIN_BASE}.sha512"
+        echo "GitHub release created."
+    else
+        echo "Skipped GitHub release."
+    fi
+fi
+
+# ========================== Step 5: Docker Hub multi-arch image 
==========================
+note "Step 5 — Docker Hub image: ${DOCKERHUB_REPO}"
+
+# Build from a CLEAN checkout of the tag so the image matches the released
+# source exactly (no local uncommitted edits leak in).
+BUILD_SRC="${WORK_DIR}/src"
+echo "Checking out ${TAG} into ${BUILD_SRC}…"
+git -C "${PROJECT_DIR}" archive --format=tar --prefix=src/ "${TAG}" | (cd 
"${WORK_DIR}" && tar -x)
+
+# A docker-container builder is required: the default 'docker' driver cannot
+# emit a multi-platform manifest. Create one if absent + ensure QEMU is set
+# up for the foreign-arch emulation.
+if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
+    echo "Creating buildx builder '${BUILDER_NAME}' (docker-container driver)…"
+    docker buildx create --name "${BUILDER_NAME}" --driver docker-container 
--bootstrap
+fi
+docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64 
>/dev/null 2>&1 || true
+
+IMG_TAGS=(-t "${DOCKERHUB_REPO}:horizon-${RELEASE_VERSION}" -t 
"${DOCKERHUB_REPO}:horizon-latest")
+echo "Image tags to push:"
+echo "  ${DOCKERHUB_REPO}:horizon-${RELEASE_VERSION}   (immutable)"
+echo "  ${DOCKERHUB_REPO}:horizon-latest              (moving Horizon pointer)"
+echo ""
+echo "Bare ':latest' on a SHARED repo overrides booster-ui's :latest —"
+echo "anyone pulling ${DOCKERHUB_REPO}:latest would then get Horizon."
+if confirm "Also push the bare ':latest' tag?"; then
+    IMG_TAGS+=(-t "${DOCKERHUB_REPO}:latest")
+    echo "Will also push :latest."
+fi
+
+if confirm "Build linux/amd64+arm64 and push to Docker Hub now? (emulated arch 
is slow)"; then
+    docker buildx build \
+        --builder "${BUILDER_NAME}" \
+        --platform linux/amd64,linux/arm64 \
+        --file "${PROJECT_DIR}/Dockerfile" \
+        --label 
"org.opencontainers.image.source=https://github.com/apache/skywalking-horizon-ui";
 \
+        --label "org.opencontainers.image.revision=$(git -C "${PROJECT_DIR}" 
rev-parse "${TAG}")" \
+        --label "org.opencontainers.image.version=${RELEASE_VERSION}" \
+        --label "org.opencontainers.image.title=Apache SkyWalking Horizon UI" \
+        --label "org.opencontainers.image.description=Next-generation web UI 
for Apache SkyWalking." \
+        --label "org.opencontainers.image.licenses=Apache-2.0" \
+        "${IMG_TAGS[@]}" \
+        --push \
+        "${BUILD_SRC}/src"
+    echo "Pushed multi-arch image to ${DOCKERHUB_REPO}."
+    echo "Verify:  docker buildx imagetools inspect 
${DOCKERHUB_REPO}:horizon-${RELEASE_VERSION}"
+else
+    echo "Skipped Docker Hub push."
+fi
+
+# ========================== Done ==========================
+note "Done — ${RELEASE_VERSION} finalized"
+echo "  SVN release:   ${SVN_RELEASE_URL}/${RELEASE_VERSION}/"
+echo "  GitHub:        
https://github.com/apache/skywalking-horizon-ui/releases/tag/${TAG}";
+echo "  Docker Hub:    ${DOCKERHUB_REPO}:horizon-${RELEASE_VERSION}"
+echo ""
+echo "Remaining manual steps:"
+echo "  1. Update the Docker Hub repo README from 
dist-material/docker-hub/README.md"
+echo "     (Docker Hub → ${DOCKERHUB_REPO} → 'Repository overview' → edit)."
+echo "  2. Send the [ANNOUNCE] email to dev@ + [email protected]."
+echo "  3. Update the download page on the SkyWalking website."
+echo ""
+echo "Working files left in ${WORK_DIR}/ (safe to delete)."
diff --git a/scripts/release.sh b/scripts/release.sh
index 51b3b57..a1468bb 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -165,21 +165,25 @@ for pj in package.json packages/api-client/package.json 
packages/design-tokens/p
 done
 check_file_has_version "apps/bff/src/server.ts" "'${CURRENT_VERSION}'"
 
-# Docs reference the LAST RELEASED image tag — derive it from the most
-# recent git tag (`vX.Y.Z`). We don't bump docs on main between releases;
-# the next-version PR (run after the vote passes) is what advances them.
+# Docs normally reference the last released image tag on main, then the
+# release commit advances them to the new tag. If the docs were already
+# prepared for the release version, accept that too.
 PRIOR_RELEASE=$(cd "${PROJECT_DIR}" && git tag --list 'v*' 
--sort=-version:refname | head -1 | sed 's/^v//')
 if [ -z "${PRIOR_RELEASE}" ]; then
     err "No prior release tag (vX.Y.Z) found. Tag the first release manually 
before using this script."
     exit 1
 fi
-check_file_has_version "docs/setup/container-image.md" 
"ghcr.io/apache/skywalking-horizon-ui:${PRIOR_RELEASE}"
+if ! file_has "${PROJECT_DIR}/docs/setup/container-image.md" 
"ghcr.io/apache/skywalking-horizon-ui:${PRIOR_RELEASE}" &&
+   ! file_has "${PROJECT_DIR}/docs/setup/container-image.md" 
"ghcr.io/apache/skywalking-horizon-ui:${RELEASE_VERSION}"; then
+    err "docs/setup/container-image.md must reference either prior image tag 
${PRIOR_RELEASE} or release tag ${RELEASE_VERSION}."
+    CONSISTENT=false
+fi
 
 if ! $CONSISTENT; then
     err "Version drift across files. Fix before continuing."
     exit 1
 fi
-echo "Code markers all at ${CURRENT_VERSION}; docs at last-released 
${PRIOR_RELEASE}."
+echo "Code markers all at ${CURRENT_VERSION}; container docs are release-check 
compatible."
 
 # ========================== Step 5: Doc + Changelog check 
==========================
 note "Step 5 — Docs + CHANGELOG check"
@@ -392,6 +396,18 @@ read -r -p "Apache SVN username: " SVN_USER
 read -r -s -p "Apache SVN password: " SVN_PASS
 echo ""
 
+# The dev parent dir (dist/dev/skywalking/horizon-ui) may not exist yet on
+# the first SVN-published release. `svn co` of a missing URL fails, so
+# check-and-create the parent chain before checking it out.
+if ! svn ls --username "${SVN_USER}" --password "${SVN_PASS}" \
+        --non-interactive --no-auth-cache "${SVN_DEV_URL}" >/dev/null 2>&1; 
then
+    echo "Dev staging dir does not exist — creating ${SVN_DEV_URL}/"
+    svn mkdir --parents --username "${SVN_USER}" --password "${SVN_PASS}" \
+        --non-interactive --no-auth-cache \
+        -m "Create Horizon UI dev staging directory" \
+        "${SVN_DEV_URL}"
+fi
+
 SVN_STAGE="${WORK_DIR}/svn-staging"
 rm -rf "${SVN_STAGE}"
 svn co --depth empty --username "${SVN_USER}" --password "${SVN_PASS}" \


Reply via email to