diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ffc82e..fb31beb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 != '' @@ -369,37 +379,14 @@ 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" @@ -407,14 +394,77 @@ jobs: 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 diff --git a/Cargo.lock b/Cargo.lock index 66f4688..8e19cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "mg-inventory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "reqwest", "serde", @@ -3359,7 +3359,7 @@ dependencies = [ [[package]] name = "mg-presence" -version = "0.2.0" +version = "0.2.1" dependencies = [ "block2", "objc2", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "mg-ssh" -version = "0.2.0" +version = "0.2.1" dependencies = [ "getrandom 0.4.3", "russh", @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "mg-store" -version = "0.2.0" +version = "0.2.1" dependencies = [ "mg-sync", "mg-vault", @@ -3406,7 +3406,7 @@ dependencies = [ [[package]] name = "mg-sync" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base64 0.23.1", "reqwest", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "mg-vault" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "chacha20poly1305 0.11.0", @@ -3434,7 +3434,7 @@ dependencies = [ [[package]] name = "mg-vpn" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes", "aws-lc-rs", @@ -3543,7 +3543,7 @@ dependencies = [ [[package]] name = "morphgate-desktop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "apple-native-keyring-store", "base64 0.23.1", @@ -6987,7 +6987,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vault-conform" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "hmac 0.13.0", @@ -7007,7 +7007,7 @@ dependencies = [ [[package]] name = "vault-sync-server" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "aws-lc-rs", diff --git a/Cargo.toml b/Cargo.toml index bfa80aa..5a65dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 656443a..a3407be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 0381420..3b939a0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 @@ -144,8 +147,10 @@ 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) @@ -153,9 +158,11 @@ The release workflow builds the iOS app and uploads it to TestFlight: on every p (`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 `-`: 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 `-`: +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 diff --git a/apps/desktop/e2e/appimage.mjs b/apps/desktop/e2e/appimage.mjs index 8355bd7..69ade5a 100644 --- a/apps/desktop/e2e/appimage.mjs +++ b/apps/desktop/e2e/appimage.mjs @@ -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`); @@ -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`, diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 95dafc2..3506ff7 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "morphgate-desktop", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "morphgate-desktop", - "version": "0.2.0", + "version": "0.2.1", "license": "MPL-2.0", "dependencies": { "@tauri-apps/api": "2.12.4", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f097f73..333e97c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "morphgate-desktop", "private": true, - "version": "0.2.0", + "version": "0.2.1", "license": "MPL-2.0", "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/tauri.android.conf.json b/apps/desktop/src-tauri/tauri.android.conf.json index abd5b37..0f4f33a 100644 --- a/apps/desktop/src-tauri/tauri.android.conf.json +++ b/apps/desktop/src-tauri/tauri.android.conf.json @@ -2,7 +2,7 @@ "identifier": "com.morphgate.app", "bundle": { "android": { - "versionCode": 20000 + "versionCode": 20100 } } } diff --git a/assets/brand/README.md b/assets/brand/README.md index f31cd54..b2fa89a 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -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` diff --git a/assets/brand/morphgate-social-preview.png b/assets/brand/morphgate-social-preview.png new file mode 100644 index 0000000..25a8ddb Binary files /dev/null and b/assets/brand/morphgate-social-preview.png differ diff --git a/docs/development.md b/docs/development.md index 66d3a86..cee9e29 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/scripts/upload-ios.py b/scripts/upload-ios.py index a546dd2..381f1db 100755 --- a/scripts/upload-ios.py +++ b/scripts/upload-ios.py @@ -7,6 +7,10 @@ The App Store Connect API key: ASC_KEY_ID and ASC_ISSUER_ID, and the .p8 at ASC_KEY_PATH (default ~/.apple/AuthKey_.p8). It needs the Admin or App Manager role. Needs PyJWT with cryptography. + +Apple's API and its upload storage drop a request now and then: requests that can be repeated +are, after a pause. A build App Store Connect has already (an earlier run that got through, +though its answer didn't) counts as uploaded. """ import hashlib import json @@ -25,6 +29,8 @@ os.environ.get("ASC_KEY_PATH") or pathlib.Path.home() / ".apple" / f"AuthKey_{KEY_ID}.p8" ).read_text() API = "https://api.appstoreconnect.apple.com" +# Pauses before each further try of a request that can be repeated. +PAUSES = (5, 15, 30, 60) def token(): @@ -37,19 +43,36 @@ def token(): ) -def api(method, path, body=None): - req = urllib.request.Request( - API + path, - data=json.dumps(body).encode() if body is not None else None, - method=method, - headers={"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req, timeout=60) as r: - raw = r.read() - return json.loads(raw) if raw else {} - except urllib.error.HTTPError as e: - sys.exit(f"{method} {path}: {e.code} {e.read().decode()[:1500]}") +def send(make, what, timeout, repeat): + """Sends the request `make()` builds; a repeatable one again after a dropped connection, a + timeout or a 5xx answer. Returns the body.""" + for pause in (*PAUSES, None) if repeat else (None,): + try: + with urllib.request.urlopen(make(), timeout=timeout) as r: + return r.read() + except urllib.error.HTTPError as e: + if e.code < 500 or pause is None: + sys.exit(f"{what}: {e.code} {e.read().decode()[:1500]}") + why = f"HTTP {e.code}" + except (urllib.error.URLError, TimeoutError, ConnectionError) as e: + if pause is None: + sys.exit(f"{what}: {e}") + why = str(e) + print(f"{what}: {why}; again in {pause} s") + time.sleep(pause) + + +def api(method, path, body=None, timeout=60, repeat=True): + def make(): + return urllib.request.Request( + API + path, + data=json.dumps(body).encode() if body is not None else None, + method=method, + headers={"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}, + ) + + raw = send(make, f"{method} {path}", timeout, repeat) + return json.loads(raw) if raw else {} ipa, bundle_id, version, build = sys.argv[1:5] @@ -60,11 +83,19 @@ def api(method, path, body=None): sys.exit(f"no App Store Connect app for {bundle_id}: create it first (Apps → + → New App)") app = apps[0]["id"] +existing = api("GET", f"/v1/builds?filter%5Bapp%5D={app}&filter%5Bversion%5D={build}" + f"&filter%5BpreReleaseVersion.version%5D={version}&limit=1")["data"] +if existing: + state = existing[0]["attributes"].get("processingState") + print(f"{version} ({build}) is in App Store Connect already ({state}): nothing to upload") + sys.exit(0) + +# Not repeated: a retry after a lost answer could start a second upload of the same build. upload = api("POST", "/v1/buildUploads", {"data": { "type": "buildUploads", "attributes": {"cfBundleShortVersionString": version, "cfBundleVersion": build, "platform": "IOS"}, "relationships": {"app": {"data": {"type": "apps", "id": app}}}, -}})["data"] +}}, repeat=False)["data"] print("build upload", upload["id"]) file = api("POST", "/v1/buildUploadFiles", {"data": { @@ -72,22 +103,21 @@ def api(method, path, body=None): "attributes": {"fileName": pathlib.Path(ipa).name, "fileSize": len(data), "uti": "com.apple.ipa", "assetType": "ASSET"}, "relationships": {"buildUpload": {"data": {"type": "buildUploads", "id": upload["id"]}}}, -}})["data"] +}}, repeat=False)["data"] ops = file["attributes"]["uploadOperations"] print(f"uploading {len(data)} bytes in {len(ops)} parts") for op in ops: part = data[op["offset"]:op["offset"] + op["length"]] - req = urllib.request.Request(op["url"], data=part, method=op["method"], - headers={h["name"]: h["value"] for h in op.get("requestHeaders") or []}) - with urllib.request.urlopen(req, timeout=300) as r: - if r.status >= 300: - sys.exit(f"part {op.get('partNumber')}: HTTP {r.status}") + headers = {h["name"]: h["value"] for h in op.get("requestHeaders") or []} + send(lambda: urllib.request.Request(op["url"], data=part, method=op["method"], headers=headers), + f"part {op.get('partNumber')}", 300, True) +# Apple checks the whole file before it answers: give it time. api("PATCH", f"/v1/buildUploadFiles/{file['id']}", {"data": { "type": "buildUploadFiles", "id": file["id"], "attributes": {"uploaded": True, "sourceFileChecksums": { "file": {"hash": hashlib.md5(data).hexdigest(), "algorithm": "MD5"}}}, -}}) +}}, timeout=300) print("uploaded; Apple is processing it") for _ in range(120):