@moifort/stack 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- package/templates/ios/scripts/testflight.ts +213 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_SLUG}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"scripts": {
|
|
5
|
+
"prepare": "bun run generate:assets && nitro prepare",
|
|
6
|
+
"build": "bun run generate:assets && nitro build",
|
|
7
|
+
"dev": "bun run generate:assets && nitro dev --host 0.0.0.0",
|
|
8
|
+
"typecheck": "bunx nitro prepare && bunx tsc --noEmit",
|
|
9
|
+
"test": "bun run generate:assets && bun test",
|
|
10
|
+
"test:unit": "bun run generate:assets && bun test .unit.test",
|
|
11
|
+
"test:int": "bun run generate:assets && bun test .int.test",
|
|
12
|
+
"test:feat": "bun run generate:assets && bun test .feat.test",
|
|
13
|
+
"test:coverage": "bun run generate:assets && bun test --coverage",
|
|
14
|
+
"lint": "bunx biome check",
|
|
15
|
+
"lint:fix": "bunx biome check --write",
|
|
16
|
+
"preflight": "bun run typecheck && bun run lint && bun run test:unit && bun run test:int && bun run test:feat",
|
|
17
|
+
"generate:assets": "bun scripts/generate-changelog-asset.ts",
|
|
18
|
+
"generate:graphql": "bun scripts/export-graphql-schema.ts",
|
|
19
|
+
"generate": "bun run generate:assets && bun run generate:graphql",
|
|
20
|
+
"deprecations": "bun scripts/deprecations.ts",
|
|
21
|
+
"schema:check": "graphql-inspector diff \"git:${SCHEMA_BASE:-origin/main}:shared/schema.graphql\" shared/schema.graphql --rule suppressRemovalOfDeprecatedField --rule ./scripts/deprecations/inspector-rule.ts",
|
|
22
|
+
"stack:check": "bunx stack check",
|
|
23
|
+
"infra:build": "bun install --frozen-lockfile && bunx nitro prepare && bun run generate:graphql && bun run build",
|
|
24
|
+
"infra:init": "cd infra && ../scripts/tf init",
|
|
25
|
+
"infra:plan": "bun run infra:build && cd infra && ../scripts/tf plan"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@biomejs/biome": "2.5.15",
|
|
29
|
+
"@graphql-inspector/cli": "7.0.2",
|
|
30
|
+
"@moifort/stack": "{{STACK_VERSION}}",
|
|
31
|
+
"@types/bun": "1.4.3",
|
|
32
|
+
"@types/lodash-es": "4.17.12",
|
|
33
|
+
"@types/node": "26.6.5",
|
|
34
|
+
"bun-types": "1.4.3",
|
|
35
|
+
"h3": "1.15.11",
|
|
36
|
+
"nitropack": "2.13.5",
|
|
37
|
+
"ts-brand": "0.2.0",
|
|
38
|
+
"typescript": "7.0.2"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@apollo/server": "5.5.1",
|
|
42
|
+
"@pothos/core": "4.15.1",
|
|
43
|
+
"@sentry/node": "11.8.0",
|
|
44
|
+
"consola": "3.4.2",
|
|
45
|
+
"firebase-admin": "13.10.0",
|
|
46
|
+
"graphql": "16.14.2",
|
|
47
|
+
"lodash-es": "4.18.1",
|
|
48
|
+
"ts-pattern": "5.9.2",
|
|
49
|
+
"zod": "4.6.7"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GraphQLSchema } from 'graphql'
|
|
2
|
+
|
|
3
|
+
type Change = {
|
|
4
|
+
type: string
|
|
5
|
+
path?: string
|
|
6
|
+
message: string
|
|
7
|
+
criticality: { level: string; reason?: string }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** graphql-inspector's `suppressRemovalOfDeprecatedField` spares a removed field
|
|
11
|
+
* that was deprecated, not a removed argument. This rule does the same for
|
|
12
|
+
* arguments, so the deprecation routine can retire one without a red run. */
|
|
13
|
+
export default ({ changes, oldSchema }: { changes: Change[]; oldSchema: GraphQLSchema }) =>
|
|
14
|
+
changes.map((change) => {
|
|
15
|
+
if (change.type !== 'FIELD_ARGUMENT_REMOVED' || !change.path) return change
|
|
16
|
+
const [typeName, fieldName, argumentName] = change.path.split('.')
|
|
17
|
+
const type = oldSchema.getType(typeName)
|
|
18
|
+
const field = type && 'getFields' in type ? type.getFields()[fieldName] : undefined
|
|
19
|
+
const argument =
|
|
20
|
+
field && 'args' in field ? field.args.find((arg) => arg.name === argumentName) : undefined
|
|
21
|
+
if (argument?.deprecationReason == null) return change
|
|
22
|
+
return {
|
|
23
|
+
...change,
|
|
24
|
+
criticality: {
|
|
25
|
+
level: 'DANGEROUS',
|
|
26
|
+
reason: 'Removing a deprecated argument is a step of the deprecation routine',
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** How long a build that no longer asks for an element must have been on the
|
|
2
|
+
* App Store before the builds that still ask are sent to update: iOS automatic
|
|
3
|
+
* updates have reached nearly everyone by then. */
|
|
4
|
+
export const GRACE_DAYS = 14
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
/** The build on sale on the App Store, and the day it was released. */
|
|
9
|
+
export type LiveBuild = { build: number; releasedOn: Date }
|
|
10
|
+
|
|
11
|
+
/** The first build that no longer asks for the element — `0` when no build ever
|
|
12
|
+
* did — or `still-asked` when an operation of the app still selects it. */
|
|
13
|
+
export type Since = number | 'still-asked'
|
|
14
|
+
|
|
15
|
+
export type Status =
|
|
16
|
+
| { kind: 'still-asked' }
|
|
17
|
+
| { kind: 'removable' }
|
|
18
|
+
| { kind: 'in-grace'; until: Date }
|
|
19
|
+
| { kind: 'waiting-release' }
|
|
20
|
+
| { kind: 'unknown' }
|
|
21
|
+
|
|
22
|
+
export const statusOf = ({
|
|
23
|
+
since,
|
|
24
|
+
live,
|
|
25
|
+
today,
|
|
26
|
+
}: {
|
|
27
|
+
since: Since
|
|
28
|
+
live: LiveBuild | undefined | 'unknown'
|
|
29
|
+
today: Date
|
|
30
|
+
}): Status => {
|
|
31
|
+
if (since === 'still-asked') return { kind: 'still-asked' }
|
|
32
|
+
if (since === 0) return { kind: 'removable' }
|
|
33
|
+
if (live === 'unknown') return { kind: 'unknown' }
|
|
34
|
+
if (!live || live.build < since) return { kind: 'waiting-release' }
|
|
35
|
+
const until = new Date(live.releasedOn.getTime() + GRACE_DAYS * DAY_MS)
|
|
36
|
+
return until <= today ? { kind: 'removable' } : { kind: 'in-grace', until }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The floor the removal commit sets: the highest build among the elements
|
|
40
|
+
* that can go, never below the floor already in force. */
|
|
41
|
+
export const proposedFloorOf = (
|
|
42
|
+
rows: readonly { since: Since; status: Status }[],
|
|
43
|
+
current: number,
|
|
44
|
+
): number =>
|
|
45
|
+
Math.max(
|
|
46
|
+
current,
|
|
47
|
+
...rows.flatMap((row) =>
|
|
48
|
+
row.status.kind === 'removable' && typeof row.since === 'number' ? [row.since] : [],
|
|
49
|
+
),
|
|
50
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { proposedFloorOf, statusOf } from './status'
|
|
3
|
+
|
|
4
|
+
const today = new Date('2026-11-20')
|
|
5
|
+
const live = { build: 1500, releasedOn: new Date('2026-11-01') }
|
|
6
|
+
|
|
7
|
+
describe('whether a deprecated element can go', () => {
|
|
8
|
+
test('not while an operation of the app still asks for it', () => {
|
|
9
|
+
expect(statusOf({ since: 'still-asked', live, today })).toEqual({ kind: 'still-asked' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('at once when no build ever asked for it', () => {
|
|
13
|
+
expect(statusOf({ since: 0, live: undefined, today })).toEqual({ kind: 'removable' })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('once a build without it has been on the App Store for two weeks', () => {
|
|
17
|
+
expect(statusOf({ since: 1400, live, today })).toEqual({ kind: 'removable' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('not before the two weeks are over', () => {
|
|
21
|
+
expect(statusOf({ since: 1400, live, today: new Date('2026-11-10') })).toEqual({
|
|
22
|
+
kind: 'in-grace',
|
|
23
|
+
until: new Date('2026-11-15'),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('not while the App Store still sells a build that asks for it', () => {
|
|
28
|
+
expect(statusOf({ since: 1600, live, today })).toEqual({ kind: 'waiting-release' })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('not before anything is on the App Store', () => {
|
|
32
|
+
expect(statusOf({ since: 1400, live: undefined, today })).toEqual({ kind: 'waiting-release' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('is unknown without App Store Connect credentials', () => {
|
|
36
|
+
expect(statusOf({ since: 1400, live: 'unknown', today })).toEqual({ kind: 'unknown' })
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('the proposed floor', () => {
|
|
41
|
+
test('is the highest build among the removable elements', () => {
|
|
42
|
+
expect(
|
|
43
|
+
proposedFloorOf(
|
|
44
|
+
[
|
|
45
|
+
{ since: 1400, status: { kind: 'removable' } },
|
|
46
|
+
{ since: 1450, status: { kind: 'removable' } },
|
|
47
|
+
{ since: 1600, status: { kind: 'waiting-release' } },
|
|
48
|
+
],
|
|
49
|
+
1,
|
|
50
|
+
),
|
|
51
|
+
).toBe(1450)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('never goes below the current floor', () => {
|
|
55
|
+
expect(proposedFloorOf([{ since: 0, status: { kind: 'removable' } }], 1200)).toBe(1200)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type GraphQLSchema,
|
|
3
|
+
isEnumType,
|
|
4
|
+
isInputObjectType,
|
|
5
|
+
isInterfaceType,
|
|
6
|
+
isObjectType,
|
|
7
|
+
parse,
|
|
8
|
+
TypeInfo,
|
|
9
|
+
visit,
|
|
10
|
+
visitWithTypeInfo,
|
|
11
|
+
} from 'graphql'
|
|
12
|
+
|
|
13
|
+
/** A deprecated element of the schema, named by its schema coordinate. Only a
|
|
14
|
+
* field or an argument can be traced through the app's operations: an input
|
|
15
|
+
* field is filled from Swift through a variable, and an enum value travels in
|
|
16
|
+
* responses, so neither shows in the `.graphql` files. */
|
|
17
|
+
export type DeprecatedElement = { coordinate: string; traceable: boolean }
|
|
18
|
+
|
|
19
|
+
export const deprecatedElementsOf = (schema: GraphQLSchema): DeprecatedElement[] =>
|
|
20
|
+
Object.values(schema.getTypeMap())
|
|
21
|
+
.filter((type) => !type.name.startsWith('__'))
|
|
22
|
+
.flatMap((type): DeprecatedElement[] => {
|
|
23
|
+
if (isObjectType(type) || isInterfaceType(type)) {
|
|
24
|
+
return Object.values(type.getFields()).flatMap((field) => [
|
|
25
|
+
...field.args
|
|
26
|
+
.filter((arg) => arg.deprecationReason != null)
|
|
27
|
+
.map((arg) => ({
|
|
28
|
+
coordinate: `${type.name}.${field.name}(${arg.name}:)`,
|
|
29
|
+
traceable: true,
|
|
30
|
+
})),
|
|
31
|
+
...(field.deprecationReason != null
|
|
32
|
+
? [{ coordinate: `${type.name}.${field.name}`, traceable: true }]
|
|
33
|
+
: []),
|
|
34
|
+
])
|
|
35
|
+
}
|
|
36
|
+
if (isInputObjectType(type)) {
|
|
37
|
+
return Object.values(type.getFields())
|
|
38
|
+
.filter((field) => field.deprecationReason != null)
|
|
39
|
+
.map((field) => ({ coordinate: `${type.name}.${field.name}`, traceable: false }))
|
|
40
|
+
}
|
|
41
|
+
if (isEnumType(type)) {
|
|
42
|
+
return type
|
|
43
|
+
.getValues()
|
|
44
|
+
.filter((value) => value.deprecationReason != null)
|
|
45
|
+
.map((value) => ({ coordinate: `${type.name}.${value.name}`, traceable: false }))
|
|
46
|
+
}
|
|
47
|
+
return []
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** Every field and argument coordinate the operations select. The files are
|
|
51
|
+
* read as one document, since a fragment may be spread from another file. A
|
|
52
|
+
* name the schema does not know — an old operation read against today's
|
|
53
|
+
* schema — is skipped. */
|
|
54
|
+
export const coordinatesUsedBy = (
|
|
55
|
+
schema: GraphQLSchema,
|
|
56
|
+
documents: readonly string[],
|
|
57
|
+
): Set<string> => {
|
|
58
|
+
const used = new Set<string>()
|
|
59
|
+
const typeInfo = new TypeInfo(schema)
|
|
60
|
+
visit(
|
|
61
|
+
parse(documents.join('\n')),
|
|
62
|
+
visitWithTypeInfo(typeInfo, {
|
|
63
|
+
Field: () => {
|
|
64
|
+
const parent = typeInfo.getParentType()
|
|
65
|
+
const field = typeInfo.getFieldDef()
|
|
66
|
+
if (parent && field) used.add(`${parent.name}.${field.name}`)
|
|
67
|
+
},
|
|
68
|
+
Argument: () => {
|
|
69
|
+
const parent = typeInfo.getParentType()
|
|
70
|
+
const field = typeInfo.getFieldDef()
|
|
71
|
+
const argument = typeInfo.getArgument()
|
|
72
|
+
if (parent && field && argument) {
|
|
73
|
+
used.add(`${parent.name}.${field.name}(${argument.name}:)`)
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
return used
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { buildSchema } from 'graphql'
|
|
3
|
+
import { coordinatesUsedBy, deprecatedElementsOf } from './usage'
|
|
4
|
+
|
|
5
|
+
const schema = buildSchema(`
|
|
6
|
+
type Query {
|
|
7
|
+
library(rated: Boolean @deprecated(reason: "gone"), favorite: Boolean): [Book!]!
|
|
8
|
+
book(id: ID!): Book
|
|
9
|
+
}
|
|
10
|
+
type Mutation { addBook(input: BookInput!): Book! }
|
|
11
|
+
type Book {
|
|
12
|
+
id: ID!
|
|
13
|
+
title: String!
|
|
14
|
+
pages: Int @deprecated(reason: "gone")
|
|
15
|
+
series: Series
|
|
16
|
+
}
|
|
17
|
+
type Series { id: ID! pages: Int }
|
|
18
|
+
input BookInput { title: String! legacy: String @deprecated(reason: "gone") }
|
|
19
|
+
enum Status { READ TO_READ @deprecated(reason: "gone") }
|
|
20
|
+
`)
|
|
21
|
+
|
|
22
|
+
describe('the deprecated elements of a schema', () => {
|
|
23
|
+
test('are listed by coordinate, and only fields and arguments can be traced', () => {
|
|
24
|
+
expect(deprecatedElementsOf(schema)).toEqual([
|
|
25
|
+
{ coordinate: 'Query.library(rated:)', traceable: true },
|
|
26
|
+
{ coordinate: 'Book.pages', traceable: true },
|
|
27
|
+
{ coordinate: 'BookInput.legacy', traceable: false },
|
|
28
|
+
{ coordinate: 'Status.TO_READ', traceable: false },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('the coordinates an operation uses', () => {
|
|
34
|
+
test('match the exact type, so two types sharing a field name are not confused', () => {
|
|
35
|
+
const used = coordinatesUsedBy(schema, ['query { book(id: "1") { series { pages } } }'])
|
|
36
|
+
|
|
37
|
+
expect(used.has('Series.pages')).toBe(true)
|
|
38
|
+
expect(used.has('Book.pages')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('include the arguments an operation passes', () => {
|
|
42
|
+
const used = coordinatesUsedBy(schema, ['query($r: Boolean) { library(rated: $r) { id } }'])
|
|
43
|
+
|
|
44
|
+
expect(used.has('Query.library(rated:)')).toBe(true)
|
|
45
|
+
expect(used.has('Query.library(favorite:)')).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Apollo iOS lets a fragment live in one file and the query that spreads it in another.
|
|
49
|
+
test('follow a fragment defined in another file', () => {
|
|
50
|
+
const used = coordinatesUsedBy(schema, [
|
|
51
|
+
'query { library { ...Row } }',
|
|
52
|
+
'fragment Row on Book { pages }',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
expect(used.has('Book.pages')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// An old operation may name a field the schema has since lost: it is skipped, not fatal.
|
|
59
|
+
test('ignore what the schema no longer knows', () => {
|
|
60
|
+
const used = coordinatesUsedBy(schema, ['query { book(id: "1") { vanished title } }'])
|
|
61
|
+
|
|
62
|
+
expect(used.has('Book.title')).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the deprecation routine can remove, and the build floor that goes with it — see
|
|
3
|
+
* docs/api-evolution.md.
|
|
4
|
+
*
|
|
5
|
+
* bun scripts/deprecations.ts
|
|
6
|
+
* One row per deprecated element of shared/schema.graphql: the first build that no
|
|
7
|
+
* longer asks for it, and whether it can go. Ends with the proposed
|
|
8
|
+
* MINIMUM_SUPPORTED_IOS_BUILD.
|
|
9
|
+
*
|
|
10
|
+
* bun scripts/deprecations.ts check-floor
|
|
11
|
+
* Fails when MINIMUM_SUPPORTED_IOS_BUILD is above the build on sale on the App Store:
|
|
12
|
+
* a reader would be blocked with no update to install. Run by the Deploy workflow.
|
|
13
|
+
*
|
|
14
|
+
* Reads ASC_KEY_ID, ASC_ISSUER_ID and the key itself from ASC_KEY_P8 (its contents) or
|
|
15
|
+
* ASC_KEY_PATH (a .p8 file). Without them the report still traces the builds and marks
|
|
16
|
+
* every status unknown; check-floor fails unless the floor is still 1.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createPrivateKey, sign } from 'node:crypto'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { buildSchema } from 'graphql'
|
|
22
|
+
import { MINIMUM_SUPPORTED_IOS_BUILD } from '../server/system/app-support'
|
|
23
|
+
import {
|
|
24
|
+
type LiveBuild,
|
|
25
|
+
proposedFloorOf,
|
|
26
|
+
type Since,
|
|
27
|
+
type Status,
|
|
28
|
+
statusOf,
|
|
29
|
+
} from './deprecations/status'
|
|
30
|
+
import { coordinatesUsedBy, deprecatedElementsOf } from './deprecations/usage'
|
|
31
|
+
|
|
32
|
+
const APP_ID = '{{APP_STORE_ID}}'
|
|
33
|
+
const OPERATIONS = 'ios/*.graphql'
|
|
34
|
+
|
|
35
|
+
const git = (...args: string[]): string => {
|
|
36
|
+
const result = Bun.spawnSync(['git', ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
37
|
+
if (result.exitCode !== 0) {
|
|
38
|
+
throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString().trim()}`)
|
|
39
|
+
}
|
|
40
|
+
return result.stdout.toString()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lines = (text: string) => text.split('\n').filter((line) => line.length > 0)
|
|
44
|
+
|
|
45
|
+
/** The app's operations as they stood at a commit. */
|
|
46
|
+
const operationsAt = (commit: string): string[] =>
|
|
47
|
+
lines(git('ls-tree', '-r', '--name-only', commit, '--', 'ios'))
|
|
48
|
+
.filter((path) => path.endsWith('.graphql'))
|
|
49
|
+
.map((path) => git('show', `${commit}:${path}`))
|
|
50
|
+
|
|
51
|
+
const buildOf = (commit: string) => Number(git('rev-list', '--count', commit).trim())
|
|
52
|
+
|
|
53
|
+
// MARK: - App Store Connect
|
|
54
|
+
|
|
55
|
+
const credentials = () => {
|
|
56
|
+
const { ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8, ASC_KEY_PATH } = process.env
|
|
57
|
+
const key = ASC_KEY_P8 || (ASC_KEY_PATH ? readFileSync(ASC_KEY_PATH, 'utf8') : undefined)
|
|
58
|
+
if (!ASC_KEY_ID || !ASC_ISSUER_ID || !key) return undefined
|
|
59
|
+
return { keyId: ASC_KEY_ID, issuerId: ASC_ISSUER_ID, key }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const apiToken = ({ keyId, issuerId, key }: NonNullable<ReturnType<typeof credentials>>) => {
|
|
63
|
+
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
64
|
+
const now = Math.floor(Date.now() / 1000)
|
|
65
|
+
const unsigned = `${encode({ alg: 'ES256', kid: keyId, typ: 'JWT' })}.${encode({
|
|
66
|
+
iss: issuerId,
|
|
67
|
+
iat: now,
|
|
68
|
+
exp: now + 15 * 60,
|
|
69
|
+
aud: 'appstoreconnect-v1',
|
|
70
|
+
})}`
|
|
71
|
+
const signature = sign('sha256', Buffer.from(unsigned), {
|
|
72
|
+
key: createPrivateKey(key),
|
|
73
|
+
dsaEncoding: 'ieee-p1363',
|
|
74
|
+
})
|
|
75
|
+
return `${unsigned}.${signature.toString('base64url')}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Resource = {
|
|
79
|
+
id: string
|
|
80
|
+
type: string
|
|
81
|
+
attributes: Record<string, unknown>
|
|
82
|
+
relationships?: Record<string, { data?: { id: string } }>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The build on sale on the App Store and the day it was released — the date of
|
|
86
|
+
* its `ios-v<version>` tag, which the release workflow submits for automatic
|
|
87
|
+
* release. Undefined before the first release. */
|
|
88
|
+
const liveBuild = async (): Promise<LiveBuild | undefined | 'unknown'> => {
|
|
89
|
+
const found = credentials()
|
|
90
|
+
if (!found) return 'unknown'
|
|
91
|
+
const response = await fetch(
|
|
92
|
+
`https://api.appstoreconnect.apple.com/v1/apps/${APP_ID}/appStoreVersions` +
|
|
93
|
+
'?filter[appStoreState]=READY_FOR_SALE&filter[platform]=IOS&include=build' +
|
|
94
|
+
'&fields[appStoreVersions]=versionString,build&fields[builds]=version',
|
|
95
|
+
{ headers: { authorization: `Bearer ${apiToken(found)}` } },
|
|
96
|
+
)
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`App Store Connect answered ${response.status}: ${await response.text()}`)
|
|
99
|
+
}
|
|
100
|
+
const { data, included = [] } = (await response.json()) as {
|
|
101
|
+
data: Resource[]
|
|
102
|
+
included?: Resource[]
|
|
103
|
+
}
|
|
104
|
+
const [version] = data
|
|
105
|
+
if (!version) return undefined
|
|
106
|
+
const buildId = version.relationships?.build?.data?.id
|
|
107
|
+
const build = included.find((resource) => resource.type === 'builds' && resource.id === buildId)
|
|
108
|
+
if (!build) throw new Error(`The version on sale lists no build: ${JSON.stringify(version)}`)
|
|
109
|
+
const tag = `ios-v${version.attributes.versionString}`
|
|
110
|
+
const tagged = git('log', '-1', '--format=%cI', `refs/tags/${tag}`).trim()
|
|
111
|
+
return { build: Number(build.attributes.version), releasedOn: new Date(tagged) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// MARK: - Commands
|
|
115
|
+
|
|
116
|
+
const describe = (status: Status): string => {
|
|
117
|
+
switch (status.kind) {
|
|
118
|
+
case 'still-asked':
|
|
119
|
+
return 'still asked by the app'
|
|
120
|
+
case 'removable':
|
|
121
|
+
return 'removable'
|
|
122
|
+
case 'in-grace':
|
|
123
|
+
return `in grace until ${status.until.toISOString().slice(0, 10)}`
|
|
124
|
+
case 'waiting-release':
|
|
125
|
+
return 'waiting for a release'
|
|
126
|
+
case 'unknown':
|
|
127
|
+
return 'unknown (no App Store credentials)'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const report = async () => {
|
|
132
|
+
const schema = buildSchema(readFileSync('shared/schema.graphql', 'utf8'))
|
|
133
|
+
const elements = deprecatedElementsOf(schema)
|
|
134
|
+
const traceable = elements.filter((element) => element.traceable)
|
|
135
|
+
const askedNow = coordinatesUsedBy(schema, operationsAt('HEAD'))
|
|
136
|
+
|
|
137
|
+
// Walk back through the commits that touched the operations until each element
|
|
138
|
+
// is found in use: the newer commit walked just before stopped asking for it.
|
|
139
|
+
const since = new Map<string, Since>()
|
|
140
|
+
for (const { coordinate } of traceable) {
|
|
141
|
+
if (askedNow.has(coordinate)) since.set(coordinate, 'still-asked')
|
|
142
|
+
}
|
|
143
|
+
let newer: string | undefined
|
|
144
|
+
for (const commit of lines(git('log', '--format=%H', 'HEAD', '--', OPERATIONS))) {
|
|
145
|
+
const pending = traceable.filter(({ coordinate }) => !since.has(coordinate))
|
|
146
|
+
if (pending.length === 0) break
|
|
147
|
+
let used: Set<string>
|
|
148
|
+
try {
|
|
149
|
+
used = coordinatesUsedBy(schema, operationsAt(commit))
|
|
150
|
+
} catch (error) {
|
|
151
|
+
process.stderr.write(`Skipping ${commit.slice(0, 7)}, unparsable: ${error}\n`)
|
|
152
|
+
newer = commit
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
for (const { coordinate } of pending) {
|
|
156
|
+
if (used.has(coordinate) && newer) since.set(coordinate, buildOf(newer))
|
|
157
|
+
}
|
|
158
|
+
newer = commit
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const live = await liveBuild()
|
|
162
|
+
const today = new Date()
|
|
163
|
+
const rows = traceable.map(({ coordinate }) => {
|
|
164
|
+
const first = since.get(coordinate) ?? 0
|
|
165
|
+
return { coordinate, since: first, status: statusOf({ since: first, live, today }) }
|
|
166
|
+
})
|
|
167
|
+
const width = Math.max(40, ...elements.map((element) => element.coordinate.length + 2))
|
|
168
|
+
const out = [`${'element'.padEnd(width)}${'no longer asked since'.padEnd(24)}status`]
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
const build = typeof row.since === 'number' ? `build ${row.since}` : '—'
|
|
171
|
+
out.push(`${row.coordinate.padEnd(width)}${build.padEnd(24)}${describe(row.status)}`)
|
|
172
|
+
}
|
|
173
|
+
for (const element of elements.filter((element) => !element.traceable)) {
|
|
174
|
+
out.push(`${element.coordinate.padEnd(width)}${'—'.padEnd(24)}check by hand`)
|
|
175
|
+
}
|
|
176
|
+
if (elements.length === 0) out.push('(nothing is deprecated)')
|
|
177
|
+
out.push(
|
|
178
|
+
'',
|
|
179
|
+
`Live on the App Store: ${
|
|
180
|
+
live === 'unknown'
|
|
181
|
+
? 'unknown (no App Store credentials)'
|
|
182
|
+
: live
|
|
183
|
+
? `build ${live.build}, released ${live.releasedOn.toISOString().slice(0, 10)}`
|
|
184
|
+
: 'nothing yet'
|
|
185
|
+
}`,
|
|
186
|
+
`Proposed MINIMUM_SUPPORTED_IOS_BUILD: ${proposedFloorOf(rows, MINIMUM_SUPPORTED_IOS_BUILD)} ` +
|
|
187
|
+
`(today ${MINIMUM_SUPPORTED_IOS_BUILD})`,
|
|
188
|
+
)
|
|
189
|
+
process.stdout.write(`${out.join('\n')}\n`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const checkFloor = async () => {
|
|
193
|
+
if (MINIMUM_SUPPORTED_IOS_BUILD <= 1) {
|
|
194
|
+
process.stdout.write('The floor is 1: every build is supported.\n')
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
const live = await liveBuild()
|
|
198
|
+
if (live === 'unknown')
|
|
199
|
+
throw new Error('App Store Connect credentials are needed to check the floor')
|
|
200
|
+
if (!live) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`MINIMUM_SUPPORTED_IOS_BUILD is ${MINIMUM_SUPPORTED_IOS_BUILD} but nothing is on the App Store`,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (MINIMUM_SUPPORTED_IOS_BUILD > live.build) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`MINIMUM_SUPPORTED_IOS_BUILD is ${MINIMUM_SUPPORTED_IOS_BUILD}, above build ${live.build} ` +
|
|
208
|
+
'on sale on the App Store: readers would be blocked with no update to install',
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
process.stdout.write(
|
|
212
|
+
`The floor ${MINIMUM_SUPPORTED_IOS_BUILD} is at or below build ${live.build} on sale.\n`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const [command] = process.argv.slice(2)
|
|
217
|
+
try {
|
|
218
|
+
if (command === undefined) await report()
|
|
219
|
+
else if (command === 'check-floor') await checkFloor()
|
|
220
|
+
else throw new Error(`Unknown command ${command}: expected none or check-floor`)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
process.stderr.write(`${error instanceof Error ? error.message : error}\n`)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Standalone script that exports the Pothos schema as SDL into shared/schema.graphql.
|
|
2
|
+
// Usage: bun run generate:graphql
|
|
3
|
+
// Run after any change to a domain's GraphQL types/queries/mutations so that
|
|
4
|
+
// the iOS Apollo codegen picks up the new shape.
|
|
5
|
+
import { printSchema } from 'graphql'
|
|
6
|
+
import { schema } from '../server/domain/shared/graphql/schema'
|
|
7
|
+
|
|
8
|
+
const sdl = printSchema(schema)
|
|
9
|
+
await Bun.write('shared/schema.graphql', sdl)
|
|
10
|
+
|
|
11
|
+
process.stdout.write('Exported GraphQL schema to shared/schema.graphql\n')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const root = resolve(import.meta.dir, '..')
|
|
5
|
+
const target = resolve(root, 'server/system/changelog-content.ts')
|
|
6
|
+
|
|
7
|
+
// CHANGELOG.md is the English source; every other language is served to the app
|
|
8
|
+
// in its own file, picked at request time from `Accept-Language`. Keep this list
|
|
9
|
+
// in sync with SUPPORTED_LANGUAGES in server/domain/shared/language.ts.
|
|
10
|
+
const files: Record<string, string> = {
|
|
11
|
+
en: 'CHANGELOG.md',
|
|
12
|
+
fr: 'CHANGELOG.fr.md',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const byLanguage: Record<string, string> = {}
|
|
16
|
+
for (const [language, file] of Object.entries(files)) {
|
|
17
|
+
const path = resolve(root, file)
|
|
18
|
+
if (!existsSync(path)) continue
|
|
19
|
+
byLanguage[language] = readFileSync(path, 'utf8')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const banner =
|
|
23
|
+
'// Generated from the CHANGELOG.*.md files by scripts/generate-changelog-asset.ts. Do not edit.\n'
|
|
24
|
+
const body = `export const changelogMarkdownByLanguage: Record<string, string> = ${JSON.stringify(byLanguage, null, 2)}\n`
|
|
25
|
+
|
|
26
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
27
|
+
writeFileSync(target, banner + body)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# install-terraform.sh — downloads a pinned, checksum-verified terraform
|
|
4
|
+
# binary into infra/.bin/terraform. Idempotent: a no-op if the target
|
|
5
|
+
# version is already installed.
|
|
6
|
+
#
|
|
7
|
+
# This removes the need for users to install terraform system-wide before
|
|
8
|
+
# running `bun run bootstrap` or `bun run infra:plan|infra:apply|destroy`.
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
TF_VERSION="1.10.0"
|
|
13
|
+
|
|
14
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
15
|
+
BIN_DIR="$REPO_ROOT/infra/.bin"
|
|
16
|
+
TF_BIN="$BIN_DIR/terraform"
|
|
17
|
+
|
|
18
|
+
log() { printf "\033[1;34m▸ %s\033[0m\n" "$*" >&2; }
|
|
19
|
+
fail() { printf "\033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; }
|
|
20
|
+
|
|
21
|
+
# Fast path: already installed at the right version.
|
|
22
|
+
if [[ -x "$TF_BIN" ]] && "$TF_BIN" version 2>/dev/null | head -n1 | grep -q "v${TF_VERSION}"; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
command -v curl >/dev/null 2>&1 || fail "curl is required to download terraform"
|
|
27
|
+
command -v unzip >/dev/null 2>&1 || fail "unzip is required to extract the terraform archive"
|
|
28
|
+
command -v shasum >/dev/null 2>&1 || fail "shasum is required to verify the terraform checksum"
|
|
29
|
+
|
|
30
|
+
case "$(uname -s)" in
|
|
31
|
+
Darwin) OS="darwin" ;;
|
|
32
|
+
Linux) OS="linux" ;;
|
|
33
|
+
*) fail "unsupported OS: $(uname -s)" ;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
36
|
+
case "$(uname -m)" in
|
|
37
|
+
arm64|aarch64) ARCH="arm64" ;;
|
|
38
|
+
x86_64|amd64) ARCH="amd64" ;;
|
|
39
|
+
*) fail "unsupported arch: $(uname -m)" ;;
|
|
40
|
+
esac
|
|
41
|
+
|
|
42
|
+
ARCHIVE="terraform_${TF_VERSION}_${OS}_${ARCH}.zip"
|
|
43
|
+
SUMS="terraform_${TF_VERSION}_SHA256SUMS"
|
|
44
|
+
BASE_URL="https://releases.hashicorp.com/terraform/${TF_VERSION}"
|
|
45
|
+
|
|
46
|
+
log "Installing terraform ${TF_VERSION} (${OS}_${ARCH}) into infra/.bin/"
|
|
47
|
+
|
|
48
|
+
mkdir -p "$BIN_DIR"
|
|
49
|
+
TMP_DIR="$(mktemp -d)"
|
|
50
|
+
trap 'rm -rf "$TMP_DIR"' EXIT
|
|
51
|
+
|
|
52
|
+
curl -fsSL "$BASE_URL/$ARCHIVE" -o "$TMP_DIR/$ARCHIVE"
|
|
53
|
+
curl -fsSL "$BASE_URL/$SUMS" -o "$TMP_DIR/$SUMS"
|
|
54
|
+
|
|
55
|
+
(cd "$TMP_DIR" && grep " $ARCHIVE\$" "$SUMS" | shasum -a 256 -c -) >/dev/null \
|
|
56
|
+
|| fail "terraform checksum verification failed"
|
|
57
|
+
|
|
58
|
+
unzip -q -o "$TMP_DIR/$ARCHIVE" -d "$BIN_DIR"
|
|
59
|
+
chmod +x "$TF_BIN"
|
|
60
|
+
|
|
61
|
+
log "terraform ${TF_VERSION} installed at $TF_BIN"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# tf — thin wrapper that ensures the pinned terraform binary exists,
|
|
4
|
+
# then execs it with the passed arguments. Used by the package.json scripts
|
|
5
|
+
# so `bun run infra:plan|infra:apply|infra:init|destroy` work on a fresh machine.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
10
|
+
"$ROOT/scripts/install-terraform.sh"
|
|
11
|
+
exec "$ROOT/infra/.bin/terraform" "$@"
|