Files
sure/.github/workflows/publish.yml
Max Barbare 700ad34eb1 feat: Introduce macOS app v0.1.0 (#2762)
* feat(desktop): scaffold Tauri 2 macOS shell with empty window

* feat(desktop): server store, URL normalization, and health-check helpers

* feat(desktop): IPC commands for server list/add/remove/health + active-server state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): native onboarding server picker with health check and remembered servers

* feat(desktop): vibrancy background, overlay titlebar, and inset traffic lights

* feat(desktop): native menu bar with standard shortcuts and menu events

* feat(desktop): webview→Rust bridge with native notifications

* fix(desktop): gate bridge injection on PageLoadEvent::Finished

Prevents double-injecting the bridge IIFE (once on Started, once on
Finished), which was duplicating every native notification.

* feat(desktop): Dock badge driven by webview attention count

* feat(desktop): launch-at-login autostart commands

* feat(desktop): sure:// deep link scheme with parse tests and navigation

* feat(desktop): preferences window with server switcher and launch-at-login

* docs(desktop): README for dev, release, signing/notarization, and deferred widget

* fix(desktop): remove dead New Window menu item

* fix(desktop): correct login route to /sessions/new

Rails uses `resources :sessions` (plural), so the login page is
/sessions/new, not the /session/new the plan assumed. Fixes an
immediate 404 when connecting to a server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): Sure-styled onboarding, draggable titlebar, and app content offset

- Restyle onboarding + prefs to match Sure's auth page: solid surface
  background, centered logomark, .form-field-style inputs, inverse primary
  button; theme-aware via prefers-color-scheme (design-system tokens).
- Add a draggable titlebar strip on bundled pages and inject one into the
  remote page so the window drags from the top everywhere.
- Inject a top offset on the logged-in app-layout root so the sidebar logo
  clears the macOS traffic lights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): de-dupe login navigation to prevent CSRF token/session race

connect() navigated to /sessions/new directly AND via the
active-server-changed event, which is also handled by a second listener
injected into the page by bridge.js. One connect fired multiple concurrent
GET /sessions/new requests, each minting a fresh session + CSRF token; the
form shown and the _sure_session finally stored could come from different
GETs, so the login POST failed 'Can't verify CSRF token authenticity'
intermittently. Route all navigation through a single window-level guard so
only the first request per server wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): enable window drag permission; offset only the icon rail

- Add core:window:allow-start-dragging (+ show/set-focus, event emit/listen) to
  capabilities so data-tauri-drag-region actually drags the window on macOS.
- Offset only the 84px left icon rail (logomark) to clear the traffic lights
  instead of pushing the entire app-layout down; keep main content full-height.
- Drag strip z-index lowered below Sure's sticky headers so its controls stay
  clickable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat(desktop): persist active server and resume session on launch

- Persist the active server to the Keychain in set_active_server; active_server
  falls back to it so a relaunch knows where to go.
- On launch, auto-resume straight to the last server instead of showing the
  picker every time.
- Navigate to the server root (not /sessions/new): Rails serves the dashboard
  when the session cookie is still valid, or redirects to login when not — so a
  persisted session no longer forces a re-login.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* feat: desktop SSO via system browser with PKCE code exchange

Passkeys/WebAuthn don't work in an embedded WKWebView, so SSO now runs in
the system browser and hands a session back to the app securely.

Server (Rails):
- GET /auth/desktop/:provider — stashes a PKCE S256 challenge, hands off to
  OmniAuth (reusing the mobile auto-submit form). Passkeys work (real browser).
- openid_connect — for a linked identity in a desktop flow, mints a single-use,
  2-min, PKCE-bound one-time code and redirects to sure://sso/callback?code=...
  (unlinked identities are sent back with an error).
- GET /sessions/desktop_exchange — verifies the code + PKCE verifier
  (secure_compare), single-use (cache delete), then create_session_for; MFA is
  enforced at exchange time. Sets the normal web session cookie in the webview.
- Tests: happy path + single-use, wrong-verifier rejection, missing challenge.

Desktop (Tauri):
- start_sso command: generates PKCE, opens the browser, stores the verifier.
- sure://sso/callback deep link -> webview navigates to desktop_exchange with
  the verifier (never sent through the deep link, so an intercepted code is
  useless).
- bridge.ts intercepts SSO provider form submits and routes them to start_sso;
  password login stays in the webview.
- remote.json capability: minimal IPC (drag, event bridge, prefs window,
  start_sso) for the remote Sure origin — no fs/shell/http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): correct remote IPC capability + handle menu in Rust + drag fallback

Root cause of prefs/switch-server/SSO/drag doing nothing on the logged-in
page: the remote-bridge capability's remote.urls ('https://*') did not match
the server origin, so all IPC (event listen, invoke, drag command) was denied.
Per Tauri v2, window.__TAURI__ is injected on remote pages only with
withGlobalTauri (set) AND a matching remote.urls; patterns need a path
wildcard.

- remote.json: urls -> https://*/**, http://*/** (+ bare host) so any server
  origin matches.
- menu.rs: Preferences and Switch Server now show the prefs window directly in
  Rust (no dependency on remote-page IPC); Switch Server moved from Window to
  the App menu.
- bridge.ts: drops the menu-event listeners (Rust owns them), adds a
  startDragging mousedown fallback for the drag strip, logs diagnostics, and
  reports start_sso success/failure to the console.
- main.ts: drops the now-unused menu listeners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): SSO via event (remote can't invoke commands), disk-backed server store

Diagnostics confirmed window.__TAURI__ + IPC work on the remote page, but a
remote origin cannot invoke custom commands ('start_sso not allowed. Plugin
not found'). Events are permitted, so SSO now goes through an event.

- SSO: bridge emits 'sure://start-sso'; Rust listens and runs begin_sso
  (opens the system browser). start_sso command kept for local use.
- servers: mirror the server list + active server to a JSON file in
  Application Support as a fallback — Keychain items don't persist for
  unsigned builds, which was wiping the saved server on relaunch.
- remote.json: add notification:default (Sure's PWA was requesting it and
  erroring).
- menu: log whether the prefs window is present when Preferences/Switch
  Server fire, to diagnose the no-op.
- main: log the persisted active server on boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): drag the top band on every page via mousedown, not a z-indexed strip

The fixed drag strip sat below Sure's sticky headers (z-10) so it worked only
on pages without a top header. Replace it with a document-level mousedown in the
top ~34px that starts a window drag unless the target is an interactive element
— so dragging works on all pages, Sure's titlebar controls stay clickable, and
main content isn't pushed down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-triggered GitHub Actions release for universal unsigned .dmg

- .github/workflows/desktop-release.yml: on a 'desktop-v*' tag, build the
  universal (Apple Silicon + Intel) .dmg on a macOS runner via tauri-action and
  publish it to a GitHub Release with unsigned-install instructions.
- README: universal build command, the tag-based release process, and the
  Gatekeeper 'Open Anyway' / xattr steps for end users.
- Drop the unused iOS/Android icon sets (macOS build only needs icon.icns).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): rolling desktop-latest build on desktop/ changes, not manual tags

Replaces the manual desktop-v* tag release with a path-filtered workflow that
builds only when desktop/ changes on main and publishes to a single rolling
'desktop-latest' prerelease with a stable Sure.dmg filename — one permanent
download URL, and the file changes only when the desktop code does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): tag-driven versioned releases; tag is the single source of version

Revert to manual version tags (desktop-v*) for explicit version control, but
derive the app/.dmg version from the tag so package.json + tauri.conf.json are
synced automatically in CI — no manual version-file edits. Each tag produces its
own versioned GitHub Release with the universal unsigned .dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): release entirely from GitHub via workflow_dispatch version input

Make the GitHub Action the single tool to version + deploy the desktop app:
Run workflow -> enter a version -> it syncs the version, builds the universal
unsigned .dmg, and creates the desktop-v<version> tag + Release. Refuses to
re-release an existing version; marks pre-release versions accordingly. Tag
push (desktop-v*) still works as a secondary trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): publish releases with make_latest:false so they don't hijack the repo's Latest badge

Desktop is a secondary artifact, not the main product. Build with tauri-action,
then publish via action-gh-release with make_latest:false so the repo's 'Latest
release' badge stays on the main app's v* release. Separate desktop-v* tag
namespace already keeps it out of the v* publish workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): address PR review feedback (security + correctness)

Security:
- workflow: pass workflow_dispatch version via env (no shell injection); pin all
  third-party actions to commit SHAs.
- SSO: gate deep-link navigation and begin_sso to servers the user has saved
  (is_known_server), so a rogue page/deep link can't drive them.
- desktop_exchange is now POST (verifier in body, not URL/logs); CSRF skipped
  since the single-use PKCE code is the protection.
- desktop_sso_start validates the code_challenge is a 43-char base64url digest.
- desktop_exchange claims the one-time code atomically (delete-and-check) to
  close the read/delete TOCTOU.
- failure: return desktop SSO errors to the app via sure://sso/callback?error.

Correctness / stability:
- prefs window hides on close instead of being destroyed, so the menu can
  reopen it.
- servers.rs: on-disk store is authoritative (file-first read), atomic writes
  (temp + rename).
- main.ts/prefs.ts: try/catch around add/set/remove/active_server and boot; add
  a shared serverErrorMessage helper (no duplicated substring checks).
- vite.config.ts: derive dir from import.meta.url (ESM has no __dirname).
- bridge.ts: coalesce MutationObserver scans to one per frame.
- README: notarization example uses the universal .dmg name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): scope remote IPC to server origins at runtime, drop wildcard capability

Resolves the remaining security finding: the static remote.json granted Tauri
IPC to any http(s) origin (https://*). Remove it and instead add a capability
scoped to each server's exact origin at runtime (CapabilityBuilder +
add_capability), granting only the minimal permissions the bridge needs, for
saved/active servers on startup and for the target in set_active_server. No
origin outside the user's configured servers can access IPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): add runtime per-origin IPC capability (grant_server_capability)

Implements the runtime-scoped capability that replaces the removed wildcard
remote.json: CapabilityBuilder scoped to each server's exact origin, added via
add_capability for saved/active servers at startup and in set_active_server.
(Split from the previous commit, which only recorded the remote.json removal.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* ci(desktop): harden release workflow (no shared caches, environment gate)

Address two release-workflow security findings:
- Remove cache: npm and the swatinem/rust-cache step so a poisoned Actions
  cache written by another workflow can't flow into a published .dmg (P0).
- Add 'environment: release' to the build job so publishing can require manual
  approval and scope secrets to release runs (P1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nno38ujctqiqSoY8eFRhaf

* fix(desktop): remove transparency code and fix relaunch behavior

* fix(desktop): fix PR review findings; adjust app notarization path, use Sure theme tokens instead of hardcoding values

* ci(desktop): switch release from independant versioning to using Sure's publishing workflow, releasing and versioning with every main app release

* fix(desktop): restrict CSP as much as possible while maintaining functionality; allow bundled scripts, Tauri IPC, inline styles; deny wildcards

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:44:06 +02:00

613 lines
23 KiB
YAML

# Reference: https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
# Conditions for pushing the image to GHCR:
# - Triggered by push to default branch (`main`)
# - Triggered by push to a version tag (`v*`)
# - Triggered by a scheduled run
# - Triggered manually via `workflow_dispatch` with `push: true`
#
# Conditional expression:
# github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push
name: Publish Docker image
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref (tag or commit SHA) to build'
required: true
type: string
default: 'main'
push:
description: 'Push the image to container registry'
required: false
type: boolean
default: false
push:
tags:
- 'v*'
branches:
- main
paths-ignore:
- 'charts/**'
- 'mobile/**'
schedule:
- cron: '30 1 * * *'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: write
packages: write
jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
name: Build Docker image
needs: [ ci ]
strategy:
fail-fast: false
matrix:
platform: [amd64, arm64]
include:
- platform: amd64
runs-on: ubuntu-24.04
- platform: arm64
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
runs-on: ${{ matrix.runs-on }}
outputs:
tags: ${{ steps.meta.outputs.tags }}
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to the container registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Configure image tags
id: tag_config
shell: bash
run: |
BASE_CONFIG="type=sha,format=long"
if [[ $GITHUB_EVENT_NAME == "schedule" ]]; then
BASE_CONFIG+=$'\n'"type=schedule,pattern=nightly"
BASE_CONFIG+=$'\n'"type=schedule,pattern=nightly-{{date 'ddd'}}"
elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
if [[ "$TAG_NAME" == v* ]]; then
BASE_CONFIG="type=semver,pattern={{version}}"
if [[ "$TAG_NAME" == v*-alpha* ]]; then
BASE_CONFIG+=$'\n'"type=raw,value=latest"
else
BASE_CONFIG+=$'\n'"type=raw,value=stable"
fi
fi
fi
{
echo 'TAGS_SPEC<<EOF'
echo "$BASE_CONFIG"
echo EOF
} >> $GITHUB_ENV
- name: Get current date (RFC 3339 format)
id: date
run: echo "date=$(date -Iseconds)" >> $GITHUB_OUTPUT
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
tags: ${{ env.TAGS_SPEC }}
labels: |
org.opencontainers.image.version=${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
org.opencontainers.image.created=${{ steps.date.outputs.date }}
org.opencontainers.image.ref.name=${{ github.ref_name }}
org.opencontainers.image.vendor=we-promise
org.opencontainers.image.title=Sure
org.opencontainers.image.description=A multi-arch Docker image for the Sure Rails app
- name: Publish 'linux/${{ matrix.platform }}' image by digest
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
id: build
with:
context: .
build-args: BUILD_COMMIT_SHA=${{ github.sha }}
platforms: 'linux/${{ matrix.platform }}'
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.platform }}
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache-${{ matrix.platform }},mode=max
labels: ${{ steps.meta.outputs.labels }}
provenance: false
push: true
# DO NOT REMOVE `oci-mediatypes=true`, fixes annotation not showing up on job.merge.steps[-1]
# ref: https://github.com/docker/build-push-action/discussions/1022
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},name-canonical=true,push-by-digest=true,oci-mediatypes=true
- name: Export the Docker image digest
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }}
run: |
mkdir -p "${RUNNER_TEMP}"/digests
echo "${DIGEST#sha256:}" > "${RUNNER_TEMP}/digests/digest-${PLATFORM}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
PLATFORM: ${{ matrix.platform }}
- name: Upload the Docker image digest
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: digest-${{ matrix.platform }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge multi-arch manifest & push multi-arch tag
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || github.event.inputs.push }}
needs: [build]
timeout-minutes: 60
runs-on: 'ubuntu-24.04'
permissions:
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Download Docker image digests
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Log in to the container registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Merge and push Docker image
env:
TAGS: ${{ needs.build.outputs.tags }}
DIGESTS_DIR: ${{ runner.temp }}/digests
REF_NAME: ${{ github.ref_name }}
shell: bash -xeuo pipefail {0}
run: |
tag_args=()
while IFS=$'\n' read -r tag; do
[[ -n "${tag}" ]] || continue
tag_args+=("--tag=${tag}")
done <<< "${TAGS}"
image_args=()
for PLATFORM in amd64 arm64; do
image_args+=("${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:$(<"${DIGESTS_DIR}/digest-${PLATFORM}")")
done
annotations=(
"index:org.opencontainers.image.created=$(date -Iseconds)"
'index:org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}'
'index:org.opencontainers.image.revision=${{ github.sha }}'
"index:org.opencontainers.image.ref.name=${REF_NAME}"
'index:org.opencontainers.image.vendor=we-promise'
'index:org.opencontainers.image.licenses=AGPL-3.0'
'index:org.opencontainers.image.title=Sure'
'index:org.opencontainers.image.description=A multi-arch Docker image for the Sure Rails app'
)
annotation_args=()
for annotation in "${annotations[@]}"; do
annotation_args+=("--annotation=${annotation}")
done
if [[ $GITHUB_REF_TYPE == "tag" ]]; then
annotation_args+=("--annotation=index:org.opencontainers.image.version=$GITHUB_REF_NAME")
fi
attempts=0
until docker buildx imagetools create \
"${annotation_args[@]}" \
"${tag_args[@]}" \
"${image_args[@]}" \
; do
attempts=$((attempts + 1))
if [[ $attempts -ge 3 ]]; then
echo "[$(date -u)] ERROR: Failed after 3 attempts." >&2
exit 1
fi
delay=$((2 ** attempts))
if [[ $delay -gt 15 ]]; then delay=15; fi
echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..."
sleep ${delay}
done
helm:
name: Package Helm chart
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/helm-publish.yml
with:
chart_version: ${{ github.ref_name }}
app_version: ${{ github.ref_name }}
update_gh_pages: true
secrets: inherit
mobile:
name: Build Mobile Apps
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/flutter-build.yml
secrets: inherit
desktop:
name: Build macOS Desktop App
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/desktop-release.yml
secrets: inherit
release:
name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [merge, mobile, desktop, helm]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download Android APK artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: app-release-apk
path: ${{ runner.temp }}/mobile-artifacts
- name: Download iOS build artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: ios-build-unsigned
path: ${{ runner.temp }}/ios-build
- name: Download Helm chart artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: helm-chart-package
path: ${{ runner.temp }}/helm-artifacts
- name: Download desktop DMG artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: desktop-release-dmg
path: ${{ runner.temp }}/desktop-artifacts
- name: Prepare release assets
env:
REF_NAME: ${{ github.ref_name }}
run: |
mkdir -p ${{ runner.temp }}/release-assets
echo "=== Debugging: List downloaded artifacts ==="
echo "Mobile artifacts:"
ls -laR "${{ runner.temp }}/mobile-artifacts" || echo "No mobile-artifacts directory"
echo "iOS build:"
ls -laR "${{ runner.temp }}/ios-build" || echo "No ios-build directory"
echo "==========================================="
# Copy debug APK if it exists
if [ -f "${{ runner.temp }}/mobile-artifacts/app-debug.apk" ]; then
cp "${{ runner.temp }}/mobile-artifacts/app-debug.apk" "${{ runner.temp }}/release-assets/sure-${REF_NAME}-debug.apk"
echo "✓ Debug APK prepared"
fi
# Copy release APK if it exists
if [ -f "${{ runner.temp }}/mobile-artifacts/app-release.apk" ]; then
cp "${{ runner.temp }}/mobile-artifacts/app-release.apk" "${{ runner.temp }}/release-assets/sure-${REF_NAME}.apk"
echo "✓ Release APK prepared"
fi
# Create iOS app archive (zip the .app bundle)
# Path preserves directory structure from artifact upload
if [ -d "${{ runner.temp }}/ios-build/ios/iphoneos/Runner.app" ]; then
cd "${{ runner.temp }}/ios-build/ios/iphoneos"
zip -r "${{ runner.temp }}/release-assets/sure-${REF_NAME}-ios-unsigned.zip" Runner.app
echo "✓ iOS build archive prepared"
fi
# Copy iOS build info
if [ -f "${{ runner.temp }}/ios-build/ios-build-info.txt" ]; then
cp "${{ runner.temp }}/ios-build/ios-build-info.txt" "${{ runner.temp }}/release-assets/"
fi
# Copy Helm chart package(s)
if compgen -G "${{ runner.temp }}/helm-artifacts/*.tgz" > /dev/null; then
cp ${{ runner.temp }}/helm-artifacts/*.tgz "${{ runner.temp }}/release-assets/"
echo "✓ Helm chart package prepared"
fi
# Copy the universal macOS desktop build.
if compgen -G "${{ runner.temp }}/desktop-artifacts/*.dmg" > /dev/null; then
cp ${{ runner.temp }}/desktop-artifacts/*.dmg "${{ runner.temp }}/release-assets/"
echo "✓ Desktop DMG prepared"
fi
echo "Release assets:"
ls -la "${{ runner.temp }}/release-assets/"
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
generate_release_notes: true
files: |
${{ runner.temp }}/release-assets/*
body: |
## Mobile Debug Builds
This release includes debug builds of the mobile applications. Download from the `Assets` area below.
- **Android APK**: Debug build for testing on Android devices
- **iOS Build**: Unsigned iOS build (requires code signing for installation)
> **Note**: These are debug builds intended for testing purposes. For production use, please build from source with proper signing credentials.
create_release_branch:
name: Create or update release branch
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') && !contains(github.ref_name, 'rc')
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Create/update minor release branch
env:
TAG_NAME: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH_NAME="$(echo "$TAG_NAME" | sed -E 's/^v([0-9]+)\.([0-9]+)\..*/v\1.\2-release-branch/')"
SHA="${GITHUB_SHA}"
echo "Updating ${BRANCH_NAME} -> ${SHA}"
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH_NAME}" \
--method PATCH \
--field sha="${SHA}" \
--field force=true 2>/dev/null; then
gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
--method POST \
--field ref="refs/heads/${BRANCH_NAME}" \
--field sha="${SHA}"
fi
bump-pre_release-version:
name: Bump Pre-release Version
if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc'))
needs: [merge]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
contents: write
pull-requests: write
steps:
- name: Determine source branch for tag
id: source_branch
run: |
# Fetch all branches to find which one contains this tag's commit
git init --quiet
git remote add origin "https://github.com/${{ github.repository }}.git"
git fetch origin --quiet
# Find branches containing the tagged commit
BRANCHES=$(git branch -r --contains ${{ github.sha }} | grep -v HEAD | sed 's/origin\///' | xargs)
echo "Branches containing commit: $BRANCHES"
# Prefer non-main branches (release branches) over main
SOURCE_BRANCH="main"
for branch in $BRANCHES; do
if [ "$branch" != "main" ] && [ "$branch" != "master" ]; then
SOURCE_BRANCH="$branch"
break
fi
done
echo "Selected source branch: $SOURCE_BRANCH"
echo "branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT
- name: Check out source branch
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ steps.source_branch.outputs.branch }}
token: ${{ github.token }}
- name: Bump pre-release version
run: |
set -euo pipefail
VERSION_FILE=".sure-version"
CHART_FILE="charts/sure/Chart.yaml"
# Ensure version file exists
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: Version file not found: $VERSION_FILE"
exit 1
fi
# Ensure chart file exists
if [ ! -f "$CHART_FILE" ]; then
echo "ERROR: Chart file not found: $CHART_FILE"
exit 1
fi
# Extract current version
CURRENT_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "ERROR: Could not extract version from $VERSION_FILE"
exit 1
fi
echo "Current version: $CURRENT_VERSION"
if [[ ! "$CURRENT_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(alpha|beta|rc)\.([0-9]+)$ ]]; then
echo "ERROR: Expected prerelease version like 1.2.3-alpha.4, got $CURRENT_VERSION"
exit 1
fi
BASE_VERSION="${BASH_REMATCH[1]}"
PRE_RELEASE_TAG="${BASH_REMATCH[2]}"
PRE_RELEASE_NUM="${BASH_REMATCH[3]}"
NEW_PRE_RELEASE_NUM=$((PRE_RELEASE_NUM + 1))
NEW_VERSION="${BASE_VERSION}-${PRE_RELEASE_TAG}.${NEW_PRE_RELEASE_NUM}"
echo "New version: $NEW_VERSION"
# Update the version file
echo "$NEW_VERSION" > "$VERSION_FILE"
# Verify the change
echo "Updated .sure-version:"
cat "$VERSION_FILE"
# Update Helm chart version and appVersion
sed -i -E "s/^version: .*/version: ${NEW_VERSION}/" "$CHART_FILE"
sed -i -E "s/^appVersion: .*/appVersion: \"${NEW_VERSION}\"/" "$CHART_FILE"
# Verify the change
echo "Updated Chart.yaml:"
grep -E "^(version|appVersion):" "$CHART_FILE"
- name: Commit and push version bump
env:
SOURCE_BRANCH: ${{ steps.source_branch.outputs.branch }}
GH_TOKEN: ${{ github.token }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .sure-version
git add charts/sure/Chart.yaml
# Check if there are changes to commit
if git diff --cached --quiet; then
echo "No changes to commit - version may have already been bumped"
exit 0
fi
COMMIT_MESSAGE="Bump version to next iteration after ${REF_NAME} release"
git commit -m "$COMMIT_MESSAGE"
echo "Pushing to branch: $SOURCE_BRANCH"
# Push directly when allowed. Main is protected and must be updated
# through a pull request. Other branches get a direct-push attempt, but
# protected-branch rejections fall back to a pull request immediately.
push_succeeded=false
if [[ "$SOURCE_BRANCH" == "main" || "$SOURCE_BRANCH" == "master" ]]; then
echo "$SOURCE_BRANCH is protected; creating a pull request instead of pushing directly."
else
direct_push_output=""
for attempt in 1 2 3 4; do
set +e
direct_push_output=$(git push origin HEAD:"refs/heads/${SOURCE_BRANCH}" 2>&1)
push_status=$?
set -e
echo "$direct_push_output"
if [[ $push_status -eq 0 ]]; then
push_succeeded=true
break
fi
if grep -Eq 'GH006|Protected branch update failed|Changes must be made through a pull request' <<< "$direct_push_output"; then
echo "Direct push is blocked by branch protection; creating a pull request instead."
break
fi
if [[ $attempt -eq 4 ]]; then
echo "Direct push failed after 4 attempts; creating a pull request instead."
break
fi
delay=$((2 ** attempt))
echo "Push failed (attempt $attempt). Retrying in ${delay} seconds..."
sleep "$delay"
git fetch origin "${SOURCE_BRANCH}"
git rebase "origin/${SOURCE_BRANCH}"
done
fi
if [[ "$push_succeeded" == "true" ]]; then
exit 0
fi
SAFE_TAG=$(printf '%s' "${REF_NAME}" | tr -c '[:alnum:]._-' '-')
BUMP_BRANCH="automation/bump-version-after-${SAFE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
git push --force-with-lease origin HEAD:"refs/heads/${BUMP_BRANCH}"
PR_BODY="This automated PR bumps .sure-version and charts/sure/Chart.yaml after the ${REF_NAME} pre-release. It was opened because the workflow could not push directly to ${SOURCE_BRANCH}."
set +e
pr_create_output=$(gh pr create \
--repo "$GITHUB_REPOSITORY" \
--head "$BUMP_BRANCH" \
--base "$SOURCE_BRANCH" \
--title "$COMMIT_MESSAGE" \
--body "$PR_BODY" 2>&1)
pr_create_status=$?
set -e
if [[ $pr_create_status -ne 0 ]]; then
echo "::error::Pushed ${BUMP_BRANCH}, but could not create the version bump PR: ${pr_create_output}"
if grep -q "GitHub Actions is not permitted to create or approve pull requests" <<< "$pr_create_output"; then
echo "::notice::Enable the organization setting that allows GitHub Actions to create and approve pull requests."
fi
exit 1
fi
PR_URL="$pr_create_output"
echo "Created version bump PR: $PR_URL"
echo "Dispatching PR checks for $BUMP_BRANCH"
if ! gh workflow run pr.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH"; then
echo "::warning::Could not dispatch pr.yml for $BUMP_BRANCH"
fi
if ! gh workflow run chart-ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BUMP_BRANCH"; then
echo "::warning::Could not dispatch chart-ci.yml for $BUMP_BRANCH"
fi