Move release tagging to Gitea, trigger GitHub releases off tag push
Gitea is the source of truth. CI runs on Forgejo Actions, and after it
passes on main, a calver tag is created automatically. Tags mirror to
GitHub, where a tag-push-triggered workflow builds binaries and creates
the release.

Assisted-by: GLM-5 via pi
change ntnklvktsxqzpvwoszsztpoxtwtlwrxp
commit e9e767a275d9621b3f9dce5c8bcbc88928892ec1
author Alpha Chen <alpha@kejadlen.dev>
date
parent qrywkksz
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..5d50e86
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+
+permissions: {}
+
+jobs:
+  ci:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+      - run: rustup component add clippy rustfmt llvm-tools
+      - run: cargo install grcov
+      - run: cargo install just
+      - run: cargo fmt --check
+      - run: just clippy coverage
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..ac35a11
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,42 @@
+name: Tag release
+
+on:
+  workflow_run:
+    workflows: [CI]
+    types: [completed]
+    branches: [main]
+  workflow_dispatch:
+
+permissions:
+  contents: write
+
+jobs:
+  tag:
+    if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Calculate version
+        id: version
+        run: |
+          CALVER=$(date -u +"%Y-%m-%d")
+          SHORT_SHA=$(git rev-parse --short HEAD)
+          VERSION="${CALVER}+${SHORT_SHA}"
+          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+
+      - name: Push tag
+        run: |
+          VERSION="${{ steps.version.outputs.version }}"
+          TAG="v${VERSION}"
+
+          # Skip if this tag already exists
+          if git tag -l "$TAG" | grep -q .; then
+            echo "Tag ${TAG} already exists, skipping"
+            exit 0
+          fi
+
+          git tag "$TAG"
+          git push origin "$TAG"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 15072c2..577d96d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,35 +1,14 @@
 name: Release
 
-on: # zizmor: ignore[dangerous-triggers] -- workflow_run only triggers on main after CI passes; no fork PR risk.
-  workflow_dispatch:
-  workflow_run:
-    workflows: [CI]
-    types: [completed]
-    branches: [main]
+on: # zizmor: ignore[dangerous-triggers] -- only triggers on tag pushes from Gitea mirror; no fork PR risk.
+  push:
+    tags:
+      - 'v*'
 
 permissions: {}
 
 jobs:
-  version:
-    if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
-    runs-on: ubuntu-latest
-    outputs:
-      version: ${{ steps.version.outputs.version }}
-    steps:
-      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
-        with:
-          fetch-depth: 0
-          persist-credentials: false
-
-      - name: Calculate version
-        id: version
-        run: |
-          CALVER=$(date -u +"%Y-%m-%d")
-          SHORT_SHA=$(git rev-parse --short HEAD)
-          echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
-
   build-macos:
-    needs: version
     runs-on: macos-latest
     steps:
       - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -47,7 +26,6 @@ jobs:
           path: ranger-aarch64-apple-darwin.tar.gz
 
   build-linux:
-    needs: version
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -66,34 +44,28 @@ jobs:
           path: ranger-aarch64-unknown-linux-gnu.tar.gz
 
   publish:
-    needs: [version, build-macos, build-linux]
+    needs: [build-macos, build-linux]
     runs-on: ubuntu-latest
     permissions:
       contents: write
     steps:
-      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # zizmor: ignore[artipacked] -- git push needs persisted credentials to push the tag.
-
       - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
         with:
           merge-multiple: true
 
       - name: Publish
         run: |
-          VERSION="${RELEASE_VERSION}"
-          git tag "v${VERSION}"
-          git push origin "v${VERSION}"
-
-          gh release create "v${VERSION}" \
-            --title "v${VERSION}" \
+          VERSION="${GITHUB_REF_NAME#v}"
+          gh release create "${GITHUB_REF_NAME}" \
+            --title "${GITHUB_REF_NAME}" \
             --generate-notes \
             ranger-aarch64-apple-darwin.tar.gz \
             ranger-aarch64-unknown-linux-gnu.tar.gz
         env:
           GH_TOKEN: ${{ github.token }}
-          RELEASE_VERSION: ${{ needs.version.outputs.version }}
 
   dotslash:
-    needs: [version, publish]
+    needs: publish
     runs-on: ubuntu-latest
     permissions:
       contents: write
@@ -104,4 +76,4 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:
           config: .github/workflows/dotslash-config.json
-          tag: v${{ needs.version.outputs.version }}
+          tag: ${{ github.ref_name }}
diff --git a/AGENTS.md b/AGENTS.md
index f96ed9b..5ad8534 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,6 +75,20 @@ When installing system packages (`apt-get`), adding Rust components (`rustup com
 - **Migrations must not lose data.** When recreating a table, always `INSERT INTO ... SELECT` all rows from the original, including data in join tables (e.g. `task_tags`). Test migrations against a database with real data, not just empty schemas.
 - SQLite doesn't support `ALTER TABLE DROP COLUMN` with foreign keys cleanly. When recreating a table, wrap in `PRAGMA foreign_keys = OFF/ON` to prevent `ON DELETE CASCADE` from wiping join tables (e.g. `task_tags`).
 
+## CI and Releases
+
+Two-forge setup:
+
+- **Gitea** (`origin`) is the source of truth. CI runs on Forgejo Actions (`.gitea/workflows/`). On push to `main` and PRs.
+- **GitHub** (`github` remote) is a mirror. Receives tags from Gitea and builds release artifacts.
+
+Release flow:
+
+1. Gitea CI passes on `main`.
+2. Gitea release workflow creates a `vYYYY-MM-DD+<short-sha>` tag and pushes it.
+3. Tag mirrors to GitHub.
+4. GitHub release workflow (`.github/workflows/release.yml`) triggers on tag push, builds macOS and Linux binaries, creates a GitHub release, and publishes Dotslash configuration.
+
 ## VCS
 
 This project uses **jj** (Jujutsu), not git directly. Use `jj` commands for commits, diffs, and history.
diff --git a/README.md b/README.md
index b26e49c..4178380 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,15 @@ Ranger is a single Rust crate with a library and binary target:
 
 The CLI exists primarily so AI agents can manage tasks programmatically. The webapp exists for humans who prefer a visual interface.
 
+## Releases
+
+Ranger uses a two-forge release pipeline:
+
+1. **Gitea** (`git.kejadlen.dev`) is the source of truth. CI runs on every push to `main` (and on PRs). When CI passes, a `vYYYY-MM-DD+<short-sha>` tag is created automatically.
+2. Tags mirror to **GitHub** (`github.com/kejadlen/ranger`). A tag push triggers the release workflow, which builds macOS and Linux binaries, creates a GitHub release, and publishes Dotslash configuration.
+
+The version format is calver: the date of the commit plus its short SHA (e.g., `v2026-04-21+abc1234`).
+
 ## Roadmap
 
 **First milestone:** self-host Ranger so an AI agent can use it for task management while building Ranger itself.