feat: replace semantic-release with tag-triggered manual release
diff --git a/.github/vcpkg/casbin/portfile.cmake b/.github/vcpkg/casbin/portfile.cmake
new file mode 100644
index 0000000..c5bd87b
--- /dev/null
+++ b/.github/vcpkg/casbin/portfile.cmake
@@ -0,0 +1,33 @@
+# Template for the vcpkg `casbin` port. The Release workflow substitutes
+# @VERSION@ and @SHA512@ and opens a pull request against microsoft/vcpkg;
+# this file is never consumed by vcpkg directly from here.
+
+vcpkg_check_linkage(ONLY_STATIC_LIBRARY)
+
+vcpkg_from_github(
+    OUT_SOURCE_PATH SOURCE_PATH
+    REPO casbin/casbin-cpp
+    REF "v${VERSION}"
+    SHA512 @SHA512@
+    HEAD_REF master
+)
+
+vcpkg_cmake_configure(
+    SOURCE_PATH "${SOURCE_PATH}"
+    OPTIONS
+        # Everything below pulls its dependencies in with FetchContent, which a
+        # port build has no network for. nlohmann_json is the one real
+        # dependency and comes from vcpkg instead.
+        -DCASBIN_BUILD_TEST=OFF
+        -DCASBIN_BUILD_BENCHMARK=OFF
+        -DCASBIN_BUILD_PYTHON_BINDINGS=OFF
+        -DCASBIN_INSTALL=ON
+)
+
+vcpkg_cmake_install()
+
+vcpkg_cmake_config_fixup(PACKAGE_NAME casbin CONFIG_PATH lib/cmake/casbin)
+
+file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include")
+
+vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE")
diff --git a/.github/vcpkg/casbin/vcpkg.json b/.github/vcpkg/casbin/vcpkg.json
new file mode 100644
index 0000000..7926319
--- /dev/null
+++ b/.github/vcpkg/casbin/vcpkg.json
@@ -0,0 +1,18 @@
+{
+  "name": "casbin",
+  "version": "@VERSION@",
+  "description": "An authorization library that supports access control models like ACL, RBAC, ABAC in C/C++",
+  "homepage": "https://github.com/casbin/casbin-cpp",
+  "license": "Apache-2.0",
+  "dependencies": [
+    "nlohmann-json",
+    {
+      "name": "vcpkg-cmake",
+      "host": true
+    },
+    {
+      "name": "vcpkg-cmake-config",
+      "host": true
+    }
+  ]
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 64b9ef3..d7eee45 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,25 +1,199 @@
+#  Copyright 2026 The casbin Authors. All Rights Reserved.
+
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+# Releases are cut by hand: the release manager pushes a tag, and this workflow
+# turns that tag into a GitHub release. Nothing is published on a plain merge to
+# master, because an Apache release has to be voted on before it exists.
+#
+#   v1.4.0-rc1  ->  GitHub *pre-release*, for the release manager to sign and
+#                   put up for a vote.
+#   v1.4.0      ->  GitHub release plus a pull request against the vcpkg
+#                   registry, once that vote has passed.
+#
+# Both cases attach the same source package, named and laid out the way an
+# Apache release vote expects. The release manager downloads it, signs it
+# locally, and votes with it; the signature never comes back through here.
+
 name: Release
 
 on:
   push:
-    branches:
-      - master
-  pull_request:
-    branches:
-      - master
+    tags:
+      - 'v*'
+
+permissions:
+  contents: write
 
 jobs:
-  semantic-release:
+  github-release:
+    name: GitHub release
+    if: github.repository == 'casbin/casbin-cpp'
     runs-on: ubuntu-latest
+    outputs:
+      version: ${{ steps.meta.outputs.version }}
+      prerelease: ${{ steps.meta.outputs.prerelease }}
     steps:
-      - uses: actions/checkout@v3
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
 
-      - name: Run semantic-release
-        if: github.repository == 'casbin/casbin-cpp' && github.event_name == 'push'
+      - name: Read the version out of the tag
+        id: meta
         run: |
-          export PATH="$(yarn global bin):$PATH"
-          yarn global add semantic-release@19.0.5
-          semantic-release
-          sleep 10
+          set -euo pipefail
+          tag="${GITHUB_REF_NAME}"
+          full="${tag#v}"
+          # The package always carries the final version, even for a release
+          # candidate, so the exact bits that were voted on can be promoted
+          # without being repackaged: v1.4.0-rc1 also packages as 1.4.0.
+          version="${full%%-rc*}"
+          if [ "$full" = "$version" ]; then
+            prerelease=false
+          else
+            prerelease=true
+          fi
+          {
+            echo "version=${version}"
+            echo "prerelease=${prerelease}"
+          } >> "$GITHUB_OUTPUT"
+          echo "::notice::${tag} -> version ${version}, prerelease=${prerelease}"
+
+      - name: Build the source release package
+        id: package
         env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+          VERSION: ${{ steps.meta.outputs.version }}
+        run: |
+          set -euo pipefail
+          name="apache-casbin-cpp-${VERSION}-src"
+          # git archive ships exactly what is tracked at the tag: no .git, no
+          # build output, no bundled binaries, everything under a single
+          # versioned top-level directory.
+          git archive --format=tar.gz --prefix="${name}/" -o "${name}.tar.gz" "${GITHUB_REF_NAME}"
+          sha512sum "${name}.tar.gz" > "${name}.tar.gz.sha512"
+          echo "tarball=${name}.tar.gz" >> "$GITHUB_OUTPUT"
+
+      - name: Check the package carries the required legal files
+        env:
+          TARBALL: ${{ steps.package.outputs.tarball }}
+        run: |
+          set -euo pipefail
+          prefix="${TARBALL%.tar.gz}"
+          # Listed once into a variable: piping tar into `grep -q` would leave
+          # tar killed by SIGPIPE, which pipefail reports as a failed check.
+          listing="$(tar -tzf "${TARBALL}")"
+          for legal in LICENSE NOTICE; do
+            if ! grep -qx "${prefix}/${legal}" <<< "${listing}"; then
+              echo "::error::${legal} is missing from ${TARBALL}; the release vote would fail on it"
+              exit 1
+            fi
+          done
+
+      - name: Publish the release
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TARBALL: ${{ steps.package.outputs.tarball }}
+          PRERELEASE: ${{ steps.meta.outputs.prerelease }}
+        run: |
+          set -euo pipefail
+          args=(--title "${GITHUB_REF_NAME}" --generate-notes)
+          if [ "${PRERELEASE}" = "true" ]; then
+            args+=(--prerelease)
+          fi
+          gh release create "${GITHUB_REF_NAME}" "${args[@]}" "${TARBALL}" "${TARBALL}.sha512"
+
+  vcpkg:
+    name: Submit to vcpkg
+    needs: github-release
+    # Release candidates exist only for the vote; they never reach a registry.
+    if: needs.github-release.outputs.prerelease == 'false'
+    runs-on: ubuntu-latest
+    env:
+      VERSION: ${{ needs.github-release.outputs.version }}
+      # A fork of microsoft/vcpkg that the token below can push to.
+      FORK: ${{ vars.VCPKG_FORK || 'casbin/vcpkg' }}
+    steps:
+      - name: Check the vcpkg fork token is configured
+        id: gate
+        env:
+          VCPKG_PR_TOKEN: ${{ secrets.VCPKG_PR_TOKEN }}
+        run: |
+          if [ -n "${VCPKG_PR_TOKEN}" ]; then
+            echo "enabled=true" >> "$GITHUB_OUTPUT"
+          else
+            echo "enabled=false" >> "$GITHUB_OUTPUT"
+            echo "::warning::VCPKG_PR_TOKEN is not set, so no vcpkg pull request was opened for ${VERSION}."
+          fi
+
+      - name: Checkout casbin-cpp
+        if: steps.gate.outputs.enabled == 'true'
+        uses: actions/checkout@v4
+        with:
+          path: casbin-cpp
+
+      - name: Checkout vcpkg
+        if: steps.gate.outputs.enabled == 'true'
+        uses: actions/checkout@v4
+        with:
+          repository: microsoft/vcpkg
+          path: vcpkg
+          fetch-depth: 0
+
+      - name: Render the casbin port
+        if: steps.gate.outputs.enabled == 'true'
+        run: |
+          set -euo pipefail
+          # vcpkg_from_github pins the tarball GitHub generates for the tag, so
+          # that is the file whose checksum goes into the port.
+          curl -fsSL -o source.tar.gz \
+            "https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/v${VERSION}.tar.gz"
+          sha512="$(sha512sum source.tar.gz | cut -d' ' -f1)"
+          mkdir -p vcpkg/ports/casbin
+          for file in portfile.cmake vcpkg.json; do
+            sed -e "s|@VERSION@|${VERSION}|g" -e "s|@SHA512@|${sha512}|g" \
+              "casbin-cpp/.github/vcpkg/casbin/${file}" > "vcpkg/ports/casbin/${file}"
+          done
+
+      - name: Update the vcpkg version database
+        if: steps.gate.outputs.enabled == 'true'
+        working-directory: vcpkg
+        run: |
+          set -euo pipefail
+          git config user.name "casbin-bot"
+          git config user.email "casbin-bot@users.noreply.github.com"
+          git add ports/casbin
+          git commit -m "[casbin] Update to ${VERSION}"
+          ./bootstrap-vcpkg.sh -disableMetrics
+          # x-add-version reads the port out of the commit above, so it has to
+          # run after it; the result is folded back into the same commit.
+          ./vcpkg x-add-version casbin --overwrite-version
+          git add versions
+          git commit --amend --no-edit
+
+      - name: Open the pull request against microsoft/vcpkg
+        if: steps.gate.outputs.enabled == 'true'
+        working-directory: vcpkg
+        env:
+          GH_TOKEN: ${{ secrets.VCPKG_PR_TOKEN }}
+        run: |
+          set -euo pipefail
+          branch="casbin-${VERSION}"
+          git remote add fork "https://x-access-token:${GH_TOKEN}@github.com/${FORK}.git"
+          git push --force fork "HEAD:${branch}"
+          gh pr create \
+            --repo microsoft/vcpkg \
+            --base master \
+            --head "${FORK%%/*}:${branch}" \
+            --title "[casbin] Update to ${VERSION}" \
+            --body "Updates the \`casbin\` port to ${VERSION}, released at https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${VERSION}."
diff --git a/.releaserc.json b/.releaserc.json
deleted file mode 100644
index 10d8b31..0000000
--- a/.releaserc.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
-    "debug": true,
-    "release": {
-      "branches": [
-        "master"
-      ]
-    },
-    "plugins": [
-      "@semantic-release/commit-analyzer",
-      "@semantic-release/release-notes-generator",
-      "@semantic-release/github"
-    ]
-  }
\ No newline at end of file
diff --git a/README.md b/README.md
index 4c4eddc..34c78a6 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
 
 [![CI](https://github.com/casbin/casbin-cpp/actions/workflows/ci.yml/badge.svg)](https://github.com/casbin/casbin-cpp/actions/workflows/ci.yml)
 [![GitHub release (latest by date)](https://img.shields.io/github/v/release/casbin/casbin-cpp)](https://github.com/casbin/casbin-cpp/releases/latest)
-[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
 [![Discord](https://img.shields.io/discord/1022748306096537660?logo=discord&label=discord&color=5865F2)](https://discord.gg/S5UjpzGZjN)
 
 **News**: Are you still worried about how to write the correct Casbin policy? ``Casbin online editor`` is coming to help! Try it at: http://casbin.org/editor/