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

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

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

    chore: preserve LF line endings in scoped changes
---
 .github/scripts/release.py      | 374 ++++++++++++++++++------------------
 .github/scripts/test_release.py | 410 ++++++++++++++++++++--------------------
 .github/workflows/release.yml   | 304 ++++++++++++++---------------
 RELEASING.md                    |  86 ++++-----
 4 files changed, 587 insertions(+), 587 deletions(-)

diff --git a/.github/scripts/release.py b/.github/scripts/release.py
index 96d4e01..8483620 100644
--- a/.github/scripts/release.py
+++ b/.github/scripts/release.py
@@ -1,187 +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-node-casbin"
-COMPONENT = "node-casbin"
-EXPECTED_PACKAGE = "apache-casbin"
-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", "examples"):
-        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", "typings", "module"):
-        if expected.get(field) and expected[field] 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()
+# 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-node-casbin"
+COMPONENT = "node-casbin"
+EXPECTED_PACKAGE = "apache-casbin"
+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", "examples"):
+        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", "typings", "module"):
+        if expected.get(field) and expected[field] 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
index d2ab77a..8fbfc39 100644
--- a/.github/scripts/test_release.py
+++ b/.github/scripts/test_release.py
@@ -1,205 +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()
+# 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/release.yml b/.github/workflows/release.yml
index 747ee07..7ac4c03 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,152 +1,152 @@
-# 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.
-
-name: Release
-
-on:
-  push:
-    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
-
-concurrency:
-  group: release-${{ github.repository }}-${{ inputs.tag || github.ref_name }}
-  cancel-in-progress: false
-
-jobs:
-  release:
-    if: github.repository == 'apache/casbin-node-casbin'
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-        with:
-          ref: ${{ inputs.tag || github.ref }}
-          fetch-depth: 0
-      - 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
-          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
-      - name: Refuse replacement of an existing GitHub release
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ inputs.tag || github.ref_name }}
-        run: |
-          set -euo pipefail
-          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: '22'
-          registry-url: 'https://registry.npmjs.org'
-      - name: Check out pinned conformance fixtures
-        if: steps.meta.outputs.is_rc == 'false'
-        uses: actions/checkout@v4
-        with:
-          repository: apache/casbin
-          ref: 8f5173b17bee2675025e7b46c9a8116ee352ec84
-          path: casbin-conformance
-          sparse-checkout: conformance
-          persist-credentials: false
-      - name: Build and test npm package from approved source
-        if: steps.meta.outputs.is_rc == 'false'
-        env:
-          NAME: ${{ steps.meta.outputs.name }}
-          CASBIN_CONFORMANCE_DIR: ${{ github.workspace 
}}/casbin-conformance/conformance
-        run: |
-          set -euo pipefail
-          cd "dist-release/source/$NAME"
-          yarn install --frozen-lockfile --ignore-scripts
-          yarn lint
-          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) — Node-Casbin %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
-          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)
+# 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.
+
+name: Release
+
+on:
+  push:
+    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
+
+concurrency:
+  group: release-${{ github.repository }}-${{ inputs.tag || github.ref_name }}
+  cancel-in-progress: false
+
+jobs:
+  release:
+    if: github.repository == 'apache/casbin-node-casbin'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ inputs.tag || github.ref }}
+          fetch-depth: 0
+      - 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
+          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
+      - name: Refuse replacement of an existing GitHub release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAG: ${{ inputs.tag || github.ref_name }}
+        run: |
+          set -euo pipefail
+          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: '22'
+          registry-url: 'https://registry.npmjs.org'
+      - name: Check out pinned conformance fixtures
+        if: steps.meta.outputs.is_rc == 'false'
+        uses: actions/checkout@v4
+        with:
+          repository: apache/casbin
+          ref: 8f5173b17bee2675025e7b46c9a8116ee352ec84
+          path: casbin-conformance
+          sparse-checkout: conformance
+          persist-credentials: false
+      - name: Build and test npm package from approved source
+        if: steps.meta.outputs.is_rc == 'false'
+        env:
+          NAME: ${{ steps.meta.outputs.name }}
+          CASBIN_CONFORMANCE_DIR: ${{ github.workspace 
}}/casbin-conformance/conformance
+        run: |
+          set -euo pipefail
+          cd "dist-release/source/$NAME"
+          yarn install --frozen-lockfile --ignore-scripts
+          yarn lint
+          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) — Node-Casbin %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
+          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)
diff --git a/RELEASING.md b/RELEASING.md
index ca3ade0..a51df06 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,43 +1,43 @@
-<!--
-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.
--->
-
-# Apache Casbin (Incubating) release
-
-Commit the final version before tagging. One preparation PR contains the 
source, legal, dependency, packaging and workflow changes.
-
-A `vX.Y.Z-rcN` tag exports an unsigned source candidate only, never a package 
registry release or GitHub latest. The release manager reviews the archive, 
signs it, uploads source/checksum/signature to ASF development distribution, 
and calls project and IPMC votes for those exact bytes. A changed candidate 
needs a new RC.
-
-After approval, promote the original signed files unchanged to 
`https://downloads.apache.org/incubator/casbin/node-casbin-X.Y.Z-incubating/` 
and create the final `vX.Y.Z` tag at the same commit. Pushing the final tag 
alone does not publish. Run the Release workflow manually with that tag and the 
two public result URLs. The workflow verifies SHA-512, signature against 
project KEYS, and every source file/mode against Git before building package 
registry distributions from the approved arch [...]
-
-Every distribution includes LICENSE, NOTICE and DISCLAIMER. Existing GitHub 
assets and registry versions are never overwritten. A partial publication must 
be inspected by the release manager before retry; reruns fail safely when a 
version already exists. Signing, votes, registry credentials, channel 
administration and final publication are distinct later gates.
-
-## Registry identity migration
-
-The adopted registry name is `apache-casbin`. Historical package versions 
remain untouched. Confirm administration of this exact new name before 
publishing; old-package credentials are not sufficient evidence. Preserve 
existing version lineage in the engineering candidate; project review adopts 
the final version. Do not publish placeholders, RCs or snapshots to reserve a 
name.
-
-## Tests and conformance fixtures
-
-Use a frozen Yarn install and run lint, tests and both module builds. To 
include
-the shared cross-language cases, check out `apache/casbin` at
-`8f5173b17bee2675025e7b46c9a8116ee352ec84` outside the source tree and set
-`CASBIN_CONFORMANCE_DIR` to its `conformance` directory before running the 
tests.
-CI and the final publication workflow use this same pinned fixture revision.
-These fixtures are test inputs and are not bundled in the source or npm 
package.
-Without the variable the optional conformance test is skipped; report that
-coverage explicitly when reviewing a release.
+<!--
+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.
+-->
+
+# Apache Casbin (Incubating) release
+
+Commit the final version before tagging. One preparation PR contains the 
source, legal, dependency, packaging and workflow changes.
+
+A `vX.Y.Z-rcN` tag exports an unsigned source candidate only, never a package 
registry release or GitHub latest. The release manager reviews the archive, 
signs it, uploads source/checksum/signature to ASF development distribution, 
and calls project and IPMC votes for those exact bytes. A changed candidate 
needs a new RC.
+
+After approval, promote the original signed files unchanged to 
`https://downloads.apache.org/incubator/casbin/node-casbin-X.Y.Z-incubating/` 
and create the final `vX.Y.Z` tag at the same commit. Pushing the final tag 
alone does not publish. Run the Release workflow manually with that tag and the 
two public result URLs. The workflow verifies SHA-512, signature against 
project KEYS, and every source file/mode against Git before building package 
registry distributions from the approved arch [...]
+
+Every distribution includes LICENSE, NOTICE and DISCLAIMER. Existing GitHub 
assets and registry versions are never overwritten. A partial publication must 
be inspected by the release manager before retry; reruns fail safely when a 
version already exists. Signing, votes, registry credentials, channel 
administration and final publication are distinct later gates.
+
+## Registry identity migration
+
+The adopted registry name is `apache-casbin`. Historical package versions 
remain untouched. Confirm administration of this exact new name before 
publishing; old-package credentials are not sufficient evidence. Preserve 
existing version lineage in the engineering candidate; project review adopts 
the final version. Do not publish placeholders, RCs or snapshots to reserve a 
name.
+
+## Tests and conformance fixtures
+
+Use a frozen Yarn install and run lint, tests and both module builds. To 
include
+the shared cross-language cases, check out `apache/casbin` at
+`8f5173b17bee2675025e7b46c9a8116ee352ec84` outside the source tree and set
+`CASBIN_CONFORMANCE_DIR` to its `conformance` directory before running the 
tests.
+CI and the final publication workflow use this same pinned fixture revision.
+These fixtures are test inputs and are not bundled in the source or npm 
package.
+Without the variable the optional conformance test is skipped; report that
+coverage explicitly when reviewing a release.


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

Reply via email to