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 649d23c6ee06d9c857bf408268c5d209d636fdf9
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 19 23:37:44 2026 +0800

    release: 0.5.0-dev — release.sh + dist LICENSE/NOTICE tooling + CI 
dep-license job
    
    - Version bump to 0.5.0-dev across every workspace package + server.ts
      HORIZON_VERSION default. -dev suffix marks main as in-flight; the
      release script strips it to derive the tagged version (mirrors the
      -SNAPSHOT pattern in the upstream skywalking repo).
    
    - scripts/release.sh: 15-step Apache release orchestrator — GPG signer
      preflight (@apache.org), required tooling check, cross-file version
      consistency, CHANGELOG section validation (rejects the "In development"
      placeholder), license-header gate, fresh clone, strip-dev + advance
      docs + tag, source tarball, self-contained binary tarball (`pnpm
      package` output + dep LICENSE/NOTICE), tarball LICENSE/NOTICE shape
      cross-check, GPG sign + sha512 + self-verify, prompt-for-Apache-SVN
      upload to dist/dev/skywalking/horizon-ui/<v>/, vote-email generation,
      next-version PR (0.5.0 → 0.6.0-dev).
    
    - scripts/collect-dist-licenses.mjs: walks the production dep graph via
      `pnpm list --prod`, copies each package's LICENSE-like file into
      dist/licenses/<name>-<ver>/, generates dist/LICENSE (Apache 2.0 + license
      family summary) and dist/NOTICE (ASF + verbatim third-party NOTICE
      pass-throughs) from dist-material/release-docs templates, and emits
      dist/.dependency-report.json for the checker.
    
    - scripts/check-dist-licenses.mjs: ASF Category-A allow-list,
      Category-X deny-list (AGPL / SSPL / BUSL / Commons-Clause /
      MPL-1.x / Ms-RL / RPL), warns on weak-copyleft (EPL / CDDL / MPL-2 /
      LGPL) and on packages shipped without a LICENSE-like file.
    
    - .github/workflows/ci.yaml: split license-header and the new
      `dependency-license` job (separate from header check on purpose — dep
      collection requires a full `pnpm package` and the dist walk). The job
      also asserts that the source-flavored LICENSE (repo root) lacks the
      bundled-dep summary section and that the binary-flavored
      dist/LICENSE contains it. Gated by the `Required` rollup status.
    
    - .licenserc.yaml: ignore dist-material/release-docs/**/*.tpl — those
      templates RENDER the LICENSE/NOTICE text, so a header comment would
      bleed into the output.
    
    - CHANGELOG.md: 0.5.0 placeholder section — to be filled before tagging.
---
 .github/workflows/ci.yaml              |  61 +++-
 .licenserc.yaml                        |   5 +
 CHANGELOG.md                           |  11 +
 apps/bff/package.json                  |   2 +-
 apps/bff/src/server.ts                 |   2 +-
 apps/ui/package.json                   |   2 +-
 dist-material/release-docs/LICENSE.tpl | 217 +++++++++++++
 dist-material/release-docs/NOTICE.tpl  |  12 +
 package.json                           |   2 +-
 packages/api-client/package.json       |   2 +-
 packages/design-tokens/package.json    |   2 +-
 packages/templates/package.json        |   2 +-
 scripts/check-dist-licenses.mjs        | 163 ++++++++++
 scripts/collect-dist-licenses.mjs      | 263 ++++++++++++++++
 scripts/release.sh                     | 540 +++++++++++++++++++++++++++++++++
 15 files changed, 1273 insertions(+), 13 deletions(-)

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9823ddb..78a7659 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -38,11 +38,59 @@ jobs:
       - name: Check license header
         uses: apache/skywalking-eyes@5b7ee1731d036b5aac68f8bd3fc9e6f98ada082e
 
-  # NOTE: `license-eye dependency resolve` is deferred to a release-only
-  # workflow. The tool invokes `npm ci` internally, which fails on pnpm
-  # workspaces (no package-lock.json). When we cut an Apache release we'll
-  # add a separate job that runs the resolver against a one-shot npm install
-  # of the production dep graph. For day-to-day CI, header-check is enough.
+  dependency-license:
+    # Separate from `license-header` on purpose: header check is a quick
+    # static scan; dep-license collection requires a full production build
+    # (pnpm package → dist/node_modules) and then a walk of that tree to
+    # produce dist/LICENSE + dist/NOTICE + dist/licenses/. We also verify
+    # source vs. binary LICENSE/NOTICE differ in the expected way
+    # (only the binary carries the bundled-dep summary).
+    name: Dependency license
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          persist-credentials: false
+      - uses: pnpm/action-setup@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '20'
+          cache: 'pnpm'
+      - run: pnpm install --frozen-lockfile
+      - run: pnpm package
+      - name: Collect bundled-dep LICENSE/NOTICE
+        run: node scripts/collect-dist-licenses.mjs
+      - name: Check bundled-dep licenses against ASF allow/deny
+        run: node scripts/check-dist-licenses.mjs
+      - name: Compare source vs. binary LICENSE/NOTICE shape
+        run: |
+          # Source-flavored LICENSE = repo root; must NOT contain the
+          # bundled-dep summary section.
+          if grep -q 'Horizon UI Subcomponents' LICENSE; then
+            echo "ERROR: repo-root LICENSE contains the bundled-dep summary 
section."
+            echo "       That section belongs only in dist/LICENSE (binary 
tarball)."
+            exit 1
+          fi
+          # Binary-flavored LICENSE = generated; must contain the summary.
+          if ! grep -q 'Horizon UI Subcomponents' dist/LICENSE; then
+            echo "ERROR: dist/LICENSE missing 'Horizon UI Subcomponents' 
section."
+            echo "       collect-dist-licenses.mjs did not run or template 
drift."
+            exit 1
+          fi
+          # NOTICE: both must exist and start with the same ASF copyright line.
+          test -f NOTICE && test -f dist/NOTICE
+          head -1 NOTICE | grep -q 'Apache SkyWalking Horizon UI'
+          head -1 dist/NOTICE | grep -q 'Apache SkyWalking Horizon UI'
+          echo "src/bin LICENSE+NOTICE shape OK."
+      - name: Upload dependency report
+        uses: actions/upload-artifact@v4
+        with:
+          name: dist-license-report
+          path: |
+            dist/LICENSE
+            dist/NOTICE
+            dist/.dependency-report.json
 
   type-check:
     name: Type-check (workspaces)
@@ -120,13 +168,14 @@ jobs:
     # [Required]`) can gate on a single rolled-up signal.
     if: always() && !cancelled()
     name: Required
-    needs: [license-header, type-check, build-ui, build-bff, test]
+    needs: [license-header, dependency-license, type-check, build-ui, 
build-bff, test]
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:
       - name: Check upstream jobs
         run: |
           [[ ${{ needs.license-header.result }} == 'success' ]] || exit 1
+          [[ ${{ needs.dependency-license.result }} == 'success' ]] || exit 1
           [[ ${{ needs.type-check.result }} == 'success' ]] || exit 1
           [[ ${{ needs.build-ui.result }} == 'success' ]] || exit 1
           [[ ${{ needs.build-bff.result }} == 'success' ]] || exit 1
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 074d08f..ab40acf 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -50,6 +50,11 @@ header:
     - 'src/types/auto-imports.d.ts'
     - 'src/types/components.d.ts'
     - 'public/**'
+    # LICENSE.tpl / NOTICE.tpl in dist-material/release-docs/ render the
+    # binary tarball's LICENSE / NOTICE (scripts/collect-dist-licenses.mjs).
+    # Their contents ARE the Apache 2.0 license text and the ASF notice
+    # text — an extra license-header comment would bleed into the output.
+    - 'dist-material/release-docs/**/*.tpl'
 
   comment: on-failure
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a7da78..8c9f8af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,17 @@ file-by-file implementation. For per-commit detail, see the 
git log.
 The version line is shared by every package in the monorepo (apps + shared
 packages) plus the BFF's `HORIZON_VERSION` default.
 
+## 0.5.0
+
+First Apache-style release cut from this repo: source + binary tarballs,
+GPG-signed and SHA-512 checksummed, with a self-contained binary that
+boots via `node server.js` and no `pnpm install` step. Binary distribution
+ships a regenerated `LICENSE` + `NOTICE` that enumerate every bundled
+third-party package — produced by `scripts/collect-dist-licenses.mjs`
+during packaging and validated against a deny-list before signing.
+
+Fill in screen-facing highlights here before tagging.
+
 ## 0.4.0
 
 OAP becomes the runtime source of truth for UI templates, the 5-theme system
diff --git a/apps/bff/package.json b/apps/bff/package.json
index 2ab2d2f..6918656 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/bff",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "type": "module",
   "main": "dist/server.js",
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 82960e8..86547ec 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -221,7 +221,7 @@ if (staticDir && existsSync(staticDir)) {
 
 app.get('/api/health', async () => ({
   status: 'ok',
-  version: process.env.HORIZON_VERSION ?? '0.4.0',
+  version: process.env.HORIZON_VERSION ?? '0.5.0-dev',
   sessions: sessions.size(),
 }));
 
diff --git a/apps/ui/package.json b/apps/ui/package.json
index 2c3878c..1747484 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/ui",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "type": "module",
   "scripts": {
diff --git a/dist-material/release-docs/LICENSE.tpl 
b/dist-material/release-docs/LICENSE.tpl
new file mode 100644
index 0000000..7b18cfc
--- /dev/null
+++ b/dist-material/release-docs/LICENSE.tpl
@@ -0,0 +1,217 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for describing the origin of the Work and
+      reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Support. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or support.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed 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 Subcomponents:
+
+   The Apache SkyWalking Horizon UI binary distribution includes a number
+   of subcomponents with separate copyright notices and license terms.
+   Your use of the source code for these subcomponents is subject to the
+   terms and conditions of the licenses listed below. The full text of
+   each license is reproduced in the licenses/ directory of this
+   distribution.
+
+   By license family, the bundled third-party software is:
+========================================================================
+
+{{ .Groups }}
diff --git a/dist-material/release-docs/NOTICE.tpl 
b/dist-material/release-docs/NOTICE.tpl
new file mode 100644
index 0000000..a774f22
--- /dev/null
+++ b/dist-material/release-docs/NOTICE.tpl
@@ -0,0 +1,12 @@
+Apache SkyWalking Horizon UI
+Copyright {{ .Year }} The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+========================================================================
+This binary distribution bundles third-party software, whose own NOTICE
+files (where present) are reproduced below verbatim.
+========================================================================
+
+{{ .Notices }}
diff --git a/package.json b/package.json
index ce18b32..1a62408 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "skywalking-horizon-ui",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "description": "Apache SkyWalking Horizon UI - next-generation web UI",
   "license": "Apache-2.0",
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index 85f64ff..a3f5301 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/api-client",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",
diff --git a/packages/design-tokens/package.json 
b/packages/design-tokens/package.json
index 7dec6e7..612d42a 100644
--- a/packages/design-tokens/package.json
+++ b/packages/design-tokens/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/design-tokens",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",
diff --git a/packages/templates/package.json b/packages/templates/package.json
index 29ae86e..d8d322c 100644
--- a/packages/templates/package.json
+++ b/packages/templates/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@skywalking-horizon-ui/templates",
-  "version": "0.4.0",
+  "version": "0.5.0-dev",
   "private": true,
   "type": "module",
   "main": "./dist/index.js",
diff --git a/scripts/check-dist-licenses.mjs b/scripts/check-dist-licenses.mjs
new file mode 100644
index 0000000..4a595d7
--- /dev/null
+++ b/scripts/check-dist-licenses.mjs
@@ -0,0 +1,163 @@
+/*
+ * 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.
+ */
+
+/**
+ * Read the dependency report produced by `collect-dist-licenses.mjs`
+ * and verify:
+ *
+ *   1. Every package has a recognized license string.
+ *   2. No package falls into the ASF "Category-X" (forbidden) bucket
+ *      — strong copyleft, source-available-only, or commercial-restrictive.
+ *   3. Every package's LICENSE file is reproduced under dist/licenses/
+ *      (a missing file fails the build — operators must commit a
+ *      hand-supplied copy under `dist-material/release-docs/licenses-extra/`
+ *      and the collector picks it up next run).
+ *
+ * Exits non-zero with a per-package diagnostic on any violation.
+ * Designed to run in CI (separate job from license-header) and as the
+ * last step before the release script signs the binary tarball.
+ */
+
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, '..');
+const distDir = resolve(repoRoot, 'dist');
+const reportPath = resolve(distDir, '.dependency-report.json');
+
+if (!existsSync(reportPath)) {
+  console.error(
+    `FATAL: ${reportPath} not found. Run \`node 
scripts/collect-dist-licenses.mjs\` first.`,
+  );
+  process.exit(1);
+}
+
+const report = JSON.parse(readFileSync(reportPath, 'utf8'));
+
+// ASF "Category A" — permissive, freely redistributable. SPDX-ish keys.
+// See https://www.apache.org/legal/resolved.html
+const ALLOWED = new Set(
+  [
+    'Apache-2.0',
+    'Apache 2.0',
+    'Apache License 2.0',
+    'Apache-2',
+    'MIT',
+    'MIT*',
+    'MIT-0',
+    'ISC',
+    'BSD',
+    'BSD-2-Clause',
+    'BSD-3-Clause',
+    'BSD-3-Clause-Clear',
+    '0BSD',
+    'CC0-1.0',
+    'CC-BY-3.0',
+    'CC-BY-4.0',
+    'Unlicense',
+    'WTFPL',
+    'Python-2.0',
+    'PSF-2.0',
+    'BlueOak-1.0.0',
+    'Zlib',
+    'Artistic-2.0',
+  ].map((s) => s.toLowerCase()),
+);
+
+// Category X — must not appear in a binary release.
+const FORBIDDEN_PATTERNS = [
+  /\bAGPL/i,
+  /\bSSPL/i,
+  /\bBUSL/i,
+  /commons[\s-]?clause/i,
+  /\bRPL/i, // Reciprocal Public License
+  /\bMs[-\s]?RL/i, // Microsoft Reciprocal
+  /\bMPL[-\s]?1\./i, // MPL 1.x (only 2.0 is Cat-B / allowed-with-care)
+];
+
+// Category B — allowed but each must be reproduced verbatim. EPL, CDDL,
+// MPL-2.0, LGPL-2.1, LGPL-3.0. Flagged but not failing — the LICENSE
+// summary already lists them; vote reviewers can audit.
+const WEAK_COPYLEFT_PATTERNS = [
+  /\bEPL\b/i,
+  /\bCDDL/i,
+  /\bMPL[-\s]?2/i,
+  /\bLGPL/i,
+];
+
+const errors = [];
+const warnings = [];
+
+for (const pkg of report.packages) {
+  const id = `${pkg.name}@${pkg.version}`;
+  const lic = (pkg.license ?? '').trim();
+  const licLower = lic.toLowerCase();
+
+  if (!lic || licLower === 'unknown') {
+    errors.push(`${id}: missing or unknown license (declared: 
${JSON.stringify(pkg.license)})`);
+    continue;
+  }
+
+  // SPDX expressions: split on OR / AND. If any sub-expression is fully
+  // allowed, we accept the package under that one. AND requires all parts 
allowed.
+  const parts = lic.split(/\s+OR\s+|\s+AND\s+/i).map((s) => s.replace(/[()]/g, 
'').trim());
+  const operator = / AND /i.test(lic) ? 'AND' : 'OR';
+
+  const partOk = parts.map((p) => ALLOWED.has(p.toLowerCase()));
+  const accepted = operator === 'AND' ? partOk.every(Boolean) : 
partOk.some(Boolean);
+
+  for (const re of FORBIDDEN_PATTERNS) {
+    if (re.test(lic)) {
+      errors.push(`${id}: forbidden license: ${lic}`);
+    }
+  }
+
+  if (!accepted && !errors.some((e) => e.startsWith(`${id}:`))) {
+    // Allow weak-copyleft with a warning. Block everything else.
+    if (WEAK_COPYLEFT_PATTERNS.some((re) => re.test(lic))) {
+      warnings.push(`${id}: weak-copyleft license '${lic}' — included verbatim 
under licenses/`);
+    } else {
+      errors.push(`${id}: unrecognized license '${lic}' — add to ALLOWED if 
compatible, or remove the dep`);
+    }
+  }
+
+  if (!pkg.licenseFile) {
+    // A missing license file is a vote-blocker for non-trivial deps.
+    warnings.push(`${id}: no LICENSE-like file shipped under licenses/ 
(license declared: ${lic})`);
+  }
+}
+
+if (warnings.length > 0) {
+  console.warn('Warnings:');
+  for (const w of warnings) console.warn(`  - ${w}`);
+}
+
+if (errors.length > 0) {
+  console.error('');
+  console.error(`License check FAILED with ${errors.length} error(s):`);
+  for (const e of errors) console.error(`  - ${e}`);
+  console.error('');
+  process.exit(1);
+}
+
+console.log(
+  `License check OK: ${report.packageCount} packages, ` +
+    `${Object.keys(report.byLicense).length} license families, ` +
+    `${warnings.length} warning(s).`,
+);
diff --git a/scripts/collect-dist-licenses.mjs 
b/scripts/collect-dist-licenses.mjs
new file mode 100644
index 0000000..ed8700b
--- /dev/null
+++ b/scripts/collect-dist-licenses.mjs
@@ -0,0 +1,263 @@
+/*
+ * 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.
+ */
+
+/**
+ * Walk the production `dist/node_modules/` tree built by
+ * `scripts/package.mjs` and emit the binary-tar's LICENSE / NOTICE /
+ * licenses/ subtree.
+ *
+ * Apache distribution rule: every bundled third-party module's license
+ * is reproduced in the binary tarball. The source tarball ships only
+ * first-party source (top-level LICENSE + NOTICE are enough), but the
+ * binary bundles npm dependencies and therefore needs an expanded LICENSE
+ * (license-family summary) and NOTICE (third-party NOTICE pass-throughs)
+ * plus per-package license texts under licenses/<name>-<version>/.
+ *
+ * Output (relative to repo `dist/`):
+ *   LICENSE                 — Apache-2.0 + grouped third-party summary
+ *   NOTICE                  — ASF + concatenated third-party NOTICEs
+ *   licenses/<pkg>-<ver>/   — verbatim LICENSE-ish files from each dep
+ *   .dependency-report.json — { packages: [...] } for check-dist-licenses
+ *
+ * Run after `pnpm package`. Re-runs are idempotent: the script clears
+ * dist/licenses/ first.
+ */
+
+import { execSync } from 'node:child_process';
+import {
+  cpSync,
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  readdirSync,
+  rmSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, '..');
+const distDir = resolve(repoRoot, 'dist');
+const nmDir = resolve(distDir, 'node_modules');
+const licensesOutDir = resolve(distDir, 'licenses');
+const templatesDir = resolve(repoRoot, 'dist-material/release-docs');
+
+if (!existsSync(nmDir)) {
+  console.error(
+    `FATAL: ${nmDir} does not exist. Run \`pnpm package\` first.`,
+  );
+  process.exit(1);
+}
+
+const LICENSE_FILE_PATTERNS = [
+  /^LICENSE$/i,
+  /^LICENCE$/i,
+  /^LICENSE\.(md|txt|rst)$/i,
+  /^LICENCE\.(md|txt|rst)$/i,
+  /^COPYING$/i,
+  /^COPYING\.(md|txt|rst)$/i,
+  /^COPYRIGHT$/i,
+  /^COPYRIGHT\.(md|txt|rst)$/i,
+];
+const NOTICE_FILE_PATTERNS = [/^NOTICE$/i, /^NOTICE\.(md|txt|rst)$/i];
+
+function pickFile(dir, patterns) {
+  if (!existsSync(dir)) return null;
+  for (const entry of readdirSync(dir)) {
+    if (patterns.some((p) => p.test(entry))) return join(dir, entry);
+  }
+  return null;
+}
+
+// Find every realpath-distinct package directory under dist/node_modules.
+// pnpm's layout puts true packages under 
`.pnpm/<pkg>@<ver>(_<peers>)/node_modules/<pkg>/`,
+// with top-level entries being symlinks into that store. We use `pnpm list`
+// to get the canonical production dep graph and resolve each entry's
+// realpath to get the package.json we should be reading.
+function collectPackages() {
+  // `pnpm list --prod --depth Infinity --json` returns the realized
+  // production dep tree. We flatten it ourselves so first-party workspace
+  // packages can be filtered out by name prefix.
+  const raw = execSync(
+    'pnpm list --prod --depth Infinity --json',
+    {
+      cwd: distDir,
+      maxBuffer: 64 * 1024 * 1024,
+      stdio: ['ignore', 'pipe', 'inherit'],
+    },
+  ).toString();
+  const json = JSON.parse(raw);
+  // pnpm list returns an array of root packages. dist/ has exactly one.
+  const root = Array.isArray(json) ? json[0] : json;
+
+  const seen = new Map(); // key: name@version → { path, name, version }
+  function walk(deps) {
+    if (!deps) return;
+    for (const [name, info] of Object.entries(deps)) {
+      // Skip first-party workspace packages — they're our own code.
+      if (name.startsWith('@skywalking-horizon-ui/')) {
+        walk(info.dependencies);
+        continue;
+      }
+      const version = info.version;
+      const key = `${name}@${version}`;
+      if (seen.has(key)) continue;
+      const pkgPath = info.path;
+      if (!pkgPath || !existsSync(pkgPath)) {
+        console.warn(`WARN: package path missing for ${key}: ${pkgPath}`);
+        continue;
+      }
+      seen.set(key, { name, version, path: pkgPath });
+      walk(info.dependencies);
+    }
+  }
+  walk(root.dependencies);
+  return Array.from(seen.values()).sort((a, b) =>
+    a.name === b.name ? a.version.localeCompare(b.version) : 
a.name.localeCompare(b.name),
+  );
+}
+
+function normalizeLicense(pkgJson) {
+  const lic = pkgJson.license;
+  if (typeof lic === 'string') return lic;
+  if (lic && typeof lic === 'object' && typeof lic.type === 'string') {
+    return lic.type;
+  }
+  if (Array.isArray(pkgJson.licenses)) {
+    // Deprecated form. Join SPDX-style.
+    return pkgJson.licenses.map((l) => l.type || l).filter(Boolean).join(' OR 
');
+  }
+  return 'UNKNOWN';
+}
+
+function readPkgJson(pkgPath) {
+  const p = join(pkgPath, 'package.json');
+  if (!existsSync(p)) return null;
+  try {
+    return JSON.parse(readFileSync(p, 'utf8'));
+  } catch (e) {
+    console.warn(`WARN: cannot parse ${p}: ${e.message}`);
+    return null;
+  }
+}
+
+const packages = collectPackages();
+
+// Reset output directory
+rmSync(licensesOutDir, { recursive: true, force: true });
+mkdirSync(licensesOutDir, { recursive: true });
+
+const report = [];
+const byLicense = new Map();
+const noticePieces = [];
+
+for (const pkg of packages) {
+  const pj = readPkgJson(pkg.path);
+  if (!pj) continue;
+  const license = normalizeLicense(pj);
+  const homepage = pj.homepage || pj.repository?.url || pj.repository || '';
+  const entry = {
+    name: pkg.name,
+    version: pkg.version,
+    license,
+    homepage: typeof homepage === 'string' ? homepage : '',
+    licenseFile: null,
+    noticeFile: null,
+  };
+
+  const slug = `${pkg.name.replace(/\//g, '__')}-${pkg.version}`;
+  const outDir = join(licensesOutDir, slug);
+
+  const licFile = pickFile(pkg.path, LICENSE_FILE_PATTERNS);
+  if (licFile) {
+    if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
+    const dest = join(outDir, relative(pkg.path, licFile));
+    mkdirSync(dirname(dest), { recursive: true });
+    cpSync(licFile, dest);
+    entry.licenseFile = relative(distDir, dest);
+  }
+  const noticeFile = pickFile(pkg.path, NOTICE_FILE_PATTERNS);
+  if (noticeFile) {
+    if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
+    const dest = join(outDir, relative(pkg.path, noticeFile));
+    mkdirSync(dirname(dest), { recursive: true });
+    cpSync(noticeFile, dest);
+    entry.noticeFile = relative(distDir, dest);
+    noticePieces.push(
+      `------ ${pkg.name}@${pkg.version} ------\n${readFileSync(
+        noticeFile,
+        'utf8',
+      ).trim()}\n`,
+    );
+  }
+
+  report.push(entry);
+  const bucket = byLicense.get(license) ?? [];
+  bucket.push(entry);
+  byLicense.set(license, bucket);
+}
+
+// Render LICENSE.tpl → dist/LICENSE
+const groupLines = [];
+const sortedLicenses = Array.from(byLicense.keys()).sort();
+for (const lic of sortedLicenses) {
+  groupLines.push(`\n--- ${lic} ---\n`);
+  for (const e of byLicense.get(lic)) {
+    const ref = e.licenseFile ? ` (${e.licenseFile})` : '';
+    groupLines.push(`  * ${e.name}@${e.version}${ref}`);
+  }
+}
+const licenseTpl = readFileSync(join(templatesDir, 'LICENSE.tpl'), 'utf8');
+const licenseOut = licenseTpl.replace('{{ .Groups }}', groupLines.join('\n'));
+writeFileSync(join(distDir, 'LICENSE'), licenseOut);
+
+// Render NOTICE.tpl → dist/NOTICE
+const noticeTpl = readFileSync(join(templatesDir, 'NOTICE.tpl'), 'utf8');
+const year = new Date().getUTCFullYear();
+const noticeOut = noticeTpl
+  .replace('{{ .Year }}', String(year))
+  .replace(
+    '{{ .Notices }}',
+    noticePieces.length > 0
+      ? noticePieces.join('\n')
+      : '(No third-party NOTICE files present in bundled dependencies.)\n',
+  );
+writeFileSync(join(distDir, 'NOTICE'), noticeOut);
+
+// Machine-readable report for the check step.
+writeFileSync(
+  join(distDir, '.dependency-report.json'),
+  JSON.stringify(
+    {
+      generatedAt: new Date().toISOString(),
+      packageCount: report.length,
+      packages: report,
+      byLicense: Object.fromEntries(
+        sortedLicenses.map((l) => [l, byLicense.get(l).length]),
+      ),
+    },
+    null,
+    2,
+  ),
+);
+
+console.log(
+  `Wrote dist/LICENSE, dist/NOTICE, dist/licenses/ (${report.length} packages, 
` +
+    `${sortedLicenses.length} license families).`,
+);
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000..7936b4c
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,540 @@
+#!/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 release automation.
+#
+# Mirrors the apache/skywalking release.sh flow (see
+# docs/en/guides/How-to-release.md upstream) but adapted for the
+# pnpm-workspace Node project layout. Produces:
+#
+#   apache-skywalking-horizon-ui-<v>-src.tar.gz {.asc,.sha512}
+#   apache-skywalking-horizon-ui-<v>-bin.tar.gz {.asc,.sha512}
+#
+# Uploads them to 
https://dist.apache.org/repos/dist/dev/skywalking/horizon-ui/<v>/
+# then prepares a next-version PR.
+#
+# Usage:  bash scripts/release.sh
+
+set -e -o pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+PROJECT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
+PRODUCT_NAME="apache-skywalking-horizon-ui"
+REPO_URL="${HORIZON_RELEASE_REPO_URL:-https://github.com/apache/skywalking-horizon-ui.git}";
+REPO_BRANCH="${HORIZON_RELEASE_BRANCH:-main}"
+SVN_DEV_URL="https://dist.apache.org/repos/dist/dev/skywalking/horizon-ui";
+WORK_DIR="${SCRIPT_DIR}/.release-work"
+CLONE_DIR="${WORK_DIR}/skywalking-horizon-ui"
+
+# ========================== Helpers ==========================
+
+err() { echo "ERROR: $*" >&2; }
+note() { echo ""; echo "=== $* ==="; }
+
+confirm() {
+    local prompt="$1"
+    read -r -p "${prompt} [y/N] " ans
+    [[ "$ans" == "y" || "$ans" == "Y" ]]
+}
+
+# Extract the root package.json "version" without depending on jq —
+# we want this script to be runnable on stock macOS / Alpine.
+read_version() {
+    node -e 
"process.stdout.write(JSON.parse(require('fs').readFileSync('${PROJECT_DIR}/package.json','utf8')).version)"
+}
+
+# Quietly check that the named file contains a literal needle.
+file_has() {
+    grep -F -q -- "$2" "$1"
+}
+
+# ========================== Step 1: GPG signer ==========================
+note "Step 1 — GPG signer check"
+
+GPG_KEY_ID=$(git config user.signingkey 2>/dev/null || true)
+if [ -z "$GPG_KEY_ID" ]; then
+    GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null | grep 
-A1 '^sec' | tail -1 | awk '{print $1}' || true)
+fi
+if [ -z "$GPG_KEY_ID" ]; then
+    err "No GPG secret key found. Configure your Apache GPG key first."
+    exit 1
+fi
+
+GPG_UIDS=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null | grep 'uid' 
| sed 's/.*] //')
+GPG_EMAIL=$(echo "$GPG_UIDS" | grep -oE 
'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' | head -1)
+
+if [[ "$GPG_EMAIL" != *"@apache.org" ]]; then
+    err "GPG key email '${GPG_EMAIL}' is not @apache.org — Apache releases 
must be signed with an @apache.org key."
+    exit 1
+fi
+echo "GPG Signer: ${GPG_UIDS}"
+echo "GPG Key:    ${GPG_KEY_ID}"
+confirm "Is this the correct signer?" || { echo "Aborted."; exit 1; }
+
+export GPG_TTY=$(tty)
+echo "Verifying GPG signing works (you may be prompted for the passphrase)…"
+TEST_FILE=$(mktemp); echo "test" > "${TEST_FILE}"
+if ! gpg --armor --detach-sig "${TEST_FILE}" 2>/dev/null; then
+    rm -f "${TEST_FILE}" "${TEST_FILE}.asc"
+    err "GPG signing failed. Try:  export GPG_TTY=\$(tty)  /  gpgconf --launch 
gpg-agent"
+    exit 1
+fi
+rm -f "${TEST_FILE}" "${TEST_FILE}.asc"
+echo "GPG signing OK."
+
+# ========================== Step 2: Required tools ==========================
+note "Step 2 — Tool check"
+
+MISSING=()
+for t in gpg svn shasum git gh node pnpm tar license-eye; do
+    command -v "$t" >/dev/null || MISSING+=("$t")
+done
+if [ ${#MISSING[@]} -gt 0 ]; then
+    err "Missing required tools: ${MISSING[*]}"
+    exit 1
+fi
+echo "All tools present."
+echo "node: $(node --version)"
+echo "pnpm: $(pnpm --version)"
+
+# ========================== Step 3: Detect version ==========================
+note "Step 3 — Detect version"
+
+CURRENT_VERSION=$(read_version)
+if [ -z "$CURRENT_VERSION" ]; then
+    err "Could not read version from package.json."
+    exit 1
+fi
+# main carries `<release>-dev` while the next release is in flight. The
+# release version is the bare semver — strip the `-dev` (also `-SNAPSHOT`
+# for parity with the upstream skywalking convention).
+RELEASE_VERSION="${CURRENT_VERSION%-dev}"
+RELEASE_VERSION="${RELEASE_VERSION%-SNAPSHOT}"
+if [ "${CURRENT_VERSION}" = "${RELEASE_VERSION}" ]; then
+    err "package.json version '${CURRENT_VERSION}' has no '-dev' / '-SNAPSHOT' 
suffix."
+    err "main should carry the dev-suffixed version between releases — bump it 
before running this script."
+    exit 1
+fi
+MAJOR=$(echo "$RELEASE_VERSION" | cut -d. -f1)
+MINOR=$(echo "$RELEASE_VERSION" | cut -d. -f2)
+NEXT_MINOR=$((MINOR + 1))
+NEXT_RELEASE_VERSION="${MAJOR}.${NEXT_MINOR}.0"
+
+echo "Current (in package.json): ${CURRENT_VERSION}"
+echo "Release:                   ${RELEASE_VERSION}"
+echo "Next dev:                  ${NEXT_RELEASE_VERSION}-dev"
+if ! confirm "Are these correct?"; then
+    read -r -p "Enter release version: " RELEASE_VERSION
+    read -r -p "Enter next version (without -dev suffix): " 
NEXT_RELEASE_VERSION
+fi
+TAG="v${RELEASE_VERSION}"
+
+# ========================== Step 4: Version-consistency check 
==========================
+note "Step 4 — Version consistency check"
+
+CONSISTENT=true
+check_file_has_version() {
+    local file="$1"; local needle="$2"
+    if ! file_has "${PROJECT_DIR}/${file}" "$needle"; then
+        err "${file} is missing expected token: ${needle}"
+        CONSISTENT=false
+    fi
+}
+# Every code-side marker carries the dev-suffixed version (`-dev`) on
+# main between releases. Strip-and-tag happens later in the clone, NOT
+# in the local working tree.
+for pj in package.json packages/api-client/package.json 
packages/design-tokens/package.json \
+          packages/templates/package.json apps/bff/package.json 
apps/ui/package.json; do
+    check_file_has_version "$pj" "\"version\": \"${CURRENT_VERSION}\""
+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.
+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 ! $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}."
+
+# ========================== Step 5: Doc + Changelog check 
==========================
+note "Step 5 — Docs + CHANGELOG check"
+
+if ! grep -q "^## ${RELEASE_VERSION}$" "${PROJECT_DIR}/CHANGELOG.md"; then
+    err "CHANGELOG.md has no '## ${RELEASE_VERSION}' section heading."
+    exit 1
+fi
+# Reject the placeholder body. Operators MUST fill the section in before
+# casting a vote — a stub CHANGELOG line in a release tarball is a
+# review smell.
+if awk -v v="${RELEASE_VERSION}" '
+    $0 == "## " v        { in_sec=1; next }
+    in_sec && /^## /     { in_sec=0 }
+    in_sec               { print }
+' "${PROJECT_DIR}/CHANGELOG.md" | grep -q "In development"; then
+    err "CHANGELOG.md ${RELEASE_VERSION} section still contains the '(In 
development …)' placeholder. Fill it in."
+    exit 1
+fi
+echo "CHANGELOG.md has a non-placeholder section for ${RELEASE_VERSION}."
+
+# Make sure LICENSE / NOTICE exist at the repo root (they ship in src+bin 
tarballs).
+for f in LICENSE NOTICE HEADER; do
+    [ -f "${PROJECT_DIR}/${f}" ] || { err "${f} missing at repo root."; exit 
1; }
+done
+echo "LICENSE / NOTICE / HEADER present."
+
+# ========================== Step 6: License-header check 
==========================
+note "Step 6 — License-header check (license-eye)"
+
+(cd "${PROJECT_DIR}" && license-eye -c .licenserc.yaml header check)
+echo "License headers OK."
+
+# ========================== Step 7: Clone fresh ==========================
+note "Step 7 — Clone fresh repo"
+
+rm -rf "${WORK_DIR}"
+mkdir -p "${WORK_DIR}"
+echo "Cloning ${REPO_URL} (branch: ${REPO_BRANCH}) into ${CLONE_DIR}…"
+git clone --depth 1 --branch "${REPO_BRANCH}" "${REPO_URL}" "${CLONE_DIR}"
+
+CLONE_VERSION=$(node -e 
"process.stdout.write(JSON.parse(require('fs').readFileSync('${CLONE_DIR}/package.json','utf8')).version)")
+if [ "${CLONE_VERSION}" != "${CURRENT_VERSION}" ]; then
+    err "Fresh clone has version ${CLONE_VERSION}, expected ${CURRENT_VERSION} 
(the dev-suffixed version on ${REPO_BRANCH})."
+    exit 1
+fi
+
+# ========================== Step 8: Strip -dev, advance docs, commit, tag 
==========================
+note "Step 8 — Prepare release commit + tag ${TAG}"
+
+cd "${CLONE_DIR}"
+
+# Strip the -dev suffix on every code marker in the clone. The committed
+# release-tagged commit must carry the bare semver.
+node -e "
+const fs = require('fs');
+const files = [
+  'package.json',
+  'packages/api-client/package.json',
+  'packages/design-tokens/package.json',
+  'packages/templates/package.json',
+  'apps/bff/package.json',
+  'apps/ui/package.json',
+];
+for (const f of files) {
+  const j = JSON.parse(fs.readFileSync(f, 'utf8'));
+  j.version = '${RELEASE_VERSION}';
+  fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\n');
+}
+"
+sed -i.bak "s/'${CURRENT_VERSION}'/'${RELEASE_VERSION}'/g" 
apps/bff/src/server.ts
+rm apps/bff/src/server.ts.bak
+
+# Advance docs from the prior release tag to the new one so the image
+# tag references in the release tarball match the release being cut.
+sed -i.bak 
"s|ghcr.io/apache/skywalking-horizon-ui:${PRIOR_RELEASE}|ghcr.io/apache/skywalking-horizon-ui:${RELEASE_VERSION}|g"
 docs/setup/container-image.md
+rm docs/setup/container-image.md.bak
+
+git add package.json packages/*/package.json apps/*/package.json 
apps/bff/src/server.ts docs/setup/container-image.md
+git commit -m "Prepare release ${RELEASE_VERSION}"
+
+if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then
+    err "Tag ${TAG} already exists on origin. Delete it first if you need to 
re-cut, or pick a new version."
+    exit 1
+fi
+git tag "${TAG}"
+git push origin HEAD:"${REPO_BRANCH}" "${TAG}"
+echo "Pushed release commit + tag ${TAG}."
+
+# ========================== Step 9: Build source tarball 
==========================
+note "Step 9 — Build source tarball"
+
+SRC_TAR="${WORK_DIR}/${PRODUCT_NAME}-${RELEASE_VERSION}-src.tar.gz"
+# A "source release" is the canonical Apache artifact. It must be buildable
+# from scratch — no node_modules, no dist, no editor leftovers.
+tar -C "${WORK_DIR}" \
+    --exclude='skywalking-horizon-ui/.git' \
+    --exclude='skywalking-horizon-ui/.github/workflows/publish-image.yaml' \
+    --exclude='skywalking-horizon-ui/node_modules' \
+    --exclude='skywalking-horizon-ui/**/node_modules' \
+    --exclude='skywalking-horizon-ui/dist' \
+    --exclude='skywalking-horizon-ui/**/dist' \
+    --exclude='skywalking-horizon-ui/_deploy_tmp' \
+    --exclude='skywalking-horizon-ui/.DS_Store' \
+    --exclude='skywalking-horizon-ui/**/.DS_Store' \
+    --exclude='skywalking-horizon-ui/.release-work' \
+    --transform 
"s,^skywalking-horizon-ui,${PRODUCT_NAME}-${RELEASE_VERSION}-src," \
+    -czf "${SRC_TAR}" \
+    skywalking-horizon-ui
+
+echo "Source tarball: ${SRC_TAR}"
+
+# ========================== Step 10: Build binary (self-contained) 
==========================
+note "Step 10 — Build binary tarball (self-contained, no install/network at 
boot)"
+
+cd "${CLONE_DIR}"
+pnpm install --frozen-lockfile
+pnpm package
+# The packager left dist/server.js + dist/node_modules + dist/static + …
+# Now layer in LICENSE/NOTICE + per-dep license texts.
+node "${CLONE_DIR}/scripts/collect-dist-licenses.mjs"
+node "${CLONE_DIR}/scripts/check-dist-licenses.mjs"
+
+# Stage the binary contents under a clean folder name so the tar root
+# entry matches the artifact name. Copy in the operator-facing docs too.
+BIN_STAGE="${WORK_DIR}/${PRODUCT_NAME}-${RELEASE_VERSION}-bin"
+rm -rf "${BIN_STAGE}"
+cp -R "${CLONE_DIR}/dist" "${BIN_STAGE}"
+cp "${CLONE_DIR}/CHANGELOG.md" "${BIN_STAGE}/CHANGELOG.md"
+cp "${CLONE_DIR}/README.md"    "${BIN_STAGE}/README.md"
+# dist/LICENSE and dist/NOTICE were just generated by the collector and
+# are the BINARY-flavored versions (Apache-2.0 + bundled-dep summary +
+# pass-through NOTICEs). The repo-root LICENSE/NOTICE — source-flavored —
+# stay in the source tarball only. Do NOT overwrite.
+
+BIN_TAR="${WORK_DIR}/${PRODUCT_NAME}-${RELEASE_VERSION}-bin.tar.gz"
+tar -C "${WORK_DIR}" -czf "${BIN_TAR}" "${PRODUCT_NAME}-${RELEASE_VERSION}-bin"
+
+echo "Binary tarball: ${BIN_TAR}"
+
+# ========================== Step 11: Compare LICENSE/NOTICE in tarballs 
==========================
+note "Step 11 — Verify LICENSE/NOTICE in src + bin tarballs"
+
+# Both tarballs must carry a LICENSE and NOTICE at their root, and the
+# binary version must be the expanded one (contains the "Subcomponents"
+# section the collector appends). The source version must NOT contain
+# that section — a bundled-dep summary on a source-only tarball would
+# be a wire-shape lie.
+src_license=$(tar -tzf "${SRC_TAR}" | grep -E 
"^${PRODUCT_NAME}-${RELEASE_VERSION}-src/LICENSE$" || true)
+src_notice=$( tar -tzf "${SRC_TAR}" | grep -E 
"^${PRODUCT_NAME}-${RELEASE_VERSION}-src/NOTICE$"  || true)
+bin_license=$(tar -tzf "${BIN_TAR}" | grep -E 
"^${PRODUCT_NAME}-${RELEASE_VERSION}-bin/LICENSE$" || true)
+bin_notice=$( tar -tzf "${BIN_TAR}" | grep -E 
"^${PRODUCT_NAME}-${RELEASE_VERSION}-bin/NOTICE$"  || true)
+[ -n "$src_license" ] || { err "Source tarball missing LICENSE";  exit 1; }
+[ -n "$src_notice"  ] || { err "Source tarball missing NOTICE";   exit 1; }
+[ -n "$bin_license" ] || { err "Binary tarball missing LICENSE";  exit 1; }
+[ -n "$bin_notice"  ] || { err "Binary tarball missing NOTICE";   exit 1; }
+
+src_lic_text=$(tar -xzf "${SRC_TAR}" -O 
"${PRODUCT_NAME}-${RELEASE_VERSION}-src/LICENSE")
+bin_lic_text=$(tar -xzf "${BIN_TAR}" -O 
"${PRODUCT_NAME}-${RELEASE_VERSION}-bin/LICENSE")
+if echo "$src_lic_text" | grep -qE 'Horizon UI Subcomponents'; then
+    err "Source LICENSE unexpectedly contains 'Horizon UI Subcomponents' — 
that section belongs in the binary tarball only."
+    exit 1
+fi
+if ! echo "$bin_lic_text" | grep -qE 'Horizon UI Subcomponents'; then
+    err "Binary LICENSE missing 'Horizon UI Subcomponents' — collector did not 
run."
+    exit 1
+fi
+
+echo "src LICENSE sha512: $(echo "$src_lic_text" | shasum -a 512 | cut -d' ' 
-f1)"
+echo "bin LICENSE sha512: $(echo "$bin_lic_text" | shasum -a 512 | cut -d' ' 
-f1)"
+
+# ========================== Step 12: GPG sign + sha512 
==========================
+note "Step 12 — GPG sign + sha512"
+
+cd "${WORK_DIR}"
+for t in "${SRC_TAR}" "${BIN_TAR}"; do
+    gpg --armor --detach-sig "${t}"
+    shasum -a 512 "$(basename "${t}")" > "${t}.sha512"
+done
+
+echo "Artifacts:"
+ls -lh "${SRC_TAR}" "${SRC_TAR}.asc" "${SRC_TAR}.sha512" \
+       "${BIN_TAR}" "${BIN_TAR}.asc" "${BIN_TAR}.sha512"
+
+# Verify signatures locally before publishing.
+gpg --verify "${SRC_TAR}.asc" "${SRC_TAR}"
+gpg --verify "${BIN_TAR}.asc" "${BIN_TAR}"
+shasum -a 512 -c "${SRC_TAR}.sha512"
+shasum -a 512 -c "${BIN_TAR}.sha512"
+echo "Self-verify OK."
+
+# ========================== Step 13: SVN upload ==========================
+note "Step 13 — Upload to ${SVN_DEV_URL}/${RELEASE_VERSION}"
+
+read -r -p "Apache SVN username: " SVN_USER
+read -r -s -p "Apache SVN password: " SVN_PASS
+echo ""
+
+SVN_STAGE="${WORK_DIR}/svn-staging"
+rm -rf "${SVN_STAGE}"
+svn co --depth empty --username "${SVN_USER}" --password "${SVN_PASS}" \
+       --non-interactive --no-auth-cache \
+       "${SVN_DEV_URL}" "${SVN_STAGE}"
+
+SVN_VERSION_DIR="${SVN_STAGE}/${RELEASE_VERSION}"
+if svn ls --username "${SVN_USER}" --password "${SVN_PASS}" --non-interactive 
--no-auth-cache \
+       "${SVN_DEV_URL}/${RELEASE_VERSION}" >/dev/null 2>&1; then
+    echo "Version folder exists on SVN. Updating in place."
+    svn update --username "${SVN_USER}" --password "${SVN_PASS}" 
--non-interactive --no-auth-cache \
+               --set-depth infinity "${SVN_VERSION_DIR}"
+else
+    mkdir -p "${SVN_VERSION_DIR}"
+    (cd "${SVN_STAGE}" && svn add "${RELEASE_VERSION}")
+fi
+
+cp "${SRC_TAR}" "${SRC_TAR}.asc" "${SRC_TAR}.sha512" "${SVN_VERSION_DIR}/"
+cp "${BIN_TAR}" "${BIN_TAR}.asc" "${BIN_TAR}.sha512" "${SVN_VERSION_DIR}/"
+
+(cd "${SVN_STAGE}" && svn add --force "${RELEASE_VERSION}")
+(cd "${SVN_STAGE}" && svn commit \
+    --username "${SVN_USER}" --password "${SVN_PASS}" \
+    --non-interactive --no-auth-cache \
+    -m "Upload Apache SkyWalking Horizon UI ${RELEASE_VERSION} release 
candidate")
+
+echo "Uploaded: ${SVN_DEV_URL}/${RELEASE_VERSION}"
+unset SVN_PASS
+
+# ========================== Step 14: Vote email ==========================
+note "Step 14 — Vote email"
+
+SRC_SHA512=$(cat "${SRC_TAR}.sha512")
+BIN_SHA512=$(cat "${BIN_TAR}.sha512")
+VOTE_DATE=$(date +"%B %d, %Y")
+RELEASE_COMMIT=$(git -C "${CLONE_DIR}" rev-parse "${TAG}")
+
+cat <<EOF
+
+========================================================================
+Vote Email — copy and send to [email protected]
+========================================================================
+
+Subject: [VOTE] Release Apache SkyWalking Horizon UI version ${RELEASE_VERSION}
+
+Hi All,
+
+This is a call for vote to release Apache SkyWalking Horizon UI
+version ${RELEASE_VERSION}.
+
+Release notes:
+
+ * https://github.com/apache/skywalking-horizon-ui/blob/${TAG}/CHANGELOG.md
+
+Release Candidate:
+
+ * ${SVN_DEV_URL}/${RELEASE_VERSION}
+ * sha512 checksums
+   - ${SRC_SHA512}
+   - ${BIN_SHA512}
+
+Release Tag:
+
+ * (Git Tag) ${TAG}
+
+Release CommitID:
+
+ * https://github.com/apache/skywalking-horizon-ui/tree/${TAG}
+ * SHA: ${RELEASE_COMMIT}
+
+Keys to verify the Release Candidate:
+
+ * https://dist.apache.org/repos/dist/release/skywalking/KEYS
+
+Guide to build the release from source:
+
+ * Extract apache-skywalking-horizon-ui-${RELEASE_VERSION}-src.tar.gz
+ * cd into the extracted directory
+ * pnpm install --frozen-lockfile
+ * pnpm package
+ * node dist/server.js (after copying horizon.example.yaml → horizon.yaml)
+
+Voting will start now (${VOTE_DATE}) and will remain open for at least
+72 hours. PMC members, please cast your vote.
+
+[ ] +1 Release this package.
+[ ] +0 No opinion.
+[ ] -1 Do not release this package because …
+
+========================================================================
+EOF
+
+# ========================== Step 15: Prepare next version 
==========================
+note "Step 15 — Prepare next-version (${NEXT_RELEASE_VERSION}) PR"
+
+if ! confirm "Push next-version PR (${NEXT_RELEASE_VERSION}) now?"; then
+    echo "Skipping next-version PR. Release artifacts are in ${WORK_DIR}/."
+    exit 0
+fi
+
+cd "${CLONE_DIR}"
+git checkout -b "prepare-next-${NEXT_RELEASE_VERSION}"
+
+# Bump every code marker to the next dev-suffixed version.
+NEXT_DEV_VERSION="${NEXT_RELEASE_VERSION}-dev"
+node -e "
+const fs = require('fs');
+const files = [
+  'package.json',
+  'packages/api-client/package.json',
+  'packages/design-tokens/package.json',
+  'packages/templates/package.json',
+  'apps/bff/package.json',
+  'apps/ui/package.json',
+];
+for (const f of files) {
+  const j = JSON.parse(fs.readFileSync(f, 'utf8'));
+  j.version = '${NEXT_DEV_VERSION}';
+  fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\n');
+}
+"
+
+# server.ts default — keep in lock-step with HORIZON_VERSION.
+sed -i.bak "s/'${RELEASE_VERSION}'/'${NEXT_DEV_VERSION}'/g" 
apps/bff/src/server.ts
+rm apps/bff/src/server.ts.bak
+
+# Container-image docs already point at ${RELEASE_VERSION} (the release
+# commit just bumped them). They stay there — docs always reference the
+# last released tag, not the in-flight dev version.
+
+# Rotate CHANGELOG: insert a fresh placeholder at the top.
+node -e "
+const fs = require('fs');
+const path = 'CHANGELOG.md';
+const txt = fs.readFileSync(path, 'utf8');
+const insertion = '## ${NEXT_RELEASE_VERSION}\n\n(In development — fill in 
highlights here before cutting the release.)\n\n';
+// Insert above the first '## <prev>' heading.
+const out = txt.replace(/^## /m, insertion + '## ');
+fs.writeFileSync(path, out);
+"
+
+git add package.json packages/*/package.json apps/*/package.json 
apps/bff/src/server.ts CHANGELOG.md
+git commit -m "Prepare next release ${NEXT_DEV_VERSION}"
+git push --set-upstream origin "prepare-next-${NEXT_RELEASE_VERSION}"
+
+gh pr create --title "Prepare next release ${NEXT_DEV_VERSION}" \
+    --body "Bump every package version to ${NEXT_DEV_VERSION} and rotate 
CHANGELOG for the next development cycle after ${RELEASE_VERSION}." \
+    --base "${REPO_BRANCH}"
+
+note "Done."
+echo "  Release version:    ${RELEASE_VERSION}"
+echo "  Next dev version:   ${NEXT_DEV_VERSION}"
+echo "  SVN dev staging:    ${SVN_DEV_URL}/${RELEASE_VERSION}"
+echo "  Release tag:        ${TAG}"
+echo ""
+echo "Next steps:"
+echo "  1. Send the vote email above to [email protected]."
+echo "  2. After the vote passes, run:  svn mv 
${SVN_DEV_URL}/${RELEASE_VERSION} \\"
+echo "         
https://dist.apache.org/repos/dist/release/skywalking/horizon-ui/${RELEASE_VERSION}";
+echo "  3. Merge the next-version PR."

Reply via email to