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

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

commit 7061b43642a2f8655f29d7b153f0c4768db886dd
Author: Zhangyanrui123 <[email protected]>
AuthorDate: Sat Nov 7 23:42:29 2026 +0800

    ci: gate Rust publication on approved source originals
---
 .github/scripts/release.py              | 229 +++++++++++++++++++++++++++++++
 .github/scripts/test_release.py         | 234 ++++++++++++++++++++++++++++++++
 .github/workflows/benchmark-comment.yml |  30 +++-
 .github/workflows/ci.yml                |   1 +
 .github/workflows/release.yml           | 165 ++++++++++++----------
 RELEASING.md                            |  34 +++++
 6 files changed, 620 insertions(+), 73 deletions(-)

diff --git a/.github/scripts/release.py b/.github/scripts/release.py
new file mode 100644
index 0000000..c8f0ddd
--- /dev/null
+++ b/.github/scripts/release.py
@@ -0,0 +1,229 @@
+# 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-rs"
+COMPONENT = "rust"
+MANIFEST = "Cargo.toml"
+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}")
+    actual = json.loads(manifest)["version"] if MANIFEST.endswith("json") else 
tomllib.loads(manifest.decode())["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_crate(path):
+    root = Path.cwd()
+    manifest = (root / MANIFEST).read_bytes()
+    expected = tomllib.loads(manifest.decode())["package"]
+    prefix = f"{expected['name']}-{expected['version']}"
+    files = {}
+    with tarfile.open(path) as archive:
+        for entry in archive:
+            parts = Path(entry.name).parts
+            if not parts or parts[0] != prefix or ".." in parts or "\\" in 
entry.name:
+                raise ValueError("Unsafe crate entry")
+            if entry.isdir():
+                continue
+            if not entry.isfile() or entry.name in files:
+                raise ValueError("Crate contains links, special files or 
duplicates")
+            files[entry.name] = archive.extractfile(entry).read()
+    def content(name):
+        try:
+            return files[prefix + "/" + name]
+        except KeyError as exc:
+            raise ValueError(f"Crate missing {name}") from exc
+    if content("Cargo.toml.orig") != manifest:
+        raise ValueError("Original Cargo manifest differs from approved 
source")
+    original = tomllib.loads(manifest.decode())
+    generated = tomllib.loads(content("Cargo.toml").decode())
+    def dependency_map(table):
+        result = {}
+        for name, value in table.items():
+            values = {"version": value} if isinstance(value, str) else 
dict(value)
+            values.setdefault("default-features", True)
+            values.setdefault("optional", False)
+            values.setdefault("features", [])
+            values["features"] = sorted(values["features"])
+            if "version" in values:
+                values["version"] = "".join(values["version"].split())
+            result[name] = values
+        return result
+    for section in ("dependencies", "dev-dependencies", "build-dependencies"):
+        if dependency_map(original.get(section, {})) != 
dependency_map(generated.get(section, {})):
+            raise ValueError(f"Crate dependency mismatch: {section}")
+    if original.get("features", {}) != generated.get("features", {}):
+        raise ValueError("Crate features differ from approved source")
+    original_targets = original.get("target", {})
+    generated_targets = generated.get("target", {})
+    if original_targets.keys() != generated_targets.keys():
+        raise ValueError("Crate target configuration differs")
+    for target in original_targets:
+        for section in ("dependencies", "dev-dependencies", 
"build-dependencies"):
+            if dependency_map(original_targets[target].get(section, {})) != 
dependency_map(generated_targets[target].get(section, {})):
+                raise ValueError(f"Crate target dependency mismatch: 
{target}/{section}")
+    normalized = generated["package"]
+    for field in ("name", "version", "license", "repository", "description", 
"homepage", "edition", "authors", "keywords", "readme"):
+        if normalized.get(field) != expected.get(field):
+            raise ValueError(f"Crate metadata mismatch: {field}")
+    for name in ("LICENSE", "NOTICE", "DISCLAIMER", "README.md"):
+        if content(name) != (root / name).read_bytes():
+            raise ValueError(f"Crate legal/display content differs: {name}")
+    for name, data in files.items():
+        relative = name[len(prefix) + 1:]
+        if relative in ("Cargo.toml", "Cargo.toml.orig"):
+            continue
+        source = root / relative
+        if not source.is_file() or source.read_bytes() != data:
+            raise ValueError(f"Unexpected or changed crate file: {relative}")
+    # Check every explicitly included source/fixture, including integration 
tests.
+    # Checking src alone could accept a crate missing examples or test data.
+    for pattern in expected.get("include", ["src/**"]):
+        if pattern.endswith("/**"):
+            required = (root / pattern[:-3]).rglob("*")
+        else:
+            required = [root / pattern]
+        for source in required:
+            if source.relative_to(root).as_posix() == MANIFEST:
+                # Cargo normalizes this file; its original and semantic 
metadata
+                # have already been checked above.
+                continue
+            if source.is_file() and 
content(source.relative_to(root).as_posix()) != source.read_bytes():
+                raise ValueError(f"Crate omits or changes an included source 
file: {source.relative_to(root)}")
+    print(f"Crate content verified against approved source: {path}")
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("command", choices=["meta", "archive", "verify", 
"crate-check"])
+    parser.add_argument("tag")
+    parser.add_argument("output", nargs="?", default="dist-release")
+    args = parser.parse_args()
+    if args.command == "crate-check":
+        check_crate(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..be0f294
--- /dev/null
+++ b/.github/scripts/test_release.py
@@ -0,0 +1,234 @@
+# 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 json
+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 = '{"version":"1.2.3"}\n' if release.MANIFEST.endswith("json") 
else '[package]\nversion="1.2.3"\n'
+        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_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 crate_fixture(self, change=None, include=None):
+        source = Path("src/lib.rs")
+        source.parent.mkdir(exist_ok=True)
+        source.write_text("pub fn fixture() {}\n", encoding="utf8")
+        manifest = 
'[package]\nname="fixture"\nversion="1.2.3"\nlicense="Apache-2.0"\nrepository="https://example.invalid/fixture"\ndescription="Fixture"\n'
+        if include:
+            manifest += 'include=' + json.dumps(include) + '\n'
+        Path(release.MANIFEST).write_text(manifest, encoding="utf8")
+        files = {name: Path(name).read_bytes() for name in ("LICENSE", 
"NOTICE", "DISCLAIMER", "README.md", "src/lib.rs")}
+        files["Cargo.toml"] = Path(release.MANIFEST).read_bytes()
+        files["Cargo.toml.orig"] = Path(release.MANIFEST).read_bytes()
+        if change:
+            change(files)
+        with tarfile.open("fixture.crate", "w:gz") as archive:
+            for name, data in files.items():
+                info = tarfile.TarInfo("fixture-1.2.3/" + name)
+                info.size = len(data)
+                archive.addfile(info, io.BytesIO(data))
+
+    def test_crate_preserves_source_and_legal_files(self):
+        self.crate_fixture()
+        release.check_crate("fixture.crate")
+
+    def test_crate_allows_normalized_included_manifest(self):
+        self.crate_fixture(
+            lambda files: files.update({"Cargo.toml": files["Cargo.toml"] + 
b'# Cargo generated metadata\n'}),
+            include=["src/**", "Cargo.toml"],
+        )
+        release.check_crate("fixture.crate")
+
+    def test_crate_rejects_changed_legal_file(self):
+        self.crate_fixture(lambda files: files.update({"NOTICE": b"changed"}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_missing_source(self):
+        self.crate_fixture(lambda files: files.pop("src/lib.rs"))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_missing_included_test(self):
+        Path("tests").mkdir()
+        Path("tests/check.rs").write_text("#[test] fn check() {}\n")
+        self.crate_fixture(include=["src/**", "tests/**"])
+        with self.assertRaisesRegex(ValueError, "Crate missing 
tests/check.rs"):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_changed_normalized_metadata(self):
+        self.crate_fixture(lambda files: files.update({"Cargo.toml": 
files["Cargo.toml"].replace(b'license="Apache-2.0"', b'license="GPL-3.0"')}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_added_dependency(self):
+        self.crate_fixture(lambda files: files.update({"Cargo.toml": 
files["Cargo.toml"] + b'\n[dependencies]\nunsafe="1.0"\n'}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_added_feature(self):
+        self.crate_fixture(lambda files: files.update({"Cargo.toml": 
files["Cargo.toml"] + b'\n[features]\ndefault=["unsafe"]\n'}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_added_target(self):
+        self.crate_fixture(lambda files: files.update({"Cargo.toml": 
files["Cargo.toml"] + 
b'\n[target.\'cfg(windows)\'.dependencies]\nunsafe="1.0"\n'}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+    def test_crate_rejects_path_traversal(self):
+        self.crate_fixture(lambda files: files.update({"../outside": 
b"unsafe"}))
+        with self.assertRaises(ValueError):
+            release.check_crate("fixture.crate")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.github/workflows/benchmark-comment.yml 
b/.github/workflows/benchmark-comment.yml
index 3d19643..73811e6 100644
--- a/.github/workflows/benchmark-comment.yml
+++ b/.github/workflows/benchmark-comment.yml
@@ -42,11 +42,37 @@ jobs:
         with:
           script: |
             const fs = require('fs');
-            const prNumber = parseInt(fs.readFileSync('pr_number.txt', 
'utf8').trim());
+            const run = context.payload.workflow_run;
+            const prs = run.pull_requests || [];
+            if (prs.length !== 1) {
+              core.info('Skipping benchmark comment: no unique PR associated 
with this run.');
+              return;
+            }
+            const prNumberText = fs.readFileSync('pr_number.txt', 
'utf8').trim();
+            if (!/^[1-9][0-9]*$/.test(prNumberText)) {
+              throw new Error('Invalid benchmark PR number');
+            }
+            const prNumber = Number(prNumberText);
+            if (!Number.isSafeInteger(prNumber) || prNumber !== prs[0].number) 
{
+              throw new Error('Benchmark artifact does not belong to this run 
PR');
+            }
+            const { data: pr } = await github.rest.pulls.get({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              pull_number: prNumber
+            });
+            if (pr.state !== 'open' || pr.head.sha !== run.head_sha ||
+                pr.head.repo?.id !== run.head_repository?.id) {
+              core.info('Skipping stale benchmark results.');
+              return;
+            }
+            if (fs.statSync('benchmark_result.txt').size > 50000) {
+              throw new Error('Benchmark result exceeds comment limit');
+            }
             const benchResult = fs.readFileSync('benchmark_result.txt', 
'utf8');
             await github.rest.issues.createComment({
               owner: context.repo.owner,
               repo: context.repo.repo,
               issue_number: prNumber,
-              body: `## Benchmark Results\n\`\`\`\n${benchResult}\n\`\`\``
+              body: `## Benchmark Results\n<pre>${benchResult.replace(/&/g, 
'&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`
             });
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 486b50a..166d787 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,7 @@ jobs:
         uses: actions/checkout@v4
         with:
           repository: apache/casbin
+          ref: c92cc6a070e65dc621210c8d451de3092683cd1c
           path: casbin-conformance
           sparse-checkout: conformance
       - name: Install Rust toolchain ${{ matrix.rust }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 67ff66b..7aee9ef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -17,101 +17,124 @@
 
 name: Release
 
-# Releases are cut by hand: nothing here runs on a merge, only on a pushed tag.
-#
-#   v1.4.0-rc1  ->  GitHub pre-release (the candidate put up for the Apache 
vote)
-#   v1.4.0      ->  GitHub release + publish to crates.io
-#
-# Either way the release carries an Apache-style source tarball as its only
-# asset. The release manager downloads that tarball and signs / checksums it
-# locally before starting the vote; nothing is signed or uploaded here.
-
 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 source release artifact, per Apache naming conventions.
-  ARTIFACT_BASENAME: apache-casbin-rust
+concurrency:
+  group: release-${{ github.repository }}-${{ inputs.tag || github.ref_name }}
+  cancel-in-progress: false
 
 jobs:
   release:
-    name: Release ${{ github.ref_name }}
     if: github.repository == 'apache/casbin-rs'
     runs-on: ubuntu-latest
     steps:
-      - name: Checkout Repository
-        uses: actions/checkout@v4
-
-      - name: Resolve version from tag
-        id: version
+      - 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
-          VERSION="${GITHUB_REF_NAME#v}"
-          # v1.4.0-rc1 -> 1.4.0. The tarball the PPMC votes on is the exact 
tarball
-          # that ships, so the candidate counter is never part of its name.
-          BASE_VERSION="${VERSION%%-rc*}"
-          if [ "$VERSION" = "$BASE_VERSION" ]; then
-            PRERELEASE=false
+          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
-            PRERELEASE=true
-          fi
-          # Anything that is neither vX.Y.Z nor vX.Y.Z-rcN is a typo, and a 
typo
-          # must not reach crates.io.
-          if ! echo "$VERSION" | grep -Eq 
'^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$'; then
-            echo "::error::Tag $GITHUB_REF_NAME is not vX.Y.Z or vX.Y.Z-rcN."
-            exit 1
+            [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc[1-9][0-9]*$ ]]
           fi
-          {
-            echo "base_version=$BASE_VERSION"
-            echo "prerelease=$PRERELEASE"
-          } >> "$GITHUB_OUTPUT"
-          echo "Tag $GITHUB_REF_NAME -> version $BASE_VERSION (pre-release: 
$PRERELEASE)"
-
-      - name: Verify Cargo.toml matches the tag
+      - name: Refuse replacement of an existing GitHub release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAG: ${{ inputs.tag || github.ref_name }}
         run: |
           set -euo pipefail
-          # Read [package].version directly rather than via cargo, which would
-          # write a Cargo.lock and leave the tree dirty for cargo publish.
-          CARGO_VERSION="$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p && 
/^version *=/{gsub(/[" ]/,"");sub(/^version=/,"");print;exit}' Cargo.toml)"
-          EXPECTED="${{ steps.version.outputs.base_version }}"
-          if [ "$CARGO_VERSION" != "$EXPECTED" ]; then
-            echo "::error::Cargo.toml says $CARGO_VERSION but the tag asks for 
$EXPECTED. Bump Cargo.toml, commit, then re-tag."
+          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: Build source release tarball
-        id: package
+      - 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"
+      - name: Select stable Rust toolchain
+        if: steps.meta.outputs.is_rc == 'false'
+        run: rustup default stable
+      - name: Build crate from approved source
+        if: steps.meta.outputs.is_rc == 'false'
+        env:
+          NAME: ${{ steps.meta.outputs.name }}
         run: |
           set -euo pipefail
-          NAME="${ARTIFACT_BASENAME}-${{ steps.version.outputs.base_version 
}}-incubating-src"
-          ASSET="${RUNNER_TEMP}/${NAME}.tar.gz"
-          # git archive gives us exactly the tracked sources at this tag, 
unpacking
-          # into a single top-level directory named after the release. It is 
written
-          # outside the checkout so cargo publish still sees a clean tree.
-          git archive --format=tar.gz --prefix="$NAME/" -o "$ASSET" HEAD
-          echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
-          ls -l "$ASSET"
-
-      - name: Publish GitHub release
+          cp -a "dist-release/source/$NAME" "$RUNNER_TEMP/$NAME"
+          cd "$RUNNER_TEMP/$NAME"
+          cargo publish --locked --dry-run
+          python "$GITHUB_WORKSPACE/.github/scripts/release.py" crate-check 
target/package/*.crate
+      - name: Publish approved-source crate
+        if: steps.meta.outputs.is_rc == 'false'
         env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TAG: ${{ github.ref_name }}
-          ASSET: ${{ steps.package.outputs.asset }}
+          NAME: ${{ steps.meta.outputs.name }}
+          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
         run: |
           set -euo pipefail
-          FLAGS=(--title "$TAG" --generate-notes)
-          if [ "${{ steps.version.outputs.prerelease }}" = "true" ]; then
-            FLAGS+=(--prerelease)
-          fi
-          gh release create "$TAG" "$ASSET" "${FLAGS[@]}"
+          cd "$RUNNER_TEMP/$NAME"
+          cargo publish --locked
 
-      - name: Publish to crates.io
-        if: steps.version.outputs.prerelease == 'false'
+      - name: Write release notes
         env:
-          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
-        run: cargo publish
+          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) — rust %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
new file mode 100644
index 0000000..347efa6
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,34 @@
+<!--
+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. Prepare focused PRs for release 
metadata and legal materials, dependency updates, test and lint fixes, source 
headers, and publication workflows. Verify the combined candidate after all 
required changes merge.
+
+## 3.0 compatibility
+
+The cache key in `CachedApi::get_mut_cache`, `CachedApi::set_cache` and 
`DefaultCache` changes from `u64` to `String`. Custom caches must implement 
`Cache<String, bool>`; do not retain a hash-only cache implementation. The 
crate and Rust import name remain `casbin`. The major version reflects this 
public API change, independently of the release approval process.
+
+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/rust-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 archive. Vo [...]
+
+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.
+
+This component is distributed as a crates.io convenience crate. Before 
registry credentials are used, inspect the dry-run crate against the approved 
source and verify LICENSE, NOTICE, DISCLAIMER, metadata and embedded source. 
Package name and registry administration remain project decisions.


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

Reply via email to