6 Commits

Author SHA1 Message Date
Millian Lamiaux
c517e9a861 fix(ci): supprimer reusable workflow + régénérer package-lock.json
Some checks failed
CI / Detect Changes (pull_request) Successful in 4s
CI / Admin Web CI (pull_request) Failing after 36s
CI / YouTube Worker (pull_request) Successful in 6s
CI / Deploy (pull_request) Has been skipped
PR → Build → WiFi/USB Deploy → LGTM / Detect Changes (pull_request) Has been cancelled
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Has been cancelled
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
Gitea Actions ne supporte pas correctement les inputs des workflow_call
(inputs.node-version évalué en '%!t(string=22)' au lieu de '22').

- Suppression de admin-web-tests.yml (reusable workflow non supporté)
- Tests admin-web réintégrés inline dans ci.yml et docker-admin-web.yml
- admin-web/package-lock.json régénéré (npm install, manquait les devDeps semantic-release)
2026-07-12 07:43:04 +02:00
Millian Lamiaux
b2bd5af0a6 fix(ci): nas-runner → ubuntu-latest pour ci et admin-web-tests
Some checks failed
CI / Detect Changes (pull_request) Successful in 4s
CI / Admin Web CI (pull_request) Failing after 13s
CI / YouTube Worker (pull_request) Successful in 7s
CI / Deploy (pull_request) Has been skipped
PR → Build → WiFi/USB Deploy → LGTM / Detect Changes (pull_request) Has been cancelled
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Has been cancelled
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
Le label nas-runner n'existe pas sur le runner NAS, les labels disponibles
sont ubuntu-latest, ubuntu-22.04, ubuntu-24.04.

- ci.yml : 3 occurrences (changes, youtube-worker-check, deploy-functions)
- admin-web-tests.yml : 1 occurrence (test)
- docker-admin-web.yml : 3 occurrences (semantic-release, build-and-push, deploy)
2026-07-12 07:34:50 +02:00
Millian Lamiaux
65d85b6d5d fix(ci): pr-iphone-deploy — remplacer paths trigger par dorny/paths-filter
Le paths sur on.pull_request ne fonctionne pas de manière fiable dans
Gitea Actions. Passage à un job 'changes' avec dorny/paths-filter@v3
(identique au pattern de ci.yml) qui détecte les changements dans
tabatago-swift/** avant de lancer le build sur le runner macos.
2026-07-12 07:33:30 +02:00
Millian Lamiaux
04a6512a6b ci: pr-iphone-deploy — filtre paths tabatago-swift/** uniquement
Some checks failed
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Failing after 3m2s
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Has been skipped
CI / Detect Changes (pull_request) Has been cancelled
CI / Admin Web CI (pull_request) Has been cancelled
CI / YouTube Worker (pull_request) Has been cancelled
CI / Deploy (pull_request) Has been cancelled
Ne déclenche le build/deploy iPhone que si des fichiers dans tabatago-swift/
ou le workflow lui-même sont modifiés.
2026-07-12 07:27:55 +02:00
Millian Lamiaux
752e863707 fix(ci): runner nas-runner + youtube-worker lock file
- Tous les runs-on: ubuntu-latest → nas-runner (ci.yml, docker-admin-web.yml, admin-web-tests.yml)
- youtube-worker: régénération package-lock.json (manquait @google/genai et dépendances)
2026-07-12 07:26:09 +02:00
Millian Lamiaux
c938c81df0 ci(admin-web): Docker build & push vers registry Gitea + reusable tests
Some checks failed
CI / Detect Changes (pull_request) Successful in 3s
CI / YouTube Worker (pull_request) Failing after 6s
CI / Admin Web CI (pull_request) Failing after 7s
CI / Deploy (pull_request) Has been skipped
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Failing after 2m54s
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Nouveau Dockerfile multi-stage Next.js standalone (node:22-bookworm-slim)
- Configuration semantic-release (.releaserc.json, version.json, devDeps)
- docker-compose.portainer.yml pour déploiement Portainer
- Workflow docker-admin-web.yml : test → semantic-release → build push → deploy
- Extraction admin-web-tests.yml (reusable workflow) appelé par ci.yml et docker-admin-web.yml → 0 duplication
- next.config.ts : ajout output: 'standalone'
- .dockerignore : exclusions node_modules, .next, etc.
2026-07-12 07:18:01 +02:00
36 changed files with 5875 additions and 8445 deletions

View File

@@ -1,178 +0,0 @@
name: Admin Web Docker
on:
push:
branches: [main]
pull_request:
paths:
- 'admin-web/**'
- '.github/workflows/admin-web-docker.yml'
workflow_dispatch:
concurrency:
group: admin-web-docker-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: "22"
jobs:
test:
name: Admin Web Tests
runs-on: ubuntu-latest
timeout-minutes: 10
if: |
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]')
defaults:
run:
working-directory: admin-web
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: admin-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run unit tests
run: npx vitest run
build-validation:
name: Docker Build Validation
needs: [test]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Install Docker CLI
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Build admin-web image (no push)
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
run: |
set -e
docker build \
--file admin-web/Dockerfile \
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}" \
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}" \
--tag "tabatago-admin-web:pr-validation" \
admin-web
semantic-release:
name: Semantic Release
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 10
if: |
github.event_name != 'pull_request' &&
(
github.event_name == 'workflow_dispatch' ||
(
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]')
)
)
outputs:
released: ${{ steps.release.outputs.released }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Record version before release
id: before
run: echo "version=$(node -p "require('./version.json').version")" >> "$GITHUB_OUTPUT"
- name: Run semantic-release
env:
GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
GITEA_URL: https://gitea.1000co.fr
run: npx semantic-release
- name: Check if released
id: release
run: |
VERSION="$(node -p "require('./version.json').version")"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ steps.before.outputs.version }}" != "$VERSION" ]; then
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
fi
build-and-push:
name: Build & Push Docker Image
needs: [semantic-release, test]
runs-on: ubuntu-latest
timeout-minutes: 15
if: |
github.event_name == 'workflow_dispatch' ||
(
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]') &&
needs.semantic-release.outputs.released == 'true'
)
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Install Docker CLI
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Login to Gitea registry
# Secret referenced via env (never inline in the run command) so it
# cannot leak into logs. It is then piped to --password-stdin.
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USER: ${{ secrets.DOCKER_LOGIN }}
run: echo "$DOCKER_PASSWORD" | docker login gitea.1000co.fr -u "$DOCKER_USER" --password-stdin
- name: Build and push admin-web image
env:
VERSION: ${{ needs.semantic-release.outputs.version }}
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
run: |
set -e
if [ -z "${VERSION}" ]; then
echo "::error::VERSION is empty — semantic-release produced no version."
exit 1
fi
docker build \
--file admin-web/Dockerfile \
--build-arg "NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}" \
--build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}" \
--tag "gitea.1000co.fr/millianlmx/admin-web:${VERSION}" \
--tag "gitea.1000co.fr/millianlmx/admin-web:latest" \
admin-web
docker push "gitea.1000co.fr/millianlmx/admin-web:${VERSION}"
docker push "gitea.1000co.fr/millianlmx/admin-web:latest"

View File

@@ -12,6 +12,7 @@ jobs:
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:
@@ -21,6 +22,9 @@ jobs:
id: filter
with:
filters: |
admin-web:
- 'admin-web/**'
- '.github/workflows/ci.yml'
youtube-worker:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
@@ -29,6 +33,40 @@ jobs:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
# ── Admin Web: Next.js ──
admin-web-test:
name: Admin Web CI
needs: changes
if: needs.changes.outputs.admin-web == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: admin-web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: admin-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run unit tests
run: npx vitest run
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
# ── YouTube Worker: Node.js microservice ──
youtube-worker-check:
name: YouTube Worker
@@ -57,7 +95,7 @@ jobs:
# ── Deploy: Supabase edge functions + YouTube worker ──
deploy-functions:
name: Deploy
needs: [changes, youtube-worker-check]
needs: [changes, admin-web-test, youtube-worker-check]
if: |
always() &&
github.ref == 'refs/heads/main' &&

183
.github/workflows/docker-admin-web.yml vendored Normal file
View File

@@ -0,0 +1,183 @@
name: Docker Admin Web
on:
push:
branches: [main]
paths:
- 'admin-web/**'
- '.github/workflows/docker-admin-web.yml'
workflow_dispatch:
concurrency:
group: docker-admin-web-${{ github.ref }}
cancel-in-progress: false
jobs:
admin-web-test:
name: Admin Web Tests
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: admin-web
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: admin-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run unit tests
run: npx vitest run
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
semantic-release:
name: Semantic Release
needs: admin-web-test
runs-on: ubuntu-latest
timeout-minutes: 5
defaults:
run:
working-directory: admin-web
outputs:
released: ${{ steps.check-release.outputs.released }}
version: ${{ steps.check-release.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: admin-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Record version before release
id: before
run: echo "version=$(node -p "require('./version.json').version")" >> $GITHUB_OUTPUT
- name: Run semantic-release
env:
GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
GITEA_URL: https://gitea.1000co.fr
run: npx semantic-release
- name: Check if released
id: check-release
run: |
VERSION=$(node -p "require('./version.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ steps.before.outputs.version }}" != "$VERSION" ]; then
echo "released=true" >> $GITHUB_OUTPUT
else
echo "released=false" >> $GITHUB_OUTPUT
fi
build-and-push:
name: Build & Push Docker Image
needs: semantic-release
if: needs.semantic-release.outputs.released == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install Docker CLI
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Login to Gitea registry
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | \
docker login gitea.1000co.fr \
-u "${{ secrets.DOCKER_USERNAME }}" \
--password-stdin
- name: Build and push
run: |
U="${{ secrets.DOCKER_USERNAME }}"
V="${{ needs.semantic-release.outputs.version }}"
docker build \
--file ./admin-web/Dockerfile \
--tag "gitea.1000co.fr/$U/tabatago-admin-web:$V" \
--tag "gitea.1000co.fr/$U/tabatago-admin-web:latest" \
./admin-web
docker push "gitea.1000co.fr/$U/tabatago-admin-web:$V"
docker push "gitea.1000co.fr/$U/tabatago-admin-web:latest"
deploy:
name: Deploy to Portainer
needs: [semantic-release, build-and-push]
if: needs.semantic-release.outputs.released == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Install deps
run: apt-get update -qq && apt-get install -y -qq gettext-base jq curl
- name: Deploy stack to Portainer
env:
PORTAINER_TOKEN: ${{ secrets.PORTAINER_ACCESS_TOKEN }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ needs.semantic-release.outputs.version }}
run: |
set -e
# Substitute env vars in compose file
export DOCKER_USERNAME VERSION
COMPOSE=$(envsubst '$DOCKER_USERNAME $VERSION' < admin-web/docker-compose.portainer.yml)
# Authenticate and discover Docker endpoint (first endpoint = local Docker)
ENDPOINT_ID=$(curl -sSf -H "X-API-Key: $PORTAINER_TOKEN" \
"https://portainer.1000co.fr/api/endpoints" | jq -r '.[0].Id')
echo "Endpoint ID: $ENDPOINT_ID"
# Check if "tabatago-admin-web" stack already exists
STACK_ID=$(curl -sSf -H "X-API-Key: $PORTAINER_TOKEN" \
"https://portainer.1000co.fr/api/stacks" | jq -r '.[] | select(.Name == "tabatago-admin-web") | .Id')
if [ -n "$STACK_ID" ] && [ "$STACK_ID" != "null" ]; then
echo "Stack tabatago-admin-web exists (ID: $STACK_ID), updating compose content..."
HTTP_RESPONSE=$(curl -s -w '\n%{http_code}' \
-X PUT \
-H "X-API-Key: $PORTAINER_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg content "$COMPOSE" '{stackFileContent: $content, prune: false, pullImage: true}')" \
"https://portainer.1000co.fr/api/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" || true)
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d')
HTTP_CODE=$(echo "$HTTP_RESPONSE" | tail -1)
if [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" -ge 400 ]; then
echo "Portainer API error (HTTP ${HTTP_CODE:-connection failed}):"
echo "$HTTP_BODY"
exit 1
fi
echo "Stack updated. Portainer will redeploy with new images."
else
echo "⚠️ Stack tabatago-admin-web not found in Portainer."
echo "Create it manually in Portainer first: Stacks → Add stack → paste compose content from admin-web/docker-compose.portainer.yml"
exit 1
fi

View File

@@ -15,8 +15,28 @@ permissions:
jobs:
# ── Path filter — only run if tabatago-swift changed ──
changes:
name: Detect Changes
runs-on: nas-runner
outputs:
tabatago-swift: ${{ steps.filter.outputs.tabatago-swift }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
tabatago-swift:
- 'tabatago-swift/**'
build-deploy:
name: Build & Deploy to iPhone (WiFi/USB)
needs: changes
if: needs.changes.outputs.tabatago-swift == 'true'
runs-on: macos
timeout-minutes: 20
@@ -106,15 +126,16 @@ jobs:
# 1. Try WiFi first (Xcode 15+ devicectl network discovery)
echo "📱 Trying WiFi install via devicectl..."
if xcrun devicectl device install app --device "$UDID" "$APP" > /tmp/devicectl.log 2>&1; then
xcrun devicectl device install app --device "$UDID" "$APP" 2>&1 | tail -3
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo "✅ Installed via WiFi"
exit 0
fi
tail -10 /tmp/devicectl.log
# 2. Fallback to USB via ios-deploy
echo "🔌 WiFi failed, trying USB..."
if ios-deploy --bundle "$APP" --id "$UDID" --justlaunch; then
ios-deploy --bundle "$APP" --id "$UDID" --justlaunch
if [ $? -eq 0 ]; then
echo "✅ Installed via USB"
exit 0
fi
@@ -122,7 +143,6 @@ jobs:
echo "❌ Install failed (both WiFi and USB)"
exit 1
- name: Post "Ready to test" comment
env:
GT_TOKEN: ${{ secrets.PR_API_TOKEN }}

View File

@@ -1,21 +0,0 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec",
{
"prepareCmd": "node scripts/prepare-release.cjs ${nextRelease.version}"
}
],
[
"@semantic-release/git",
{
"assets": ["version.json", "admin-web/package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@markwylde/semantic-release-gitea"
]
}

View File

@@ -1,28 +1,12 @@
# Dépendances & caches
node_modules
.next
out
build
# VCS & IDE
.git
.gitignore
.vscode
# Env locaux
.env
.env.*
!.env.test.example
# Tests & couverture
e2e
test
test-results
playwright-report
coverage
*.tsbuildinfo
# Divers
.DS_Store
npm-debug.log*
README.md
.npm
.pnp
.DS_Store
*.md
coverage
playwright-report
test-results

14
admin-web/.releaserc.json Normal file
View File

@@ -0,0 +1,14 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/exec", {
"prepareCmd": "echo '{\"version\": \"${nextRelease.version}\"}' > version.json"
}],
["@semantic-release/git", {
"assets": ["version.json"],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
}]
]
}

View File

@@ -1,32 +1,29 @@
# syntax=docker/dockerfile:1
# TODO(security): pin node:22-alpine by digest in a follow-up.
FROM node:22-bookworm-slim AS build
# ---- deps ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN npm run build
# ---- runner ----
FROM node:22-alpine AS runner
FROM node:22-bookworm-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
USER node
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
RUN groupadd -g 1001 appgroup && useradd -u 1001 -g appgroup -s /bin/sh -M nodeuser
USER nodeuser
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
ENTRYPOINT ["node", "server.js"]

View File

@@ -14,15 +14,11 @@ vi.mock('next/navigation', () => ({
}),
}))
// Mock sonner toast — the login page calls toast.success on success. We also
// stub toast.error for future-proofing (the page uses setError for the error
// path today, but tests may evolve to assert toasts).
// Mock sonner toast
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
vi.mock('sonner', () => ({
toast: {
success: (...args: any[]) => mockToastSuccess(...args),
error: (...args: any[]) => mockToastError(...args),
},
}))
@@ -49,9 +45,7 @@ describe('LoginPage', () => {
it('should render login form', () => {
render(<LoginPage />)
// CardTitle is not a semantic heading (shadcn renders it as <div>), so we
// assert on the visible text instead of querying by role="heading".
expect(screen.getByText(/tabatafit admin/i)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /tabatafit admin/i })).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
@@ -119,14 +113,9 @@ describe('LoginPage', () => {
})
it('should show error for invalid credentials', async () => {
// Production supabase-js returns an AuthError (extends Error) instance,
// so the page's `err instanceof Error ? err.message : "Login failed"`
// branch surfaces the real message. The mock must match that shape;
// a plain `{ message }` object would hit the fallback ("Login failed")
// and the test would never see the real credential error text.
mockSignInWithPassword.mockResolvedValue({
data: { user: null },
error: new Error('Invalid login credentials'),
mockSignInWithPassword.mockResolvedValue({
data: { user: null },
error: { message: 'Invalid login credentials' }
})
render(<LoginPage />)

View File

@@ -97,27 +97,21 @@ describe('DashboardPage', () => {
expect(screen.getByText(/manage trainer profiles/i)).toBeInTheDocument()
})
it('should link to correct routes', async () => {
it('should link to correct routes', () => {
mockFrom.mockReturnValue({
select: () => Promise.resolve({ count: 0, error: null }),
})
render(<DashboardPage />)
// Stats are fetched in useEffect; the link accessible name only includes
// the count digit once the async state has resolved. Wait for it.
await waitFor(() => {
expect(screen.getByRole('link', { name: /workouts\s+\d+/i })).toHaveAttribute('href', '/workouts')
})
expect(screen.getByRole('link', { name: /trainers\s+\d+/i })).toHaveAttribute('href', '/trainers')
expect(screen.getByRole('link', { name: /collections\s+\d+/i })).toHaveAttribute('href', '/collections')
expect(screen.getByRole('link', { name: /workouts \d+/i })).toHaveAttribute('href', '/workouts')
expect(screen.getByRole('link', { name: /trainers \d+/i })).toHaveAttribute('href', '/trainers')
expect(screen.getByRole('link', { name: /collections \d+/i })).toHaveAttribute('href', '/collections')
})
it('should handle stats fetch errors gracefully', async () => {
// TODO: code path silently ignores {error} from supabase — should log/toast (out of scope).
// The production fetchStats destructures `{ count }` from the resolved payload without
// ever throwing on `{ count: null, error }`, so console.error is NOT called on this path.
// We only assert that the UI renders without crashing and falls back to the empty state.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFrom.mockReturnValue({
select: () => Promise.resolve({ count: null, error: new Error('Database error') }),
})
@@ -125,29 +119,24 @@ describe('DashboardPage', () => {
render(<DashboardPage />)
await waitFor(() => {
expect(screen.getByText('Workouts')).toBeInTheDocument()
expect(screen.getByText('Trainers')).toBeInTheDocument()
expect(screen.getByText('Collections')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalled()
})
consoleError.mockRestore()
})
it('should render correct icons', async () => {
it('should render correct icons', () => {
mockFrom.mockReturnValue({
select: () => Promise.resolve({ count: 0, error: null }),
})
render(<DashboardPage />)
// shadcn Card renders <div data-slot="card">, not a semantic <article>.
// Only the 3 stat cards live inside a <Link> (rendered as <a>); the
// "Getting Started" card below has no icon, so we scope the query.
await waitFor(() => {
const cards = document.querySelectorAll('a [data-slot="card"]')
expect(cards.length).toBe(3)
cards.forEach(card => {
const icon = card.querySelector('svg')
expect(icon).toBeInTheDocument()
})
// All cards should have icons (lucide icons render as svg)
const cards = screen.getAllByRole('article')
cards.forEach(card => {
const icon = card.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
@@ -166,14 +155,13 @@ describe('DashboardPage', () => {
render(<DashboardPage />)
await waitFor(() => {
// shadcn Card renders <div data-slot="card">, not a semantic <article>.
const workoutCard = screen.getByText('Workouts').closest('[data-slot="card"]')
const workoutCard = screen.getByText('Workouts').closest('article')
expect(workoutCard?.querySelector('.text-orange-500')).toBeInTheDocument()
const trainerCard = screen.getByText('Trainers').closest('[data-slot="card"]')
const trainerCard = screen.getByText('Trainers').closest('article')
expect(trainerCard?.querySelector('.text-blue-500')).toBeInTheDocument()
const collectionCard = screen.getByText('Collections').closest('[data-slot="card"]')
const collectionCard = screen.getByText('Collections').closest('article')
expect(collectionCard?.querySelector('.text-green-500')).toBeInTheDocument()
})
})

View File

@@ -91,10 +91,8 @@ describe('Sidebar', () => {
})
it('should handle logout errors gracefully', async () => {
// Real supabase.auth.signOut() never throws — it resolves with { error }.
// Mocking it realistically avoids an unhandled rejection and matches prod behaviour.
mockSignOut.mockResolvedValueOnce({ error: new Error('Network error') })
mockSignOut.mockRejectedValueOnce(new Error('Network error'))
render(<Sidebar />)
const logoutButton = screen.getByRole('button', { name: /logout/i })
@@ -103,9 +101,6 @@ describe('Sidebar', () => {
await waitFor(() => {
expect(mockSignOut).toHaveBeenCalled()
})
// Graceful: even when signOut errors, the user is still redirected to login.
expect(mockPush).toHaveBeenCalledWith('/login')
})
it('should have correct styling for navigation', () => {

View File

@@ -55,12 +55,7 @@ describe('Dialog', () => {
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
// SKIPPED: jsdom limitation — Radix Dialog closes its content via a
// `pointerDown` listener on the overlay, which is not reproducible in jsdom
// (jsdom has no real pointer events / hit-testing). The escape-key path
// below covers the close behaviour; the click-outside path is exercised by
// Playwright E2E instead.
it.skip('should close dialog when clicking outside', async () => {
it('should close dialog when clicking outside', async () => {
render(
<Dialog>
<DialogTrigger asChild>
@@ -75,7 +70,7 @@ describe('Dialog', () => {
)
await userEvent.click(screen.getByRole('button', { name: /open dialog/i }))
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
@@ -183,10 +178,7 @@ describe('Dialog', () => {
await waitFor(() => {
const dialog = screen.getByRole('dialog')
// Modern Radix no longer emits aria-modal; it relies on role=dialog
// plus aria-labelledby (and aria-describedby when a Description is set).
expect(dialog).toHaveAttribute('role', 'dialog')
expect(dialog).toHaveAttribute('aria-labelledby')
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})
})

View File

@@ -3,24 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WorkoutForm from './workout-form'
// Mock sonner toast — the production form uses toast.error / toast.success,
// never window.alert. We surface named vi.fn()s so tests can assert calls.
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
vi.mock('sonner', () => ({
toast: {
error: (...args: any[]) => mockToastError(...args),
success: (...args: any[]) => mockToastSuccess(...args),
info: vi.fn(),
loading: vi.fn(),
warning: vi.fn(),
message: vi.fn(),
promise: vi.fn(),
custom: vi.fn(),
dismiss: vi.fn(),
},
}))
// Mock Next.js router
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
@@ -577,16 +559,6 @@ describe('WorkoutForm', () => {
})
it('should successfully submit form in create mode', async () => {
// Make the insert promise resolve on a macrotask so React has time to
// re-render the "Saving..." loading state between setIsLoading(true)
// and setIsLoading(false). Without this, the loading state is transient
// and the waitFor below never observes it.
mockInsertSelectSingle.mockImplementation(
() => new Promise(resolve =>
setTimeout(() => resolve({ data: { id: 'new-workout-id' }, error: null }), 10)
)
)
render(<WorkoutForm mode="create" />)
await waitFor(() => {
@@ -596,20 +568,10 @@ describe('WorkoutForm', () => {
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Wait for trainers fetch to resolve (the custom Select replaces the
// "Loading trainers..." placeholder once isLoadingTrainers flips false).
await waitFor(() => {
expect(screen.getByLabelText(/trainer/i)).toBeInTheDocument()
})
// Open the custom dropdown and pick the trainer
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
// Fill the first exercise name — the default `exercises` state is
// [{ name: "", duration: 20 }] which fails validation without this.
await userEvent.click(screen.getByRole('tab', { name: /content/i }))
await userEvent.type(screen.getByTestId('exercise-name-0'), 'Push-ups')
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
@@ -630,15 +592,6 @@ describe('WorkoutForm', () => {
})
it('should successfully submit form in edit mode', async () => {
// Make the update promise resolve on a macrotask so React has time to
// re-render the "Saving..." loading state between setIsLoading(true)
// and setIsLoading(false). See the create-mode test for rationale.
mockUpdateEqSelectSingle.mockImplementation(
() => new Promise(resolve =>
setTimeout(() => resolve({ data: { id: 'test-id' }, error: null }), 10)
)
)
const initialData = {
id: 'test-id',
title: 'Test Workout',
@@ -711,20 +664,10 @@ describe('WorkoutForm', () => {
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Wait for trainers fetch to resolve (the custom Select replaces the
// "Loading trainers..." placeholder once isLoadingTrainers flips false).
await waitFor(() => {
expect(screen.getByLabelText(/trainer/i)).toBeInTheDocument()
})
// Open the custom dropdown and pick the trainer
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
// Fill the first exercise name — the default `exercises` state is
// [{ name: "", duration: 20 }] which fails validation without this.
await userEvent.click(screen.getByRole('tab', { name: /content/i }))
await userEvent.type(screen.getByTestId('exercise-name-0'), 'Push-ups')
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
@@ -758,20 +701,10 @@ describe('WorkoutForm', () => {
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Wait for trainers fetch to resolve (the custom Select replaces the
// "Loading trainers..." placeholder once isLoadingTrainers flips false).
await waitFor(() => {
expect(screen.getByLabelText(/trainer/i)).toBeInTheDocument()
})
// Open the custom dropdown and pick the trainer
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
// Fill the first exercise name — the default `exercises` state is
// [{ name: "", duration: 20 }] which fails validation without this.
await userEvent.click(screen.getByRole('tab', { name: /content/i }))
await userEvent.type(screen.getByTestId('exercise-name-0'), 'Push-ups')
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
@@ -787,6 +720,8 @@ describe('WorkoutForm', () => {
})
it('should handle submission error', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {})
// Setup insert to fail
mockInsertSelectSingle.mockResolvedValue({
data: null,
@@ -802,30 +737,23 @@ describe('WorkoutForm', () => {
// Fill in required fields
await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout')
// Wait for trainers fetch to resolve (the custom Select replaces the
// "Loading trainers..." placeholder once isLoadingTrainers flips false).
await waitFor(() => {
expect(screen.getByLabelText(/trainer/i)).toBeInTheDocument()
})
// Open the custom dropdown and pick the trainer
// Select trainer
await userEvent.click(screen.getByLabelText(/trainer/i))
await userEvent.click(screen.getByText('Test Trainer'))
// Fill the first exercise name — the default `exercises` state is
// [{ name: "", duration: 20 }] which fails validation without this.
await userEvent.click(screen.getByRole('tab', { name: /content/i }))
await userEvent.type(screen.getByTestId('exercise-name-0'), 'Push-ups')
const submitButton = screen.getByRole('button', { name: /create workout/i })
await userEvent.click(submitButton)
// Production form calls toast.error (sonner) on save failure, not window.alert
// Should show alert on error
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('Failed to save workout. Please try again.')
expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.')
})
alertMock.mockRestore()
})
it('should handle update error in edit mode', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {})
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {})
// Setup update to fail
@@ -865,11 +793,12 @@ describe('WorkoutForm', () => {
const submitButton = screen.getByRole('button', { name: /update workout/i })
await userEvent.click(submitButton)
// Production form calls toast.error (sonner) on save failure, not window.alert
// Should show alert on error
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('Failed to save workout. Please try again.')
expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.')
})
alertMock.mockRestore()
consoleErrorMock.mockRestore()
})
})

View File

@@ -0,0 +1,16 @@
services:
admin-web:
image: gitea.1000co.fr/${DOCKER_USERNAME}/tabatago-admin-web:${VERSION:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
networks:
- supabase_supabase-network
networks:
supabase_supabase-network:
external: true

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
output: 'standalone',
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "my-app",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,7 +13,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:all": "npm run test && npm run test:e2e"
"test:all": "npm run test && npm run test:e2e",
"semantic-release": "semantic-release"
},
"dependencies": {
"@google/genai": "^1.47.0",
@@ -30,6 +31,10 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@semantic-release/commit-analyzer": "^13.0.0",
"@semantic-release/exec": "^6.0.0",
"@semantic-release/git": "^10.0.0",
"@semantic-release/release-notes-generator": "^14.0.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -42,6 +47,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"semantic-release": "^24.0.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",

1
admin-web/version.json Normal file
View File

@@ -0,0 +1 @@
{"version": "0.1.0"}

View File

@@ -1,4 +1,4 @@
import { defineConfig, configDefaults } from 'vitest/config'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
@@ -9,14 +9,6 @@ export default defineConfig({
globals: true,
setupFiles: ['./test/setup.ts'],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// Exclude Playwright E2E specs + reports from the Vitest unit run.
// Spread configDefaults.exclude to keep filtering node_modules/dist/build.
exclude: [
'e2e/**',
'playwright-report/**',
'test-results/**',
...configDefaults.exclude,
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],

6928
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
{
"name": "tabatago-release-tooling",
"version": "1.0.0",
"private": true,
"description": "Tooling de release monorepo (semantic-release). NE PAS confondre avec l'app iOS ni admin-web.",
"scripts": {
"semantic-release": "semantic-release"
},
"devDependencies": {
"@markwylde/semantic-release-gitea": "^2.2.0",
"@semantic-release/commit-analyzer": "^13.0.0",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/git": "^10.0.0",
"@semantic-release/release-notes-generator": "^14.0.0",
"semantic-release": "^24.0.0"
}
}

View File

@@ -1,46 +0,0 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[\dA-Za-z.-]+)?(?:\+[\dA-Za-z.-]+)?$/;
const nextVersion = process.argv[2];
if (!nextVersion) {
throw new Error(
'Usage: node scripts/prepare-release.cjs <version> — version argument is required.',
);
}
if (!SEMVER_PATTERN.test(nextVersion)) {
throw new Error(
`Invalid semver "${nextVersion}". Expected e.g. 1.2.3 or 1.2.3-beta.1.`,
);
}
const workspaceRoot = path.resolve(__dirname, '..');
const versionJsonPath = path.join(workspaceRoot, 'version.json');
const adminWebPackagePath = path.join(workspaceRoot, 'admin-web', 'package.json');
writeVersionJson(versionJsonPath, nextVersion);
bumpPackageVersion(adminWebPackagePath, nextVersion);
console.log(`[prepare-release] Updated version to ${nextVersion}`);
console.log('[prepare-release] - version.json');
console.log('[prepare-release] - admin-web/package.json');
function writeVersionJson(filePath, version) {
const versionJson = { version };
fs.writeFileSync(filePath, `${JSON.stringify(versionJson, null, 2)}\n`, 'utf8');
}
function bumpPackageVersion(filePath, version) {
if (!fs.existsSync(filePath)) {
throw new Error(`Package manifest not found: ${filePath}`);
}
const manifest = JSON.parse(fs.readFileSync(filePath, 'utf8'));
manifest.version = version;
fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}

View File

@@ -1,228 +0,0 @@
/*
* prepare-release.test.cjs — Test standalone (zero deps) pour scripts/prepare-release.cjs
*
* Contrat vérifié (issu du planner / critères d'acceptation) :
* - `node scripts/prepare-release.cjs <semver>` met à jour `version.json` (racine)
* ET `admin-web/package.json` (champ `version`) avec la valeur passée.
* - Sans argument -> exit code != 0.
* - Version non-semver -> exit code != 0.
* - semver simple (1.2.3) ou avec prerelease (1.2.3-beta.1) -> succès.
* - Les autres clés de `admin-web/package.json` (name, dependencies, scripts...)
* sont préservées (pas écrasées).
*
* Ce test s'exécute AVANT l'implémentation (le script n'existe pas encore).
* Dans ce cas, chaque test échoue proprement avec "[FAIL] ... script not implemented yet",
* ce qui confirme l'état rouge attendu (TDD spec-first).
*
* Usage : node scripts/prepare-release.test.cjs
* Exit : 0 si tous les tests passent, 1 sinon.
*/
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const ROOT = path.resolve(__dirname, "..");
const SCRIPT = path.join(ROOT, "scripts", "prepare-release.cjs");
const VERSION_JSON = path.join(ROOT, "version.json");
const ADMIN_PKG = path.join(ROOT, "admin-web", "package.json");
let passed = 0;
let failed = 0;
function ok(name) {
console.log(`[PASS] ${name}`);
passed++;
}
function ko(name, detail) {
console.log(`[FAIL] ${name}${detail ? " — " + detail : ""}`);
failed++;
}
/**
* Exécute prepare-release.cjs via node et renvoie { status, stdout, stderr }.
* Si le script n'existe pas, on simule un échec avec status null.
*/
function runScript(args) {
if (!fs.existsSync(SCRIPT)) {
return {
status: null,
stdout: "",
stderr: "scripts/prepare-release.cjs not implemented yet",
missing: true,
};
}
const res = spawnSync(process.execPath, [SCRIPT, ...args], {
cwd: ROOT,
encoding: "utf8",
timeout: 15000,
});
return {
status: res.status,
stdout: res.stdout ?? "",
stderr: res.stderr ?? "",
missing: false,
};
}
/* ------------------------------------------------------------------ *
* Backup / restore : on ne veut PAS polluer le working tree.
* On backup les fichiers AVANT les tests d'écriture et on restore à la fin,
* même si une assertion throw.
* ------------------------------------------------------------------ */
const backup = {};
function backupFile(file) {
if (fs.existsSync(file)) {
backup[file] = { existed: true, content: fs.readFileSync(file) };
} else {
backup[file] = { existed: false };
}
}
function restoreFile(file) {
const b = backup[file];
if (!b) return;
if (b.existed) {
fs.writeFileSync(file, b.content);
} else if (fs.existsSync(file)) {
fs.unlinkSync(file);
}
}
function readJSON(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/* ------------------------------------------------------------------ *
* TEST CASES
* ------------------------------------------------------------------ */
// (a) succès semver simple
function test_simple_semver_updates_both_files() {
const name = "simple semver (9.9.9) updates version.json + admin-web/package.json";
const r = runScript(["9.9.9"]);
if (r.missing) return ko(name, "script not implemented yet");
try {
assert.strictEqual(r.status, 0, `expected exit 0, got ${r.status} (stderr=${r.stderr.trim()})`);
const v = readJSON(VERSION_JSON);
assert.strictEqual(v.version, "9.9.9", `version.json.version=${v.version}`);
const p = readJSON(ADMIN_PKG);
assert.strictEqual(p.version, "9.9.9", `admin-web/package.json.version=${p.version}`);
ok(name);
} catch (e) {
ko(name, e.message);
}
}
// (b) succès prerelease
function test_prerelease_semver_accepted() {
const name = "prerelease semver (1.2.3-beta.1) accepted";
const r = runScript(["1.2.3-beta.1"]);
if (r.missing) return ko(name, "script not implemented yet");
try {
assert.strictEqual(r.status, 0, `expected exit 0, got ${r.status} (stderr=${r.stderr.trim()})`);
const v = readJSON(VERSION_JSON);
assert.strictEqual(v.version, "1.2.3-beta.1", `version.json.version=${v.version}`);
const p = readJSON(ADMIN_PKG);
assert.strictEqual(p.version, "1.2.3-beta.1", `admin-web/package.json.version=${p.version}`);
ok(name);
} catch (e) {
ko(name, e.message);
}
}
// (c) échec sans arg
function test_no_arg_fails() {
const name = "no argument -> exit != 0";
const r = runScript([]);
if (r.missing) return ko(name, "script not implemented yet");
try {
assert.notStrictEqual(r.status, 0, `expected non-zero exit, got ${r.status}`);
ok(name);
} catch (e) {
ko(name, e.message);
}
}
// (d) échec version invalide
function test_invalid_version_fails() {
const name = "invalid version ('not-a-version') -> exit != 0";
const r = runScript(["not-a-version"]);
if (r.missing) return ko(name, "script not implemented yet");
try {
assert.notStrictEqual(r.status, 0, `expected non-zero exit, got ${r.status}`);
// fichiers ne doivent pas avoir été modifiés avec une version invalide
if (fs.existsSync(VERSION_JSON)) {
const v = readJSON(VERSION_JSON);
assert.notStrictEqual(v.version, "not-a-version", "version.json should not contain invalid version");
}
ok(name);
} catch (e) {
ko(name, e.message);
}
}
// (e) préservation des autres clés du package.json
function test_preserves_other_keys() {
const name = "other keys of admin-web/package.json preserved (name, scripts, dependencies)";
const r = runScript(["7.7.7"]);
if (r.missing) return ko(name, "script not implemented yet");
try {
assert.strictEqual(r.status, 0, `expected exit 0, got ${r.status}`);
const p = readJSON(ADMIN_PKG);
// Le package.json actuel contient name, scripts, dependencies, devDependencies.
// On vérifie que ces clés sont toujours là et non vides.
assert.ok(typeof p.name === "string" && p.name.length > 0, `name missing/empty: ${p.name}`);
assert.ok(p.scripts && typeof p.scripts === "object", "scripts object missing");
assert.ok(Object.keys(p.scripts).length > 0, "scripts object empty");
assert.ok(p.dependencies && typeof p.dependencies === "object", "dependencies object missing");
assert.ok(Object.keys(p.dependencies).length > 0, "dependencies object empty");
// La version, elle, doit avoir été bumpée
assert.strictEqual(p.version, "7.7.7", `version should be 7.7.7, got ${p.version}`);
ok(name);
} catch (e) {
ko(name, e.message);
}
}
/* ------------------------------------------------------------------ *
* RUN
* ------------------------------------------------------------------ */
function main() {
console.log("# prepare-release.cjs — test suite (standalone, zero deps)");
console.log(`# script: ${path.relative(ROOT, SCRIPT)}`);
console.log(`# exists: ${fs.existsSync(SCRIPT)}`);
console.log("");
// Backup AVANT les tests d'écriture
backupFile(VERSION_JSON);
backupFile(ADMIN_PKG);
let crashed = false;
try {
test_simple_semver_updates_both_files();
test_prerelease_semver_accepted();
test_no_arg_fails();
test_invalid_version_fails();
test_preserves_other_keys();
} catch (e) {
crashed = true;
console.error("[ERROR] unexpected exception in test runner:", e);
} finally {
// Restore TOUJOURS
restoreFile(VERSION_JSON);
restoreFile(ADMIN_PKG);
}
console.log("");
console.log(`# Summary: ${passed} passed, ${failed} failed${crashed ? " (+runner crash)" : ""}`);
process.exit(failed === 0 && !crashed ? 0 : 1);
}
main();

View File

@@ -1,685 +0,0 @@
#!/usr/bin/env bash
# verify-admin-web-docker.sh — Validation structurelle de la feature
# "Pipeline CI/CD Docker pour admin-web (build + push registry)".
#
# Encode TOUS les critères d'acceptation structurels issus du planner :
# - existence + contenu des fichiers créés par la feature
# - validité YAML / JSON
# - patterns de sécurité (secrets, login password-stdin, pas de GITEA_TOKEN…)
# - actions pinnées par SHA
# - garde anti-[skip ci] sur chaque job
# - contextes git (branche feat/admin-web-docker + commit feat:)
#
# Usage :
# ./scripts/verify-admin-web-docker.sh # run complet
# ./scripts/verify-admin-web-docker.sh --summary # run + total final uniquement
#
# Exit : 0 si 0 échec, 1 si ≥1 échec.
# Idempotent / ré-entrant : lancé avant implé (tout rouge) ET après (tout vert).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
SUMMARY_ONLY=0
[[ "${1:-}" == "--summary" ]] && SUMMARY_ONLY=1
PASS_COUNT=0
FAIL_COUNT=0
declare -a FAIL_NAMES=()
# --- helpers -------------------------------------------------------- #
pass() { # name
PASS_COUNT=$((PASS_COUNT + 1))
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
printf '[PASS] %s\n' "$1"
fi
}
fail() { # name [detail...]
FAIL_COUNT=$((FAIL_COUNT + 1))
FAIL_NAMES+=("$1 :: ${2:-}")
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
printf '[FAIL] %s' "$1"
[[ $# -ge 2 ]] && printf ' — %s' "${@:2}"
printf '\n'
fi
}
# file_contains <file> <label> <pattern> (uses grep -E)
file_contains() {
local file="$1" label="$2" pattern="$3"
if [[ ! -f "$file" ]]; then
fail "$label" "file missing: $file"
return
fi
if grep -Eq "$pattern" "$file"; then
pass "$label"
else
fail "$label" "pattern not found: $pattern"
fi
}
# file_not_contains <file> <label> <pattern>
file_not_contains() {
local file="$1" label="$2" pattern="$3"
if [[ ! -f "$file" ]]; then
fail "$label" "file missing: $file"
return
fi
if grep -Eq "$pattern" "$file"; then
fail "$label" "forbidden pattern found: $pattern"
else
pass "$label"
fi
}
# json_valid <file> <label>
json_valid() {
local file="$1" label="$2"
if [[ ! -f "$file" ]]; then
fail "$label" "file missing: $file"
return
fi
if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$file" 2>/dev/null; then
pass "$label"
else
fail "$label" "invalid JSON"
fi
}
# yaml_valid <file> <label>
yaml_valid() {
local file="$1" label="$2"
if [[ ! -f "$file" ]]; then
fail "$label" "file missing: $file"
return
fi
if ! python3 -c "import yaml" 2>/dev/null; then
fail "$label" "pyyaml not installed (cannot validate)"
return
fi
if python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$file" 2>/dev/null; then
pass "$label"
else
fail "$label" "invalid YAML"
fi
}
# =================================================================== #
# CHECKS
# =================================================================== #
# --- version.json --------------------------------------------------- #
check_version_json() {
local label="version.json exists with {\"version\":\"1.0.0\"}"
if [[ ! -f version.json ]]; then
fail "$label" "file missing"
return
fi
local v
v="$(node -p "require('./version.json').version" 2>/dev/null || true)"
if [[ "$v" == "1.0.0" ]]; then
pass "$label"
else
fail "$label" "version.json.version='$v' (expected 1.0.0)"
fi
}
# --- root package.json ---------------------------------------------- #
check_root_package_json() {
local label="root package.json: private + semantic-release deps + script"
if [[ ! -f package.json ]]; then
fail "$label" "file missing"
return
fi
local missing=()
node -e '
const p = require("./package.json");
const want = [
["private === true", p.private === true],
["scripts.semantic-release", typeof p.scripts?.["semantic-release"] === "string"],
["devDependencies.semantic-release", !!(p.devDependencies?.["semantic-release"] || p.dependencies?.["semantic-release"])],
["@semantic-release/exec", !!(p.devDependencies?.["@semantic-release/exec"] || p.dependencies?.["@semantic-release/exec"])],
["@semantic-release/git", !!(p.devDependencies?.["@semantic-release/git"] || p.dependencies?.["@semantic-release/git"])],
["@semantic-release/commit-analyzer", !!(p.devDependencies?.["@semantic-release/commit-analyzer"] || p.dependencies?.["@semantic-release/commit-analyzer"])],
["@semantic-release/release-notes-generator", !!(p.devDependencies?.["@semantic-release/release-notes-generator"] || p.dependencies?.["@semantic-release/release-notes-generator"])],
["@markwylde/semantic-release-gitea", !!(p.devDependencies?.["@markwylde/semantic-release-gitea"] || p.dependencies?.["@markwylde/semantic-release-gitea"])],
];
for (const [name, ok] of want) if (!ok) console.log(name);
' > /tmp/_pkgcheck 2>/dev/null || { fail "$label" "node parse failed"; return; }
while IFS= read -r line; do
[[ -n "$line" ]] && missing+=("$line")
done < /tmp/_pkgcheck
rm -f /tmp/_pkgcheck
if [[ ${#missing[@]} -eq 0 ]]; then
pass "$label"
else
fail "$label" "missing: ${missing[*]}"
fi
}
# --- root package-lock.json ----------------------------------------- #
check_root_pkg_lock() {
if [[ -f package-lock.json ]]; then
pass "root package-lock.json exists"
else
fail "root package-lock.json exists" "file missing"
fi
}
# --- .releaserc.json ------------------------------------------------ #
check_releaserc() {
local label1=".releaserc.json valid JSON"
if [[ ! -f .releaserc.json ]]; then
fail "$label1" "file missing"
fail ".releaserc.json assets include version.json + admin-web/package.json" "file missing"
fail ".releaserc.json git plugin message contains [skip ci]" "file missing"
return
fi
if python3 -c "import json,sys; json.load(open(sys.argv[1]))" .releaserc.json 2>/dev/null; then
pass "$label1"
else
fail "$label1" "invalid JSON"
fi
# assets contain version.json + admin-web/package.json
local label2=".releaserc.json assets include version.json + admin-web/package.json"
if grep -Eq '"version\.json"' .releaserc.json && grep -Eq '"admin-web/package\.json"' .releaserc.json; then
pass "$label2"
else
fail "$label2" "assets should reference both version.json and admin-web/package.json"
fi
# git plugin message contains literal [skip ci]
local label3=".releaserc.json git plugin message contains [skip ci]"
if grep -Fq "[skip ci]" .releaserc.json; then
pass "$label3"
else
fail "$label3" "literal [skip ci] not found"
fi
}
# --- Dockerfile ----------------------------------------------------- #
check_dockerfile() {
local f="admin-web/Dockerfile"
if [[ ! -f "$f" ]]; then
fail "Dockerfile exists" "file missing: $f"
return
fi
pass "Dockerfile exists"
# 3 stages FROM node:22-alpine
local count
count=$(grep -Ec '^FROM node:22-alpine' "$f" || true)
if [[ "$count" -eq 3 ]]; then
pass "Dockerfile has exactly 3 'FROM node:22-alpine' stages"
else
fail "Dockerfile has exactly 3 'FROM node:22-alpine' stages" "found $count"
fi
file_contains "$f" "Dockerfile uses USER node" '^USER node\b'
file_contains "$f" "Dockerfile EXPOSE 3000" '^EXPOSE 3000\b'
file_contains "$f" 'Dockerfile CMD ["node","server.js"]' 'CMD \[ ?"node" ?, ?"server\.js" ?\]'
# build-args NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_ANON_KEY
file_contains "$f" "Dockerfile ARG NEXT_PUBLIC_SUPABASE_URL" 'ARG[[:space:]]+NEXT_PUBLIC_SUPABASE_URL'
file_contains "$f" "Dockerfile ARG NEXT_PUBLIC_SUPABASE_ANON_KEY" 'ARG[[:space:]]+NEXT_PUBLIC_SUPABASE_ANON_KEY'
}
# --- next.config.ts standalone -------------------------------------- #
check_next_config() {
local f="admin-web/next.config.ts"
if [[ ! -f "$f" ]]; then
fail "next.config.ts contains output: \"standalone\"" "file missing"
return
fi
if grep -Eq "output:[[:space:]]*[\"']standalone[\"']" "$f"; then
pass 'next.config.ts contains output: "standalone"'
else
fail 'next.config.ts contains output: "standalone"' "pattern not found"
fi
}
# --- .dockerignore -------------------------------------------------- #
check_dockerignore() {
local f="admin-web/.dockerignore"
if [[ ! -f "$f" ]]; then
fail ".dockerignore exists" "file missing"
return
fi
pass ".dockerignore exists"
file_contains "$f" ".dockerignore excludes node_modules" '^node_modules$'
file_contains "$f" ".dockerignore excludes .next" '^\.next(/.*)?$'
file_contains "$f" ".dockerignore excludes .git" '^\.git(/.*)?$'
file_contains "$f" ".dockerignore excludes .env" '^\.env(\..*)?$'
file_not_contains "$f" ".dockerignore does NOT exclude package-lock.json" '^package-lock\.json$'
}
# --- workflow YAML validity ----------------------------------------- #
check_workflow_valid() {
yaml_valid ".github/workflows/admin-web-docker.yml" "admin-web-docker.yml is valid YAML"
}
# --- workflow: exactly 4 jobs --------------------------------------- #
check_workflow_jobs() {
local label="workflow has exactly 4 jobs: test + build-validation + semantic-release + build-and-push"
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local out
if ! out=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
jobs=list((d.get('jobs') or {}).keys())
print(','.join(sorted(jobs)))
" "$f" 2>/dev/null); then
fail "$label" "could not parse jobs"
return
fi
if [[ "$out" == "build-and-push,build-validation,semantic-release,test" ]]; then
pass "$label"
else
fail "$label" "jobs=[$out]"
fi
}
# --- workflow: build-and-push needs semantic-release + test ---------- #
check_workflow_needs() {
local label="build-and-push needs: [semantic-release, test]"
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local out
out=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
bp=d.get('jobs',{}).get('build-and-push',{})
n=bp.get('needs')
if isinstance(n,list): print(','.join(n))
else: print(str(n))
" "$f" 2>/dev/null || true)
if [[ "$out" == "semantic-release,test" ]]; then
pass "$label"
else
fail "$label" "needs='$out'"
fi
}
# --- workflow: skip-ci guard on both jobs -------------------------- #
check_skip_ci_guard() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "both jobs guard against [skip ci] + [ci skip]" "file missing"
return
fi
# Need at least 2 occurrences of each literal pattern (one per job minimum).
# We accept "≥2" rather than "exactly 2" to stay robust to multi-line ifs.
local c_skip c_ciskip
c_skip=$(grep -Fc "[skip ci]" "$f" || true)
c_ciskip=$(grep -Fc "[ci skip]" "$f" || true)
if [[ "$c_skip" -ge 2 && "$c_ciskip" -ge 2 ]]; then
pass "both jobs guard against [skip ci] + [ci skip]"
else
fail "both jobs guard against [skip ci] + [ci skip]" "[skip ci] x$c_skip, [ci skip] x$c_ciskip (need >=2 each)"
fi
}
# --- workflow: build-and-push if condition ------------------------- #
check_bp_if_condition() {
local label="build-and-push if = skip-ci guard AND (released || workflow_dispatch)"
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
# On extrait la condition `if:` du job build-and-push.
local cond
cond=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
print(d.get('jobs',{}).get('build-and-push',{}).get('if','') or '')
" "$f" 2>/dev/null || true)
if [[ -z "$cond" ]]; then
fail "$label" "no 'if' on build-and-push"
return
fi
# La condition doit mentionner : [skip ci], [ci skip], released, workflow_dispatch
local miss=()
grep -Fq "[skip ci]" <<<"$cond" || miss+=("[skip ci]")
grep -Fq "[ci skip]" <<<"$cond" || miss+=("[ci skip]")
grep -Eq "released" <<<"$cond" || miss+=("released")
grep -Eq "workflow_dispatch" <<<"$cond" || miss+=("workflow_dispatch")
if [[ ${#miss[@]} -eq 0 ]]; then
pass "$label"
else
fail "$label" "condition missing: ${miss[*]} (cond=$cond)"
fi
}
# --- workflow: docker login uses --password-stdin + env, no inline --#
check_docker_login() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "docker login uses --password-stdin + env (no inline secret)" "file missing"
return
fi
# doit contenir --password-stdin et echo "$DOCKER_PASSWORD"
if grep -Eq -- '--password-stdin' "$f" && grep -Fq 'echo "$DOCKER_PASSWORD"' "$f"; then
# On n'interdit ${{ secrets.DOCKER_PASSWORD }} que DANS les blocs run:
# (secret inline = fuite potentielle dans les logs). Une référence dans un
# bloc env: est au contraire la bonne pratique et ne doit PAS échouer.
# On extrait donc le contenu des run: via awk, puis on grep ce contenu.
# Cas couverts : run: <cmd> (mono-ligne) et run: | ou run: > (multi-lignes).
local runs
runs=$(awk '
/^[[:space:]]*run:[[:space:]]*[|>][-+]?[[:space:]]*$/ { in_run=1; next }
/^[[:space:]]*run:[[:space:]].+/ { in_run=0; print; next }
in_run && /^[[:space:]]+/ { print; next }
in_run { in_run=0 }
' "$f")
# Regex tolérante au whitespace : ${{ secrets.DOCKER_PASSWORD }}, ${{secrets.DOCKER_PASSWORD}}, etc.
if echo "$runs" | grep -Eq '\$\{\{\s*secrets\.DOCKER_PASSWORD\s*\}\}'; then
fail "docker login uses --password-stdin + env (no inline secret)" \
"secrets.DOCKER_PASSWORD must not be used inline in a run: block — pass via env + --password-stdin"
else
pass "docker login uses --password-stdin + env (no inline secret)"
fi
else
fail "docker login uses --password-stdin + env (no inline secret)" "missing --password-stdin or echo \"\$DOCKER_PASSWORD\""
fi
}
# --- workflow: login uses DOCKER_LOGIN (not DOCKER_USERNAME) ------- #
check_docker_login_secret_name() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "docker login uses secrets.DOCKER_LOGIN" "file missing"
return
fi
if grep -Fq 'secrets.DOCKER_LOGIN' "$f"; then
pass "docker login uses secrets.DOCKER_LOGIN"
else
fail "docker login uses secrets.DOCKER_LOGIN" "secrets.DOCKER_LOGIN not referenced"
fi
file_not_contains "$f" "workflow does not use secrets.DOCKER_USERNAME" 'secrets\.DOCKER_USERNAME'
}
# --- workflow: push :VERSION + :latest + set -e ------------------- #
check_push_tags() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "workflow pushes :VERSION and :latest with set -e" "file missing"
return
fi
local has_version=0 has_latest=0 has_set_e=0
grep -Eq '\$\{?VERSION\}?|:\$\{VERSION\}' "$f" && has_version=1 || true
grep -Eq ':latest' "$f" && has_latest=1 || true
grep -Eq 'set -e' "$f" && has_set_e=1 || true
if [[ $has_version -eq 1 && $has_latest -eq 1 && $has_set_e -eq 1 ]]; then
pass "workflow pushes :VERSION and :latest with set -e"
else
fail "workflow pushes :VERSION and :latest with set -e" "VERSION=$has_version latest=$has_latest set-e=$has_set_e"
fi
}
# --- workflow: forbidden secrets ----------------------------------- #
check_forbidden_secrets() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "workflow forbids secrets.(GITEA_TOKEN|GITHUB_TOKEN) (use CI_GITEA_TOKEN)" "file missing"
fail "workflow forbids GEMINI_API_KEY + SUPABASE_SERVICE_ROLE_KEY" "file missing"
return
fi
file_not_contains "$f" "workflow forbids secrets.(GITEA_TOKEN|GITHUB_TOKEN) (use CI_GITEA_TOKEN)" 'secrets\.(GITEA_TOKEN|GITHUB_TOKEN)'
file_not_contains "$f" "workflow forbids GEMINI_API_KEY + SUPABASE_SERVICE_ROLE_KEY" '(GEMINI_API_KEY|SUPABASE_SERVICE_ROLE_KEY)'
}
# --- workflow: build-args from vars.NEXT_PUBLIC_* ------------------ #
check_buildargs_source() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "build-args fed by vars.NEXT_PUBLIC_* (not secrets.*)" "file missing"
return
fi
if grep -Eq 'vars\.NEXT_PUBLIC_(SUPABASE_URL|SUPABASE_ANON_KEY)' "$f"; then
pass "build-args fed by vars.NEXT_PUBLIC_*"
else
fail "build-args fed by vars.NEXT_PUBLIC_*" "no vars.NEXT_PUBLIC_* reference"
fi
}
# --- workflow: actions pinned by SHA (no @vN) --------------------- #
check_actions_pinned() {
local f=".github/workflows/admin-web-docker.yml"
if [[ ! -f "$f" ]]; then
fail "actions pinned by SHA (no @vN)" "file missing"
return
fi
# Pour checkout / setup-node / setup-buildx-action : aucun @v<digit>
local bad
bad=$(grep -E 'uses:[[:space:]]+(actions/checkout|actions/setup-node|docker/setup-buildx-action)@v[0-9]' "$f" || true)
if [[ -z "$bad" ]]; then
pass "actions pinned by SHA (no @vN)"
else
fail "actions pinned by SHA (no @vN)" "non-pinned: $(echo "$bad" | tr '\n' '|')"
fi
}
# --- workflow: no sudo (runner Gitea ubuntu-latest = root, pas de binaire sudo) -- #
check_no_sudo() {
local f=".github/workflows/admin-web-docker.yml"
local label="workflow contains no 'sudo' (runner Gitea ubuntu-latest is root without sudo)"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local count
count=$(grep -Ec '\bsudo\b' "$f" || true)
if [[ "$count" -eq 0 ]]; then
pass "$label"
else
fail "$label" "found $count occurrence(s) of 'sudo'"
fi
}
# --- git context --------------------------------------------------- #
check_git_branch() {
local label="current branch = feat/admin-web-docker"
local b
b="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if [[ "$b" == "feat/admin-web-docker" ]]; then
pass "$label"
else
fail "$label" "branch='$b'"
fi
}
check_git_commit_prefix() {
local label="HEAD commit message starts with 'feat:'"
local subj
subj="$(git log -1 --format=%s 2>/dev/null || true)"
if [[ "$subj" == feat:* ]]; then
pass "$label"
else
fail "$label" "subject='$subj'"
fi
}
# =================================================================== #
# PR BUILD-VALIDATION FEATURE CHECKS
# (trigger pull_request + job build-validation build-only/no-secret)
# =================================================================== #
# --- workflow: pull_request trigger with admin-web/** path filter --- #
check_pr_trigger() {
local f=".github/workflows/admin-web-docker.yml"
local label="workflow triggers on pull_request with admin-web/** path filter"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local out
out=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
on=d.get(True) or d.get('on') or {}
if not isinstance(on,dict): print('NO-PR: on not a dict'); sys.exit()
pr=on.get('pull_request')
if pr is None: print('NO-PR: no pull_request key'); sys.exit()
paths=(pr.get('paths') if isinstance(pr,dict) else None) or []
print('|'.join(str(p) for p in paths))
" "$f" 2>/dev/null || echo "ERR")
if echo "$out" | grep -Eq 'admin-web/\*\*'; then
pass "$label"
else
fail "$label" "pull_request.paths=[$out]"
fi
}
# --- build-validation job: exists + if: pull_request ---------------- #
check_build_validation_if() {
local f=".github/workflows/admin-web-docker.yml"
local label="build-validation job if: github.event_name == 'pull_request'"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local cond
cond=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
bv=d.get('jobs',{}).get('build-validation',{})
print(bv.get('if','') or '')
" "$f" 2>/dev/null || true)
if echo "$cond" | grep -Eq "github\.event_name\s*==\s*'pull_request'"; then
pass "$label"
else
fail "$label" "if='$cond'"
fi
}
# --- build-validation job: NO docker push (build-only) -------------- #
check_build_validation_no_push() {
local f=".github/workflows/admin-web-docker.yml"
local label="build-validation does NOT docker push (build-only)"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local out
out=$(python3 -c "
import yaml,sys,json
d=yaml.safe_load(open(sys.argv[1]))
bv=d.get('jobs',{}).get('build-validation',{})
blob=json.dumps(bv)
print('PUSH' if 'docker push' in blob else 'NO-PUSH')
" "$f" 2>/dev/null || echo "ERR")
if [[ "$out" == "NO-PUSH" ]]; then
pass "$label"
else
fail "$label" "docker push found in build-validation job"
fi
}
# --- build-validation job: NO secrets (public vars only) ------------ #
check_build_validation_no_secrets() {
local f=".github/workflows/admin-web-docker.yml"
local label="build-validation references no secrets (build-only, public vars only)"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local out
out=$(python3 -c "
import yaml,sys,json
d=yaml.safe_load(open(sys.argv[1]))
bv=d.get('jobs',{}).get('build-validation',{})
blob=json.dumps(bv)
print('SECRET' if 'secrets.' in blob else 'NO-SECRET')
" "$f" 2>/dev/null || echo "ERR")
if [[ "$out" == "NO-SECRET" ]]; then
pass "$label"
else
fail "$label" "secrets.* referenced in build-validation job"
fi
}
# --- semantic-release if excludes pull_request ---------------------- #
check_sr_excludes_pr() {
local f=".github/workflows/admin-web-docker.yml"
local label="semantic-release if excludes pull_request"
if [[ ! -f "$f" ]]; then
fail "$label" "file missing"
return
fi
local cond
cond=$(python3 -c "
import yaml,sys
d=yaml.safe_load(open(sys.argv[1]))
print(d.get('jobs',{}).get('semantic-release',{}).get('if','') or '')
" "$f" 2>/dev/null || true)
if echo "$cond" | grep -Eq "github\.event_name\s*!=\s*'pull_request'"; then
pass "$label"
else
fail "$label" "if missing pull_request exclusion (if='$cond')"
fi
}
# =================================================================== #
# RUN ALL
# =================================================================== #
if [[ "$SUMMARY_ONLY" -eq 0 ]]; then
echo "# verify-admin-web-docker.sh — structural validation"
echo "# repo: $ROOT"
echo ""
fi
check_version_json
check_root_package_json
check_root_pkg_lock
check_releaserc
check_dockerfile
check_next_config
check_dockerignore
check_workflow_valid
check_workflow_jobs
check_workflow_needs
check_skip_ci_guard
check_bp_if_condition
check_docker_login
check_docker_login_secret_name
check_push_tags
check_forbidden_secrets
check_buildargs_source
check_actions_pinned
check_no_sudo
check_git_branch
check_git_commit_prefix
# PR build-validation feature checks
check_pr_trigger
check_build_validation_if
check_build_validation_no_push
check_build_validation_no_secrets
check_sr_excludes_pr
echo ""
echo "# Summary: $PASS_COUNT passed, $FAIL_COUNT failed"
if [[ "$FAIL_COUNT" -gt 0 ]]; then
echo "# Failures:"
for f in "${FAIL_NAMES[@]}"; do
echo "# - $f"
done
fi
if [[ "$FAIL_COUNT" -gt 0 ]]; then
exit 1
fi
exit 0

View File

@@ -18,8 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>tabatago</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>1</string>
<key>NSHealthShareUsageDescription</key>
<string>TabataGo reads your health data to show fitness stats and personalize your workouts.</string>
<key>NSHealthUpdateUsageDescription</key>

View File

@@ -9,8 +9,6 @@
<string>health-records</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.millianlmx.tabatago</string>
</array>
<array/>
</dict>
</plist>

View File

@@ -28,55 +28,12 @@ final class AudioService {
try audioSession.setCategory(
.playback,
mode: .default,
options: [.allowAirPlay, .allowBluetooth]
options: [.mixWithOthers, .allowAirPlay, .allowBluetooth]
)
try audioSession.setActive(true)
} catch {
print("[Audio] Session configuration failed: \(error)")
}
// Interruption Handler
NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .ended {
guard let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt,
AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) else { return }
do {
try self.audioSession.setActive(true)
} catch {
print("[Audio] Re-activation failed after interruption: \(error)")
}
}
}
// Route Change Handler
NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
switch reason {
case .newDeviceAvailable, .oldDeviceUnavailable:
do {
try self.audioSession.setActive(true)
} catch {
print("[Audio] Route re-activation failed: \(error)")
}
default:
break
}
}
}
// Phase Audio Cues

View File

@@ -39,7 +39,6 @@ final class PlayerViewModel: ObservableObject {
@Published var isComplete: Bool = false
@Published var showExitConfirmation: Bool = false
@Published private(set) var completedSession: WorkoutSession? = nil
@Published var liveActivityError: String? = nil
// Track info for Live Activity set by View when music changes
var currentTrackTitle = ""
@@ -383,10 +382,7 @@ final class PlayerViewModel: ObservableObject {
// Dynamic Island / Live Activity
func syncActivity(shouldAlert: Bool = false) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
liveActivityError = "Live Activities are disabled in Settings"
return
}
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard isRunning else { return }
let isPlayingMusic = (phase == .work || phase == .rest) && isRunning && !isPaused
@@ -453,14 +449,8 @@ final class PlayerViewModel: ObservableObject {
attributes: attrs,
content: ActivityContent(state: state, staleDate: staleDate)
)
liveActivityError = nil
observeActivityState()
} catch let authError as ActivityAuthorizationError {
liveActivityError = "Live Activities: \(authError.localizedDescription)"
} catch {
let msg = error.localizedDescription
let truncated = msg.count > 200 ? String(msg.prefix(200)) + "" : msg
liveActivityError = "Live Activity error: \(truncated)"
print("❌ Failed to start Live Activity: \(error.localizedDescription)")
}
}

View File

@@ -66,40 +66,6 @@ struct PlayerView: View {
}
}
//
// Live Activity error banner (non-blocking)
//
if let error = vm.liveActivityError {
VStack {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.yellow)
Text(error)
.font(.caption2.weight(.medium))
.foregroundStyle(.white.opacity(0.9))
Spacer()
Button {
vm.liveActivityError = nil
} label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white.opacity(0.6))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.ultraThinMaterial.opacity(0.9))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 60)
Spacer()
}
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.spring(duration: 0.3), value: vm.liveActivityError)
.zIndex(100)
}
//
// Layer 4 Compact timer ring (top-right corner)
//

View File

@@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>1</string>
<key>NSHealthShareUsageDescription</key>
<string>TabataGo reads your heart rate and calories during workouts.</string>
<key>NSHealthUpdateUsageDescription</key>

View File

@@ -4,6 +4,8 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TabataGoWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View File

@@ -553,7 +553,7 @@ struct WorkoutLiveActivity: Widget {
.contentMargins(.leading, 8, for: .expanded)
.contentMargins(.trailing, 8, for: .expanded)
.contentMargins(.bottom, 6, for: .expanded)
.widgetURL(URL(string: "tabatago://workout"))
.widgetURL(URL(string: "tabatago://workout")!)
}
.supplementalActivityFamilies([.small, .medium])
}

View File

@@ -60,10 +60,6 @@ targets:
SUPABASE_ANON_KEY: $(SUPABASE_ANON_KEY)
REVENUECAT_API_KEY: $(REVENUECAT_API_KEY)
POSTHOG_API_KEY: $(POSTHOG_API_KEY)
NSSupportsLiveActivities: true
NSSupportsLiveActivitiesFrequentUpdates: true
UIBackgroundModes:
- audio
entitlements:
path: TabataGo/Resources/TabataGo.entitlements
properties:
@@ -80,8 +76,6 @@ targets:
- target: TabataGoWatch
embed: true
- target: TabataGoWidget
embed: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago
@@ -162,30 +156,6 @@ targets:
TARGETED_DEVICE_FAMILY: "4"
WATCHOS_DEPLOYMENT_TARGET: "11.0"
TabataGoWidget:
type: app-extension
platform: iOS
deploymentTarget: "26.0"
sources:
- path: TabataGoWidget
excludes:
- "**/.DS_Store"
- path: TabataGo/Models/WorkoutActivityAttributes.swift
- path: TabataGo/Models/MusicActivityAttributes.swift
info:
path: TabataGoWidget/Info.plist
properties:
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
NSSupportsLiveActivities: true
NSSupportsLiveActivitiesFrequentUpdates: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.widget
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
TARGETED_DEVICE_FAMILY: "1"
ENABLE_PREVIEWS: YES
TabataGoTests:
type: bundle.unit-test
platform: iOS
@@ -215,7 +185,6 @@ schemes:
build:
targets:
TabataGo: all
TabataGoWidget: all
run:
config: Debug
archive:

View File

@@ -1,3 +0,0 @@
{
"version": "1.0.0"
}

View File

@@ -11,8 +11,8 @@ RUN apt-get update && \
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY package.json package-lock.json* ./
RUN npm install --production
COPY server.js ./