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

EmryZhang pushed a commit to branch release/casbin-js-workflow-20261107
in repository https://gitbox.apache.org/repos/asf/casbin-casbin.js.git

commit e0c2d8ab39d12318f9511ae025f08977e2fb5e46
Author: Zhangyanrui123 <[email protected]>
AuthorDate: Sat Nov 7 23:32:01 2026 +0800

    ci: publish only from verified ASF source originals
---
 .github/scripts/release.py      | 187 ++++++++++++++++++++++++++++++++++++
 .github/scripts/test_release.py | 205 ++++++++++++++++++++++++++++++++++++++++
 .github/workflows/ci.yml        |   7 +-
 .github/workflows/release.yml   | 157 ++++++++++++++++++------------
 4 files changed, 494 insertions(+), 62 deletions(-)

diff --git a/.github/scripts/release.py b/.github/scripts/release.py
new file mode 100644
index 0000000..dd30d98
--- /dev/null
+++ b/.github/scripts/release.py
@@ -0,0 +1,187 @@
+# 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
+#
+#   https://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.
+
+import argparse
+import gzip
+import hashlib
+import io
+import json
+import re
+import subprocess
+import tarfile
+import tempfile
+import tomllib
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+PROJECT = "casbin-js"
+COMPONENT = "casbin-js"
+EXPECTED_PACKAGE = "apache-casbin.js"
+MANIFEST = "package.json"
+ASF_BASE = "https://downloads.apache.org/incubator/casbin";
+
+
+def git(*args):
+    return subprocess.check_output(["git", *args])
+
+
+def metadata(tag):
+    match = re.fullmatch(r"v(\d+\.\d+\.\d+)(-rc[1-9]\d*)?", tag)
+    if not match:
+        raise ValueError("Use vX.Y.Z or vX.Y.Z-rcN (N >= 1)")
+    version, rc = match.groups()
+    manifest = git("show", f"{tag}:{MANIFEST}")
+    package = json.loads(manifest) if MANIFEST.endswith("json") else 
tomllib.loads(manifest.decode())["project"]
+    if package.get("name") != EXPECTED_PACKAGE:
+        raise ValueError("Committed package identity does not match the 
adopted name")
+    actual = package["version"]
+    if actual != version:
+        raise ValueError(f"Commit version {actual} does not match {version}; 
no version stamping")
+    name = f"apache-{PROJECT}-{version}-incubating-src"
+    return version, bool(rc), name
+
+
+def inventory(data, name):
+    result = {}
+    with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive:
+        for member in archive:
+            parts = Path(member.name).parts
+            if not parts or parts[0] != name or ".." in parts or "\\" in 
member.name:
+                raise ValueError(f"Unexpected archive path: {member.name}")
+            if member.isdir():
+                continue
+            if not member.isfile() or member.name in result:
+                raise ValueError(f"Link, special file or duplicate: 
{member.name}")
+            result[member.name] = (archive.extractfile(member).read(), 
member.mode & 0o777)
+    for required in ("LICENSE", "NOTICE", "DISCLAIMER", MANIFEST):
+        if f"{name}/{required}" not in result:
+            raise ValueError(f"Missing {required}")
+    return result
+
+
+def source(tag, name):
+    data = git("archive", "--format=tar", f"--prefix={name}/", tag)
+    inventory(data, name)
+    return data
+
+
+def download(url):
+    with urllib.request.urlopen(url, timeout=90) as response:
+        destination = urllib.parse.urlparse(response.url)
+        if (
+            destination.scheme != "https"
+            or destination.hostname not in ("downloads.apache.org", 
"dlcdn.apache.org")
+            or not destination.path.startswith("/incubator/casbin/")
+        ):
+            raise ValueError("Download redirected outside the project's 
approved ASF release area")
+        return response.read()
+
+
+def verify_signature(key_data, target, signature):
+    with tempfile.TemporaryDirectory() as scratch:
+        keyhome = Path(scratch) / "gnupg"
+        keyhome.mkdir(mode=0o700)
+        keys = Path(scratch) / "KEYS"
+        keys.write_bytes(key_data)
+        subprocess.run(["gpg", "--batch", "--homedir", str(keyhome), 
"--import", str(keys)], check=True)
+        subprocess.run(["gpg", "--batch", "--homedir", str(keyhome), 
"--verify", str(signature), str(target)], check=True)
+
+
+def check_npm(path):
+    root = Path.cwd()
+    expected = json.loads((root / MANIFEST).read_text())
+    files = {}
+    with tarfile.open(path) as archive:
+        for entry in archive:
+            parts = Path(entry.name).parts
+            if not parts or parts[0] != "package" or ".." in parts or "\\" in 
entry.name:
+                raise ValueError("Unsafe npm package entry")
+            if entry.isdir():
+                continue
+            canonical = "/".join(parts[1:])
+            if not entry.isfile() or canonical in files:
+                raise ValueError("npm package contains links, special files or 
duplicates")
+            files[canonical] = archive.extractfile(entry).read()
+    actual = json.loads(files.get(MANIFEST, b"{}"))
+    for field, value in expected.items():
+        if actual.get(field) != value:
+            raise ValueError(f"npm metadata mismatch: {field}")
+    for name in ("LICENSE", "NOTICE", "DISCLAIMER", "README.md"):
+        if files.get(name) != (root / name).read_bytes():
+            raise ValueError(f"npm legal/display content differs: {name}")
+    for name, data in files.items():
+        source = root / name
+        if not source.is_file() or source.read_bytes() != data:
+            raise ValueError(f"Unexpected or changed npm file: {name}")
+    for directory in ("lib",):
+        for source in (root / directory).rglob("*"):
+            if source.is_file() and 
files.get(source.relative_to(root).as_posix()) != source.read_bytes():
+                raise ValueError(f"npm package omits built/source file: 
{source}")
+    for field in ("main", "types"):
+        if expected.get(field) and expected[field].removeprefix("./") not in 
files:
+            raise ValueError(f"npm entry point missing: {field}")
+    print(f"npm contents verified against approved source/build: {path}")
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("command", choices=["meta", "archive", "verify", 
"npm-check"])
+    parser.add_argument("tag")
+    parser.add_argument("output", nargs="?", default="dist-release")
+    args = parser.parse_args()
+    if args.command == "npm-check":
+        check_npm(args.tag)
+        return
+    version, rc, name = metadata(args.tag)
+    if args.command == "meta":
+        print(f"version={version}\nis_rc={str(rc).lower()}\nname={name}")
+        return
+    output = Path(args.output)
+    if output.exists() and any(output.iterdir()):
+        raise ValueError("Refusing to replace existing release files; choose 
an empty output directory")
+    output.mkdir(parents=True, exist_ok=True)
+    expected = source(args.tag, name)
+    filename = name + ".tar.gz"
+    target = output / filename
+    if args.command == "archive":
+        if not rc:
+            raise ValueError("Final releases must reuse approved ASF 
originals, not rebuild source archives")
+        target.write_bytes(gzip.compress(expected, mtime=0))
+        (output / (filename + 
".sha512")).write_text(hashlib.sha512(target.read_bytes()).hexdigest() + "  " + 
filename + "\n", encoding="ascii")
+    else:
+        if rc:
+            raise ValueError("An RC cannot enter final publication")
+        url = f"{ASF_BASE}/{COMPONENT}-{version}-incubating/{filename}"
+        for suffix in ("", ".sha512", ".asc"):
+            (output / (filename + suffix)).write_bytes(download(url + suffix))
+        checksum = (output / (filename + ".sha512")).read_text().split()
+        if len(checksum) != 2 or checksum[1].lstrip("*") != filename:
+            raise ValueError("Unexpected checksum record")
+        if hashlib.sha512(target.read_bytes()).hexdigest() != 
checksum[0].lower():
+            raise ValueError("SHA-512 mismatch")
+        verify_signature(download(ASF_BASE + "/KEYS"), target, output / 
(filename + ".asc"))
+        if inventory(target.read_bytes(), name) != inventory(expected, name):
+            raise ValueError("Approved source contents/modes differ from the 
selected Git tag")
+        # Build package registries only from the approved source, never a 
fresh checkout.
+        with tarfile.open(target) as archive:
+            archive.extractall(output / "source", filter="data")
+    print(f"Verified {target}; {'local unsigned candidate' if rc else 
'approved ASF original'}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py
new file mode 100644
index 0000000..d2ab77a
--- /dev/null
+++ b/.github/scripts/test_release.py
@@ -0,0 +1,205 @@
+# 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
+#
+#   https://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.
+
+import contextlib
+import gzip
+import hashlib
+import io
+import os
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import release
+
+
+class ReleaseSafetyTest(unittest.TestCase):
+    def setUp(self):
+        self.previous = Path.cwd()
+        self.directory = tempfile.TemporaryDirectory()
+        os.chdir(self.directory.name)
+        subprocess.run(["git", "init", "-q"], check=True)
+        for key, value in (("user.name", "Fixture"), ("user.email", 
"[email protected]")):
+            subprocess.run(["git", "config", key, value], check=True)
+        text = ('{"name":"%s","version":"1.2.3"}\n' % 
release.EXPECTED_PACKAGE) if release.MANIFEST.endswith("json") else 
('[project]\nname="%s"\nversion="1.2.3"\n' % release.EXPECTED_PACKAGE)
+        Path(release.MANIFEST).write_text(text, encoding="utf8")
+        for filename in ("LICENSE", "NOTICE", "DISCLAIMER", "README.md"):
+            Path(filename).write_text("Fixture for command safety, not a legal 
compliance assessment.\n", encoding="utf8")
+        self.commit()
+        for tag in ("v1.2.3", "v1.2.3-rc1"):
+            subprocess.run(["git", "tag", tag], check=True)
+        self.name = release.metadata("v1.2.3")[2]
+        # A different gzip compression level proves verify preserves originals.
+        self.original = gzip.compress(release.source("v1.2.3", self.name), 
compresslevel=1, mtime=0)
+
+    def tearDown(self):
+        os.chdir(self.previous)
+        self.directory.cleanup()
+
+    def commit(self):
+        subprocess.run(["git", "add", "-A"], check=True)
+        subprocess.run(["git", "commit", "-qm", "fixture"], check=True)
+
+    def invoke(self, *args):
+        with patch.object(sys, "argv", ["release.py", *args]), 
contextlib.redirect_stdout(io.StringIO()):
+            release.main()
+
+    def downloaded(self, url):
+        if url.endswith("KEYS"):
+            return b"MOCK KEY: cryptographic verification is covered by later 
real RC checks"
+        if url.endswith(".sha512"):
+            return (hashlib.sha512(self.original).hexdigest() + "  " + 
self.name + ".tar.gz\n").encode()
+        if url.endswith(".asc"):
+            return b"MOCK SIGNATURE"
+        return self.original
+
+    def test_rejects_legacy_package_identity(self):
+        manifest = Path(release.MANIFEST)
+        
manifest.write_text(manifest.read_text().replace(release.EXPECTED_PACKAGE, 
"legacy-package"), encoding="utf8")
+        self.commit()
+        subprocess.run(["git", "tag", "v1.2.3-rc9"], check=True)
+        with self.assertRaisesRegex(ValueError, "identity"):
+            release.metadata("v1.2.3-rc9")
+
+    def test_strict_tags(self):
+        for tag in ("master", "v1", "v1.2.3-rc0", "v1.2.3-beta1", 
"v1.2.3-rc1/x", "v1.2.3 "):
+            with self.subTest(tag=tag), self.assertRaises(ValueError):
+                release.metadata(tag)
+
+    def test_version_must_be_committed(self):
+        subprocess.run(["git", "tag", "v1.2.4-rc1"], check=True)
+        with self.assertRaises(ValueError):
+            release.metadata("v1.2.4-rc1")
+
+    def test_reproducible_candidate(self):
+        self.invoke("archive", "v1.2.3-rc1", "one")
+        self.invoke("archive", "v1.2.3-rc1", "two")
+        filename = self.name + ".tar.gz"
+        self.assertEqual((Path("one") / filename).read_bytes(), (Path("two") / 
filename).read_bytes())
+
+    def test_refuses_overwrite(self):
+        Path("existing").mkdir()
+        Path("existing/sentinel").write_bytes(b"preserve")
+        with self.assertRaises(ValueError):
+            self.invoke("archive", "v1.2.3-rc1", "existing")
+        self.assertEqual(Path("existing/sentinel").read_bytes(), b"preserve")
+
+    def test_final_cannot_be_rebuilt(self):
+        with self.assertRaises(ValueError):
+            self.invoke("archive", "v1.2.3", "final")
+
+    def test_rc_cannot_be_promoted(self):
+        with self.assertRaises(ValueError), patch.object(release, "download") 
as download:
+            self.invoke("verify", "v1.2.3-rc1", "promotion")
+        download.assert_not_called()
+
+    def test_missing_legal_file(self):
+        Path("NOTICE").unlink()
+        self.commit()
+        subprocess.run(["git", "tag", "v1.2.3-rc2"], check=True)
+        with self.assertRaises(ValueError):
+            self.invoke("archive", "v1.2.3-rc2", "missing")
+
+    def test_rejects_unsafe_archive_entries(self):
+        for path, link in (("../escape", False), (self.name + "/../escape", 
False), (self.name + "/link", True)):
+            buffer = io.BytesIO()
+            with tarfile.open(fileobj=buffer, mode="w") as archive:
+                entry = tarfile.TarInfo(path)
+                if link:
+                    entry.type = tarfile.SYMTYPE
+                    entry.linkname = "../../escape"
+                archive.addfile(entry)
+            with self.subTest(path=path), self.assertRaises(ValueError):
+                release.inventory(buffer.getvalue(), self.name)
+
+    def test_preserves_original_bytes_after_mock_verification(self):
+        with patch.object(release, "download", side_effect=self.downloaded), 
patch.object(release, "verify_signature") as gpg:
+            self.invoke("verify", "v1.2.3", "approved")
+        self.assertEqual(gpg.call_count, 1)
+        self.assertEqual((Path("approved") / (self.name + 
".tar.gz")).read_bytes(), self.original)
+        self.assertTrue((Path("approved/source") / self.name / 
release.MANIFEST).is_file())
+
+    def test_checksum_failure_stops_before_gpg_and_build(self):
+        def bad(url):
+            return ("0" * 128 + "  " + self.name + ".tar.gz\n").encode() if 
url.endswith(".sha512") else self.downloaded(url)
+
+        with patch.object(release, "download", side_effect=bad), 
patch.object(release, "verify_signature") as gpg:
+            with self.assertRaises(ValueError):
+                self.invoke("verify", "v1.2.3", "bad-checksum")
+        gpg.assert_not_called()
+        self.assertFalse(Path("bad-checksum/source").exists())
+
+    def test_signature_failure_stops_before_build(self):
+        with patch.object(release, "download", side_effect=self.downloaded):
+            with patch.object(release, "verify_signature", 
side_effect=subprocess.CalledProcessError(1, "gpg")):
+                with self.assertRaises(subprocess.CalledProcessError):
+                    self.invoke("verify", "v1.2.3", "bad-signature")
+        self.assertFalse(Path("bad-signature/source").exists())
+
+    def test_different_approved_source_is_rejected(self):
+        Path("README.md").write_text("Changed source\n", encoding="utf8")
+        self.commit()
+        subprocess.run(["git", "tag", "-f", "v1.2.3"], check=True, 
stdout=subprocess.DEVNULL)
+        with patch.object(release, "download", side_effect=self.downloaded), 
patch.object(release, "verify_signature"):
+            with self.assertRaises(ValueError):
+                self.invoke("verify", "v1.2.3", "different-source")
+        self.assertFalse(Path("different-source/source").exists())
+
+    def npm_fixture(self, change=None):
+        Path("lib").mkdir(exist_ok=True)
+        Path("lib/index.js").write_bytes(b"exports.fixture = true;\n")
+        
Path(release.MANIFEST).write_text('{"name":"fixture","version":"1.2.3","license":"Apache-2.0","main":"lib/index.js"}',
 encoding="utf8")
+        files = {name: Path(name).read_bytes() for name in 
(release.MANIFEST,"LICENSE","NOTICE","DISCLAIMER","README.md","lib/index.js")}
+        if change:
+            change(files)
+        with tarfile.open("fixture.tgz", "w:gz") as archive:
+            for name, data in files.items():
+                entry = tarfile.TarInfo("package/" + name)
+                entry.size = len(data)
+                archive.addfile(entry, io.BytesIO(data))
+
+    def test_npm_preserves_source_legal_and_entry_files(self):
+        self.npm_fixture()
+        release.check_npm("fixture.tgz")
+
+    def test_npm_rejects_missing_built_file(self):
+        self.npm_fixture(lambda files: files.pop("lib/index.js"))
+        with self.assertRaises(ValueError):
+            release.check_npm("fixture.tgz")
+
+    def test_npm_rejects_changed_dependency_metadata(self):
+        self.npm_fixture(lambda files: files.update({release.MANIFEST: 
b'{"name":"fixture","version":"9.9.9"}'}))
+        with self.assertRaises(ValueError):
+            release.check_npm("fixture.tgz")
+
+    def test_npm_rejects_changed_legal_file(self):
+        self.npm_fixture(lambda files: files.update({"LICENSE":b"wrong"}))
+        with self.assertRaises(ValueError):
+            release.check_npm("fixture.tgz")
+
+    def test_npm_rejects_path_traversal(self):
+        self.npm_fixture(lambda files: files.update({"../outside":b"unsafe"}))
+        with self.assertRaises(ValueError):
+            release.check_npm("fixture.tgz")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ffe106..f8d7a3e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,7 +22,10 @@ jobs:
           cache: yarn
 
       - name: Install Dependency
-        run: yarn install --frozen-lockfile
+        run: yarn install --frozen-lockfile --ignore-scripts
+
+      - name: Check release safety helpers
+        run: python3 -m unittest discover -s .github/scripts -p "test_*.py"
 
       - name: Run Lint
         run: yarn run lint
@@ -47,7 +50,7 @@ jobs:
           cache: yarn
 
       - name: Install Dependency
-        run: yarn install --frozen-lockfile
+        run: yarn install --frozen-lockfile --ignore-scripts
 
       - name: Run Unit test
         run: yarn run test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f96b5a5..bf3c513 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,7 +6,7 @@
 # "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
+#   https://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
@@ -15,27 +15,32 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Manual release. Releases are cut by pushing a tag by hand:
-#
-#   vX.Y.Z-rcN  ->  GitHub *pre-release* only (the candidate to vote on)
-#   vX.Y.Z      ->  GitHub release + publish to npm (after the vote passed)
-#
-# Both cases attach the same Apache source package. The release manager
-# downloads it, signs it locally and takes it to the vote.
-
-name: release
+name: Release
 
 on:
   push:
-    tags:
-      - 'v*'
+    tags: ['v*-rc*']
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: 'Existing final tag vX.Y.Z, pointing at the approved 
candidate commit'
+        required: true
+        type: string
+      project_result:
+        description: 'Public project vote result URL 
(lists.apache.org/thread/...)'
+        required: true
+        type: string
+      incubator_result:
+        description: 'Public IPMC vote result URL 
(lists.apache.org/thread/...)'
+        required: true
+        type: string
 
 permissions:
   contents: write
 
-env:
-  # Base name of the Apache source package, without version/suffix.
-  ARTIFACT_PREFIX: apache-casbin-js
+concurrency:
+  group: release-${{ github.repository }}-${{ inputs.tag || github.ref_name }}
+  cancel-in-progress: false
 
 jobs:
   release:
@@ -44,63 +49,95 @@ jobs:
     steps:
       - uses: actions/checkout@v4
         with:
+          ref: ${{ inputs.tag || github.ref }}
           fetch-depth: 0
-
-      - name: Resolve version from tag
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+      - name: Validate mode and committed version
+        id: meta
+        env:
+          TAG: ${{ inputs.tag || github.ref_name }}
+          EVENT: ${{ github.event_name }}
+          PROJECT_RESULT: ${{ inputs.project_result }}
+          INCUBATOR_RESULT: ${{ inputs.incubator_result }}
         run: |
           set -euo pipefail
-          TAG="${GITHUB_REF_NAME}"
-          if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]]; then
-            echo "::error::Tag '${TAG}' is not a release tag. Expected vX.Y.Z 
or vX.Y.Z-rcN."
-            exit 1
+          python .github/scripts/release.py meta "$TAG" >> "$GITHUB_OUTPUT"
+          if [[ "$EVENT" == workflow_dispatch ]]; then
+            [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
+            [[ "$PROJECT_RESULT" =~ 
^https://lists\.apache\.org/thread/[a-z0-9]+$ ]]
+            [[ "$INCUBATOR_RESULT" =~ 
^https://lists\.apache\.org/thread/[a-z0-9]+$ ]]
+          else
+            [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc[1-9][0-9]*$ ]]
           fi
-          FULL="${TAG#v}"
-          # The release candidate and the final release are the very same
-          # artifact, so the -rcN suffix never appears in the package name.
-          VERSION="${FULL%-rc*}"
-          if [[ "${FULL}" == *-rc* ]]; then PRERELEASE=true; else 
PRERELEASE=false; fi
-          echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
-          echo "PRERELEASE=${PRERELEASE}" >> "${GITHUB_ENV}"
-          echo "SRC_NAME=${ARTIFACT_PREFIX}-${VERSION}-incubating-src" >> 
"${GITHUB_ENV}"
-
-      - name: Build Apache source package
+      - name: Refuse replacement of an existing GitHub release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAG: ${{ inputs.tag || github.ref_name }}
         run: |
           set -euo pipefail
-          git archive --format=tar.gz --prefix="${SRC_NAME}/" -o 
"${SRC_NAME}.tar.gz" HEAD
-          sha512sum "${SRC_NAME}.tar.gz" > "${SRC_NAME}.tar.gz.sha512"
-
-      - name: Set up Node.js
-        uses: actions/setup-node@v4
+          if gh release view "$TAG" >/dev/null 2>&1; then
+            echo '::error::Release exists; assets will not be overwritten. 
Inspect partial publication before retrying.'
+            exit 1
+          fi
+      - name: Export unsigned source candidate
+        if: steps.meta.outputs.is_rc == 'true'
+        env:
+          TAG: ${{ github.ref_name }}
+        run: python .github/scripts/release.py archive "$TAG"
+      - name: Verify approved ASF originals against the final tag
+        if: steps.meta.outputs.is_rc == 'false'
+        env:
+          TAG: ${{ inputs.tag }}
+        run: python .github/scripts/release.py verify "$TAG"
+      - uses: actions/setup-node@v4
+        if: steps.meta.outputs.is_rc == 'false'
         with:
-          node-version: '20.x'
+          node-version: '22'
           registry-url: 'https://registry.npmjs.org'
-
-      - name: Install Dependency
-        run: yarn install
-
-      - name: Run Unit test
-        run: yarn run test
-
-      # An -rcN tag only ever produces a pre-release; the vote decides whether
-      # it is promoted, which happens by pushing the matching vX.Y.Z tag.
-      - name: Publish GitHub release
+      - name: Build and test npm package from approved source
+        if: steps.meta.outputs.is_rc == 'false'
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          NAME: ${{ steps.meta.outputs.name }}
         run: |
           set -euo pipefail
-          FLAGS=""
-          if [[ "${PRERELEASE}" == "true" ]]; then FLAGS="--prerelease"; fi
-          gh release create "${GITHUB_REF_NAME}" \
-            --title "${GITHUB_REF_NAME}" \
-            --generate-notes \
-            ${FLAGS} \
-            "${SRC_NAME}.tar.gz" "${SRC_NAME}.tar.gz.sha512"
-
-      - name: Publish to npm
-        if: env.PRERELEASE == 'false'
+          cd "dist-release/source/$NAME"
+          yarn install --frozen-lockfile --ignore-scripts
+          yarn lint
+          yarn format:check
+          yarn test --runInBand
+          yarn build
+          mkdir -p "$GITHUB_WORKSPACE/npm-package"
+          npm pack --ignore-scripts --pack-destination 
"$GITHUB_WORKSPACE/npm-package"
+          python "$GITHUB_WORKSPACE/.github/scripts/release.py" npm-check 
"$GITHUB_WORKSPACE"/npm-package/*.tgz
+      - name: Publish approved-source npm package
+        if: steps.meta.outputs.is_rc == 'false'
         env:
           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+        run: npm publish npm-package/*.tgz --access public
+
+      - name: Write release notes
+        env:
+          IS_RC: ${{ steps.meta.outputs.is_rc }}
+          VERSION: ${{ steps.meta.outputs.version }}
+          PROJECT_RESULT: ${{ inputs.project_result }}
+          INCUBATOR_RESULT: ${{ inputs.incubator_result }}
+        run: |
+          printf 'Apache Casbin (Incubating) — Casbin.js %s\n\n' "$VERSION" > 
release-notes.md
+          cat DISCLAIMER >> release-notes.md
+          if [[ "$IS_RC" == true ]]; then
+            printf '\n\nUnsigned source candidate for review; the RM must sign 
and stage it before voting. This is not an approved release.\n' >> 
release-notes.md
+          else
+            printf '\n\nProject result: %s\nIPMC result: %s\nSource assets are 
the approved ASF originals.\n' "$PROJECT_RESULT" "$INCUBATOR_RESULT" >> 
release-notes.md
+          fi
+      - name: Create GitHub release without replacing assets
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAG: ${{ inputs.tag || github.ref_name }}
+          IS_RC: ${{ steps.meta.outputs.is_rc }}
         run: |
           set -euo pipefail
-          npm version "${VERSION}" --no-git-tag-version --allow-same-version
-          npm publish --access public
+          kind=(--latest)
+          if [[ "$IS_RC" == true ]]; then kind=(--prerelease --latest=false); 
fi
+          gh release create "$TAG" --title "$TAG (Apache Casbin Incubating)" 
--notes-file release-notes.md "${kind[@]}" dist-release/*.tar.gz 
dist-release/*.sha512 $(find dist-release -maxdepth 1 -name '*.asc' -print)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to