Skip to content

Release

Release #24

Workflow file for this run

name: Release
on:
schedule:
- cron: "0 3 * * 1" # every Monday — check for new core binaries
workflow_dispatch:
inputs:
bump:
description: 'Version bump type'
type: choice
options: [patch, minor, major]
default: patch
skip_bump:
description: 'Skip version bump (use current version)'
type: boolean
default: false
force:
description: 'Force a release even when change detection finds nothing'
type: boolean
default: false
permissions:
contents: write
# Never run two releases at once (they both bump module.prop).
concurrency:
group: release
cancel-in-progress: false
# Third-party actions are pinned to commit SHAs (with the human-readable tag in a
# trailing comment) so a moved tag cannot silently change what runs with the
# `contents: write` token. Bump the SHA + comment together when upgrading.
jobs:
# ── Prepare the release: decide whether to cut one, bump the version, regenerate
# update.json/changelog, commit+tag, and create the (empty) GitHub release. The
# two build jobs below then attach their artifacts to it in parallel — neither
# waits on the other, and the heavy Android/desktop toolchains stay out of here. ──
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
skip: ${{ steps.changes.outputs.skip }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
# Push the version-bump commit under a PAT so it bypasses the `main`
# ruleset (the default GITHUB_TOKEN / github-actions app is not in the
# bypass list and cannot be added on a user-owned repo). Set the
# RELEASE_TOKEN secret to a PAT of a user holding the bypass role; it
# falls back to GITHUB_TOKEN, with which the push step would still be
# rejected by the ruleset.
token: ${{ secrets.RELEASE_TOKEN || github.token }}
persist-credentials: true
- name: Check for changes since last release
id: changes
env:
GH_TOKEN: ${{ github.token }}
run: |
skip=true
# 1. New code commits (docs/ci/chore-only changes do not trigger release).
# These only gate a *manual* run: on the weekly schedule we release
# solely for upstream core bumps (below), never just because code
# landed — cut a code release on demand via workflow_dispatch.
last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
has_code_commits=false
if [ -z "$last_tag" ]; then
has_code_commits=true
else
code_commits=$(git log "$last_tag"..HEAD --oneline \
-- \
'module/*.sh' \
'module/bin/kasumi-proxyctl' \
'module/bin/utils.sh' \
'module/webroot/cgi-bin/' \
'frontend/src/' \
'crates/' \
'src-tauri/' \
'scripts/fetch-binaries.sh' \
'scripts/binaries.json' \
'scripts/binary-versions.sh' \
'scripts/package-release.sh' \
2>/dev/null | wc -l)
[ "$code_commits" -gt 0 ] && has_code_commits=true
fi
if [ "$has_code_commits" = "true" ] && [ "${{ github.event_name }}" != "schedule" ]; then
skip=false
fi
# 2. Upstream binary updates
pinned_xray=$(grep -m1 'XRAY_VERSION=.*:-' scripts/binary-versions.sh | sed 's/.*:-\([^}"]*\)}.*/\1/')
pinned_t2s=$(grep -m1 'TUN2SOCKS_VERSION=.*:-' scripts/binary-versions.sh | sed 's/.*:-\([^}"]*\)}.*/\1/')
pinned_sb=$(grep -m1 'SINGBOX_VERSION=.*:-' scripts/binary-versions.sh | sed 's/.*:-\([^}"]*\)}.*/\1/')
latest_xray=$(gh release view --repo XTLS/Xray-core --json tagName -q .tagName)
latest_t2s=$(gh release view --repo xjasonlyu/tun2socks --json tagName -q .tagName)
latest_sb=$(gh release view --repo SagerNet/sing-box --json tagName -q .tagName)
bins_changed=false
[ "$pinned_xray" != "$latest_xray" ] && { skip=false; bins_changed=true; }
[ "$pinned_t2s" != "$latest_t2s" ] && { skip=false; bins_changed=true; }
[ "$pinned_sb" != "$latest_sb" ] && { skip=false; bins_changed=true; }
if [ "$bins_changed" = "true" ]; then
echo "bump=minor" >> "$GITHUB_OUTPUT"
else
echo "bump=${{ inputs.bump || 'patch' }}" >> "$GITHUB_OUTPUT"
fi
# Manual override: a workflow_dispatch with force=true releases even when
# nothing changed (empty on schedule/non-dispatch runs, so it's a no-op there).
if [ "${{ inputs.force }}" = "true" ]; then
skip=false
fi
{
echo "xray_old=$pinned_xray"
echo "t2s_old=$pinned_t2s"
echo "sb_old=$pinned_sb"
echo "skip=$skip"
echo "bins_changed=$bins_changed"
echo "xray_version=$latest_xray"
echo "t2s_version=$latest_t2s"
echo "sb_version=$latest_sb"
} >> "$GITHUB_OUTPUT"
- name: Sync core pins + nix hashes
# When upstream cores advanced, write the new versions into the pinned
# source of truth (binary-versions.sh) and regenerate the nix FOD hashes
# (binary-hashes.json), so the next build — and the committed flake — match.
# Both build jobs check out the tag, so the synced pins are authoritative
# for the Android and desktop core fetches; no per-build version override.
if: steps.changes.outputs.skip != 'true' && steps.changes.outputs.bins_changed == 'true'
env:
XRAY_NEW: ${{ steps.changes.outputs.xray_version }}
T2S_NEW: ${{ steps.changes.outputs.t2s_version }}
SB_NEW: ${{ steps.changes.outputs.sb_version }}
run: |
sed -i -E \
-e "s|(XRAY_VERSION=\"\\\$\{XRAY_VERSION:-)[^}]*|\1${XRAY_NEW}|" \
-e "s|(TUN2SOCKS_VERSION=\"\\\$\{TUN2SOCKS_VERSION:-)[^}]*|\1${T2S_NEW}|" \
-e "s|(SINGBOX_VERSION=\"\\\$\{SINGBOX_VERSION:-)[^}]*|\1${SB_NEW}|" \
scripts/binary-versions.sh
scripts/update-binary-hashes.sh
- name: Setup Bun
if: steps.changes.outputs.skip != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Cache Bun dependencies
if: steps.changes.outputs.skip != 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- name: Install UI dependencies
if: steps.changes.outputs.skip != 'true'
# bun workspace rooted at the repo — install from root against the single bun.lock.
run: bun install --frozen-lockfile
- name: Lint & test
# Gate the release on a green frontend before any version is bumped or tagged.
if: steps.changes.outputs.skip != 'true'
run: |
bunx biome check
bun run frontend/scripts/check-i18n.ts
( cd frontend && bunx vitest run )
# ── Block the auto-bump when a new core's config schema drifted from our
# generators. "Sync core pins" above already wrote the new versions into
# binary-versions.sh, so this stages those exact cores and runs the
# config-validation harness against them. A rejected config fails the job
# before any version is bumped or tagged — so we never ship a release whose
# pinned core rejects the configs we generate. Only runs when cores moved. ──
- name: Setup Rust (core-compat gate)
if: steps.changes.outputs.skip != 'true' && steps.changes.outputs.bins_changed == 'true'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- name: Verify the new cores accept our generated configs
if: steps.changes.outputs.skip != 'true' && steps.changes.outputs.bins_changed == 'true'
run: scripts/check-binary-compat.sh
- name: Bump version
if: steps.changes.outputs.skip != 'true'
id: bump
run: |
if [ "${{ inputs.skip_bump }}" = "true" ]; then
version=$(grep -m1 '^version=' module/module.prop | cut -d= -f2)
else
version=$(scripts/bump-version.sh ${{ inputs.bump || 'patch' }})
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Generate update.json
if: steps.changes.outputs.skip != 'true'
run: scripts/gen-update-json.sh
- name: Generate changelog
if: steps.changes.outputs.skip != 'true'
env:
VERSION: ${{ steps.bump.outputs.version }}
XRAY_OLD: ${{ steps.changes.outputs.xray_old }}
XRAY_NEW: ${{ steps.changes.outputs.xray_version }}
T2S_OLD: ${{ steps.changes.outputs.t2s_old }}
T2S_NEW: ${{ steps.changes.outputs.t2s_version }}
SB_OLD: ${{ steps.changes.outputs.sb_old }}
SB_NEW: ${{ steps.changes.outputs.sb_version }}
run: |
scripts/gen-changelog.sh \
"$VERSION" "$XRAY_OLD" "$XRAY_NEW" "$T2S_OLD" "$T2S_NEW" "$SB_OLD" "$SB_NEW"
- name: Commit version bump
if: steps.changes.outputs.skip != 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add module/module.prop update.json CHANGELOG.md \
scripts/binary-versions.sh scripts/binary-hashes.json
# The version/changelog may already be committed (a hand-prepared
# skip_bump release, or a re-run) — don't fail when there is nothing new.
if git diff --cached --quiet; then
echo "nothing to commit — version, update.json and changelog already current"
else
git commit -m "chore(release): ${{ steps.bump.outputs.version }}"
git push
fi
- name: Create GitHub Release
# Created empty here; the module and desktop jobs attach their artifacts to
# this tag in parallel. target_commitish points at the just-pushed bump
# commit, so the tag the build jobs check out carries the bumped version.
if: steps.changes.outputs.skip != 'true'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.bump.outputs.version }}
target_commitish: ${{ github.ref_name }}
name: Kasumi Proxy ${{ steps.bump.outputs.version }}
generate_release_notes: true
# ── Android module zip → uploaded to the release `prepare` created. Runs in
# parallel with `desktop`; the committed binary-versions.sh on the tag pins the
# core fetch. ──
module:
needs: prepare
if: needs.prepare.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout the released tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.prepare.outputs.version }}
fetch-depth: 0
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14
with:
extra-conf: |
accept-flake-config = true
- name: Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kasumi-proxy
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build release zip
run: |
nix develop .#android --command bash -euo pipefail -c '
bun install --frozen-lockfile
scripts/package-release.sh
'
- name: Upload to the release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ needs.prepare.outputs.version }}
# Glob, not a hardcoded name: package-release.sh owns the zip's
# filename format (the module job's checkout produces exactly one zip
# here), so the name lives in one place — same as nightly.yml.
files: build/*.zip
# ── Desktop installers (Linux deb/rpm/AppImage, Windows NSIS/MSI + portable zip) →
# uploaded to the same release. Cores are fetched per-target into
# src-tauri/binaries/ and bundled via the externalBin overlay. AppImage GPG
# signing is opt-in (active only when the secret is set). macOS isn't shipped
# yet (no Platform port) — adding it is a new matrix row. ──
desktop:
needs: prepare
if: needs.prepare.outputs.skip != 'true'
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- name: Checkout the released tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.prepare.outputs.version }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Install Tauri CLI
uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2
with:
tool: tauri-cli
- name: Linux desktop deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \
libayatana-appindicator3-dev patchelf
- name: Build the UI
run: |
bun install --frozen-lockfile
( cd frontend && bun run build )
- name: Fetch desktop binaries
shell: bash
run: scripts/fetch-binaries.sh desktop ${{ matrix.target }}
- name: Import GPG key (Linux signing, optional)
if: runner.os == 'Linux'
shell: bash
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
fi
- name: Build desktop bundles
shell: bash
env:
# linuxdeploy/appimagetool mount themselves with FUSE, which the runner
# doesn't provide — extract-and-run instead, or AppImage bundling aborts
# the whole job ("failed to run linuxdeploy") after the .deb is built.
APPIMAGE_EXTRACT_AND_RUN: 1
# Linux AppImage GPG signing — active only when the key/passphrase are set.
SIGN: ${{ secrets.GPG_PRIVATE_KEY != '' && '1' || '' }}
SIGN_KEY: ${{ secrets.GPG_KEY_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
# minisign key for the auto-updater — makes `tauri build` emit a `.sig`
# next to each updatable artifact (AppImage / NSIS). Distinct from the
# GPG key above, which signs the AppImage file for manual verification.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
# Build + stage the privilege helper as a sidecar so the bundles ship it
# next to the app (the Linux GUI spawns it elevated; the Windows build is
# an unused stub).
t="${{ matrix.target }}"
ext=""; case "$t" in *windows*) ext=".exe";; esac
cargo build --release --target "$t" --bin kasumi-helper
cp "target/$t/release/kasumi-helper$ext" "src-tauri/binaries/kasumi-helper-$t$ext"
# Inject the real product version (module.prop is the single source of
# truth) so the bundles aren't the 0.0.0 placeholder.
ver=$(scripts/app-version.sh)
cargo tauri build --target "$t" \
--config src-tauri/tauri.bundle.conf.json \
--config "{\"version\":\"$ver\"}"
- name: Collect artifacts
id: collect
shell: bash
run: |
dir="target/${{ matrix.target }}/release/bundle"
mkdir -p dist
find "$dir" -type f \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' -o -name '*-setup.exe' -o -name '*.msi' -o -name '*.sig' \) \
-exec cp {} dist/ \;
ls -la dist
- name: Assemble Windows portable zip
if: runner.os == 'Windows'
shell: bash
run: |
# Portable build: app exe + cores (suffix stripped) + wintun.dll +
# libcronet.dll + a `portable.dat` marker that pins all state next to the
# exe at runtime.
t="${{ matrix.target }}"
out="dist/portable/Kasumi-Proxy"
mkdir -p "$out"
cp "target/$t/release/kasumi-desktop.exe" "$out/"
cp "target/$t/release/kasumi-helper.exe" "$out/"
for c in xray sing-box tun2socks hev-socks5-tunnel; do
cp "src-tauri/binaries/$c-$t.exe" "$out/$c.exe"
done
cp src-tauri/binaries/wintun.dll "$out/"
# libcronet.dll must sit next to sing-box for its naive outbound to load.
cp src-tauri/binaries/libcronet.dll "$out/"
# msys-2.0.dll must sit next to hev-socks5-tunnel (msys2 build) to load.
cp src-tauri/binaries/msys-2.0.dll "$out/"
: > "$out/portable.dat"
name=$(scripts/artifact-name.sh windows-portable)
( cd dist/portable && 7z a -tzip "../$name" Kasumi-Proxy >/dev/null )
rm -rf dist/portable
ls -la dist
- name: Assemble Linux portable zip
if: runner.os == 'Linux'
shell: bash
run: |
# Portable build: app binary + cores (suffix stripped) + a `portable.dat`
# marker that pins all state next to the binary at runtime (USB-stick run).
t="${{ matrix.target }}"
out="dist/portable/Kasumi-Proxy"
mkdir -p "$out"
cp "target/$t/release/kasumi-desktop" "$out/"
cp "target/$t/release/kasumi-helper" "$out/"
for c in xray sing-box tun2socks hev-socks5-tunnel; do
cp "src-tauri/binaries/$c-$t" "$out/$c"
done
# libcronet.so must sit next to sing-box for its naive outbound to load.
cp src-tauri/binaries/libcronet.so "$out/"
chmod +x "$out"/*
: > "$out/portable.dat"
name=$(scripts/artifact-name.sh linux-portable)
( cd dist/portable && zip -qr "../$name" Kasumi-Proxy )
rm -rf dist/portable
ls -la dist
- name: Upload to the release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ needs.prepare.outputs.version }}
files: dist/*
# Per-platform slice of the updater manifest: the `.sig` content + the asset
# URL. The `updater-manifest` job merges every platform's slice into one
# latest.json (the updater endpoint must list all platforms in a single file).
- name: Build updater manifest fragment
shell: bash
run: |
case "${{ matrix.target }}" in
*linux*) plat=linux-x86_64; sig=$(find dist -maxdepth 1 -name '*.AppImage.sig' | head -1) ;;
*windows*) plat=windows-x86_64; sig=$(find dist -maxdepth 1 -name '*-setup.exe.sig' | head -1) ;;
*) plat=""; sig="" ;;
esac
if [ -z "${sig:-}" ]; then
echo "no updater signature for ${{ matrix.target }} (signing key unset?) — skipping"
exit 0
fi
# GitHub rewrites spaces in uploaded asset names to dots — match that so
# the URL resolves (the AppImage carries the "Kasumi Proxy" product name).
asset=$(basename "${sig%.sig}" | tr ' ' '.')
tag="${{ needs.prepare.outputs.version }}"
url="https://github.com/loss-and-quick/Kasumi-Proxy/releases/download/${tag}/${asset}"
mkdir -p frag
jq -n --arg p "$plat" --arg s "$(cat "$sig")" --arg u "$url" \
'{($p): {signature: $s, url: $u}}' > "frag/${plat}.json"
cat "frag/${plat}.json"
- name: Upload updater manifest fragment
if: hashFiles('frag/*.json') != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: updater-frag-${{ matrix.target }}
path: frag/*.json
retention-days: 1
# ── Build the desktop app through the flake and push it + its closure to our
# Cachix cache, so Nix users who add this repo as a flake input pull the
# prebuilt `kasumi-desktop` for the released tag instead of compiling it. Runs
# in parallel with the artifact jobs and gates nothing (a slow/failed nix build
# never blocks the release). Consumers must add the cache to their own Nix
# config — a flake's nixConfig does not propagate to downstream flakes (README). ──
cachix-desktop:
needs: prepare
if: needs.prepare.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout the released tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.prepare.outputs.version }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14
with:
extra-conf: |
accept-flake-config = true
- name: Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: kasumi-proxy
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build kasumi-desktop (cachix-action pushes the result + closure)
run: nix build .#kasumi-desktop --accept-flake-config --print-build-logs
# ── Merge the per-platform updater slices into a single latest.json and attach
# it to the release — this is the static endpoint the desktop updater polls. ──
updater-manifest:
needs: [prepare, desktop]
if: needs.prepare.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- name: Download manifest fragments
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: updater-frag-*
path: frag
merge-multiple: true
- name: Assemble latest.json
shell: bash
run: |
if ! compgen -G "frag/*.json" >/dev/null; then
echo "no updater fragments — skipping latest.json"
exit 0
fi
tag="${{ needs.prepare.outputs.version }}"
platforms=$(jq -s 'add' frag/*.json)
jq -n \
--arg version "${tag#v}" \
--arg pub "$(date -u +%FT%TZ)" \
--argjson platforms "$platforms" \
'{version: $version, pub_date: $pub, platforms: $platforms}' > latest.json
cat latest.json
- name: Upload latest.json to the release
if: hashFiles('latest.json') != ''
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ needs.prepare.outputs.version }}
files: latest.json