3 Commits

Author SHA1 Message Date
Millian Lamiaux
38576fd528 ci: replace dead Expo CI with linux-only monorepo pipeline
Some checks failed
CI / Admin Web CI (pull_request) Failing after 34s
CI / YouTube Worker (pull_request) Failing after 5s
CI / Deploy (pull_request) Has been skipped
CI / Detect Changes (pull_request) Successful in 6s
Remove: root Expo typecheck/lint/test/build-check (project deleted Apr 19)
Remove: swift-build-test + app-store.yml (no macOS runner on Gitea)
Add: path-filtered conditional jobs with dorny/paths-filter@v3
Add: youtube-worker deploy to deploy-functions job
Fix: deploy-functions always() to handle skipped upstream jobs
Fix: SPM cache key includes Package.resolved (before removal)
Fix: remove continue-on-error from vitest/playwright steps
2026-05-23 12:09:28 +02:00
Millian Lamiaux
df9fd48964 chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.
2026-05-23 00:41:41 +02:00
Millian Lamiaux
e42c1217db fix: Live Activity persists after workout cancel/background
Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.

Fixes (all in PlayerViewModel.swift):

1. observeActivityState(): Split the monolithic stale/ended/dismissed
   handler. .stale now only stops the sync timer but keeps the
   workoutActivity reference so endActivity() can still call .end()
   to properly dismiss the stale Live Activity.

2. syncActivity() nil guard: Changed from != .active to explicit
   == .ended || == .dismissed so stale activities are not prematurely
   discarded when tick() re-enters syncActivity().

3. endActivity(): Added stopActivitySyncTimer() defensive call at top
   to prevent orphaned timer from racing in and recreating the activity
   during .end(). Also relaxed the guard from == .active to
   != .ended && != .dismissed so stale activities can be ended.

4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
   before cleanup to prevent accidental Live Activity recreation.
2026-05-23 00:40:41 +02:00
6 changed files with 103 additions and 236 deletions

View File

@@ -1,82 +0,0 @@
name: App Store Submission
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
upload-to-app-store:
name: Archive & Upload to App Store
runs-on: macos-15
timeout-minutes: 60
defaults:
run:
working-directory: tabatago-swift
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode.app
- name: Cache SPM dependencies
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-macos-${{ hashFiles('tabatago-swift/project.yml') }}
restore-keys: |
spm-macos-
- name: Write App Store Connect API key
env:
API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
run: |
printf '%s' "$API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8"
- name: Archive
run: |
xcodebuild archive \
-project TabataGo.xcodeproj \
-scheme TabataGo \
-configuration Release \
-archivePath ./build/TabataGo.xcarchive \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
-authenticationKeyID "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Archive failed — check signing and API key permissions"; exit 1; }
- name: Verify build settings
run: |
echo "Checking version and build number..."
xcodebuild -showBuildSettings \
-project TabataGo.xcodeproj \
-scheme TabataGo \
-configuration Release \
| grep -E "MARKETING_VERSION|CURRENT_PROJECT_VERSION"
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath ./build/TabataGo.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
-authenticationKeyID "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Export failed — check ExportOptions.plist and provisioning"; exit 1; }
# NOTE: The first upload automatically creates the app record in
# App Store Connect if one does not already exist.
- name: Upload to App Store
run: |
xcrun altool --upload-app \
--type ios \
--file ./build/export/TabataGo.ipa \
--apiKey "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
--apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Upload failed — check API key permissions and app record"; exit 1; }

View File

@@ -2,150 +2,42 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
typecheck:
name: TypeScript
# ── Path filter — determines which downstream jobs run ──
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
admin-web: ${{ steps.filter.outputs.admin-web }}
youtube-worker: ${{ steps.filter.outputs.youtube-worker }}
supabase-functions: ${{ steps.filter.outputs.supabase-functions }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: dorny/paths-filter@v3
id: filter
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Run component render tests
run: npm run test:render
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
- name: Coverage summary
if: always()
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f coverage/coverage-summary.json ]; then
echo '```' >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct + '%';
console.log('Statements: ' + fmt(c.statements));
console.log('Branches: ' + fmt(c.branches));
console.log('Functions: ' + fmt(c.functions));
console.log('Lines: ' + fmt(c.lines));
" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
elif [ -f coverage/coverage-final.json ]; then
echo "Coverage report generated. Download the artifact for details." >> $GITHUB_STEP_SUMMARY
else
echo "Coverage report not found." >> $GITHUB_STEP_SUMMARY
fi
- name: Comment coverage on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Test Coverage Report\n\n';
try {
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const total = summary.total;
const fmt = (v) => `${v.pct}%`;
const icon = (v) => v.pct >= 80 ? '✅' : v.pct >= 60 ? '⚠️' : '❌';
body += '| Metric | Coverage | Status |\n';
body += '|--------|----------|--------|\n';
body += `| Statements | ${fmt(total.statements)} | ${icon(total.statements)} |\n`;
body += `| Branches | ${fmt(total.branches)} | ${icon(total.branches)} |\n`;
body += `| Functions | ${fmt(total.functions)} | ${icon(total.functions)} |\n`;
body += `| Lines | ${fmt(total.lines)} | ${icon(total.lines)} |\n`;
} catch (e) {
body += '_Coverage summary not available._\n';
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Test Coverage Report')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
filters: |
admin-web:
- 'admin-web/**'
- '.github/workflows/ci.yml'
youtube-worker:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
supabase-functions:
- 'supabase/functions/**'
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
# ── Admin Web: Next.js ──
admin-web-test:
name: Admin Web Tests
name: Admin Web CI
needs: changes
if: needs.changes.outputs.admin-web == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -168,19 +60,22 @@ jobs:
- name: Run unit tests
run: npx vitest run
continue-on-error: true
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
continue-on-error: true
build-check:
name: Build Check
# ── YouTube Worker: Node.js microservice ──
youtube-worker-check:
name: YouTube Worker
needs: changes
if: needs.changes.outputs.youtube-worker == 'true'
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
defaults:
run:
working-directory: youtube-worker
steps:
- uses: actions/checkout@v4
@@ -189,39 +84,78 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: youtube-worker/package-lock.json
- name: Install dependencies
run: npm ci
- name: Export web build
run: npx expo export --platform web
continue-on-error: true
- name: Validate syntax
run: node --check server.js
# ── Deploy: Supabase edge functions + YouTube worker ──
deploy-functions:
name: Deploy Edge Functions
name: Deploy
needs: [changes, admin-web-test, youtube-worker-check]
if: |
always() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(needs.*.result, 'failure') &&
(needs.changes.outputs.supabase-functions == 'true' ||
needs.changes.outputs.youtube-worker == 'true')
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to self-hosted Supabase
- name: Setup SSH
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy Supabase Edge Functions
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
supabase/functions/ \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker restart supabase-edge-functions"
- name: Deploy YouTube Worker
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
WORKER_PATH: /opt/supabase/youtube-worker
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
youtube-worker/ \
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "\
cd $WORKER_PATH && \
docker build -t youtube-worker:latest . && \
docker stop youtube-worker 2>/dev/null || true && \
docker rm youtube-worker 2>/dev/null || true && \
docker run -d \
--name youtube-worker \
--restart unless-stopped \
--network supabase_supabase-network \
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
-e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \
-e STORAGE_BUCKET=workout-audio \
-e PORT=3001 \
youtube-worker:latest"

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
tabatago-swift/build/

View File

@@ -7,12 +7,12 @@
<key>TabataGo.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>1</integer>
</dict>
<key>TabataGoTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>0</integer>
</dict>
<key>TabataGoUITests.xcscheme_^#shared#^_</key>
<dict>
@@ -29,6 +29,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>TabataGoWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>

View File

@@ -116,6 +116,8 @@ final class PlayerViewModel: ObservableObject {
}
func abandonWorkout() {
isRunning = false
isPaused = false
timer?.invalidate()
stopActivitySyncTimer()
Task { try? await liveSession.end() }
@@ -401,7 +403,8 @@ final class PlayerViewModel: ObservableObject {
phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining))
)
if let existing = workoutActivity, existing.activityState != .active {
if let existing = workoutActivity,
existing.activityState == .ended || existing.activityState == .dismissed {
workoutActivity = nil
}
@@ -449,12 +452,14 @@ final class PlayerViewModel: ObservableObject {
}
func endActivity() async {
stopActivitySyncTimer()
guard let activity = workoutActivity else { return }
workoutActivity = nil
activityStateTask?.cancel()
activityStateTask = nil
nonisolated(unsafe) let safeActivity = activity
guard safeActivity.activityState == .active else { return }
guard safeActivity.activityState != .ended,
safeActivity.activityState != .dismissed else { return }
let finalState = WorkoutActivityAttributes.ContentState(
exerciseName: safeActivity.content.state.exerciseName,
phase: .complete,
@@ -497,7 +502,11 @@ final class PlayerViewModel: ObservableObject {
activityStateTask = Task { @MainActor [weak self] in
for await state in activity.activityStateUpdates {
guard let self else { return }
if state == .stale || state == .ended || state == .dismissed {
if state == .stale {
// Stop sync timer, but keep the activity reference
// so endActivity() can still call .end() to properly dismiss it.
self.stopActivitySyncTimer()
} else if state == .ended || state == .dismissed {
self.workoutActivity = nil
self.stopActivitySyncTimer()
}