Skip to content

Commit 3d355c8

Browse files
committed
Merge remote-tracking branch 'origin/code/cash' into feat/you-tab-redesign
* origin/code/cash: fix(core): add \ to escape ' in What's (#1298) feat(shared-core): wrap the framework in a Swift target (#1297) chore: update display name entry title/hint (#1296) ci(shared-core): write a placeholder local.properties before publishing (#1295) build(shared-core): publish the XCFramework to flipcash-shared-core-spm (#1294) refactor(build-logic): extract the KMP test-fixture generator into a convention plugin (#1293) build(base58): declare the base58 lint tasks' dependency on generated fixtures (#1292) test(base58): run the vector gate on Kotlin/Native, not just the JVM (#1289) build(codes): declare the kikcode lint tasks' dependency on generated fixtures (#1288) # Conflicts: # apps/flipcash/core/src/main/res/values/strings.xml
2 parents 9ebf033 + 3912020 commit 3d355c8

22 files changed

Lines changed: 473 additions & 108 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
name: Publish SharedCore
2+
3+
# Cuts a release of the `:kmp:shared-core` XCFramework into
4+
# code-payments/flipcash-shared-core-spm, which is the repo iOS depends on. The
5+
# Kotlin stays here; that repo only ever holds the generated `Package.swift` and
6+
# the release assets it points at.
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
version:
11+
description: 'Version to publish, e.g. 0.1.0 — also the Swift Package tag'
12+
required: true
13+
14+
concurrency:
15+
# Two publishes at once would race on the same tag and release.
16+
group: publish-shared-core
17+
cancel-in-progress: false
18+
19+
env:
20+
CI: true
21+
SPM_REPO: code-payments/flipcash-shared-core-spm
22+
23+
jobs:
24+
publish:
25+
name: Publish SharedCore ${{ inputs.version }}
26+
# Apple targets and `swift package compute-checksum` both need Xcode, so this
27+
# lane can't share the Ubuntu runners the rest of CI uses.
28+
runs-on: macos-15
29+
steps:
30+
- uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 1
33+
34+
- name: Validate Gradle wrapper
35+
uses: gradle/actions/wrapper-validation@v4
36+
37+
- name: Setup Java env
38+
uses: actions/setup-java@v3
39+
with:
40+
java-version: '21'
41+
distribution: 'corretto'
42+
cache: 'gradle'
43+
44+
# A fine-grained PAT with Contents: read & write on the Swift Package repo.
45+
# The job's own GITHUB_TOKEN can't reach another repository, and a deploy
46+
# key can only push git — it can't create the GitHub release the
47+
# XCFramework is uploaded to — so one PAT covers both halves.
48+
- name: Check out the Swift Package repo
49+
uses: actions/checkout@v4
50+
with:
51+
repository: ${{ env.SPM_REPO }}
52+
path: spm-repo
53+
token: ${{ secrets.SHARED_CORE_PUBLISH_TOKEN }}
54+
55+
# Gradle configures every project in the build, and the Android app applies the
56+
# secrets plugin, which fails configuration outright when `local.properties` is
57+
# absent. This lane only builds the KMP module — nothing here compiles the app or
58+
# talks to any of these services — so obviously-fake values are enough to get
59+
# through configuration without handing this workflow the real secrets.
60+
- name: Write placeholder local.properties
61+
run: |
62+
set -euo pipefail
63+
{
64+
echo 'BUGSNAG_API_KEY="00000000000000000000000000000000"'
65+
echo 'GOOGLE_CLOUD_PROJECT_NUMBER=000000000000'
66+
echo 'MIXPANEL_API_KEY="00000000000000000000000000000000"'
67+
echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000'
68+
} > ./local.properties
69+
70+
# The Swift half of the package lives here, next to the Kotlin it wraps, so the
71+
# two move in one commit; the Swift Package repo is a publish target, not a place
72+
# to edit. `Package.swift` has to be in place before Gradle runs, since KMMBridge
73+
# only rewrites the variables block inside it.
74+
- name: Stage the Swift package sources
75+
run: |
76+
set -euo pipefail
77+
rm -rf spm-repo/Sources spm-repo/Tests
78+
cp -R kmp/shared-core/spm/Package.swift kmp/shared-core/spm/Sources kmp/shared-core/spm/Tests spm-repo/
79+
80+
- name: Build the XCFramework, upload it, and update Package.swift
81+
env:
82+
# Gradle reads ORG_GRADLE_PROJECT_-prefixed vars as project properties,
83+
# which keeps the token out of the command line.
84+
ORG_GRADLE_PROJECT_GITHUB_PUBLISH_TOKEN: ${{ secrets.SHARED_CORE_PUBLISH_TOKEN }}
85+
run: |
86+
./gradlew :kmp:shared-core:kmmBridgePublish \
87+
-PENABLE_PUBLISHING=true \
88+
-PsharedCoreVersion=${{ inputs.version }} \
89+
-PspmRepoDir=$GITHUB_WORKSPACE/spm-repo
90+
91+
# The release asset is up by now but the tag still points at the old
92+
# Package.swift, so nothing consumes this build until the next step. Compiling
93+
# the Swift glue against the framework we just uploaded is the last moment a
94+
# mismatch between the two is cheap to fix — after the tag moves, it's a
95+
# published-and-broken version.
96+
- name: Verify the package builds against the uploaded framework
97+
working-directory: spm-repo
98+
run: |
99+
set -euo pipefail
100+
xcodebuild -scheme SharedCore \
101+
-destination 'generic/platform=iOS Simulator' \
102+
-clonedSourcePackagesDirPath "$RUNNER_TEMP/spm-verify" \
103+
-quiet \
104+
build
105+
106+
- name: Commit and tag the Swift Package
107+
working-directory: spm-repo
108+
run: |
109+
set -euo pipefail
110+
git config user.name "github-actions[bot]"
111+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
112+
113+
git add -A Package.swift Sources Tests
114+
if git diff --cached --quiet; then
115+
echo "Nothing to commit — the upload produced the same URL and checksum, and the Swift sources are unchanged."
116+
exit 1
117+
fi
118+
git commit -m "SharedCore ${{ inputs.version }}"
119+
git push origin HEAD:main
120+
121+
# The release upload already created this tag, pointing at whatever
122+
# main was before the commit above — i.e. at a Package.swift that
123+
# doesn't mention this release. Move it onto the commit that does, or
124+
# SPM resolves the version to the previous binary.
125+
git tag -f "${{ inputs.version }}"
126+
git push -f origin "${{ inputs.version }}"
127+
128+
- name: Summary
129+
run: |
130+
{
131+
echo "### SharedCore ${{ inputs.version }} published"
132+
echo
133+
echo "- Release: https://github.com/${SPM_REPO}/releases/tag/${{ inputs.version }}"
134+
echo '- Consume: `.package(url: "https://github.com/'"${SPM_REPO}"'", from: "${{ inputs.version }}")`'
135+
} >> "$GITHUB_STEP_SUMMARY"

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,7 @@
919919
<string name="subtitle_tipIntro">Add your name to receive tips</string>
920920
<string name="action_startReceivingTips">Start Receiving Tips</string>
921921

922-
<string name="title_profileNameSelection">What is your name?</string>
922+
<string name="title_profileNameSelection">What\'s your name?</string>
923923
<string name="subtitle_profileNameSelection">This is how you\'ll appear to others</string>
924924
<string name="hint_profileName">Your Name</string>
925925
<string name="error_title_profileNameNotAllowed">This Name is Not Allowed</string>

‎build-logic/convention/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,9 @@ gradlePlugin {
4545
id = "flipcash.kmp.library"
4646
implementationClass = "KmpLibraryConventionPlugin"
4747
}
48+
register("kmpTestFixtures") {
49+
id = "flipcash.kmp.test.fixtures"
50+
implementationClass = "KmpTestFixturesConventionPlugin"
51+
}
4852
}
4953
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import com.getcode.buildlogic.testfixtures.GenerateTestFixtures
2+
import com.getcode.buildlogic.testfixtures.TestFixturesExtension
3+
import org.gradle.api.Plugin
4+
import org.gradle.api.Project
5+
import org.gradle.kotlin.dsl.configure
6+
import org.gradle.kotlin.dsl.create
7+
import org.gradle.kotlin.dsl.register
8+
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
9+
10+
/**
11+
* Compiles a KMP module's `src/commonTest/resources` into a generated `TestFixtures.kt` on
12+
* `commonTest`, so the same fixtures are readable from every target (Kotlin/Native test binaries
13+
* ship no resource bundle, so a resource-based loader only ever runs on the JVM).
14+
*
15+
* Usage in a module's `build.gradle.kts`:
16+
* ```
17+
* plugins {
18+
* alias(libs.plugins.flipcash.kmp.test.fixtures)
19+
* }
20+
*
21+
* testFixtures {
22+
* packageName = "com.getcode.vendor"
23+
* }
24+
* ```
25+
*
26+
* The generated directory is registered on `commonTest` and the AGP lint tasks are made to depend
27+
* on the generator -- adding the source directory only carries the dependency to the Kotlin compile
28+
* tasks, while lint reads the same directories straight off disk and Gradle then fails the build
29+
* over an undeclared dependency on generated sources.
30+
*/
31+
class KmpTestFixturesConventionPlugin : Plugin<Project> {
32+
override fun apply(target: Project) {
33+
with(target) {
34+
val extension = extensions.create<TestFixturesExtension>("testFixtures")
35+
extension.fixtures.convention(layout.projectDirectory.dir("src/commonTest/resources"))
36+
37+
val generateTestFixtures = tasks.register<GenerateTestFixtures>("generateTestFixtures") {
38+
packageName.set(extension.packageName)
39+
fixtures.set(extension.fixtures)
40+
outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures"))
41+
}
42+
43+
tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") }
44+
.configureEach { dependsOn(generateTestFixtures) }
45+
46+
pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
47+
extensions.configure<KotlinMultiplatformExtension> {
48+
sourceSets.named("commonTest") { kotlin.srcDir(generateTestFixtures) }
49+
}
50+
}
51+
}
52+
}
53+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.getcode.buildlogic.testfixtures
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.file.DirectoryProperty
5+
import org.gradle.api.provider.Property
6+
import org.gradle.api.tasks.Input
7+
import org.gradle.api.tasks.InputDirectory
8+
import org.gradle.api.tasks.OutputDirectory
9+
import org.gradle.api.tasks.TaskAction
10+
11+
/**
12+
* Compiles the cross-platform fixtures into `commonTest` as Kotlin constants.
13+
*
14+
* The parity gate is only worth something if it runs on *both* platforms, and Kotlin/Native test
15+
* binaries ship no resource bundle -- `NSBundle.pathForResource` finds nothing there, so a
16+
* resource-based loader quietly only ever runs on the JVM. Generating a source file instead makes
17+
* the same fixtures readable from every target with no platform code at all.
18+
*/
19+
abstract class GenerateTestFixtures : DefaultTask() {
20+
21+
/** Package the generated `TestFixtures.kt` is declared in. */
22+
@get:Input
23+
abstract val packageName: Property<String>
24+
25+
@get:InputDirectory
26+
abstract val fixtures: DirectoryProperty
27+
28+
@get:OutputDirectory
29+
abstract val outputDirectory: DirectoryProperty
30+
31+
@TaskAction
32+
fun generate() {
33+
val target = packageName.get()
34+
val files = fixtures.get().asFile.listFiles().orEmpty().sortedBy { it.name }
35+
val destination = outputDirectory.get().asFile
36+
.resolve(target.replace('.', '/'))
37+
.resolve("TestFixtures.kt")
38+
destination.parentFile.mkdirs()
39+
40+
destination.writeText(
41+
buildString {
42+
appendLine("package $target")
43+
appendLine()
44+
appendLine("// Generated from src/commonTest/resources -- do not edit.")
45+
appendLine()
46+
appendLine("private val FIXTURES: Map<String, String> = mapOf(")
47+
files.forEach { file ->
48+
append(" \"").append(file.name).append("\" to \"")
49+
append(file.readText().escapeForKotlin())
50+
appendLine("\",")
51+
}
52+
appendLine(")")
53+
appendLine()
54+
appendLine("/** Reads a fixture compiled in from `src/commonTest/resources/`. */")
55+
appendLine("fun readTestResource(name: String): String =")
56+
append(" requireNotNull(FIXTURES[name]) { \"unknown fixture '")
57+
appendLine("\$name'\" }")
58+
}
59+
)
60+
}
61+
62+
private fun String.escapeForKotlin(): String = buildString(length) {
63+
this@escapeForKotlin.forEach { character ->
64+
when (character) {
65+
'\\' -> append("\\\\")
66+
'"' -> append("\\\"")
67+
'$' -> append("\\$")
68+
'\n' -> append("\\n")
69+
'\r' -> append("\\r")
70+
'\t' -> append("\\t")
71+
else -> append(character)
72+
}
73+
}
74+
}
75+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.getcode.buildlogic.testfixtures
2+
3+
import org.gradle.api.file.DirectoryProperty
4+
import org.gradle.api.provider.Property
5+
6+
/**
7+
* Configures [GenerateTestFixtures] for the module.
8+
*
9+
* ```
10+
* testFixtures {
11+
* packageName = "com.getcode.vendor"
12+
* }
13+
* ```
14+
*/
15+
abstract class TestFixturesExtension {
16+
17+
/** Package the generated `TestFixtures.kt` is declared in. Required. */
18+
abstract val packageName: Property<String>
19+
20+
/** Directory of fixture files to compile in. Defaults to `src/commonTest/resources`. */
21+
abstract val fixtures: DirectoryProperty
22+
}

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ flipcash-android-library-compose = { id = "flipcash.android.library.compose" }
335335
flipcash-android-feature = { id = "flipcash.android.feature" }
336336
flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" }
337337
flipcash-kmp-library = { id = "flipcash.kmp.library" }
338+
flipcash-kmp-test-fixtures = { id = "flipcash.kmp.test.fixtures" }
338339
android-application = { id = "com.android.application", version.ref = "agp" }
339340
android-library = { id = "com.android.library", version.ref = "agp" }
340341
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

‎kmp/shared-core/build.gradle.kts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@ plugins {
66
}
77

88
group = "com.flipcash"
9-
version = "0.1.0"
9+
10+
// The published version, and the tag the Swift Package repo gets. CI passes the
11+
// release version in; the fallback only matters for local builds.
12+
version = findProperty("sharedCoreVersion") as String? ?: "0.1.0"
13+
14+
// Where the published `Package.swift` lives. CI points this at a checkout of
15+
// `code-payments/flipcash-shared-core-spm` that already holds a copy of
16+
// `spm/`; locally it falls back to the root build directory so a publish run
17+
// can't dirty the repo. Left unset, KMMBridge would write it to this repo's root.
18+
val spmPackageDir = findProperty("spmRepoDir") as String?
19+
?: rootProject.layout.buildDirectory.dir("spm").get().asFile.path
1020

1121
kotlin {
1222
android {
@@ -44,8 +54,16 @@ kotlin {
4454
}
4555

4656
kmmbridge {
47-
gitHubReleaseArtifacts()
48-
spm(swiftToolVersion = "5.9") {
57+
// Both halves point at the Swift Package repo rather than this one: the
58+
// XCFramework zip is uploaded as a release asset there, and the generated
59+
// `Package.swift` that references it is committed there. iOS then depends on
60+
// a small public repo instead of the whole Android app.
61+
gitHubReleaseArtifacts(repository = "code-payments/flipcash-shared-core-spm")
62+
// `useCustomPackageFile` keeps `spm/Package.swift` — which adds the `SharedCoreKit`
63+
// Swift target over the framework — and rewrites only the variables block inside it.
64+
// The platform and tools version below are what KMMBridge would generate on its own;
65+
// with a custom file it's `spm/Package.swift` that decides, so keep the two in step.
66+
spm(spmDirectory = spmPackageDir, useCustomPackageFile = true, swiftToolVersion = "5.9") {
4967
iOS { v("15") }
5068
}
5169
}

‎kmp/shared-core/spm/Package.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
// The publish job rewrites this block — everything else in this file is ours. Note the
5+
// tags are load-bearing: KMMBridge looks for them verbatim and fails the publish if
6+
// they've drifted.
7+
// BEGIN KMMBRIDGE VARIABLES BLOCK (do not edit)
8+
let remoteKotlinUrl = "https://api.github.com/repos/code-payments/flipcash-shared-core-spm/releases/assets/524049263.zip"
9+
let remoteKotlinChecksum = "b86241d770fa186e44eec4c9ff0ff092d54cf66329828485d243cb7b6cf588b5"
10+
let packageName = "SharedCore"
11+
// END KMMBRIDGE BLOCK
12+
13+
let package = Package(
14+
name: packageName,
15+
platforms: [
16+
.iOS(.v15)
17+
],
18+
products: [
19+
// The only product on purpose. Callers get Swift types; the Kotlin framework's
20+
// own surface — `KotlinByteArray`, `.shared` singletons, no default arguments —
21+
// stays behind this target.
22+
.library(
23+
name: "SharedCoreKit",
24+
targets: ["SharedCoreKit"]
25+
),
26+
],
27+
targets: [
28+
.binaryTarget(
29+
name: packageName,
30+
url: remoteKotlinUrl,
31+
checksum: remoteKotlinChecksum
32+
),
33+
.target(
34+
name: "SharedCoreKit",
35+
dependencies: [.target(name: packageName)]
36+
),
37+
.testTarget(
38+
name: "SharedCoreKitTests",
39+
dependencies: ["SharedCoreKit"]
40+
),
41+
]
42+
)

0 commit comments

Comments
 (0)