Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfbf80f327 | ||
|
|
96958c53a3 | ||
|
|
13509b68f2 | ||
|
|
f689409990 | ||
|
|
c69f4f7fc7 | ||
|
|
10f993e686 | ||
|
|
a02ddfbfbc | ||
|
|
7a85c93631 | ||
|
|
a3d11cfca3 | ||
|
|
74279fc443 | ||
|
|
7e3a72961f | ||
|
|
ca2179352c | ||
|
|
8ddac065c6 | ||
|
|
a930bd35a5 | ||
|
|
6b8f3ff806 | ||
|
|
3323f392e3 | ||
|
|
f8f2b80641 | ||
|
|
1729443cc3 | ||
|
|
6b5ac8a8b3 | ||
|
|
b12aca2757 | ||
|
|
39cc3a780a | ||
|
|
18302723e8 | ||
|
|
20c383d22d | ||
|
|
77a0c4e018 | ||
|
|
9a5c8b193d | ||
|
|
0562ba8ab0 | ||
|
|
906e860c86 | ||
|
|
516e1fb58d | ||
|
|
7da1307cf6 | ||
|
|
ea4893d815 | ||
|
|
f2e0079b59 | ||
|
|
47d8baa496 | ||
|
|
6d96d64811 | ||
|
|
92fb998e5a | ||
|
|
bed6030e2b | ||
|
|
328a85bad1 | ||
|
|
394375e429 | ||
|
|
ca744adfd9 | ||
|
|
5e42f55570 | ||
|
|
021da1a074 | ||
|
|
0e55f45e91 | ||
|
|
9749837506 | ||
|
|
4fbb0b98c3 | ||
|
|
0edf8afcaf | ||
|
|
e2ee54578c | ||
|
|
b35e4484de | ||
|
|
c8368612eb | ||
|
|
26266e724b | ||
|
|
6ccfea9eb4 | ||
|
|
d4349c20a7 | ||
|
|
63f1cc2450 | ||
|
|
acec2a694a | ||
|
|
f15d7224e0 | ||
|
|
236f552a22 | ||
|
|
321b6f2c64 | ||
|
|
218ead9a31 | ||
|
|
60b6e82c88 | ||
|
|
bbe4ad938a | ||
|
|
9e27380a10 | ||
|
|
baf83fd079 | ||
|
|
dfe3163692 | ||
|
|
4452bd4088 | ||
|
|
6f93b840a9 | ||
|
|
443f50c144 | ||
|
|
5a7f902d01 | ||
|
|
02cc82cee1 | ||
|
|
fa9b05958d | ||
|
|
6fb2c9e7df | ||
|
|
54e59b4b3e | ||
|
|
8a0ea6cae5 | ||
|
|
1bbd816e83 | ||
|
|
4e56191b7e | ||
|
|
4404fb49d3 |
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -15,6 +15,7 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
dev-dist
|
||||
.playwright-cli/
|
||||
|
||||
# Mobile build and signing outputs
|
||||
/app/build/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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>
|
||||
@@ -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": "/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
@@ -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.");
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -16,9 +16,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const redirect = (path) => {
|
||||
window.location = path;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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)">
|
||||
|
||||