Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dddde80
chore(shorebird): point code push at the Divine org app
NotThatKindOfDrLiz Aug 12, 2026
e273f68
ci(shorebird): cut store releases through Shorebird, add patch workflows
NotThatKindOfDrLiz Aug 12, 2026
57ab834
ci(shorebird): require explicit confirmation before patching
NotThatKindOfDrLiz Aug 12, 2026
910727a
docs(shorebird): document code push setup, constraints, and patch run…
NotThatKindOfDrLiz Aug 13, 2026
f729fb3
ci(shorebird): tighten patch workflow review fixes
NotThatKindOfDrLiz Aug 13, 2026
ae59542
ci(shorebird): harden release and patch safeguards
NotThatKindOfDrLiz Aug 13, 2026
479a5f8
ci: budget CI configuration tests
NotThatKindOfDrLiz Aug 13, 2026
78c8a37
ci(shorebird): block unsafe patch diffs
NotThatKindOfDrLiz Aug 13, 2026
07ec75e
ci(shorebird): use Flutter 3.44.9 engine
NotThatKindOfDrLiz Aug 13, 2026
5124ee1
ci(shorebird): preserve Flutter embedding for Android plugins
NotThatKindOfDrLiz Aug 14, 2026
3ac15ba
ci(shorebird): remove plugin Flutter jar dependency
NotThatKindOfDrLiz Aug 14, 2026
784ba72
ci(shorebird): keep Flutter jar dependency out of release
NotThatKindOfDrLiz Aug 14, 2026
67cec33
ci(shorebird): fix caption generator debug test classpath
NotThatKindOfDrLiz Aug 14, 2026
2a91d9f
ci(shorebird): harden release preflight
NotThatKindOfDrLiz Aug 16, 2026
475c47e
ci(flutter): align workflow SDK pins
NotThatKindOfDrLiz Aug 17, 2026
e0bc138
ci(shorebird): close release hardening gaps
NotThatKindOfDrLiz Aug 17, 2026
a153223
ci(flutter): align post-rebase workflow pins
NotThatKindOfDrLiz Aug 17, 2026
d3cf2cc
docs(shorebird): record privacy disclosures
NotThatKindOfDrLiz Aug 17, 2026
ffb1ca8
ci(shorebird): harden cached release workflows
NotThatKindOfDrLiz Aug 18, 2026
a46851e
fix(shorebird): reject closed iOS release trains
NotThatKindOfDrLiz Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Generic Flutter and Dart standards live in `.claude/rules/`:
## Project Rules

- Most app work happens in `mobile/`. Key entry points are `mobile/lib/main.dart` and `mobile/lib/router/app_router.dart`.
- Use current code plus focused docs as source of truth: `docs/STATE_MANAGEMENT.md`, `docs/BLOC_UI_MIGRATION_PRD.md`, `docs/NOSTR_EVENT_TYPES.md`, `mobile/docs/NOSTR_VIDEO_EVENTS.md`, `mobile/docs/DESIGN_SYSTEM_COMPONENTS.md`, `mobile/docs/GOLDEN_TESTING_GUIDE.md`, and `mobile/docs/PEOPLE_SEARCH.md`.
- Use current code plus focused docs as source of truth: `docs/STATE_MANAGEMENT.md`, `docs/BLOC_UI_MIGRATION_PRD.md`, `docs/NOSTR_EVENT_TYPES.md`, `mobile/docs/NOSTR_VIDEO_EVENTS.md`, `mobile/docs/DESIGN_SYSTEM_COMPONENTS.md`, `mobile/docs/GOLDEN_TESTING_GUIDE.md`, `mobile/docs/PEOPLE_SEARCH.md`, and `mobile/docs/SHOREBIRD_CODE_PUSH.md`.
- Older docs can drift. If documentation disagrees with code, trust the current implementation, targeted tests, and the newest focused doc.

## Architecture And State
Expand Down
1 change: 1 addition & 0 deletions .github/ci-timing-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"jobs": {
"Detect App CI Scope": { "warn": 40, "fail": 90 },
"Codemagic Variable Groups": { "warn": 30, "fail": 90 },
"CI Configuration Tests": { "warn": 30, "fail": 90 },
"Tests": { "warn": 600, "fail": 780 },
"Generated Files": { "warn": 260, "fail": 420 },
"Analyze": { "warn": 150, "fail": 300 },
Expand Down
244 changes: 237 additions & 7 deletions .github/scripts/tests/test_codemagic_android_build_number.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,258 @@
import re
import subprocess
import unittest
from pathlib import Path


CODEMAGIC_PATH = Path(__file__).resolve().parents[3] / "codemagic.yaml"
MISE_PATH = Path(__file__).resolve().parents[3] / "mobile" / "mise.toml"
MOBILE_CI_PATH = (
Path(__file__).resolve().parents[3] / ".github" / "workflows" / "mobile_ci.yaml"
)
WORKFLOWS_PATH = Path(__file__).resolve().parents[2] / "workflows"
SHOREBIRD_DOC_PATH = (
Path(__file__).resolve().parents[3] / "mobile" / "docs" / "SHOREBIRD_CODE_PUSH.md"
)


class CodemagicAndroidBuildNumberTest(unittest.TestCase):
def test_android_aab_build_uses_google_play_floor(self) -> None:
contents = CODEMAGIC_PATH.read_text()
def setUp(self) -> None:
self.contents = CODEMAGIC_PATH.read_text()
self.mobile_ci_contents = MOBILE_CI_PATH.read_text()
self.mise_contents = MISE_PATH.read_text()
self.shorebird_doc_contents = SHOREBIRD_DOC_PATH.read_text()

def test_codemagic_yaml_parses_with_aliases(self) -> None:
result = subprocess.run(
[
"ruby",
"-e",
(
"require 'yaml'; "
"data = YAML.safe_load(File.read(ARGV[0]), aliases: true); "
"abort 'missing workflows' unless data.fetch('workflows').is_a?(Hash); "
"abort 'missing definitions' unless data.fetch('definitions').is_a?(Hash)"
),
str(CODEMAGIC_PATH),
],
check=False,
text=True,
capture_output=True,
)

self.assertEqual(result.returncode, 0, result.stderr)

def test_android_aab_build_uses_google_play_floor(self) -> None:
self.assertIn(
'google-play get-latest-build-number --package-name "co.openvine.app"',
contents,
self.contents,
)
self.assertIn("BUILD_NUMBER=$PROJECT_BUILD_NUMBER", contents)
self.assertIn("BUILD_NUMBER=$PROJECT_BUILD_NUMBER", self.contents)
self.assertIn(
'NEXT_PLAY_BUILD_NUMBER=$((LATEST_GOOGLE_PLAY_BUILD_NUMBER + 1))',
contents,
self.contents,
)
self.assertIn(
'if [ "$NEXT_PLAY_BUILD_NUMBER" -gt "$BUILD_NUMBER" ]; then',
contents,
self.contents,
)
self.assertNotIn("PROJECT_BUILD_NUMBER + 8", self.contents)

def test_shorebird_supporters_defines_are_optional(self) -> None:
self.assertIn("optional_names = %w[", self.contents)
self.assertIn("SUPPORTERS_API_BASE_URL", self.contents)
self.assertIn("FF_DIVINE_SUPPORTERS", self.contents)
self.assertIn("defines[name] = ENV.fetch(name, '')", self.contents)

def test_shorebird_required_defines_reject_empty_values(self) -> None:
self.assertIn(
"required_names.reject { |name| ENV.key?(name) && !ENV.fetch(name).empty? }",
self.contents,
)

def test_shorebird_install_reuses_only_the_expected_cached_version(self) -> None:
self.assertIn('EXPECTED_SHOREBIRD_VERSION="Shorebird 1.6.117"', self.contents)
self.assertIn('if [ -x "$SHOREBIRD_BIN" ]; then', self.contents)
self.assertIn("bash -s -- --force", self.contents)
self.assertRegex(
self.contents,
r"(?s)&shorebird_install.*?script: \|\n\s+set -euo pipefail\n",
)

def test_shorebird_release_commands_are_signed_and_preflighted(self) -> None:
self.assertIn("*preflight_shorebird_ios_release", self.contents)
Comment thread
NotThatKindOfDrLiz marked this conversation as resolved.
self.assertIn("*preflight_shorebird_android_release", self.contents)
self.assertIn("shorebird releases list --platform=android --json", self.contents)
self.assertIn("shorebird releases list --platform=ios --json", self.contents)
self.assertEqual(2, self.contents.count("shorebird_release_preflight.rb"))
self.assertNotIn("shorebird releases info", self.contents)
self.assertIn("--build-name=$BUILD_NAME", self.contents)
self.assertIn("--public-key-path=build/shorebird/patch_public_key.pem", self.contents)

def test_ios_release_rejects_closed_app_store_version_before_shorebird(self) -> None:
workflow = self._workflow_block("ios-build")
self.assertIn(
"app-store-connect get-latest-app-store-build-number",
self.contents,
)
self.assertIn("--include-version --json", self.contents)
self.assertEqual(1, self.contents.count("ios_store_version_preflight.rb"))
self.assertLess(
workflow.index("- *preflight_shorebird_ios_release"),
workflow.index("- *build_ios"),
)

def test_flutter_pins_match_current_shorebird_engine(self) -> None:
mise_version = re.search(r'^flutter = "([^"]+)"$', self.mise_contents, re.MULTILINE)
self.assertIsNotNone(mise_version)
expected = mise_version.group(1)

mobile_ci_versions = set(
re.findall(r'flutter-version: "([^"]+)"', self.mobile_ci_contents)
)
codemagic_flutter_versions = set(
re.findall(r"^\s+flutter: ([0-9]+\.[0-9]+\.[0-9]+)$", self.contents, re.MULTILINE)
)
self.assertNotIn("PROJECT_BUILD_NUMBER + 8", contents)
shorebird_release_versions = set(
re.findall(r"^\s+FLUTTER_VERSION: ([0-9]+\.[0-9]+\.[0-9]+)$", self.contents, re.MULTILINE)
)

self.assertEqual({expected}, mobile_ci_versions)
self.assertEqual({expected}, codemagic_flutter_versions)
self.assertEqual({expected}, shorebird_release_versions)
stale_workflows = []
for workflow in WORKFLOWS_PATH.glob("*.y*ml"):
workflow_contents = workflow.read_text()
uses_flutter = re.search(
r"flutter_package\.yml|subosito/flutter-action|"
r"\bflutter\s+(?:analyze|build|drive|pub|test)\b",
workflow_contents,
)
if uses_flutter is None:
continue
versions = re.findall(
r"flutter(?:-|_)version:\s*[\"']?([^\"'\s]+)",
workflow_contents,
)
if set(versions) != {expected}:
stale_workflows.append(workflow.name)
self.assertEqual([], stale_workflows)
Comment thread
NotThatKindOfDrLiz marked this conversation as resolved.

def test_shorebird_patch_commands_publish_to_staging_and_are_signed(self) -> None:
self.assertIn(
"shorebird patch android --release-version=${{ inputs.RELEASE_VERSION }} --track=staging",
self.contents,
)
self.assertIn(
"shorebird patch ios --release-version=${{ inputs.RELEASE_VERSION }} --track=staging",
self.contents,
)
self.assertIn("--private-key-path=build/shorebird/patch_private_key.pem", self.contents)
self.assertNotIn("--track=stable", self.contents)

def test_shorebird_patch_workflows_gate_branch_and_release_commit(self) -> None:
self.assertIn('if [ "$CM_BRANCH" != "main" ]; then', self.contents)
self.assertIn("RELEASE_COMMIT:", self.contents)
self.assertIn("git merge-base --is-ancestor", self.contents)
self.assertIn("git diff --name-only", self.contents)
self.assertIn("*prepare_patch_source", self.contents)

def test_shorebird_patch_workflows_block_native_asset_and_dependency_changes(self) -> None:
self.assertIn("BLOCKED_PATCH_PATHS=$(git diff --name-only", self.contents)
self.assertIn("'android/**'", self.contents)
self.assertIn("'ios/**'", self.contents)
self.assertIn("'assets/**'", self.contents)
self.assertIn("'shaders/**'", self.contents)
self.assertIn("'pubspec.yaml'", self.contents)
self.assertIn("'pubspec.lock'", self.contents)
self.assertIn("'packages/**/android/**'", self.contents)
self.assertIn("'packages/**/assets/**'", self.contents)
self.assertIn("'packages/**/shaders/**'", self.contents)
self.assertIn("'packages/**/darwin/**'", self.contents)
self.assertIn("Cut a normal store release instead of a Shorebird patch.", self.contents)
for command in self._shorebird_patch_commands():
self.assertNotRegex(command, r"--allow-native-diffs|--allow-asset-diffs")

def test_store_artifacts_are_built_by_shorebird_release(self) -> None:
self.assertIn("*build_aab", self._workflow_block("android-build"))
self.assertIn("*build_ios", self._workflow_block("ios-build"))
self.assertRegex(self.contents, r"(?m)^\s+shorebird release android ")
self.assertRegex(self.contents, r"(?m)^\s+shorebird release ios ")
self.assertNotRegex(self.contents, r"(?m)^\s+flutter build appbundle ")
self.assertNotRegex(self.contents, r"(?m)^\s+flutter build ipa ")

def test_release_jobs_never_materialize_patch_private_key(self) -> None:
self.assertIn("*write_shorebird_public_key", self._workflow_block("ios-build"))
self.assertIn("*write_shorebird_public_key", self._workflow_block("android-build"))
self.assertNotIn("*write_shorebird_private_key", self._workflow_block("ios-build"))
self.assertNotIn("*write_shorebird_private_key", self._workflow_block("android-build"))

def test_shorebird_cache_is_enabled_for_release_and_patch_workflows(self) -> None:
for workflow in ("ios-build", "android-build", "ios-patch", "android-patch"):
self.assertIn("- $HOME/.shorebird", self._workflow_block(workflow))

def test_shorebird_install_runs_before_ios_dependency_resolution(self) -> None:
for workflow_name in ("ios-build", "ios-patch"):
workflow = self._workflow_block(workflow_name)
self.assertLess(
workflow.index("- *shorebird_install"),
workflow.index("- *prepare_ios_spm_packages"),
)
for workflow_name in ("android-build", "android-patch"):
workflow = self._workflow_block(workflow_name)
self.assertLess(
workflow.index("- *shorebird_install"),
workflow.index("- *setup_local_properties"),
)

def test_shorebird_release_preflight_runs_before_store_build(self) -> None:
for workflow_name, preflight, build in (
("ios-build", "- *preflight_shorebird_ios_release", "- *build_ios"),
("android-build", "- *preflight_shorebird_android_release", "- *build_aab"),
):
workflow = self._workflow_block(workflow_name)
self.assertLess(workflow.index(preflight), workflow.index(build))

def test_shorebird_release_builds_fail_on_missing_preflight_output(self) -> None:
for anchor in ("build_aab", "build_ios"):
self.assertRegex(
self.contents,
rf"(?s)&{anchor}.*?script: \|\n\s+set -euo pipefail\n",
)

def test_runbook_uses_current_patch_promotion_and_reporting(self) -> None:
set_track = (
"shorebird patches set-track --release <version> --patch <n> "
"--track stable"
)
self.assertIn(set_track, self.shorebird_doc_contents)
self.assertLess(
self.shorebird_doc_contents.index(set_track),
self.shorebird_doc_contents.index("shorebird patches promote"),
)
self.assertIn("`shorebird_code_push` is a runtime dependency", self.shorebird_doc_contents)
self.assertIn("Crashlytics", self.shorebird_doc_contents)

def _workflow_block(self, workflow_name: str) -> str:
start = self.contents.index(f" {workflow_name}:")
next_workflow = re.search(r"^ [a-z0-9-]+:", self.contents[start + 1 :], re.MULTILINE)
if next_workflow is None:
return self.contents[start:]
return self.contents[start : start + 1 + next_workflow.start()]

def _shorebird_patch_commands(self) -> list[str]:
commands = []
lines = self.contents.splitlines()
for index, line in enumerate(lines):
if "shorebird patch " not in line:
continue
command_lines = [line]
cursor = index
while command_lines[-1].rstrip().endswith("\\"):
cursor += 1
command_lines.append(lines[cursor])
commands.append("\n".join(command_lines))
return commands


if __name__ == "__main__":
Expand Down
89 changes: 89 additions & 0 deletions .github/scripts/tests/test_ios_store_version_preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import json
import subprocess
import tempfile
import unittest
from pathlib import Path


SCRIPT_PATH = (
Path(__file__).resolve().parents[3]
/ "mobile"
/ "scripts"
/ "ios_store_version_preflight.rb"
)


class IosStoreVersionPreflightTest(unittest.TestCase):
def run_preflight(
self,
payload: dict | list | str,
candidate_version: str = "1.0.21",
) -> subprocess.CompletedProcess[str]:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json") as file:
json.dump(payload, file)
file.flush()
return subprocess.run(
["ruby", str(SCRIPT_PATH), file.name, candidate_version],
check=False,
text=True,
capture_output=True,
)

def test_allows_version_above_latest_approved_version(self) -> None:
result = self.run_preflight(
{"buildId": "build-id", "version": "1.0.20", "buildNumber": "848"}
)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("newer than approved App Store version 1.0.20", result.stdout)

def test_blocks_same_version_as_latest_approved_version(self) -> None:
result = self.run_preflight(
{"buildId": "build-id", "version": "1.0.20", "buildNumber": "848"},
candidate_version="1.0.20",
)

self.assertNotEqual(result.returncode, 0)
self.assertIn("must be newer than approved App Store version 1.0.20", result.stderr)

def test_blocks_version_below_latest_approved_version(self) -> None:
result = self.run_preflight(
{"buildId": "build-id", "version": "1.2.0", "buildNumber": "848"},
candidate_version="1.1.99",
)

self.assertNotEqual(result.returncode, 0)
self.assertIn("must be newer than approved App Store version 1.2.0", result.stderr)

def test_compares_numeric_components_instead_of_lexically(self) -> None:
result = self.run_preflight(
{"buildId": "build-id", "version": "1.9.9", "buildNumber": "848"},
candidate_version="1.10.0",
)

self.assertEqual(result.returncode, 0, result.stderr)

def test_fails_closed_on_missing_version(self) -> None:
result = self.run_preflight({"buildId": "build-id", "buildNumber": "848"})

self.assertNotEqual(result.returncode, 0)
self.assertIn("missing version", result.stderr)

def test_fails_closed_on_invalid_json_shape(self) -> None:
result = self.run_preflight([])

self.assertNotEqual(result.returncode, 0)
self.assertIn("expected a JSON object", result.stderr)

def test_fails_closed_on_invalid_version(self) -> None:
result = self.run_preflight(
{"buildId": "build-id", "version": "1.0.20", "buildNumber": "848"},
candidate_version="next",
)

self.assertNotEqual(result.returncode, 0)
self.assertIn("invalid candidate version", result.stderr)


if __name__ == "__main__":
unittest.main()
Loading
Loading