==============================================================
ZYRYN RELEASE GUIDE
==============================================================

Zyryn has three separate distribution routes. Choose one before building.

1. R2 DIRECT DOWNLOAD / SIDELOAD + IN-APP UPDATER
   - Fixed downloads under https://dl.zyryn.com.
   - Cloudflare R2 bucket: zyryn-downloads.
   - Supabase project: fagjsaituzwfkpzeeatc.
   - Six channels: windows, android, linux, macos, ios, and tvos.
   - Publish one platform at a time and update only its app_releases row.

2. APP STORE CONNECT / TESTFLIGHT
   - iOS, macOS, and tvOS only.
   - scripts/appstore/deliver.sh builds, signs, exports, and uploads.
   - TestFlight does not publish the R2 sideload artifact and does not update
   the Supabase updater table.

3. GOOGLE PLAY
   - Android phone, tablet, Android TV, ChromeOS, and compatible XR devices.
   - Upload one Play-profile Android App Bundle; Play generates device APKs.
   - Google Play delivery does not publish the R2 APK and does not update the
     Supabase updater table.

Never substitute one route's Apple artifact for the other:
- R2 iOS is ad-hoc signed for sideload tools.
- R2 tvOS is unsigned for Sideloadly.
- TestFlight iOS/tvOS is Apple Distribution signed.
- TestFlight macOS is exported as a signed installer package.

==============================================================
SHARED PREFLIGHT
==============================================================

1. Inspect the working tree and preserve unrelated changes. Confirm which dirty
   changes the release build will include; never clean/reset automatically.
2. Set pubspec.yaml to a semantic version plus build number:

     version: <SEMVER>+<BUILD>

3. The build number must increase monotonically.
   - R2: query the target platform's current latest_version and latest_build.
     Never move semantic version backward, and always choose a larger build.
   - TestFlight: run scripts/appstore/testflight_status.sh and choose a number
     above every existing upload attempt (including failed/awaiting attempts)
     for every Apple target being released.
   - Google Play: inspect all test and production tracks and choose a versionCode
     above every accepted upload, including drafts.
4. Run release quality gates before any upload:

     dart format --output=none --set-exit-if-changed lib test
     flutter analyze
     flutter test
     flutter test --dart-define=ZYRYN_STORE_BUILD=true
     flutter test --dart-define=ZYRYN_APP_STORE_BUILD=true
     deno test --allow-env supabase/functions
     (cd android && ./gradlew :app:testDebugUnitTest)
     bash -n .agents/skills/release-push/push-downloads.sh \
       scripts/build_linux_media_runtime.sh scripts/package_linux_portable.sh \
       scripts/package_linux_run.sh scripts/linux_run_stub.sh.in \
       scripts/publish_r2_artifact.sh \
       scripts/appstore/deliver*.sh scripts/appstore/lib/*.sh
     ruby -c scripts/appstore/lib/prepare_ios_app_store_package.rb

   Stop and diagnose any failure; do not silently publish around it.
5. Run gates on the exact release tree, then build immediately. If included
   source, assets, platform config, or pubspec changes afterward, rerun affected
   gates and rebuild. Never stamp a new filename onto a stale binary.
6. Prepare short user-facing release notes. When deriving them from Git, cover
   the complete range from the currently published version through HEAD and
   omit version-only, CI-only, reverted, and marketing-only commits.
   If no release tag exists, identify and state the last commit whose pubspec
   matched the published version; do not silently guess the range anchor.
7. Do not raise min_version unless a forced update was explicitly requested.

==============================================================
R2 DIRECT DOWNLOAD / SIDELOAD
==============================================================

Fixed keys and URLs (do not rename):

  windows  -> https://dl.zyryn.com/windows
  android  -> https://dl.zyryn.com/android
  linux    -> https://dl.zyryn.com/linux
  macos    -> https://dl.zyryn.com/macos
  ios      -> https://dl.zyryn.com/ios
  tvos     -> https://dl.zyryn.com/tvos

The object key never changes. Content-Disposition gives the downloaded file a
versioned filename.

Build commands:

  Windows:
    flutter build windows --release
    Compile zyryn.iss / the Inno installer afterward.

  Android direct/sideload universal APK:
    flutter build apk --release --no-shrink \
      --target-platform android-arm,android-arm64,android-x64

  Linux:
    Build on the pinned Ubuntu 22.04 baseline, then create the portable one-file
    launcher:

      bash scripts/build_linux_media_runtime.sh
      runtime_prefix="${ZYRYN_LINUX_RUNTIME_PREFIX:-$PWD/build/linux-media-runtime}"
      export PKG_CONFIG_PATH="$runtime_prefix/lib/x86_64-linux-gnu/pkgconfig:$runtime_prefix/lib/pkgconfig"
      export LD_LIBRARY_PATH="$runtime_prefix/lib/x86_64-linux-gnu:$runtime_prefix/lib"
      flutter build linux --release --no-pub
      bash scripts/package_linux_portable.sh

    Output: build/release/Zyryn-linux-x64.run

    Do not upload build/linux/x64/release/bundle or tar that directory by hand.
    The runtime builder compiles the pinned, reviewed LGPL mpv/FFmpeg stack. The
    portable packager adds that exact stack and required desktop runtime
    libraries, their package copyright/common-license texts, patches relative
    RPATHs, and verifies the self-extracting file.
    A user downloads one .run file and launches it; they do not manually extract
    anything and do not need apt, libmpv, GTK, Flutter, or another download.
    Since HTTP downloads cannot preserve Unix executable mode, either invoke it
    with bash or mark it executable once:

      bash Zyryn-<version>-linux-x64.run

      chmod +x Zyryn-<version>-linux-x64.run
      ./Zyryn-<version>-linux-x64.run

    Re-run the one-file verifier before any handoff or upload:

      bash scripts/package_linux_portable.sh --verify-run \
        build/release/Zyryn-linux-x64.run

  macOS direct download:
    flutter build macos --release
    The publisher verifies the app version/build, then packs it into a fresh
    build/release/Zyryn-macos.dmg.

  iOS sideload:
    flutter build ipa --release

  tvOS Sideloadly artifact:
    scripts/build_tvos_ipa.sh

Publish and verify one platform:

  bash .agents/skills/release-push/push-downloads.sh <platform>

Use a logged-in global Wrangler v4 when installed. Without one, the script uses
the reviewed `wrangler@4.110.0` fallback rather than an unpinned latest version.
Update that pin only after reviewing current Cloudflare R2 command docs, or set
ZYRYN_WRANGLER_VERSION for a deliberately reviewed run.

The script uploads to R2, re-downloads the object through Wrangler and requires
an exact SHA-256 match, then checks the public URL for HTTP 200, byte length,
content type, and versioned download filename. Linux publication additionally
requires and re-verifies build/release/Zyryn-linux-x64.run; neither publisher
accepts an unverified raw Flutter bundle. If it fails, do not change Supabase.

--------------------------------------------------------------
ANDROID SIGNING - FAIL CLOSED
--------------------------------------------------------------

android/app/build.gradle.kts uses these environment variables for the stable
release key:

  ZYRYN_ANDROID_KEYSTORE_PATH
  ZYRYN_ANDROID_KEYSTORE_PASSWORD
  ZYRYN_ANDROID_KEY_ALIAS
  ZYRYN_ANDROID_KEY_PASSWORD

If any are absent, a local release build falls back to ~/.android/debug.keystore
so flutter run --release remains usable. That fallback MUST NOT be published.

Production Android signer certificate SHA-256:

  76acdb97c8a3ed468d7cb138d3b72fd2a9cdbc09ae0d53148c9afeca5625c4b3

The release script must confirm before upload:
- package is com.zyryn.app;
- APK versionName equals pubspec SEMVER;
- APK versionCode equals pubspec BUILD;
- all three universal ABIs contain the direct-distribution profile marker and
  no Play/Apple store marker;
- APK signature verifies;
- signer fingerprint equals the production fingerprint above.

Before overwrite, the script also downloads the existing R2 Android APK and
requires its pinned production signer and a versionCode below the candidate.
This catches historical R2/Supabase drift and prevents package downgrades.

The private key cannot be recovered from the public certificate, live APK, or
a GitHub Actions secret. If the original keystore is on another computer,
transfer it securely. Never send the keystore or passwords through chat.
Changing the signer breaks in-place updates unless a compatible Android signing
lineage was deliberately configured.

After any verified R2 upload, update semantic version, build, and notes. Android
requires the build for package-manager compatibility; every channel uses it as
a same-semver updater tie-break:

  update public.app_releases
     set latest_version = '<SEMVER>',
         latest_build   = <BUILD>,
         whats_new      = array['Line 1', 'Line 2'],
         updated_at     = now()
   where platform = '<platform>'
   returning platform, latest_version, latest_build, whats_new, updated_at;

Use safely escaped/parameterized note values; double embedded single quotes in
SQL literals. Require exactly one returned row, query it again afterward, and
report the URL, remote size, version, build number, and release notes.

R2 replacement and the Supabase update are separate. If the metadata update
fails after a verified upload, leave the updater row unchanged, report the
split state, reverify R2, and retry only the metadata update. Do not replace the
fixed object again with an unverified rollback artifact.

==============================================================
GOOGLE PLAY (ANDROID PHONE / TABLET / TV)
==============================================================

Google Play receives one AAB, not the direct-distribution universal APK. The
same bundle declares optional touch and Leanback support and serves phone,
tablet, Android TV, ChromeOS, and compatible Android XR devices.

Before building, confirm the pubspec versionCode is greater than every code
already consumed in Play Console. Configure the Play upload key through these
Play-only variables:

  ZYRYN_PLAY_KEYSTORE_PATH
  ZYRYN_PLAY_KEYSTORE_PASSWORD
  ZYRYN_PLAY_KEY_ALIAS
  ZYRYN_PLAY_KEY_PASSWORD

Never reuse the direct APK's ZYRYN_ANDROID_* signer by accident. The Play build
script maps the Play-only variables to Gradle for that process and pins the
registered upload certificate SHA-256:

  11:4D:49:4B:D7:C7:7C:7A:85:95:60:D6:55:43:29:BE:78:CF:C4:2A:7F:57:7E:DE:1A:9A:66:04:80:BC:2F:91

Build the upload artifact only with:

  bash scripts/build_play_aab.sh \
    --target-platform android-arm,android-arm64,android-x64

The script must verify the package/version, Play profile and product IDs,
upload signer, bundle structure, required ABIs, 16 KB page and ELF alignment,
media-playback DVR service, encrypted Play transport policy, and absence of the
direct updater and Stripe. ZYRYN_PLAY_ALLOW_DEBUG_SIGNING=true is verification
only; its output is renamed to app-release.debug-verification.aab and must
never be uploaded.

Before production review or rollout:
- complete the Google Play setup and listing declarations;
- enable and test Play RTDN through Pub/Sub and the play-rtdn Edge Function;
- confirm both subscription offers match the app's displayed trial;
- upload to Internal testing and install through the tester join link;
- test purchase, cancel, restore, reinstall, ownership rejection, management,
  refund/revocation, and RTDN entitlement updates on real hardware;
- smoke-test the same release on phone, tablet, and Android TV, including D-pad
  navigation and confirming no TV action attempts to launch a browser.

Play App Signing re-signs installed APKs. Keep its SHA-256 certificate alongside
the direct signer in website/.well-known/assetlinks.json so verified App Links
work from both channels.

==============================================================
APP STORE CONNECT / TESTFLIGHT
==============================================================

Prerequisites under apple-appstore/ (or SECRETS_DIR):

  Certificates.p12
  MacOsCertificates.p12                 (macOS installer identity)
  Zyryn_ios.mobileprovision
  Zyryn_macOS.provisionprofile
  Zyryn_TvOs.mobileprovision
  AuthKey_<KEY_ID>.p8

Never paste the P12 password into chat. Use a real terminal prompt, a local
ZYRYN_P12_PASSWORD environment variable, or a mode-600 temporary handoff. The
delivery scripts import identities into a temporary keychain and remove it on
exit. One password must decrypt both P12 files. A handoff is consumed by one
target process; create a fresh one or use the silent prompt for every additional
target. A safe local handoff is:

  bash -c 'umask 077; read -r -s -p "Apple P12 password: " pw; printf "\n"; printf "%s" "$pw" > /tmp/zyryn-p12-password'

Load it into one delivery process without echoing, remove the file before the
build begins, and never expose the value in tool output or shell history:

  bash -c 'export ZYRYN_P12_PASSWORD="$(<"$1")"; rm -f "$1"; exec bash scripts/appstore/deliver.sh "$2"' _ /tmp/zyryn-p12-password <ios|macos|tvos>

Before building, check existing uploads and choose a fresh build number. Use
uploads[].attributes.platform and cfBundleVersion; count COMPLETE, FAILED,
PROCESSING, and AWAITING_UPLOAD attempts. The script paginates the API and emits
suggested_next_build_number:

  bash scripts/appstore/testflight_status.sh

Use the same new build number for every Apple target in the release; do not
increment between iOS, macOS, and tvOS. Each delivery script rechecks its target
against App Store Connect and rejects a build at or below that platform's
highest recorded attempt.

Validate the subscription catalog once before any Apple delivery, even when
iOS is not among the requested targets:

  bash scripts/appstore/subscription_status.sh

Keep MPV and VLCKit enabled while using the reviewed privacy-safe source
rebuilds for Apple delivery. Build or reverify the ignored local caches before
starting any signed archive:

  bash scripts/appstore/build_custom_libmpv.sh all
  bash scripts/appstore/build_custom_vlckit.sh all

The libmpv cache must pass iOS arm64, simulator arm64/x86_64, and macOS
arm64/x86_64 ABI/deployment/install-name/signability checks. The VLCKit cache
must pass MobileVLCKit and TVVLCKit checks. Delivery stages only the matching
framework into temporary App Store package copies; it must not modify the
global Flutter pub cache or direct/debug builds. Publish and verify
https://zyryn.com/open-source before review so end users can access the full
LGPL text, exact corresponding source, Zyryn patches, and build instructions.

Run targets separately so a later failure does not obscure completed uploads.
Run and fully process a target with known unresolved risk before the others:

  bash scripts/appstore/deliver.sh ios
  bash scripts/appstore/deliver.sh macos
  bash scripts/appstore/deliver.sh tvos

There is intentionally no all target; confirm processing between platforms.

Each command builds, validates, signs, exports, and uploads immediately. Signed
exports use temporary directories; do not expect a permanent TestFlight IPA or
PKG under build/.

Before export, every Apple delivery scans every Mach-O in the archived app for
AdSupport.framework linkage, ASIdentifierManager, and advertisingIdentifier.
The scan ignores non-native assets and fails closed if a binary cannot be
inspected, so `usesIdfa=false` is based on the exact archive being uploaded.

The scripts reject expired, wrong-platform, wrong-bundle, development, ad-hoc,
or enterprise provisioning profiles, then require the exact selected profile
UUID in each archive. Installed profiles and the App Store Connect API key
remain in Apple's standard per-user locations as persistent local credentials.

iOS delivery additionally:
- compiles with ZYRYN_APP_STORE_BUILD=true;
- validates App Store subscription product IDs;
- excludes Stripe from the native App Store archive;
- validates transport policy and compiled profile markers.

macOS delivery additionally:
- creates a manual Release archive;
- requires Apple Distribution plus Mac Installer Distribution identities;
- exports and uploads a signed .pkg.

--------------------------------------------------------------
TVOS TESTFLIGHT INVARIANTS
--------------------------------------------------------------

tvos/Runner/Assets.xcassets/AppIcon.brandassets/Contents.json must declare:

  role: top-shelf-image-wide
  size: 2320x720

tvos/Runner/Info.plist must map:

  TVTopShelfImage -> TVTopShelfPrimaryImageWide -> Top Shelf Image

The source images must be exactly:

  Top Shelf Image.imageset/top_shelf_wide.png      2320x720
  Top Shelf Image.imageset/top_shelf_wide@2x.png   4640x1440

If these files or metadata change, compile the asset catalog with xcrun actool
and inspect Assets.car with assetutil. Source dimensions alone are not proof:
the wrong catalog role can make Xcode compile a regular 1920x720 shelf image.
The delivery script exposes the same source/catalog gate without signing,
building, or uploading:

  ZYRYN_TVOS_PREFLIGHT_ONLY=1 bash scripts/appstore/deliver_tvos.sh

The flutter-tvos preparation phase runs with the checked-in unsigned Xcode
configuration and must complete successfully after producing fresh
App.framework, AOT, and generated-config output. Do not tolerate or hand-wave an
intermediate signing failure.

Before the manual archive, delivery must:

- package each CocoaPods plugin's reviewed PrivacyInfo.xcprivacy at the root of
  its framework, including package_info_plus_tvos and sqflite_tvos;
- stage the path_provider_tvos and shared_preferences_tvos Swift packages with
  pinned privacy resources, without editing the pub cache;
- copy the pinned unsigned flutter-tvos Flutter.xcframework into a private
  staging directory and sign that source dependency, including its inner
  Flutter.framework, with the exact Apple Distribution identity and a secure
  timestamp before Xcode resolves it;
- use a fresh dedicated DerivedData directory and require Xcode's single
  Flutter.xcframework-tvos.signature record to report signed=true, signature
  type AppleDeveloperProgram, ZYRYN's exact team identifier, nonempty
  certificate data and code-directory hashes, library Flutter.framework, and
  platform tvos. Do not gate on that record's isSecureTimestamp boolean:
  Xcode 26 can serialize false even for a securely timestamped signature.
  Require a real, non-"Not Available" Timestamp directly from codesign for
  both the staged inner framework and outer XCFramework instead.

The archived app and the exact unzipped exported IPA must both contain the
pinned Flutter, package-info, path-provider, shared-preferences, sqflite,
video-player, and wakelock manifests at their expected SDK bundle paths. Every
embedded framework and Runner.app must pass strict non-ad-hoc signature and
LC_CODE_SIGNATURE verification. Required-reason API markers must be covered by
that framework's own manifest. Any missing manifest, origin signature,
resource bundle, or final-payload signature stops delivery before upload.

--------------------------------------------------------------
TESTFLIGHT VERIFICATION
--------------------------------------------------------------

An altool message saying UPLOAD SUCCEEDED means transfer completed, not that
Apple accepted the build. Poll:

  bash scripts/appstore/testflight_status.sh

Wait until the newest upload for the target is COMPLETE and its errors,
warnings, and infos arrays are all empty. Its processed build must be VALID and
expired=false. If the upload is FAILED, fix the exact server error. Any attempt
visible in App Store Connect is consumed: choose a new build number, rebuild,
and upload only that failed target. Never rerun already successful targets
merely to recover a later failure. VALID proves processing, not beta-group
assignment or tester availability; verify export compliance and TestFlight
group assignment separately when testers are part of the requested outcome.

==============================================================
FORCED UPDATE (R2 ROUTE ONLY, EXPLICIT REQUEST ONLY)
==============================================================

  update public.app_releases
     set min_version = '<SEMVER>'
   where platform = '<platform>';

This prevents older clients from continuing without updating. Use sparingly.

==============================================================
WEBSITE NOTE
==============================================================

Release uploads do not require a Cloudflare Pages deploy. Website buttons use
fixed R2 URLs. Deploy Pages only when website content, copy, or links change:

  wrangler pages deploy website --project-name=zyryn \
    --branch production --commit-dirty=true
