From ba327d6045aea02175f434d5621911d9fd9481db Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 21:50:12 -0400 Subject: [PATCH 1/5] fix(release): fail closed on production publishing --- .github/workflows/ci.yml | 10 +- .../workflows/deploy-convex-production.yml | 66 +++++++++ .github/workflows/release-desktop.yml | 34 +++++ .github/workflows/release-store.yml | 10 ++ README.md | 6 +- docs/auth_flow_reference.md | 15 +- docs/cloud_online_release_gaps.md | 4 +- docs/release_process.md | 73 +++++++++- lib/config/cloud_build_config.dart | 123 +++++++++++++++++ lib/main.dart | 14 +- release/metadata/4.6.1+97.json | 29 ++++ scripts/assert_release_preflight.ps1 | 30 ++++ scripts/build_desktop_release.ps1 | 31 +++-- scripts/build_store_release.ps1 | 23 +++- scripts/common_release.ps1 | 83 ++++++++++++ scripts/publish_pages_branch.ps1 | 6 + scripts/release_desktop.ps1 | 14 +- scripts/test_release_safety.ps1 | 128 ++++++++++++++++++ test/cloud_build_config_test.dart | 100 ++++++++++++++ 19 files changed, 765 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/deploy-convex-production.yml create mode 100644 lib/config/cloud_build_config.dart create mode 100644 release/metadata/4.6.1+97.json create mode 100644 scripts/assert_release_preflight.ps1 create mode 100644 scripts/test_release_safety.ps1 create mode 100644 test/cloud_build_config_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec4799bf..ab8217b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,10 @@ jobs: # Fetch all branch history so that ref exists on PR and push runs. fetch-depth: 0 + - name: Test Release Safety Policy + shell: pwsh + run: powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1 + - uses: actions/setup-node@v4 with: node-version: 22 @@ -158,7 +162,7 @@ jobs: run: fvm flutter analyze --no-fatal-infos - name: Build Web Client shell: pwsh - run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons + run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development - name: Run Tests shell: pwsh run: fvm flutter test @@ -170,7 +174,7 @@ jobs: cargo test --manifest-path third_party/convex_rs/Cargo.toml - name: Build Windows Client shell: pwsh - run: fvm flutter build windows --no-tree-shake-icons + run: fvm flutter build windows --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development - name: Build Windows Installer shell: pwsh @@ -278,4 +282,4 @@ jobs: cargo test --manifest-path third_party/convex_rs/Cargo.toml - name: Build Linux Client - run: fvm flutter build linux --no-tree-shake-icons + run: fvm flutter build linux --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development diff --git a/.github/workflows/deploy-convex-production.yml b/.github/workflows/deploy-convex-production.yml new file mode 100644 index 00000000..7d58ce29 --- /dev/null +++ b/.github/workflows/deploy-convex-production.yml @@ -0,0 +1,66 @@ +name: Deploy Convex Production + +on: + workflow_dispatch: + inputs: + confirmation: + description: Type deploy-production to confirm this production backend deploy. + type: string + required: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Production Deploy + shell: bash + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "The production Convex backend can only deploy from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ "${{ inputs.confirmation }}" != "deploy-production" ]]; then + echo "Confirmation must be exactly: deploy-production" + exit 1 + fi + + - name: Checkout Production Source + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Require Production Convex Deploy Key + shell: bash + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: | + if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then + echo "Add CONVEX_PRODUCTION_DEPLOY_KEY to the GitHub Production environment." + exit 1 + fi + if [[ "$CONVEX_DEPLOY_KEY" != prod:* ]]; then + echo "CONVEX_PRODUCTION_DEPLOY_KEY must be a production deploy key with the prod: prefix." + exit 1 + fi + + - name: Install Convex Dependencies + run: npm ci + + - name: Check Convex Types + run: npx tsc --noEmit + + - name: Run Convex Tests + run: npm run test:convex + + - name: Deploy Convex Production Backend + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: npx convex deploy --typecheck enable --message "GitHub Actions $GITHUB_SHA" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 86dae011..0f517e64 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -46,7 +46,30 @@ permissions: contents: write jobs: + production-approval: + if: ${{ inputs.channel == 'stable' }} + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Stable Desktop Release + shell: bash + env: + PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Stable desktop releases can only run from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ -z "$PRODUCTION_CONVEX_DEPLOYMENT_URL" || -z "$PRODUCTION_CONVEX_CLIENT_ID" ]]; then + echo "Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before releasing stable." + exit 1 + fi + build: + needs: production-approval + if: ${{ always() && (inputs.channel == 'prerelease' || needs.production-approval.result == 'success') }} runs-on: windows-latest steps: @@ -54,6 +77,15 @@ jobs: with: fetch-depth: 0 + - name: Run Release Preflight + shell: pwsh + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: >- + powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 + -ReleaseTarget "${{ inputs.channel == 'stable' && 'stable-desktop' || 'prerelease-desktop' }}" + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -76,6 +108,8 @@ jobs: RELEASE_TITLE: ${{ inputs.release_title }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} run: | $args = @( "-ExecutionPolicy", "Bypass", diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 3c142c1a..5657f3e5 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -25,12 +25,20 @@ permissions: jobs: build: runs-on: windows-latest + environment: Production steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Run Store Release Preflight + shell: pwsh + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 -ReleaseTarget store + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -55,6 +63,8 @@ jobs: shell: pwsh env: POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} run: powershell -ExecutionPolicy Bypass -File scripts/build_store_release.ps1 - name: Upload Store Artifacts diff --git a/README.md b/README.md index 1ebe064a..4691478d 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,13 @@ back to the installed build. ## Build ```bash -flutter build +flutter build --dart-define=ICARUS_CLOUD_ENVIRONMENT=development ``` +That command makes an internal build against the named development Convex +deployment. Use the release scripts in `docs/release_process.md` for stable or +Store artifacts. They require explicit production cloud configuration. + ## Versioning (Windows MSIX) There is a helper script for bumping versions across `pubspec.yaml` and `lib/const/settings.dart`. diff --git a/docs/auth_flow_reference.md b/docs/auth_flow_reference.md index 486797a5..64fa988e 100644 --- a/docs/auth_flow_reference.md +++ b/docs/auth_flow_reference.md @@ -24,6 +24,7 @@ That means the flow is: These are the files that define the current behavior: - `lib/main.dart` +- `lib/config/cloud_build_config.dart` - `lib/providers/auth_provider.dart` - `lib/collab/convex_strategy_repository.dart` - `lib/collab/generated/` @@ -41,11 +42,14 @@ These are the files that define the current behavior: At startup the app initializes the Convex client and then Supabase: ```dart +final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, +); await ConvexClient.initialize( - const ConvexConfig( - deploymentUrl: 'https://majestic-eel-413.convex.cloud', - clientId: 'dev:majestic-eel-413', - operationTimeout: Duration(seconds: 30), + ConvexConfig( + deploymentUrl: cloudBuildConfig.deploymentUrl, + clientId: cloudBuildConfig.clientId, + operationTimeout: const Duration(seconds: 30), healthCheckQuery: defaultConvexHealthCheckQuery, ), ); @@ -60,6 +64,9 @@ await Supabase.initialize( Why this matters: - `ConvexClient.initialize(...)` creates the global Convex client used by the app. +- `CloudBuildConfig` selects the named development deployment for local, CI, + and prerelease builds. Stable and Store release scripts require an explicit + production URL and client ID. - `Supabase.initialize(...)` sets up the auth provider that will issue JWTs. - `detectSessionInUri: false` is intentional because the desktop app handles OAuth callback URIs itself instead of relying on automatic URI parsing. diff --git a/docs/cloud_online_release_gaps.md b/docs/cloud_online_release_gaps.md index 988535ae..7dcc4c34 100644 --- a/docs/cloud_online_release_gaps.md +++ b/docs/cloud_online_release_gaps.md @@ -210,8 +210,8 @@ npm run snapshot:convex-contract:check npm run audit:convex-contract fvm flutter analyze --no-fatal-infos fvm flutter test -fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons -fvm flutter build macos --no-tree-shake-icons +fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development +fvm flutter build macos --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development ``` On the pull request, CI also builds the Windows installer, runs diff --git a/docs/release_process.md b/docs/release_process.md index f4df0851..c1491e94 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -19,21 +19,85 @@ Keep them separate. Run the workflow for the channel you actually want to publis ## Before Any Release -1. Make sure the branch contains the changes you want to ship. +1. Check the branch. Stable desktop and every Store build must run from + `main`. The release scripts stop before a version bump or build on any other + branch. Desktop prerelease builds may run from a feature branch. 2. Run the focused validation locally: - `fvm flutter test test/update_checker_test.dart` + - `fvm flutter test test/cloud_build_config_test.dart` + - `powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1` - `fvm flutter analyze` 3. Check `pubspec.yaml` and confirm the version you want to release. 4. Create or update the matching release metadata file in `release/metadata/`. 5. Write player-facing release notes in that metadata file. +## Cloud build configuration + +Icarus has one named development Convex configuration in source. Local +development, CI, and desktop prerelease builds select it with +`ICARUS_CLOUD_ENVIRONMENT=development`. + +An ordinary debug run defaults to development. A release-mode app with no +`ICARUS_CLOUD_ENVIRONMENT` stops during startup, so any new release entry point +must choose `development` or `production` deliberately. + +Stable desktop and Store builds select `production` and require both of these +GitHub repository variables: + +- `ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL` +- `ICARUS_PRODUCTION_CONVEX_CLIENT_ID` + +The production URL and client ID are public build inputs, not deploy keys. The +release scripts pass them to Flutter through a temporary Dart-defines file and +delete that file after the build. A missing value, invalid URL, or the known +development deployment stops the release before Flutter runs. + +Stable desktop, Store, and production backend workflows all enter the protected +GitHub `Production` environment before they can build or publish. Desktop +prerelease skips that environment and remains available on feature branches. + +For a local stable build, set the same two environment variables in the shell +before running `scripts/release_desktop.ps1`. Never put a Convex deploy key in a +Dart define or repository variable. + +## One-time production Convex setup + +No production deployment or key is checked into this repository. Before the +first production release: + +1. Create or select the Icarus production deployment in Convex. Record its + `.convex.cloud` client URL in the repository variable above. +2. Create a deployment-scoped production deploy key with only the permissions + needed to deploy. Convex supports this in the deployment settings or with + `npx convex deployment token create github-production --deployment prod`. + See the [Convex deploy-key documentation](https://docs.convex.dev/cli/deploy-key-types). +3. Create a GitHub environment named `Production`. Restrict its deployment + branches to `main`, add any required reviewers, and add the secret + `CONVEX_PRODUCTION_DEPLOY_KEY`. +4. Add the public production URL and a stable client identifier, such as the + identifier chosen for the shipped Icarus client, to the two GitHub repository + variables in the previous section. +5. Configure the production deployment's required R2 environment values before + testing cloud media. The backend reports the exact missing names if they are + absent. + +Run the manual `Deploy Convex Production` workflow from `main` and type +`deploy-production`. The workflow enters the GitHub `Production` environment, +requires a `prod:` deploy key, installs locked dependencies, runs TypeScript and +Convex tests, then runs `npx convex deploy --typecheck enable`. Convex documents +that `CONVEX_DEPLOY_KEY` selects the deployment associated with that key. See +the [`convex deploy` reference](https://docs.convex.dev/cli/reference/deploy). + +The production workflow never reads `CONVEX_PREVIEW_DEPLOY_KEY`. That secret is +only for the isolated contract deployment in CI. + ## Desktop Release Checklist Use this when you want to publish the direct installer channel. 1. Go to `Actions` in GitHub. 2. Open `Release Desktop`. -3. Click `Run workflow`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `channel`: `stable` @@ -81,7 +145,7 @@ Use this when you want to publish the Microsoft Store channel. 1. Go to `Actions` in GitHub. 2. Open `Release Store`. -3. Click `Run workflow`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `publish_to_store`: `false` for a dry run, `true` when you are ready to submit @@ -110,6 +174,9 @@ Use this when you want to publish the Microsoft Store channel. - `scripts/publish_prerelease_local.ps1` pushes the staged site content to `gh-pages`. - GitHub Pages should be configured to serve `gh-pages` from `/ (root)`. - No extra Pages deploy workflow is needed for prerelease testing. +- `release/metadata/4.6.1+97.json` is prerelease-only while the online beta + checks remain open. Do not add `stable` to its channels to make a stable + manifest build pass. - Direct desktop installs now use a per-user install path and per-user registry registration. - Store installs should continue to use the Microsoft Store update path only. - The metadata file should not be a generic `template.json` in the live metadata folder, because the manifest generator treats every JSON file there as a real release entry. diff --git a/lib/config/cloud_build_config.dart b/lib/config/cloud_build_config.dart new file mode 100644 index 00000000..86922c49 --- /dev/null +++ b/lib/config/cloud_build_config.dart @@ -0,0 +1,123 @@ +const String developmentConvexDeploymentUrl = + 'https://majestic-eel-413.convex.cloud'; +const String developmentConvexClientId = 'dev:majestic-eel-413'; +const String _developmentConvexHost = 'majestic-eel-413.convex.cloud'; + +const String _compiledCloudEnvironment = String.fromEnvironment( + 'ICARUS_CLOUD_ENVIRONMENT', +); +const String _compiledConvexDeploymentUrl = String.fromEnvironment( + 'ICARUS_CONVEX_DEPLOYMENT_URL', +); +const String _compiledConvexClientId = String.fromEnvironment( + 'ICARUS_CONVEX_CLIENT_ID', +); + +class CloudBuildConfig { + const CloudBuildConfig._({ + required this.environment, + required this.deploymentUrl, + required this.clientId, + }); + + final String environment; + final String deploymentUrl; + final String clientId; + + factory CloudBuildConfig.fromEnvironment({required bool isReleaseMode}) { + return CloudBuildConfig.forBuild( + isReleaseMode: isReleaseMode, + environment: _compiledCloudEnvironment, + deploymentUrl: _compiledConvexDeploymentUrl, + clientId: _compiledConvexClientId, + ); + } + + factory CloudBuildConfig.forBuild({ + required bool isReleaseMode, + String environment = '', + String deploymentUrl = '', + String clientId = '', + }) { + if (environment.trim().isEmpty) { + if (isReleaseMode) { + throw StateError( + 'Release builds require an explicit ICARUS_CLOUD_ENVIRONMENT. ' + "Use 'development' for CI or prerelease, or 'production' with " + 'production Convex values.', + ); + } + environment = 'development'; + } + + return CloudBuildConfig.resolve( + environment: environment, + deploymentUrl: deploymentUrl, + clientId: clientId, + ); + } + + factory CloudBuildConfig.resolve({ + required String environment, + String deploymentUrl = '', + String clientId = '', + }) { + final resolvedEnvironment = environment.trim().toLowerCase(); + final resolvedDeploymentUrl = deploymentUrl.trim(); + final resolvedClientId = clientId.trim(); + + switch (resolvedEnvironment) { + case 'development': + if (resolvedDeploymentUrl.isEmpty != resolvedClientId.isEmpty) { + throw StateError( + 'Development Convex overrides must include both ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID.', + ); + } + + final developmentUrl = resolvedDeploymentUrl.isEmpty + ? developmentConvexDeploymentUrl + : resolvedDeploymentUrl; + _requireHttpsUrl(developmentUrl); + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: developmentUrl, + clientId: resolvedClientId.isEmpty + ? developmentConvexClientId + : resolvedClientId, + ); + case 'production': + if (resolvedDeploymentUrl.isEmpty || resolvedClientId.isEmpty) { + throw StateError( + 'Production cloud builds require explicit ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID values.', + ); + } + _requireHttpsUrl(resolvedDeploymentUrl); + if (Uri.parse(resolvedDeploymentUrl).host == _developmentConvexHost || + resolvedClientId == developmentConvexClientId) { + throw StateError( + 'Production cloud builds cannot use the Icarus development ' + 'Convex deployment.', + ); + } + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: resolvedDeploymentUrl, + clientId: resolvedClientId, + ); + default: + throw StateError( + "Unsupported ICARUS_CLOUD_ENVIRONMENT '$environment'. " + "Use 'development' or 'production'.", + ); + } + } + + static void _requireHttpsUrl(String value) { + final uri = Uri.tryParse(value); + if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { + throw StateError('Convex deployment URL must be an absolute HTTPS URL.'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 7b03bb4d..824c49fe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:app_links/app_links.dart'; import 'package:icarus/collab/convex_client.dart'; import 'package:icarus/collab/durable_strategy_outbox.dart'; import 'package:custom_mouse_cursor/custom_mouse_cursor.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, kReleaseMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +23,7 @@ import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/routes.dart'; import 'package:icarus/const/second_instance_args.dart'; import 'package:icarus/const/settings.dart' show Settings; +import 'package:icarus/config/cloud_build_config.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/auth_provider.dart'; @@ -115,6 +116,9 @@ Future main(List args) async { appProviderContainer = ProviderContainer(); await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); + final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, + ); await registerDeepLinkProtocol('icarus'); await _initializeDeepLinkHandling(); @@ -160,10 +164,10 @@ Future main(List args) async { await StrategyMigrator.migrateAllStrategies(); await ConvexClient.initialize( - const ConvexConfig( - deploymentUrl: 'https://majestic-eel-413.convex.cloud', - clientId: 'dev:majestic-eel-413', - operationTimeout: Duration(seconds: 30), + ConvexConfig( + deploymentUrl: cloudBuildConfig.deploymentUrl, + clientId: cloudBuildConfig.clientId, + operationTimeout: const Duration(seconds: 30), healthCheckQuery: defaultConvexHealthCheckQuery, ), ); diff --git a/release/metadata/4.6.1+97.json b/release/metadata/4.6.1+97.json new file mode 100644 index 00000000..eee3b8ae --- /dev/null +++ b/release/metadata/4.6.1+97.json @@ -0,0 +1,29 @@ +{ + "version": "4.6.1+97", + "shortVersion": 97, + "title": "Icarus Online Beta", + "description": "Invite-only cloud strategy sync and sharing for internal testing.", + "date": "2026-09-03", + "mandatory": false, + "channels": [ + "desktop", + "prerelease" + ], + "platforms": [ + "windows" + ], + "changes": [ + { + "message": "Signed-in testers can keep a cloud library and sync strategy changes through the Icarus backend.", + "type": "feature" + }, + { + "message": "Strategies can be shared with view-only or editor access during the online beta.", + "type": "feature" + }, + { + "message": "This build is an internal prerelease. Cloud rollback and the complete two-client release check are not finished yet.", + "type": "other" + } + ] +} diff --git a/scripts/assert_release_preflight.ps1 b/scripts/assert_release_preflight.ps1 new file mode 100644 index 00000000..d16699be --- /dev/null +++ b/scripts/assert_release_preflight.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, + [string]$BranchName = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $ReleaseTarget -BranchName $BranchName | Out-Null + +$cloudReleaseTarget = switch ($ReleaseTarget) { + "stable-desktop" { "stable" } + "prerelease-desktop" { "prerelease" } + "store" { "store" } + default { $null } +} + +if ($null -ne $cloudReleaseTarget) { + Resolve-CloudBuildConfiguration ` + -ReleaseTarget $cloudReleaseTarget ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null +} diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index a9285b20..dacccf9f 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -10,6 +10,8 @@ param( [string]$InitialChangeMessage = "Describe this release before publishing.", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -19,6 +21,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null +$cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot if (-not $SkipPubGet) { @@ -35,14 +43,21 @@ try { "--release", "--dart-define=ICARUS_UPDATE_CHANNEL=$Channel" ) + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + } + if ($cloudBuildConfiguration.Environment -eq "production") { + $dartDefines.ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + $dartDefines.ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $releaseArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $releaseArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $releaseArguments } finally { @@ -146,9 +161,7 @@ else { $missingChannels = @($requiredChannels | Where-Object { $channels -notcontains $_ }) if ($missingChannels.Count -gt 0) { - $metadata.channels = @($channels + $missingChannels) - Write-JsonFileUtf8 -Value $metadata -Path $metadataPath -Depth 6 - Write-Host ("Updated release metadata channels at {0}: {1}" -f $metadataPath, ($metadata.channels -join ", ")) + throw "Release metadata '$metadataPath' does not include channel(s): $($missingChannels -join ', '). Review and edit the metadata explicitly before building." } } diff --git a/scripts/build_store_release.ps1 b/scripts/build_store_release.ps1 index b5aeefe9..534d64e0 100644 --- a/scripts/build_store_release.ps1 +++ b/scripts/build_store_release.ps1 @@ -2,6 +2,8 @@ param( [string]$OutputDir = "release\out\store", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -11,6 +13,11 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" | Out-Null +$cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot $windowsBuildRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\windows" @@ -25,14 +32,18 @@ try { } $flutterBuildArguments = @("flutter", "build", "windows", "--release") + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $flutterBuildArguments # Stage the video-export encoder into the build output before packaging. Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 7c309c1f..138392c8 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -48,6 +48,89 @@ function Get-VersionInfo { } } +function Get-ReleaseBranchName { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [string]$BranchName = "" + ) + + if (-not [string]::IsNullOrWhiteSpace($BranchName)) { + return $BranchName.Trim() + } + + if ($env:GITHUB_REF_TYPE -eq "branch" -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_REF_NAME)) { + return $env:GITHUB_REF_NAME.Trim() + } + + $resolvedBranch = (& git -C $RepoRoot branch --show-current).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($resolvedBranch)) { + throw "Could not determine the current branch for release safety checks." + } + + return $resolvedBranch +} + +function Assert-ReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$BranchName = "" + ) + + $resolvedBranch = Get-ReleaseBranchName -RepoRoot $RepoRoot -BranchName $BranchName + if ($ReleaseTarget -ne "prerelease-desktop" -and $resolvedBranch -ne "main") { + throw "Release target '$ReleaseTarget' is public and can only run from branch 'main'. Current branch: '$resolvedBranch'." + } + + Write-Host "Release branch check passed for '$ReleaseTarget' on '$resolvedBranch'." -ForegroundColor Green + return $resolvedBranch +} + +function Resolve-CloudBuildConfiguration { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "prerelease", "store")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = "", + [string]$ProductionConvexClientId = "" + ) + + if ($ReleaseTarget -eq "prerelease") { + return [ordered]@{ + Environment = "development" + DeploymentUrl = "" + ClientId = "" + } + } + + $deploymentUrl = $ProductionConvexDeploymentUrl.Trim() + $clientId = $ProductionConvexClientId.Trim() + if ([string]::IsNullOrWhiteSpace($deploymentUrl) -or [string]::IsNullOrWhiteSpace($clientId)) { + throw "Production cloud configuration is missing. Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before building '$ReleaseTarget'." + } + + $parsedUrl = $null + if (-not [System.Uri]::TryCreate($deploymentUrl, [System.UriKind]::Absolute, [ref]$parsedUrl) -or + $parsedUrl.Scheme -ne "https" -or + [string]::IsNullOrWhiteSpace($parsedUrl.Host)) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be an absolute HTTPS URL." + } + + if ($parsedUrl.Host -ieq "majestic-eel-413.convex.cloud" -or $clientId -eq "dev:majestic-eel-413") { + throw "Production cloud configuration cannot use the Icarus development Convex deployment." + } + + return [ordered]@{ + Environment = "production" + DeploymentUrl = $deploymentUrl + ClientId = $clientId + } +} + function Get-FlutterRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index 2f2ee184..f24df647 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -11,6 +11,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$publishesStable = @($SyncPaths | Where-Object { + (($_ -replace '\\', '/').Trim('/')) -match '^(updates|downloads)/windows/stable($|/)' +}).Count -gt 0 +if ($publishesStable) { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" | Out-Null +} $resolvedSourceDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $SourceDir if (-not (Test-Path $resolvedSourceDir)) { diff --git a/scripts/release_desktop.ps1 b/scripts/release_desktop.ps1 index 7a7e4e97..f4a85d10 100644 --- a/scripts/release_desktop.ps1 +++ b/scripts/release_desktop.ps1 @@ -14,6 +14,8 @@ param( [string]$PagesStageRoot = "release\out\gh-pages", [string]$MetadataDir = "release\metadata", [string]$AppArchiveBaseUrl = "", + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -23,6 +25,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null +Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null if ([string]::IsNullOrWhiteSpace($AppArchiveBaseUrl)) { $AppArchiveBaseUrl = "https://sunkenintime.github.io/icarus/updates/windows/$Channel" @@ -53,7 +61,11 @@ $buildArgs = @( "-AppArchiveBaseUrl", $AppArchiveBaseUrl, "-InitialChangeMessage", - $ChangeMessage + $ChangeMessage, + "-ProductionConvexDeploymentUrl", + $ProductionConvexDeploymentUrl, + "-ProductionConvexClientId", + $ProductionConvexClientId ) if ($Mandatory) { diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 new file mode 100644 index 00000000..aca2b45a --- /dev/null +++ b/scripts/test_release_safety.ps1 @@ -0,0 +1,128 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot + +function Assert-ThrowsContaining { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Action, + [Parameter(Mandatory = $true)] + [string]$ExpectedMessage + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$ExpectedMessage*") { + throw "Expected an error containing '$ExpectedMessage', got: $($_.Exception.Message)" + } + return + } + + throw "Expected an error containing '$ExpectedMessage', but the action succeeded." +} + +function Assert-TextAppearsBefore { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$First, + [Parameter(Mandatory = $true)] + [string]$Second + ) + + $firstIndex = $Text.IndexOf($First) + $secondIndex = $Text.IndexOf($Second) + if ($firstIndex -lt 0 -or $secondIndex -lt 0 -or $firstIndex -gt $secondIndex) { + throw "Expected '$First' to appear before '$Second'." + } +} + +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "production-backend" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "prerelease-desktop" -BranchName "icarus-cloud" | Out-Null +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "icarus-cloud" +} +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "feature/cloud" +} + +$prereleaseConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "prerelease" +if ($prereleaseConfig.Environment -ne "development") { + throw "Prerelease builds must select the development cloud environment." +} +if (-not [string]::IsNullOrWhiteSpace($prereleaseConfig.DeploymentUrl) -or + -not [string]::IsNullOrWhiteSpace($prereleaseConfig.ClientId)) { + throw "Prerelease builds must use the app's named development defaults." +} + +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" +} +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" +} +Assert-ThrowsContaining -ExpectedMessage "absolute HTTPS URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https:production-example" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://majestic-eel-413.convex.cloud/" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "dev:majestic-eel-413" +} + +$productionConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "icarus-production" +if ($productionConfig.Environment -ne "production" -or + $productionConfig.DeploymentUrl -ne "https://production-example.convex.cloud" -or + $productionConfig.ClientId -ne "icarus-production") { + throw "A complete production cloud configuration was not preserved." +} + +$desktopWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-desktop.yml") -Raw +$storeWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-store.yml") -Raw +$productionWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\deploy-convex-production.yml") -Raw + +Assert-TextAppearsBefore -Text $desktopWorkflow -First "Run Release Preflight" -Second "Install FVM" +if ($desktopWorkflow -notmatch 'production-approval:[\s\S]*environment:\s*Production' -or + $desktopWorkflow -notmatch 'needs:\s*production-approval') { + throw "Stable desktop publishing must pass the GitHub Production environment before the build job." +} +Assert-TextAppearsBefore -Text $storeWorkflow -First "Run Store Release Preflight" -Second "Bump Version" +if ($productionWorkflow -notmatch 'environment:\s*Production') { + throw "The production Convex deployment job must use the GitHub Production environment." +} +if ($productionWorkflow -notmatch 'secrets\.CONVEX_PRODUCTION_DEPLOY_KEY') { + throw "The production Convex deployment must read CONVEX_PRODUCTION_DEPLOY_KEY." +} +if ($productionWorkflow -match 'CONVEX_PREVIEW_DEPLOY_KEY') { + throw "The production Convex deployment must never reference the preview deploy key." +} +Assert-TextAppearsBefore -Text $productionWorkflow -First "Check Convex Types" -Second "Deploy Convex Production Backend" +Assert-TextAppearsBefore -Text $productionWorkflow -First "Run Convex Tests" -Second "Deploy Convex Production Backend" + +$currentMetadata = Get-Content (Join-Path $repoRoot "release\metadata\4.6.1+97.json") -Raw | ConvertFrom-Json +if (@($currentMetadata.channels) -contains "stable") { + throw "The current online-beta metadata must not claim the stable channel." +} +if (@($currentMetadata.channels) -notcontains "prerelease") { + throw "The current online-beta metadata must include the prerelease channel." +} + +Write-Host "Release safety checks passed." -ForegroundColor Green diff --git a/test/cloud_build_config_test.dart b/test/cloud_build_config_test.dart new file mode 100644 index 00000000..d1287f30 --- /dev/null +++ b/test/cloud_build_config_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/config/cloud_build_config.dart'; + +void main() { + group('CloudBuildConfig', () { + test('debug builds default to the development environment', () { + final config = CloudBuildConfig.forBuild(isReleaseMode: false); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('release builds require an intentional environment', () { + expect( + () => CloudBuildConfig.forBuild(isReleaseMode: true), + throwsStateError, + ); + }); + + test('release builds may intentionally select development', () { + final config = CloudBuildConfig.forBuild( + isReleaseMode: true, + environment: 'development', + ); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('development uses the named development deployment by default', () { + final config = CloudBuildConfig.resolve(environment: 'development'); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + expect(config.clientId, developmentConvexClientId); + }); + + test('development overrides must be supplied as a pair', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'development', + deploymentUrl: 'https://custom-dev.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production requires an explicit deployment URL and client ID', () { + expect( + () => CloudBuildConfig.resolve(environment: 'production'), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production rejects the development deployment', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: '$developmentConvexDeploymentUrl/', + clientId: 'icarus-production', + ), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: developmentConvexClientId, + ), + throwsStateError, + ); + }); + + test('production accepts a complete non-development configuration', () { + final config = CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: 'icarus-production', + ); + + expect(config.environment, 'production'); + expect(config.deploymentUrl, 'https://production-example.convex.cloud'); + expect(config.clientId, 'icarus-production'); + }); + + test('unknown environments fail closed', () { + expect( + () => CloudBuildConfig.resolve(environment: 'staging'), + throwsStateError, + ); + }); + }); +} From 75660c19d2db9acda6879db39b025746b3686399 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 22:11:18 -0400 Subject: [PATCH 2/5] fix(release): guard full Pages publishes --- scripts/common_release.ps1 | 26 ++++++++++++++++++++++++++ scripts/publish_pages_branch.ps1 | 6 ++---- scripts/test_release_safety.ps1 | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 138392c8..07d7149e 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -131,6 +131,32 @@ function Resolve-CloudBuildConfiguration { } } +function Test-PublishesStablePages { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + [Parameter()] + [string[]]$SyncPaths = @() + ) + + $explicitStablePath = @($SyncPaths | Where-Object { + (($_ -replace '\\', '/').Trim('/')) -match '^(updates|downloads)/windows/stable($|/)' + }).Count -gt 0 + if ($explicitStablePath) { + return $true + } + + if ($SyncPaths.Count -gt 0) { + return $false + } + + $stableRoots = @( + (Join-Path $SourceDirectory "updates\windows\stable"), + (Join-Path $SourceDirectory "downloads\windows\stable") + ) + return @($stableRoots | Where-Object { Test-Path -LiteralPath $_ }).Count -gt 0 +} + function Get-FlutterRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index f24df647..d14085d2 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -11,13 +11,11 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot -$publishesStable = @($SyncPaths | Where-Object { - (($_ -replace '\\', '/').Trim('/')) -match '^(updates|downloads)/windows/stable($|/)' -}).Count -gt 0 +$resolvedSourceDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $SourceDir +$publishesStable = Test-PublishesStablePages -SourceDirectory $resolvedSourceDir -SyncPaths $SyncPaths if ($publishesStable) { Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" | Out-Null } -$resolvedSourceDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $SourceDir if (-not (Test-Path $resolvedSourceDir)) { throw "Pages source directory not found at $resolvedSourceDir" diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 index aca2b45a..93d61d53 100644 --- a/scripts/test_release_safety.ps1 +++ b/scripts/test_release_safety.ps1 @@ -54,6 +54,26 @@ Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "feature/cloud" } +if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/stable")) { + throw "An explicit stable updater path must require the stable branch guard." +} +if (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/prerelease") { + throw "An explicit prerelease updater path must remain available on feature branches." +} + +$pagesFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-release-safety-" + [guid]::NewGuid().ToString("N")) +try { + New-Item -ItemType Directory -Force -Path (Join-Path $pagesFixtureRoot "downloads\windows\stable") | Out-Null + if (-not (Test-PublishesStablePages -SourceDirectory $pagesFixtureRoot)) { + throw "A full-directory publish containing stable downloads must require the stable branch guard." + } +} +finally { + if (Test-Path -LiteralPath $pagesFixtureRoot) { + Remove-Item -LiteralPath $pagesFixtureRoot -Recurse -Force + } +} + $prereleaseConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "prerelease" if ($prereleaseConfig.Environment -ne "development") { throw "Prerelease builds must select the development cloud environment." From ac4c2812096701de1bc956e5b49b473788766085 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 22:25:11 -0400 Subject: [PATCH 3/5] fix(ci): treat deploy confirmation as data --- .github/workflows/deploy-convex-production.yml | 4 +++- scripts/test_release_safety.ps1 | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-convex-production.yml b/.github/workflows/deploy-convex-production.yml index 7d58ce29..cc2afc85 100644 --- a/.github/workflows/deploy-convex-production.yml +++ b/.github/workflows/deploy-convex-production.yml @@ -19,12 +19,14 @@ jobs: steps: - name: Guard Production Deploy shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} run: | if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then echo "The production Convex backend can only deploy from branch main. Current ref: $GITHUB_REF" exit 1 fi - if [[ "${{ inputs.confirmation }}" != "deploy-production" ]]; then + if [[ "$CONFIRMATION" != "deploy-production" ]]; then echo "Confirmation must be exactly: deploy-production" exit 1 fi diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 index 93d61d53..890f398d 100644 --- a/scripts/test_release_safety.ps1 +++ b/scripts/test_release_safety.ps1 @@ -134,6 +134,13 @@ if ($productionWorkflow -notmatch 'secrets\.CONVEX_PRODUCTION_DEPLOY_KEY') { if ($productionWorkflow -match 'CONVEX_PREVIEW_DEPLOY_KEY') { throw "The production Convex deployment must never reference the preview deploy key." } +if ($productionWorkflow -notmatch 'CONFIRMATION:\s*\$\{\{\s*inputs\.confirmation\s*\}\}' -or + $productionWorkflow -notmatch 'if \[\[ "\$CONFIRMATION" != "deploy-production" \]\]') { + throw "The production confirmation must enter Bash through the environment and remain data." +} +if ($productionWorkflow -match 'if \[\[ "\$\{\{\s*inputs\.confirmation') { + throw "The production confirmation must never be interpolated directly into Bash source." +} Assert-TextAppearsBefore -Text $productionWorkflow -First "Check Convex Types" -Second "Deploy Convex Production Backend" Assert-TextAppearsBefore -Text $productionWorkflow -First "Run Convex Tests" -Second "Deploy Convex Production Backend" From d9200fa36bae53963457c8b827aa4ede63ad8383 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 22:44:24 -0400 Subject: [PATCH 4/5] fix(release): require canonical Convex production URL --- docs/release_process.md | 6 ++++++ lib/config/cloud_build_config.dart | 28 ++++++++++++++++++++++++++-- scripts/common_release.ps1 | 5 +++++ scripts/test_release_safety.ps1 | 15 +++++++++++++++ test/cloud_build_config_test.dart | 19 +++++++++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/release_process.md b/docs/release_process.md index c1491e94..1d9d86dd 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -52,6 +52,12 @@ release scripts pass them to Flutter through a temporary Dart-defines file and delete that file after the build. A missing value, invalid URL, or the known development deployment stops the release before Flutter runs. +Use the deployment's canonical `https://.convex.cloud` client URL. +The release validator does not accept custom domains, and `.convex.site` is the +HTTP Actions URL rather than the client deployment URL. See Convex's +[deployment URL guide](https://docs.convex.dev/client/react/deployment-urls) +and [system environment URL definitions](https://docs.convex.dev/production/environment-variables). + Stable desktop, Store, and production backend workflows all enter the protected GitHub `Production` environment before they can build or publish. Desktop prerelease skips that environment and remains available on feature branches. diff --git a/lib/config/cloud_build_config.dart b/lib/config/cloud_build_config.dart index 86922c49..5d1d2597 100644 --- a/lib/config/cloud_build_config.dart +++ b/lib/config/cloud_build_config.dart @@ -2,6 +2,9 @@ const String developmentConvexDeploymentUrl = 'https://majestic-eel-413.convex.cloud'; const String developmentConvexClientId = 'dev:majestic-eel-413'; const String _developmentConvexHost = 'majestic-eel-413.convex.cloud'; +final RegExp _convexDeploymentHost = RegExp( + r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud$', +); const String _compiledCloudEnvironment = String.fromEnvironment( 'ICARUS_CLOUD_ENVIRONMENT', @@ -93,8 +96,10 @@ class CloudBuildConfig { 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID values.', ); } - _requireHttpsUrl(resolvedDeploymentUrl); - if (Uri.parse(resolvedDeploymentUrl).host == _developmentConvexHost || + final productionUri = _requireProductionDeploymentUrl( + resolvedDeploymentUrl, + ); + if (productionUri.host == _developmentConvexHost || resolvedClientId == developmentConvexClientId) { throw StateError( 'Production cloud builds cannot use the Icarus development ' @@ -120,4 +125,23 @@ class CloudBuildConfig { throw StateError('Convex deployment URL must be an absolute HTTPS URL.'); } } + + static Uri _requireProductionDeploymentUrl(String value) { + final uri = Uri.tryParse(value); + final hasCanonicalOrigin = uri != null && + uri.scheme == 'https' && + _convexDeploymentHost.hasMatch(uri.host.toLowerCase()) && + uri.userInfo.isEmpty && + !uri.hasPort && + (uri.path.isEmpty || uri.path == '/') && + !uri.hasQuery && + !uri.hasFragment; + if (!hasCanonicalOrigin) { + throw StateError( + 'Production Convex deployment URL must be a canonical ' + 'https://.convex.cloud URL.', + ); + } + return uri; + } } diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 07d7149e..2c9bb33a 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -120,6 +120,11 @@ function Resolve-CloudBuildConfiguration { throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be an absolute HTTPS URL." } + $hasCanonicalConvexOrigin = $deploymentUrl -match '^https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud/?$' + if (-not $hasCanonicalConvexOrigin) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be a canonical https://.convex.cloud URL." + } + if ($parsedUrl.Host -ieq "majestic-eel-413.convex.cloud" -or $clientId -eq "dev:majestic-eel-413") { throw "Production cloud configuration cannot use the Icarus development Convex deployment." } diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 index 890f398d..1bd074c1 100644 --- a/scripts/test_release_safety.ps1 +++ b/scripts/test_release_safety.ps1 @@ -95,6 +95,21 @@ Assert-ThrowsContaining -ExpectedMessage "absolute HTTPS URL" -Action { -ProductionConvexDeploymentUrl "https:production-example" ` -ProductionConvexClientId "icarus-production" } +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.invalid" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.site" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud/api" ` + -ProductionConvexClientId "icarus-production" +} Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` -ProductionConvexDeploymentUrl "https://majestic-eel-413.convex.cloud/" ` diff --git a/test/cloud_build_config_test.dart b/test/cloud_build_config_test.dart index d1287f30..d699c9b3 100644 --- a/test/cloud_build_config_test.dart +++ b/test/cloud_build_config_test.dart @@ -78,6 +78,25 @@ void main() { ); }); + test('production requires a canonical Convex deployment URL', () { + for (final invalidUrl in [ + 'https://production-example.invalid', + 'https://production-example.convex.site', + 'https://production-example.convex.cloud.example.com', + 'https://production-example.convex.cloud/api', + ]) { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: invalidUrl, + clientId: 'icarus-production', + ), + throwsStateError, + reason: invalidUrl, + ); + } + }); + test('production accepts a complete non-development configuration', () { final config = CloudBuildConfig.resolve( environment: 'production', From e6de85236c0f01e15c563f3328fc79737fc5d95d Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 3 Sep 2026 23:32:49 -0400 Subject: [PATCH 5/5] fix(release): guard overlapping Pages paths --- scripts/common_release.ps1 | 67 ++++++++++++++++++++++++++++---- scripts/publish_pages_branch.ps1 | 21 +++++++--- scripts/test_release_safety.ps1 | 26 ++++++++++++- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 2c9bb33a..b1303674 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -144,14 +144,20 @@ function Test-PublishesStablePages { [string[]]$SyncPaths = @() ) - $explicitStablePath = @($SyncPaths | Where-Object { - (($_ -replace '\\', '/').Trim('/')) -match '^(updates|downloads)/windows/stable($|/)' - }).Count -gt 0 - if ($explicitStablePath) { - return $true - } - if ($SyncPaths.Count -gt 0) { + $stableRoots = @( + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "updates/windows/stable"), + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "downloads/windows/stable") + ) + + foreach ($syncPath in $SyncPaths) { + $selectedPath = Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath $syncPath + foreach ($stableRoot in $stableRoots) { + if ((Test-ReleasePathsOverlap -FirstPath $selectedPath -SecondPath $stableRoot)) { + return $true + } + } + } return $false } @@ -162,6 +168,53 @@ function Test-PublishesStablePages { return @($stableRoots | Where-Object { Test-Path -LiteralPath $_ }).Count -gt 0 } +function Resolve-PagesSyncPath { + param( + [Parameter(Mandatory = $true)] + [string]$RootDirectory, + [Parameter(Mandatory = $true)] + [string]$SyncPath + ) + + if ([string]::IsNullOrWhiteSpace($SyncPath)) { + throw "Pages sync paths cannot be empty. Omit SyncPaths to publish the full source directory." + } + if ([System.IO.Path]::IsPathRooted($SyncPath)) { + throw "Pages sync path '$SyncPath' must be relative to the Pages source directory." + } + + $rootPath = [System.IO.Path]::GetFullPath($RootDirectory) + $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $rootPath $SyncPath)) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $rootPrefix = if ($rootPath.EndsWith($separator)) { $rootPath } else { "$rootPath$separator" } + $isRoot = [string]::Equals($resolvedPath, $rootPath, [System.StringComparison]::OrdinalIgnoreCase) + $isChild = $resolvedPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $isRoot -and -not $isChild) { + throw "Pages sync path '$SyncPath' must stay within the Pages source directory." + } + + return $resolvedPath +} + +function Test-ReleasePathsOverlap { + param( + [Parameter(Mandatory = $true)] + [string]$FirstPath, + [Parameter(Mandatory = $true)] + [string]$SecondPath + ) + + $first = [System.IO.Path]::GetFullPath($FirstPath) + $second = [System.IO.Path]::GetFullPath($SecondPath) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $firstPrefix = if ($first.EndsWith($separator)) { $first } else { "$first$separator" } + $secondPrefix = if ($second.EndsWith($separator)) { $second } else { "$second$separator" } + + return [string]::Equals($first, $second, [System.StringComparison]::OrdinalIgnoreCase) -or + $first.StartsWith($secondPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + $second.StartsWith($firstPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + function Get-FlutterRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index d14085d2..e67858af 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -71,19 +71,28 @@ try { if ($SyncPaths.Count -gt 0) { foreach ($syncPath in $SyncPaths) { - $sourcePath = Join-Path $resolvedSourceDir $syncPath - $targetPath = Join-Path $tempRoot $syncPath + $sourcePath = Resolve-PagesSyncPath -RootDirectory $resolvedSourceDir -SyncPath $syncPath + $targetPath = Resolve-PagesSyncPath -RootDirectory $tempRoot -SyncPath $syncPath + + if ([string]::Equals( + $targetPath, + [System.IO.Path]::GetFullPath($tempRoot), + [System.StringComparison]::OrdinalIgnoreCase + )) { + Copy-Item -Path (Join-Path $resolvedSourceDir "*") -Destination $tempRoot -Recurse -Force + continue + } - if (Test-Path $targetPath) { - Remove-Item -Path $targetPath -Recurse -Force + if (Test-Path -LiteralPath $targetPath) { + Remove-Item -LiteralPath $targetPath -Recurse -Force } - if (Test-Path $sourcePath) { + if (Test-Path -LiteralPath $sourcePath) { $targetParent = Split-Path -Parent $targetPath if (-not [string]::IsNullOrWhiteSpace($targetParent)) { New-Item -ItemType Directory -Force -Path $targetParent | Out-Null } - Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force + Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Recurse -Force } } } diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 index 1bd074c1..db013f91 100644 --- a/scripts/test_release_safety.ps1 +++ b/scripts/test_release_safety.ps1 @@ -57,8 +57,30 @@ Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/stable")) { throw "An explicit stable updater path must require the stable branch guard." } -if (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/prerelease") { - throw "An explicit prerelease updater path must remain available on feature branches." +foreach ($stableSelection in @( + ".", + "updates/windows", + "downloads/windows", + "updates/windows/stable/4.6.1+97", + "updates/windows/prerelease/../stable" +)) { + if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $stableSelection)) { + throw "Pages selection '$stableSelection' must require the stable branch guard." + } +} +foreach ($prereleaseSelection in @( + "updates/windows/prerelease", + "downloads/windows/prerelease" +)) { + if (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $prereleaseSelection) { + throw "Prerelease-only Pages selection '$prereleaseSelection' must remain available on feature branches." + } +} +Assert-ThrowsContaining -ExpectedMessage "must stay within the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths ".." +} +Assert-ThrowsContaining -ExpectedMessage "must be relative to the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $repoRoot } $pagesFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-release-safety-" + [guid]::NewGuid().ToString("N"))