Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 85 additions & 35 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ jobs:
# The signing itself authenticates from the AZURE_* variables; this
# `az` lets the login through. And Tauri shows only the stderr of a sign command that
# fails, while the signing library says why on stdout: sign.cmd sends it to stderr.
# Signing needs Microsoft's timestamp server, which doesn't always answer: sign.cmd tries
# four times, 20 seconds apart, rather than fail the build.
- name: Windows signing setup
if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != ''
shell: pwsh
Expand All @@ -252,8 +254,16 @@ jobs:
$az = "$env:RUNNER_TEMP\az-login.cmd"
Set-Content -Path $az -Encoding ascii -Value '@call "C:\Program Files\Microsoft SDKs\Azure\CLI2\wbin\az.cmd" %* --allow-no-subscriptions'
"AZURE_CLI_PATH=$az" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii `
-Value "@artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2"
$cli = "artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2"
Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii -Value @(
'@echo off',
'for /L %%i in (1,1,4) do (',
" $cli && exit /b 0",
' ping -n 21 127.0.0.1 >nul',
')',
'exit /b 1'
)
Get-Content "$env:RUNNER_TEMP\sign.cmd"

- uses: actions/setup-dotnet@v6
if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != ''
Expand Down Expand Up @@ -369,52 +379,92 @@ jobs:
}
if ($files.Count -lt 3 -or $bad) { Write-Error "$bad of $($files.Count) files aren't signed and timestamped"; exit 1 }

# Tauri notarizes the app; the DMG it's downloaded in is notarized here too (Apple's advice:
# the outermost container), stapled, and put in the release in place of the one uploaded.
# Updates use the .app.tar.gz, which this leaves alone.
- name: Notarize the DMG
if: runner.os == 'macOS' && env.APPLE_API_KEY != ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.plan.outputs.release_id }}
run: |
dmg=$(ls target/universal-apple-darwin/release/bundle/dmg/*.dmg | head -1)
status=$(xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" --wait --output-format json | jq -r .status)
[ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; }
xcrun stapler staple "$dmg"
asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \
--jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1)
[ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; }
gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}"
gh api -X POST -H "Content-Type: application/x-apple-diskimage" \
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \
--input "$dmg" --jq .browser_download_url

# The proof, in the log: signed with the Developer ID (hardened runtime, secure timestamp),
# notarized with the ticket stapled, and accepted by Gatekeeper as a downloaded app would be.
# The proof, in the log: the app signed with the Developer ID (hardened runtime, secure
# timestamp), notarized by Tauri with the ticket stapled, and accepted by Gatekeeper as a
# downloaded app would be. The DMG is the macOS notarize job's.
- name: Check the macOS signature
if: runner.os == 'macOS' && env.APPLE_SIGNING_IDENTITY != ''
shell: bash
run: |
bundle=target/universal-apple-darwin/release/bundle
app=$(ls -d "$bundle"/macos/*.app | head -1)
app=$(ls -d target/universal-apple-darwin/release/bundle/macos/*.app | head -1)
codesign --verify --deep --strict --verbose=2 "$app"
codesign -dv --verbose=4 "$app" 2>&1 | grep -E "^(Identifier|Authority|TeamIdentifier|Timestamp|CodeDirectory)"
plutil -p "$app/Contents/Info.plist" | grep -E "CFBundleIdentifier|NSLocalNetworkUsageDescription|LSMinimumSystemVersion"
if [ -n "${APPLE_API_KEY:-}" ]; then
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=4 "$app"
fi
for dmg in "$bundle"/dmg/*.dmg; do
codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority"
if [ -n "${APPLE_API_KEY:-}" ]; then
xcrun stapler validate "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"
fi

# The DMG goes on to the macOS notarize job, as the iOS app goes on to TestFlight.
- uses: actions/upload-artifact@v7
if: runner.os == 'macOS' && env.APPLE_API_KEY != ''
with:
name: macos-dmg
path: target/universal-apple-darwin/release/bundle/dmg/*.dmg
retention-days: 7
if-no-files-found: error

# The DMG the app is downloaded in, notarized too (Apple's advice: the outermost container),
# stapled, checked and put in the release in place of the one the build uploaded. Updates use
# the .app.tar.gz, which this leaves alone. Apart from the build because this is the part that
# talks to Apple's services, which drop a connection now and then: re-running it takes a
# minute or two, not a new build.
macos-notarize:
name: macOS notarize
needs: [plan, desktop]
if: >-
needs.plan.outputs.build == 'true' && !cancelled() && vars.APPLE_API_KEY != ''
&& contains(needs.plan.outputs.matrix, 'macos')
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/download-artifact@v8
with:
name: macos-dmg
path: ${{ runner.temp }}/dmg

- name: Notarize and staple
env:
API_KEY: ${{ vars.APPLE_API_KEY }}
API_ISSUER: ${{ vars.APPLE_API_ISSUER }}
API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
key="$RUNNER_TEMP/AuthKey_$API_KEY.p8"
printf '%s\n' "$API_KEY_P8" > "$key"
status=$(xcrun notarytool submit "$dmg" --key "$key" --key-id "$API_KEY" \
--issuer "$API_ISSUER" --wait --output-format json | jq -r .status)
[ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; }
# Stapling fetches the ticket from Apple's servers: tried a few times.
for i in 1 2 3 4 5; do
xcrun stapler staple "$dmg" && break
[ "$i" = 5 ] && { echo "::error::stapling the DMG failed 5 times"; exit 1; }
sleep $((i * 20))
done

# The proof, in the log: signed, notarized with the ticket stapled, and accepted by
# Gatekeeper as a downloaded disk image would be.
- name: Check the DMG
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority"
xcrun stapler validate "$dmg"
spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg"

- name: Put it in the release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.plan.outputs.release_id }}
run: |
dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1)
asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \
--jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1)
[ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; }
gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}"
gh api -X POST -H "Content-Type: application/x-apple-diskimage" \
"https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \
--input "$dmg" --jq .browser_download_url

android:
name: Android
needs: plan
Expand Down
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/mg-vault", "crates/mg-ssh", "crates/mg-store", "crates/mg-sync", "crates/mg-vpn", "crates/mg-presence", "crates/mg-inventory", "server", "apps/desktop/src-tauri", "tools/vault-conform"]

[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
rust-version = "1.91"

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ encrypted sync you can self-host.
- **Sync**: `vault-sync/v1`, open OpenAPI contract — [`spec/openapi.yaml`](spec/openapi.yaml); client `crates/mg-sync`,
self-hostable reference server [`server/`](server)

Docs: [developer docs](docs/README.md) · [sync & crypto spec](docs/sync-crypto-spec.md) · [roadmap](ROADMAP.md) · [releasing](RELEASING.md) ·
Docs: [developer docs](docs/README.md) · [sync & crypto spec](docs/sync-crypto-spec.md) · [self-host](https://morphgate.com/docs/self-hosting) · [roadmap](ROADMAP.md) · [releasing](RELEASING.md) ·
[changelog](CHANGELOG.md)

## Install
Expand Down
25 changes: 16 additions & 9 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,13 @@ Updates are signed regardless (see above).
- variables `APPLE_SIGNING_IDENTITY` (`Developer ID Application: Vallic d.o.o. (988D7A4F4L)`),
`APPLE_API_KEY` (the key's ID), `APPLE_API_ISSUER` (App Store Connect → Users and Access →
Integrations → App Store Connect API: the Issuer ID).
Without the API key, builds are signed but not notarized. Tauri notarizes the app; the
step "Notarize the DMG" then notarizes the DMG it comes in too and replaces the release's
copy with the stapled one. "Check the macOS signature" shows the signing authority, the
stapled tickets and Gatekeeper's verdict on both, and fails the build where they're wrong.
Without the API key, builds are signed but not notarized. Tauri notarizes the app, and "Check
the macOS signature" shows its signing authority, stapled ticket and Gatekeeper's verdict. The
DMG goes on as an artifact to the **macOS notarize** job, which notarizes it too, staples it
(tried five times: Apple's ticket service drops connections), checks it the same way and puts
it in the release in place of the one the build uploaded. That job only talks to Apple, so
when it fails, re-running it takes a minute or two instead of a new build. Either check fails
the run where something is wrong.
A dev build with *platforms* `macos` is the cheapest check of a change to any of this. The
certificate was made from a
CSR on Linux (`openssl req -newkey rsa:2048`); make a new one before it expires
Expand Down Expand Up @@ -144,18 +147,22 @@ Updates are signed regardless (see above).
valid after. The validation expires: its date is on the portal's Identity validation page, it
can be renewed there from 60 days before, and Microsoft mails a reminder. Once it lapses,
certificates stop renewing and signing stops (a new validation then needs the profile
replaced, same name). "Check the Windows signature" after the build shows each file's signer
and timestamp, and fails the build where one is missing.
replaced, same name). Signing needs Microsoft's timestamp server, which doesn't always
answer: the sign command tries each file four times, 20 seconds apart. "Check the Windows
signature" after the build shows each file's signer and timestamp, and fails the build where
one is missing.

## iOS (TestFlight)

The release workflow builds the iOS app and uploads it to TestFlight: on every production release
(`main`), and for a dev build when a manual run asks for *platforms* `ios` or `all`. The **iOS**
job (macOS runner) builds and signs the .ipa; the **TestFlight** job (Linux) uploads it with
`scripts/upload-ios.py`, through the App Store Connect API, and waits until Apple has processed
it. The version is the one in `Cargo.toml` (a dev build's too, without its `-<build>`: App Store
versions are at most three numbers), and the build number is the release workflow's: its run
number plus `RUN_NUMBER_OFFSET` (above). A build uploaded by hand needs a number above
it. Requests Apple drops are tried again, and a build App Store Connect has already (an earlier
attempt that got through though its answer didn't) counts as uploaded, so re-running the job is
always safe. The version is the one in `Cargo.toml` (a dev build's too, without its `-<build>`:
App Store versions are at most three numbers), and the build number is the release workflow's:
its run number plus `RUN_NUMBER_OFFSET` (above). A build uploaded by hand needs a number above
the last one.

It needs the secrets `IOS_CERTIFICATE` (an Apple Distribution certificate with its key, a .p12 in
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/e2e/appimage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ try {
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
await s.clickButton("Clouds…");
// The page shows what to add once it has listed the sources.
await s.waitText("Connect Vallic Cloud", ".cloud-page", 30000);
await s.clickButton("Connect Vallic Cloud");
await s.waitText("WXYZ-1234", ".cloud-sign-in", 30000);
await s.screenshot(`${shots}/vallic-sign-in.png`);
Expand Down Expand Up @@ -531,6 +533,7 @@ try {
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
await s.clickButton("Clouds…");
await s.waitText("123456789012", ".cloud-source", 30000);
await s.exec(
`const card = [...document.querySelectorAll(".cloud-source")].find((x) => x.innerText.includes("123456789012"));
[...card.querySelectorAll("button")].find((b) => b.innerText === "Settings…").click(); return true`,
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "morphgate-desktop",
"private": true,
"version": "0.2.0",
"version": "0.2.1",
"license": "MPL-2.0",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.android.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"identifier": "com.morphgate.app",
"bundle": {
"android": {
"versionCode": 20000
"versionCode": 20100
}
}
}
1 change: 1 addition & 0 deletions assets/brand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ the wordmark in a live font.
| `morphgate-android-foreground.svg`, `-background.svg`, `-monochrome.svg` | Android's adaptive icon layers: the mark alone, scaled to stay inside the circle launchers cut, on the tile's colour; one-colour for themed icons. |
| `morphgate-lockup-on-dark.svg` | On dark grounds (Console, terminal backgrounds). |
| `morphgate-lockup-on-light.svg` | On light grounds (Greenbar, paper). |
| `morphgate-social-preview.png` | The repository's social preview on GitHub (Settings → General → Social preview), 1280 × 640: the lockup on the dark ground, the tagline, the platforms. |

The phone icons come from `icon-manifest.json` (Android) and the iOS tile, and go to both
`src-tauri/icons` and the generated projects (`gen/android`, `gen/apple`), which `tauri icon`
Expand Down
Binary file added assets/brand/morphgate-social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ The reference server's tests (`server/tests/`) are the older conformance suite:

All jobs run on GitHub's runners.

`.github/workflows/release.yml` builds releases on pushes to `develop` and `main` ([Releasing](#releasing)): a `plan` job (channel, version, build number, platforms, the release to upload to), the desktop matrix through `tauri-action`, the Android APK, and on iOS the signed `.ipa` and a `testflight` upload job on Linux.
`.github/workflows/release.yml` builds releases on pushes to `develop` and `main` ([Releasing](#releasing)): a `plan` job (channel, version, build number, platforms, the release to upload to), the desktop matrix through `tauri-action`, a `macos-notarize` job for the DMG, the Android APK, and on iOS the signed `.ipa` and a `testflight` upload job on Linux. The jobs that only talk to Apple are apart from the builds, so re-running a failed one doesn't build again; Windows signing retries the timestamp server inside the build ([RELEASING.md](../RELEASING.md)).

## Versioning

Expand Down
Loading