Compare commits

...
Author SHA1 Message Date
Jeppe B cfbf80f327 Reject unsafe FTPS path components 2026-07-20 16:06:37 +02:00
Jeppe B 96958c53a3 Document FTPS directory-index recovery 2026-07-20 16:03:35 +02:00
Jeppe B 13509b68f2 Add rollback-capable public htaccess repair 2026-07-20 16:03:34 +02:00
Jeppe B f689409990 Add guarded FTPS root repair workflow 2026-07-20 16:03:32 +02:00
Jeppe B c69f4f7fc7 Run cPanel recovery and deploy from stable runner (#189)
Use the stable self-hosted runner for cPanel API calls and install lftp job-locally without sudo.
2026-07-20 15:54:45 +02:00
Jeppe B 10f993e686 Support direct cPanel UAPI responses (#188) 2026-07-20 15:47:52 +02:00
Jeppe B a02ddfbfbc Normalize cPanel success results (#187) 2026-07-20 15:41:15 +02:00
Jeppe B 7a85c93631 Use Android artwork for iOS app icon
Replace the iOS AppIcon variants with the exact Android launcher artwork
from `public/favicons/web-app-manifest-512x512.png`, so the physical
iPhone home-screen icon matches Android. Verified all declared icon
dimensions and focused iOS icon test.
2026-07-20 15:41:02 +02:00
Jeppe B a3d11cfca3 Use relative account home in cPanel audit (#186) 2026-07-20 15:36:14 +02:00
Jeppe B 74279fc443 Address cPanel deployment review findings (#183) 2026-07-20 15:30:54 +02:00
Jeppe B 7e3a72961f Add complete iOS app icon asset set (#184)
Add standard iPhone, iPad, and marketing AppIcon variants with asset regression coverage.
2026-07-20 15:23:39 +02:00
Jeppe B ca2179352c Fix customer booking product restrictions and responsive layout (#182)
## What changed

- load customer product rules for the booking customer and keep
selection fail-closed while they resolve
- prevent restricted products, add-ons, pickup, and wash certificates
from remaining selected or being submitted
- improve product-grid containment, selected add-on styling, unavailable
messaging, and tablet/mobile rendering
- preserve booking details when the API rejects stale restricted items
- add unit and Playwright coverage for loading, retry, exact
restrictions, automatic products, layout, and stale backend recovery

## Why

The customer booking flow could use stale or unrelated POS customer-rule
state, expose restricted items, and lose useful form state after a
server rejection. The desktop product area also overflowed and switched
to an inconsistent compact layout after selection.

## Validation

- desktop booking Playwright suite: 12 passed
- mobile booking Playwright suite: 4 passed, 8 expected desktop-only
skips
- relevant unit tests: 35 passed
- ESLint and i18n checks
- production Vite build

## Related backend PR

The coordinated API PR enforces the same rule at the write boundary.
2026-07-20 14:41:47 +02:00
Jeppe B 8ddac065c6 Guard iOS debug signing with live CORS (#181)
Verify the stable API and exact Capacitor iOS CORS contract before signing device-debug IPAs, with regression coverage and troubleshooting guidance.
2026-07-20 14:37:17 +02:00
Jeppe B a930bd35a5 Add atomic cPanel frontend deployment (#178)
Build and archive the tested frontend, upload it through dedicated FTPS credentials, and atomically activate it through cPanel after CI succeeds.
2026-07-20 13:53:49 +02:00
Jeppe B 6b8f3ff806 Avoid Xcode version broken pipe in iOS debug workflow (#180)
Capture the full Xcode version output before selecting its first line so Xcode 26 cannot abort on a closed pipe.
2026-07-20 12:37:42 +02:00
Jeppe B 3323f392e3 Add signed iOS device debug workflow (#179)
Add a protected development-signing workflow, isolated debug app identity, Linux USB device tooling, documentation, and focused validation coverage.
2026-07-20 12:07:10 +02:00
Jeppe B f8f2b80641 Align restricted POS add-on controls and registration label (#177)
Align restricted desktop and mobile POS controls, scope the Danish primary registration label to POS, and preserve generic registration wording across shared surfaces and other locales.
2026-07-20 10:57:17 +02:00
Jeppe B 1729443cc3 Resolve frontend Qodana critical and high findings (#176)
Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
2026-07-17 06:22:51 +02:00
Jeppe B 6b5ac8a8b3 Document frontend branch protection activation (#175)
## Purpose

After-activation canary for repository ruleset `19051697`.

## Evidence before activation

- preparation PR #172 established stable `Required CI`
- strict-current-base remediation PR #174 passed `Required CI` and
Qodana
- merged master run `29501928124` completed with 43 successful jobs and
one intentional skip
- effective master rules were audited after activation

## Canary acceptance

- merge is blocked while `Required CI` is pending
- the exact `Required CI` check comes from GitHub Actions integration
`15368`
- only squash merge is accepted
- the branch is deleted automatically after merge
2026-07-16 20:45:00 +02:00
Jeppe B b12aca2757 Fix customer-rule i18n coverage gate (#174)
* fix: make customer rule translations statically auditable

* test: scope draft filter coverage to desktop layouts
2026-07-16 15:22:35 +02:00
Jeppe B 39cc3a780a Configure advisory Qodana analysis (#173) 2026-07-16 14:21:02 +02:00
Jeppe B 18302723e8 Prepare frontend master branch protection (#172)
Add stable Required CI, repair E2E ownership, make Qodana optional-state handling explicit, and document the desired protection policy.
2026-07-16 13:12:57 +02:00
Jeppe B 20c383d22d Format customer rule tests 2026-07-16 12:11:06 +02:00
Jeppe B 77a0c4e018 Add customer rule product restrictions 2026-07-16 11:51:42 +02:00
Jeppe B 9a5c8b193d style: format admin order filter test 2026-07-16 11:50:41 +02:00
Jeppe B 0562ba8ab0 feat: add admin order filters 2026-07-16 11:45:08 +02:00
Jeppe Bundgaard 906e860c86 test: distinguish order table requests 2026-07-16 01:29:02 +02:00
Jeppe B 516e1fb58d Expose invoicing flag badge translations 2026-07-15 22:47:11 +02:00
Jeppe B 7da1307cf6 Restore i18n locale compatibility keys 2026-07-15 21:37:21 +02:00
Jeppe B ea4893d815 Ignore warm prefetches in month selection E2E 2026-07-15 20:50:02 +02:00
Jeppe B f2e0079b59 Stabilize invoicing period E2E scenarios 2026-07-15 20:26:49 +02:00
Jeppe B 47d8baa496 Format frontend test files 2026-07-15 19:42:27 +02:00
Jeppe B 6d96d64811 Improve invoicing period tree and POS restrictions 2026-07-15 17:04:51 +02:00
Jeppe B 92fb998e5a Fix customer additional-service restrictions (#171) 2026-07-15 15:08:01 +02:00
Jeppe B bed6030e2b Stabilize invoicing period date fixtures 2026-07-15 02:45:02 +02:00
Jeppe B 328a85bad1 Review object tree dynamic i18n helper
Add the invoicing period object tree translation wrapper to the reviewed non-literal call list; it mirrors the existing flag-list helper pattern.
2026-07-15 01:30:18 +02:00
Jeppe B 394375e429 Fix user profile two-factor test id
Keep the profile two-factor wrapper test id stable and move spacing tokens to class so the mobile profile security E2E can find the status element.
2026-07-15 00:17:34 +02:00
Jeppe B ca744adfd9 Link security status filter i18n label
Reuse the canonical system status label for the security incident status filter so the v2 duplicate raw phrase gate stays green.
2026-07-14 23:35:53 +02:00
Jeppe B 5e42f55570 Fix invoicing period object-tree flag handling
Keep expanded period rows on InvoicingPeriodObjectTree while rendering order-scoped flags in a compact expanded flag list. Update the invoicing-period E2E fixtures and source contract for the object-tree path.
2026-07-14 22:39:33 +02:00
Jeppe B 021da1a074 Fix invoicing period expanded order details (#170) 2026-07-14 21:02:22 +02:00
Jeppe B 0e55f45e91 Fix invoicing queue-state unit spec 2026-07-14 16:05:04 +02:00
Jeppe B 9749837506 Format frontend test files 2026-07-14 15:55:17 +02:00
Jeppe B 4fbb0b98c3 Improve superuser invoicing and system status views 2026-07-14 15:41:05 +02:00
Jeppe B 0edf8afcaf Regenerate Danish i18n runtime 2026-07-14 13:52:23 +02:00
gold-dev e2ee54578c Feat: 202607 - truckwash.dk V15_sent slide 8 2026-07-14 12:28:56 +03:00
gold-dev b35e4484de Merge remote-tracking branch 'origin/master' 2026-07-14 12:15:00 +03:00
gold-dev c8368612eb Feat: 202607 - truckwash.dk V15_sent slide 6, 7 2026-07-14 12:14:47 +03:00
Jeppe B 26266e724b Stabilize customer booking and i18n checks 2026-07-14 10:34:29 +02:00
Jeppe B 6ccfea9eb4 Limit security nav assertion to desktop layout 2026-07-14 02:15:44 +02:00
Jeppe B d4349c20a7 Format subuser management spec 2026-07-14 00:59:12 +02:00
Jeppe Bundgaard 63f1cc2450 Fix custom subuser Dognvask updates 2026-07-13 22:30:18 +02:00
Jeppe Bundgaard acec2a694a Add loading screen for session confirmation and enhance vehicle management labels 2026-07-13 22:11:51 +02:00
Jeppe B f15d7224e0 Make failover wiring source check formatter tolerant 2026-07-13 21:50:28 +02:00
Jeppe B 236f552a22 Stabilize subuser QR auth smoke assertion 2026-07-13 20:57:30 +02:00
Jeppe B 321b6f2c64 Format auth smoke session bootstrap test 2026-07-13 20:36:03 +02:00
Jeppe B 218ead9a31 Fix subuser QR session bootstrap 2026-07-13 20:33:48 +02:00
Jeppe B 60b6e82c88 Dedupe cron worker target i18n key 2026-07-13 20:32:03 +02:00
Jeppe B bbe4ad938a Add cron worker controls and align superuser specs 2026-07-13 19:47:08 +02:00
Jeppe B 9e27380a10 Fix i18n v2 catalog integrity drift
Fill missing backup compatibility keys for Danish and Norwegian and centralize repeated shared/support labels through existing i18n links.
2026-07-13 17:59:51 +02:00
Jeppe Bundgaard baf83fd079 Restore gateway smoke helpers after route spec cleanup
Keep the gateway health-card smoke test runnable after removing the stale replication-management helper block.
2026-07-13 15:02:15 +00:00
Jeppe Bundgaard dfe3163692 Align frontend smoke tests with current route permissions
Update self-serve mocked customer permissions for the guarded wash routes and replace removed replication-management E2E coverage with dashboard redirect assertions.
2026-07-13 16:41:08 +02:00
Jeppe Bundgaard 4452bd4088 Update system status route contract test 2026-07-13 15:57:42 +02:00
Jeppe Bundgaard 6f93b840a9 Relax superuser module wiring assertions 2026-07-13 15:45:59 +02:00
Jeppe Bundgaard 443f50c144 Enhance subuser management contact and action flows 2026-07-13 15:11:49 +02:00
Jeppe Bundgaard 5a7f902d01 Enhance subuser action menu with new sections and labels for improved user experience 2026-07-13 10:42:13 +02:00
Jeppe Bundgaard 02cc82cee1 Enhance pagination filter dropdown styling and update subuser management tests 2026-07-13 10:27:05 +02:00
Jeppe Bundgaard fa9b05958d Enhance subuser management functionality and improve UI responsiveness 2026-07-13 10:25:20 +02:00
Jeppe Bundgaard 6fb2c9e7df Update self-serve permissions and enhance UI components for customer interactions 2026-07-13 10:21:18 +02:00
Jeppe Bundgaard 54e59b4b3e Add system status displays for Minio and Redis, and enhance backup configuration 2026-07-13 10:07:56 +02:00
Jeppe B 8a0ea6cae5 Fix cron status translation literals 2026-07-13 08:48:45 +02:00
Jeppe B 1bbd816e83 Fix session bootstrap heading assertion
Target the system status heading in the delayed session bootstrap test so Playwright strict mode does not match both the navigation link and page heading.
2026-07-09 16:17:33 +02:00
803 changed files with 245950 additions and 12629 deletions
+58
View File
@@ -0,0 +1,58 @@
# Default branch protection
The intended repository ruleset is stored in
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json).
It targets the configured default branch and requires pull requests, the strict
`Required CI` check from GitHub Actions, resolved review conversations,
squash-only merges, and linear history. Branch deletion and force pushes are
blocked. Qodana remains advisory and is not part of the required gate.
The ruleset's `RepositoryRole` actor ID `5` is GitHub's built-in Administrator
role. Its `pull_request` bypass mode permits an administrator to bypass rules
only while merging an existing pull request; it does not permit a direct push.
## Repository settings
Keep squash merge enabled and disable merge commits and rebase merge. Enable
auto-merge, the update-branch option, and automatic deletion of merged head
branches. Keep the Actions token read-only and do not allow Actions to approve
pull-request reviews.
## Activation and verification
1. Confirm a pull request and a `master` push each produce exactly one
successful `Required CI` check from GitHub Actions integration `15368`.
2. For the initial ruleset POST, override the committed JSON's `enforcement`
value to `disabled`, then compare GitHub's normalized API response with this
file.
3. PUT the exact committed JSON to the inspected ruleset to activate it.
4. Open a canary pull request and confirm that pending or failing CI, unresolved
conversations, and an out-of-date branch block merging; only squash merge is
available.
5. After merging, confirm the head branch is deleted and the post-merge full
E2E, frontend release, and mobile release guards still run.
If validation exposes a blocker, disable the ruleset rather than deleting it so
its configuration and history remain available.
## Activation record
Repository ruleset `19051697` was activated on 2026-07-16 after preparation
PR #172 established `Required CI`, remediation PR #174 passed the strict
current-base gate, and merged master run `29501928124` completed with all 43
executed jobs successful. This documentation update is the after-activation
canary for the normal protected pull-request path.
## Normal publishing flow
Create a scoped feature branch, open a pull request to `master`, wait for
`Required CI`, update the branch if `master` advanced, resolve every review
conversation, and squash-merge. For waits expected to exceed 90 seconds, use
the workspace `scripts/ci-watch.sh` helper instead of repeatedly polling GitHub.
## Break glass
For an incident, an administrator must still open a pull request. Document the
incident and why the normal gate cannot complete, then use the PR-only bypass
when merging. Monitor all post-merge workflows and open a follow-up pull request
for any validation or remediation deferred during the incident.
@@ -0,0 +1,53 @@
{
"name": "Protect default branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "required_linear_history"
},
{
"type": "pull_request",
"parameters": {
"allowed_merge_methods": ["squash"],
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_approving_review_count": 0,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "Required CI",
"integration_id": 15368
}
],
"strict_required_status_checks_policy": true
}
}
]
}
+52 -18
View File
@@ -1,32 +1,66 @@
name: Qodana Configuration Upload
name: Qodana
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
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:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
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
uses: actions/checkout@v5
# 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: Run Qodana Configuration Uploader
- name: Require Qodana project token
shell: bash
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
docker run --rm \
-v "$(pwd):/workspace" \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
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 }}
+99
View File
@@ -0,0 +1,99 @@
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
+72
View File
@@ -0,0 +1,72 @@
name: Frontend Root FTPS Repair
on:
workflow_dispatch:
inputs:
mode:
description: Audit downloads and hashes .htaccess; repair backs it up and activates the reviewed file.
required: true
default: audit
type: choice
options:
- audit
- repair
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-repair:
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: 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: Audit or repair live root .htaccess
run: bash scripts/release/repair-public-htaccess.sh "${{ inputs.mode }}"
env:
NODE_OPTIONS: --use-system-ca
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_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
ROOT_REPAIR_REPORT_DIR: output/cpanel-root-ftps
- name: Upload FTPS root report
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-root-ftps-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root-ftps
if-no-files-found: ignore
retention-days: 30
+624
View File
@@ -0,0 +1,624 @@
name: iOS Device Debug IPA
on:
workflow_dispatch:
inputs:
source_ref:
description: Same-repository branch, tag, or commit to build
required: true
default: master
type: string
expected_sha:
description: Full 40-character SHA that source_ref must resolve to
required: true
type: string
confirmation:
description: Type SIGN IOS DEBUG IPA
required: true
type: string
permissions:
contents: read
concurrency:
group: ios-device-debug-${{ github.run_id }}
cancel-in-progress: false
jobs:
resolve:
name: Resolve and verify source
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
source_sha: ${{ steps.resolve.outputs.source_sha }}
steps:
- name: Validate dispatch confirmation
shell: bash
env:
CONFIRMATION: ${{ inputs.confirmation }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
SOURCE_REF: ${{ inputs.source_ref }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]; then
echo "The signing workflow must be dispatched from the master workflow ref" >&2
exit 1
fi
if [[ "$CONFIRMATION" != "SIGN IOS DEBUG IPA" ]]; then
echo "confirmation must exactly match SIGN IOS DEBUG IPA" >&2
exit 1
fi
if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "expected_sha must be a full 40-character commit SHA" >&2
exit 1
fi
if [[ -z "$SOURCE_REF" || "$SOURCE_REF" =~ [[:space:]] ]]; then
echo "source_ref must be non-empty and contain no whitespace" >&2
exit 1
fi
if [[ "$SOURCE_REF" == refs/pull/* || "$SOURCE_REF" == pull/* ]]; then
echo "Pull-request refs are not eligible for device-debug signing" >&2
exit 1
fi
- name: Checkout same-repository history
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve immutable commit
id: resolve
shell: bash
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
SOURCE_REF: ${{ inputs.source_ref }}
run: |
set -euo pipefail
expected_sha="${EXPECTED_SHA,,}"
if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
candidate="$SOURCE_REF"
elif [[ "$SOURCE_REF" == refs/heads/* ]]; then
candidate="refs/remotes/origin/${SOURCE_REF#refs/heads/}"
elif [[ "$SOURCE_REF" == refs/tags/* ]]; then
candidate="$SOURCE_REF"
elif git show-ref --verify --quiet "refs/remotes/origin/$SOURCE_REF"; then
candidate="refs/remotes/origin/$SOURCE_REF"
elif git show-ref --verify --quiet "refs/tags/$SOURCE_REF"; then
candidate="refs/tags/$SOURCE_REF"
else
echo "source_ref does not identify a same-repository branch, tag, or fetched commit" >&2
exit 1
fi
source_sha="$(git rev-parse --verify "${candidate}^{commit}" 2>/dev/null || true)"
source_sha="${source_sha,,}"
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "source_ref could not be resolved to a commit" >&2
exit 1
fi
if [[ "$source_sha" != "$expected_sha" ]]; then
echo "source_ref resolved to a SHA different from expected_sha" >&2
exit 1
fi
reachable=false
while IFS= read -r repository_ref; do
if git merge-base --is-ancestor "$source_sha" "$repository_ref" 2>/dev/null; then
reachable=true
break
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin refs/tags)
if [[ "$reachable" != true ]]; then
echo "The requested commit is not reachable from a same-repository branch or tag" >&2
exit 1
fi
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "Resolved source_ref to $source_sha" >> "$GITHUB_STEP_SUMMARY"
build:
name: Build development-signed IPA
needs: resolve
runs-on: macos-26
timeout-minutes: 90
environment:
name: mobile-device-debug
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_DEBUG_BUNDLE_ID: ${{ vars.IOS_DEBUG_BUNDLE_ID || 'io.truckwash.app.debug' }}
IOS_DEBUG_API_URL: ${{ vars.IOS_DEBUG_API_URL || 'https://api-v2.truckwash.io/master/api' }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
MOBILE_VERSION_NAME: 0.0.${{ github.run_number }}
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
steps:
- name: Checkout resolved source
uses: actions/checkout@v5
with:
ref: ${{ needs.resolve.outputs.source_sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify runner and resolve build number
shell: bash
run: |
set -euo pipefail
xcode_version_output="$(xcodebuild -version)"
IFS= read -r xcode_version <<< "$xcode_version_output"
xcode_major="$(awk '{split($2, version, "."); print version[1]}' <<< "$xcode_version")"
if [[ ! "$xcode_major" =~ ^[0-9]+$ ]] || (( xcode_major < 26 )); then
echo "Xcode 26 or newer is required; found $xcode_version" >&2
exit 1
fi
build_number="$((10#$GITHUB_RUN_NUMBER * 100 + 10#$GITHUB_RUN_ATTEMPT))"
if [[ ! "$build_number" =~ ^[1-9][0-9]{0,17}$ ]]; then
echo "Derived build number is outside Apple's supported integer format" >&2
exit 1
fi
echo "MOBILE_VERSION_CODE=$build_number" >> "$GITHUB_ENV"
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Verify stable API and Capacitor iOS CORS
shell: bash
run: |
set -euo pipefail
api_base="${IOS_DEBUG_API_URL%/}"
curl --fail --silent --show-error --location \
--connect-timeout 10 --max-time 20 \
--header 'Accept: application/json' \
--output /dev/null \
"$api_base/ping"
cors_headers="$RUNNER_TEMP/ios-debug-cors-headers.txt"
cors_body="$RUNNER_TEMP/ios-debug-cors-body.txt"
cors_status="$(curl --silent --show-error \
--connect-timeout 10 --max-time 20 \
--request OPTIONS \
--header 'Origin: capacitor://localhost' \
--header 'Access-Control-Request-Method: POST' \
--header 'Access-Control-Request-Headers: authorization,content-type' \
--dump-header "$cors_headers" \
--output "$cors_body" \
--write-out '%{http_code}' \
"$api_base/ping")"
if [[ ! "$cors_status" =~ ^2[0-9][0-9]$ ]]; then
echo "Stable API rejected the Capacitor iOS CORS preflight with HTTP $cors_status" >&2
sed -n '1,20p' "$cors_body" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-origin:[[:space:]]*capacitor://localhost[[:space:]]*$' "$cors_headers"; then
echo "Stable API did not allow the exact capacitor://localhost origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-credentials:[[:space:]]*true[[:space:]]*$' "$cors_headers"; then
echo "Stable API did not allow credentialed Capacitor requests" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-methods:.*[[:space:],]POST([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow POST from the Capacitor origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Authorization([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow the Authorization header from the Capacitor origin" >&2
exit 1
fi
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Content-Type([[:space:],]|$)' "$cors_headers"; then
echo "Stable API did not allow the Content-Type header from the Capacitor origin" >&2
exit 1
fi
- name: Build stable production web payload
env:
RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}
VITE_API_URL: ${{ env.IOS_DEBUG_API_URL }}
VITE_RELEASE_MANAGER_CONTROL_API_URL: ${{ env.IOS_DEBUG_API_URL }}
VITE_RELEASE_PUBLIC_GATEWAY_API_URL: https://api-v2.truckwash.io
run: |
npm run build
node -e "const manifest = require('./dist/release-manifest.json'); if (manifest.commit_sha !== process.env.RESOLVED_SOURCE_SHA) { throw new Error('Web release manifest source SHA mismatch'); }"
- name: Sync and validate iOS shell
shell: bash
run: |
set -euo pipefail
npx cap sync ios
npm run mobile:permissions:check
if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then
echo "Unexpected Xcode shell-script build phase detected" >&2
exit 1
fi
xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Validate native Debug and Release settings
shell: bash
run: |
set -euo pipefail
debug_settings="$RUNNER_TEMP/ios-debug-build-settings.txt"
release_settings="$RUNNER_TEMP/ios-release-build-settings.txt"
xcodebuild -showBuildSettings \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Debug \
CODE_SIGNING_ALLOWED=NO > "$debug_settings"
xcodebuild -showBuildSettings \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Release \
CODE_SIGNING_ALLOWED=NO > "$release_settings"
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app\.debug$' "$debug_settings"
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash Debug$' "$debug_settings"
grep -Eq '^[[:space:]]*PRODUCT_NAME = TruckWashDebug$' "$debug_settings"
grep -Eq '^[[:space:]]*CAPACITOR_DEBUG = true$' "$debug_settings"
grep -Eq '^[[:space:]]*DEBUG_INFORMATION_FORMAT = dwarf-with-dsym$' "$debug_settings"
grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$debug_settings"
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app$' "$release_settings"
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$release_settings"
grep -Eq '^[[:space:]]*PRODUCT_NAME = App$' "$release_settings"
- name: Install and validate Apple development signing assets
shell: bash
env:
IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}
IOS_DEBUG_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEBUG_CERTIFICATE_PASSWORD }}
IOS_DEBUG_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_DEBUG_PROVISION_PROFILE_BASE64 }}
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
run: |
set -euo pipefail
node scripts/mobile/check-ios-debug-signing-env.mjs
certificate_path="$RUNNER_TEMP/ios-debug-development.p12"
profile_path="$RUNNER_TEMP/ios-debug-development.mobileprovision"
profile_plist="$RUNNER_TEMP/ios-debug-development-profile.plist"
keychain_path="$RUNNER_TEMP/ios-debug-signing.keychain-db"
keychain_password="$(openssl rand -base64 48 | tr -d '\n')"
echo "::add-mask::$keychain_password"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_CERTIFICATE_BASE64.replace(/\\s/g, ''), 'base64'))" "$certificate_path"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_PROVISION_PROFILE_BASE64.replace(/\\s/g, ''), 'base64'))" "$profile_path"
chmod 600 "$certificate_path" "$profile_path"
security cms -D -i "$profile_path" > "$profile_plist"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" \
-P "$IOS_DEBUG_CERTIFICATE_PASSWORD" \
-A \
-t cert \
-f pkcs12 \
-k "$keychain_path"
security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list \
-S apple-tool:,apple: \
-s \
-k "$keychain_password" \
"$keychain_path"
signing_identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Development/ {print $2; exit}')"
if [[ ! "$signing_identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
echo "The PKCS#12 file does not contain a valid Apple Development signing identity" >&2
exit 1
fi
IOS_SIGNING_IDENTITY_SHA="$signing_identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY'
import datetime
import hashlib
import os
import plistlib
import re
import sys
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
profile = plistlib.load(handle)
team_id = os.environ["APPLE_TEAM_ID"]
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
entitlements = profile.get("Entitlements", {})
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
provisioned = set(profile.get("ProvisionedDevices", []))
expiration = profile.get("ExpirationDate")
now = datetime.datetime.now(datetime.timezone.utc)
if expiration and expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
checks = {
"profile team identifier": team_id in profile.get("TeamIdentifier", []),
"application identifier": entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
"entitlement team identifier": entitlements.get("com.apple.developer.team-identifier") == team_id,
"development entitlement": entitlements.get("get-task-allow") is True,
"profile expiration": expiration is not None and expiration > now,
"registered devices": bool(allowed) and allowed <= provisioned,
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
"developer certificate": bool(profile.get("DeveloperCertificates")),
"profile UUID": isinstance(profile.get("UUID"), str) and re.fullmatch(r"[0-9A-Fa-f-]{36}", profile["UUID"]) is not None,
"safe profile name": isinstance(profile.get("Name"), str) and not any(char in profile["Name"] for char in "\r\n"),
}
identity_sha = os.environ["IOS_SIGNING_IDENTITY_SHA"].upper()
certificate_hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}
checks["certificate belongs to profile"] = identity_sha in certificate_hashes
failures = [label for label, passed in checks.items() if not passed]
if failures:
print("Development provisioning profile validation failed:", file=sys.stderr)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
sys.exit(1)
PY
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")"
profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$profile_plist")"
profile_expiration="$(PROFILE_PLIST="$profile_plist" python3 - <<'PY'
import datetime
import os
import plistlib
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
expiration = plistlib.load(handle)["ExpirationDate"]
if expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
print(expiration.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"))
PY
)"
profile_install_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
profile_install_path="$profile_install_dir/$profile_uuid.mobileprovision"
mkdir -p "$profile_install_dir"
cp "$profile_path" "$profile_install_path"
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
echo "IOS_PROFILE_INSTALL_PATH=$profile_install_path" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_EXPIRATION=$profile_expiration" >> "$GITHUB_ENV"
- name: Archive Debug app with Apple Development signing
shell: bash
run: |
set -euo pipefail
archive_path="$RUNNER_TEMP/TruckWashDebug.xcarchive"
xcodebuild \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Debug \
-destination "generic/platform=iOS" \
-archivePath "$archive_path" \
archive \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Development" \
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
PRODUCT_BUNDLE_IDENTIFIER="$IOS_DEBUG_BUNDLE_ID" \
MARKETING_VERSION="$MOBILE_VERSION_NAME" \
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" \
DEBUG_INFORMATION_FORMAT="dwarf-with-dsym" \
ONLY_ACTIVE_ARCH=NO
echo "IOS_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
- name: Export development IPA
shell: bash
run: |
set -euo pipefail
export_options="$RUNNER_TEMP/ios-debug-ExportOptions.plist"
export_path="$RUNNER_TEMP/ios-debug-export"
EXPORT_OPTIONS="$export_options" python3 <<'PY'
import os
import plistlib
options = {
"method": "development",
"signingStyle": "manual",
"teamID": os.environ["APPLE_TEAM_ID"],
"provisioningProfiles": {
os.environ["IOS_DEBUG_BUNDLE_ID"]: os.environ["IOS_PROFILE_NAME"],
},
"stripSwiftSymbols": True,
"manageAppVersionAndBuildNumber": False,
}
with open(os.environ["EXPORT_OPTIONS"], "wb") as handle:
plistlib.dump(options, handle)
PY
xcodebuild \
-exportArchive \
-archivePath "$IOS_ARCHIVE_PATH" \
-exportPath "$export_path" \
-exportOptionsPlist "$export_options"
shopt -s nullglob
ipa_files=("$export_path"/*.ipa)
if [[ ${#ipa_files[@]} -ne 1 ]]; then
echo "Expected exactly one exported IPA; found ${#ipa_files[@]}" >&2
exit 1
fi
echo "IOS_EXPORTED_IPA=${ipa_files[0]}" >> "$GITHUB_ENV"
- name: Validate exported IPA and embedded signature
shell: bash
env:
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
run: |
set -euo pipefail
inspect_dir="$RUNNER_TEMP/ios-debug-inspect"
mkdir -p "$inspect_dir"
unzip -q "$IOS_EXPORTED_IPA" -d "$inspect_dir"
shopt -s nullglob
app_bundles=("$inspect_dir"/Payload/*.app)
if [[ ${#app_bundles[@]} -ne 1 ]]; then
echo "Expected exactly one Payload app; found ${#app_bundles[@]}" >&2
exit 1
fi
app_path="${app_bundles[0]}"
app_info="$app_path/Info.plist"
embedded_profile="$RUNNER_TEMP/ios-debug-embedded-profile.plist"
signature_entitlements="$RUNNER_TEMP/ios-debug-signature-entitlements.plist"
signature_details="$RUNNER_TEMP/ios-debug-signature-details.txt"
security cms -D -i "$app_path/embedded.mobileprovision" > "$embedded_profile"
codesign --verify --deep --strict "$app_path"
codesign -d --entitlements :- "$app_path" > "$signature_entitlements"
codesign -dvv "$app_path" > /dev/null 2> "$signature_details"
grep -Fq "TeamIdentifier=$APPLE_TEAM_ID" "$signature_details"
grep -Eq '^Authority=Apple Development:' "$signature_details"
executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_info")"
architectures="$(lipo -archs "$app_path/$executable_name")"
if [[ " $architectures " != *" arm64 "* ]]; then
echo "Exported executable does not contain arm64" >&2
exit 1
fi
APP_INFO="$app_info" PROFILE_PLIST="$embedded_profile" SIGNATURE_ENTITLEMENTS="$signature_entitlements" python3 <<'PY'
import datetime
import os
import plistlib
import sys
def load(path):
with open(path, "rb") as handle:
return plistlib.load(handle)
info = load(os.environ["APP_INFO"])
profile = load(os.environ["PROFILE_PLIST"])
signature = load(os.environ["SIGNATURE_ENTITLEMENTS"])
profile_entitlements = profile.get("Entitlements", {})
team_id = os.environ["APPLE_TEAM_ID"]
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
provisioned = set(profile.get("ProvisionedDevices", []))
expiration = profile.get("ExpirationDate")
now = datetime.datetime.now(datetime.timezone.utc)
if expiration and expiration.tzinfo is None:
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
checks = {
"bundle identifier": info.get("CFBundleIdentifier") == bundle_id,
"display name": info.get("CFBundleDisplayName") == "Truck Wash Debug",
"debug executable": info.get("CFBundleExecutable") == "TruckWashDebug",
"marketing version": info.get("CFBundleShortVersionString") == os.environ["MOBILE_VERSION_NAME"],
"build number": info.get("CFBundleVersion") == os.environ["MOBILE_VERSION_CODE"],
"minimum iOS": info.get("MinimumOSVersion") == "15.0",
"profile UUID": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"],
"profile team": team_id in profile.get("TeamIdentifier", []),
"profile application identifier": profile_entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
"development profile": profile_entitlements.get("get-task-allow") is True,
"signature application identifier": signature.get("application-identifier") == f"{team_id}.{bundle_id}",
"signature team identifier": signature.get("com.apple.developer.team-identifier") == team_id,
"debuggable signature": signature.get("get-task-allow") is True,
"profile expiration": expiration is not None and expiration > now,
"registered devices": bool(allowed) and allowed <= provisioned,
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
}
failures = [label for label, passed in checks.items() if not passed]
if failures:
print("Exported development IPA validation failed:", file=sys.stderr)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
sys.exit(1)
PY
- name: Assemble debug artifact
shell: bash
env:
SOURCE_REF: ${{ inputs.source_ref }}
run: |
set -euo pipefail
short_sha="${RESOLVED_SOURCE_SHA:0:12}"
artifact_name="truck-wash-debug-${MOBILE_VERSION_NAME}-${short_sha}"
artifact_dir="$RUNNER_TEMP/device-debug-artifact"
ipa_filename="$artifact_name.ipa"
dsym_filename="$artifact_name.dSYM.zip"
mkdir -p "$artifact_dir"
cp "$IOS_EXPORTED_IPA" "$artifact_dir/$ipa_filename"
shopt -s nullglob
dsym_bundles=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM)
if [[ ${#dsym_bundles[@]} -eq 0 ]]; then
echo "The Debug archive did not contain any dSYM bundles" >&2
exit 1
fi
ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact_dir/$dsym_filename"
capacitor_version="$(node -p "require('./node_modules/@capacitor/core/package.json').version")"
BUILT_AT_UTC="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
CAPACITOR_VERSION="$capacitor_version" \
DSYM_FILENAME="$dsym_filename" \
IPA_FILENAME="$ipa_filename" \
MANIFEST_PATH="$artifact_dir/manifest.json" \
python3 <<'PY'
import json
import os
manifest = {
"schema_version": 1,
"repository": os.environ["GITHUB_REPOSITORY"],
"source_ref": os.environ["SOURCE_REF"],
"source_sha": os.environ["RESOLVED_SOURCE_SHA"],
"workflow_run": int(os.environ["GITHUB_RUN_NUMBER"]),
"workflow_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
"built_at_utc": os.environ["BUILT_AT_UTC"],
"api_url": os.environ["IOS_DEBUG_API_URL"],
"release_manager_control_api_url": os.environ["IOS_DEBUG_API_URL"],
"bundle_id": os.environ["IOS_DEBUG_BUNDLE_ID"],
"display_name": "Truck Wash Debug",
"executable_name": "TruckWashDebug",
"version": os.environ["MOBILE_VERSION_NAME"],
"build": os.environ["MOBILE_VERSION_CODE"],
"minimum_ios": "15.0",
"capacitor_version": os.environ["CAPACITOR_VERSION"],
"xcode_version": os.environ["XCODE_VERSION"],
"signing_method": "development",
"profile_expiration_utc": os.environ["IOS_PROFILE_EXPIRATION"],
"ipa_filename": os.environ["IPA_FILENAME"],
"dsym_filename": os.environ["DSYM_FILENAME"],
}
with open(os.environ["MANIFEST_PATH"], "w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
handle.write("\n")
PY
(
cd "$artifact_dir"
shasum -a 256 "$ipa_filename" "$dsym_filename" manifest.json > SHA256SUMS
)
echo "IOS_DEBUG_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV"
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
- name: Upload device-debug artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
if-no-files-found: error
retention-days: 7
- name: Clean up Apple signing assets
if: always()
shell: bash
run: |
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$IOS_KEYCHAIN_PATH" || true
else
security delete-keychain "$RUNNER_TEMP/ios-debug-signing.keychain-db" || true
fi
if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then
rm -f "$IOS_PROFILE_INSTALL_PATH"
fi
rm -f \
"$RUNNER_TEMP/ios-debug-development.p12" \
"$RUNNER_TEMP/ios-debug-development.mobileprovision" \
"$RUNNER_TEMP/ios-debug-development-profile.plist" \
"$RUNNER_TEMP/ios-debug-embedded-profile.plist" \
"$RUNNER_TEMP/ios-debug-signature-entitlements.plist" \
"$RUNNER_TEMP/ios-debug-signature-details.txt"
+223 -30
View File
@@ -11,6 +11,36 @@ on:
description: Store build number/version code
required: false
type: string
upload_android_to_play:
description: Upload the signed Android App Bundle to Google Play
required: false
type: boolean
default: true
upload_ios_to_app_store:
description: Upload the signed iOS IPA to App Store Connect
required: false
type: boolean
default: true
android_track:
description: Google Play track for manual dispatches
required: false
type: choice
default: production
options:
- production
- beta
- alpha
- internal
android_release_status:
description: Google Play release status for manual dispatches
required: false
type: choice
default: completed
options:
- completed
- draft
- inProgress
- halted
push:
tags:
- "mobile-v*"
@@ -26,40 +56,81 @@ permissions:
contents: read
concurrency:
group: mobile-store-artifacts-${{ github.ref_name }}
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
android:
name: Android AAB
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-latest
timeout-minutes: 45
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' }}
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 || '' }}
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
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 }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; 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
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
@@ -67,10 +138,11 @@ jobs:
sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
INPUT_VERSION_NAME: ${{ inputs.version_name }}
INPUT_VERSION_CODE: ${{ inputs.version_code }}
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: |
set -euo pipefail
version_name="$INPUT_VERSION_NAME"
@@ -84,10 +156,22 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check Android store environment
if: steps.release-guard.outputs.current == 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --android
- name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Decode Android signing key
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -96,10 +180,6 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
keystore_path="$RUNNER_TEMP/android-release.keystore"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path"
echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
@@ -108,48 +188,98 @@ jobs:
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
- name: Build and sync Android shell
if: steps.release-guard.outputs.current == 'true'
run: |
npm run mobile:android:sync
npm run mobile:permissions:check
npm run mobile:android:signing:check
- name: Build signed Android App Bundle
if: steps.release-guard.outputs.current == 'true'
working-directory: android
run: ./gradlew --no-daemon bundleRelease
- name: Verify Android App Bundle signature
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null
if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: android/app/build/outputs/bundle/release/app-release.aab
path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error
retention-days: 14
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
ios:
name: iOS IPA
if: github.event_name != 'workflow_run'
runs-on: macos-latest
timeout-minutes: 60
name: iOS IPA and App Store 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: macos-15
environment: mobile-store-production
timeout-minutes: 90
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_BUNDLE_ID: io.truckwash.app
UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
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 }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; 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
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
INPUT_VERSION_NAME: ${{ inputs.version_name }}
INPUT_VERSION_CODE: ${{ inputs.version_code }}
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: |
set -euo pipefail
version_name="$INPUT_VERSION_NAME"
@@ -163,16 +293,32 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check iOS store environment
if: steps.release-guard.outputs.current == 'true'
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --ios
- name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Build and sync iOS shell
if: steps.release-guard.outputs.current == 'true'
run: |
npm run build
npx cap sync ios
npm run mobile:permissions:check
- name: Install Apple signing assets
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
@@ -182,12 +328,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
test -n "$IOS_CERTIFICATE_BASE64"
test -n "$IOS_CERTIFICATE_PASSWORD"
test -n "$IOS_PROVISION_PROFILE_BASE64"
test -n "$IOS_KEYCHAIN_PASSWORD"
test -n "$APPLE_TEAM_ID"
certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
@@ -214,14 +354,35 @@ jobs:
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
- name: Install App Store Connect API key
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: |
set -euo pipefail
private_keys_dir="$RUNNER_TEMP/private_keys"
private_key_path="$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
mkdir -p "$private_keys_dir"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64, 'base64'))" "$private_key_path"
chmod 600 "$private_key_path"
echo "API_PRIVATE_KEYS_DIR=$private_keys_dir" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_ID=$APP_STORE_CONNECT_API_KEY_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_PATH=$private_key_path" >> "$GITHUB_ENV"
- name: Resolve Swift packages
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App
if: steps.release-guard.outputs.current == 'true'
run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Archive iOS app
if: steps.release-guard.outputs.current == 'true'
run: |
xcodebuild \
-project ios/App/App.xcodeproj \
-scheme App \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
@@ -234,6 +395,7 @@ jobs:
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
- name: Export iOS IPA
if: steps.release-guard.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
@@ -255,7 +417,7 @@ jobs:
<string>$APPLE_TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>io.truckwash.app</key>
<key>$IOS_BUNDLE_ID</key>
<string>$IOS_PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
@@ -270,15 +432,43 @@ jobs:
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
-exportPath "$RUNNER_TEMP/ios-export" \
-exportOptionsPlist "$export_options"
ipa_path="$(find "$RUNNER_TEMP/ios-export" -name '*.ipa' -print -quit)"
test -n "$ipa_path"
echo "IOS_IPA_PATH=$ipa_path" >> "$GITHUB_ENV"
- name: Upload iOS artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ runner.temp }}/ios-export/*.ipa
if-no-files-found: error
retention-days: 14
- name: Validate iOS IPA with App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--validate-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Upload iOS IPA to App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--upload-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Clean up Apple signing assets
if: always()
shell: bash
@@ -289,3 +479,6 @@ jobs:
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
fi
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
fi
+239 -79
View File
@@ -14,49 +14,57 @@ permissions:
actions: read
concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
group: frontend-production
cancel-in-progress: false
jobs:
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
build-release:
if: >-
github.event.workflow_run.conclusion == 'success' &&
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]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
outputs:
current: ${{ steps.branch-head.outputs.current }}
build_id: ${{ steps.package.outputs.build_id }}
artifact_name: ${{ steps.package-names.outputs.artifact_name }}
archive_name: ${{ steps.package.outputs.archive_name }}
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
release_id: ${{ steps.package.outputs.release_id }}
steps:
- name: Checkout repository
- name: Check release commit is current
id: branch-head
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const { data: branch } = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "master",
});
const expected = process.env.RELEASE_EXPECTED_COMMIT;
const current = branch.commit.sha === expected;
core.setOutput("current", String(current));
core.info(
current
? `Release commit ${expected} is current for master.`
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
);
- name: Checkout tested commit
if: steps.branch-head.outputs.current == 'true'
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
@@ -79,16 +87,22 @@ jobs:
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build
- name: Record pre-gate dist inventory
if: steps.branch-head.outputs.current == 'true'
run: |
inventory="$RUNNER_TEMP/dist-before-production-gate.txt"
while IFS= read -r -d '' file; do
relative_path="${file#dist/}"
printf '%s\t%s\t%s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"$(stat --format='%s' "$file")" \
"$relative_path"
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
- name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs chromium
@@ -96,56 +110,186 @@ jobs:
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
env:
PLAYWRIGHT_PROD_WEBKIT: "0"
- name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 3
- name: Request Release Manager auto sync
- name: Confirm production gate did not mutate dist
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-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\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
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 }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
inventory="$RUNNER_TEMP/dist-after-production-gate.txt"
while IFS= read -r -d '' file; do
relative_path="${file#dist/}"
printf '%s\t%s\t%s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"$(stat --format='%s' "$file")" \
"$relative_path"
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
cmp "$RUNNER_TEMP/dist-before-production-gate.txt" "$inventory"
- name: Wait for Coolify release artifact
- name: Package and validate release
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload
id: package
run: node scripts/release/package-dist.mjs
env:
RELEASE_OUTPUT_DIR: release-artifacts
- name: Resolve package metadata
if: steps.branch-head.outputs.current == 'true'
id: package-names
env:
BUILD_ID: ${{ steps.package.outputs.build_id }}
CHECKSUM_PATH: ${{ steps.package.outputs.checksum_path }}
INVENTORY_PATH: ${{ steps.package.outputs.inventory_path }}
run: |
echo "artifact_name=frontend-release-$BUILD_ID" >> "$GITHUB_OUTPUT"
echo "checksum_name=$(basename -- "$CHECKSUM_PATH")" >> "$GITHUB_OUTPUT"
echo "inventory_name=$(basename -- "$INVENTORY_PATH")" >> "$GITHUB_OUTPUT"
- name: Upload release package
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.package-names.outputs.artifact_name }}
path: |
${{ steps.package.outputs.archive_path }}
${{ steps.package.outputs.checksum_path }}
${{ steps.package.outputs.inventory_path }}
if-no-files-found: error
retention-days: 14
deploy-frontend-production:
needs: build-release
if: needs.build-release.outputs.current == 'true'
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 90
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
env:
RELEASE_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
RELEASE_EXPECTED_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
RELEASE_ID: ${{ needs.build-release.outputs.release_id }}
RELEASE_STRICT_BUILD_ID: "true"
RELEASE_REQUIRE_CACHE_HEADERS: "true"
RELEASE_WAIT_INITIAL_SECONDS: 0
RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_POLL_INTERVAL_SECONDS: 5
steps:
- name: Checkout tested commit
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
uses: actions/setup-node@v5
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: Download validated release package
uses: actions/download-artifact@v4
with:
name: ${{ needs.build-release.outputs.artifact_name }}
path: release-artifacts
- name: Resolve downloaded release package
env:
ARCHIVE_NAME: ${{ needs.build-release.outputs.archive_name }}
CHECKSUM_NAME: ${{ needs.build-release.outputs.checksum_name }}
INVENTORY_NAME: ${{ needs.build-release.outputs.inventory_name }}
run: |
[[ -n "$ARCHIVE_NAME" && "$ARCHIVE_NAME" == "$(basename -- "$ARCHIVE_NAME")" ]]
[[ -n "$CHECKSUM_NAME" && "$CHECKSUM_NAME" == "$(basename -- "$CHECKSUM_NAME")" ]]
[[ -n "$INVENTORY_NAME" && "$INVENTORY_NAME" == "$(basename -- "$INVENTORY_NAME")" ]]
archive_path="$GITHUB_WORKSPACE/release-artifacts/$ARCHIVE_NAME"
checksum_path="$GITHUB_WORKSPACE/release-artifacts/$CHECKSUM_NAME"
inventory_path="$GITHUB_WORKSPACE/release-artifacts/$INVENTORY_NAME"
[[ -f "$archive_path" && -f "$checksum_path" && -f "$inventory_path" ]]
echo "RELEASE_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
echo "RELEASE_ARCHIVE_SHA256_PATH=$checksum_path" >> "$GITHUB_ENV"
echo "RELEASE_INVENTORY_PATH=$inventory_path" >> "$GITHUB_ENV"
- name: Check release commit is still current
id: branch-head
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const { data: branch } = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "master",
});
const expected = process.env.RELEASE_EXPECTED_COMMIT;
const current = branch.commit.sha === expected;
core.setOutput("current", String(current));
core.info(
current
? `Release commit ${expected} is current immediately before activation.`
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
);
- name: Deploy atomically and verify cPanel release
if: steps.branch-head.outputs.current == 'true'
id: deploy
timeout-minutes: 15
run: node scripts/release/deploy-cpanel.mjs
env:
NODE_OPTIONS: --use-system-ca
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_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'
timeout-minutes: 10
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
timeout-minutes: 15
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
@@ -157,6 +301,23 @@ 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_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
if: steps.branch-head.outputs.current == 'true'
run: |
@@ -166,12 +327,11 @@ 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\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"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\":[\"static_artifact\",\"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 }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
@@ -189,4 +349,4 @@ jobs:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 3
retention-days: 14
+27 -2
View File
@@ -48,8 +48,8 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
cancel-in-progress: true
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' }}
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
@@ -511,6 +511,31 @@ jobs:
if-no-files-found: ignore
retention-days: 1
required-ci:
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
name: Required CI
needs: [format-tests, build-and-unit, e2e-pr]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify required jobs succeeded
shell: bash
env:
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
BUILD_AND_UNIT_RESULT: ${{ needs.build-and-unit.result }}
E2E_PR_RESULT: ${{ needs.e2e-pr.result }}
run: |
set -euo pipefail
failed=0
for required_job in FORMAT_TESTS_RESULT BUILD_AND_UNIT_RESULT E2E_PR_RESULT; do
result="${!required_job:-missing}"
if [[ "$result" != "success" ]]; then
echo "${required_job}=${result}" >&2
failed=1
fi
done
exit "$failed"
e2e-full:
if: >
always() &&
+1
View File
@@ -15,6 +15,7 @@ dist-ssr
coverage
*.local
dev-dist
.playwright-cli/
# Mobile build and signing outputs
/app/build/
+23
View File
@@ -16,6 +16,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
npm install
```
## Contributing Changes
Create a scoped feature branch, push it, and open a pull request targeting
`master`. Do not push directly to `master`. Merge only after the `Required CI`
check succeeds, all review conversations are resolved, and the branch is up to
date. Use squash merge so `master` retains linear history.
See [`.github/BRANCH_PROTECTION.md`](.github/BRANCH_PROTECTION.md) for the
repository policy, rollout checks, and emergency bypass procedure.
### Compile and Hot-Reload for Development
```sh
@@ -183,6 +193,19 @@ the Capacitor Android project. The generator updates `android/app/src/main/res`
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
and `store_icon.png`.
## Mobile Store Releases
Signed Android and iOS store artifacts are built through the GitHub Actions
`Mobile Store Artifacts` workflow. By default, current `master` after green
`Automated Tests` uploads Android to Google Play production and uploads iOS to
App Store Connect.
See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and
local mobile checks. See `docs/app-store-release.md` for App Store Connect
release preparation and review notes. For a separate development-signed IPA
that can be installed on an approved iPhone from Ubuntu over USB, see
`docs/ios-device-debug.md`.
## Bubblewrap (TWA) Build and Install
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
+102
View File
@@ -0,0 +1,102 @@
# Apple App Store Release Runbook
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
## Account And App Record
- Use the Truck Wash ApS Apple Developer account. The Account Holder must accept
the latest Apple agreements before builds can be uploaded.
- Create or verify the App Store Connect app record:
- Platform: iOS
- Name: Truck Wash Kundeportal
- Bundle ID: `io.truckwash.app`
- SKU: `truckwash-ios`
- Primary language: Danish
- Category: Business
- Price: Free
- Initial availability: Denmark
- Keep the GitHub environment `mobile-store-production` configured with the
iOS signing, App Store Connect, Android signing, and Google Play upload
secrets used by the mobile workflow.
## Build And Upload
1. Merge the release commit to `master`.
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
3. Create a release tag such as `mobile-v1.0.0`.
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts from
the tested commit. By default it uploads Android to the Google Play
production track and uploads the iOS IPA to App Store Connect.
5. For a manual upload, dispatch `Mobile Store Artifacts` with `version_name`
and `version_code`. Leave `upload_ios_to_app_store` enabled for the iOS
upload, or disable it to produce only the signed GitHub artifact.
The same workflow also runs automatically after a successful `Automated Tests`
run on current `master`. It skips stale workflow-run commits if `master` has
advanced before the mobile jobs start.
The iOS workflow expects these environment secrets:
- `IOS_CERTIFICATE_BASE64`
- `IOS_CERTIFICATE_PASSWORD`
- `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The workflow installs the signing certificate and provisioning profile in a
temporary keychain on the `macos-15` runner, archives the Capacitor Xcode
project, exports an App Store IPA, validates it with `xcrun altool`, uploads it
with the App Store Connect API key, and removes temporary signing assets in the
cleanup step.
## Product Page Defaults
- Support URL: `https://truckwash.io/support`
- Privacy URL: `https://truckwash.io/privacy-policy`
- Subtitle: `Book og start truckvask`
- Promotional text: `Administrer vask, koeretoejer, ordrer og fakturaer fra mobilen.`
- Keywords: `truck wash,lastbilvask,vask,booking,kundeportal`
- Expected age rating: 4+, subject to the App Store Connect questionnaire.
Use real iOS simulator or device screenshots. Provide at least:
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
Recommended screenshot scenes: dashboard, booking flow, self-service wash start,
vehicles/orders, and invoices/payment history. Do not include real customer
data, private tokens, or placeholder copy.
## Privacy And Review Notes
App Store Connect privacy labels must match the actual app and backend behavior.
Expected minimum disclosures include account/contact data, identifiers such as
customer number, vehicle/license plate data, order and invoice history, payment
state, approximate/precise location when used, and photos or attachments when
users upload them. Tracking should remain false unless analytics/ad tracking is
introduced.
Review notes must include:
- A demo account and password.
- OTP/2FA/passkey fallback instructions when enabled for the account.
- A clear statement that Stripe/card payments are for physical truck-wash
services consumed outside the app, so Apple in-app purchase is not used.
- Any hardware-dependent functionality that reviewers cannot reproduce, with a
short demo video if needed.
- Confirmation that the backend environment is online for the whole review
window.
## TestFlight And Release
1. Wait for App Store Connect processing to finish.
2. Distribute the processed build to internal TestFlight testers.
3. Run clean-device QA on iPhone and iPad.
4. Fix issues using the same marketing version and an incremented build number.
5. Submit for App Review with manual release after approval.
6. After approval, release to Denmark first and monitor crashes, support mail,
and App Store Connect feedback before expanding availability.
+262
View File
@@ -0,0 +1,262 @@
# cPanel frontend deployment
This runbook covers the production deployment of `pleno-vue` only. The API is
not uploaded to cPanel and continues to use its existing release process and
hosts.
## Release flow
`.github/workflows/release.yml` starts only after the `Automated Tests`
workflow succeeds for a push to `master` in this repository. It then:
1. Rechecks that the tested commit is still the head of `master`.
2. Checks out that exact commit without persisting GitHub credentials.
3. Installs dependencies, runs source checks, and builds `dist` once.
4. Runs the local-production Playwright gate against that existing `dist`.
5. Creates an immutable ZIP, SHA-256 sidecar, and file inventory, then verifies
a local archive round trip.
6. Uploads the package as a required GitHub Actions artifact.
7. Enters the protected `frontend-production` GitHub environment and rechecks
`master` immediately before deployment.
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
uploaded `.part` files are downloaded and hashed before they are renamed.
9. Uses the cPanel Fileman API to extract into a new inactive release. The
extracted tree is downloaded and compared byte-for-byte with the validated
inventory, then `master` is checked again through the read-only workflow
token.
10. Replaces the `current` symlink with a single server-side rename. Public
manifest, asset-integrity, cache-header, API-ping, and role gates run after
activation. A failed public or role gate restores the previous symlink.
The fixed `frontend-production` concurrency group is not cancellable. A newer
push therefore cannot interrupt an in-progress switch or rollback.
## GitHub environment
Create the environment `frontend-production`, restrict deployment branches to
protected branches, and keep `master` protected by the required CI checks.
Production approvals can be added as an environment protection rule.
Add these environment **secrets**:
- `PRODUCTION_FTP_HOST`
- `PRODUCTION_FTP_USER`
- `PRODUCTION_FTP_PASSWORD`
- `PRODUCTION_FTP_PATH`
- `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 cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI
replacement for the required extract, symlink, and rename operations. Revoke
and rotate the token if it is ever exposed.
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.
### Create the dedicated FTP credentials
1. Open **Files -> FTP Accounts** in the `truckwash` cPanel account.
2. Create `github-pleno-vue@truckwash.io` with a generated, unique password.
3. Set its directory to `frontend-deployments`, which cPanel resolves to
`/home/truckwash/frontend-deployments`, and leave quota unlimited.
4. Add `server.red-block.com` as `PRODUCTION_FTP_HOST`. Do not use
`truckwash.io`: the FTPS certificate is issued to the server hostname.
5. Add the full account login as `PRODUCTION_FTP_USER`, the generated password
as `PRODUCTION_FTP_PASSWORD`, and `/` as `PRODUCTION_FTP_PATH`. `/` is the
root of this jailed FTP account, not the cPanel account home.
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 preflight against the configured API
origin. It must be able to call cPanel API2 `Fileman::fileop` for extract,
symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider
restricts those operations, request the required account feature access;
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.
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. GitHub-hosted deploy runners install
`lftp` and Playwright Chromium during the job; the existing self-hosted build
runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and
`sha256sum`.
## 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:
```text
archives/
releases/
<commit>-<github-run>-<attempt>/
dist/
staging/
current -> releases/<release-id>/dist
```
The domain's document root must resolve to
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/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.
Before merging the workflow change, perform a one-time bootstrap in cPanel:
1. Back up the existing cPanel webroot and confirm the frontend hostname does
not serve API/PHP files from this location.
2. Create `archives`, `releases`, and `staging` below the dedicated deployment
root.
3. Put one complete, validated frontend build at
`releases/<commit>-<build-id>/dist`. Its `release-manifest.json` must contain
that full 40-character commit and the same build ID used in the directory
name.
4. Create `current` as a relative symlink to that release's `dist` directory.
5. Make the frontend domain document root resolve to the stable `current` path.
For a cPanel primary domain whose configured document root remains
`/home/truckwash/public_html`, make `public_html` a symlink to
`frontend-deployments/current`. Exchange the old directory and prepared
symlink atomically, and retain the old directory as a recovery copy.
6. Confirm the release `.htaccess` contains `DirectoryIndex index.html` so a
symlinked primary-domain root serves the Vue shell instead of a directory
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
a disposable symlink-replacement preflight and refuses deployment if the
filesystem or hosting policy cannot replace a symlink atomically.
The automatic deployer intentionally refuses to create the first `current`
pointer. This prevents a missing or misconfigured bootstrap from turning the
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`.
If the regression followed the one-time webroot exchange, 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. An unreadable root is eligible only when the
independent account-home listing identifies it as a symbolic link. It renames
the current 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 Imunify360 blocks the cPanel API before the audit can read Fileman, use the
protected **Frontend Root FTPS Repair** workflow. Start with `audit`: it reads
and hashes only the active `.htaccess`. The `repair` mode stages the reviewed
`public/.htaccess`, verifies its checksum, rechecks that the live file has not
changed since the audit, and retains the original as a run-specific backup
before activation. It then verifies the public root, `index.html`, release
manifest, and a deep Vue route; a failed checksum or live check restores the
original file and retains the failed candidate for inspection.
This FTPS repair is deliberately limited to `.htaccess`. It restores direct
SPA loading when a valid release is already present but does not replace the
atomic cPanel release workflow. The hosting administrator must still whitelist
the automation source in Imunify360 WebShield before normal deployments can
resume.
## 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.
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
implementation keeps previous release directories for rollback, but does not
publish their asset paths through the new `current` pointer. Treat long-lived
open-tab compatibility as a separate CDN/shared-assets enhancement if product
usage requires it; the deployment itself does not serve mixed files.
## Failure and rollback behavior
- Any error before the symlink rename leaves the current release untouched.
- The deploy helper immediately verifies the public release after the rename.
A failure restores the captured previous release.
- A later public or credentialed Playwright failure runs the explicit rollback
step with the previous immutable target emitted by the deploy step.
- A stale workflow run exits before activation when `master` has advanced.
- Release Manager is record-only (`auto_sync: false`); it no longer deploys the
frontend through the API/Coolify path.
For manual rollback from a controlled runner, provide the same GitHub
environment settings plus the target recorded in the successful deployment:
```bash
RELEASE_ROLLBACK_TARGET=releases/<release-id>/dist npm run release:deploy:cpanel:rollback
```
Never point this command outside `releases/<release-id>/dist`; the helper rejects
path traversal and operations outside the configured deployment root.
+60
View File
@@ -0,0 +1,60 @@
# Customer attributes refactor and migration plan
## Problem statement
Customer attributes are currently represented as loosely typed string flags and evaluated in several UI, POS, and invoicing paths. This makes product restrictions vulnerable to broad category heuristics. The immediate defect is that `restrictAdditionalServices` ("Begræns tillægsydelser") treats related booking add-ons as additional services, so interior wash add-ons plus trailer/dolly additions are blocked even though that attribute is intended to cover standalone additional services only.
## Target behavior matrix
| Attribute | Canonical intent | Product availability behavior | Invoice/workflow behavior |
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `restrictAdditionalServices` | Block standalone additional services/tillægsydelser. | Block standalone additional-service catalog items; do not block related booking add-ons such as interior wash, trailer, or dolly. | Flag only order lines that are standalone additional services. |
| `restrictTankCleaning` | Block tank-cleaning services. | Block products whose category or legacy name identifies tank cleaning. | Flag tank-cleaning order lines. |
| `restrictSpotFree` | Block Spot Free/RO rinse products. | Block canonical Spot Free product IDs and legacy Spot Free/RO naming. | Flag Spot Free order lines. |
| `restrictInteriorCleaning` | Block interior wash services. | Block products whose names/categories explicitly identify interior wash. | Flag interior-wash order lines. |
| `onlyTankCleaning` | Allow only tank-cleaning services. | Block every non-tank-cleaning product while keeping tank-cleaning products available. | Flag non-tank-cleaning order lines. |
| `requiresReferenceNumber` | Require an order reference. | No product filtering. | Flag orders missing a required reference. |
| `requiresRegistrationNumbersInvoice` | Require registration numbers on invoice/order context. | No product filtering. | Flag orders missing required registration numbers. |
| `invoiceAllOrdersIndividually` | Prevent grouped invoicing. | No product filtering. | Split/flag invoice collections containing multiple orders for the customer. |
| `invoiceWithStripe` | Invoice through Stripe workflow. | No product filtering. | Route the customer through Stripe invoicing/payment handling. |
| `showPricesOnBookingPage` | Show customer prices during booking. | No product filtering. | Presentation-only booking behavior. |
| `usePONumbers` | Use/prompt for PO numbers. | No product filtering. | Require or expose PO-number workflow where configured. |
| `exemptFromAdministrationFee` | Do not charge administration fees. | No product filtering. | Suppress/flag administration-fee order lines for this customer. |
## Refactor plan
1. **Create a canonical customer-rule domain module**
- Keep `CUSTOMER_RULE_DEFINITIONS` as the registry of public attributes, but extend each entry with a typed evaluator contract: product predicate, category predicate, invoice predicate, and UI impact metadata.
- Replace scattered string comparisons with registry lookups so every surface uses the same semantics.
- Add explicit names for ambiguous categories: `standaloneAdditionalService`, `relatedAddon`, `primaryProduct`, `tankCleaning`, `spotFree`, and `interiorCleaning`.
2. **Normalize product classification once**
- Build a `classifyCustomerRuleProduct(product, context)` helper returning booleans for each product class.
- Treat related add-ons (`isRelatedAddon`, `relatedItemId`) as context, not as proof that the item is a standalone additional service.
- Reserve `restrictAdditionalServices` for category 8/standalone service context or explicit additional-service labels, not numeric booking add-on category 4.
3. **Migrate rule evaluation paths**
- POS product cards and mobile flows should call `getCustomerProductRestriction` only with the normalized product context.
- Customer-rule tooltips should derive blocked/available products from the same evaluator used by POS.
- Invoicing-period flag generation should use the same classification vocabulary as product availability so historical and current orders are flagged consistently.
4. **Backfill and data migration**
- Keep existing attribute keys unchanged to avoid a destructive migration.
- Add a one-time data audit/report listing customers with `restrictAdditionalServices` and recent orders containing interior wash, trailer, or dolly add-ons. These rows should be verified as no longer violating the rule after deployment.
- If any historical invoice flags were created solely because related add-ons were treated as additional services, provide an idempotent cleanup command to recalculate customer-rule violations for affected invoice periods.
5. **Regression test coverage**
- Unit-test every attribute in the target behavior matrix.
- Add focused cases for the defect: interior wash related add-on, trailer related add-on, and dolly related add-on must remain available under `restrictAdditionalServices`.
- Add invoice-flag fixtures mirroring the same products so invoicing behavior cannot drift from POS behavior.
- Keep tooltip tests aligned with the evaluator, showing standalone additional services under `restrictAdditionalServices` and not showing related add-ons.
6. **Rollout and verification**
- Ship the evaluator patch behind the existing attribute keys.
- Run unit tests and targeted POS/customer-rule e2e tests.
- Verify with production-like catalog data that `restrictAdditionalServices` blocks only standalone additional services while `restrictInteriorCleaning`, `restrictTankCleaning`, `restrictSpotFree`, and `onlyTankCleaning` continue to behave exactly as listed above.
+400
View File
@@ -0,0 +1,400 @@
# Cable-Connected iPhone Debug IPA Runbook
This runbook covers development-signed iOS builds installed from an Ubuntu
workstation over USB. It is separate from the public App Store release path in
`docs/app-store-release.md`.
The device build is deliberately a second app:
- Debug bundle ID: `io.truckwash.app.debug`
- Debug display name: `Truck Wash Debug`
- Production bundle ID: `io.truckwash.app`
- Capacitor/Android app ID: `io.truckwash.twa`
- API: `https://api-v2.truckwash.io/master/api`
Installing or uninstalling the debug app must not replace or remove the
production app. The debug workflow builds the Vue application in production
mode against the stable API; it does not use Vite's development `/api` default
or a live-reload server.
## What Ubuntu Can And Cannot Do
The current Ubuntu workstation already has `usbmuxd`, the libimobiledevice
utilities, and `ideviceinstaller`. The current iPhone has previously been
trusted and paired. Run the repository doctor before every install because the
phone can still be locked, trust can be reset, or Developer Mode can be off.
This workflow supports:
- Inspecting pairing, activation, lock, Developer Mode, and install-service
readiness.
- Installing and upgrading a valid development-signed IPA.
- Reading filtered device syslog and copying crash reports.
Apple does not provide Xcode, LLDB device debugging, or Safari Web Inspector on
Linux. `CAPACITOR_DEBUG` and `get-task-allow` make the IPA suitable for a
development device, but they do not create an official Linux LLDB or WebKit
debugger. Use a physical Mac with Xcode/Safari for breakpoints or Web Inspector.
Use TestFlight or a physical Mac if a new or beta iOS release is incompatible
with libimobiledevice; never weaken device security or signing validation as a
workaround.
## One-Time iPhone Preparation
1. Connect the iPhone directly with a data-capable USB cable.
2. Unlock the phone and keep it awake. Tap **Trust** if iOS asks whether to
trust this computer, then enter the device passcode.
3. On iOS 16 or newer, open **Settings -> Privacy & Security -> Developer
Mode**, turn Developer Mode on, and accept the restart. iOS 15 does not have
this setting and the helper does not require it there.
4. After the restart, unlock the phone, confirm **Turn On** in the Developer
Mode prompt, and enter the passcode again.
5. Reconnect the cable and run the doctor described below.
Developer Mode is an iOS security control and cannot be bypassed from Ubuntu.
If the Developer Mode setting is absent, connect the phone once to a physical
Mac and use Apple's supported Xcode or Apple Configurator device preparation,
then return to Ubuntu after the phone has restarted and Developer Mode is on.
Trust, pairing, and Developer Mode can be cleared by device resets, iOS updates,
or privacy/location resets. Repeat these steps if the doctor reports that the
previously working device is no longer ready.
## Apple Developer Setup
This requires the paid Truck Wash ApS Apple Developer team and a user permitted
to manage certificates, identifiers, and devices.
### Register the device and debug App ID
1. Connect and unlock the iPhone, then get its UDID locally with
`idevice_id -l`. Treat the full UDID as sensitive operational data: do not
commit it or paste it into ordinary build logs.
2. In Apple Developer **Certificates, Identifiers & Profiles -> Devices**, add
the iPhone using that UDID.
3. Under **Identifiers**, create an explicit App ID for
`io.truckwash.app.debug`.
4. Enable only capabilities required by the current Xcode project. Do not copy
unrelated production entitlements into the debug App ID.
### Create the certificate and development profile
1. Create a dedicated **Apple Development** certificate for CI device-debug
signing. Keep its private key under the team's normal credential controls.
2. Export the certificate and private key together as a password-protected
`.p12` file.
3. Create an **iOS App Development** provisioning profile that selects:
- App ID `io.truckwash.app.debug`
- The dedicated Apple Development certificate
- Every approved physical test iPhone, including the cable-connected device
4. Download the `.mobileprovision` file.
5. Confirm the profile has not expired, includes the intended device UDIDs, and
grants `get-task-allow=true`. An App Store or ad-hoc profile is not valid for
this workflow.
Base64-encode both files without line wrapping before adding them to GitHub. On
Ubuntu, for example:
```sh
base64 -w 0 TruckWashDebug.p12 > TruckWashDebug.p12.base64
base64 -w 0 TruckWashDebug.mobileprovision > TruckWashDebug.mobileprovision.base64
```
Store the encoded values in GitHub immediately, verify one successful build,
then securely remove the local `.p12`, profile, encoded copies, CSR, and any
other private-key intermediates that are no longer required. Never commit
signing files or their encoded contents.
## GitHub Environment And Dispatch Approval
Create a repository environment named `mobile-device-debug`. Store the debug
signing configuration only in that environment. Required environment reviewers
are not available for this private repository's current GitHub plan, so the
manual `workflow_dispatch` inputs are the signing approval boundary.
Restrict the environment's custom deployment branches to the exact `master`
branch. The checked-in workflow also refuses any other workflow ref. This keeps
signing secrets behind the reviewed workflow on `master`, while `source_ref`
can still select a separately inspected same-repository commit to build.
Add these environment variables exactly:
- `APPLE_TEAM_ID`
- `IOS_DEBUG_BUNDLE_ID=io.truckwash.app.debug`
- `IOS_DEBUG_API_URL=https://api-v2.truckwash.io/master/api`
Add these environment secrets exactly:
- `IOS_DEBUG_CERTIFICATE_BASE64`: base64 of the password-protected `.p12`
- `IOS_DEBUG_CERTIFICATE_PASSWORD`: password used to export the `.p12`
- `IOS_DEBUG_PROVISION_PROFILE_BASE64`: base64 of the development
`.mobileprovision`
- `IOS_DEBUG_ALLOWED_UDIDS`: newline-delimited UDIDs for every device that the
profile is expected to contain
The workflow generates and masks a new random password for its temporary macOS
keychain on every run. Do not create or store an
`IOS_DEBUG_KEYCHAIN_PASSWORD` secret.
Do not reuse the `mobile-store-production` distribution secrets. The debug job
must use an Apple Development certificate and iOS App Development profile; the
existing `io.truckwash.app` App Store workflow remains unchanged.
The person dispatching a run must inspect the intended commit first. Do not
dispatch when:
- The exact 40-character SHA is not the branch, tag, or commit intended.
- The source comes from a fork or another repository.
- The requested change is not appropriate to sign for a physical device.
- The signing profile is expired or no longer covers the intended device.
The workflow independently resolves `source_ref` inside this repository and
requires it to equal `expected_sha`. It also requires the exact typed
confirmation `SIGN IOS DEBUG IPA`. A missing/mismatched SHA or confirmation
stops the unprivileged resolver before the environment signing secrets are used.
## Build And Download A Debug IPA
1. Open **Actions -> iOS Device Debug IPA -> Run workflow** and keep **Use
workflow from** set to `master`.
2. Inspect the intended commit and copy its complete 40-character SHA.
3. Enter `source_ref`. It may be a branch, tag, or commit in this repository and
defaults to `master`.
4. Enter the complete SHA as `expected_sha` and enter the exact confirmation
`SIGN IOS DEBUG IPA`. Submitting these inputs is approval to sign that source.
5. The resolver pins `source_ref` inside this repository and verifies it equals
`expected_sha`. A mismatch stops the run before signing.
6. Wait for the signed macOS job to finish. It builds a Debug archive against
`https://api-v2.truckwash.io/master/api`, exports it with method
`development`, validates the embedded profile and app identity, and never
uploads the result to App Store Connect.
7. Download the `truck-wash-debug-<version>-<12-character-SHA>` GitHub Actions
artifact for the run. Keep its same-prefix `.ipa`, `.dSYM.zip`,
`manifest.json`, and `SHA256SUMS` together in one directory.
8. From that directory, verify the download before connecting it to a device:
```sh
sha256sum --check SHA256SUMS
```
Do not install an artifact after a checksum failure. The manifest records the
source ref and SHA, build/run numbers, bundle identity, stable API, minimum iOS,
Xcode/Capacitor versions, signing method, and provisioning-profile expiration.
It intentionally does not contain device UDIDs or secrets.
Artifacts are retained for seven days. Keep the zipped dSYM with any crash
report from that build so a Mac/Xcode crash-symbolication path remains
available.
## Ubuntu Device Commands
Run commands from the repository root. The npm interface is:
```sh
npm run mobile:ios:device -- <command>
```
If the npm wrapper is unavailable, use the equivalent direct entrypoint:
```sh
node scripts/mobile/ios-device.mjs <command>
```
The helper uses USB devices only. With one connected iPhone, omit `--udid`.
With multiple devices connected, provide `--udid ID`; the command fails instead
of guessing. Normal output redacts full UDIDs.
### Check readiness
Unlock the phone and run:
```sh
npm run mobile:ios:device -- doctor
```
The doctor verifies required host commands, USB discovery, pairing, activation,
unlocked state, Developer Mode, and installation-proxy access. Resolve every
reported failure before attempting an install.
For a specific connected device:
```sh
npm run mobile:ios:device -- doctor --udid DEVICE_UDID
```
### Install or upgrade
Keep the downloaded artifact files together and run:
```sh
npm run mobile:ios:device -- install ./truck-wash-debug-VERSION-SHA.ipa --manifest ./manifest.json
```
The helper requires and verifies `SHA256SUMS` and the complete workflow
manifest, then inspects the IPA and its embedded profile. It rejects missing or
mismatched artifact metadata, the wrong repository/source/API/bundle/executable,
an App Store/ad-hoc or expired profile, `get-task-allow=false`, a profile
missing the connected UDID, or an invalid app payload before calling
`ideviceinstaller`.
If `io.truckwash.app.debug` is absent, the helper installs it. If it is already
present, the helper upgrades it and confirms the resulting version/build on the
phone. It never uninstalls or replaces `io.truckwash.app`.
Launch **Truck Wash Debug** manually from the iPhone Home Screen. Keep the phone
online for the first launch so iOS can perform Apple's PPQ validation for the
provisioning profile. A firewall, DNS filter, VPN, or captive portal that
blocks Apple's validation service can prevent a correctly signed development
app from opening.
### Collect filtered logs
Start logging, then reproduce the issue on the phone:
```sh
npm run mobile:ios:device -- logs
npm run mobile:ios:device -- logs --output ./truck-wash-debug.log
```
The debug executable is deliberately named `TruckWashDebug`, distinct from the
production executable. The helper verifies that exact name and filters
`idevicesyslog` output for it. It streams child-tool output through UDID
redaction; `--output` files are written by the helper with mode `0600` after
redaction. Logs should make it possible to correlate the app with its source SHA
and stable API target without exposing signing secrets or full device IDs.
### Copy crash reports
Create a destination directory and copy reports from the phone:
```sh
mkdir -p ./ios-crashes
npm run mobile:ios:device -- crashes ./ios-crashes
```
Crash retrieval always keeps the original reports on the iPhone. Preserve the
matching IPA manifest and dSYM with each report.
### Remove only the debug app
Uninstall requires the exact debug bundle ID as typed confirmation:
```sh
npm run mobile:ios:device -- uninstall --confirm io.truckwash.app.debug
```
The helper refuses to remove the production bundle or any other bundle ID.
## Adding Devices And Renewing Signing
A provisioning profile is a snapshot. Registering another iPhone in Apple
Developer does not update an already downloaded profile.
When adding a device:
1. Obtain its UDID locally and register it in the Apple Developer portal.
2. Regenerate the `io.truckwash.app.debug` iOS App Development profile with the
new and existing approved devices selected.
3. Replace `IOS_DEBUG_PROVISION_PROFILE_BASE64`.
4. Add the UDID to the newline-delimited `IOS_DEBUG_ALLOWED_UDIDS` secret.
5. Dispatch a new build; an existing IPA does not gain access to the new device.
Monitor the profile expiration recorded in each artifact manifest and the Apple
Development certificate expiration in the portal. Before either expires,
create/renew the signing material, regenerate the profile, replace the affected
GitHub secrets, and prove the result with a new build and real-device install.
Revoked or expired signing material invalidates later installation and can stop
an already installed development build from launching.
## Troubleshooting
### No device, device locked, or installation proxy unavailable
- Use a direct data-capable cable and avoid an unreliable hub.
- Unlock the iPhone, keep its screen awake, reconnect it, and rerun `doctor`.
- Close other tools that may be exclusively interacting with the device.
- Do not repeatedly retry installation while the doctor reports a lock/service
failure.
### Pairing or trust failure
- Unlock the phone and accept the Trust prompt.
- If no prompt appears and `doctor` reports invalid pairing, use the explicit
repair guidance printed by the helper, reconnect, and confirm trust again.
- Device privacy resets and some iOS updates require a new trust decision.
### Developer Mode is disabled or absent on iOS 16 or newer
- Enable it under **Settings -> Privacy & Security -> Developer Mode**, restart,
and confirm after the reboot.
- If the switch is absent, use a Mac with Xcode or Apple Configurator for
Apple's supported one-time preparation. There is no Ubuntu bypass.
### IPA, profile, certificate, or UDID mismatch
- Confirm the IPA is from **iOS Device Debug IPA**, not **Mobile Store Artifacts**.
- Check `manifest.json` for `io.truckwash.app.debug`, development signing, the
intended source SHA, and a future profile expiration.
- Regenerate the development profile when a device was added, a certificate was
replaced, or the profile expired; then replace the GitHub secret and rebuild.
- Never suppress the helper's profile, entitlement, bundle, or checksum checks.
### App installs but will not launch
- Keep the phone online for Apple's initial PPQ validation.
- Check whether VPN, DNS, firewall, captive-portal, or device-management policy
blocks Apple developer-app verification.
- Confirm Developer Mode is still on and the certificate/profile has not expired
or been revoked.
- Collect syslog and crash reports before reinstalling so evidence is preserved.
### App reports `No response was received`
- Keep the API base set to `https://api-v2.truckwash.io/master/api`. The bare
`https://api-v2.truckwash.io` host is the public gateway, not the application
API base.
- Confirm `https://api-v2.truckwash.io/master/api/ping` responds before
investigating the device or app.
- Capacitor serves bundled iOS content from `capacitor://localhost`. The stable
API must return `Access-Control-Allow-Origin: capacitor://localhost` for that
exact origin, including authenticated preflight requests.
- The signing workflow checks API reachability and this CORS contract before
compiling or signing. If it fails, deploy the backend CORS policy fix before
dispatching another IPA; do not replace the API URL or use an unsupported
HTTP/HTTPS `iosScheme` workaround.
- An already-built IPA starts using a corrected server-side CORS policy without
modification. Build and install a higher version when recording a verified
device-test result for the fix.
### iOS beta or new major iOS version breaks device tools
- Record the device model, exact iOS version, helper error, source SHA, and IPA
checksum.
- Update libimobiledevice only through a trusted package/source and rerun the
doctor. Do not install arbitrary device images or disable signing checks.
- If compatibility remains broken, distribute through TestFlight or install and
debug from a physical Mac with a compatible Xcode version.
## Real-Device Acceptance Checklist
For the first setup, after signing changes, and after major iOS upgrades:
- `doctor` passes while the phone is unlocked.
- `SHA256SUMS` verifies and the manifest identifies the intended immutable SHA.
- **Truck Wash Debug** installs as `io.truckwash.app.debug` while the production
app and its data remain unchanged.
- Authentication, camera/QR permission, and foreground-location behavior work.
- Logs show the expected build/source context and stable API target.
- The debug app still launches and reaches the API after the cable is removed.
- A higher-numbered IPA upgrades the debug app without clearing its local state.
- Crash reports are copied without being deleted from the phone.
- The test record includes artifact checksum, source SHA, device model, iOS
version, outcome, and any residual iOS/libimobiledevice compatibility risk.
## References
- [Apple: enable Developer Mode on a device](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device)
- [Apple: run an app on a physical device](https://developer.apple.com/documentation/Xcode/running-your-app-on-simulated-or-physical-devices)
- [Apple: register a single device](https://developer.apple.com/help/account/devices/register-a-single-device/)
- [Apple: create a development provisioning profile](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile/)
- [libimobiledevice project](https://github.com/libimobiledevice/libimobiledevice)
+41 -3
View File
@@ -1,23 +1,41 @@
# Mobile Store Artifacts
The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor.
The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor, then uploads them to Google Play and App Store Connect by default.
Use the Capacitor project under `android/` for the Google Play Store package. The Bubblewrap/TWA files at the repository root are not the path used by `mobile:android:bundle`.
## Triggers
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name` and `version_code`.
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name`, `version_code`, upload toggles, and Android track/status overrides.
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
- Automatic Android Play Store artifact: after the `Automated Tests` workflow completes successfully on `master`, GitHub Actions builds and uploads a signed Android App Bundle from the tested commit.
- Automatic store upload: after the `Automated Tests` workflow completes successfully on current `master`, GitHub Actions builds signed Android and iOS artifacts from that tested commit and uploads them to the stores.
- Stale workflow-run protection: if a newer commit reaches `master` before the mobile workflow runs, both store-upload jobs skip the stale commit.
Default upload behavior:
- Android uploads package `io.truckwash.twa` to the Google Play `production` track with release status `completed`.
- iOS uploads bundle `io.truckwash.app` to App Store Connect for TestFlight/App Review processing. Public App Store release still depends on App Store Connect review and release settings.
- Manual dispatch can disable either upload path while still producing signed GitHub artifacts.
## Required Secrets
Store secrets are expected in the GitHub environment `mobile-store-production`.
Non-secret environment variables:
- `ANDROID_PACKAGE_NAME=io.truckwash.twa`
- `ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab`
- `PLAY_STORE_TRACK=production`
- `PLAY_STORE_RELEASE_STATUS=completed`
- `PLAY_STORE_USER_FRACTION` only when using `PLAY_STORE_RELEASE_STATUS=inProgress`
Android:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
iOS:
@@ -26,6 +44,11 @@ iOS:
- `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The Google Play secret is a base64-encoded service-account JSON file with Android Publisher API access to the Play Console app. The App Store Connect private key secret is the base64-encoded `.p8` API key file.
## Local Checks
@@ -57,6 +80,21 @@ The signed Android bundle is written to:
android/app/build/outputs/bundle/release/app-release.aab
```
Upload a locally built signed App Bundle to Google Play after exporting the Play service-account secret and release metadata:
```sh
export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64=...
export ANDROID_PACKAGE_NAME=io.truckwash.twa
export ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab
export MOBILE_VERSION_NAME=1.4.0
export MOBILE_VERSION_CODE=10400
export PLAY_STORE_TRACK=production
export PLAY_STORE_RELEASE_STATUS=completed
npm run mobile:android:play-upload
```
Use `PLAY_STORE_RELEASE_STATUS=inProgress` only with `PLAY_STORE_USER_FRACTION` set to a value greater than `0` and less than `1`.
Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`.
The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk
+6 -3
View File
@@ -7,6 +7,9 @@ const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
CanvasImageSource: "readonly",
EventListener: "readonly",
PositionCallback: "readonly",
grecaptcha: "readonly",
};
@@ -101,12 +104,12 @@ export default [
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-mutating-props": ["warn", { shallowOnly: true }],
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
@@ -133,7 +136,7 @@ export default [
},
},
rules: {
"no-empty": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
},
},
];
+434 -3
View File
@@ -1,9 +1,12 @@
<!DOCTYPE html>
<html lang="" class="theme-light" data-theme="light">
<html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">
<head>
<meta charset="UTF-8">
<script>
(function () {
window.__TW_LOADER_STARTED_AT__ = window.performance && typeof window.performance.now === 'function'
? window.performance.now()
: Date.now();
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
@@ -21,9 +24,437 @@
<meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Truck Wash Kundeportal</title>
<style>
html,
body,
#app {
min-height: 100%;
}
html.tw-bootstrap-loading {
font-size: 16px !important;
}
body {
margin: 0;
background: #fff;
}
body.tw-bootstrap-loading {
background: #041f32;
}
html.tw-bootstrap-loading,
html.tw-loader-scroll-lock,
body.tw-bootstrap-loading,
body.tw-loader-scroll-lock {
overflow: hidden;
scrollbar-width: none;
}
html.tw-bootstrap-loading::-webkit-scrollbar,
html.tw-loader-scroll-lock::-webkit-scrollbar,
body.tw-bootstrap-loading::-webkit-scrollbar,
body.tw-loader-scroll-lock::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.tw-bootstrap-loader {
--tw-loader-navy: #063651;
--tw-loader-blue: #0787bb;
--tw-loader-cyan: #69d7f5;
--tw-loader-foam: #f8fbff;
--tw-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--tw-loader-navy) 52%, #04263d 100%);
color: var(--tw-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.tw-bootstrap-loader__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.tw-bootstrap-loader__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.tw-bootstrap-loader__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.tw-bootstrap-loader__wash--wide {
top: 26%;
animation: tw-bootstrap-loader-wash 5.8s ease-in-out var(--tw-loader-wash-wide-delay, 0ms) infinite;
}
.tw-bootstrap-loader__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: tw-bootstrap-loader-wash 6.8s ease-in-out var(--tw-loader-wash-tight-delay, 0ms) infinite reverse;
}
.tw-bootstrap-loader__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
text-align: center;
backdrop-filter: blur(18px);
}
.tw-bootstrap-loader__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.tw-bootstrap-loader__brand,
.tw-bootstrap-loader__motion,
.tw-bootstrap-loader__status,
.tw-bootstrap-loader__hint,
.tw-bootstrap-loader__dots {
position: relative;
z-index: 1;
}
.tw-bootstrap-loader__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.tw-bootstrap-loader__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.tw-bootstrap-loader__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.tw-bootstrap-loader__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--tw-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: tw-bootstrap-loader-spin 1.8s linear var(--tw-loader-spin-delay, 0ms) infinite;
}
.tw-bootstrap-loader__ring::before,
.tw-bootstrap-loader__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.tw-bootstrap-loader__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.tw-bootstrap-loader__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: tw-bootstrap-loader-pulse 2.4s ease-in-out var(--tw-loader-pulse-delay, 0ms) infinite;
}
.tw-bootstrap-loader__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--tw-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: tw-bootstrap-loader-sweep 1.8s ease-in-out var(--tw-loader-sweep-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--tw-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.tw-bootstrap-loader__spark--one {
top: 1rem;
right: 1.3rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-two-delay, 0.65s) infinite;
}
.tw-bootstrap-loader__status {
margin: 0;
color: var(--tw-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.tw-bootstrap-loader__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.tw-bootstrap-loader__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.tw-bootstrap-loader__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--tw-loader-cyan);
animation: tw-bootstrap-loader-dot 1.35s ease-in-out var(--tw-loader-dot-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__dots span:nth-child(2) {
animation-delay: var(--tw-loader-dot-two-delay, 0.18s);
}
.tw-bootstrap-loader__dots span:nth-child(3) {
animation-delay: var(--tw-loader-dot-three-delay, 0.36s);
}
@keyframes tw-bootstrap-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes tw-bootstrap-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes tw-bootstrap-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes tw-bootstrap-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.tw-bootstrap-loader {
padding: 1rem;
}
.tw-bootstrap-loader__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.tw-bootstrap-loader__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.tw-bootstrap-loader__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.tw-bootstrap-loader__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tw-bootstrap-loader *,
.tw-bootstrap-loader *::before,
.tw-bootstrap-loader *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tw-bootstrap-loader__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.tw-bootstrap-loader__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
</head>
<body>
<div id="app"></div>
<body class="tw-bootstrap-loading">
<div id="app">
<div
class="tw-bootstrap-loader"
data-testid="app-bootstrap-loading"
role="status"
aria-live="polite"
aria-label="Indlæser Truck Wash kundeportal"
>
<div class="tw-bootstrap-loader__ambient" aria-hidden="true">
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--wide"></span>
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--tight"></span>
</div>
<div class="tw-bootstrap-loader__panel">
<div class="tw-bootstrap-loader__brand">
<img
src="%BASE_URL%assets/branding/truckwash-banner-white-compressed.png"
alt="Truck Wash"
class="tw-bootstrap-loader__logo"
/>
</div>
<div class="tw-bootstrap-loader__motion" aria-hidden="true">
<span class="tw-bootstrap-loader__ring"></span>
<span class="tw-bootstrap-loader__sweep"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--one"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--two"></span>
</div>
<p class="tw-bootstrap-loader__status" data-testid="app-bootstrap-loading-status">Indlæser...</p>
<p class="tw-bootstrap-loader__hint">Vi gør kundeportalen klar</p>
<div class="tw-bootstrap-loader__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
hasStoredSession = false;
}
var usesProtectedSessionLoader = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/.test(appPath);
var usesGuestSessionLoader = /^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/.test(appPath);
var usesSessionLoader = usesProtectedSessionLoader || (hasStoredSession && usesGuestSessionLoader);
if (!usesSessionLoader) return;
var loader = document.querySelector('[data-testid="app-bootstrap-loading"]');
var status = document.querySelector('[data-testid="app-bootstrap-loading-status"]');
var hint = loader ? loader.querySelector('.tw-bootstrap-loader__hint') : null;
if (loader) loader.setAttribute('aria-label', 'Bekræfter bruger');
if (status) status.textContent = 'Bekræfter bruger...';
if (hint) hint.textContent = 'Vi indlæser din session';
})();
</script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+5 -3
View File
@@ -217,7 +217,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -298,6 +298,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
APP_DISPLAY_NAME = "Truck Wash Debug";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -309,8 +310,8 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug;
PRODUCT_NAME = TruckWashDebug;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -320,6 +321,7 @@
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_DISPLAY_NAME = "Truck Wash";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,14 +1,23 @@
{
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
"images": [
{ "filename": "AppIcon-20@2x.png", "idiom": "iphone", "scale": "2x", "size": "20x20" },
{ "filename": "AppIcon-20@3x.png", "idiom": "iphone", "scale": "3x", "size": "20x20" },
{ "filename": "AppIcon-29@2x.png", "idiom": "iphone", "scale": "2x", "size": "29x29" },
{ "filename": "AppIcon-29@3x.png", "idiom": "iphone", "scale": "3x", "size": "29x29" },
{ "filename": "AppIcon-40@2x.png", "idiom": "iphone", "scale": "2x", "size": "40x40" },
{ "filename": "AppIcon-40@3x.png", "idiom": "iphone", "scale": "3x", "size": "40x40" },
{ "filename": "AppIcon-60@2x.png", "idiom": "iphone", "scale": "2x", "size": "60x60" },
{ "filename": "AppIcon-60@3x.png", "idiom": "iphone", "scale": "3x", "size": "60x60" },
{ "filename": "AppIcon-20@1x.png", "idiom": "ipad", "scale": "1x", "size": "20x20" },
{ "filename": "AppIcon-20-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "20x20" },
{ "filename": "AppIcon-29@1x.png", "idiom": "ipad", "scale": "1x", "size": "29x29" },
{ "filename": "AppIcon-29-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "29x29" },
{ "filename": "AppIcon-40@1x.png", "idiom": "ipad", "scale": "1x", "size": "40x40" },
{ "filename": "AppIcon-40-ipad@2x.png", "idiom": "ipad", "scale": "2x", "size": "40x40" },
{ "filename": "AppIcon-76@1x.png", "idiom": "ipad", "scale": "1x", "size": "76x76" },
{ "filename": "AppIcon-76@2x.png", "idiom": "ipad", "scale": "2x", "size": "76x76" },
{ "filename": "AppIcon-83.5@2x.png", "idiom": "ipad", "scale": "2x", "size": "83.5x83.5" },
{ "filename": "AppIcon-1024.png", "idiom": "ios-marketing", "scale": "1x", "size": "1024x1024" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
"info": { "author": "xcode", "version": 1 }
}
+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Truck Wash</string>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+1410 -2374
View File
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -1,6 +1,7 @@
{
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"web-types": "./web-types.json",
"private": true,
"type": "module",
"scripts": {
@@ -49,6 +50,11 @@
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"release:package": "node scripts/release/package-dist.mjs",
"release:deploy:cpanel": "node scripts/release/deploy-cpanel.mjs",
"release:deploy:cpanel:rollback": "node scripts/release/deploy-cpanel.mjs --rollback",
"release:cpanel-root:audit": "node scripts/release/cpanel-root.mjs audit",
"release:cpanel-root:restore": "node scripts/release/cpanel-root.mjs restore",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
@@ -61,15 +67,18 @@
"mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check",
"mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android",
"mobile:permissions:check": "node scripts/mobile/check-permissions.mjs",
"mobile:store:env-check": "node scripts/mobile/check-store-upload-env.mjs",
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs",
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
},
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@creativebulma/bulma-badge": "^1.0.1",
@@ -89,7 +98,7 @@
"@xterm/addon-fit": "^0.11.0",
"animate.css": "^4.1.1",
"apexcharts": "^5.10.4",
"axios": "1.13.5",
"axios": "1.18.1",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
"bulma-block-list": "^1.1.0",
@@ -120,7 +129,7 @@
"vue3-apexcharts": "^1.11.1",
"vue3-cookies": "^1.0.6",
"vuex": "^4.1.0",
"xlsx": "^0.18.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xterm": "^5.3.0"
},
"devDependencies": {
@@ -147,9 +156,13 @@
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite": "8.1.5",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^7.5.4",
"vitest": "^2.1.9"
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.3.0"
},
"overrides": {
"tar": "7.5.19"
}
}
+57
View File
@@ -0,0 +1,57 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.truckwash.twa",
"sha256_cert_fingerprints": [
"29:56:F7:8D:BD:A0:2E:A9:32:82:97:28:A3:E2:65:16:23:73:DD:2C:16:F6:7A:97:AD:63:27:14:5C:8C:FB:89"
]
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
<script type="module" crossorigin src="/assets/index-6ljr7rTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq5akw1E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#0787bb",
"theme_color": "#063651",
"orientation": "portrait",
"scope": "/"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
+17
View File
@@ -1,3 +1,5 @@
DirectoryIndex index.html
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
@@ -6,6 +8,21 @@
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_headers.c>
# Fingerprinted build assets are content-addressed and safe to retain across
# atomic release switches. Mutable application shells and PWA control files
# below override this policy and must always be revalidated.
<FilesMatch "[._-][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^(?:index\.html|manifest\.json|manifest\.webmanifest|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js)$">
Header set Cache-Control "no-cache, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+44 -40
View File
@@ -1,46 +1,50 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
linter: jetbrains/qodana-js:2026.1
#Specify inspection profile for code analysis
profile:
name: qodana.starter
name: qodana.recommended
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
bootstrap: npm ci --legacy-peer-deps
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
include:
- name: Eslint
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:2025.3
exclude:
# These flows deliberately funnel synchronous validation failures into the same
# catch blocks that normalize asynchronous API/auth failures for the UI.
- name: ExceptionCaughtLocallyJS
paths:
- scripts/mobile/upload-google-play.mjs
- src/components/displays/buttons/ActionSettingsWheelButton.vue
- src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue
- src/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue
- src/components/displays/superuser/tables/rolesTable.vue
- src/components/displays/superuser/tables/usersTable.vue
- src/components/session/token/SessionUser/Objects/DepartmentDailyReportComplaints.vue
- src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue
- src/components/session/token/SessionUser/Objects/Subusers.vue
- src/components/shop/POSDepartmentProcess.vue
- src/components/timebookings/displays/CalendarController.vue
- src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue
- src/composables/useSelfServeLogic.js
- src/features/edgeGateways/EdgeGatewayManager.vue
- src/services/PasskeyAuthService.js
- src/services/releaseChannelAvailability.js
- src/views/backoffice/components/LimitedBackofficeEmployeesManager.vue
- src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue
- src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue
- src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue
- src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue
- src/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue
- src/views/dashboards/superUserDashboard/statistics/displays/overview/StatisticsIncomeCard.vue
- src/views/dashboards/userDashboard/profile/displays/Passkeys/PasskeyManagement.vue
- name: All
paths:
- src/i18n/generated
- node_modules.codex-backup
- output
- .gradle
- playwright/.cache
- android
- ios
- app
+2 -2
View File
@@ -9,8 +9,8 @@ const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
+2 -11
View File
@@ -7,8 +7,8 @@ const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
@@ -138,15 +138,6 @@ const slugify = (token, usedKeys) => {
return key;
};
const isCapitalizedToken = (token) => {
const lower = token.toLocaleLowerCase();
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
};
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
const applyTextModifier = (value, modifier) => {
@@ -0,0 +1,93 @@
import { argv, env, exit } from "node:process";
const failures = [];
const expectedBundleId = "io.truckwash.app.debug";
const expectedApiUrl = "https://api-v2.truckwash.io/master/api";
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const decodeBase64 = (name) => {
const encoded = String(env[name] ?? "").replace(/\s/g, "");
if (!encoded) {
return null;
}
if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
failures.push(`${name} is not valid base64`);
return null;
}
const decoded = Buffer.from(encoded, "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
};
for (const name of [
"APPLE_TEAM_ID",
"IOS_DEBUG_BUNDLE_ID",
"IOS_DEBUG_API_URL",
"IOS_DEBUG_CERTIFICATE_BASE64",
"IOS_DEBUG_CERTIFICATE_PASSWORD",
"IOS_DEBUG_PROVISION_PROFILE_BASE64",
"IOS_DEBUG_ALLOWED_UDIDS",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
]) {
requireVariable(name);
}
decodeBase64("IOS_DEBUG_CERTIFICATE_BASE64");
decodeBase64("IOS_DEBUG_PROVISION_PROFILE_BASE64");
if (env.APPLE_TEAM_ID && !/^[A-Z0-9]{10}$/.test(env.APPLE_TEAM_ID)) {
failures.push("APPLE_TEAM_ID must be a 10-character Apple team identifier");
}
if (env.IOS_DEBUG_BUNDLE_ID && env.IOS_DEBUG_BUNDLE_ID !== expectedBundleId) {
failures.push(`IOS_DEBUG_BUNDLE_ID must be ${expectedBundleId}`);
}
if (env.IOS_DEBUG_API_URL && env.IOS_DEBUG_API_URL !== expectedApiUrl) {
failures.push(`IOS_DEBUG_API_URL must be ${expectedApiUrl}`);
}
if (env.MOBILE_VERSION_NAME && !/^0\.0\.[1-9]\d*$/.test(env.MOBILE_VERSION_NAME)) {
failures.push("MOBILE_VERSION_NAME must use the deterministic 0.0.<run number> format");
}
if (env.MOBILE_VERSION_CODE && !/^[1-9]\d{0,17}$/.test(env.MOBILE_VERSION_CODE)) {
failures.push("MOBILE_VERSION_CODE must be a positive integer of at most 18 digits");
}
if (env.IOS_DEBUG_ALLOWED_UDIDS) {
const lines = env.IOS_DEBUG_ALLOWED_UDIDS.split(/\r?\n/).map((value) => value.trim());
const udids = lines.filter(Boolean);
if (udids.length === 0) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS must contain at least one device UDID");
} else if (udids.some((udid) => !/^[A-Za-z0-9-]{16,64}$/.test(udid))) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS contains an invalid device UDID");
} else if (new Set(udids).size !== udids.length) {
failures.push("IOS_DEBUG_ALLOWED_UDIDS contains duplicate device UDIDs");
}
}
if (argv.length > 2) {
failures.push("This check does not accept command-line arguments");
}
if (failures.length > 0) {
console.error("iOS device-debug signing environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("iOS device-debug signing environment is configured.");
+148
View File
@@ -0,0 +1,148 @@
import { existsSync } from "node:fs";
import { argv, env, exit } from "node:process";
const args = new Set(argv.slice(2));
const failures = [];
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const requireOneVariable = (names, label) => {
if (!names.some((name) => env[name])) {
failures.push(`Missing ${label}: set one of ${names.join(", ")}`);
}
};
const decodeBase64 = (name) => {
if (!env[name]) {
return null;
}
try {
const decoded = Buffer.from(env[name], "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
return null;
}
};
const decodeBase64Json = (name) => {
const decoded = decodeBase64(name);
if (!decoded) {
return null;
}
try {
return JSON.parse(decoded.toString("utf8"));
} catch {
failures.push(`${name} is not base64-encoded JSON`);
return null;
}
};
const isEnabled = (name) => !["false", "0", "no"].includes(String(env[name] ?? "true").toLowerCase());
const checkAndroid = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireOneVariable(["ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_FILE"], "Android release keystore");
requireVariable("ANDROID_KEYSTORE_PASSWORD");
requireVariable("ANDROID_KEY_ALIAS");
requireVariable("ANDROID_KEY_PASSWORD");
if (env.ANDROID_KEYSTORE_FILE && !existsSync(env.ANDROID_KEYSTORE_FILE)) {
failures.push("ANDROID_KEYSTORE_FILE does not point to an existing file");
}
decodeBase64("ANDROID_KEYSTORE_BASE64");
if (!isEnabled("UPLOAD_ANDROID_TO_PLAY")) {
return;
}
requireVariable("ANDROID_PACKAGE_NAME");
requireVariable("ANDROID_AAB_PATH");
requireVariable("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
}
if (!validStatuses.has(status)) {
failures.push(`PLAY_STORE_RELEASE_STATUS must be one of ${Array.from(validStatuses).join(", ")}`);
}
if (status === "inProgress") {
const fraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(fraction > 0 && fraction < 1)) {
failures.push("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress");
}
}
const serviceAccount = decodeBase64Json("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
if (serviceAccount) {
if (!serviceAccount.client_email) {
failures.push("Google Play service account JSON is missing client_email");
}
if (!serviceAccount.private_key) {
failures.push("Google Play service account JSON is missing private_key");
}
}
};
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("APPLE_TEAM_ID");
decodeBase64("IOS_CERTIFICATE_BASE64");
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_ISSUER_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
if (privateKey && !privateKey.toString("utf8").includes("PRIVATE KEY")) {
failures.push("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 does not look like a .p8 private key");
}
};
if (args.has("--android")) {
checkAndroid();
}
if (args.has("--ios")) {
checkIos();
}
if (!args.has("--android") && !args.has("--ios")) {
failures.push("Pass --android or --ios");
}
if (failures.length > 0) {
console.error("Mobile store upload environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("Mobile store upload environment is configured.");
+856
View File
@@ -0,0 +1,856 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { constants as fsConstants, createWriteStream, fchmodSync, openSync } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { finished } from "node:stream/promises";
import { fileURLToPath } from "node:url";
export const DEBUG_BUNDLE_ID = "io.truckwash.app.debug";
export const DEBUG_DISPLAY_NAME = "Truck Wash Debug";
export const DEBUG_EXECUTABLE_NAME = "TruckWashDebug";
export const DEBUG_API_URL = "https://api-v2.truckwash.io/master/api";
const BASE_TOOLS = ["idevice_id", "idevicepair", "ideviceinfo"];
const IPA_TOOLS = ["unzip", "openssl", "python3"];
const DOCTOR_TOOLS = [...BASE_TOOLS, ...IPA_TOOLS, "ideviceinstaller", "idevicesyslog", "idevicecrashreport"];
const PLIST_TO_JSON = String.raw`
import datetime
import json
import plistlib
import sys
def encode(value):
if isinstance(value, (datetime.datetime, datetime.date)):
encoded = value.isoformat()
return (encoded + "Z") if value.tzinfo is None else encoded.replace("+00:00", "Z")
if isinstance(value, bytes):
return {"type": "data", "length": len(value)}
raise TypeError(f"Unsupported plist value: {type(value).__name__}")
with open(sys.argv[1], "rb") as source:
print(json.dumps(plistlib.load(source), default=encode))
`;
export class CliError extends Error {
constructor(message, { exitCode = 1, cause } = {}) {
super(message, { cause });
this.name = "CliError";
this.exitCode = exitCode;
}
}
function usage() {
return `Usage:
node scripts/mobile/ios-device.mjs doctor [--udid ID]
node scripts/mobile/ios-device.mjs install IPA [--manifest FILE] [--udid ID]
node scripts/mobile/ios-device.mjs logs [--output FILE] [--udid ID]
node scripts/mobile/ios-device.mjs crashes DIRECTORY [--udid ID]
node scripts/mobile/ios-device.mjs uninstall --confirm ${DEBUG_BUNDLE_ID} [--udid ID]`;
}
export function parseArgs(argv) {
const args = [...argv];
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
return { help: true };
}
const command = args.shift();
const supported = new Set(["doctor", "install", "logs", "crashes", "uninstall"]);
if (!supported.has(command)) {
throw new CliError(`Unknown command: ${command}\n\n${usage()}`);
}
const options = { command, positionals: [] };
const valueOptions = new Set(["--udid", "--manifest", "--output", "--confirm"]);
while (args.length > 0) {
const arg = args.shift();
if (!arg.startsWith("--")) {
options.positionals.push(arg);
continue;
}
if (!valueOptions.has(arg)) {
throw new CliError(`Unknown option for ${command}: ${arg}`);
}
const key = arg.slice(2);
if (Object.hasOwn(options, key)) {
throw new CliError(`Option may only be supplied once: ${arg}`);
}
const value = args.shift();
if (!value || value.startsWith("--")) {
throw new CliError(`Option requires a value: ${arg}`);
}
options[key] = value;
}
const allowedOptions = {
doctor: new Set(["udid"]),
install: new Set(["udid", "manifest"]),
logs: new Set(["udid", "output"]),
crashes: new Set(["udid"]),
uninstall: new Set(["udid", "confirm"]),
};
for (const key of ["udid", "manifest", "output", "confirm"]) {
if (Object.hasOwn(options, key) && !allowedOptions[command].has(key)) {
throw new CliError(`--${key} is not valid for ${command}`);
}
}
const expectedPositionals = command === "install" || command === "crashes" ? 1 : 0;
if (options.positionals.length !== expectedPositionals) {
const expectation = expectedPositionals === 0 ? "no positional arguments" : "exactly one path";
throw new CliError(`${command} requires ${expectation}.\n\n${usage()}`);
}
if (command === "uninstall" && options.confirm !== DEBUG_BUNDLE_ID) {
throw new CliError(`Refusing to uninstall. Supply --confirm ${DEBUG_BUNDLE_ID} exactly.`);
}
return options;
}
export function redactUdids(value, udids = []) {
let redacted = String(value ?? "");
for (const udid of udids) {
if (udid) redacted = redacted.split(udid).join("<redacted-udid>");
}
return redacted
.replace(/\b[0-9a-f]{40}\b/giu, "<redacted-udid>")
.replace(/\b[0-9a-f]{8}-[0-9a-f]{16}\b/giu, "<redacted-udid>");
}
export function selectUsbDevice(rawOutput, requestedUdid) {
const devices = [
...new Set(
String(rawOutput)
.split(/\r?\n/u)
.map((item) => item.trim())
.filter(Boolean)
),
];
if (devices.length === 0) {
throw new CliError("No cable-connected iPhone was found. Connect and unlock the phone, then retry.");
}
if (requestedUdid) {
if (!devices.includes(requestedUdid)) {
throw new CliError("The requested device is not connected over USB.");
}
return { udid: requestedUdid, allUdids: devices };
}
if (devices.length !== 1) {
throw new CliError(`Found ${devices.length} USB devices. Select one explicitly with --udid ID.`);
}
return { udid: devices[0], allUdids: devices };
}
export function createRedactedLineWriter(output, udids) {
const decoder = new StringDecoder("utf8");
let pending = "";
let ended = false;
const flushCompleteLines = () => {
const newline = Math.max(pending.lastIndexOf("\n"), pending.lastIndexOf("\r"));
if (newline < 0) return;
output.write(redactUdids(pending.slice(0, newline + 1), udids));
pending = pending.slice(newline + 1);
};
return {
write(chunk) {
pending += decoder.write(chunk);
flushCompleteLines();
},
end() {
if (ended) return;
ended = true;
pending += decoder.end();
if (pending) output.write(redactUdids(pending, udids));
},
};
}
export function spawnCommand(command, args, { inherit = false, streamRedactedUdids, redactedStdoutFile } = {}) {
return new Promise((resolvePromise, rejectPromise) => {
const streamRedacted = Array.isArray(streamRedactedUdids);
if (redactedStdoutFile && !streamRedacted) {
rejectPromise(new CliError("redactedStdoutFile requires streamed UDID redaction."));
return;
}
const stdoutFileDescriptor = redactedStdoutFile ? openSync(redactedStdoutFile, "w", 0o600) : null;
if (stdoutFileDescriptor !== null) fchmodSync(stdoutFileDescriptor, 0o600);
const stdoutTarget = redactedStdoutFile
? createWriteStream(redactedStdoutFile, { fd: stdoutFileDescriptor, autoClose: true })
: process.stdout;
const stdoutFinished = redactedStdoutFile
? finished(stdoutTarget).then(
() => null,
(error) => error
)
: null;
const child = spawn(command, args, {
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
});
if (redactedStdoutFile) {
stdoutTarget.once("error", (error) => {
child.kill("SIGTERM");
rejectPromise(error);
});
}
const stdout = [];
const stderr = [];
let finishStreamedStdout = () => {};
if (!inherit) {
if (streamRedacted) {
const stdoutWriter = createRedactedLineWriter(stdoutTarget, streamRedactedUdids);
const stderrWriter = createRedactedLineWriter(process.stderr, streamRedactedUdids);
child.stdout.on("data", (chunk) => stdoutWriter.write(chunk));
child.stderr.on("data", (chunk) => stderrWriter.write(chunk));
finishStreamedStdout = () => {
stdoutWriter.end();
if (redactedStdoutFile && !stdoutTarget.writableEnded) stdoutTarget.end();
};
child.stdout.on("end", finishStreamedStdout);
child.stderr.on("end", () => stderrWriter.end());
} else {
child.stdout.on("data", (chunk) => stdout.push(chunk));
child.stderr.on("data", (chunk) => stderr.push(chunk));
}
}
child.once("error", (error) => {
finishStreamedStdout();
rejectPromise(error);
});
child.once("close", async (code, signal) => {
finishStreamedStdout();
const outputError = stdoutFinished ? await stdoutFinished : null;
if (outputError) {
rejectPromise(outputError);
return;
}
resolvePromise({
code: code ?? (signal ? 1 : 0),
signal,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
});
});
});
}
async function defaultCommandExists(command) {
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
for (const entry of pathEntries) {
try {
await access(join(entry, command), fsConstants.X_OK);
return true;
} catch {
// Continue through PATH.
}
}
return false;
}
function commandFailure(command, args, result, udids) {
const stderr = redactUdids(
Buffer.from(result.stderr ?? "")
.toString("utf8")
.trim(),
udids
);
const detail = stderr ? `: ${stderr}` : "";
return new CliError(`${command} ${args.join(" ")} failed${detail}`);
}
async function runChecked(context, command, args, options = {}) {
let result;
try {
result = await context.run(command, args, options);
} catch (error) {
if (error?.code === "ENOENT") {
throw new CliError(`Required command is not installed: ${command}`, { cause: error });
}
throw error;
}
if (result.code !== 0) {
throw commandFailure(command, args, result, context.knownUdids);
}
return result;
}
async function requireTools(context, tools) {
const missing = [];
for (const tool of tools) {
if (!(await context.commandExists(tool))) missing.push(tool);
}
if (missing.length > 0) {
throw new CliError(`Missing required command${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
}
}
async function discoverDevice(context, requestedUdid) {
const result = await runChecked(context, "idevice_id", ["-l"]);
const selection = selectUsbDevice(result.stdout, requestedUdid);
context.knownUdids = selection.allUdids;
return selection.udid;
}
function lockedOrTrustHint(error) {
const message = String(error?.message ?? error).toLowerCase();
if (message.includes("password protected") || message.includes("passcode") || message.includes("locked")) {
return "The iPhone is locked. Unlock it, keep the screen awake, and retry.";
}
if (message.includes("pair") || message.includes("trust") || message.includes("invalid host")) {
return "Pairing is not valid. Unlock the phone, accept Trust This Computer, then run doctor again.";
}
return null;
}
async function validatePairing(context, udid) {
try {
await runChecked(context, "idevicepair", ["-u", udid, "validate"]);
} catch (error) {
throw new CliError(lockedOrTrustHint(error) ?? `Pairing validation failed: ${error.message}`, {
cause: error,
});
}
}
async function queryDeviceValue(context, udid, key) {
const result = await runChecked(context, "ideviceinfo", ["-u", udid, "-k", key]);
return Buffer.from(result.stdout).toString("utf8").trim();
}
async function assertInstallationProxy(context, udid) {
try {
await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
]);
} catch (error) {
throw new CliError(
lockedOrTrustHint(error) ??
"The installation service is unavailable. Unlock the phone, reconnect the cable, and retry.",
{ cause: error }
);
}
}
function developerModeEnabled(output) {
return /\benabled\b/iu.test(String(output)) && !/\bdisabled\b/iu.test(String(output));
}
async function assertDeveloperMode(context, udid) {
const result = await runChecked(context, "idevicedevmodectl", ["-u", udid, "list"]);
if (!developerModeEnabled(Buffer.from(result.stdout).toString("utf8"))) {
throw new CliError(
"Developer Mode is disabled. Enable Settings > Privacy & Security > Developer Mode, restart the iPhone, and confirm Enable."
);
}
}
async function runDoctor(context, options) {
await requireTools(context, DOCTOR_TOOLS);
const udid = await discoverDevice(context, options.udid);
await validatePairing(context, udid);
const activationState = await queryDeviceValue(context, udid, "ActivationState");
if (activationState !== "Activated") {
throw new CliError(`The iPhone is not activated (state: ${activationState || "unknown"}).`);
}
await assertInstallationProxy(context, udid);
const [model, iosVersion] = await Promise.all([
queryDeviceValue(context, udid, "ProductType"),
queryDeviceValue(context, udid, "ProductVersion"),
]);
if (requiresDeveloperMode(iosVersion)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
context.out(`Ready: ${model || "iPhone"}, iOS ${iosVersion || "unknown"}`);
context.out(
`Pairing: valid; activation: active; installation service: available; Developer Mode: ${
requiresDeveloperMode(iosVersion) ? "enabled" : "not required before iOS 16"
}.`
);
}
function bufferText(value) {
return Buffer.from(value ?? "").toString("utf8");
}
async function parsePlistFile(context, plistPath) {
const result = await runChecked(context, "python3", ["-c", PLIST_TO_JSON, plistPath]);
try {
return JSON.parse(bufferText(result.stdout));
} catch (error) {
throw new CliError(`Could not parse plist ${basename(plistPath)}.`, { cause: error });
}
}
export function selectIpaPayload(entries) {
const normalized = entries.map((entry) => String(entry).trim()).filter(Boolean);
const infoPlists = normalized.filter((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(entry));
if (infoPlists.length !== 1) {
throw new CliError(`IPA must contain exactly one app payload; found ${infoPlists.length}.`);
}
const appRoot = dirname(infoPlists[0]);
const profilePath = `${appRoot}/embedded.mobileprovision`;
if (!normalized.includes(profilePath)) {
throw new CliError("IPA does not contain an embedded provisioning profile.");
}
return { appRoot, infoPlistPath: infoPlists[0], profilePath };
}
async function extractZipEntry(context, ipaPath, entry, destination) {
const result = await runChecked(context, "unzip", ["-p", ipaPath, entry]);
await writeFile(destination, Buffer.from(result.stdout));
}
async function fileExists(path) {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
export async function verifySha256File(targetPath, checksumPath) {
const content = await readFile(checksumPath, "utf8");
const targetName = basename(targetPath);
const matches = content
.split(/\r?\n/u)
.map((line) => line.match(/^([0-9a-f]{64})\s+\*?(.+)$/iu))
.filter(Boolean)
.filter((match) => basename(match[2].trim()) === targetName);
if (matches.length !== 1) {
throw new CliError(`${basename(checksumPath)} must contain exactly one checksum for ${targetName}.`);
}
const hash = createHash("sha256");
hash.update(await readFile(targetPath));
const actual = hash.digest("hex");
if (actual.toLowerCase() !== matches[0][1].toLowerCase()) {
throw new CliError(`Checksum verification failed for ${targetName}.`);
}
}
export function validateManifest(manifest, artifact, ipaPath) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new CliError("manifest.json must contain a JSON object.");
}
if (manifest.schema_version !== 1) {
throw new CliError("manifest.json must use schema_version 1.");
}
if (manifest.signing_method !== "development") {
throw new CliError("manifest.json must identify the signing method as development.");
}
if (manifest.repository !== "copenhagentruckwash/pleno-vue") {
throw new CliError("manifest.json repository must be copenhagentruckwash/pleno-vue.");
}
if (typeof manifest.source_ref !== "string" || !manifest.source_ref.trim()) {
throw new CliError("manifest.json must contain a non-empty source_ref.");
}
if (typeof manifest.source_sha !== "string" || !/^[0-9a-f]{40}$/iu.test(manifest.source_sha)) {
throw new CliError("manifest.json must contain a full 40-character source_sha.");
}
if (manifest.api_url !== DEBUG_API_URL || manifest.release_manager_control_api_url !== DEBUG_API_URL) {
throw new CliError(`manifest.json API URLs must both be ${DEBUG_API_URL}.`);
}
const expectations = [
{
name: "bundle identifier",
value: manifest.bundle_id,
actual: artifact.info.CFBundleIdentifier,
},
{
name: "display name",
value: manifest.display_name,
actual: artifact.info.CFBundleDisplayName ?? artifact.info.CFBundleName,
},
{
name: "executable name",
value: manifest.executable_name,
actual: artifact.info.CFBundleExecutable,
},
{
name: "version",
value: manifest.version,
actual: artifact.info.CFBundleShortVersionString,
},
{
name: "build",
value: manifest.build,
actual: artifact.info.CFBundleVersion,
},
{
name: "IPA filename",
value: manifest.ipa_filename,
actual: basename(ipaPath),
},
{
name: "minimum iOS version",
value: manifest.minimum_ios,
actual: artifact.info.MinimumOSVersion,
},
];
for (const expectation of expectations) {
if (expectation.value === undefined || expectation.value === null || expectation.value === "") {
throw new CliError(`manifest.json is missing ${expectation.name}.`);
}
if (String(expectation.value) !== String(expectation.actual ?? "")) {
throw new CliError(
`Manifest ${expectation.name} does not match the IPA (${expectation.value} != ${
expectation.actual ?? "missing"
}).`
);
}
}
const manifestExpiration = new Date(manifest.profile_expiration_utc).getTime();
const profileExpiration = new Date(artifact.profile.ExpirationDate).getTime();
if (
!Number.isFinite(manifestExpiration) ||
!Number.isFinite(profileExpiration) ||
manifestExpiration !== profileExpiration
) {
throw new CliError("Manifest profile expiration does not match the embedded profile.");
}
}
function profileApplicationIdentifier(profile) {
return profile?.Entitlements?.["application-identifier"];
}
function parseNumericVersion(value, label) {
const normalized = String(value ?? "").trim();
const match = normalized.match(/^(\d+(?:\.\d+)*)(?:[^.\d].*)?$/u);
if (!match) {
throw new CliError(`${label} is malformed: ${normalized || "missing"}.`);
}
const components = match[1].split(".").map((component) => Number(component));
if (components.some((component) => !Number.isSafeInteger(component))) {
throw new CliError(`${label} contains an unsupported numeric component: ${normalized}.`);
}
return components;
}
export function assertMinimumIosCompatible(minimumIos, deviceIos) {
const minimum = parseNumericVersion(minimumIos, "IPA minimum iOS version");
const device = parseNumericVersion(deviceIos, "Connected iPhone iOS version");
const componentCount = Math.max(minimum.length, device.length);
for (let index = 0; index < componentCount; index += 1) {
const minimumComponent = minimum[index] ?? 0;
const deviceComponent = device[index] ?? 0;
if (minimumComponent < deviceComponent) return;
if (minimumComponent > deviceComponent) {
throw new CliError(`IPA requires iOS ${minimumIos}, but the connected iPhone runs iOS ${deviceIos}.`);
}
}
}
export function requiresDeveloperMode(deviceIos) {
return parseNumericVersion(deviceIos, "Connected iPhone iOS version")[0] >= 16;
}
export function validateDevelopmentArtifact(artifact, connectedUdid, now = new Date()) {
const { info, profile } = artifact;
if (info.CFBundleIdentifier !== DEBUG_BUNDLE_ID) {
throw new CliError(
`Refusing IPA with bundle identifier ${info.CFBundleIdentifier ?? "missing"}; expected ${DEBUG_BUNDLE_ID}.`
);
}
const displayName = info.CFBundleDisplayName ?? info.CFBundleName;
if (displayName !== DEBUG_DISPLAY_NAME) {
throw new CliError(`Refusing IPA with display name ${displayName ?? "missing"}; expected ${DEBUG_DISPLAY_NAME}.`);
}
if (!info.CFBundleExecutable || !info.CFBundleShortVersionString || !info.CFBundleVersion || !info.MinimumOSVersion) {
throw new CliError("IPA Info.plist is missing executable, version, build, or minimum iOS metadata.");
}
if (info.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(`Refusing IPA with executable ${info.CFBundleExecutable}; expected ${DEBUG_EXECUTABLE_NAME}.`);
}
const entitlements = profile?.Entitlements ?? {};
if (entitlements["get-task-allow"] !== true) {
throw new CliError("IPA is not development-signed: get-task-allow is not true.");
}
const applicationIdentifier = profileApplicationIdentifier(profile);
if (typeof applicationIdentifier !== "string" || !applicationIdentifier.endsWith(`.${DEBUG_BUNDLE_ID}`)) {
throw new CliError("Provisioning profile application identifier does not match the debug bundle.");
}
const teamIdentifier = entitlements["com.apple.developer.team-identifier"];
if (typeof teamIdentifier !== "string" || applicationIdentifier !== `${teamIdentifier}.${DEBUG_BUNDLE_ID}`) {
throw new CliError("Provisioning profile team identifier is inconsistent with its application identifier.");
}
if (!Array.isArray(profile.TeamIdentifier) || !profile.TeamIdentifier.includes(teamIdentifier)) {
throw new CliError("Provisioning profile does not include its entitlement team identifier.");
}
if (profile.ProvisionsAllDevices === true) {
throw new CliError("Enterprise provisioning profiles are not accepted for cable debug installation.");
}
if (!Array.isArray(profile.ProvisionedDevices) || profile.ProvisionedDevices.length === 0) {
throw new CliError("Provisioning profile contains no registered development devices.");
}
if (!profile.ProvisionedDevices.includes(connectedUdid)) {
throw new CliError("The connected iPhone is not included in the provisioning profile.");
}
const expiresAt = new Date(profile.ExpirationDate);
if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) {
throw new CliError("The provisioning profile is expired or has an invalid expiration date.");
}
return {
bundleId: info.CFBundleIdentifier,
displayName,
executable: info.CFBundleExecutable,
version: String(info.CFBundleShortVersionString),
build: String(info.CFBundleVersion),
minimumIos: String(info.MinimumOSVersion),
expiresAt: expiresAt.toISOString(),
};
}
export async function inspectIpa(context, ipaInput, { manifestPath, connectedUdid }) {
const ipaPath = resolve(ipaInput);
if (extname(ipaPath).toLowerCase() !== ".ipa" || !(await fileExists(ipaPath))) {
throw new CliError(`IPA file not found: ${ipaInput}`);
}
await requireTools(context, IPA_TOOLS);
const candidateManifest = manifestPath ? resolve(manifestPath) : join(dirname(ipaPath), "manifest.json");
if (!(await fileExists(candidateManifest))) {
throw new CliError(
`Manifest file not found: ${manifestPath ?? candidateManifest}. Keep manifest.json with the workflow IPA.`
);
}
const siblingChecksum = join(dirname(ipaPath), "SHA256SUMS");
if (!(await fileExists(siblingChecksum))) {
throw new CliError(`Checksum file not found: ${siblingChecksum}. Keep SHA256SUMS with the workflow IPA.`);
}
await verifySha256File(ipaPath, siblingChecksum);
await verifySha256File(candidateManifest, siblingChecksum);
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-device-"));
try {
const listing = await runChecked(context, "unzip", ["-Z1", ipaPath]);
const payload = selectIpaPayload(bufferText(listing.stdout).split(/\r?\n/u));
const infoPath = join(temporaryDirectory, "Info.plist");
const profileCmsPath = join(temporaryDirectory, "embedded.mobileprovision");
const profilePlistPath = join(temporaryDirectory, "profile.plist");
await extractZipEntry(context, ipaPath, payload.infoPlistPath, infoPath);
await extractZipEntry(context, ipaPath, payload.profilePath, profileCmsPath);
const decodedProfile = await runChecked(context, "openssl", [
"smime",
"-inform",
"der",
"-verify",
"-noverify",
"-in",
profileCmsPath,
]);
await writeFile(profilePlistPath, Buffer.from(decodedProfile.stdout));
const artifact = {
info: await parsePlistFile(context, infoPath),
profile: await parsePlistFile(context, profilePlistPath),
};
artifact.validated = validateDevelopmentArtifact(artifact, connectedUdid, context.now());
let manifest;
try {
manifest = JSON.parse(await readFile(candidateManifest, "utf8"));
} catch (error) {
throw new CliError(`Could not parse ${basename(candidateManifest)} as JSON.`, { cause: error });
}
validateManifest(manifest, artifact, ipaPath);
return { ...artifact, ipaPath };
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function parsePlistBuffer(context, buffer, label) {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-plist-"));
const plistPath = join(temporaryDirectory, "value.plist");
try {
await writeFile(plistPath, Buffer.from(buffer));
return await parsePlistFile(context, plistPath);
} catch (error) {
throw new CliError(`Could not parse ${label}.`, { cause: error });
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
function findInstalledApp(plist) {
if (!plist || typeof plist !== "object") return null;
if (plist[DEBUG_BUNDLE_ID] && typeof plist[DEBUG_BUNDLE_ID] === "object") {
return plist[DEBUG_BUNDLE_ID];
}
const candidates = Array.isArray(plist) ? plist : Object.values(plist);
return candidates.find((item) => item?.CFBundleIdentifier === DEBUG_BUNDLE_ID) ?? null;
}
async function getInstalledDebugApp(context, udid) {
const result = await runChecked(context, "ideviceinstaller", [
"-u",
udid,
"list",
"--user",
"--xml",
"-b",
DEBUG_BUNDLE_ID,
"-a",
"CFBundleIdentifier",
"-a",
"CFBundleExecutable",
"-a",
"CFBundleShortVersionString",
"-a",
"CFBundleVersion",
]);
return findInstalledApp(await parsePlistBuffer(context, result.stdout, "installed-app list"));
}
async function prepareDevice(context, requestedUdid, tools) {
await requireTools(context, [...BASE_TOOLS, ...tools]);
const udid = await discoverDevice(context, requestedUdid);
await validatePairing(context, udid);
return udid;
}
async function runInstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", ...IPA_TOOLS]);
const deviceIos = await queryDeviceValue(context, udid, "ProductVersion");
if (requiresDeveloperMode(deviceIos)) {
await requireTools(context, ["idevicedevmodectl"]);
await assertDeveloperMode(context, udid);
}
await assertInstallationProxy(context, udid);
const artifact = await context.inspectIpa(context, options.positionals[0], {
manifestPath: options.manifest,
connectedUdid: udid,
});
assertMinimumIosCompatible(artifact.validated.minimumIos, deviceIos);
const installed = await context.getInstalledDebugApp(context, udid);
const action = installed ? "upgrade" : "install";
context.out(`${action === "upgrade" ? "Upgrading" : "Installing"} ${DEBUG_DISPLAY_NAME}...`);
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", action, artifact.ipaPath], {
streamRedactedUdids: context.knownUdids,
});
const verified = await context.getInstalledDebugApp(context, udid);
if (!verified) {
throw new CliError("Installation command completed, but the debug app is not present on the iPhone.");
}
const actualVersion = String(verified.CFBundleShortVersionString ?? "");
const actualBuild = String(verified.CFBundleVersion ?? "");
if (actualVersion !== artifact.validated.version || actualBuild !== artifact.validated.build) {
throw new CliError(
`Installed version verification failed (expected ${artifact.validated.version} (${
artifact.validated.build
}), found ${actualVersion || "missing"} (${actualBuild || "missing"})).`
);
}
context.out(`Installed ${DEBUG_DISPLAY_NAME} ${actualVersion} (${actualBuild}).`);
}
async function requireInstalledDebugApp(context, udid) {
const app = await context.getInstalledDebugApp(context, udid);
if (!app) {
throw new CliError(`${DEBUG_DISPLAY_NAME} is not installed.`);
}
if (app.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) {
throw new CliError(
`Installed debug app executable is ${
app.CFBundleExecutable ?? "missing"
}; expected ${DEBUG_EXECUTABLE_NAME}. Reinstall the current device-debug IPA.`
);
}
return app;
}
async function runLogs(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicesyslog", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
const args = ["-u", udid, "--no-colors", "-p", String(app.CFBundleExecutable)];
context.out(`Streaming logs for ${app.CFBundleExecutable}; press Ctrl-C to stop.`);
await runChecked(context, "idevicesyslog", args, {
streamRedactedUdids: context.knownUdids,
...(options.output ? { redactedStdoutFile: resolve(options.output) } : {}),
});
}
async function runCrashes(context, options) {
const destination = resolve(options.positionals[0]);
await mkdir(destination, { recursive: true });
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicecrashreport", "python3"]);
const app = await requireInstalledDebugApp(context, udid);
await runChecked(
context,
"idevicecrashreport",
["-u", udid, "--keep", "--extract", "--filter", String(app.CFBundleExecutable), destination],
{ streamRedactedUdids: context.knownUdids }
);
context.out(`Copied crash reports to ${destination}; reports were kept on the iPhone.`);
}
async function runUninstall(context, options) {
const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "python3"]);
const installed = await context.getInstalledDebugApp(context, udid);
if (!installed) {
context.out(`${DEBUG_DISPLAY_NAME} is not installed; nothing to remove.`);
return;
}
await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", "uninstall", DEBUG_BUNDLE_ID], {
streamRedactedUdids: context.knownUdids,
});
const remaining = await context.getInstalledDebugApp(context, udid);
if (remaining) throw new CliError(`Uninstall completed, but ${DEBUG_DISPLAY_NAME} is still present.`);
context.out(`Removed ${DEBUG_DISPLAY_NAME}. The production app was not touched.`);
}
function createContext(overrides = {}) {
return {
run: overrides.run ?? spawnCommand,
commandExists: overrides.commandExists ?? defaultCommandExists,
inspectIpa: overrides.inspectIpa ?? inspectIpa,
getInstalledDebugApp: overrides.getInstalledDebugApp ?? getInstalledDebugApp,
now: overrides.now ?? (() => new Date()),
out: overrides.out ?? ((message) => process.stdout.write(`${message}\n`)),
err: overrides.err ?? ((message) => process.stderr.write(`${message}\n`)),
knownUdids: [],
};
}
export async function main(argv = process.argv.slice(2), overrides = {}) {
const context = createContext(overrides);
try {
const options = parseArgs(argv);
if (options.help) {
context.out(usage());
return 0;
}
if (options.command === "doctor") await runDoctor(context, options);
if (options.command === "install") await runInstall(context, options);
if (options.command === "logs") await runLogs(context, options);
if (options.command === "crashes") await runCrashes(context, options);
if (options.command === "uninstall") await runUninstall(context, options);
return 0;
} catch (error) {
const message = redactUdids(error?.message ?? error, context.knownUdids);
context.err(`Error: ${message}`);
return error?.exitCode ?? 1;
}
}
const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
process.exitCode = await main();
}
+260
View File
@@ -0,0 +1,260 @@
import { createSign } from "node:crypto";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { env, exit } from "node:process";
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
const requiredVariables = [
"ANDROID_PACKAGE_NAME",
"ANDROID_AAB_PATH",
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
];
const fail = (message) => {
console.error(message);
exit(1);
};
const requireEnvironment = () => {
const missingVariables = requiredVariables.filter((name) => !env[name]);
if (missingVariables.length > 0) {
fail(`Google Play upload is not configured. Missing: ${missingVariables.join(", ")}`);
}
if (!existsSync(env.ANDROID_AAB_PATH)) {
fail(`Android App Bundle not found at ${env.ANDROID_AAB_PATH}`);
}
};
const base64Url = (value) =>
Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const parseServiceAccount = () => {
let serviceAccount;
try {
serviceAccount = JSON.parse(Buffer.from(env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, "base64").toString("utf8"));
} catch {
fail("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 must be base64-encoded service account JSON.");
}
if (!serviceAccount.client_email || !serviceAccount.private_key) {
fail("Google Play service account JSON must include client_email and private_key.");
}
return serviceAccount;
};
const createJwtAssertion = (serviceAccount) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
};
const claims = {
iss: serviceAccount.client_email,
scope: androidPublisherScope,
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
return `${signingInput}.${base64Url(signer.sign(serviceAccount.private_key))}`;
};
const readJsonResponse = async (response, label) => {
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.raw || response.statusText;
throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
}
return body;
};
const requestJson = async (url, options, label) => {
const response = await fetch(url, options);
return readJsonResponse(response, label);
};
const getAccessToken = async (serviceAccount) => {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: createJwtAssertion(serviceAccount),
});
const tokenResponse = await requestJson(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Google OAuth token request",
);
if (!tokenResponse?.access_token) {
throw new Error("Google OAuth token response did not include access_token.");
}
return tokenResponse.access_token;
};
const playUrl = (path) => `https://androidpublisher.googleapis.com/androidpublisher/v3/${path}`;
const playUploadUrl = (path) => `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/${path}`;
const authorizedJson = (accessToken, extraHeaders = {}) => ({
Authorization: `Bearer ${accessToken}`,
...extraHeaders,
});
const insertEdit = async (accessToken, packageName) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit insert",
);
const deleteEdit = async (accessToken, packageName, editId) => {
const response = await fetch(playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}`), {
method: "DELETE",
headers: authorizedJson(accessToken),
});
if (!response.ok && response.status !== 404) {
const body = await response.text();
console.warn(`Could not delete failed Google Play edit ${editId}: HTTP ${response.status} ${body}`);
}
};
const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
requestJson(
`${playUploadUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/bundles`,
)}?uploadType=media`,
{
method: "POST",
headers: authorizedJson(accessToken, {
"Content-Type": "application/octet-stream",
}),
body: readFileSync(bundlePath),
},
"Google Play bundle upload",
);
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
if (!validTracks.has(track)) {
throw new Error(`Unsupported PLAY_STORE_TRACK: ${track}`);
}
if (!validStatuses.has(status)) {
throw new Error(`Unsupported PLAY_STORE_RELEASE_STATUS: ${status}`);
}
const release = {
name: env.PLAY_STORE_RELEASE_NAME || `Truck Wash ${env.MOBILE_VERSION_NAME} (${versionCode})`,
versionCodes: [String(versionCode)],
status,
};
if (status === "inProgress") {
const userFraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(userFraction > 0 && userFraction < 1)) {
throw new Error("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress.");
}
release.userFraction = userFraction;
}
return requestJson(
playUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/tracks/${encodeURIComponent(track)}`,
),
{
method: "PUT",
headers: authorizedJson(accessToken, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
track,
releases: [release],
}),
},
"Google Play track update",
);
};
const commitEdit = async (accessToken, packageName, editId) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit commit",
);
const writeStepSummary = (summary) => {
if (!env.GITHUB_STEP_SUMMARY) {
return;
}
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const main = async () => {
requireEnvironment();
const packageName = env.ANDROID_PACKAGE_NAME;
const serviceAccount = parseServiceAccount();
const accessToken = await getAccessToken(serviceAccount);
let editId = null;
try {
const edit = await insertEdit(accessToken, packageName);
editId = edit.id;
if (!editId) {
throw new Error("Google Play edit insert response did not include id.");
}
const bundle = await uploadBundle(accessToken, packageName, editId, env.ANDROID_AAB_PATH);
const versionCode = String(bundle?.versionCode || env.MOBILE_VERSION_CODE);
await updateTrack(accessToken, packageName, editId, versionCode);
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}.`);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
}
throw error;
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
+39 -1
View File
@@ -108,6 +108,33 @@ export const sourceMappings = [
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{
name: "orders-filters",
patterns: [
/^src\/components\/displays\/pagination\/models\/(?:DepartmentPos\/OrdersPagination|SuperUserDashboard\/InvoiceOrdersPagination)\.vue$/u,
/^src\/components\/displays\/pagination\/(?:PaginationOtherFiltersDropdown|TableLabeledPagination)\.vue$/u,
/^src\/components\/displays\/buttons\/DatePeriodSelector\.vue$/u,
/^src\/services\/orderDateEvents\.js$/u,
/^src\/services\/relativeDateShortcuts\.js$/u,
/^src\/views\/dashboards\/departmentDashboard\/modules\/Pos\/DepartmentPos(?:Orders|Drafts)\.vue$/u,
],
specs: [
"tests/e2e/admin-pos-order-filters.spec.ts",
"tests/e2e/superuser-orders-date-filters.spec.ts",
],
projects: chromiumProjects,
},
{
name: "superuser-customer-rules",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/CustomerRuleProductRestrictions\.vue$/u,
/^src\/features\/customer\/customerRuleProductRestrictionService\.js$/u,
/^src\/features\/customer\/customerRuleConfigurationPermissions\.js$/u,
/^src\/views\/dashboards\/superUserDashboard\/user\/UserCustomerRuleManager\.vue$/u,
],
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{
name: "pos",
patterns: [
@@ -175,10 +202,21 @@ export const sourceMappings = [
},
{
name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
patterns: [/system[-/]?status/iu, /system(?:Database|Redis|Minio)/iu, /SystemDependencyDisplay/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-security",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/system\/SystemSecurity\.vue$/u,
/^src\/services\/superuserSecurity\.js$/u,
/superuser[-/]?security/iu,
/system\/security/iu,
],
specs: ["tests/e2e/superuser-security.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
import crypto from "node:crypto";
import { DeploymentError, deriveCpanelRoot, normalizeRemoteRoot } from "./cpanel-deploy-lib.mjs";
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function succeeded(value) {
return value === 1 || value === "1" || value === true;
}
function required(env, name) {
const value = env[name];
const hasControlCharacter =
typeof value === "string" &&
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
});
if (typeof value !== "string" || !value || hasControlCharacter) {
throw new DeploymentError(`Missing or invalid ${name}.`);
}
return value;
}
function httpsUrl(value, name) {
let url;
try {
url = new URL(value);
} catch {
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
}
if (url.protocol !== "https:" || url.username || url.password) {
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
}
return url.href.endsWith("/") ? url.href : `${url.href}/`;
}
export function readRootConfig(env = process.env) {
const user = required(env, "PRODUCTION_CPANEL_USER");
if (!SAFE_COMPONENT.test(user)) {
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
}
const webroot = normalizeRemoteRoot(env.PRODUCTION_CPANEL_WEBROOT || "public_html", "PRODUCTION_CPANEL_WEBROOT");
if (webroot.includes("/")) {
throw new DeploymentError("PRODUCTION_CPANEL_WEBROOT must be one account-home entry.");
}
return {
user,
token: required(env, "PRODUCTION_CPANEL_API_TOKEN"),
apiUrl: httpsUrl(required(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
deploymentRoot: deriveCpanelRoot(required(env, "PRODUCTION_CPANEL_PATH"), user),
webroot,
frontendUrl: httpsUrl(env.PRODUCTION_FRONTEND_URL || "https://truckwash.io", "PRODUCTION_FRONTEND_URL"),
};
}
function entryName(entry) {
return String(entry?.file || entry?.name || entry?.basename || "");
}
function normalizedType(entry) {
const value = String(entry?.type || entry?.filetype || "").toLowerCase();
if (value.includes("link") || entry?.islink) return "link";
if (value.includes("dir") || entry?.isdir) return "dir";
if (value.includes("file") || entry?.isfile) return "file";
return value || "unknown";
}
function normalizeAccountPath(config, value) {
const normalized = String(value || "")
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "");
const homePrefix = `home/${config.user}/`;
return normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : normalized;
}
function safeAccountPath(value, label = "cPanel path") {
const normalized = String(value || "").replace(/^\/+/, "");
if (!normalized || /[\\,]/.test(normalized) || normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))) {
throw new DeploymentError(`${label} is unsafe.`);
}
return normalized;
}
function uapiResult(payload) {
return payload?.result && typeof payload.result === "object" ? payload.result : payload;
}
function responseShape(payload) {
const result = uapiResult(payload);
const rootKeys = payload && typeof payload === "object" ? Object.keys(payload).sort().join(",") : typeof payload;
const resultKeys = result && typeof result === "object" ? Object.keys(result).sort().join(",") : typeof result;
const dataType = Array.isArray(result?.data) ? "array" : typeof result?.data;
return `response shape root=[${rootKeys}] result=[${resultKeys}] statusType=${typeof result?.status} dataType=${dataType}`;
}
function responseError(payload) {
return (
payload?.cpanelresult?.error ||
payload?.cpanelresult?.event?.reason ||
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.reason ||
payload?.cpanelresult?.data?.find?.((item) => item?.reason || item?.error)?.error ||
payload?.result?.errors?.[0] ||
payload?.result?.messages?.[0] ||
payload?.errors?.[0] ||
payload?.messages?.[0] ||
payload?.error ||
payload?.message ||
"unknown cPanel error"
);
}
export class CpanelAccountClient {
constructor(config, options = {}) {
this.config = config;
this.fetch = options.fetchImpl || globalThis.fetch;
this.timeoutMs = options.timeoutMs || 30_000;
this.allowedMutable = new Set(options.allowedMutable || []);
}
redact(value) {
let result = String(value || "");
for (const secret of [this.config.user, this.config.token, this.config.apiUrl].filter(Boolean)) {
result = result.replaceAll(secret, "[redacted]");
}
return result;
}
async request(url) {
let response;
try {
response = await this.fetch(url, {
headers: {
Accept: "application/json",
Authorization: `cpanel ${this.config.user}:${this.config.token}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
throw new DeploymentError("cPanel request failed.", { cause: error });
}
if (!response.ok) throw new DeploymentError(`cPanel returned HTTP ${response.status}.`);
try {
return await response.json();
} catch (error) {
throw new DeploymentError("cPanel returned invalid JSON.", { cause: error });
}
}
async api2(functionName, parameters) {
const url = new URL("json-api/cpanel", this.config.apiUrl);
url.searchParams.set("cpanel_jsonapi_user", this.config.user);
url.searchParams.set("cpanel_jsonapi_apiversion", "2");
url.searchParams.set("cpanel_jsonapi_module", "Fileman");
url.searchParams.set("cpanel_jsonapi_func", functionName);
for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, String(value));
const payload = await this.request(url);
const result = payload?.cpanelresult;
const failed = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
if (!succeeded(result?.event?.result) || failed) {
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseError(payload))}`);
}
return [...(result?.data || []), ...(result?.files || []), ...(result?.dirs || [])];
}
async list(directory) {
const dir = directory === "." ? "." : safeAccountPath(directory);
const url = new URL("execute/Fileman/list_files", this.config.apiUrl);
url.searchParams.set("dir", dir);
url.searchParams.set("include_mime", "0");
url.searchParams.set("include_permissions", "1");
url.searchParams.set("limit_to_list", "0");
url.searchParams.set("show_hidden", "1");
url.searchParams.set("types", "dir|file|link");
const payload = await this.request(url);
const result = uapiResult(payload);
if (!succeeded(result?.status)) {
const error = responseError(payload);
const detail = error === "unknown cPanel error" ? `${error}; ${responseShape(payload)}` : error;
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(detail)}`);
}
if (!Array.isArray(result.data)) {
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
}
return result.data;
}
async domains() {
const url = new URL("execute/DomainInfo/domains_data", this.config.apiUrl);
url.searchParams.set("format", "list");
const payload = await this.request(url);
const result = uapiResult(payload);
if (!succeeded(result?.status)) {
throw new DeploymentError(`cPanel DomainInfo failed: ${responseError(payload)}`);
}
if (!Array.isArray(result.data)) {
throw new DeploymentError("cPanel DomainInfo returned an unexpected data shape.");
}
return result.data;
}
assertMutable(remotePath) {
const safe = safeAccountPath(remotePath);
if (!this.allowedMutable.has(safe)) {
throw new DeploymentError(`cPanel mutation outside the explicit restore allowlist: ${safe}.`);
}
return safe;
}
async rename(source, destination) {
return await this.api2("fileop", {
op: "rename",
sourcefiles: this.assertMutable(source),
destfiles: this.assertMutable(destination),
doubledecode: 0,
});
}
}
function find(entries, name) {
return entries.find((entry) => entryName(entry) === name);
}
function publicEntry(entry) {
if (!entry) return null;
return {
name: entryName(entry),
type: normalizedType(entry),
mode: String(entry.mode || entry.permissions || ""),
modified: String(entry.mtime || entry.modified || ""),
size: String(entry.size ?? entry.filesize ?? ""),
};
}
function domainRoot(config, domain) {
return normalizeAccountPath(config, domain?.documentroot || domain?.document_root || domain?.docroot || "");
}
function recoveryPattern(webroot) {
return new RegExp(`^${webroot}[-._](?:recovery|backup|before[-._]atomic)[-._][A-Za-z0-9._-]+$`, "i");
}
function inspectedDomain(config, domain, index) {
const name = String(domain?.domain || domain?.servername || "").trim();
const documentRoot = domainRoot(config, domain);
if (!name || !documentRoot) {
throw new DeploymentError(`cPanel DomainInfo returned incomplete domain data at index ${index}.`);
}
return {
domain: name,
type: String(domain?.domain_type || domain?.type || ""),
documentRoot,
};
}
export async function auditRoot(config, options = {}) {
const client = options.client || new CpanelAccountClient(config, options);
const [homeEntries, deploymentEntries, domains] = await Promise.all([
client.list("."),
client.list(config.deploymentRoot),
client.domains(),
]);
const currentEntry = find(deploymentEntries, "current");
const currentPath = `${config.deploymentRoot}/current`;
const requiredReleaseFiles = ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"];
const current = {
path: currentPath,
type: normalizedType(currentEntry),
accessible: false,
missingFiles: requiredReleaseFiles,
error: "",
};
if (currentEntry && normalizedType(currentEntry) === "link") {
try {
const currentEntries = await client.list(currentPath);
current.accessible = true;
current.missingFiles = requiredReleaseFiles.filter((name) => !find(currentEntries, name));
} catch (error) {
current.error = error instanceof Error ? error.message : "Could not follow the current link.";
}
} else {
current.error = currentEntry
? "The deployment current entry is not a symbolic link."
: "The deployment current entry is missing.";
}
const root = publicEntry(find(homeEntries, config.webroot));
const rootAccess = { accessible: false, error: "" };
let rootEntries = [];
if (root) {
try {
rootEntries = await client.list(config.webroot);
rootAccess.accessible = true;
} catch (error) {
rootAccess.error = error instanceof Error ? error.message : "Could not inspect the primary webroot.";
}
} else {
rootAccess.error = "The primary webroot entry is missing.";
}
const recoveryCandidates = homeEntries
.map((entry) => publicEntry(entry))
.filter((entry) => entry && recoveryPattern(config.webroot).test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name));
const domainRoots = domains
.map((domain, index) => inspectedDomain(config, domain, index))
.sort((left, right) =>
[left.domain, left.documentRoot, left.type]
.join("\0")
.localeCompare([right.domain, right.documentRoot, right.type].join("\0"))
);
const nestedDomainRoots = domainRoots.filter(({ documentRoot }) => documentRoot.startsWith(`${config.webroot}/`));
const state = {
webroot: root,
webrootAccess: rootAccess,
webrootEntries: rootEntries.map((entry) => publicEntry(entry)).filter(Boolean),
current,
recoveryCandidates,
domainRoots,
nestedDomainRoots,
};
const stateToken = crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex");
return {
...state,
stateToken,
rootTargetVerified: false,
healthy: false,
};
}
export async function verifyFrontend(config, options = {}) {
const fetchImpl = options.fetchImpl || globalThis.fetch;
const checks = [
["", "html"],
["index.html", "html"],
["release-manifest.json", "json"],
["guest/book/wash", "html"],
];
for (const [pathname, expected] of checks) {
const requestedUrl = new URL(pathname, config.frontendUrl);
const response = await fetchImpl(requestedUrl, {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
redirect: "follow",
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
});
if (!response.ok) throw new DeploymentError(`Live ${pathname || "/"} returned HTTP ${response.status}.`);
if (response.url && new URL(response.url).origin !== requestedUrl.origin) {
throw new DeploymentError(`Live ${pathname || "/"} redirected outside the production frontend origin.`);
}
const body = await response.text();
if (body.includes("Index of /")) throw new DeploymentError("The production root still exposes a directory index.");
if (expected === "json") {
let manifest;
try {
manifest = JSON.parse(body);
} catch {
throw new DeploymentError(`Live ${pathname} did not return JSON.`);
}
if (!/^[a-f0-9]{40}$/i.test(String(manifest?.commit_sha || "")) || !String(manifest?.build_id || "")) {
throw new DeploymentError(`Live ${pathname} did not identify a packaged frontend release.`);
}
} else if (!/<div\s+id=["']app["']/i.test(body) || !/<title>[^<]*truck\s*wash/i.test(body)) {
throw new DeploymentError(`Live ${pathname || "/"} did not return the frontend HTML shell.`);
}
}
}
async function homeNames(client) {
return new Set((await client.list(".")).map((entry) => entryName(entry)).filter(Boolean));
}
async function renameWithReconciliation(client, source, destination) {
try {
await client.rename(source, destination);
return;
} catch (error) {
let names;
try {
names = await homeNames(client);
} catch (inspectionError) {
throw new DeploymentError(`Could not reconcile the cPanel rename from ${source} to ${destination}.`, {
cause: new AggregateError([error, inspectionError]),
});
}
const sourceExists = names.has(source);
const destinationExists = names.has(destination);
if (!sourceExists && destinationExists) return;
if (sourceExists && !destinationExists) {
throw new DeploymentError(`cPanel did not rename ${source} to ${destination}; the source remains in place.`, {
cause: error,
});
}
throw new DeploymentError(`The cPanel rename from ${source} to ${destination} left an ambiguous account state.`, {
cause: error,
});
}
}
async function reinstatePreRestoreState(client, webroot, recovery, failed) {
const errors = [];
let names;
try {
names = await homeNames(client);
} catch (error) {
return [error];
}
const restored = () => names.has(webroot) && names.has(recovery) && !names.has(failed);
if (restored()) return errors;
if (names.has(webroot) && !names.has(recovery) && names.has(failed)) {
try {
await renameWithReconciliation(client, webroot, recovery);
names = await homeNames(client);
} catch (error) {
errors.push(error);
return errors;
}
}
if (!names.has(webroot) && names.has(recovery) && names.has(failed)) {
try {
await renameWithReconciliation(client, failed, webroot);
names = await homeNames(client);
} catch (error) {
errors.push(error);
return errors;
}
}
if (!restored()) {
errors.push(
new DeploymentError(
`Automatic rollback could not prove the required entries: ${webroot}, ${recovery}, and no ${failed}.`
)
);
}
return errors;
}
export async function restoreRoot(config, recovery, expectedStateToken, confirmation, options = {}) {
const safeRecovery = safeAccountPath(recovery, "recovery path");
if (!recoveryPattern(config.webroot).test(safeRecovery)) {
throw new DeploymentError(
"Recovery path must name a retained public_html recovery, backup, or before-atomic entry."
);
}
if (!/^[a-f0-9]{64}$/.test(expectedStateToken)) {
throw new DeploymentError("Restore requires the exact state token emitted by the audit.");
}
const expectedConfirmation = `RESTORE ${safeRecovery} TO ${config.webroot} STATE ${expectedStateToken}`;
if (confirmation !== expectedConfirmation) {
throw new DeploymentError(`Confirmation must exactly match: ${expectedConfirmation}`);
}
const runId = String(
options.runId || [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join("-") || Date.now()
);
if (!SAFE_COMPONENT.test(runId)) throw new DeploymentError("Restore run ID is unsafe.");
const failed = `${config.webroot}.failed-${runId}`;
const allowedMutable = [config.webroot, safeRecovery, failed];
const client = options.client || new CpanelAccountClient(config, { ...options, allowedMutable });
if (client.allowedMutable instanceof Set) {
for (const item of allowedMutable) client.allowedMutable.add(item);
}
const before = await auditRoot(config, { ...options, client });
if (before.stateToken !== expectedStateToken) {
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
}
if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) {
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
}
if (!before.webroot)
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
if (!new Set(["dir", "link"]).has(before.webroot.type)) {
throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore.");
}
if (!before.webrootAccess.accessible && before.webroot.type !== "link") {
throw new DeploymentError(
"The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery."
);
}
if (before.nestedDomainRoots.length > 0) {
throw new DeploymentError(
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots
.map(({ domain, documentRoot }) => `${domain}=${documentRoot}`)
.join(", ")}.`
);
}
if (find(await client.list("."), failed)) {
throw new DeploymentError(`The displaced-state path ${failed} already exists; refusing to overwrite it.`);
}
try {
await renameWithReconciliation(client, config.webroot, failed);
await renameWithReconciliation(client, safeRecovery, config.webroot);
await (options.verify || verifyFrontend)(config, options);
} catch (error) {
const rollbackErrors = await reinstatePreRestoreState(client, config.webroot, safeRecovery, failed);
if (rollbackErrors.length > 0) {
throw new DeploymentError(
"The retained webroot failed and automatic rollback was incomplete; both retained cPanel entries were preserved for manual recovery.",
{ cause: new AggregateError([error, ...rollbackErrors]) }
);
}
throw new DeploymentError("The retained webroot failed live verification; the pre-restore state was reinstated.", {
cause: error,
});
}
return { restored: safeRecovery, displaced: failed };
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { DeploymentError } from "./cpanel-deploy-lib.mjs";
import { auditRoot, readRootConfig, restoreRoot } from "./cpanel-root-lib.mjs";
async function writeReport(report) {
const reportPath = process.env.CPANEL_ROOT_REPORT_PATH;
if (!reportPath) return;
await fs.mkdir(path.dirname(reportPath), { recursive: true });
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
}
async function main() {
const mode = process.argv[2] || "audit";
if (!new Set(["audit", "restore"]).has(mode) || process.argv.length > 3) {
throw new DeploymentError("Usage: cpanel-root.mjs [audit|restore]");
}
const config = readRootConfig(process.env);
if (mode === "audit") {
const report = await auditRoot(config);
await writeReport(report);
console.log(JSON.stringify(report, null, 2));
if (process.env.GITHUB_OUTPUT) {
await fs.appendFile(
process.env.GITHUB_OUTPUT,
`healthy=${report.healthy}\nstate_token=${report.stateToken}\nrecovery_count=${report.recoveryCandidates.length}\n`
);
}
return;
}
const result = await restoreRoot(
config,
process.env.CPANEL_ROOT_RECOVERY || "",
process.env.CPANEL_ROOT_STATE_TOKEN || "",
process.env.CPANEL_ROOT_CONFIRMATION || ""
);
await writeReport(result);
console.log(`Restored ${result.restored}; retained displaced state as ${result.displaced}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : "Unknown cPanel root failure.");
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
import { DeploymentError, deployRelease, readDeploymentConfig, rollbackRelease } from "./cpanel-deploy-lib.mjs";
async function main() {
const mode = process.argv[2] || "deploy";
if (!new Set(["deploy", "--rollback"]).has(mode) || process.argv.length > 3) {
throw new DeploymentError("Usage: deploy-cpanel.mjs [--rollback]");
}
if (mode === "--rollback") {
const target = process.env.RELEASE_ROLLBACK_TARGET;
if (!target) {
throw new DeploymentError("RELEASE_ROLLBACK_TARGET is required for --rollback.");
}
const config = readDeploymentConfig(process.env, { rollbackOnly: true });
const result = await rollbackRelease(config, target);
console.log(`Frontend rollback completed: active release ${result.activeTarget.split("/")[1]}.`);
return;
}
const config = readDeploymentConfig(process.env);
const result = await deployRelease(config);
console.log(
`Frontend deployment completed: active release ${config.releaseId}; retained rollback release ${
result.previousTarget.split("/")[1]
}.`
);
if (result.removed.length > 0) {
console.log(`Pruned ${result.removed.length} inactive release(s).`);
}
if (result.retentionWarning) {
console.warn(result.retentionWarning);
}
}
main().catch((error) => {
const message = error instanceof Error ? error.message : "Unknown deployment failure.";
console.error(message);
process.exit(1);
});
+400
View File
@@ -0,0 +1,400 @@
import crypto from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
function comparePaths(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
function sha256(contents) {
return crypto.createHash("sha256").update(contents).digest("hex");
}
function assertSafeRelativePath(relativePath, label = "release path") {
if (typeof relativePath !== "string" || relativePath.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (relativePath.includes("\\") || /[\0\r\n]/.test(relativePath)) {
throw new Error(`${label} contains unsafe characters: ${JSON.stringify(relativePath)}`);
}
if (path.posix.isAbsolute(relativePath)) {
throw new Error(`${label} must be relative: ${relativePath}`);
}
const segments = relativePath.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error(`${label} contains an unsafe path segment: ${relativePath}`);
}
}
function assertSafeFileName(relativePath) {
assertSafeRelativePath(relativePath, "dist file path");
if (relativePath !== ".htaccess" && SERVER_EXECUTABLE_EXTENSION_PATTERN.test(path.posix.basename(relativePath))) {
throw new Error(`dist contains a server-executable file: ${relativePath}`);
}
}
function manifestFilePath(distDirectory, assetUrl, label) {
if (typeof assetUrl !== "string" || assetUrl.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl) || assetUrl.includes("?") || assetUrl.includes("#")) {
throw new Error(`${label} must reference a local release file: ${assetUrl}`);
}
let decodedPath;
try {
decodedPath = decodeURIComponent(assetUrl.replace(/^\/+/, ""));
} catch {
throw new Error(`${label} is not valid URL-encoded text: ${assetUrl}`);
}
assertSafeFileName(decodedPath);
const absolutePath = path.resolve(distDirectory, ...decodedPath.split("/"));
const root = path.resolve(distDirectory);
if (!absolutePath.startsWith(`${root}${path.sep}`)) {
throw new Error(`${label} escapes dist: ${assetUrl}`);
}
return { absolutePath, relativePath: decodedPath };
}
async function readJson(filePath, label) {
let contents;
try {
contents = await fsp.readFile(filePath, "utf8");
} catch (error) {
throw new Error(`${label} could not be read: ${error instanceof Error ? error.message : error}`, { cause: error });
}
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`${label} does not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
function assertStringArray(value, label) {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
throw new Error(`${label} must be an array of non-empty strings.`);
}
return value;
}
export async function collectDistInventory(distDirectory) {
const root = path.resolve(distDirectory);
const rootStat = await fsp.lstat(root).catch(() => null);
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error(`dist directory does not exist or is not a real directory: ${root}`);
}
const files = [];
const walk = async (relativeDirectory = "") => {
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
for (const name of names) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
assertSafeRelativePath(relativePath, "dist path");
const absolutePath = path.join(root, ...relativePath.split("/"));
const stat = await fsp.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error(`dist contains a symbolic link: ${relativePath}`);
}
if (stat.isDirectory()) {
await walk(relativePath);
continue;
}
if (!stat.isFile()) {
throw new Error(`dist contains an unsupported filesystem entry: ${relativePath}`);
}
assertSafeFileName(relativePath);
const contents = await fsp.readFile(absolutePath);
files.push({
path: `dist/${relativePath}`,
bytes: contents.length,
sha256: sha256(contents),
});
}
};
await walk();
if (files.length === 0) {
throw new Error("dist does not contain any files.");
}
files.sort((left, right) => comparePaths(left.path, right.path));
return {
schema_version: 1,
files,
};
}
export async function validateReleaseMetadata(distDirectory, { expectedCommitSha, expectedBuildId }) {
if (typeof expectedCommitSha !== "string" || !COMMIT_SHA_PATTERN.test(expectedCommitSha)) {
throw new Error("RELEASE_COMMIT_SHA must be the full 40-character hexadecimal commit SHA.");
}
if (typeof expectedBuildId !== "string" || expectedBuildId.length === 0) {
throw new Error("RELEASE_BUILD_ID is required.");
}
const root = path.resolve(distDirectory);
const manifest = await readJson(path.join(root, "release-manifest.json"), "release-manifest.json");
const releaseEntry = await readJson(path.join(root, "release-entry.json"), "release-entry.json");
if (manifest.schema_version !== 1) {
throw new Error(`release-manifest.json schema_version must be 1, got ${JSON.stringify(manifest.schema_version)}.`);
}
if (String(manifest.commit_sha || "").toLowerCase() !== expectedCommitSha.toLowerCase()) {
throw new Error(
`release-manifest.json commit_sha ${
manifest.commit_sha || "(missing)"
} does not exactly match ${expectedCommitSha}.`
);
}
if (manifest.build_id !== expectedBuildId) {
throw new Error(
`release-manifest.json build_id ${manifest.build_id || "(missing)"} does not exactly match ${expectedBuildId}.`
);
}
if (typeof releaseEntry.entry !== "string" || releaseEntry.entry.length === 0) {
throw new Error("release-entry.json entry must be a non-empty string.");
}
const entryCss = assertStringArray(releaseEntry.css || [], "release-entry.json css");
const manifestCss = assertStringArray(manifest.css || [], "release-manifest.json css");
if (releaseEntry.entry !== manifest.entry || JSON.stringify(entryCss) !== JSON.stringify(manifestCss)) {
throw new Error("release-entry.json entry/css does not match release-manifest.json.");
}
const assetUrls = assertStringArray(manifest.asset_urls, "release-manifest.json asset_urls");
if (!manifest.asset_hashes || typeof manifest.asset_hashes !== "object" || Array.isArray(manifest.asset_hashes)) {
throw new Error("release-manifest.json asset_hashes must be an object.");
}
const requiredAssets = ["index.html", "release-entry.json", releaseEntry.entry, ...entryCss];
for (const requiredAsset of requiredAssets) {
if (!assetUrls.includes(requiredAsset)) {
throw new Error(`release-manifest.json asset_urls is missing required asset ${requiredAsset}.`);
}
}
for (const assetUrl of assetUrls) {
if (!Object.hasOwn(manifest.asset_hashes, assetUrl)) {
throw new Error(`release-manifest.json asset_hashes is missing ${assetUrl}.`);
}
}
const hashEntries = Object.entries(manifest.asset_hashes).sort(([left], [right]) => comparePaths(left, right));
if (hashEntries.length === 0) {
throw new Error("release-manifest.json asset_hashes must not be empty.");
}
for (const [assetUrl, expected] of hashEntries) {
const { absolutePath } = manifestFilePath(root, assetUrl, `release asset ${assetUrl}`);
const stat = await fsp.lstat(absolutePath).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) {
throw new Error(`release asset is missing or is not a regular file: ${assetUrl}`);
}
if (
!expected ||
typeof expected !== "object" ||
typeof expected.sha256 !== "string" ||
!SHA256_PATTERN.test(expected.sha256) ||
!Number.isSafeInteger(expected.bytes) ||
expected.bytes < 0
) {
throw new Error(`release-manifest.json contains invalid hash metadata for ${assetUrl}.`);
}
const contents = await fsp.readFile(absolutePath);
const actualHash = sha256(contents);
if (contents.length !== expected.bytes || actualHash !== expected.sha256.toLowerCase()) {
throw new Error(
`release asset integrity mismatch for ${assetUrl}: expected ${expected.bytes} bytes/${expected.sha256}, got ${contents.length} bytes/${actualHash}.`
);
}
}
return { manifest, releaseEntry };
}
function assertMatchingInventories(expected, actual, label) {
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${label} does not match the validated dist inventory.`);
}
}
async function validateArchiveEntries(archivePath) {
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
const entries = stdout.split(/\r?\n/).filter(Boolean);
if (entries.length === 0) {
throw new Error("release archive is empty.");
}
for (const archiveEntry of entries) {
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
if (normalizedEntry === "dist") {
continue;
}
if (!normalizedEntry.startsWith("dist/")) {
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
}
assertSafeRelativePath(normalizedEntry, "archive entry");
}
}
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
if (!githubOutput) {
return;
}
const lines = Object.entries(values).map(([key, value]) => {
const normalizedValue = String(value);
if (/\r|\n/.test(normalizedValue)) {
throw new Error(`GitHub output ${key} contains a newline.`);
}
return `${key}=${normalizedValue}`;
});
fs.appendFileSync(githubOutput, `${lines.join("\n")}\n`);
}
export async function createReleaseArchive({
distDirectory = "dist",
outputDirectory = "release-artifacts",
expectedCommitSha,
expectedBuildId,
runId,
runAttempt,
githubOutput,
} = {}) {
if (!/^\d+$/.test(String(runId || "")) || !/^\d+$/.test(String(runAttempt || ""))) {
throw new Error("GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be numeric.");
}
const normalizedCommitSha = String(expectedCommitSha || "").toLowerCase();
const expectedRunBuildId = `${runId}-${runAttempt}`;
if (expectedBuildId !== expectedRunBuildId) {
throw new Error(`RELEASE_BUILD_ID must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT (${expectedRunBuildId}).`);
}
const resolvedDistDirectory = path.resolve(distDirectory);
const resolvedOutputDirectory = path.resolve(outputDirectory);
if (
resolvedOutputDirectory === resolvedDistDirectory ||
resolvedOutputDirectory.startsWith(`${resolvedDistDirectory}${path.sep}`)
) {
throw new Error("RELEASE_OUTPUT_DIR must not be inside dist.");
}
const inventory = await collectDistInventory(resolvedDistDirectory);
for (const requiredPath of [
"dist/.htaccess",
"dist/index.html",
"dist/release-entry.json",
"dist/release-manifest.json",
]) {
if (!inventory.files.some((file) => file.path === requiredPath)) {
throw new Error(`dist is missing required release file: ${requiredPath.slice("dist/".length)}`);
}
}
await validateReleaseMetadata(resolvedDistDirectory, { expectedCommitSha: normalizedCommitSha, expectedBuildId });
const releaseId = `${normalizedCommitSha}-${runId}-${runAttempt}`;
const archiveName = `pleno-vue-${releaseId}.zip`;
const archivePath = path.join(resolvedOutputDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const inventoryPath = path.join(resolvedOutputDirectory, `pleno-vue-${releaseId}.inventory.json`);
await fsp.mkdir(resolvedOutputDirectory, { recursive: true });
for (const outputPath of [archivePath, checksumPath, inventoryPath]) {
if (await fsp.lstat(outputPath).catch(() => null)) {
throw new Error(`refusing to overwrite existing release artifact: ${outputPath}`);
}
}
const temporaryDirectory = await fsp.mkdtemp(path.join(path.dirname(resolvedOutputDirectory), ".pleno-release-"));
try {
const sourceRoot = path.join(temporaryDirectory, "source");
const copiedDistDirectory = path.join(sourceRoot, "dist");
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
const extractionRoot = path.join(temporaryDirectory, "extracted");
await fsp.mkdir(sourceRoot, { recursive: true });
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
const copiedInventory = await collectDistInventory(copiedDistDirectory);
assertMatchingInventories(inventory, copiedInventory, "archive source");
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
cwd: sourceRoot,
maxBuffer: 10 * 1024 * 1024,
});
await validateArchiveEntries(temporaryArchivePath);
await fsp.mkdir(extractionRoot);
await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], {
maxBuffer: 10 * 1024 * 1024,
});
const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist"));
assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive");
const archiveContents = await fsp.readFile(temporaryArchivePath);
const archiveSha256 = sha256(archiveContents);
await fsp.copyFile(temporaryArchivePath, archivePath, fs.constants.COPYFILE_EXCL);
await fsp.writeFile(checksumPath, `${archiveSha256} ${archiveName}\n`, { flag: "wx" });
await fsp.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" });
const outputs = {
build_id: expectedBuildId,
archive_name: archiveName,
archive_path: archivePath,
checksum_path: checksumPath,
inventory_path: inventoryPath,
release_id: releaseId,
archive_sha256: archiveSha256,
};
appendGithubOutputs(outputs, githubOutput);
return { ...outputs, inventory };
} catch (error) {
await Promise.all(
[archivePath, checksumPath, inventoryPath].map((outputPath) => fsp.unlink(outputPath).catch(() => {}))
);
throw error;
} finally {
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function main() {
const result = await createReleaseArchive({
distDirectory: process.env.RELEASE_DIST_DIR || "dist",
outputDirectory: process.env.RELEASE_OUTPUT_DIR || "release-artifacts",
expectedCommitSha: process.env.RELEASE_COMMIT_SHA,
expectedBuildId: process.env.RELEASE_BUILD_ID,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
});
console.log(
`Validated and packaged ${result.inventory.files.length} files as ${result.archive_name} (${result.archive_sha256}).`
);
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exitCode = 1;
});
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-audit}"
FTP_HOST="${PRODUCTION_FTP_HOST:-}"
FTP_USER="${PRODUCTION_FTP_USER:-}"
FTP_PASSWORD="${PRODUCTION_FTP_PASSWORD:-}"
FTP_PATH="${PRODUCTION_FTP_PATH:-}"
WEBROOT="${PRODUCTION_CPANEL_WEBROOT:-public_html}"
FRONTEND_URL="${PRODUCTION_FRONTEND_URL:-https://truckwash.io}"
SOURCE_HTACCESS="${SOURCE_HTACCESS:-public/.htaccess}"
REPORT_DIR="${ROOT_REPAIR_REPORT_DIR:-output/cpanel-root-ftps}"
RUN_ID="${GITHUB_RUN_ID:-local}"
RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}"
if [[ "$MODE" != "audit" && "$MODE" != "repair" ]]; then
echo "Usage: $0 [audit|repair]" >&2
exit 2
fi
if [[ -z "$FTP_HOST" || -z "$FTP_USER" || -z "$FTP_PASSWORD" || -z "$FTP_PATH" ]]; then
echo "Production FTPS credentials and path are required." >&2
exit 2
fi
if [[ "$FTP_USER" == *$'\n'* || "$FTP_USER" == *$'\r'* || "$FTP_PASSWORD" == *$'\n'* || "$FTP_PASSWORD" == *$'\r'* ]]; then
echo "Production FTPS credentials contain unsupported control characters." >&2
exit 2
fi
if [[ ! "$FTP_HOST" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ ]]; then
echo "PRODUCTION_FTP_HOST is invalid." >&2
exit 2
fi
normalized_ftp_path="${FTP_PATH#/}"
normalized_ftp_path="${normalized_ftp_path%/}"
if [[ ! "$normalized_ftp_path" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$ ]]; then
echo "PRODUCTION_FTP_PATH must contain only safe path components." >&2
exit 2
fi
if [[ ! "$WEBROOT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "PRODUCTION_CPANEL_WEBROOT must be one safe path component." >&2
exit 2
fi
if [[ ! "$RUN_ID" =~ ^[A-Za-z0-9._-]+$ || ! "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]; then
echo "GitHub run identity is invalid." >&2
exit 2
fi
if [[ ! -f "$SOURCE_HTACCESS" ]] || ! grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$SOURCE_HTACCESS"; then
echo "The reviewed source .htaccess must declare DirectoryIndex index.html." >&2
exit 2
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required." >&2
exit 2
fi
mkdir -p "$REPORT_DIR"
original="$REPORT_DIR/original.htaccess"
remote_part_copy="$REPORT_DIR/uploaded-part.htaccess"
pre_activation_copy="$REPORT_DIR/pre-activation.htaccess"
active_copy="$REPORT_DIR/active.htaccess"
backup_name=".htaccess.before-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
part_name=".htaccess.directory-index-${RUN_ID}-${RUN_ATTEMPT}.part"
failed_name=".htaccess.failed-directory-index-${RUN_ID}-${RUN_ATTEMPT}"
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
write_connection() {
printf 'set cmd:fail-exit yes\n'
printf 'set cmd:interactive no\n'
printf 'set ftp:ssl-allow yes\n'
printf 'set ftp:ssl-force yes\n'
printf 'set ftp:ssl-protect-data yes\n'
printf 'set ssl:verify-certificate yes\n'
printf 'set ssl:check-hostname yes\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
printf 'open -u %s,%s %s\n' \
"$(lftp_quote "$FTP_USER")" \
"$(lftp_quote "$FTP_PASSWORD")" \
"$(lftp_quote "ftp://$FTP_HOST")"
printf 'cd %s\n' "$(lftp_quote "$FTP_PATH")"
IFS='/' read -r -a path_components <<< "$normalized_ftp_path"
for _component in "${path_components[@]}"; do
printf "cd '..'\n"
done
printf 'cd %s\n' "$(lftp_quote "$WEBROOT")"
}
run_lftp() {
{
write_connection
printf '%s\n' "$@"
printf 'bye\n'
} | lftp -f /dev/stdin
}
write_report() {
local state="$1"
local original_sha source_sha active_sha has_directory_index
original_sha="$(sha256sum "$original" | awk '{print $1}')"
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
active_sha=""
if [[ -f "$active_copy" ]]; then
active_sha="$(sha256sum "$active_copy" | awk '{print $1}')"
fi
has_directory_index=false
if grep -Eq '^DirectoryIndex[[:space:]]+index\.html[[:space:]]*$' "$original"; then
has_directory_index=true
fi
REPORT_STATE="$state" \
REPORT_WEBROOT="$WEBROOT" \
REPORT_BACKUP="$backup_name" \
REPORT_ORIGINAL_SHA="$original_sha" \
REPORT_SOURCE_SHA="$source_sha" \
REPORT_ACTIVE_SHA="$active_sha" \
REPORT_HAS_DIRECTORY_INDEX="$has_directory_index" \
node --input-type=module <<'NODE' > "$REPORT_DIR/report.json"
const report = {
state: process.env.REPORT_STATE,
webroot: process.env.REPORT_WEBROOT,
backup: process.env.REPORT_BACKUP,
originalSha256: process.env.REPORT_ORIGINAL_SHA,
expectedSha256: process.env.REPORT_SOURCE_SHA,
activeSha256: process.env.REPORT_ACTIVE_SHA || null,
originalHasDirectoryIndex: process.env.REPORT_HAS_DIRECTORY_INDEX === "true",
};
console.log(`${JSON.stringify(report, null, 2)}\n`);
NODE
}
verify_live() {
VERIFY_BASE_URL="$FRONTEND_URL" VERIFY_RUN_ID="$RUN_ID" node --input-type=module <<'NODE'
const baseUrl = new URL(process.env.VERIFY_BASE_URL);
const checks = ["/", "/index.html", "/release-manifest.json", "/guest/book/wash"];
for (const path of checks) {
const url = new URL(path, baseUrl);
url.searchParams.set("root-repair", process.env.VERIFY_RUN_ID);
const response = await fetch(url, { headers: { Accept: "text/html,application/json" } });
if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}`);
const body = await response.text();
if ((path === "/" || path === "/index.html" || path === "/guest/book/wash") &&
(!body.includes('<div id="app"') || body.includes("Index of /"))) {
throw new Error(`${path} did not render the Vue application shell`);
}
}
console.log("Live root, index, manifest, and deep route verified.");
NODE
}
run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$original")"
write_report "audited"
if [[ "$MODE" == "audit" ]]; then
cat "$REPORT_DIR/report.json"
exit 0
fi
source_sha="$(sha256sum "$SOURCE_HTACCESS" | awk '{print $1}')"
original_sha="$(sha256sum "$original" | awk '{print $1}')"
if [[ "$source_sha" == "$original_sha" ]]; then
cp "$original" "$active_copy"
verify_live
write_report "already-current"
cat "$REPORT_DIR/report.json"
exit 0
fi
run_lftp \
"put $(lftp_quote "$SOURCE_HTACCESS") -o $(lftp_quote "$part_name")" \
"get $(lftp_quote "$part_name") -o $(lftp_quote "$remote_part_copy")" \
"get $(lftp_quote '.htaccess') -o $(lftp_quote "$pre_activation_copy")"
if [[ "$(sha256sum "$remote_part_copy" | awk '{print $1}')" != "$source_sha" ]]; then
echo "Uploaded .htaccess staging file failed checksum verification." >&2
exit 1
fi
if [[ "$(sha256sum "$pre_activation_copy" | awk '{print $1}')" != "$original_sha" ]]; then
echo "Live .htaccess changed after audit; refusing to overwrite it." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote '.htaccess') $(lftp_quote "$backup_name")"; then
echo "Could not retain the original .htaccess; no activation was attempted." >&2
exit 1
fi
if ! run_lftp "mv $(lftp_quote "$part_name") $(lftp_quote '.htaccess')"; then
run_lftp "mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "FTPS activation failed; rollback was attempted." >&2
exit 1
fi
if ! run_lftp "get $(lftp_quote '.htaccess') -o $(lftp_quote "$active_copy")"; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Could not verify the active .htaccess; rollback was attempted." >&2
exit 1
fi
if [[ "$(sha256sum "$active_copy" | awk '{print $1}')" != "$source_sha" ]]; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Active .htaccess checksum mismatched; rollback was attempted." >&2
exit 1
fi
if ! verify_live; then
run_lftp \
"mv $(lftp_quote '.htaccess') $(lftp_quote "$failed_name")" \
"mv $(lftp_quote "$backup_name") $(lftp_quote '.htaccess')" || true
echo "Live verification failed; the original .htaccess was restored." >&2
exit 1
fi
write_report "repaired"
cat "$REPORT_DIR/report.json"
+91 -27
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -40,12 +41,15 @@ function pathUrl(baseUrl, assetPath) {
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
async function fetchBytes(url, deadline) {
const configuredTimeout = Math.max(1, numberEnv("RELEASE_FETCH_TIMEOUT_SECONDS", 30)) * 1000;
const remaining = deadline ? Math.max(1, deadline - Date.now()) : configuredTimeout;
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal: AbortSignal.timeout(Math.min(configuredTimeout, remaining)),
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
@@ -55,9 +59,9 @@ async function fetchBytes(url) {
};
}
async function fetchJson(baseUrl, assetPath) {
async function fetchJson(baseUrl, assetPath, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const result = await fetchBytes(url, deadline);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
@@ -72,7 +76,9 @@ async function fetchJson(baseUrl, assetPath) {
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
@@ -96,9 +102,40 @@ function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
function isMutableReleaseFile(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:index\.html|release-(?:entry|manifest)\.json|manifest(?:\.json|\.webmanifest)|registerSW\.js|sw\.js)$/i.test(
normalized
);
}
function isContentAddressedAsset(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:workbox-)?[^/]*[-.][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$/i.test(
normalized
);
}
export function verifyCachePolicy(assetPath, response) {
if (!booleanEnv("RELEASE_REQUIRE_CACHE_HEADERS")) {
return;
}
const cacheControl = response.headers.get("cache-control") || "";
const mutable = isMutableReleaseFile(assetPath);
if (mutable && !/(?:no-store|no-cache|max-age=0)/i.test(cacheControl)) {
throw new Error(
`${assetPath} must be served with a revalidating Cache-Control policy (got ${cacheControl || "missing"})`
);
}
if (!mutable && isContentAddressedAsset(assetPath) && !/immutable/i.test(cacheControl)) {
throw new Error(`${assetPath} must be served with immutable caching (got ${cacheControl || "missing"})`);
}
}
async function verifyAsset(baseUrl, assetPath, expectedHash, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const result = await fetchBytes(url, deadline);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
@@ -110,6 +147,7 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
verifyCachePolicy(assetPath, result.response);
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
@@ -118,8 +156,8 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
async function verifyShell(baseUrl, shellPath, deadline) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath), deadline);
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
@@ -132,23 +170,41 @@ async function verifyShell(baseUrl, shellPath) {
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
if (!containsVueAppRoot(body)) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
export function containsVueAppRoot(body) {
return /<div\b[^>]*\bid=(["'])app\1[^>]*>/i.test(String(body));
}
async function runWithConcurrency(values, concurrency, operation) {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
await operation(values[index]);
}
});
await Promise.all(workers);
}
async function verifyRelease(baseUrl, deadline) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
const manifest = await fetchJson(baseUrl, "release-manifest.json", deadline);
const releaseEntry = await fetchJson(baseUrl, "release-entry.json", deadline);
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
throw new Error(
`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`
);
}
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
@@ -163,12 +219,15 @@ async function verifyRelease(baseUrl) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
const shellPaths = unique(
(process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim())
);
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
await verifyShell(baseUrl, shellPath, deadline);
}
const assetUrls = unique([
"index.html",
"release-manifest.json",
"release-entry.json",
manifest.entry,
@@ -178,12 +237,15 @@ async function verifyRelease(baseUrl) {
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
const concurrency = Math.max(1, Math.min(32, numberEnv("RELEASE_VERIFY_CONCURRENCY", 8)));
await runWithConcurrency(assetUrls, concurrency, async (assetUrl) => {
await verifyAsset(
baseUrl,
assetUrl,
manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`],
deadline
);
});
return {
build_id: manifest.build_id,
@@ -210,7 +272,7 @@ async function main() {
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
const result = await verifyRelease(baseUrl, deadline);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
@@ -226,14 +288,16 @@ async function main() {
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
await sleep(Math.min(pollIntervalSeconds * 1000, Math.max(0, deadline - Date.now())));
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
}
+2 -1
View File
@@ -122,7 +122,8 @@ function normalizePositiveInt(value, fallback) {
}
function stripAnsi(input) {
return input.replace(/\u001b\[[0-9;]*m/g, "");
// eslint-disable-next-line no-control-regex -- ANSI escape sequences intentionally start with ESC.
return input.replace(new RegExp("\\x1b\\[[0-9;]*m", "g"), "");
}
function formatDuration(durationMs) {
+3 -1
View File
@@ -62,6 +62,7 @@ export const ownedFilesByRole = {
"admin-department-visibility.spec.ts",
"admin-overview-mobile.spec.ts",
"admin-overview-night-washes.spec.ts",
"admin-pos-order-filters.spec.ts",
"admin-pos-drafts.spec.ts",
"admin-pos-orders.spec.ts",
"adminModuleGoals.spec.ts",
@@ -80,7 +81,6 @@ export const ownedFilesByRole = {
"pos.visual.spec.js",
],
superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
@@ -100,6 +100,7 @@ export const ownedFilesByRole = {
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"superuser-customer-rules.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
@@ -114,6 +115,7 @@ export const ownedFilesByRole = {
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-security.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
-4
View File
@@ -152,10 +152,6 @@ function getArtifactNamespace(suffix) {
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
}
function buildProjectArgs(projects) {
return projects.flatMap((project) => ["--project", project]);
}
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
+4 -3
View File
@@ -38,7 +38,7 @@ const SUSPICIOUS_PATTERNS = [
/ÃÂ/g,
/Ã./g,
/Â(?=\S)/g,
/â€(?:[™œžŸ"“”‘’•–—…])/g,
/â€[™œžŸ"“”‘’•–—…]/g,
/’/g,
/“/g,
/”/g,
@@ -49,7 +49,8 @@ const SUSPICIOUS_PATTERNS = [
];
const REGIONAL_CHAR_REGEX = /[æøåÆØÅäöÄÖüÜßéÉèÈáÁàÀóÓúÚíÍñÑçÇ]/g;
const LATIN_EXTENDED_CHAR_REGEX = /[\u00C0-\u024F]/g;
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
// eslint-disable-next-line no-control-regex -- these are precisely the invalid control characters this scanner detects.
const CONTROL_CHAR_REGEX = new RegExp("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]", "g");
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function countMatches(text, regex) {
@@ -227,7 +228,7 @@ async function collectScanFiles(rootDir) {
const files = [];
async function walk(currentDir) {
let entries = [];
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
+95 -16
View File
@@ -6,6 +6,7 @@ import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import {
releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus,
@@ -16,6 +17,20 @@ import {
const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const pageRouteLoadingProps = {
title: "Indlæser side...",
subtitle: "Vi gør siden klar",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Indlæser side",
};
const sessionRouteLoadingProps = {
title: "Bekræfter bruger...",
subtitle: "Vi indlæser din session",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Bekræfter bruger",
};
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
@@ -81,6 +96,42 @@ const shouldRenderReleaseChannelSwitched = computed(
const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
}
return window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, "") || "/";
};
const routeMiddlewareList = () =>
route.matched.flatMap((record) => {
const middleware = record.meta?.middleware;
if (!middleware) {
return [];
}
return Array.isArray(middleware) ? middleware : [middleware];
});
const hasRouteMiddleware = (middlewareName) =>
routeMiddlewareList().some((middleware) => middleware?.name === middlewareName);
const shouldUseSessionPathLoader = (path) =>
sessionLoaderRoutePattern.test(path || "") &&
(!guestSessionLoaderRoutePattern.test(path || "") || hasStoredSessionToken());
const shouldUseGuestSessionPathLoader = (path) =>
guestSessionLoaderRoutePattern.test(path || "") && hasStoredSessionToken();
const shouldUseSessionRouteLoader = computed(() =>
route.name !== "default" &&
(shouldUseSessionPathLoader(route.path || "") ||
shouldUseSessionPathLoader(currentBrowserPath()) ||
shouldUseGuestSessionPathLoader(route.path || "") ||
shouldUseGuestSessionPathLoader(currentBrowserPath()) ||
hasRouteMiddleware("authMiddleware") ||
(hasStoredSessionToken() && hasRouteMiddleware("guestMiddleware")))
);
const appRouteLoadingProps = computed(() =>
shouldUseSessionRouteLoader.value ? sessionRouteLoadingProps : pageRouteLoadingProps
);
const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) {
@@ -111,22 +162,50 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script>
<template>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view />
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view />
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view />
</DefaultPageWrapper>
</main>
</template>
<Suspense :timeout="0">
<template #default>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</DefaultPageWrapper>
</main>
</template>
</template>
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
+1 -5
View File
@@ -184,8 +184,6 @@ const generateSquareAround = (matches: colorMatch[], canvasId: string) => {
}
combinedCtx.drawImage(canvas, minX, minY, combinedWidth, combinedHeight, 0, 0, combinedWidth, combinedHeight);
// Get the image data of the combined square
const combinedImageData = combinedCtx.getImageData(0, 0, combinedWidth, combinedHeight);
const combinedData = combinedImageData.data;
// Console.log the base64 representation of the combined square
const base64Combined = combinedCanvas.toDataURL();
console.log(`Base64 representation of combined square:`, base64Combined);
@@ -490,8 +488,6 @@ const findLicensePlate = (object: gridObject) => {
location: { x: (i / 4) % canvas.width, y: Math.floor((i / 4) / canvas.width) }
});
// Check if there's black text around the found color
const x = (i / 4) % canvas.width;
const y = Math.floor((i / 4) / canvas.width);
const textFound = data.slice(i - 100, i + 100).some((value, index) => {
if (index % 4 === 0) { // Check only the red channel
return value < 50; // Assuming black text is below a certain threshold
@@ -589,4 +585,4 @@ const findLicensePlate = (object: gridObject) => {
outline: none;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
</style>
</style>
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
const props = withDefaults(defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
});
const emit = defineEmits<{
"update:selected": [value: any];
"update:expandedKeys": [value: any[]];
"update:checkedKeys": [value: any[]];
select: [node: TreeNode, key: any];
check: [node: TreeNode, key: any, checkedKeys: any[]];
expand: [node: TreeNode, key: any];
collapse: [node: TreeNode, key: any];
"node-click": [node: TreeNode, key: any];
"load-start": [node: TreeNode, key: any];
"load-error": [error: unknown, node: TreeNode, key: any];
}>();
const resolvedFields = computed(() => ({
id: props.fields?.id || "id",
label: props.fields?.label || "label",
children: props.fields?.children || "children",
isLeaf: props.fields?.isLeaf || "isLeaf",
disabled: props.fields?.disabled || "disabled",
}));
const state = reactive({
selected: props.selected,
expandedKeys: [...props.expandedKeys],
checkedKeys: [...props.checkedKeys],
lazyChildrenCache: {} as Record<string, TreeNode[]>,
loadingKeys: [] as any[],
loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>,
});
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
const childrenOf = (node: TreeNode) => {
const children = node?.[resolvedFields.value.children];
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
};
const progressiveBatchSize = computed(() => Math.max(0, Math.floor(Number(props.progressiveBatchSize) || 0)));
const visibleChildrenOf = (node: TreeNode) => {
const children = effectiveChildrenOf(node);
if (progressiveBatchSize.value < 1 || children.length <= progressiveBatchSize.value) {
return children;
}
const key = keyString(keyOf(node));
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit);
};
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) {
return;
}
const key = keyString(keyOf(node));
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
};
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
"{count}",
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
const isCheckDisabled = (node: TreeNode) => isDisabled(node) || (!isSelfSelectable(node) && !isBranchCheckable(node));
const isLeaf = (node: TreeNode) => Boolean(node?.[resolvedFields.value.isLeaf]);
const collectKeys = (node: TreeNode): any[] => {
const keys: any[] = [];
const key = keyOf(node);
if (key !== undefined && key !== null && isSelfSelectable(node)) {
keys.push(key);
}
effectiveChildrenOf(node).forEach((child) => {
keys.push(...collectKeys(child));
});
return keys;
};
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch(
() => props.selected,
(value) => {
state.selected = value;
}
);
watch(
() => props.expandedKeys,
(value) => {
state.expandedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.checkedKeys,
(value) => {
state.checkedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]);
};
const setCheckedKeys = (keys: any[]) => {
state.checkedKeys = [...new Set(keys)];
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.loadingKeys.includes(key)) {
return;
}
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
if (progressiveBatchSize.value > 0) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
}
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
} finally {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
};
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
if (!props.lazy || !props.load || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (childrenOf(node).length > 0 || state.lazyChildrenCache[keyString(key)]) {
return;
}
await loadNode(node);
};
const toggleExpand = async (node: TreeNode) => {
if (isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.expandedKeys.includes(key)) {
setExpandedKeys(state.expandedKeys.filter((current) => current !== key));
emit("collapse", node, key);
return;
}
setExpandedKeys([...state.expandedKeys, key]);
emit("expand", node, key);
if (props.lazy && childrenOf(node).length === 0 && !state.lazyChildrenCache[keyString(key)]) {
await loadNode(node);
}
};
const retryLoad = async (node: TreeNode) => {
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
delete state.lazyChildrenCache[keyString(key)];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
if (!state.expandedKeys.includes(key)) {
setExpandedKeys([...state.expandedKeys, key]);
}
await loadNode(node);
};
const toggleCheck = async (node: TreeNode) => {
if (props.selectionMode !== "checkbox" || isCheckDisabled(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
await ensureChildrenLoadedForCheck(node);
const keys = collectKeys(node);
if (keys.length === 0) {
emit("check", node, key, [...state.checkedKeys]);
return;
}
const allChecked = keys.every((current) => state.checkedKeys.includes(current));
if (allChecked) {
const toRemove = new Set(keys);
setCheckedKeys(state.checkedKeys.filter((current) => !toRemove.has(current)));
} else {
setCheckedKeys([...state.checkedKeys, ...keys]);
}
emit("check", node, key, [...state.checkedKeys]);
};
const checkState = (node: TreeNode): "checked" | "unchecked" | "indeterminate" => {
const key = keyOf(node);
const children = effectiveChildrenOf(node);
if (children.length === 0) {
return state.checkedKeys.includes(key) ? "checked" : "unchecked";
}
const states = children.map((child) => checkState(child));
if (states.every((item) => item === "checked")) {
return "checked";
}
if (states.every((item) => item === "unchecked") && !state.checkedKeys.includes(key)) {
return "unchecked";
}
return "indeterminate";
};
const handleNodeClick = async (node: TreeNode) => {
if (isDisabled(node)) {
return;
}
const key = keyOf(node);
emit("node-click", node, key);
if (props.selectionMode === "single") {
state.selected = key;
emit("update:selected", key);
emit("select", node, key);
} else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key)
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected);
emit("select", node, key);
}
if (props.expandOnClickNode) {
await toggleExpand(node);
}
};
provide("BuefyTreeContext", {
props,
state,
fields: resolvedFields,
keyOf,
childrenOf,
effectiveChildrenOf,
visibleChildrenOf,
hasMoreChildren,
showMoreChildren,
remainingChildrenCount,
loadMoreLabelFor,
isDisabled,
isCheckDisabled,
isLeaf,
toggleExpand,
retryLoad,
toggleCheck,
checkState,
handleNodeClick,
});
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
:node="node"
:depth="1"
:setsize="data.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps">
{{ slotProps.node?.[resolvedFields.label] }}
</slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</template>
<style scoped>
.b-tree {
list-style: none;
margin: 0;
padding: 0;
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { BCheckbox } from "buefy";
type TreeNode = Record<string, any>;
const props = defineProps<{
node: TreeNode;
depth: number;
setsize: number;
posinset: number;
}>();
const tree = inject<any>("BuefyTreeContext");
if (!tree) {
throw new Error("BuefyTreeNode must be used inside BuefyTree");
}
const keyValue = computed(() => tree.keyOf(props.node));
const keyString = computed(() => String(keyValue.value ?? ""));
const logicalChildren = computed(() => tree.effectiveChildrenOf(props.node));
const children = computed(() => tree.visibleChildrenOf(props.node));
const hasMoreChildren = computed(() => tree.hasMoreChildren(props.node));
const checkState = computed(() => tree.checkState(props.node));
const isExpanded = computed(() => tree.state.expandedKeys.includes(keyValue.value));
const isLoading = computed(() => tree.state.loadingKeys.includes(keyValue.value));
const hasLoadError = computed(() => tree.state.loadErrorKeys.includes(keyValue.value));
const isSelected = computed(() => {
const selected = tree.state.selected;
return Array.isArray(selected) ? selected.includes(keyValue.value) : selected === keyValue.value;
});
const isDisabled = computed(() => tree.isDisabled(props.node));
const isCheckDisabled = computed(() => tree.isCheckDisabled(props.node));
const isLeaf = computed(() => tree.isLeaf(props.node));
const hasExpandToggle = computed(() => {
if (isLeaf.value) {
return false;
}
return tree.props.lazy || children.value.length > 0;
});
const label = computed(() => String(props.node?.[tree.fields.value.label] ?? ""));
const loadMoreButton = ref<HTMLElement | null>(null);
let loadMoreObserver: IntersectionObserver | null = null;
const disconnectLoadMoreObserver = () => {
loadMoreObserver?.disconnect();
loadMoreObserver = null;
};
const observeLoadMoreButton = async () => {
disconnectLoadMoreObserver();
if (!isExpanded.value || !hasMoreChildren.value || typeof IntersectionObserver === "undefined") {
return;
}
await nextTick();
if (!loadMoreButton.value) {
return;
}
loadMoreObserver = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
disconnectLoadMoreObserver();
tree.showMoreChildren(props.node);
void observeLoadMoreButton();
}, { rootMargin: "160px 0px" });
loadMoreObserver.observe(loadMoreButton.value);
};
watch([isExpanded, hasMoreChildren], () => {
void observeLoadMoreButton();
}, { immediate: true });
onBeforeUnmount(disconnectLoadMoreObserver);
</script>
<template>
<li
class="b-tree-node"
:class="{
'is-expanded': isExpanded,
'is-selected': isSelected,
'is-disabled': isDisabled,
'is-loading': isLoading,
'has-load-error': hasLoadError,
}"
role="treeitem"
:aria-expanded="hasExpandToggle ? isExpanded : undefined"
:aria-selected="tree.props.selectionMode !== 'checkbox' && tree.props.selectionMode !== 'none' ? isSelected : undefined"
:aria-checked="tree.props.selectionMode === 'checkbox' ? (checkState === 'indeterminate' ? 'mixed' : checkState === 'checked') : undefined"
:aria-level="depth"
:aria-setsize="setsize"
:aria-posinset="posinset"
:aria-disabled="isDisabled || undefined"
:data-node-key="keyString"
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
:disabled="isDisabled"
@click.stop="tree.toggleExpand(node)"
>
<span class="icon is-small">
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
</button>
<span
v-if="tree.props.selectionMode === 'checkbox'"
class="b-tree-node-checkbox"
@click.stop
>
<BCheckbox
:model-value="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:disabled="isCheckDisabled"
tabindex="-1"
@update:model-value="tree.toggleCheck(node)"
/>
</span>
<span class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
</span>
</slot>
</span>
<span class="b-tree-node-label">
<slot
:node="node"
:data="node"
:depth="depth"
:expanded="isExpanded"
:checked="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:selected="isSelected"
:loading="isLoading"
:error="hasLoadError"
:retry="() => tree.retryLoad(node)"
>
{{ label }}
</slot>
</span>
</div>
<ul v-if="isExpanded && children.length > 0" class="b-tree-children" role="group">
<BuefyTreeNode
v-for="(child, index) in children"
:key="tree.keyOf(child) ?? index"
:node="child"
:depth="depth + 1"
:setsize="logicalChildren.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps"></slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
<li v-if="hasMoreChildren" class="b-tree-load-more" role="none">
<button
ref="loadMoreButton"
type="button"
class="button is-small is-light b-tree-load-more__button"
:aria-label="tree.loadMoreLabelFor(node)"
@click.stop="tree.showMoreChildren(node)"
>
<span class="icon is-small" aria-hidden="true"><i class="fas fa-chevron-down"></i></span>
<span>{{ tree.loadMoreLabelFor(node) }}</span>
</button>
</li>
</ul>
</li>
</template>
<style scoped>
.b-tree-node {
list-style: none;
}
.b-tree-node-content {
align-items: flex-start;
border-radius: 6px;
cursor: pointer;
display: flex;
gap: 0.35rem;
min-height: 2rem;
padding: 0.25rem 0.35rem;
}
.b-tree-node-content:hover {
background: #f5f7fa;
}
.b-tree-node.is-selected > .b-tree-node-content {
background: #eef4ff;
}
.b-tree-node.is-disabled > .b-tree-node-content {
cursor: default;
opacity: 0.65;
}
.b-tree-node-toggle {
flex: 0 0 1.85rem;
height: 1.75rem;
padding: 0;
width: 1.85rem;
}
.b-tree-node-checkbox {
flex: 0 0 auto;
margin-top: 0.08rem;
}
.b-tree-node-icon {
flex: 0 0 auto;
margin-top: 0.2rem;
}
.b-tree-node-label {
flex: 1 1 auto;
min-width: 0;
}
.b-tree-children {
border-left: 1px solid #dfe4ea;
list-style: none;
margin: 0 0 0 1.05rem;
padding: 0 0 0 0.65rem;
}
.b-tree-load-more {
list-style: none;
padding: 0.25rem 0 0.35rem 0.35rem;
}
.b-tree-load-more__button {
gap: 0.25rem;
}
.is-invisible {
visibility: hidden;
}
</style>
+1 -3
View File
@@ -16,9 +16,7 @@ defineProps({
},
});
const redirect = (path) => {
window.location = path;
}
</script>
<template>
+8 -10
View File
@@ -315,11 +315,11 @@ const onFieldChange = async (field, value) => {
console.log(field, value);
}
const getFieldName = (field) => {
const _getFieldName = (field) => {
try {
return SessionUser.objects.forms.meta.labels.fields.names[field.name].label;
}
catch (e) {
catch {
return field.name;
}
}
@@ -364,7 +364,7 @@ const getFieldOptions = (field) => {
console.log(result);
return result;
}
catch (e) {
catch {
return {};
}
}
@@ -376,7 +376,7 @@ const getFieldMetadata = (field) => {
...props.form_field_options[field.name] ?? {}
};
}
catch (e) {
catch {
return {};
}
}
@@ -435,7 +435,6 @@ const getForm = () => {
}
// Parse the types
for (const field_index in fields.value) {
const key = field_index;
const field = fields.value[field_index];
const validator = getValidator(field.validation);
//console.log(field, validator);
@@ -459,7 +458,6 @@ const setDefaultValues = () => {
//console.log('setDefaultValues');
//console.log(fields.value);
for (const field_index in fields.value) {
const key = field_index;
const field = fields.value[field_index];
const validator = getValidator(field.validation);
//console.log(field, validator);
@@ -555,7 +553,7 @@ const isFieldSelect = (field) => {
}
const isFieldMultiple = (field) => {
return field.metadata.allow_multiple === true ?? false;
return field.metadata.allow_multiple === true;
}
const doesMultipleFieldContainValue = (field, index) => {
@@ -614,7 +612,7 @@ const debugGetForm = () => {
<!-- Multiple select -->
<template v-if="isFieldMultiple(field)">
<div class="buttons is-multiline">
<template v-for="(option, index) in getFieldOptions(field)">
<template v-for="(option, index) in getFieldOptions(field)" :key="`${field.id}-${index}`">
<div
class="button"
:class="{
@@ -638,7 +636,7 @@ const debugGetForm = () => {
<template v-else>
<select :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)">
<!-- Loop through the options, while creating the select options -->
<template v-for="(option, index) in getFieldOptions(field)">
<template v-for="(option, index) in getFieldOptions(field)" :key="`${field.id}-${index}`">
<option :value="index">{{ option }}</option>
</template>
</select>
@@ -661,7 +659,7 @@ const debugGetForm = () => {
<!-- Text -->
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'text'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" v-bind:disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder">
<!-- Email -->
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'email'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder" v-if="isFieldDisplayable(field)">
<input class="input is-link" v-else-if="getValidator(field.validation).type === 'email' && isFieldDisplayable(field)" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.value)" v-model="fieldValues[field.id]" :disabled="isFieldLocked(field)" v-bind:placeholder="field.metadata.placeholder">
<!-- Checkbox -->
<div class="control" v-else-if="getValidator(field.validation).type === 'checkbox'" :type="getValidator(field.validation).type" :required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.checked)">
<input class="switch" :type="getValidator(field.validation).type" v-bind:required="isFieldRequired(field)" :name="field.id" :id="field.id" @change="onFieldChange(field, $event.target.checked)" :checked="isCheckboxChecked(field)">

Some files were not shown because too many files have changed in this diff Show More