ci: replace semantic-release with manual release workflow (#8)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a6a3495..4f0c0e1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -70,32 +70,3 @@
         coveralls --finish
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
-  release:
-    name: Release
-    runs-on: ubuntu-latest
-    needs: [ test, coveralls ]
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-        with:
-          fetch-depth: 0
-
-      - name: Setup Node.js
-        uses: actions/setup-node@v2
-        with:
-          node-version: '20'
-
-      - name: Setup
-        run: npm install
-      
-      - name: Set up python
-        uses: actions/setup-python@v4
-        with:
-          python-version: '3.12'
-      
-      - name: Release
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
-        run: npx semantic-release
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..2c3e2e6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,131 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: Release
+
+# Releases are NOT automatic. A release manager pushes a git tag manually:
+#   * v1.7.0-rc1  -> publishes a GitHub *pre-release* only (artifact for the Apache vote)
+#   * v1.7.0      -> publishes a GitHub *release* and pushes the package to PyPI
+on:
+  push:
+    tags:
+      - 'v*'
+
+permissions:
+  contents: write
+
+jobs:
+  release:
+    name: Manual Tag Release
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout Repository
+        uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+
+      - name: Resolve Tag
+        id: tag
+        run: |
+          set -euo pipefail
+          TAG="${GITHUB_REF_NAME}"
+          VERSION="${TAG#v}"
+
+          if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-[Rr][Cc]([0-9]+)$ ]]; then
+            BASE_VERSION="${BASH_REMATCH[1]}"
+            RC_NUMBER="${BASH_REMATCH[2]}"
+            IS_RC=true
+          elif [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            BASE_VERSION="$VERSION"
+            RC_NUMBER=""
+            IS_RC=false
+          else
+            echo "::error::Tag '$TAG' is not a supported release tag (expected vX.Y.Z or vX.Y.Z-rcN)."
+            exit 1
+          fi
+
+          # The source release must describe its own version, so pyproject.toml
+          # has to be bumped and committed before the tag is pushed.
+          PROJECT_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
+          if [[ "$PROJECT_VERSION" != "$BASE_VERSION" ]]; then
+            echo "::error::pyproject.toml version ($PROJECT_VERSION) does not match tag version ($BASE_VERSION). Bump pyproject.toml before tagging."
+            exit 1
+          fi
+
+          # Apache source release artifact naming. The RC number is intentionally
+          # NOT part of the file name, so the bits that get voted on are byte
+          # identical to the bits published for the final release.
+          ARTIFACT="apache-casbin-django-orm-adapter-${BASE_VERSION}-incubating-src.tar.gz"
+
+          {
+            echo "tag=$TAG"
+            echo "base_version=$BASE_VERSION"
+            echo "rc_number=$RC_NUMBER"
+            echo "is_rc=$IS_RC"
+            echo "artifact=$ARTIFACT"
+          } >> "$GITHUB_OUTPUT"
+
+      - name: Build Apache Source Package
+        run: |
+          set -euo pipefail
+          ARTIFACT="${{ steps.tag.outputs.artifact }}"
+          # The tarball unpacks into a single top level directory named after the
+          # artifact, and contains sources only - no binaries, no VCS metadata.
+          git archive --format=tar.gz \
+            --prefix="${ARTIFACT%.tar.gz}/" \
+            -o "$ARTIFACT" HEAD
+          sha512sum "$ARTIFACT" > "$ARTIFACT.sha512"
+
+      - name: Publish GitHub Pre-Release
+        if: steps.tag.outputs.is_rc == 'true'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -euo pipefail
+          ARTIFACT="${{ steps.tag.outputs.artifact }}"
+          gh release create "${{ steps.tag.outputs.tag }}" \
+            --title "v${{ steps.tag.outputs.base_version }}-rc${{ steps.tag.outputs.rc_number }}" \
+            --prerelease \
+            --notes "Release candidate ${{ steps.tag.outputs.rc_number }} for version ${{ steps.tag.outputs.base_version }}. This is not an official release; it is the source package for the Apache vote. Release managers should download \`$ARTIFACT\`, sign it locally, and use it for the vote." \
+            "$ARTIFACT" "$ARTIFACT.sha512"
+
+      - name: Publish GitHub Release
+        if: steps.tag.outputs.is_rc == 'false'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -euo pipefail
+          ARTIFACT="${{ steps.tag.outputs.artifact }}"
+          gh release create "${{ steps.tag.outputs.tag }}" \
+            --title "v${{ steps.tag.outputs.base_version }}" \
+            --notes "Version ${{ steps.tag.outputs.base_version }}. The official source package is \`$ARTIFACT\`." \
+            "$ARTIFACT" "$ARTIFACT.sha512"
+
+      - name: Publish to PyPI
+        if: steps.tag.outputs.is_rc == 'false'
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+        run: |
+          set -euo pipefail
+          python -m pip install --upgrade build twine
+          python -m build
+          python -m twine upload dist/*
diff --git a/.releaserc.json b/.releaserc.json
deleted file mode 100644
index 94e24cd..0000000
--- a/.releaserc.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-  "branches": "master",
-  "plugins": [
-    "@semantic-release/commit-analyzer",
-    "@semantic-release/release-notes-generator",
-    "semantic-release-pypi",
-    "@semantic-release/github",
-    [
-      "@semantic-release/changelog",
-      {
-        "changelogFile": "CHANGELOG.md",
-        "changelogTitle": "# Semantic Versioning Changelog"
-      }
-    ],
-	  [
-      "@semantic-release/git",
-      {
-        "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
-        "assets": ["CHANGELOG.md", "pyproject.toml"]
-      }
-    ]
-  ]
-}
\ No newline at end of file
diff --git a/package.json b/package.json
deleted file mode 100644
index 16bdf0f..0000000
--- a/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
-    "devDependencies": {
-        "@semantic-release/changelog": "^6.0.3",
-        "@semantic-release/git": "^10.0.1",
-        "semantic-release": "^22.0.5",
-        "semantic-release-pypi": "^5.1.0"
-    }
-}