Skip to content

chore(release): 0.9.24 #39

chore(release): 0.9.24

chore(release): 0.9.24 #39

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 }}
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
# Convert to draft so tauri-action can upload assets to it
# (tauri-action expects releaseDraft: true).
gh api --method PATCH "repos/${GH_REPO}/releases/${published_id}" \
-f draft=true
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"
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"
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 }}
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'
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: |
# Use release_id via gh api — `gh release edit TAG` uses
# /releases/tags/{tag} which does NOT find draft releases.
gh api --method PATCH "repos/${GH_REPO}/releases/${RELEASE_ID}" \
-f draft=false \
-F make_latest=true
# ── 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: needs.publish.result == 'success'
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}'