Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 82 additions & 0 deletions .github/workflows/android-google-play.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Android Google Play

on:
workflow_call:
inputs:
track:
type: string
required: true
release_status:
type: string
required: true

permissions:
contents: read

concurrency:
group: google-play-production
cancel-in-progress: false

jobs:
upload:
name: Build and upload Android app
runs-on: ubuntu-24.04
environment: play-store-production
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: mobile/package-lock.json

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Install mobile dependencies
working-directory: mobile
run: npm ci

- name: Generate the Android project
working-directory: mobile
run: npx expo prebuild --platform android --no-install

- name: Install Android signing key
run: |
set -euo pipefail
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
KEYSTORE_PATH="$RUNNER_TEMP/openscene-release.keystore"
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH"
echo "OPENSCENE_KEYSTORE_PATH=$KEYSTORE_PATH" >> "$GITHUB_ENV"

- name: Build the signed Android App Bundle
working-directory: mobile/android
run: |
set -euo pipefail
./gradlew bundleRelease \
-POPENSCENE_STORE_FILE="$OPENSCENE_KEYSTORE_PATH" \
-POPENSCENE_STORE_PASSWORD="$ANDROID_KEYSTORE_PASSWORD" \
-POPENSCENE_KEY_ALIAS="$ANDROID_KEY_ALIAS" \
-POPENSCENE_KEY_PASSWORD="$ANDROID_KEY_PASSWORD"

- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1.1.3
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: ${{ vars.ANDROID_PACKAGE_NAME }}
releaseFiles: mobile/android/app/build/outputs/bundle/release/app-release.aab
track: ${{ inputs.track }}
status: ${{ inputs.release_status }}
124 changes: 124 additions & 0 deletions .github/workflows/ios-app-store-connect.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: iOS App Store Connect

on:
workflow_call:

permissions:
contents: read

concurrency:
group: app-store-connect-production
cancel-in-progress: false

jobs:
upload:
name: Build and upload iOS app
# Expo's Swift package dependencies require Swift tools 6.2 or newer.
runs-on: macos-26
environment: app-store-production
env:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APP_STORE_PROFILE_NAME: ${{ vars.APP_STORE_PROFILE_NAME }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
APPLE_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_BASE64 }}
APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD }}
APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APP_STORE_PROVISIONING_PROFILE_BASE64 }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: mobile/package-lock.json

- name: Install mobile dependencies
working-directory: mobile
run: npm ci

- name: Generate the iOS project
working-directory: mobile
run: npx expo prebuild --platform ios --no-install

- name: Install App Store signing assets
env:
KEYCHAIN_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail
test -n "$APPLE_TEAM_ID"
test -n "$APP_STORE_PROFILE_NAME"
test -n "$APPLE_DISTRIBUTION_CERTIFICATE_BASE64"
test -n "$APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD"
test -n "$APP_STORE_PROVISIONING_PROFILE_BASE64"

CERTIFICATE_PATH="$RUNNER_TEMP/distribution.p12"
PROFILE_PATH="$RUNNER_TEMP/profile.mobileprovision"
KEYCHAIN_PATH="$RUNNER_TEMP/app-store-signing.keychain-db"

printf '%s' "$APPLE_DISTRIBUTION_CERTIFICATE_BASE64" | base64 -D > "$CERTIFICATE_PATH"
printf '%s' "$APP_STORE_PROVISIONING_PROFILE_BASE64" | base64 -D > "$PROFILE_PATH"

security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db

mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$(uuidgen).mobileprovision"

- name: Install CocoaPods dependencies
working-directory: mobile/ios
run: pod install --repo-update

- name: Archive the App Store build
working-directory: mobile
run: |
set -euo pipefail
xcodebuild \
-workspace ios/OpenScene.xcworkspace \
-scheme OpenScene \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/OpenScene.xcarchive" \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CODE_SIGN_STYLE=Manual \
PROVISIONING_PROFILE_SPECIFIER="$APP_STORE_PROFILE_NAME" \
CODE_SIGN_IDENTITY='Apple Distribution' \
archive

- name: Export the IPA
working-directory: mobile
run: |
set -euo pipefail
EXPORT_OPTIONS="$RUNNER_TEMP/ExportOptions.plist"
/usr/bin/plutil -create xml1 "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c 'Add :destination string export' "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c 'Add :method string app-store-connect' "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c "Add :teamID string $APPLE_TEAM_ID" "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c 'Add :signingStyle string manual' "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c 'Add :provisioningProfiles dict' "$EXPORT_OPTIONS"
/usr/libexec/PlistBuddy -c "Add :provisioningProfiles:com.sloki9637.openscene string $APP_STORE_PROFILE_NAME" "$EXPORT_OPTIONS"
xcodebuild \
-exportArchive \
-archivePath "$RUNNER_TEMP/OpenScene.xcarchive" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-exportPath "$RUNNER_TEMP/export"

- name: Upload to App Store Connect
run: |
set -euo pipefail
test -n "$ASC_KEY_ID"
test -n "$ASC_ISSUER_ID"
test -n "$ASC_PRIVATE_KEY"
API_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8"
umask 077
printf '%s' "$ASC_PRIVATE_KEY" > "$API_KEY_PATH"
xcrun altool --upload-app \
-f "$RUNNER_TEMP/export/OpenScene.ipa" \
--api-key "$ASC_KEY_ID" \
--api-issuer "$ASC_ISSUER_ID" \
--p8-file-path "$API_KEY_PATH"
17 changes: 16 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,24 @@ jobs:
dist/*.blockmap
if-no-files-found: error

release:
app-store-connect:
needs: [check, build]
if: needs.check.outputs.already-released == 'false'
uses: ./.github/workflows/ios-app-store-connect.yml
secrets: inherit

google-play:
needs: [check, build]
if: needs.check.outputs.already-released == 'false'
uses: ./.github/workflows/android-google-play.yml
with:
track: production
release_status: completed
secrets: inherit

release:
needs: [check, build, app-store-connect, google-play]
if: needs.check.outputs.already-released == 'false'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down
47 changes: 46 additions & 1 deletion mobile/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ survive `expo prebuild` belongs in a config plugin, as the release signing does.
| --- | --- |
| Display name | OpenScene |
| iOS bundle id / Android applicationId | `com.sloki9637.openscene` |
| Version | `0.3.0` (`expo.version`) |
| Version | `0.3.1` (`expo.version`) |
| iOS build number | `expo.ios.buildNumber` |
| Android version code | `expo.android.versionCode` |

Expand Down Expand Up @@ -58,6 +58,51 @@ OPENSCENE_KEY_PASSWORD=…
build falls back to the debug key, so a local build still works for testing and
only a real submission needs the keystore.

## Store distribution on main releases

The **release** workflow is the only store-distribution trigger. It runs when
an unreleased version is promoted to `main`, after the release verification and
desktop packaging jobs finish. It calls the iOS and Android distribution
workflows; they cannot be run manually. A push to `main` with an already tagged
version skips all release and store-distribution work.

The iOS job builds an IPA and uploads it to App Store Connect. It never submits
an app for review: that remains an explicit App Store Connect action after build
processing and metadata review. The Android job uploads the signed AAB to the
Google Play **production** track with status `completed`.

Create the `app-store-production` GitHub Environment, restrict it to the `main`
branch, and require a reviewer before deploying. Store the following values as
environment configuration (not in the repository):

- Variables: `APPLE_TEAM_ID` (`5H9F8F82WT`) and `APP_STORE_PROFILE_NAME`
(`macbook`).
- Secrets: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY`,
`APPLE_DISTRIBUTION_CERTIFICATE_BASE64`,
`APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD`, and
`APP_STORE_PROVISIONING_PROFILE_BASE64`.

`ASC_PRIVATE_KEY` is the complete content of the downloaded App Store Connect
`.p8` file. The two `*_BASE64` secrets are base64-encoded copies of the signing
certificate `.p12` and the App Store provisioning profile respectively. Never
commit any of these files or their decoded values.

### Google Play automation

Create the `play-store-production` GitHub Environment, restrict it to the
`main` branch, and require a reviewer before deploying. Store the following
values as environment configuration (not in the repository):

- Variable: `ANDROID_PACKAGE_NAME` (`com.sloki9637.openscene`).
- Secrets: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`,
`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, and
`GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`.

`ANDROID_KEYSTORE_BASE64` is the base64-encoded release keystore. The Google
Play service account must have access to the OpenScene app in Play Console and
the Google Play Android Developer API must be enabled for its Google Cloud
project. Never commit the keystore, Gradle properties, or service-account JSON.

### Build

```bash
Expand Down
6 changes: 3 additions & 3 deletions mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "OpenScene",
"slug": "openscene",
"version": "0.3.0",
"version": "0.3.1",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "openscene",
Expand All @@ -15,7 +15,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.sloki9637.openscene",
"buildNumber": "1",
"buildNumber": "2",
"infoPlist": {
"NSPhotoLibraryUsageDescription": "OpenScene reads the videos you pick so you can put them on a timeline. It copies the ones you choose into the project and never scans your library on its own.",
"NSPhotoLibraryAddUsageDescription": "OpenScene saves a finished export to your photo library when you ask it to.",
Expand All @@ -31,7 +31,7 @@
},
"predictiveBackGestureEnabled": false,
"package": "com.sloki9637.openscene",
"versionCode": 1,
"versionCode": 2,
"blockedPermissions": [
"android.permission.RECORD_AUDIO",
"android.permission.SYSTEM_ALERT_WINDOW",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "video-window-recorder",
"productName": "OpenScene",
"version": "0.3.0",
"version": "0.3.1",
"description": "OpenScene secure Electron MVP for selected-window preview and local WebM recording.",
"main": "./out/main/index.js",
"type": "module",
Expand Down
18 changes: 15 additions & 3 deletions tests/releaseWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ describe('release workflow', () => {
// publish again, so every later step is gated on the tag being absent.
expect(workflow).toContain('if git rev-parse -q --verify "refs/tags/${TAG}"');
expect(workflow).toContain("already-released=true");
// Packaging and publishing are separate jobs, so each has to be gated too;
// a job without the guard would run on any push to main.
// Packaging, store distribution, and publishing are separate jobs, so each
// has to be gated too; a job without the guard would run on any push to main.
expect(workflow).toContain("if: needs.check.outputs.already-released == 'false'");
const jobGates = workflow.match(/if: needs\.check\.outputs\.already-released == 'false'/g) ?? [];
expect(jobGates.length).toBe(2);
expect(jobGates.length).toBe(4);
});

it('verifies the exact commit it packages', () => {
Expand Down Expand Up @@ -81,10 +81,22 @@ describe('release workflow', () => {
const uses = workflow.match(/uses: [^\n]+/g) ?? [];
expect(uses.length).toBeGreaterThan(0);
for (const use of uses) {
// Reusable workflows in this repository are versioned by the exact
// commit promoted to main, so they intentionally have a local path
// rather than an external action ref.
if (use.includes('uses: ./.github/workflows/')) continue;
expect(use).toMatch(/@[0-9a-f]{40}/);
}
});

it('distributes mobile stores only as part of a new main release', () => {
expect(workflow).toContain('uses: ./.github/workflows/ios-app-store-connect.yml');
expect(workflow).toContain('uses: ./.github/workflows/android-google-play.yml');
expect(workflow).toContain('track: production');
expect(workflow).toContain('release_status: completed');
expect(workflow).toContain('needs: [check, build, app-store-connect, google-play]');
});

it('keeps packaging able to run locally with the same inputs', () => {
expect(packageJson.scripts.package).toBe('npm run build && electron-builder --publish never');
expect(packageJson.devDependencies['electron-builder']).toBeDefined();
Expand Down
Loading