name: Release on: workflow_dispatch: inputs: version: description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number." required: false type: string permissions: contents: write concurrency: group: release cancel-in-progress: false jobs: prepare: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} release_id: ${{ steps.release.outputs.release_id }} steps: - uses: actions/checkout@v7 with: ref: main - name: Determine version id: version env: REQUESTED_VERSION: ${{ inputs.version }} run: | if [ -n "$REQUESTED_VERSION" ]; then VERSION="$REQUESTED_VERSION" VERSION="${VERSION#v}" else CURRENT=$(jq -r .version src-tauri/tauri.conf.json) IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT" VERSION="$MAJOR.$MINOR.$((PATCH + 1))" fi if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::Expected a release version such as 0.2.0." exit 1 fi EXISTING=$(git ls-remote --tags origin "refs/tags/v$VERSION") if [ -n "$EXISTING" ]; then echo "::error::v$VERSION already exists; choose a new version." exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "Releasing v$VERSION" - name: Bump version in manifests env: VERSION: ${{ steps.version.outputs.version }} run: | tmp=$(mktemp) jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml # Cargo.lock records margin-mail's own version, so bumping only Cargo.toml leaves the lock # a release behind and the next build rewrites it under whoever checked it out. awk -v v="$VERSION" ' /^name = "margin-mail"$/ { print; getline; sub(/^version = ".*"/, "version = \"" v "\""); print; next } { print } ' src-tauri/Cargo.lock > "$tmp" && mv "$tmp" src-tauri/Cargo.lock - name: Commit and tag env: TAG: ${{ steps.version.outputs.tag }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml src-tauri/Cargo.lock git commit -m "chore(release): $TAG" for attempt in 1 2 3 4 5; do git fetch origin main git rebase origin/main if git push origin HEAD; then break fi if [ "$attempt" = "5" ]; then echo "::error::main kept advancing; could not push release bump after 5 attempts." exit 1 fi echo "main advanced during release; rebasing and retrying ($attempt)…" sleep 3 done git tag "$TAG" if ! git push origin "$TAG"; then # A push can land remotely even when Git reports a ref-lock failure. # Accept only this exact commit; never move an existing release tag. REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" | cut -f1) [ "$REMOTE" = "$(git rev-parse HEAD)" ] || exit 1 fi - name: Create draft release id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.version.outputs.tag }} run: | gh release create "$TAG" --draft --title "Margin Mail $TAG" --notes "Release $TAG" ID=$(gh release view "$TAG" --json databaseId --jq .databaseId) echo "release_id=$ID" >> "$GITHUB_OUTPUT" build: needs: prepare strategy: fail-fast: false matrix: include: - os: macos-26 args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json" rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin" # Ubuntu 22.04 is the glibc baseline: the bundle will not run on anything older than the # glibc it was linked against, so build on the oldest supported. - os: ubuntu-22.04 args: "--config src-tauri/tauri.release.conf.json" rust-targets: "" - os: windows-latest args: "--config src-tauri/tauri.release.conf.json" rust-targets: "" runs-on: ${{ matrix.os }} defaults: run: working-directory: rust/margin-mail steps: - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.tag }} path: rust/margin-mail - uses: actions/checkout@v7 with: repository: priyanshujain/margin path: python/margin - name: Install Linux dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ patchelf \ libxdo-dev \ libssl-dev \ build-essential \ curl \ wget \ file - uses: actions/setup-node@v6 with: node-version: 26 - uses: pnpm/action-setup@v6 with: version: 10 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust-targets }} - uses: swatinem/rust-cache@v2 with: workspaces: rust/margin-mail/src-tauri -> target key: ${{ matrix.os }} - name: Install frontend dependencies run: pnpm install --frozen-lockfile # The OAuth client is the whole suite's, so this secret is the same value in every Margin # repository. A build without it still runs; it just cannot connect to Google. - name: Provision Google credentials shell: bash env: GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }} run: | if [ -n "$GOOGLE_CREDENTIALS" ]; then printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret." else cp google-credentials.example.json google-credentials.json echo "::warning::GOOGLE_CREDENTIALS secret not set, embedding placeholder credentials; this build cannot connect to Gmail." fi # macOS shows no notifications from a bundle that is not signed, so tauri.conf.json ad-hoc # signs at minimum; a Developer ID from the secrets replaces that, and the App Store Connect # key notarizes on top. Only what is present is exported, because Tauri takes an empty # APPLE_SIGNING_IDENTITY for an identity and fails the signing step on it. - name: Provision Apple signing if: runner.os == 'macOS' shell: bash env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} run: | if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_SIGNING_IDENTITY" ]; then echo "::warning::APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY not set; the macOS bundle will be ad-hoc signed and not notarized." exit 0 fi for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_TEAM_ID; do { echo "$name<> "$GITHUB_ENV" done echo "Signing as $APPLE_SIGNING_IDENTITY" if [ -n "$APPLE_API_KEY_P8" ] && [ -n "$APPLE_API_KEY" ] && [ -n "$APPLE_API_ISSUER" ]; then printf '%s' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8" { echo "APPLE_API_KEY=$APPLE_API_KEY" echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/AuthKey.p8" } >> "$GITHUB_ENV" echo "Notarizing with App Store Connect key $APPLE_API_KEY" else echo "::warning::APPLE_API_KEY, APPLE_API_ISSUER or APPLE_API_KEY_P8 not set; the macOS bundle will be signed and not notarized." fi - name: Build and upload uses: tauri-apps/tauri-action@v0 with: projectPath: rust/margin-mail releaseId: ${{ needs.prepare.outputs.release_id }} args: ${{ matrix.args }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: Verify the bundle is signed and notarized if: runner.os == 'macOS' && env.APPLE_API_KEY_PATH != '' run: | app="src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin Mail.app" codesign --verify --deep --strict --verbose=2 "$app" # Gatekeeper only says "accepted" once the notarization ticket is stapled to the bundle, # so this is the check that somebody double-clicking the dmg will actually get past. spctl --assess --type execute --verbose=4 "$app" xcrun stapler validate "$app" # The flatpak is not a Tauri bundle target, so it is built here from the deb the Linux job just # published and uploaded to the same draft release. Before publish, so a release never goes out # with the Linux artifacts half there. flatpak: needs: [prepare, build] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.tag }} - run: | sudo apt-get update sudo apt-get install -y flatpak - name: Build the flatpak from the published deb env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} TAG: ${{ needs.prepare.outputs.tag }} VERSION: ${{ needs.prepare.outputs.version }} run: | gh release download "$TAG" --repo "$REPO" \ --pattern "Margin.Mail_${VERSION}_amd64.deb" --output flatpak/margin-mail.deb flatpak/build.sh "$PWD/Margin.Mail_${VERSION}_amd64.flatpak" gh release upload "$TAG" --repo "$REPO" "Margin.Mail_${VERSION}_amd64.flatpak" --clobber publish: needs: [prepare, build, flatpak] runs-on: ubuntu-latest steps: - name: Verify manifest is complete, then publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} TAG: ${{ needs.prepare.outputs.tag }} run: | gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber echo "Platforms in latest.json:" jq '.platforms | keys' latest.json for key in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then echo "::error::latest.json is missing platform '$key', refusing to publish a partial update manifest. Re-run the release." exit 1 fi done gh release edit "$TAG" --repo "$REPO" --draft=false --latest # Nix is the other Linux package. The AppImage carries Ubuntu's GTK stack, which cannot talk to a # modern Wayland compositor and silently falls back to Xwayland; the Nix package relinks the # published deb against nixpkgs' webkit2gtk and runs as a native Wayland client. Runs after # publish so the flake can only ever point at a release that survived the manifest check. nix: needs: [prepare, publish] runs-on: ubuntu-latest steps: # main rather than the tag: the pin lands on main, and the tag was cut before the artifact # it needs the hash of existed. - uses: actions/checkout@v7 with: ref: main - name: Pin the flake to this release env: VERSION: ${{ needs.prepare.outputs.version }} REPO: ${{ github.repository }} run: | URL="https://github.com/$REPO/releases/download/v$VERSION/Margin.Mail_${VERSION}_amd64.deb" # From the published asset, so the hash is of the artifact users will actually fetch. curl -fsSL --retry 3 -o package.deb "$URL" HASH="sha256-$(openssl dgst -sha256 -binary package.deb | base64)" jq -n --arg v "$VERSION" --arg h "$HASH" '{version: $v, hash: $h}' > nix/release.json cat nix/release.json - uses: cachix/install-nix-action@v31 # Building it is the check: a wrong hash, a library autoPatchelf cannot find or a broken flake # stops here rather than on someone's machine. --impure and the variable because FSL is not a # free licence, so nixpkgs refuses to build the package without being told. - name: Build the package run: NIXPKGS_ALLOW_UNFREE=1 nix build --impure .#margin-mail --print-build-logs - name: Commit the pin env: VERSION: ${{ needs.prepare.outputs.version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add nix/release.json # A rerun after the pin already landed has nothing to commit, and the release is done. if git diff --cached --quiet; then echo "The flake already points at v$VERSION, nothing to push." exit 0 fi git commit -m "point the nix package at v$VERSION" for attempt in 1 2 3 4 5; do git fetch origin main git rebase origin/main if git push origin HEAD:main; then break fi if [ "$attempt" = "5" ]; then echo "::error::main kept advancing; could not push the nix pin after 5 attempts. The release is published; rerun this job." exit 1 fi echo "main advanced; rebasing and retrying ($attempt)…" sleep 3 done