Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ce3fbb56 | ||
|
|
61a27a0e8e | ||
|
|
4637cb867b | ||
|
|
3873849de1 | ||
|
|
f22ca71558 | ||
|
|
7d5ec8894c | ||
|
|
f2fc7643a2 | ||
|
|
06b6498f7a | ||
|
|
c5c186242b | ||
|
|
3112a4f985 | ||
|
|
ddd31bd2dd | ||
|
|
f07c15972c | ||
|
|
210d7d051d | ||
|
|
0d2098449a | ||
|
|
cafe671e85 | ||
|
|
2401ebb674 | ||
|
|
6f39eb897c | ||
|
|
670746d70c | ||
|
|
a345d91ae4 | ||
|
|
19fbbcddce | ||
|
|
7817f0c13b | ||
|
|
c19aeffb98 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a | ||
|
|
cc7d5cf4ff | ||
|
|
8d9f1e6fde | ||
|
|
d61d91b6ae | ||
|
|
ba92bc4cb6 | ||
|
|
c353bfac3a | ||
|
|
9024a5a1fa | ||
|
|
35e4bba859 | ||
|
|
50535dbed0 | ||
|
|
f5ccb2a2a9 | ||
|
|
29ef97a86c | ||
|
|
8d646ce770 | ||
|
|
f63e51c96e | ||
|
|
4810e113f3 | ||
|
|
82c95d32c0 | ||
|
|
d4f92cd259 | ||
|
|
6b44835347 | ||
|
|
683196ddf5 | ||
|
|
1548ae8cd5 | ||
|
|
fd8b896c56 | ||
|
|
2e95608b05 | ||
|
|
d393c8c175 | ||
|
|
668e240e12 | ||
|
|
0831d37d3c | ||
|
|
60dff74507 | ||
|
|
f995440098 | ||
|
|
7a5ee1aa5b | ||
|
|
3639527b0e | ||
|
|
f4816124c2 | ||
|
|
664b50d4ef | ||
|
|
1768f5a38e | ||
|
|
f2453ba0a3 | ||
|
|
7b769eeb24 | ||
|
|
391e0c8a6f | ||
|
|
0149e06c42 | ||
|
|
832b362254 | ||
|
|
eee9ba1c13 | ||
|
|
aaecffbdfa | ||
|
|
917c10c1d3 | ||
|
|
14a0d65a01 | ||
|
|
468d436d3e | ||
|
|
c85a82b9ac | ||
|
|
3de5215b5e | ||
|
|
5ffd471a45 | ||
|
|
1da6fbd1c4 | ||
|
|
5204536f92 | ||
|
|
7a84cd9162 | ||
|
|
4f26ddd2cc | ||
|
|
b7859d4ede | ||
|
|
cbdca71e3f | ||
|
|
fd31609cb3 | ||
|
|
009519ee62 | ||
|
|
b6f9b5a3a4 | ||
|
|
7387a2b56e | ||
|
|
9ff103d4d0 | ||
|
|
32418b42a0 | ||
|
|
bf2208e77b | ||
|
|
5702d45bc6 | ||
|
|
42352b4c2d | ||
|
|
729416e5ef | ||
|
|
41b3be926a | ||
|
|
fc67e7cf0b | ||
|
|
9b3c06fc6f | ||
|
|
f0e3c4812b | ||
|
|
4c7d8c6f2e | ||
|
|
74dd8e3691 | ||
|
|
7782d93fe9 | ||
|
|
fd26b0ee81 | ||
|
|
a01902356d | ||
|
|
0692cb3aea | ||
|
|
de3f067372 | ||
|
|
a0c11e4bb7 | ||
|
|
71e7fac555 | ||
|
|
6dc27a355f | ||
|
|
eeec725f08 | ||
|
|
97df3193e3 | ||
|
|
ed2d67934c |
@@ -8,6 +8,8 @@ on:
|
||||
- "fastlane/**"
|
||||
- "ios/**"
|
||||
- "scripts/mobile/**"
|
||||
- "tests/node/app-store-connect.test.mjs"
|
||||
- ".github/workflows/app-store-readiness.yml"
|
||||
- "Gemfile*"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -20,21 +22,22 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: App Store Readiness
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Preserve a generated lock for review
|
||||
if: steps.fastlane-lock.outcome == 'failure'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: generated-fastlane-lock
|
||||
path: Gemfile.lock
|
||||
@@ -61,12 +64,25 @@ jobs:
|
||||
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate App Store metadata and available assets
|
||||
run: node scripts/mobile/validate-app-store.mjs
|
||||
- name: Install the pinned Fastlane dependency graph
|
||||
run: bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Validate strict App Store metadata and candidate assets
|
||||
run: node scripts/mobile/validate-app-store.mjs --strict
|
||||
|
||||
- name: Validate native mobile permissions
|
||||
run: node scripts/mobile/check-permissions.mjs
|
||||
|
||||
- name: Test App Store Connect automation
|
||||
run: node --test tests/node/app-store-connect.test.mjs
|
||||
|
||||
- name: Validate Fastlane configuration
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Validate JavaScript syntax
|
||||
run: |
|
||||
node --check scripts/mobile/validate-app-store.mjs
|
||||
node --check scripts/mobile/app-store-connect.mjs
|
||||
node --check scripts/mobile/create-ios-release-manifest.mjs
|
||||
node --check tests/node/app-store-connect.test.mjs
|
||||
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [master, beta, canary, internal]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [master, beta, canary, internal]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
# v5.0.1
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require Qodana project token
|
||||
shell: bash
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN:-}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
||||
with:
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
upload-result: false
|
||||
use-annotations: true
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
@@ -1,99 +0,0 @@
|
||||
name: cPanel Root Audit and Restore
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
|
||||
required: true
|
||||
default: audit
|
||||
type: choice
|
||||
options:
|
||||
- audit
|
||||
- restore
|
||||
recovery:
|
||||
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
|
||||
required: false
|
||||
type: string
|
||||
state_token:
|
||||
description: Exact 64-character audit-metadata state token reported by the audit.
|
||||
required: false
|
||||
type: string
|
||||
confirmation:
|
||||
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
audit-or-restore:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: frontend-production
|
||||
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Audit cPanel primary webroot
|
||||
if: inputs.mode == 'audit'
|
||||
id: audit
|
||||
run: node scripts/release/cpanel-root.mjs audit
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
|
||||
|
||||
- name: Validate restore inputs
|
||||
if: inputs.mode == 'restore'
|
||||
env:
|
||||
RECOVERY: ${{ inputs.recovery }}
|
||||
STATE_TOKEN: ${{ inputs.state_token }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
run: |
|
||||
test -n "$RECOVERY"
|
||||
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
|
||||
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
|
||||
|
||||
- name: Restore retained cPanel webroot
|
||||
if: inputs.mode == 'restore'
|
||||
run: node scripts/release/cpanel-root.mjs restore
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
|
||||
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
|
||||
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
|
||||
|
||||
- name: Upload cPanel root report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
|
||||
path: output/cpanel-root
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Deploy pleno-vue to Hetzner (staging)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-pleno-vue
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
|
||||
jobs:
|
||||
test-and-deploy:
|
||||
name: Build + Deploy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install + Build
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.10.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
|
||||
- name: Add host key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Pre-deploy snapshot
|
||||
id: pre
|
||||
run: |
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
set -e
|
||||
cd /opt/pleno-vue
|
||||
git rev-parse HEAD > /tmp/last_deploy_sha
|
||||
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
|
||||
'
|
||||
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
run: |
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
set -e
|
||||
cd /opt/pleno-vue
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
sudo systemctl reload nginx || true
|
||||
sudo systemctl reload pleno-vue || true
|
||||
echo "Deploy complete: $(git rev-parse --short HEAD)"
|
||||
'
|
||||
|
||||
- name: Smoke test
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
env:
|
||||
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
|
||||
run: |
|
||||
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
|
||||
|
||||
- name: Sergii Review Batch smoke test (TRU-96)
|
||||
id: smoke_sergii
|
||||
continue-on-error: true
|
||||
env:
|
||||
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
|
||||
run: |
|
||||
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
|
||||
|
||||
- name: Auto-rollback on smoke failure
|
||||
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
|
||||
run: |
|
||||
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
|
||||
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
set -e
|
||||
cd /opt/pleno-vue
|
||||
git reset --hard ${{ steps.pre.outputs.pre_sha }}
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
sudo systemctl reload nginx || true
|
||||
'
|
||||
|
||||
- name: Post Slack status
|
||||
if: always()
|
||||
uses: slackapi/slack-github-action@v4.0.0
|
||||
with:
|
||||
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
|
||||
payload: |
|
||||
{
|
||||
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
|
||||
}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
|
||||
steps:
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
promote:
|
||||
name: Sync storefront and prepare manual review
|
||||
name: Sync and verify App Store candidate
|
||||
needs: resolve
|
||||
if: needs.resolve.outputs.enabled == 'true'
|
||||
runs-on: macos-15
|
||||
@@ -119,19 +119,19 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout exact candidate source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Ruby and pinned Fastlane
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -153,22 +153,33 @@ jobs:
|
||||
- name: Sync metadata and screenshots without App Review submission
|
||||
run: bundle exec fastlane ios prepare_candidate
|
||||
|
||||
- name: Configure automatic release after approval
|
||||
run: node scripts/mobile/app-store-connect.mjs configure-release-policy
|
||||
|
||||
- name: Read back exact App Store candidate
|
||||
id: readback
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-store-version
|
||||
|
||||
- name: Verify Denmark-only availability and no preorder
|
||||
id: availability
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-availability
|
||||
|
||||
- name: Write candidate handoff
|
||||
env:
|
||||
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
||||
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
|
||||
RELEASE_TYPE: ${{ steps.readback.outputs.release_type }}
|
||||
AVAILABLE_TERRITORIES: ${{ steps.availability.outputs.available_territories }}
|
||||
run: |
|
||||
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release policy: \`$RELEASE_TYPE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Availability: \`$AVAILABLE_TERRITORIES\` only; preorder disabled" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Review submission remains manual; Apple will release automatically after approval." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
disabled:
|
||||
name: Promotion disabled
|
||||
|
||||
@@ -44,12 +44,12 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout same-repository history
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout resolved source
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.source_sha }}
|
||||
fetch-depth: 1
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -596,7 +596,7 @@ jobs:
|
||||
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload device-debug artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
||||
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
current: ${{ steps.resolve.outputs.current }}
|
||||
steps:
|
||||
- name: Checkout repository history
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -82,6 +82,14 @@ jobs:
|
||||
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Require green WebKit mobile tests before App Store upload
|
||||
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
deliver:
|
||||
name: Sign, upload, process, and distribute
|
||||
needs: prepare
|
||||
@@ -103,7 +111,7 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout verified source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||
fetch-depth: 1
|
||||
@@ -134,13 +142,19 @@ jobs:
|
||||
const fs = require("node:fs");
|
||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||
const checks = {
|
||||
schema: proof.schemaVersion === 1,
|
||||
schema: proof.schemaVersion === 2,
|
||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
||||
exactSource: proof.sha === process.env.IOS_SOURCE_SHA,
|
||||
releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0,
|
||||
archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""),
|
||||
activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0,
|
||||
verification: proof.verificationState === "verified",
|
||||
publicGate: proof.livePublicGate === "passed",
|
||||
credentialedGate: proof.liveCredentialedGate === "passed",
|
||||
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||
managerGate: proof.releaseManagerGate === "passed",
|
||||
serverVersion: proof.serverVersionUpdated === true,
|
||||
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||
};
|
||||
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
||||
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
||||
@@ -159,13 +173,13 @@ jobs:
|
||||
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Ruby and pinned Fastlane
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
@@ -388,7 +402,7 @@ jobs:
|
||||
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
|
||||
|
||||
- name: Upload signed IPA
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
|
||||
path: output/ios-release/*.ipa
|
||||
@@ -396,7 +410,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload release manifest, dSYM, and checksums
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
|
||||
path: |
|
||||
|
||||
@@ -30,53 +30,42 @@ on:
|
||||
description: Google Play release status for manual dispatches
|
||||
required: false
|
||||
type: choice
|
||||
default: completed
|
||||
default: inProgress
|
||||
options:
|
||||
- completed
|
||||
- draft
|
||||
- inProgress
|
||||
- draft
|
||||
- halted
|
||||
push:
|
||||
tags:
|
||||
- "mobile-v*"
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Automated Tests
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
|
||||
group: android-store-artifacts-${{ github.ref_name || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android AAB and Play upload
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == github.event.repository.default_branch)
|
||||
runs-on: ubuntu-24.04
|
||||
environment: mobile-store-production
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
||||
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
|
||||
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
|
||||
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
|
||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
|
||||
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
|
||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
|
||||
PLAY_STORE_USER_FRACTION: "0.01"
|
||||
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Guard current master release
|
||||
@@ -84,50 +73,59 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current=true
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
else
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||
else
|
||||
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Require green Chromium mobile tests before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
TEST_WORKFLOW_RUN_ID: ""
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
@@ -203,17 +201,64 @@ jobs:
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
||||
|
||||
- name: Record immutable Android artifact proof
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
id: artifact-proof
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_sha256="$(sha256sum "$ANDROID_AAB_PATH" | awk '{print $1}')"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||||
echo "ANDROID_AAB_SHA256=$artifact_sha256" >> "$GITHUB_ENV"
|
||||
echo "sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
echo "Android AAB SHA-256: \`$artifact_sha256\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Android artifact
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||
path: ${{ env.ANDROID_AAB_PATH }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Recheck live master before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
live_master_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
|
||||
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
|
||||
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload Android App Bundle to Google Play
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
id: play-upload
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||
run: npm run mobile:android:play-upload
|
||||
|
||||
- name: Record Google Play submission proof
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
ARTIFACT_SHA256: ${{ steps.artifact-proof.outputs.sha256 }}
|
||||
PLAY_EDIT_ID: ${{ steps.play-upload.outputs.play_edit_id }}
|
||||
PLAY_VERSION_CODE: ${{ steps.play-upload.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$ARTIFACT_SHA256"
|
||||
test -n "$PLAY_EDIT_ID"
|
||||
test -n "$PLAY_VERSION_CODE"
|
||||
printf 'Google Play submission proof: platform=android applicationId=%s version=%s buildNumber=%s artifactSha256=%s signingIdentityRef=%s storeSubmissionId=%s status=%s fraction=%s\n' \
|
||||
"$ANDROID_PACKAGE_NAME" "$MOBILE_VERSION_NAME" "$PLAY_VERSION_CODE" \
|
||||
"$ARTIFACT_SHA256" "$ANDROID_SIGNING_IDENTITY_REF" "$PLAY_EDIT_ID" \
|
||||
"$PLAY_STORE_RELEASE_STATUS" "$PLAY_STORE_USER_FRACTION" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
name: Frontend Release Recovery
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: Verify the active release or roll back before verification
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- reverify
|
||||
- rollback
|
||||
source_sha:
|
||||
description: Exact 40-character commit SHA expected after recovery
|
||||
required: true
|
||||
type: string
|
||||
rollback_target:
|
||||
description: Immutable releases/.../dist target; required for rollback
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
name: Protected production recovery
|
||||
runs-on: ubuntu-latest
|
||||
environment: frontend-production
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
steps:
|
||||
- name: Validate exact recovery target
|
||||
shell: bash
|
||||
env:
|
||||
RECOVERY_ACTION: ${{ inputs.action }}
|
||||
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$RECOVERY_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
if [[ "$RECOVERY_ACTION" == "rollback" ]]; then
|
||||
[[ "$RECOVERY_TARGET" =~ ^releases/[A-Za-z0-9._-]+/dist$ ]]
|
||||
else
|
||||
[[ -z "$RECOVERY_TARGET" ]]
|
||||
fi
|
||||
|
||||
- name: Checkout exact recovery source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.source_sha }}
|
||||
|
||||
- name: Authorize source from successful release proof
|
||||
id: authorize
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RECOVERY_ACTION: ${{ inputs.action }}
|
||||
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
runs="$RUNNER_TEMP/recovery-runs.json"
|
||||
artifacts="$RUNNER_TEMP/recovery-artifacts.json"
|
||||
curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?head_sha=$RECOVERY_SHA&status=success&per_page=20" \
|
||||
> "$runs"
|
||||
release_run_id="$(jq -r '[.workflow_runs[] | select(.event == "workflow_run")] | first | .id // empty' "$runs")"
|
||||
[[ "$release_run_id" =~ ^[0-9]+$ ]]
|
||||
artifact_name="frontend-release-proof-$RECOVERY_SHA"
|
||||
curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$release_run_id/artifacts?name=$artifact_name&per_page=20" \
|
||||
> "$artifacts"
|
||||
artifact_id="$(jq -r '[.artifacts[] | select(.expired == false)] | first | .id // empty' "$artifacts")"
|
||||
[[ "$artifact_id" =~ ^[0-9]+$ ]]
|
||||
mkdir -p "$RUNNER_TEMP/recovery-proof"
|
||||
curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||
-o "$RUNNER_TEMP/recovery-proof.zip"
|
||||
unzip -q "$RUNNER_TEMP/recovery-proof.zip" -d "$RUNNER_TEMP/recovery-proof"
|
||||
PROOF_PATH="$RUNNER_TEMP/recovery-proof/frontend-release-proof.json" \
|
||||
RELEASE_RUN_ID="$release_run_id" node <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require("node:fs");
|
||||
const proof = JSON.parse(readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||
const sha = process.env.RECOVERY_SHA;
|
||||
const target = process.env.RECOVERY_TARGET;
|
||||
const expectedPrefix = `releases/${sha}-`;
|
||||
const valid = proof.schemaVersion === 2
|
||||
&& proof.repository === process.env.GITHUB_REPOSITORY
|
||||
&& proof.sha === sha
|
||||
&& proof.sourceSha === sha
|
||||
&& proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID
|
||||
&& proof.verificationState === "verified"
|
||||
&& proof.livePublicGate === "passed"
|
||||
&& ["passed", "not-configured"].includes(proof.liveCredentialedGate)
|
||||
&& proof.releaseManagerGate === "passed"
|
||||
&& proof.serverVersionUpdated === true
|
||||
&& proof.serverVersionReadBack === "passed"
|
||||
&& /^[1-9][0-9]*-[1-9][0-9]*$/.test(String(proof.buildId || ""))
|
||||
&& typeof proof.activeTarget === "string"
|
||||
&& proof.activeTarget.startsWith(expectedPrefix)
|
||||
&& proof.activeTarget.endsWith("/dist");
|
||||
if (!valid) throw new Error("Recovery source does not have valid exact-release proof.");
|
||||
if (process.env.RECOVERY_ACTION === "rollback" && target !== proof.activeTarget) {
|
||||
throw new Error("Rollback target does not match the verified release proof.");
|
||||
}
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `verified_target=${proof.activeTarget}\n`);
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `build_id=${proof.buildId}\n`);
|
||||
NODE
|
||||
|
||||
- name: Capture current immutable target
|
||||
id: current
|
||||
shell: bash
|
||||
env:
|
||||
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { appendFileSync } from "node:fs";
|
||||
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Active manifest returned HTTP ${response.status}.`);
|
||||
const manifest = await response.json();
|
||||
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||
const build = String(manifest.build_id || "");
|
||||
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[A-Za-z0-9._-]{1,180}$/.test(build)) {
|
||||
throw new Error("Active manifest has invalid release identity.");
|
||||
}
|
||||
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(build)) {
|
||||
throw new Error("Active manifest build id is not a release run identity.");
|
||||
}
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `previous_sha=${sha}\nprevious_build_id=${build}\nprevious_target=releases/${sha}-${build}/dist\n`);
|
||||
NODE
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install secure FTP client without system changes
|
||||
run: |
|
||||
if command -v lftp >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
package_root="$RUNNER_TEMP/lftp-package"
|
||||
mkdir -p "$package_root"
|
||||
(
|
||||
cd "$package_root"
|
||||
apt-get download lftp
|
||||
dpkg-deb --extract ./lftp_*.deb root
|
||||
)
|
||||
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Roll back atomically
|
||||
if: inputs.action == 'rollback'
|
||||
id: rollback
|
||||
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ inputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
|
||||
- name: Verify active manifest matches authorized release
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.source_sha }}
|
||||
EXPECTED_TARGET: ${{ steps.authorize.outputs.verified_target }}
|
||||
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
const deadline = Date.now() + 300_000;
|
||||
let actual = "";
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const manifest = await response.json();
|
||||
const manifestSha = String(manifest.commit_sha || "").toLowerCase();
|
||||
actual = `releases/${manifestSha}-${String(manifest.build_id || "")}/dist`;
|
||||
if (manifestSha === process.env.EXPECTED_SHA && actual === process.env.EXPECTED_TARGET) process.exit(0);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
}
|
||||
throw new Error(`Active release identity did not converge to the authorized target; observed ${actual || "unavailable"}.`);
|
||||
NODE
|
||||
|
||||
- name: Public live verification
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live verification
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record verified server version
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ inputs.source_sha }}
|
||||
RELEASE_BUILD_ID: ${{ steps.authorize.outputs.build_id }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Publish recovery audit
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-recovery-${{ inputs.source_sha }}-${{ github.run_id }}
|
||||
path: |
|
||||
test-results
|
||||
playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Restore pre-recovery target after downstream failure
|
||||
if: >-
|
||||
failure() && inputs.action == 'rollback'
|
||||
shell: bash
|
||||
run: |
|
||||
node scripts/release/deploy-cpanel.mjs --rollback
|
||||
node --input-type=module <<'NODE'
|
||||
const deadline = Date.now() + 300_000;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(new URL(`release-manifest.json?restore=${Date.now()}`, process.env.PRODUCTION_FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const manifest = await response.json();
|
||||
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||
const target = `releases/${sha}-${String(manifest.build_id || "")}/dist`;
|
||||
if (sha === process.env.RELEASE_VERSION && target === process.env.RELEASE_ROLLBACK_TARGET) process.exit(0);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
}
|
||||
throw new Error("Failed to restore and verify the pre-recovery target.");
|
||||
NODE
|
||||
npm run release:update-server-version
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.current.outputs.previous_target }}
|
||||
RELEASE_VERSION: ${{ steps.current.outputs.previous_sha }}
|
||||
RELEASE_BUILD_ID: ${{ steps.current.outputs.previous_build_id }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'master' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||
release_id: ${{ steps.package.outputs.release_id }}
|
||||
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||
steps:
|
||||
- name: Check release commit is current
|
||||
id: branch-head
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Checkout tested commit
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -68,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -111,6 +112,7 @@ jobs:
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:prod
|
||||
env:
|
||||
PLAYWRIGHT_PROD_PREBUILT: "1"
|
||||
PLAYWRIGHT_PROD_WEBKIT: "0"
|
||||
|
||||
- name: Confirm production gate did not mutate dist
|
||||
@@ -147,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Upload release package
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ steps.package-names.outputs.artifact_name }}
|
||||
path: |
|
||||
@@ -160,7 +162,7 @@ jobs:
|
||||
deploy-frontend-production:
|
||||
needs: build-release
|
||||
if: needs.build-release.outputs.current == 'true'
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
environment:
|
||||
name: frontend-production
|
||||
@@ -183,14 +185,14 @@ jobs:
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 5
|
||||
steps:
|
||||
- name: Checkout tested commit
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -217,7 +219,7 @@ jobs:
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Download validated release package
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||
path: release-artifacts
|
||||
@@ -243,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Check release commit is still current
|
||||
id: branch-head
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
@@ -273,28 +275,45 @@ jobs:
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Public live Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: public_live
|
||||
timeout-minutes: 10
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
- name: Detect credentialed live gate configuration
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: credentialed_live_config
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" &&
|
||||
-n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
|
||||
- name: Credentialed live Playwright gate (when configured)
|
||||
if: >-
|
||||
steps.branch-head.outputs.current == 'true' &&
|
||||
steps.credentialed_live_config.outputs.configured == 'true'
|
||||
id: credentialed_live
|
||||
timeout-minutes: 15
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
@@ -302,26 +321,10 @@ jobs:
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Roll back after live verification failure
|
||||
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
|
||||
timeout-minutes: 10
|
||||
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
id: release_manager
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||
@@ -329,7 +332,7 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
@@ -341,7 +344,9 @@ jobs:
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Create verified frontend release proof
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
@@ -356,38 +361,92 @@ jobs:
|
||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||
return process.env[name];
|
||||
};
|
||||
const requireSuccessfulStep = (name) => {
|
||||
const outcome = required(name);
|
||||
if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`);
|
||||
return "passed";
|
||||
};
|
||||
const credentialedGate = () => {
|
||||
const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED");
|
||||
if (configured === "false") return "not-configured";
|
||||
if (configured !== "true") {
|
||||
throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`);
|
||||
}
|
||||
return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME");
|
||||
};
|
||||
const proof = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
releaseId: required("RELEASE_ID"),
|
||||
sha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||
archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(),
|
||||
activeTarget: required("RELEASE_ACTIVE_TARGET"),
|
||||
rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null,
|
||||
verificationState: "verified",
|
||||
observedAt: new Date().toISOString(),
|
||||
repository: required("GITHUB_REPOSITORY"),
|
||||
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
||||
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
||||
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
||||
buildId: required("RELEASE_BUILD_ID"),
|
||||
livePublicGate: "passed",
|
||||
liveCredentialedGate: "passed",
|
||||
releaseManagerGate: "passed",
|
||||
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||
liveCredentialedGate: credentialedGate(),
|
||||
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||
serverVersionUpdated: true,
|
||||
serverVersionReadBack: "passed",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
||||
NODE
|
||||
env:
|
||||
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }}
|
||||
RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }}
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }}
|
||||
LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }}
|
||||
LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }}
|
||||
RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }}
|
||||
|
||||
- name: Publish verified frontend release proof
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
|
||||
path: output/frontend-release-proof/frontend-release-proof.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Roll back after any post-deployment verification failure
|
||||
if: >-
|
||||
failure() && steps.branch-head.outputs.current == 'true' &&
|
||||
steps.deploy.outcome == 'success'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release/deploy-cpanel.mjs --rollback
|
||||
[[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]]
|
||||
export RELEASE_VERSION="${BASH_REMATCH[1]}"
|
||||
export RELEASE_BUILD_ID="${BASH_REMATCH[2]}"
|
||||
npm run release:update-server-version
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
|
||||
@@ -48,15 +48,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
||||
# - FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL: full-suite parallelism while PR E2E runs beside it.
|
||||
# GitHub-hosted target: ["ubuntu-24.04"], with PR parallelism 10, concurrent full parallelism 26,
|
||||
# and standalone scheduled full parallelism 36. This keeps the workflow peak at 36 hosted jobs.
|
||||
jobs:
|
||||
format-tests:
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
@@ -78,10 +80,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -94,10 +96,15 @@ jobs:
|
||||
- name: Check frontend test formatting
|
||||
run: npm run format:tests:check
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
quality-checks:
|
||||
name: Quality-${{ matrix.check }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
check: [lint, i18n, build, unit-fast, unit-serial]
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
@@ -115,29 +122,68 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check i18n source consistency
|
||||
run: npm run i18n:v2:check
|
||||
|
||||
- name: Build sanity check
|
||||
run: npm run build
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Run quality check
|
||||
shell: bash
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
MATRIX_CHECK: ${{ matrix.check }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_CHECK" in
|
||||
lint)
|
||||
npm run lint
|
||||
;;
|
||||
i18n)
|
||||
npm run i18n:v2:check
|
||||
;;
|
||||
build)
|
||||
npm run build
|
||||
;;
|
||||
unit-fast)
|
||||
npm run text:check-encoding
|
||||
npm run test:unit:fast
|
||||
;;
|
||||
unit-serial)
|
||||
VITEST_BATCH_SIZE=5 npm run test:unit:serial
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported quality check: $MATRIX_CHECK" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
build-and-unit:
|
||||
if: ${{ always() }}
|
||||
name: Build and unit summary
|
||||
needs: [format-tests, quality-checks]
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify quality jobs succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for required_job in FORMAT_TESTS_RESULT QUALITY_CHECKS_RESULT; do
|
||||
result="${!required_job:-missing}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "${required_job}=${result}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
e2e-targeted:
|
||||
if: >
|
||||
@@ -177,10 +223,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -301,7 +347,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-targeted-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -329,7 +375,7 @@ jobs:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
@@ -353,7 +399,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -390,7 +436,7 @@ jobs:
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -405,8 +451,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_SUITE" in
|
||||
core) suite_offset=0 ;;
|
||||
changed) suite_offset=10 ;;
|
||||
changed-1-of-2) suite_offset=0 ;;
|
||||
changed-2-of-2) suite_offset=10 ;;
|
||||
smoke) suite_offset=20 ;;
|
||||
pr) suite_offset=30 ;;
|
||||
ct) suite_offset=40 ;;
|
||||
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$MATRIX_PROJECT" in
|
||||
@@ -414,7 +463,7 @@ jobs:
|
||||
chromium-mobile) project_offset=2 ;;
|
||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
port_seed=$((21000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -485,13 +534,27 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
||||
fi
|
||||
case "$MATRIX_SUITE" in
|
||||
ct)
|
||||
npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
smoke)
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
pr)
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
changed-1-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=1/2 --pass-with-no-tests
|
||||
;;
|
||||
changed-2-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=2/2 --pass-with-no-tests
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
@@ -502,7 +565,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -539,26 +602,38 @@ jobs:
|
||||
e2e-full:
|
||||
if: >
|
||||
always() &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
||||
(
|
||||
github.event_name != 'workflow_dispatch' ||
|
||||
inputs.mode == 'full' ||
|
||||
needs.e2e-targeted.result == 'success'
|
||||
)
|
||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
needs: [build-and-unit, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') > 0 && github.event_name == 'schedule' && fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') || fromJSON(vars.FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL || '26') }}
|
||||
matrix:
|
||||
browser: [chromium, webkit, firefox]
|
||||
device: [mobile, desktop, tablet]
|
||||
role: [superuser, admin, customer, subuser]
|
||||
shard_index: [1, 2]
|
||||
browser: [chromium, webkit, firefox]
|
||||
exclude:
|
||||
- role: customer
|
||||
shard_index: 2
|
||||
- role: subuser
|
||||
shard_index: 2
|
||||
include:
|
||||
- browser: chromium
|
||||
browser_label: Chromium
|
||||
@@ -570,7 +645,7 @@ jobs:
|
||||
browser_label: Firefox
|
||||
browser_install: firefox
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: off
|
||||
@@ -591,10 +666,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -604,6 +679,8 @@ jobs:
|
||||
MATRIX_ROLE: ${{ matrix.role }}
|
||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||
MATRIX_DEVICE: ${{ matrix.device }}
|
||||
MATRIX_SHARD_INDEX: ${{ matrix.shard_index }}
|
||||
MATRIX_SHARD_TOTAL: ${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -626,7 +703,12 @@ jobs:
|
||||
tablet) device_offset=3 ;;
|
||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
case "$MATRIX_SHARD_INDEX" in
|
||||
1) shard_offset=0 ;;
|
||||
2) shard_offset=400 ;;
|
||||
*) echo "Unsupported Playwright shard index: $MATRIX_SHARD_INDEX" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((22000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset + shard_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -660,8 +742,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" \
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" \
|
||||
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
@@ -676,6 +758,8 @@ jobs:
|
||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||
--env MATRIX_SHARD_INDEX="$MATRIX_SHARD_INDEX" \
|
||||
--env MATRIX_SHARD_TOTAL="$MATRIX_SHARD_TOTAL" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
@@ -696,23 +780,52 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE" --shard="$MATRIX_SHARD_INDEX/$MATRIX_SHARD_TOTAL"
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}"
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}/shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
full-e2e-summary:
|
||||
if: >
|
||||
always() &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||
name: Full E2E summary
|
||||
needs: [e2e-full]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify full E2E succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FULL_E2E_RESULT: ${{ needs.e2e-full.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$FULL_E2E_RESULT" != "success" ]]; then
|
||||
echo "E2E_FULL_RESULT=${FULL_E2E_RESULT:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
ruby ">= 3.2", "< 3.5"
|
||||
gem "fastlane", "2.229.1"
|
||||
gem "fastlane", "2.238.0"
|
||||
|
||||
@@ -8,8 +8,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1271.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-partitions (1.1281.0)
|
||||
aws-sdk-core (3.254.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -20,98 +20,95 @@ GEM
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.228.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-s3 (1.229.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
erb (6.0.7)
|
||||
excon (1.7.0)
|
||||
logger
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.229.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
fastlane (2.238.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
@@ -120,50 +117,60 @@ GEM
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.2.5)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.66.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.3)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
io-console (0.9.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (2.10.3)
|
||||
json (2.21.2)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
@@ -173,20 +180,38 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.1.3)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
@@ -197,15 +222,19 @@ GEM
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
@@ -226,7 +255,7 @@ PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.229.1)
|
||||
fastlane (= 2.238.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.12p206
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 16 KiB |
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
||||
server: {
|
||||
androidScheme: "https",
|
||||
},
|
||||
plugins: {
|
||||
StatusBar: {
|
||||
overlaysWebView: false,
|
||||
style: "LIGHT",
|
||||
backgroundColor: "#FFFFFFFF",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -1,31 +1,32 @@
|
||||
# Apple App Store Release Runbook
|
||||
|
||||
This is the operating runbook for the public iOS application and its signed
|
||||
GitHub Actions delivery. Public review submission and public release remain
|
||||
human actions in App Store Connect.
|
||||
GitHub Actions delivery. Public review submission remains a human action in App
|
||||
Store Connect; the approved version releases automatically after Apple approval.
|
||||
|
||||
## Storefront record
|
||||
|
||||
Create or reconcile one App Store Connect record:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Name | Truck Wash Kundeportal |
|
||||
| Bundle ID | `io.truckwash.app` |
|
||||
| SKU | `truckwash-ios` |
|
||||
| Primary language | Danish |
|
||||
| Category | Business |
|
||||
| Price | Free |
|
||||
| Availability | Denmark only |
|
||||
| Support URL | `https://truckwash.io/support` |
|
||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||
| Marketing URL | `https://truckwash.io/` |
|
||||
| Release | Manual after approval |
|
||||
| Setting | Value |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| Name | Truck Wash |
|
||||
| Bundle ID | `io.truckwash.app` |
|
||||
| SKU | `truckwash-ios` |
|
||||
| Primary language | Danish |
|
||||
| Category | Business |
|
||||
| Price | Free |
|
||||
| Availability | Denmark only |
|
||||
| Support URL | `https://truckwash.io/support` |
|
||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||
| Marketing URL | `https://truckwash.io/` |
|
||||
| Release | Automatically after approval |
|
||||
|
||||
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
||||
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||
been tested deliberately.
|
||||
been tested deliberately. Do not enable preorder or phased release for version
|
||||
`1.0.0`, and disable automatic availability in newly added territories.
|
||||
|
||||
The Account Holder or Admin must complete these console-only items before the
|
||||
first candidate:
|
||||
@@ -95,7 +96,9 @@ Configure two GitHub environments:
|
||||
Private repositories on the Team plan cannot rely on environment required
|
||||
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
||||
limited to release managers. Manual App Review submission is the final human
|
||||
approval.
|
||||
approval. App Store Connect API readback must show `AFTER_APPROVAL`, Denmark
|
||||
(`DNK`) as the only available territory, preorder disabled, and automatic
|
||||
future territories disabled.
|
||||
|
||||
Environment secrets:
|
||||
|
||||
@@ -179,11 +182,14 @@ delivery automatically. Stale or proofless releases do not sign or upload.
|
||||
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
|
||||
verifies the exact processed App Store build, enforces complete screenshots,
|
||||
synchronizes Danish metadata, attaches the existing build, and reads it back.
|
||||
It does not rebuild, submit for review, or release publicly.
|
||||
It also writes and verifies automatic release after approval, then verifies
|
||||
Denmark-only availability and no preorder. It does not rebuild or submit for
|
||||
review.
|
||||
5. In App Store Connect, review the rendered product page, review account,
|
||||
privacy/export/age answers, and candidate build. Submit manually.
|
||||
6. Release the first Denmark version manually after approval. Use phased release
|
||||
for later updates unless there is a reason not to.
|
||||
6. Submit version `1.0.0` for review. Apple releases it automatically after
|
||||
approval. Do not use phased release for `1.0.0`; use phased release for later
|
||||
updates unless there is a reason not to.
|
||||
7. Merge the next `ios/release.json` version bump before further delivery after
|
||||
Apple closes the released version to new builds.
|
||||
|
||||
|
||||
@@ -46,33 +46,22 @@ Add these environment **secrets**:
|
||||
- `PRODUCTION_FTP_PASSWORD`
|
||||
- `PRODUCTION_FTP_PATH`
|
||||
- `PRODUCTION_ACTIVATION_KEY`
|
||||
- `PRODUCTION_CPANEL_USER`
|
||||
- `PRODUCTION_CPANEL_API_TOKEN`
|
||||
|
||||
The API `.env` contains legacy values under the first four names, but production
|
||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||
deployment unchanged.
|
||||
|
||||
The cPanel token is separate from the FTP password. Create it in cPanel under
|
||||
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
|
||||
uses the token for fail-closed directory and release-state inspection. It does
|
||||
not use legacy Fileman mutation calls to replace symlinks: on this server those
|
||||
calls can follow the target instead of renaming the link itself. Revoke and
|
||||
rotate the token if it is ever exposed.
|
||||
The hosted release path deliberately does not call the remote cPanel API.
|
||||
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||
activator, exact inventory comparison, and public manifest verification.
|
||||
|
||||
Add these environment **variables**:
|
||||
|
||||
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
|
||||
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
|
||||
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||
|
||||
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
|
||||
fallback. The cPanel URL and path deliberately fail closed when absent. The
|
||||
production environment must keep the explicit
|
||||
`https://server.red-block.com:2083` cPanel origin: the public origin serves
|
||||
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
|
||||
cPanel JSON API.
|
||||
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||
|
||||
### Create the dedicated FTP credentials
|
||||
|
||||
@@ -88,53 +77,26 @@ cPanel JSON API.
|
||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||
these frontend-only credentials back into the API `.env`.
|
||||
|
||||
### Create the missing cPanel credentials
|
||||
|
||||
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
|
||||
separately; do not reuse the FTP password as an API token.
|
||||
|
||||
1. Sign in to the cPanel account that owns the frontend deployment root.
|
||||
2. Record the exact cPanel account username shown in **General Information**.
|
||||
Add it to the `frontend-production` environment as the
|
||||
`PRODUCTION_CPANEL_USER` secret.
|
||||
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
|
||||
hosting provider to enable API Tokens in WHM Feature Manager.
|
||||
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
|
||||
expiration date that matches the team's rotation policy. Expiration cannot
|
||||
be edited later, so add a reminder before that date.
|
||||
5. Click **Create**, copy the token immediately, and add it to the same GitHub
|
||||
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
|
||||
again after leaving the page.
|
||||
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
|
||||
the GitHub secret has been saved.
|
||||
7. Before merging, run the deployment audit against the configured API origin.
|
||||
It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable
|
||||
releases. Do not broaden the token or deployment root beyond this cPanel
|
||||
account.
|
||||
|
||||
The current production token is named `github-pleno-vue-production` and
|
||||
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
|
||||
secret before that date, then revoke the replaced token in cPanel.
|
||||
|
||||
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||
Use **Add secret** for credentials and **Add variable** for the two URLs and the
|
||||
cPanel deployment path.
|
||||
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||
Environment values are available only to the deployment job that names this
|
||||
environment, and configured protection rules are evaluated before its secrets
|
||||
are released.
|
||||
|
||||
The existing live-test, Release Manager, and server-version secrets used by
|
||||
`release.yml` must remain configured. The self-hosted deployment job installs
|
||||
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
|
||||
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
|
||||
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||
and `sha256sum`.
|
||||
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
||||
`sha256sum` for the account-scoped activator.
|
||||
|
||||
## cPanel layout and one-time bootstrap
|
||||
|
||||
The production FTP account is jailed directly to the deployment root, so its
|
||||
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
|
||||
relative to the cPanel account home. The helper creates this layout below it:
|
||||
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||
`frontend-deployments` directory below the account home. The helper creates
|
||||
this layout below it:
|
||||
|
||||
```text
|
||||
archives/
|
||||
@@ -146,7 +108,7 @@ current -> releases/<release-id>/dist
|
||||
```
|
||||
|
||||
The domain's document root must resolve to
|
||||
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
|
||||
`<cPanel account home>/frontend-deployments/current`, not to the deployment
|
||||
root itself. This stable document-root path is what makes replacing `current`
|
||||
atomic: every HTTP request resolves either the complete old release or the
|
||||
complete new release, never a partly uploaded directory.
|
||||
@@ -172,9 +134,8 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
|
||||
listing.
|
||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||
8. Test the cPanel token against the exact host and port. The workflow performs
|
||||
read-only state checks and refuses deployment if `current` or the captured
|
||||
rollback release is missing.
|
||||
8. The server-side activator, rather than the hosted runner, validates that
|
||||
`current` and the captured rollback release exist before every switch.
|
||||
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
||||
hexadecimal form in the protected `frontend-production` environment as
|
||||
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
||||
@@ -205,47 +166,33 @@ first automated run into an unreviewed production cutover.
|
||||
|
||||
### Auditing or restoring the primary webroot
|
||||
|
||||
Use the protected **cPanel Root Audit and Restore** workflow if the primary
|
||||
domain starts showing a directory index or returns 404 for files that cPanel
|
||||
lists in `public_html`. The `audit` mode is read-only: it reports the exact
|
||||
`public_html` entry, whether the internal `current` link can serve the required
|
||||
release files, domain document roots, and retained recovery candidates without
|
||||
printing the cPanel token. API2 does not expose a documented symlink-target
|
||||
field, so the audit deliberately reports `rootTargetVerified: false` instead
|
||||
of claiming that an arbitrary `public_html` link follows `current`; the live
|
||||
HTTP checks remain the source of truth for service health. The audit fails
|
||||
closed if any domain record lacks an identity or document root, and restore is
|
||||
blocked while an addon or subdomain is rooted below `public_html`.
|
||||
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||
runner label would risk sending production cPanel secrets to a self-hosted
|
||||
runner, so that workflow has been removed.
|
||||
|
||||
If the regression followed the one-time webroot exchange and both the active
|
||||
webroot and selected recovery are physical directories, select `restore`
|
||||
and copy one exact recovery entry from the audit, including the retained
|
||||
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
|
||||
The workflow requires the
|
||||
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
|
||||
exact token string from that audit. The token is an optimistic-concurrency
|
||||
guard over the cPanel metadata visible to the audit; it is not a content hash
|
||||
or a substitute for validating the selected recovery. Restore also rejects an
|
||||
unreadable physical directory. Restore also rejects symbolic-link roots and
|
||||
recoveries because legacy cPanel Fileman may follow their targets rather than
|
||||
rename the links. It renames the current physical entry to a run-specific
|
||||
`public_html.failed-*` path, restores the retained entry, and
|
||||
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
|
||||
any mutation response is lost or any check fails, it reconciles the observed
|
||||
account-home entries and reinstates the pre-restore cPanel state. It never
|
||||
deletes the recovery or displaced webroot, and reports manual intervention if
|
||||
the expected entries cannot be proven after compensation.
|
||||
If the primary domain starts showing a directory index or returns 404 for files
|
||||
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||
or the hosting provider. Before replacing anything, confirm the exact
|
||||
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||
replace the root while an addon or subdomain document root is nested below it.
|
||||
Restore only a verified physical directory, retain the displaced webroot, and
|
||||
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||
releases do not depend on remote cPanel API access.
|
||||
|
||||
## Caching and compatibility
|
||||
|
||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||
service-worker control files always revalidate. The deployer retains at least
|
||||
the active and rollback releases and keeps five recent release directories by
|
||||
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
|
||||
falls outside that validated retention set, its directory and matching ZIP and
|
||||
checksum are removed over FTPS. Cleanup failure is reported without rolling
|
||||
back an otherwise verified deployment.
|
||||
service-worker control files always revalidate. The deployer retains every
|
||||
immutable release while hosted runners cannot query reliable cPanel
|
||||
modification metadata. Each successful run reports that retention cleanup is
|
||||
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||
releases and their matching archives; never remove the active or recorded
|
||||
rollback target.
|
||||
|
||||
Because the document root switches as one symlink, an already-loaded page may
|
||||
still request an asset from its previous release after activation. The current
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
|
||||
|
||||
## Goal
|
||||
|
||||
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
|
||||
"Tankrengøring") only appear when the user is already on the matching view
|
||||
tab. On the "Alle" tab the chips never show, even when a customer actually
|
||||
belongs to several categories.
|
||||
|
||||
We want every chip to render on every subpage whenever the customer belongs
|
||||
to that category — independent of which view tab is active.
|
||||
|
||||
---
|
||||
|
||||
## 1. Root cause (already confirmed by investigation)
|
||||
|
||||
### Front-end rendering path
|
||||
|
||||
* `Right.vue` (line ~300+) declares view tabs and fetches
|
||||
`/superuser/invoicing/period` with the corresponding `periodView` query
|
||||
param (`all`, `invoice_per_order`, …).
|
||||
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
|
||||
(including `all`). It reads the active bucket via
|
||||
`view.variables.sharedVariables.value.types[componentName]`.
|
||||
* For each customer card it mounts
|
||||
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
|
||||
`list_views_with_customer`:
|
||||
|
||||
```ts
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
return view_type && view_type.some(
|
||||
(v: any) => v.customer_number === props.customer.customer_number,
|
||||
);
|
||||
});
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
It only treats a customer as belonging to a view if
|
||||
`types[view_key]` contains an entry with the same `customer_number`.
|
||||
|
||||
### Back-end paging path
|
||||
|
||||
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
|
||||
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
|
||||
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
|
||||
all) holds full customer cards.
|
||||
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
|
||||
truncates the response so that ONLY the bucket matching `$periodView`
|
||||
carries the full card data; every other bucket becomes `[]`.
|
||||
|
||||
```php
|
||||
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||
if ($isAllLimit) {
|
||||
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||
} else {
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||
}
|
||||
```
|
||||
|
||||
* The frontend then iterates over the (empty) non-active buckets and finds
|
||||
no customer entries → no chip is rendered → the bug.
|
||||
|
||||
### Why the existing e2e test missed it
|
||||
|
||||
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
|
||||
~864) returns FULL customer data for every type in the mock payload.
|
||||
Because the mock already mimics the "pre-fix" backend behaviour (every type
|
||||
populated), the chip-rendering path is exercised even when the real backend
|
||||
strips the data. Updating the mock to mirror the new, real backend shape
|
||||
gives us an end-to-end safety net.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix strategy
|
||||
|
||||
We want one round trip, no N+1 calls, and a payload that stays bounded.
|
||||
|
||||
**Approach: lightweight membership entries**
|
||||
|
||||
Extend `applyPeriodPagination` so that, after pagination, every non-active
|
||||
view bucket is populated with "membership only" entries — each entry is
|
||||
just `{ customer_number }` so the frontend can resolve membership via the
|
||||
existing `view_type.some(v => v.customer_number === …)` check.
|
||||
|
||||
* The **active view** continues to carry full customer cards (transactions,
|
||||
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
|
||||
it.
|
||||
* **Every other view** carries a `{customer_number: N}` array (one per
|
||||
matching customer after all filters / search / sort / pagination). No
|
||||
transactions or auxiliary fields — keeping the payload small.
|
||||
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
|
||||
`type_counts` (already computed before pagination) keeps the totals per
|
||||
view, so tab counters remain correct.
|
||||
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
|
||||
stores the full `periodResult` verbatim, so cached responses naturally
|
||||
retain the new lightweight entries.
|
||||
|
||||
### Why this option wins
|
||||
|
||||
| Approach | Network | Payload | Schema change | UX consistency |
|
||||
|---|---|---|---|---|
|
||||
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
|
||||
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
|
||||
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
|
||||
|
||||
---
|
||||
|
||||
## 3. Concrete code changes
|
||||
|
||||
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
|
||||
|
||||
In `applyPeriodPagination(...)` (around line 730-742), after the active
|
||||
bucket is sliced, populate every non-active bucket with lightweight
|
||||
memberships derived from the already-filtered/searched/sorted `$types`
|
||||
arrays:
|
||||
|
||||
```php
|
||||
// Existing pagination of the active bucket
|
||||
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||
if ($isAllLimit) {
|
||||
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||
} else {
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||
}
|
||||
|
||||
// NEW: lightweight memberships for every non-active view so the front-end
|
||||
// can render category chips regardless of which tab is active.
|
||||
foreach ($types as $typeName => $customers) {
|
||||
if ($typeName === $periodView) {
|
||||
continue;
|
||||
}
|
||||
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
||||
is_array($customers) ? $customers : []
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Add a new helper:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Return a minimal `{customer_number: N}` array per customer so the
|
||||
* front-end can determine which non-active view buckets the customer
|
||||
* belongs to without us shipping full transaction/queue data.
|
||||
*
|
||||
* Filters, searches, sort and visibility rules have already been applied
|
||||
* to `$customers` by the time we run, so we just de-duplicate and emit.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $customers
|
||||
* @return array<int, array{customer_number: int, membership_only: true}>
|
||||
*/
|
||||
private static function summarizePeriodCustomerMemberships(array $customers): array
|
||||
{
|
||||
$memberships = [];
|
||||
$seen = [];
|
||||
foreach ($customers as $customer) {
|
||||
if (!is_array($customer)) {
|
||||
continue;
|
||||
}
|
||||
$customerNumber = (int) ($customer['customer_number'] ?? 0);
|
||||
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$customerNumber] = true;
|
||||
$memberships[] = [
|
||||
'customer_number' => $customerNumber,
|
||||
'membership_only' => true,
|
||||
];
|
||||
}
|
||||
return $memberships;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* We deduplicate on `customer_number` so a customer appearing twice in a
|
||||
bucket (rare but possible — multiple PO transactions for the same
|
||||
customer in `invoice_per_order`) still only emits one membership.
|
||||
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
|
||||
so consumers that iterate `Object.keys(types)` still see every view
|
||||
even when the filtered list ends up empty.
|
||||
* The active bucket's structure is **unchanged** — the front-end
|
||||
`customersInCurrentView` and `list_views_with_customer` paths continue to
|
||||
work as before.
|
||||
* `type_counts` and `type_totals` are computed before pagination (see
|
||||
`summarizePeriodTypes`) and remain authoritative for tab counters.
|
||||
|
||||
### 3.2 OpenAPI specs
|
||||
|
||||
Both repositories carry a copy of the schema and must stay in lock-step.
|
||||
|
||||
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
|
||||
|
||||
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
|
||||
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
|
||||
`transactions`, `invoice_collections`. Membership entries don't carry those
|
||||
fields, so we need to relax the `required` constraint on non-active buckets
|
||||
and document the new shape.
|
||||
|
||||
Add a new sibling component:
|
||||
|
||||
```yaml
|
||||
InvoicingPeriodCustomerMembership:
|
||||
type: object
|
||||
description: >-
|
||||
Lightweight customer marker returned for every non-active view bucket.
|
||||
Used only by the front-end to render category chips (e.g. "Faktura pr.
|
||||
ordre") regardless of which tab is active. Full transaction / queue
|
||||
data is intentionally omitted; see InvoicingPeriodCustomer for the
|
||||
shape returned for the active bucket.
|
||||
additionalProperties: false
|
||||
required: [customer_number, membership_only]
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
membership_only:
|
||||
type: true
|
||||
enum: [true]
|
||||
```
|
||||
|
||||
In the `InvoicingPeriod` schema, switch the `types` property from
|
||||
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
|
||||
|
||||
```yaml
|
||||
types:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||
discriminator:
|
||||
propertyName: membership_only
|
||||
```
|
||||
|
||||
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
|
||||
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
|
||||
longer `required` (they remain documented in `properties`). The active
|
||||
bucket still emits them, but the union makes the membership shape valid.
|
||||
|
||||
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||
|
||||
After the backend fix, the chip rendering logic in
|
||||
`list_views_with_customer` will start working on every subpage. To keep
|
||||
performance bounded when buckets grow large, we also turn the membership
|
||||
arrays into `Set<number>` lookups via a small `computed`:
|
||||
|
||||
```ts
|
||||
const membershipIndexes = computed(() => {
|
||||
const result: Record<string, Set<number>> = {};
|
||||
for (const view_key of view_keys.value) {
|
||||
if (view_key === 'all') continue;
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
if (!Array.isArray(view_type)) {
|
||||
result[view_key] = new Set<number>();
|
||||
continue;
|
||||
}
|
||||
result[view_key] = new Set(
|
||||
view_type
|
||||
.map((entry) => Number(entry?.customer_number ?? 0))
|
||||
.filter((n) => Number.isInteger(n) && n > 0),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
|
||||
});
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
Behavioural impact:
|
||||
|
||||
* Same chip set as today, now visible on every subpage including `Alle`.
|
||||
* Lookup is O(1) per (view × customer) instead of O(bucket size).
|
||||
* Defensive against the lightweight entries (no `customer_name`,
|
||||
`transactions`, etc. fields) — the chip only needs the view's friendly
|
||||
name, which already comes from `view.computed.getViewFriendlyName(...)`.
|
||||
|
||||
### 3.4 Front-end — e2e mock
|
||||
|
||||
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
|
||||
(line ~864) currently mocks every bucket as fully populated. Update the
|
||||
mock so that:
|
||||
|
||||
* The **active** bucket (whichever the page requested) carries full
|
||||
customer cards (unchanged).
|
||||
* Every **other** bucket carries membership-only entries
|
||||
(`{customer_number, membership_only: true}`).
|
||||
|
||||
This mirrors the real backend so the existing chip-stacking test
|
||||
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
|
||||
guards the membership path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tests to add / update
|
||||
|
||||
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||
|
||||
Existing assertion at line 292:
|
||||
|
||||
```php
|
||||
expect($result['period']['types']['fixed_pricing'])->toBe([]);
|
||||
```
|
||||
|
||||
…becomes:
|
||||
|
||||
```php
|
||||
expect($result['period']['types']['fixed_pricing'])
|
||||
->toBe(array_map(
|
||||
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
|
||||
[1001], // the test fixture's other-bucket membership
|
||||
));
|
||||
```
|
||||
|
||||
Add a new test that, given a period with two customers in `all` and one
|
||||
in `invoice_per_order`, paging `periodView=all` yields:
|
||||
|
||||
* `types.all` — full customer cards (existing behaviour preserved)
|
||||
* `types.invoice_per_order` — one lightweight membership entry
|
||||
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
|
||||
matching customers, so nothing to emit)
|
||||
|
||||
Add a search-aware test: searching for "Beta" while paging
|
||||
`periodView=all` must surface the lightweight membership only for
|
||||
customers that pass the filter, mirroring the active bucket.
|
||||
|
||||
Add a flag-tab-aware test: the `red` flag filter must propagate to the
|
||||
membership arrays just as it does to `type_counts`.
|
||||
|
||||
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
|
||||
|
||||
Add a focused Vitest spec
|
||||
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
|
||||
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
|
||||
`sharedVariables.value.types` containing:
|
||||
|
||||
```ts
|
||||
{
|
||||
all: [...full cards],
|
||||
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
|
||||
fixed_pricing: [],
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
|
||||
other categories the stubbed customer is a member of), independent of
|
||||
which view tab is "active" in the stub.
|
||||
|
||||
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
|
||||
|
||||
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
|
||||
membership-only entries for non-active buckets — matching the real
|
||||
backend contract.
|
||||
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
|
||||
that on the `Alle` tab the rendered customer cards include the
|
||||
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
|
||||
* Add a new spec scenario:
|
||||
`Given: Alle tab with mixed customers across categories. When: page
|
||||
loads. Then: every customer card shows chips for every category it
|
||||
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
|
||||
|
||||
### 4.4 OpenAPI consistency
|
||||
|
||||
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
|
||||
script in `scripts/sync-ai-workflow.mjs` to verify that the two
|
||||
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
|
||||
it so future schema edits surface in CI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verification steps (manual + automated)
|
||||
|
||||
### 5.1 Manual smoke test (in dev)
|
||||
|
||||
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
|
||||
bring up the API stack.
|
||||
2. `cd /workspace/pleno-vue && npm run dev`.
|
||||
3. Sign in as a superuser that owns customers spanning multiple categories
|
||||
(fixed_pricing + invoice_per_order, for instance).
|
||||
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
|
||||
5. On the **Alle** tab confirm every customer card shows every chip it
|
||||
qualifies for.
|
||||
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
|
||||
render (sans the active tab's own chip).
|
||||
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
|
||||
8. Apply the search box; chips should update with the filter.
|
||||
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
|
||||
flagged subset.
|
||||
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
|
||||
consistent across pages.
|
||||
11. Reload the page — chips must persist from the cache layer
|
||||
(`setCachedPeriodPage`) and not flash empty.
|
||||
|
||||
### 5.2 Automated
|
||||
|
||||
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
|
||||
inside `php1` container per `scripts/setup.sh`).
|
||||
* Backend static analysis: `composer analyse` (phpstan).
|
||||
* Backend rector dry-run: `composer rector:dry-run`.
|
||||
* Front-end unit: `npm run test:unit`.
|
||||
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
|
||||
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
|
||||
* Front-end lint: `npm run lint:strict`.
|
||||
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
|
||||
|
||||
### 5.3 CI checks to watch
|
||||
|
||||
* `.github/workflows/tests.yml` (api) — PHP matrix
|
||||
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
|
||||
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
|
||||
* `.github/workflows/code_quality.yml` — Qodana scan.
|
||||
|
||||
---
|
||||
|
||||
## 6. Roll-out plan
|
||||
|
||||
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
|
||||
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
|
||||
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
|
||||
3. OpenAPI updates (3.2) in both repos.
|
||||
4. Frontend attribute component (3.3) — add the `Set` index, keep the
|
||||
array `.some()` fallback for back-compat.
|
||||
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
|
||||
6. Run the full verification suite (5.2) locally before pushing.
|
||||
7. Open the PR; CI should turn green; Qodana should not flag the new
|
||||
memberships (they are deliberate additive fields).
|
||||
8. After merge, monitor the period page in staging for payload size and
|
||||
chip rendering parity.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk assessment
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
|
||||
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
|
||||
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
|
||||
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
|
||||
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Files touched (summary)
|
||||
|
||||
**Backend (`/workspace/api`):**
|
||||
|
||||
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
|
||||
`summarizePeriodCustomerMemberships`, populate non-active buckets.
|
||||
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||
— relax line 292, add membership / search / flag-tab tests.
|
||||
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
|
||||
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
|
||||
|
||||
**Front-end (`/workspace/pleno-vue`):**
|
||||
|
||||
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||
— `Set`-based membership index.
|
||||
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
|
||||
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
|
||||
shape, extended chip-stacking assertions.
|
||||
* `openapi.yaml` — mirror backend schema edits.
|
||||
@@ -9,10 +9,15 @@ must never publish an Android production artifact.
|
||||
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
|
||||
`io.truckwash.twa` and supports:
|
||||
|
||||
- Automatic delivery after successful current-master `Automated Tests`.
|
||||
- Automatic delivery after successful current-master `Automated Tests`, with
|
||||
all six full Chromium-mobile role shards explicitly verified as green.
|
||||
- Manual dispatch with version, version code, upload toggle, track, and status.
|
||||
- Existing `mobile-v*` tags for the Android workflow.
|
||||
|
||||
Every Google Play upload path must resolve an exact completed `Automated Tests`
|
||||
push run for the same current-master commit. Manual no-upload artifact builds
|
||||
remain available for safe CI validation without invoking the store gate.
|
||||
|
||||
The Android job continues using GitHub environment `mobile-store-production`.
|
||||
Its required secrets are:
|
||||
|
||||
@@ -37,6 +42,11 @@ iOS uses three separate workflows:
|
||||
storefront candidate, without rebuilding or submission.
|
||||
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
|
||||
|
||||
Before signing or uploading to TestFlight, the workflow resolves the exact
|
||||
current-master test run and requires all six full WebKit-mobile role shards to
|
||||
be green. App Store candidates reuse that gated TestFlight build and do not
|
||||
rebuild it.
|
||||
|
||||
The GitHub environments and variables are documented in
|
||||
`docs/app-store-release.md`. The repository-level
|
||||
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "VisualEvidenceManifest",
|
||||
"taskId": "a0edd464-44c0-4d77-96c1-d3562496a1b7",
|
||||
"repository": "copenhagentruckwash/pleno-vue",
|
||||
"baseSha": "eee9ba1c138f0c88c772ea284a5a97a46cb9412c",
|
||||
"subjectSha": "89dec2c5690f9eaf1e0e34651bb40b7f441b85fb",
|
||||
"views": [
|
||||
{
|
||||
"id": "invoicing-period-review",
|
||||
"name": "Superuser invoice period review workspace",
|
||||
"description": "Replaces the long mixed invoice-period page with grouped review navigation, compact totals, explicit review filters, and a responsive master-detail workspace while preserving every invoice category and action.",
|
||||
"route": "/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all",
|
||||
"fixture": "Synthetic superuser invoice-period fixture with three fictional customers and no production data",
|
||||
"comparisons": {
|
||||
"mobile": {
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-mobile.png",
|
||||
"sha256": "b3c25c072f45e912358cc61d21577bbf61d5216b114a6911f900ca19b90d477e",
|
||||
"bytes": 28914,
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period mobile view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-mobile.png",
|
||||
"sha256": "257db0e6e295b6b13ba5d0b29509c2a8b7170244ad6539a8e2ff18d2c9c0ffba",
|
||||
"bytes": 31339,
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period mobile view after the review workspace redesign"
|
||||
}
|
||||
},
|
||||
"tablet": {
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-tablet.png",
|
||||
"sha256": "8cd17ea1e384ab6a9711a643d4e50e971cabcf704c3672d8f90cb2f5f2d36ba0",
|
||||
"bytes": 38508,
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period tablet view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-tablet.png",
|
||||
"sha256": "1c627c30ade435ee004b8cdcd0223022594a351658639edec1f6e5396efaba18",
|
||||
"bytes": 46062,
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period tablet view after the review workspace redesign"
|
||||
}
|
||||
},
|
||||
"desktop": {
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-desktop.png",
|
||||
"sha256": "849ea0eedabc7f1e52172e26602cddfddbeed32c91d04672dfe8b713343a54fa",
|
||||
"bytes": 142738,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period desktop view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-desktop.png",
|
||||
"sha256": "d026925bc4f6ced62686ae7f8252594e3a3fe9bd559a7f53f38a1bfcb0b96892",
|
||||
"bytes": 108740,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period desktop view after the review workspace redesign"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Customer and subuser lifecycle visual comparisons
|
||||
|
||||
## Forgot-password account selection
|
||||
|
||||
The reset page previously accepted only a customer number. It now lets the
|
||||
visitor choose a customer or chauffeur account. Chauffeur recovery uses the
|
||||
country code and phone number and sends the one-time reset link by SMS.
|
||||
|
||||
- Mobile: [before](before-mobile.png) / [after](after-mobile.png)
|
||||
- Tablet: [before](before-tablet.png) / [after](after-tablet.png)
|
||||
- Desktop: [before](before-desktop.png) / [after](after-desktop.png)
|
||||
|
||||
## Pre-authorized customer access decision
|
||||
|
||||
The SMS link previously had no destination view. It now opens a read-only
|
||||
request preview, identifies the chauffeur and customer, and requires an
|
||||
explicit approve or deny action before the one-time token mutates access.
|
||||
|
||||
- Mobile: [before](access-before-mobile.png) / [after](access-after-mobile.png)
|
||||
- Tablet: [before](access-before-tablet.png) / [after](access-after-tablet.png)
|
||||
- Desktop: [before](access-before-desktop.png) / [after](access-after-desktop.png)
|
||||
|
||||
The related signed-in profile and customer grant selector use the same
|
||||
responsive components. The selector is deduplicated by customer number and
|
||||
uses colored permission indicators for vehicles, tools, calendar, orders,
|
||||
and driver access.
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,28 @@
|
||||
# Automatic cron execution visual comparison
|
||||
|
||||
The Cron workers panel now exposes machine-verifiable scheduler cadence.
|
||||
The summary reports how many active workers have maintained the required
|
||||
once-per-minute cadence, and each worker row shows its latest loop gap and
|
||||
consecutive qualifying loops.
|
||||
|
||||
A worker is verified only after two consecutive loops, with no gap above
|
||||
60 seconds, while the worker is running and its latest observation is no more
|
||||
than 60 seconds old.
|
||||
|
||||
## Mobile
|
||||
|
||||
- [Before](before-mobile.png)
|
||||
- [After](after-mobile.png)
|
||||
|
||||
## Tablet
|
||||
|
||||
- [Before](before-tablet.png)
|
||||
- [After](after-tablet.png)
|
||||
|
||||
## Desktop
|
||||
|
||||
- [Before](before-desktop.png)
|
||||
- [After](after-desktop.png)
|
||||
|
||||
The updated state tags use explicit foreground colors so success and warning
|
||||
labels remain readable against their backgrounds.
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,48 @@
|
||||
# Stripe cleanup visual evidence
|
||||
|
||||
Authentic browser captures compare `origin/master` at
|
||||
`fd31609cb379cda36fb16ef7077fc9db3c91eb2b` with the feature at
|
||||
`6e795b253062606e6122cc7e630e17651b9b7efd`.
|
||||
|
||||
Both revisions were rendered by their own Vite applications and exercised with
|
||||
the repository's mocked Playwright API support. No production API, Stripe
|
||||
account, or product-source modification was used to create the evidence.
|
||||
|
||||
## Regular POS card-payment view
|
||||
|
||||
The baseline identifies the integration as Stripe and offers an email payment.
|
||||
The feature uses provider-neutral card-terminal wording and removes the hosted
|
||||
email-payment action while preserving terminal payment.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-pos-card-payment-mobile.png) | [After](after-pos-card-payment-mobile.png) |
|
||||
| Tablet | [Before](before-pos-card-payment-tablet.png) | [After](after-pos-card-payment-tablet.png) |
|
||||
| Desktop | [Before](before-pos-card-payment-desktop.png) | [After](after-pos-card-payment-desktop.png) |
|
||||
|
||||
## Authorized payment capture
|
||||
|
||||
Both revisions receive a mocked payment intent in `requires_capture` state. The
|
||||
baseline exposes a manual capture action. The feature automatically issues the
|
||||
capture request and shows its in-progress state without a second manual action.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-payment-capture-mobile.png) | [After](after-payment-capture-mobile.png) |
|
||||
| Tablet | [Before](before-payment-capture-tablet.png) | [After](after-payment-capture-tablet.png) |
|
||||
| Desktop | [Before](before-payment-capture-desktop.png) | [After](after-payment-capture-desktop.png) |
|
||||
|
||||
## Order-dashboard action rail
|
||||
|
||||
The baseline action rail includes the hosted Stripe invoice/payment-link
|
||||
action. The feature removes it while preserving receipts, ordinary order
|
||||
completion, and navigation.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-order-dashboard-mobile.png) | [After](after-order-dashboard-mobile.png) |
|
||||
| Tablet | [Before](before-order-dashboard-tablet.png) | [After](after-order-dashboard-tablet.png) |
|
||||
| Desktop | [Before](before-order-dashboard-desktop.png) | [After](after-order-dashboard-desktop.png) |
|
||||
|
||||
All nine paired states passed their relevant DOM assertions across Chromium
|
||||
mobile, tablet, and desktop projects.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"kind": "VisualEvidenceManifestV1",
|
||||
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||
"view": "POS extra sale audit",
|
||||
"files": [
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "VisualEvidenceManifest",
|
||||
"taskId": "xlvask-autopilot-20260803",
|
||||
"repository": "copenhagentruckwash/pleno-vue",
|
||||
"baseSha": "f995440098f9e3f3b5ff122a55c8c4e018cc716e",
|
||||
"subjectSha": "d5c291e6f7f81d6992aa63375d77521c2983f8e5",
|
||||
"views": [
|
||||
{
|
||||
"id": "invoice-period-self-wash",
|
||||
"name": "Invoice period XL-Vask autopilot",
|
||||
"description": "Shows the previous self-wash import beside the new state-separated, evidence-led autopilot review workspace.",
|
||||
"route": "/superuser/invoices?tab=period&periodView=self_wash&startDate=2026-03-01&endDate=2026-03-31",
|
||||
"fixture": "Playwright mocked March 2026 invoice period with one uncertain match and one failed match",
|
||||
"comparisons": {
|
||||
"mobile": {
|
||||
"width": 1081,
|
||||
"height": 1999,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-mobile.png",
|
||||
"sha256": "8c67915dd48847ef553db32f3fc2481e58af7490dab9a41c49728003b84df5a7",
|
||||
"bytes": 141379,
|
||||
"width": 1081,
|
||||
"height": 1999,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Mobile self-wash period import before the autopilot review workspace"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-mobile.png",
|
||||
"sha256": "02b5d6560566350cbf702ff995602084a70ffdda13290c3754438885c3730a20",
|
||||
"bytes": 142027,
|
||||
"width": 1081,
|
||||
"height": 1999,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Mobile XL-Vask autopilot summary, filters, and visible review states"
|
||||
}
|
||||
},
|
||||
"tablet": {
|
||||
"width": 1536,
|
||||
"height": 2048,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-tablet.png",
|
||||
"sha256": "5a20daa875e211e1bfbfdd4e17282956720954ce14a9e76f1002b1355f795dd4",
|
||||
"bytes": 179803,
|
||||
"width": 1536,
|
||||
"height": 2048,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Tablet self-wash period import before the autopilot review workspace"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-tablet.png",
|
||||
"sha256": "1916b97a6d3f7c0084130b3514fe67b5a8f729e23bca37c1ad5c7ed75f821d44",
|
||||
"bytes": 198536,
|
||||
"width": 1536,
|
||||
"height": 2048,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Tablet XL-Vask autopilot summary, filters, rows, and pagination"
|
||||
}
|
||||
},
|
||||
"desktop": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-desktop.png",
|
||||
"sha256": "617095bcb916dd2f25b5cd0e8d77e7b0b05f1b5db662aeb810b52d606bcbe0af",
|
||||
"bytes": 94026,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Desktop self-wash period import before the autopilot review workspace"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-desktop.png",
|
||||
"sha256": "dd33f7e07ca426ee63c4708d9994e2403375bae9a86c119d0d296f52e5e90a9a",
|
||||
"bytes": 117715,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Desktop XL-Vask autopilot summary and state-separated review rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,11 +45,12 @@ platform :ios do
|
||||
skip_screenshots: false,
|
||||
overwrite_screenshots: true,
|
||||
force: true,
|
||||
submit_for_review: false,
|
||||
automatic_release: false,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
phased_release: false,
|
||||
run_precheck_before_submit: false,
|
||||
precheck_include_in_app_purchases: false
|
||||
precheck_include_in_app_purchases: false,
|
||||
ignore_language_directory_validation: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||