chore(release): 0.9.22 #37
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: production | |
| 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}' |