Skip to content

Release

Release #48

Workflow file for this run

name: Release
# Triggered when `np` (or manual `git tag`) pushes a semver tag.
# Orchestrates: GitHub Release creation -> Desktop builds -> Mobile builds -> Checksums.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g. 0.3.2)'
required: true
type: string
# Minimal permissions — only contents:write for release management
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── 1. Create the GitHub Release ────────────────────────────────────────
create-release:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.resolve.outputs.release_id }}
upload_url: ${{ steps.resolve.outputs.upload_url }}
version: ${{ steps.version.outputs.version }}
release_draft: ${{ steps.resolve.outputs.release_draft }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Extract and validate version
id: version
run: |
VERSION="${{ github.event.inputs.version || '' }}"
if [ -z "$VERSION" ]; then
VERSION="${GITHUB_REF#refs/tags/v}"
fi
# Validate semver format (major.minor.patch with optional pre-release)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "Error: tag does not match semver format: $VERSION"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Reuse the release that `np` already created (preserving its release
# notes and author). Only create a new one when no release exists for
# this tag (e.g. manual tag push or workflow_dispatch).
#
# If a *draft* release exists from a previous failed CI run, delete it
# first so we don't accumulate stale drafts — but never delete a
# published release that `np` created.
- name: Resolve or create GitHub Release
id: resolve
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
TAG="v${{ steps.version.outputs.version }}"
# Fetch all releases for this tag (including drafts).
# gh api /releases/tags/{tag} does NOT return drafts, so we
# paginate the full list and filter by tag_name.
releases=$(gh api "repos/${GH_REPO}/releases" --paginate \
--jq "[.[] | select(.tag_name==\"$TAG\")]")
# Look for a published (non-draft) release first — this is the
# one `np` created with the author's release notes.
published_id=$(echo "$releases" | jq -r '[.[] | select(.draft==false)] | first // empty | .id')
if [ -n "$published_id" ]; then
echo "Found existing published release id=$published_id for $TAG — reusing it"
# Delete any stale drafts that may have been left by a previous
# failed CI run (they would be duplicates).
draft_ids=$(echo "$releases" | jq -r '[.[] | select(.draft==true)] | .[].id')
for did in $draft_ids; do
echo "Deleting stale draft release id=$did"
gh api -X DELETE "repos/${GH_REPO}/releases/${did}" || true
done
upload_url=$(gh api "repos/${GH_REPO}/releases/${published_id}" \
--jq '.upload_url')
echo "release_id=$published_id" >> "$GITHUB_OUTPUT"
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
echo "release_draft=false" >> "$GITHUB_OUTPUT"
else
echo "No published release found for $TAG"
# Delete any stale drafts from previous failed runs
draft_ids=$(echo "$releases" | jq -r '.[].id')
for did in $draft_ids; do
echo "Deleting stale draft release id=$did"
gh api -X DELETE "repos/${GH_REPO}/releases/${did}" || true
done
# Create a new release (only happens for manual tag pushes
# or workflow_dispatch where np didn't run).
echo "Creating new GitHub Release for $TAG"
response=$(gh api --method POST "repos/${GH_REPO}/releases" \
-f tag_name="$TAG" \
-f name="$TAG" \
-F draft=true \
-F prerelease=false \
-F generate_release_notes=true)
release_id=$(echo "$response" | jq -r '.id')
upload_url=$(echo "$response" | jq -r '.upload_url')
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
echo "release_draft=true" >> "$GITHUB_OUTPUT"
fi
# ── 2. Build Desktop (Tauri) ────────────────────────────────────────────
build-desktop:
needs: create-release
uses: ./.github/workflows/release-desktop.yml
with:
version: ${{ needs.create-release.outputs.version }}
release_id: ${{ needs.create-release.outputs.release_id }}
release_draft: ${{ needs.create-release.outputs.release_draft }}
secrets: inherit
# ── 2b. Build Mobile (Android APK/AAB + iOS IPA) ───────────────────────
# Reusable workflow runs android + ios as sibling jobs. iOS is secret-gated
# and skips gracefully when signing secrets aren't configured, so this
# never blocks the release when provisioning isn't ready.
build-mobile:
needs: create-release
uses: ./.github/workflows/release-mobile.yml
with:
version: ${{ needs.create-release.outputs.version }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
# ── 3. Generate checksums (best-effort) ────────────────────────────────
checksums:
needs: [create-release, build-desktop, build-mobile]
if: always() && needs.build-desktop.result == 'success'
runs-on: ubuntu-latest
steps:
- name: List existing release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
echo "=== Release id=${RELEASE_ID} assets ==="
gh api "repos/${GH_REPO}/releases/${RELEASE_ID}/assets" \
--jq '.[].name' || echo "Failed to list assets"
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
# Use release_id via gh api — `gh release download TAG` uses
# /releases/tags/{tag} which doesn't find draft releases.
mkdir -p artifacts
cd artifacts
patterns='\.(dmg|app\.tar\.gz|app\.tar\.gz\.sig|msi|msi\.zip|msi\.zip\.sig|nsis\.zip|nsis\.zip\.sig|exe|deb|rpm|AppImage|AppImage\.tar\.gz|AppImage\.tar\.gz\.sig|apk|aab|ipa)$'
gh api "repos/${GH_REPO}/releases/${RELEASE_ID}/assets" \
--jq ".[] | select(.name | test(\"${patterns}\")) | [.id, .name] | @tsv" | \
while IFS=$'\t' read -r asset_id asset_name; do
[ -z "$asset_id" ] && continue
echo "→ $asset_name"
gh api -H "Accept: application/octet-stream" \
"repos/${GH_REPO}/releases/assets/${asset_id}" > "$asset_name" || true
done
echo "=== Downloaded files ==="
ls -la . || true
- name: Generate SHA-256 checksums
id: sums
run: |
cd artifacts
shopt -s nullglob
files=( * )
if [ ${#files[@]} -gt 0 ]; then
sha256sum -- "${files[@]}" | LC_ALL=C sort > SHA256SUMS.txt
echo "=== SHA256SUMS.txt ==="
cat SHA256SUMS.txt
echo "=== File count: $(wc -l < SHA256SUMS.txt) ==="
echo "generated=true" >> "$GITHUB_OUTPUT"
else
echo "No artifacts found to checksum — skipping (release will publish without SHA256SUMS.txt)"
echo "generated=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload checksums to release
if: steps.sums.outputs.generated == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
# Use release_id via gh api instead of softprops/action-gh-release,
# which uses the /releases/tags/{tag} endpoint that does NOT find
# draft releases and would create a duplicate non-draft release.
cd artifacts
name="SHA256SUMS.txt"
existing_id=$(gh api "repos/${GH_REPO}/releases/${RELEASE_ID}/assets" \
--jq ".[] | select(.name==\"$name\") | .id" | head -n 1)
if [ -n "$existing_id" ]; then
gh api -X DELETE "repos/${GH_REPO}/releases/assets/${existing_id}" || true
fi
gh api \
--method POST \
-H "Content-Type: text/plain" \
"https://uploads.github.com/repos/${GH_REPO}/releases/${RELEASE_ID}/assets?name=${name}" \
--input "$name"
# ── 4. Publish the release ─────────────────────────────────────────────
# Publish as long as the desktop build succeeded — checksums are best-effort
# and shouldn't block publishing the actual binaries.
publish:
needs: [create-release, build-desktop, build-mobile, checksums]
if: always() && needs.build-desktop.result == 'success' && needs.create-release.outputs.release_draft == 'true'
runs-on: ubuntu-latest
steps:
- name: Publish release (remove draft status)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
run: |
# Only releases created as drafts by this workflow need publishing.
gh api --method PATCH "repos/${GH_REPO}/releases/${RELEASE_ID}" \
-f draft=false
# ── 5. Deploy web app ─────────────────────────────────────────────────
# Inline deploy because GITHUB_TOKEN events don't trigger other workflows,
# so deploy.yml's `on: release: [published]` never fires from the publish step.
# deploy.yml is kept for manual re-deploys via workflow_dispatch.
deploy:
needs: [create-release, publish]
if: always() && (needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: read
environment: release
env:
R2_BUCKET: ${{ vars.R2_BUCKET }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
run_install: false
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20'
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Strip source maps
run: find dist -name '*.map' -delete
- name: Deploy to Cloudflare R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws --endpoint-url "$ENDPOINT" s3 sync dist/ "s3://${R2_BUCKET}/" --acl private
- name: Deploy Worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
run: |
sed -i "s/bucket_name = \"myselfhosted-webmail-test-1\"/bucket_name = \"${R2_BUCKET}\"/" worker/wrangler.toml
cd worker
pnpm install --no-frozen-lockfile
npx wrangler deploy
- name: Purge Cloudflare Cache
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
curl -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
# ── 6. Notify Matrix (final step — runs only on full success) ──────────
# Standalone matrix workflow has `release:` disabled so this is the single
# source of release notifications. Uses a direct curl to the Client-Server
# API to sidestep lkiesow/matrix-notification@v1 and surface 4xx/5xx bodies
# directly in the CI log for debuggability.
notify-matrix:
needs: [create-release, deploy]
if: always() && needs.deploy.result == 'success'
runs-on: ubuntu-latest
env:
MATRIX_TOKEN: ${{ secrets.MATRIX_TOKEN }}
steps:
- name: Send Matrix notification
if: env.MATRIX_TOKEN != ''
continue-on-error: true
env:
RELEASE_TAG: v${{ needs.create-release.outputs.version }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
ROOM_ENCODED='%21azsMvrsrqEENIcSLOe%3Amatrix.org'
TXN="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${RELEASE_TAG}"
BODY_TEXT="🚀 Release ${RELEASE_TAG} deployed — ${RELEASE_URL}"
BODY_HTML="🚀 <b>Release ${RELEASE_TAG} deployed</b><br><b>Repository:</b> ${REPO}<br><b>Link:</b> <a href=\"${RELEASE_URL}\">${RELEASE_URL}</a>"
payload=$(jq -n \
--arg body "$BODY_TEXT" \
--arg html "$BODY_HTML" \
'{msgtype:"m.text", body:$body, format:"org.matrix.custom.html", formatted_body:$html}')
http_code=$(curl -sS -o /tmp/matrix-response.json -w '%{http_code}' \
-X PUT \
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://matrix.org/_matrix/client/v3/rooms/${ROOM_ENCODED}/send/m.room.message/${TXN}")
if [ "$http_code" != "200" ]; then
echo "::warning::Matrix notification failed (HTTP ${http_code})"
cat /tmp/matrix-response.json || true
echo
exit 0
fi
echo "Matrix notification sent (HTTP ${http_code})"