Compare commits

..
Author SHA1 Message Date
Jeppe B 8c073f7efb Fix customer additional-service restrictions 2026-07-15 14:36:15 +02:00
731 changed files with 7468 additions and 20723 deletions
-58
View File
@@ -1,58 +0,0 @@
# 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.
-21
View File
@@ -1,21 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "07:00"
timezone: Europe/Copenhagen
open-pull-requests-limit: 5
labels: [dependencies, ci]
- package-ecosystem: bundler
directory: /
schedule:
interval: weekly
day: monday
time: "07:15"
timezone: Europe/Copenhagen
open-pull-requests-limit: 3
labels: [dependencies, ios]
@@ -1,53 +0,0 @@
{
"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
}
}
]
}
-72
View File
@@ -1,72 +0,0 @@
name: App Store Readiness
on:
pull_request:
push:
branches: [master]
paths:
- "fastlane/**"
- "ios/**"
- "scripts/mobile/**"
- "Gemfile*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: app-store-readiness-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Setup Ruby
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
- name: Resolve the pinned Fastlane dependency graph
run: bundle lock
- name: Check the committed Fastlane dependency lock
id: fastlane-lock
continue-on-error: true
run: test -z "$(git status --porcelain -- Gemfile.lock)"
- name: Preserve a generated lock for review
if: steps.fastlane-lock.outcome == 'failure'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: generated-fastlane-lock
path: Gemfile.lock
if-no-files-found: error
retention-days: 1
- name: Require a current committed Fastlane dependency lock
if: steps.fastlane-lock.outcome == 'failure'
run: |
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
exit 1
- name: Validate App Store metadata and available assets
run: node scripts/mobile/validate-app-store.mjs
- name: Validate JavaScript syntax
run: |
node --check scripts/mobile/validate-app-store.mjs
node --check scripts/mobile/app-store-connect.mjs
node --check scripts/mobile/create-ios-release-manifest.mjs
node scripts/mobile/app-store-connect.mjs self-test-jwt
+18 -52
View File
@@ -1,66 +1,32 @@
name: Qodana
name: Qodana Configuration Upload
on:
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
uses: actions/checkout@v5
- name: Require Qodana project token
shell: bash
- name: Run Qodana Configuration Uploader
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
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
-99
View File
@@ -1,99 +0,0 @@
name: cPanel Root Audit and Restore
on:
workflow_dispatch:
inputs:
mode:
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
required: true
default: audit
type: choice
options:
- audit
- restore
recovery:
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
required: false
type: string
state_token:
description: Exact 64-character audit-metadata state token reported by the audit.
required: false
type: string
confirmation:
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
required: false
type: string
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-restore:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Audit cPanel primary webroot
if: inputs.mode == 'audit'
id: audit
run: node scripts/release/cpanel-root.mjs audit
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
- name: Validate restore inputs
if: inputs.mode == 'restore'
env:
RECOVERY: ${{ inputs.recovery }}
STATE_TOKEN: ${{ inputs.state_token }}
CONFIRMATION: ${{ inputs.confirmation }}
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
run: |
test -n "$RECOVERY"
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
- name: Restore retained cPanel webroot
if: inputs.mode == 'restore'
run: node scripts/release/cpanel-root.mjs restore
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
- name: Upload cPanel root report
if: always()
uses: actions/upload-artifact@v4
with:
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root
if-no-files-found: ignore
retention-days: 30
@@ -1,179 +0,0 @@
name: iOS App Store Candidate
on:
push:
tags: ["ios-v*"]
permissions:
contents: read
actions: read
concurrency:
group: ios-app-store-candidate
cancel-in-progress: false
jobs:
resolve:
name: Resolve exact tested build
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
enabled: ${{ steps.resolve.outputs.enabled }}
source_sha: ${{ steps.resolve.outputs.source_sha }}
version: ${{ steps.resolve.outputs.version }}
build_number: ${{ steps.manifest.outputs.build_number }}
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
steps:
- name: Checkout tagged source
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Validate protected tag and release version
id: resolve
shell: bash
env:
AUTOMATION_ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }}
run: |
set -euo pipefail
[[ "$GITHUB_REF_NAME" =~ ^ios-v([0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "Tag must be ios-vX.Y.Z." >&2; exit 1; }
version="${BASH_REMATCH[1]}"
source_sha="$(git rev-parse HEAD)"
manifest_version="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).marketingVersion")"
[[ "$version" == "$manifest_version" ]] || { echo "Tag version $version does not match ios/release.json $manifest_version." >&2; exit 1; }
git show-ref --verify --quiet refs/remotes/origin/master || { echo "origin/master was not included in the full checkout." >&2; exit 1; }
git merge-base --is-ancestor "$source_sha" origin/master || { echo "Tagged commit is not reachable from master." >&2; exit 1; }
enabled=false
[[ "$AUTOMATION_ENABLED" == true ]] && enabled=true
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
if [[ "$enabled" != true ]]; then
echo "### Candidate promotion safely disabled" >> "$GITHUB_STEP_SUMMARY"
echo 'No App Store environment or credentials were accessed. Enable only after the signed canary.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Download exact TestFlight release manifest
if: steps.resolve.outputs.enabled == 'true'
id: manifest
shell: bash
env:
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
EXPECTED_VERSION: ${{ steps.resolve.outputs.version }}
run: |
set -euo pipefail
artifact_name="ios-release-manifest-$SOURCE_SHA"
response="$RUNNER_TEMP/ios-artifacts.json"
curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" > "$response"
artifact_id="$(jq -r --arg sha "$SOURCE_SHA" '[.artifacts[] | select(.expired == false) | select(.workflow_run.head_sha == $sha)] | sort_by(.created_at) | last | .id // empty' "$response")"
[[ "$artifact_id" =~ ^[0-9]+$ ]] || { echo "No successful TestFlight release manifest exists for $SOURCE_SHA." >&2; exit 1; }
mkdir -p output/candidate
curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" -o "$RUNNER_TEMP/manifest.zip"
unzip -q "$RUNNER_TEMP/manifest.zip" -d output/candidate
MANIFEST=output/candidate/ios-release-manifest.json node <<'NODE'
const fs = require("node:fs");
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST, "utf8"));
const checks = {
schema: manifest.schemaVersion === 1,
repository: manifest.repository === process.env.GITHUB_REPOSITORY,
source: manifest.sourceSha === process.env.SOURCE_SHA,
version: manifest.marketingVersion === process.env.EXPECTED_VERSION,
bundle: manifest.bundleId === "io.truckwash.app",
build: /^[1-9][0-9]*$/.test(manifest.buildNumber),
appStoreBuild: typeof manifest.appStoreBuildId === "string" && manifest.appStoreBuildId.length > 0,
};
const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name);
if (failed.length) throw new Error(`Invalid iOS release manifest: ${failed.join(", ")}`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `build_number=${manifest.buildNumber}\napp_store_build_id=${manifest.appStoreBuildId}\n`);
NODE
promote:
name: Sync storefront and prepare manual review
needs: resolve
if: needs.resolve.outputs.enabled == 'true'
runs-on: macos-15
timeout-minutes: 60
environment: app-store-candidate
env:
IOS_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
IOS_MARKETING_VERSION: ${{ needs.resolve.outputs.version }}
IOS_BUILD_NUMBER: ${{ needs.resolve.outputs.build_number }}
EXPECTED_APP_STORE_BUILD_ID: ${{ needs.resolve.outputs.app_store_build_id }}
IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID || 'io.truckwash.app' }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_ISSUER_ID || '' }}
APP_STORE_CONNECT_APP_ID: ${{ vars.APP_STORE_CONNECT_APP_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
steps:
- name: Checkout exact candidate source
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ env.IOS_SOURCE_SHA }}
persist-credentials: false
- name: Setup Ruby and pinned Fastlane
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Validate complete candidate storefront
run: node scripts/mobile/validate-app-store.mjs --strict
- name: Verify public storefront URLs
shell: bash
run: |
set -euo pipefail
for file in support_url privacy_url marketing_url; do
url="$(tr -d '\r\n' < "fastlane/metadata/da-DK/$file.txt")"
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 --output /dev/null "$url"
done
- name: Verify exact processed TestFlight build
run: node scripts/mobile/app-store-connect.mjs verify-candidate
- name: Sync metadata and screenshots without App Review submission
run: bundle exec fastlane ios prepare_candidate
- name: Read back exact App Store candidate
id: readback
run: node scripts/mobile/app-store-connect.mjs verify-store-version
- name: Write candidate handoff
env:
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
run: |
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
disabled:
name: Promotion disabled
needs: resolve
if: needs.resolve.outputs.enabled != 'true'
runs-on: ubuntu-24.04
steps:
- run: echo "App Store candidate promotion is disabled; no environment or credentials were accessed."
-117
View File
@@ -1,117 +0,0 @@
name: iOS Credential Health
on:
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-credential-health
cancel-in-progress: true
jobs:
gate:
runs-on: ubuntu-24.04
outputs:
enabled: ${{ steps.gate.outputs.enabled }}
steps:
- id: gate
env:
ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }}
run: |
enabled=false
[[ "$ENABLED" == true ]] && enabled=true
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
if [[ "$enabled" != true ]]; then
echo "App Store automation is disabled; credential health did not access its environment." >> "$GITHUB_STEP_SUMMARY"
fi
validate:
needs: gate
if: needs.gate.outputs.enabled == 'true'
runs-on: macos-15
timeout-minutes: 15
environment: app-store-signing
env:
IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID || 'io.truckwash.app' }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_ISSUER_ID || '' }}
APP_STORE_CONNECT_APP_ID: ${{ vars.APP_STORE_CONNECT_APP_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Validate API key and app access
run: node scripts/mobile/app-store-connect.mjs verify-credentials
- name: Validate certificate and profile identity and expiry
shell: bash
env:
IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64 }}
IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
IOS_APP_STORE_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 }}
run: |
set -euo pipefail
cert_p12="$RUNNER_TEMP/distribution.p12"
cert_pem="$RUNNER_TEMP/distribution.pem"
cert_der="$RUNNER_TEMP/distribution.der"
profile="$RUNNER_TEMP/distribution.mobileprovision"
profile_plist="$RUNNER_TEMP/distribution-profile.plist"
keychain="$RUNNER_TEMP/credential-health.keychain-db"
keychain_password="$(openssl rand -hex 24)"
node -e "const fs=require('fs');fs.writeFileSync(process.argv[1],Buffer.from(process.env.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64.replace(/\\s/g,''),'base64'))" "$cert_p12"
node -e "const fs=require('fs');fs.writeFileSync(process.argv[1],Buffer.from(process.env.IOS_APP_STORE_PROFILE_BASE64.replace(/\\s/g,''),'base64'))" "$profile"
chmod 600 "$cert_p12" "$profile"
security create-keychain -p "$keychain_password" "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$cert_p12" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
security list-keychains -d user -s "$keychain"
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null
security find-identity -v -p codesigning "$keychain" | grep -q 'Apple Distribution' || {
echo "Distribution P12 does not contain a usable private signing identity." >&2
exit 1
}
openssl pkcs12 -in "$cert_p12" -clcerts -nokeys -passin env:IOS_DISTRIBUTION_CERTIFICATE_PASSWORD -out "$cert_pem"
openssl x509 -in "$cert_pem" -noout -subject -issuer -dates
openssl x509 -in "$cert_pem" -checkend 2592000 -noout || { echo "Distribution certificate expires within 30 days." >&2; exit 1; }
openssl x509 -in "$cert_pem" -outform DER -out "$cert_der"
security cms -D -i "$profile" > "$profile_plist"
CERT_DER="$cert_der" PROFILE_PLIST="$profile_plist" python3 <<'PY'
import datetime, hashlib, os, plistlib, sys
with open(os.environ["PROFILE_PLIST"], "rb") as handle: profile = plistlib.load(handle)
with open(os.environ["CERT_DER"], "rb") as handle: cert_sha = hashlib.sha1(handle.read()).hexdigest().upper()
expiration = profile.get("ExpirationDate")
if expiration and expiration.tzinfo is None: expiration = expiration.replace(tzinfo=datetime.timezone.utc)
warning = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30)
ent = profile.get("Entitlements", {})
checks = {
"team": os.environ["APPLE_TEAM_ID"] in profile.get("TeamIdentifier", []),
"bundle": ent.get("application-identifier") == f'{os.environ["APPLE_TEAM_ID"]}.{os.environ["IOS_BUNDLE_ID"]}',
"distribution": ent.get("get-task-allow") is False and not profile.get("ProvisionedDevices"),
"profile expiry beyond 30 days": expiration is not None and expiration > warning,
"certificate belongs to profile": cert_sha in {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])},
}
failed = [name for name, ok in checks.items() if not ok]
if failed:
print("Credential health failed:", *[f"- {name}" for name in failed], sep="\n", file=sys.stderr)
sys.exit(1)
print(f"Provisioning profile is healthy through {expiration.isoformat()}.")
PY
- name: Clean temporary credential files
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/credential-health.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP"/distribution.{p12,pem,der,mobileprovision} "$RUNNER_TEMP/distribution-profile.plist"
-624
View File
@@ -1,624 +0,0 @@
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"
-423
View File
@@ -1,423 +0,0 @@
name: iOS Internal TestFlight
on:
workflow_run:
workflows: [Frontend Release]
types: [completed]
branches: [master]
workflow_dispatch:
inputs:
source_sha:
description: Full master commit SHA with a verified Frontend Release proof
required: true
type: string
confirmation:
description: Type UPLOAD IOS INTERNAL BUILD
required: true
type: string
permissions:
contents: read
actions: read
concurrency:
group: ios-internal-testflight
cancel-in-progress: false
jobs:
prepare:
name: Resolve verified release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'workflow_run' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
source_sha: ${{ steps.resolve.outputs.source_sha }}
enabled: ${{ steps.resolve.outputs.enabled }}
current: ${{ steps.resolve.outputs.current }}
steps:
- name: Checkout repository history
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve immutable source and rollout gate
id: resolve
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || '' }}
INPUT_SOURCE_SHA: ${{ inputs.source_sha || '' }}
CONFIRMATION: ${{ inputs.confirmation || '' }}
AUTOMATION_ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }}
run: |
set -euo pipefail
source_sha="$WORKFLOW_SOURCE_SHA"
if [[ "$EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/master ]] || { echo "Dispatch this workflow from master." >&2; exit 1; }
[[ "$CONFIRMATION" == "UPLOAD IOS INTERNAL BUILD" ]] || { echo "Invalid confirmation." >&2; exit 1; }
source_sha="${INPUT_SOURCE_SHA,,}"
fi
[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "A full lowercase source SHA is required." >&2; exit 1; }
git show-ref --verify --quiet refs/remotes/origin/master || { echo "origin/master was not included in the full checkout." >&2; exit 1; }
git cat-file -e "${source_sha}^{commit}"
git merge-base --is-ancestor "$source_sha" origin/master || { echo "Source is not reachable from master." >&2; exit 1; }
current=false
[[ "$(git rev-parse origin/master)" == "$source_sha" ]] && current=true
enabled=false
[[ "$AUTOMATION_ENABLED" == true ]] && enabled=true
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
if [[ "$enabled" != true ]]; then
echo "### iOS automation is safely disabled" >> "$GITHUB_STEP_SUMMARY"
echo 'Set repository variable `APP_STORE_AUTOMATION_ENABLED=true` only after the signing/API credential canary passes.' >> "$GITHUB_STEP_SUMMARY"
elif [[ "$current" != true ]]; then
echo "### Stale release skipped" >> "$GITHUB_STEP_SUMMARY"
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
fi
deliver:
name: Sign, upload, process, and distribute
needs: prepare
if: needs.prepare.outputs.enabled == 'true' && needs.prepare.outputs.current == 'true'
runs-on: macos-15
timeout-minutes: 120
environment: app-store-signing
env:
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
IOS_SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
IOS_PROJECT_PATH: ${{ vars.IOS_PROJECT || 'ios/App/App.xcodeproj' }}
IOS_SCHEME: ${{ vars.IOS_SCHEME || 'App' }}
IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID || 'io.truckwash.app' }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_ISSUER_ID || '' }}
APP_STORE_CONNECT_APP_ID: ${{ vars.APP_STORE_CONNECT_APP_ID }}
TESTFLIGHT_INTERNAL_GROUP_ID: ${{ vars.TESTFLIGHT_INTERNAL_GROUP_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
steps:
- name: Checkout verified source
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ env.IOS_SOURCE_SHA }}
fetch-depth: 1
persist-credentials: false
- name: Download and verify frontend release proof
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TRIGGERING_RELEASE_RUN_ID: ${{ github.event.workflow_run.id || '' }}
run: |
set -euo pipefail
artifact_name="frontend-release-proof-$IOS_SOURCE_SHA"
response="$RUNNER_TEMP/proof-artifacts.json"
curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" > "$response"
artifact_id="$(jq -r --arg run "$TRIGGERING_RELEASE_RUN_ID" '
[.artifacts[] | select(.expired == false) | select(($run == "") or ((.workflow_run.id|tostring) == $run))] |
sort_by(.created_at) | last | .id // empty' "$response")"
[[ "$artifact_id" =~ ^[0-9]+$ ]] || { echo "No verified Frontend Release proof found for $IOS_SOURCE_SHA." >&2; exit 1; }
mkdir -p output/frontend-release-proof
curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" -o "$RUNNER_TEMP/proof.zip"
unzip -q "$RUNNER_TEMP/proof.zip" -d output/frontend-release-proof
PROOF_PATH=output/frontend-release-proof/frontend-release-proof.json node <<'NODE'
const fs = require("node:fs");
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
const checks = {
schema: proof.schemaVersion === 1,
repository: proof.repository === process.env.GITHUB_REPOSITORY,
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
publicGate: proof.livePublicGate === "passed",
credentialedGate: proof.liveCredentialedGate === "passed",
managerGate: proof.releaseManagerGate === "passed",
serverVersion: proof.serverVersionUpdated === true,
};
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
NODE
- name: Verify Xcode 26 and iOS 26 SDK
shell: bash
run: |
set -euo pipefail
[[ -x "$DEVELOPER_DIR/usr/bin/xcodebuild" ]] || { echo "Xcode 26.3 is not installed at $DEVELOPER_DIR." >&2; exit 1; }
xcode_version="$(xcodebuild -version | sed -n '1p')"
sdk_version="$(xcrun --sdk iphoneos --show-sdk-version)"
[[ "$xcode_version" =~ ^Xcode\ 26\. ]] || { echo "Xcode 26.x required; found $xcode_version." >&2; exit 1; }
[[ "$sdk_version" =~ ^26\. ]] || { echo "iPhoneOS 26 SDK required; found $sdk_version." >&2; exit 1; }
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- name: Setup Ruby and pinned Fastlane
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Install web dependencies
run: npm ci --legacy-peer-deps
- name: Validate storefront and resolve version
id: version
shell: bash
run: |
set -euo pipefail
node scripts/mobile/validate-app-store.mjs
version="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).marketingVersion")"
bundle="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).bundleId")"
[[ "$bundle" == "$IOS_BUNDLE_ID" ]]
echo "IOS_MARKETING_VERSION=$version" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_NAME=$version" >> "$GITHUB_ENV"
- name: Resolve build number from App Store Connect
id: app-store
run: node scripts/mobile/app-store-connect.mjs next-build-number
- name: Export resolved build number
env:
BUILD_NUMBER: ${{ steps.app-store.outputs.build_number }}
run: |
[[ "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]]
echo "IOS_BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$BUILD_NUMBER" >> "$GITHUB_ENV"
- name: Validate complete Apple environment
env:
IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64 }}
IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
IOS_APP_STORE_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 }}
UPLOAD_IOS_TO_APP_STORE: "true"
run: node scripts/mobile/check-store-upload-env.mjs --ios
- name: Build and sync production iOS shell
run: |
npm run build
npx cap sync ios
npm run mobile:permissions:check
xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Validate native release settings
shell: bash
run: |
set -euo pipefail
settings="$RUNNER_TEMP/ios-release-build-settings.txt"
xcodebuild -showBuildSettings -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" -configuration Release CODE_SIGNING_ALLOWED=NO > "$settings"
grep -Eq "^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = ${IOS_BUNDLE_ID//./\.}$" "$settings"
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$settings"
grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$settings"
if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then
echo "Unexpected Xcode shell-script build phase detected." >&2
exit 1
fi
- name: Install and validate Apple distribution signing assets
shell: bash
env:
IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64 }}
IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
IOS_APP_STORE_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 }}
run: |
set -euo pipefail
certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
profile_plist="$RUNNER_TEMP/app-store-profile.plist"
keychain_path="$RUNNER_TEMP/app-store-signing.keychain-db"
keychain_password="$(openssl rand -base64 48 | tr -d '\n')"
echo "::add-mask::$keychain_password"
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
node -e "const fs=require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64.replace(/\\s/g,''),'base64'))" "$certificate_path"
node -e "const fs=require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_APP_STORE_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_DISTRIBUTION_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"
identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Distribution/ {print $2; exit}')"
[[ "$identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]] || { echo "P12 lacks an Apple Distribution identity." >&2; exit 1; }
IOS_SIGNING_IDENTITY_SHA="$identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY'
import datetime, hashlib, os, plistlib, re, sys
with open(os.environ["PROFILE_PLIST"], "rb") as handle: profile = plistlib.load(handle)
entitlements = profile.get("Entitlements", {})
expiration = profile.get("ExpirationDate")
if expiration and expiration.tzinfo is None: expiration = expiration.replace(tzinfo=datetime.timezone.utc)
team = os.environ["APPLE_TEAM_ID"]
bundle = os.environ["IOS_BUNDLE_ID"]
hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}
checks = {
"team": team in profile.get("TeamIdentifier", []),
"application identifier": entitlements.get("application-identifier") == f"{team}.{bundle}",
"team entitlement": entitlements.get("com.apple.developer.team-identifier") == team,
"distribution entitlement": entitlements.get("get-task-allow") is False,
"App Store profile has no devices": not profile.get("ProvisionedDevices"),
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
"expiration": expiration is not None and expiration > datetime.datetime.now(datetime.timezone.utc),
"certificate belongs to profile": os.environ["IOS_SIGNING_IDENTITY_SHA"].upper() in hashes,
"safe profile name": isinstance(profile.get("Name"), str) and not re.search(r"[\r\n]", profile["Name"]),
}
failed = [name for name, passed in checks.items() if not passed]
if failed:
print("Distribution signing validation failed:", *[f"- {name}" for name in failed], sep="\n", 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_install="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision"
mkdir -p "$(dirname "$profile_install")"
echo "IOS_PROFILE_INSTALL_PATH=$profile_install" >> "$GITHUB_ENV"
cp "$profile_path" "$profile_install"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
- name: Archive and export App Store IPA
shell: bash
run: |
set -euo pipefail
archive="$RUNNER_TEMP/TruckWash.xcarchive"
export_dir="$RUNNER_TEMP/ios-export"
export_options="$RUNNER_TEMP/ExportOptions.plist"
xcodebuild -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" -configuration Release \
-destination 'generic/platform=iOS' -archivePath "$archive" archive \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' \
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" MARKETING_VERSION="$IOS_MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" DEBUG_INFORMATION_FORMAT='dwarf-with-dsym'
EXPORT_OPTIONS="$export_options" python3 <<'PY'
import os, plistlib
options = {"method":"app-store-connect","signingStyle":"manual","teamID":os.environ["APPLE_TEAM_ID"],"provisioningProfiles":{os.environ["IOS_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 "$archive" -exportPath "$export_dir" -exportOptionsPlist "$export_options"
shopt -s nullglob
ipa_files=("$export_dir"/*.ipa)
[[ ${#ipa_files[@]} -eq 1 ]] || { echo "Expected one IPA; found ${#ipa_files[@]}." >&2; exit 1; }
echo "IOS_ARCHIVE_PATH=$archive" >> "$GITHUB_ENV"
echo "IOS_IPA_PATH=${ipa_files[0]}" >> "$GITHUB_ENV"
- name: Inspect signed IPA
shell: bash
run: |
set -euo pipefail
inspect="$RUNNER_TEMP/ios-inspect"
unzip -q "$IOS_IPA_PATH" -d "$inspect"
shopt -s nullglob
apps=("$inspect"/Payload/*.app)
[[ ${#apps[@]} -eq 1 ]] || { echo "Expected one Payload app." >&2; exit 1; }
app="${apps[0]}"
codesign --verify --deep --strict "$app"
codesign -d --entitlements :- "$app" > "$RUNNER_TEMP/entitlements.plist"
security cms -D -i "$app/embedded.mobileprovision" > "$RUNNER_TEMP/embedded-profile.plist"
[[ -f "$app/PrivacyInfo.xcprivacy" ]]
[[ -f "$app/da.lproj/InfoPlist.strings" ]]
[[ -f "$app/en.lproj/InfoPlist.strings" ]]
APP_PATH="$app" python3 <<'PY'
import os, plistlib, sys
app = os.environ["APP_PATH"]
with open(f"{app}/Info.plist", "rb") as handle: info = plistlib.load(handle)
with open(os.path.join(os.environ["RUNNER_TEMP"], "entitlements.plist"), "rb") as handle: ent = plistlib.load(handle)
with open(os.path.join(os.environ["RUNNER_TEMP"], "embedded-profile.plist"), "rb") as handle: profile = plistlib.load(handle)
checks = {
"bundle": info.get("CFBundleIdentifier") == os.environ["IOS_BUNDLE_ID"],
"version": info.get("CFBundleShortVersionString") == os.environ["IOS_MARKETING_VERSION"],
"build": info.get("CFBundleVersion") == os.environ["IOS_BUILD_NUMBER"],
"minimum iOS": info.get("MinimumOSVersion") == "15.0",
"profile": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"],
"non-debug signature": ent.get("get-task-allow") is not True,
"signature application id": ent.get("application-identifier") == f'{os.environ["APPLE_TEAM_ID"]}.{os.environ["IOS_BUNDLE_ID"]}',
}
failed = [name for name, passed in checks.items() if not passed]
if failed:
print("IPA validation failed:", *[f"- {name}" for name in failed], sep="\n", file=sys.stderr)
sys.exit(1)
PY
- name: Recheck live master before upload
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
live_master_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/master" | jq -r '.object.sha')"
[[ "$live_master_sha" == "$IOS_SOURCE_SHA" ]] || {
echo "master advanced while the signed build was queued; refusing upload." >&2
exit 1
}
- name: Upload and wait for App Store processing
env:
TESTFLIGHT_WHAT_TO_TEST: Automatisk intern build fra verificeret master ${{ env.IOS_SOURCE_SHA }}.
run: bundle exec fastlane ios upload_internal
- name: Assign exact processed build to Internal QA
id: distribute
env:
TESTFLIGHT_WHAT_TO_TEST: Automatisk intern build fra verificeret master ${{ env.IOS_SOURCE_SHA }}.
run: node scripts/mobile/app-store-connect.mjs wait-and-distribute
- name: Assemble signed release evidence
shell: bash
env:
APP_STORE_BUILD_ID: ${{ steps.distribute.outputs.app_store_build_id }}
run: |
set -euo pipefail
artifact="output/ios-release"
mkdir -p "$artifact"
cp "$IOS_IPA_PATH" "$artifact/TruckWash-$IOS_MARKETING_VERSION-$IOS_BUILD_NUMBER.ipa"
shopt -s nullglob
dsyms=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM)
[[ ${#dsyms[@]} -gt 0 ]] || { echo "Release archive contains no dSYM bundles." >&2; exit 1; }
ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact/TruckWash-$IOS_MARKETING_VERSION-$IOS_BUILD_NUMBER.dSYM.zip"
node scripts/mobile/create-ios-release-manifest.mjs
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
- name: Upload signed IPA
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
path: output/ios-release/*.ipa
if-no-files-found: error
retention-days: 30
- name: Upload release manifest, dSYM, and checksums
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
path: |
output/ios-release/ios-release-manifest.json
output/ios-release/*.dSYM.zip
output/ios-release/SHA256SUMS
if-no-files-found: error
retention-days: 90
- name: Clean up Apple signing material
if: always()
shell: bash
run: |
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then security delete-keychain "$IOS_KEYCHAIN_PATH" || true; fi
if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then rm -f "$IOS_PROFILE_INSTALL_PATH"; fi
rm -f "$RUNNER_TEMP/apple-distribution.p12" "$RUNNER_TEMP/app-store.mobileprovision" "$RUNNER_TEMP/app-store-profile.plist"
disabled:
name: Automation disabled
needs: prepare
if: needs.prepare.outputs.enabled != 'true'
runs-on: ubuntu-24.04
steps:
- run: echo "App Store automation is disabled; no signing environment or secrets were accessed."
+279 -14
View File
@@ -1,4 +1,4 @@
name: Android Store Artifacts
name: Mobile Store Artifacts
on:
workflow_dispatch:
@@ -16,6 +16,11 @@ on:
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
@@ -51,7 +56,7 @@ permissions:
contents: read
concurrency:
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
@@ -74,10 +79,9 @@ jobs:
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false
- name: Guard current master release
id: release-guard
@@ -87,16 +91,12 @@ jobs:
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 }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
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
@@ -113,21 +113,21 @@ jobs:
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
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@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
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@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
@@ -205,7 +205,7 @@ jobs:
- name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@v4
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ env.ANDROID_AAB_PATH }}
@@ -217,3 +217,268 @@ jobs:
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 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.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 || '' }}
run: |
set -euo pipefail
version_name="$INPUT_VERSION_NAME"
if [[ -z "$version_name" && "$GITHUB_REF_NAME" == mobile-v* ]]; then
version_name="${GITHUB_REF_NAME#mobile-v}"
fi
if [[ -z "$version_name" ]]; then
version_name="0.0.${GITHUB_RUN_NUMBER}"
fi
version_code="${INPUT_VERSION_CODE:-$GITHUB_RUN_NUMBER}"
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 }}
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 }}
run: |
set -euo pipefail
certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
profile_plist="$RUNNER_TEMP/profile.plist"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_CERTIFICATE_BASE64, 'base64'))" "$certificate_path"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_PROVISION_PROFILE_BASE64, 'base64'))" "$profile_path"
security create-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$keychain_path"
security import "$certificate_path" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
security list-keychain -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
security set-key-partition-list -S apple-tool:,apple: -s -k "$IOS_KEYCHAIN_PASSWORD" "$keychain_path"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
security cms -D -i "$profile_path" > "$profile_plist"
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "$profile_plist")"
profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "$profile_plist")"
cp "$profile_path" "$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision"
echo "APPLE_TEAM_ID=$APPLE_TEAM_ID" >> "$GITHUB_ENV"
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
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
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_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
archive \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
MARKETING_VERSION="$MOBILE_VERSION_NAME" \
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
- name: Export iOS IPA
if: steps.release-guard.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
export_method="app-store-connect"
if ! xcodebuild -help 2>&1 | grep -q "app-store-connect"; then
export_method="app-store"
fi
export_options="$RUNNER_TEMP/ExportOptions.plist"
cat > "$export_options" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>$export_method</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>$APPLE_TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>$IOS_BUNDLE_ID</key>
<string>$IOS_PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
EOF
xcodebuild \
-exportArchive \
-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.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
run: |
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$IOS_KEYCHAIN_PATH" || true
fi
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
+80 -283
View File
@@ -14,61 +14,53 @@ permissions:
actions: read
concurrency:
group: frontend-production
cancel-in-progress: false
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
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
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
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 }}
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 }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Check release commit is current
id: branch-head
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
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 }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -87,22 +79,16 @@ 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
@@ -110,187 +96,56 @@ jobs:
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
env:
PLAYWRIGHT_PROD_WEBKIT: "0"
- name: Confirm production gate did not mutate dist
- 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
if: steps.branch-head.outputs.current == 'true'
run: |
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: Package and validate release
if: steps.branch-head.outputs.current == 'true'
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # 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
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
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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")" ]]
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 }}
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@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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
- name: Wait for Coolify release artifact
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_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_GITHUB_TOKEN: ${{ github.token }}
run: npm run release:verify-upload
- 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
@@ -302,24 +157,6 @@ jobs:
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Roll back after live verification failure
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
timeout-minutes: 10
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: |
@@ -329,11 +166,12 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$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\"]}"
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
@@ -343,53 +181,12 @@ jobs:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
- name: Create verified frontend release proof
if: steps.branch-head.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
proof_dir="output/frontend-release-proof"
mkdir -p "$proof_dir"
PROOF_PATH="$proof_dir/frontend-release-proof.json" node <<'NODE'
const { writeFileSync } = require("node:fs");
const required = (name) => {
if (!process.env[name]) throw new Error(`Missing ${name}`);
return process.env[name];
};
const proof = {
schemaVersion: 1,
repository: required("GITHUB_REPOSITORY"),
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
frontendReleaseRunId: required("GITHUB_RUN_ID"),
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
buildId: required("RELEASE_BUILD_ID"),
livePublicGate: "passed",
liveCredentialedGate: "passed",
releaseManagerGate: "passed",
serverVersionUpdated: true,
completedAt: new Date().toISOString(),
};
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
NODE
env:
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
- name: Publish verified frontend release proof
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
path: output/frontend-release-proof/frontend-release-proof.json
if-no-files-found: error
retention-days: 30
- name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
retention-days: 3
+2 -27
View File
@@ -48,8 +48,8 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
cancel-in-progress: true
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
@@ -511,31 +511,6 @@ 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() &&
-4
View File
@@ -1,4 +0,0 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.229.1"
-235
View File
@@ -1,235 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1271.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.228.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.229.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.7.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.1)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
fastlane (= 2.229.1)
RUBY VERSION
ruby 3.3.12p206
BUNDLED WITH
2.5.22
+3 -16
View File
@@ -16,16 +16,6 @@ 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
@@ -170,11 +160,10 @@ The Play Store Android package is built from the Capacitor project in `android/`
The legacy Bubblewrap/TWA project at the repository root is not used by
`npm run mobile:android:bundle`.
The native launcher and store icons use the opaque iOS marketing icon as their
shared master so Android and iOS keep the same white background:
The source image for the native launcher icon is:
```text
ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
public/favicons/web-app-manifest-512x512.png
```
Regenerate the checked-in launcher assets after changing that source image:
@@ -203,9 +192,7 @@ 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`.
release preparation and review notes.
## Bubblewrap (TWA) Build and Install
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_launcher_background">#0787BB</color>
</resources>
+80 -181
View File
@@ -1,203 +1,102 @@
# Apple App Store Release Runbook
This is the operating runbook for the public iOS application and its signed
GitHub Actions delivery. Public review submission and public release remain
human actions in App Store Connect.
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
## Storefront record
## Account And App Record
Create or reconcile one App Store Connect 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.
| Setting | Value |
| --- | --- |
| Name | Truck Wash Kundeportal |
| Bundle ID | `io.truckwash.app` |
| SKU | `truckwash-ios` |
| Primary language | Danish |
| Category | Business |
| Price | Free |
| Availability | Denmark only |
| Support URL | `https://truckwash.io/support` |
| Privacy URL | `https://truckwash.io/privacy-policy` |
| Marketing URL | `https://truckwash.io/` |
| Release | Manual after approval |
## Build And Upload
Use the standard Apple EULA and do not configure in-app purchases. Payments in
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
been tested deliberately.
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 Account Holder or Admin must complete these console-only items before the
first candidate:
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.
- Accept current Apple developer and business agreements.
- Verify Truck Wash ApS's EU Digital Services Act trader identity and contact
information.
- Complete the current age-rating questionnaire. Do not hard-code an expected
rating in automation.
- Approve the privacy data matrix and enter matching App Privacy answers,
including third-party SDK behavior.
- Decide export compliance after reviewing the final binary. Only add
`ITSAppUsesNonExemptEncryption=false` when the exempt determination is
approved.
- Complete accessibility declarations only for behavior verified on devices.
- Store a durable, sanitized review account in App Store Connect. Never commit
its password, OTP seed, or recovery data.
The iOS workflow expects these environment secrets:
Review notes must explain customer and driver login, the review account's 2FA
path, QR/hardware behavior, camera/location denial fallbacks, and the physical
service payment model.
## Metadata and assets in Git
`fastlane/metadata/da-DK/` is the Danish storefront source of truth.
`ios/release.json` is the release-version source of truth. Its version is
numeric `X.Y.Z`; its bundle ID must remain `io.truckwash.app`.
Run the readiness validation locally:
```sh
npm run mobile:ios:storefront:check
```
Readiness mode validates all present assets and reports missing screenshot sets
as warnings. A candidate tag runs strict mode and requires exactly six reviewed
images in each set:
- `fastlane/screenshots/da-DK/iphone-6.9-01-*.png` through `06`, 1320×2868.
- `fastlane/screenshots/da-DK/ipad-13-01-*.png` through `06`, 2064×2752.
Use Xcode 26 simulators and the real Capacitor app. Capture dashboard, booking,
self-wash/QR, vehicles, orders/history, and invoices. Screenshots must contain
sanitized fixture data, no alpha channel, no real customer data, and no
placeholder content. Linux CI cannot honestly synthesize authenticated native
captures; capture and approve them on a controlled macOS machine before tagging.
The validator also rejects the known default Capacitor icon and splash artwork.
Native permission strings must exist in Danish and English and are included via
the `InfoPlist.strings` Xcode variant group.
## Apple identities and GitHub configuration
Create:
1. A dedicated App Store Connect team API key named `GitHub App Store CI` with
the App Manager role. Team JWTs use the account issuer ID in the `iss` claim.
2. A dedicated Apple Distribution certificate for CI.
3. An App Store distribution provisioning profile for `io.truckwash.app`.
4. An internal TestFlight group named `Internal QA` with automatic distribution.
Configure two GitHub environments:
- `app-store-signing`, branch policy limited to protected `master`.
- `app-store-candidate`, tag policy limited to protected `ios-v*` tags.
Private repositories on the Team plan cannot rely on environment required
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
limited to release managers. Manual App Review submission is the final human
approval.
Environment secrets:
- `IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64`
- `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD`
- `IOS_APP_STORE_PROFILE_BASE64`
- `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`
Environment variables:
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.
- `APPLE_TEAM_ID=HP3FJ4GVL7`
- `IOS_BUNDLE_ID=io.truckwash.app`
- `IOS_SCHEME=App`
- `IOS_PROJECT=ios/App/App.xcodeproj`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_APP_ID` (Apple's numeric app resource ID)
- `TESTFLIGHT_INTERNAL_GROUP_ID` (Apple's beta-group resource ID)
- `APP_STORE_CONNECT_ISSUER_ID=074cc671-edc3-403d-b85f-98470f3b16bd`
## Product Page Defaults
The repository variable `APP_STORE_AUTOMATION_ENABLED` is the authoritative
activation switch. Missing or any value other than `true` makes all signing,
credential-health, and candidate workflows succeed as safe no-ops without
selecting an App Store environment or reading Apple secrets.
- 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.
## Enablement and first canary
Use real iOS simulator or device screenshots. Provide at least:
Keep `APP_STORE_AUTOMATION_ENABLED=false` while configuring Apple/GitHub state.
Then:
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
1. Merge all product-readiness work and confirm `App Store Readiness` passes.
After its first successful default-branch run, add that job to the protected
master ruleset's required status checks.
2. Verify the privacy policy, account-deletion flow, icons, localized permission
copy, and privacy manifest on a device.
3. Set the repository switch to `true` during a controlled release window.
4. Dispatch `iOS Internal TestFlight` from the `master` workflow definition,
supplying the full current master SHA and confirmation
`UPLOAD IOS INTERNAL BUILD`.
5. Confirm the workflow validates Xcode 26.3/iOS 26, certificate/profile
identity and expiry, the signed IPA, App Store processing, and exact Internal
QA assignment.
6. Install the result on a clean supported iPhone and iPad. Verify fresh install,
upgrade, login, resume, offline/reconnect, permission allow/deny, booking,
self-wash/QR, vehicles, orders, invoices, support/privacy, and account
deletion.
7. Leave the switch enabled only after the canary is accepted.
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.
If the first live credential attempt fails, set the repository switch back to
`false` before investigating. This avoids red master releases while credentials
are incomplete.
## Privacy And Review Notes
## Continuous TestFlight delivery
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.
`Frontend Release` publishes a signed-by-CI evidence artifact only after the
production deployment, public live gate, credentialed live gate, Release
Manager gate, and server-version update all pass for current `master`.
Review notes must include:
`iOS Internal TestFlight` consumes that exact proof. It refuses a stale SHA,
uses `/Applications/Xcode_26.3.app`, requires an iOS 26 SDK, queries App Store
Connect for the next build number under serialized concurrency, signs and
inspects the IPA, uploads through pinned Fastlane, waits for processing, and
idempotently assigns the exact build to Internal QA.
- 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.
Outputs include:
## TestFlight And Release
- Signed IPA, retained for 30 days.
- dSYMs, SHA-256 checksums, and `ios-release-manifest.json`, retained for 90
days.
- Source SHA, marketing/build versions, App Store build ID, Xcode/SDK versions,
and workflow identity in the manifest.
Every successful future Frontend Release for current `master` triggers this
delivery automatically. Stale or proofless releases do not sign or upload.
## Select a public candidate
1. Verify the desired TestFlight build on iPhone and iPad.
2. Confirm its commit's `ios/release.json` contains the public version.
3. Create a new protected tag such as `ios-v1.0.0` on that exact commit. Never
move or reuse an existing release tag.
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
verifies the exact processed App Store build, enforces complete screenshots,
synchronizes Danish metadata, attaches the existing build, and reads it back.
It does not rebuild, submit for review, or release publicly.
5. In App Store Connect, review the rendered product page, review account,
privacy/export/age answers, and candidate build. Submit manually.
6. Release the first Denmark version manually after approval. Use phased release
for later updates unless there is a reason not to.
7. Merge the next `ios/release.json` version bump before further delivery after
Apple closes the released version to new builds.
## Rotation and recovery
`iOS Credential Health` runs every Monday and fails when certificate/profile
identity drifts or either expires within 30 days. Rotate one credential at a
time, keep automation disabled during rotation, and repeat the canary.
- Bad TestFlight build: expire it, fix master, and produce a new build number.
- Bad candidate: detach it in App Store Connect and tag a corrected tested SHA
with a new version; never move the tag.
- Bad phased update: pause the phase.
- Compromised key/certificate: disable automation, revoke it in Apple, rotate
GitHub secrets, inspect audit logs, and run a fresh canary.
- Public emergency: remove from sale only when necessary and prepare an
expedited corrective version.
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.
-276
View File
@@ -1,276 +0,0 @@
# 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. Uploads an authenticated, bounded-lifetime request into the jailed
deployment directory. A root-owned account-scoped activator validates the
request and archive, extracts an inactive release, verifies its manifest
identity and required files, and replaces `current` with a local
single-filesystem rename.
10. Downloads the extracted tree and compares it byte-for-byte with the
validated inventory. Public manifest, asset-integrity, cache-header,
API-ping, and role gates then run against the active release. A failed gate
asks the same activator to restore the previous immutable target.
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_ACTIVATION_KEY`
- `PRODUCTION_CPANEL_USER`
- `PRODUCTION_CPANEL_API_TOKEN`
The API `.env` contains legacy values under the first four names, but production
frontend deployment uses a dedicated cPanel FTP account jailed to
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
deployment unchanged.
The cPanel token is separate from the FTP password. Create it in cPanel under
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
uses the token for fail-closed directory and release-state inspection. It does
not use legacy Fileman mutation calls to replace symlinks: on this server those
calls can follow the target instead of renaming the link itself. Revoke and
rotate the token if it is ever exposed.
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 audit against the configured API origin.
It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable
releases. Do not broaden the token or deployment root beyond this cPanel
account.
The current production token is named `github-pleno-vue-production` and
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
secret before that date, then revoke the replaced token in cPanel.
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
Use **Add secret** for credentials and **Add variable** for the two URLs and the
cPanel deployment path.
Environment values are available only to the deployment job that names this
environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. The self-hosted deployment job installs
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
`sha256sum` for the account-scoped activator.
## cPanel layout and one-time bootstrap
The production FTP account is jailed directly to the deployment root, so its
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
relative to the cPanel account home. The helper creates this layout below it:
```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
read-only state checks and refuses deployment if `current` or the captured
rollback release is missing.
9. Generate a dedicated 32-byte random activation key. Store its 64-character
hexadecimal form in the protected `frontend-production` environment as
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
`/etc/pleno-release-activator/truckwash.key`, owned by `root:truckwash` and
mode `0440`. The FTPS jail must not expose this key.
10. As `root`, install `scripts/release/cpanel-activate.sh` out of band at
`/usr/local/sbin/truckwash-release-activate.sh`, owned by `root:root` and
mode `0755`. The FTPS principal must not be able to replace or modify this
executable. Then install this one `truckwash` account cron entry without
replacing any other account cron lines:
```cron
* * * * * /bin/flock -n /home/truckwash/frontend-deployments/.activation.lock /usr/bin/env CPANEL_ACTIVATION_ROOT=/home/truckwash/frontend-deployments CPANEL_ACTIVATION_KEY_FILE=/etc/pleno-release-activator/truckwash.key /bin/sh /usr/local/sbin/truckwash-release-activate.sh >/dev/null 2>&1
```
The workflow can upload release data and bounded-lifetime request files, but
it cannot replace the root-owned executable or read the activation key. The
script authenticates each bounded-lifetime request with HMAC-SHA-256,
accepts only strict filename components and hashes, validates the archive
and manifest, runs a disposable local symlink preflight, journals the prior
pointer for crash recovery, and writes a request-specific result. It runs as
`truckwash`; it does not need root or a shell credential in GitHub. The host
must provide `/bin/sh`, `flock`, `openssl`, `unzip`, `jq`, and `sha256sum`.
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 and both the active
webroot and selected recovery are physical directories, select `restore`
and copy one exact recovery entry from the audit, including the retained
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
The workflow requires the
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
exact token string from that audit. The token is an optimistic-concurrency
guard over the cPanel metadata visible to the audit; it is not a content hash
or a substitute for validating the selected recovery. Restore also rejects an
unreadable physical directory. Restore also rejects symbolic-link roots and
recoveries because legacy cPanel Fileman may follow their targets rather than
rename the links. It renames the current physical entry to a run-specific
`public_html.failed-*` path, restores the retained entry, and
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
any mutation response is lost or any check fails, it reconciles the observed
account-home entries and reinstates the pre-restore cPanel state. It never
deletes the recovery or displaced webroot, and reports manual intervention if
the expected entries cannot be proven after compensation.
## 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.
-400
View File
@@ -1,400 +0,0 @@
# 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)
+84 -37
View File
@@ -1,20 +1,35 @@
# Mobile Store Delivery
# Mobile Store Artifacts
Android and iOS delivery are intentionally independent. An iOS release or tag
must never publish an Android production artifact.
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.
## Android
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`.
`Android Store Artifacts` remains in
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
`io.truckwash.twa` and supports:
## Triggers
- Automatic delivery after successful current-master `Automated Tests`.
- Manual dispatch with version, version code, upload toggle, track, and status.
- Existing `mobile-v*` tags for the Android workflow.
- 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 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.
The Android job continues using GitHub environment `mobile-store-production`.
Its required secrets are:
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`
@@ -22,43 +37,75 @@ Its required secrets are:
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
Its variables are `ANDROID_PACKAGE_NAME`, `ANDROID_AAB_PATH`,
`PLAY_STORE_TRACK`, `PLAY_STORE_RELEASE_STATUS`, and optional
`PLAY_STORE_USER_FRACTION`. See the Google Play Console runbook for production
track policy.
iOS:
## iOS
- `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`
iOS uses three separate workflows:
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.
- `iOS Internal TestFlight`: exact verified master release to signed internal
TestFlight build.
- `iOS App Store Candidate`: protected `ios-vX.Y.Z` tag to exact-build
storefront candidate, without rebuilding or submission.
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
## Local Checks
The GitHub environments and variables are documented in
`docs/app-store-release.md`. The repository-level
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
`false` until the signed credential canary is approved.
Local source/storefront checks:
Run the native permission validation after changing Capacitor, native manifests, or store metadata:
```sh
npm run mobile:permissions:check
npm run mobile:ios:storefront:check
```
Strict candidate asset check:
Build a local unsigned Android App Bundle for packaging verification:
```sh
npm run mobile:ios:storefront:check-strict
npm run mobile:android:bundle:unsigned
```
Version identity is deliberately different between platforms:
Build a signed Play Console upload bundle after exporting the Android upload-key variables:
- Android package: `io.truckwash.twa`
- iOS App Store bundle: `io.truckwash.app`
```sh
export ANDROID_KEYSTORE_FILE=/path/to/upload-key.jks
export ANDROID_KEYSTORE_PASSWORD=...
export ANDROID_KEY_ALIAS=...
export ANDROID_KEY_PASSWORD=...
npm run mobile:android:bundle
```
The iOS release build verifies the final signed IPA rather than relying on the
Capacitor `appId`, which remains the Android package identifier.
The signed Android bundle is written to:
```text
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
Play Store graphics are generated in the workspace-level `playstoregraphics/` folder:
- App icon: `playstoregraphics/universal/app-icon/truck-wash-icon-512.png`
- Feature graphic: `playstoregraphics/universal/feature-graphic/truck-wash-feature-1024x500.jpg`
- Phone screenshots: `playstoregraphics/phone/screenshots/`
- 7-inch tablet screenshots: `playstoregraphics/tablet-7/screenshots/`
- 10-inch tablet screenshots: `playstoregraphics/tablet-10/screenshots/`
- Chromebook screenshots: `playstoregraphics/chromebook/screenshots/`
The native manifests declare camera and foreground location access for the store binaries. Keep the App Store Connect and Play Console privacy questionnaires aligned with the app's actual camera and location data handling before submitting a release.
+3 -6
View File
@@ -7,9 +7,6 @@ const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
CanvasImageSource: "readonly",
EventListener: "readonly",
PositionCallback: "readonly",
grecaptcha: "readonly",
};
@@ -104,12 +101,12 @@ export default [
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": ["warn", { shallowOnly: true }],
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
@@ -136,7 +133,7 @@ export default [
},
},
rules: {
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-empty": "warn",
},
},
];
-3
View File
@@ -1,3 +0,0 @@
app_identifier(ENV.fetch("IOS_BUNDLE_ID", "io.truckwash.app"))
team_id(ENV["APPLE_TEAM_ID"])
itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"]) if ENV["APP_STORE_CONNECT_TEAM_ID"]
-5
View File
@@ -1,5 +0,0 @@
app_identifier(ENV.fetch("IOS_BUNDLE_ID", "io.truckwash.app"))
metadata_path("fastlane/metadata")
screenshots_path("fastlane/screenshots")
primary_category("BUSINESS")
price_tier(0)
-55
View File
@@ -1,55 +0,0 @@
default_platform(:ios)
def app_store_api_key
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"].to_s.strip
app_store_connect_api_key(
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: issuer_id.empty? ? nil : issuer_id,
key_content: ENV.fetch("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"),
is_key_content_base64: true,
duration: 1200,
in_house: false
)
end
platform :ios do
desc "Validate and upload the signed IPA, then wait for App Store Connect processing"
lane :upload_internal do
api_key = app_store_api_key
upload_to_testflight(
api_key: api_key,
app_identifier: ENV.fetch("IOS_BUNDLE_ID"),
ipa: ENV.fetch("IOS_IPA_PATH"),
changelog: ENV.fetch("TESTFLIGHT_WHAT_TO_TEST", "Automatisk intern build fra verificeret master."),
distribute_external: false,
notify_external_testers: false,
skip_submission: true,
skip_waiting_for_build_processing: false,
wait_processing_interval: 30,
reject_build_waiting_for_review: false
)
end
desc "Create/update the Denmark App Store candidate without submitting it for review"
lane :prepare_candidate do
api_key = app_store_api_key
deliver(
api_key: api_key,
app_identifier: ENV.fetch("IOS_BUNDLE_ID"),
app_version: ENV.fetch("IOS_MARKETING_VERSION"),
build_number: ENV.fetch("IOS_BUILD_NUMBER"),
metadata_path: "fastlane/metadata",
screenshots_path: "fastlane/screenshots",
skip_binary_upload: true,
skip_metadata: false,
skip_screenshots: false,
overwrite_screenshots: true,
force: true,
submit_for_review: false,
automatic_release: false,
phased_release: false,
run_precheck_before_submit: false,
precheck_include_in_app_purchases: false
)
end
end
-1
View File
@@ -1 +0,0 @@
2026 Truck Wash ApS
-12
View File
@@ -1,12 +0,0 @@
Kundeportal til truckvask.
Start selvvask, book tid og hent dokumentation fra mobilen.
Med Truck Wash får du samlet dine truckvaske ét sted:
- Start selvvask direkte fra mobilen.
- Book vask og vælg tidspunkt.
- Se dine køretøjer og tidligere vaske.
- Find vaskecertifikater, ordrer og fakturaer.
Truck Wash gør det nemt for vognmænd, disponenter og chauffører at håndtere den daglige truckvask.
-1
View File
@@ -1 +0,0 @@
lastbilvask,truckvask,vask,booking,kundeportal,køretøjer
@@ -1 +0,0 @@
https://truckwash.io/
-1
View File
@@ -1 +0,0 @@
Truck Wash
-1
View File
@@ -1 +0,0 @@
https://truckwash.io/privacy-policy
@@ -1 +0,0 @@
Start selvvask, book tid og hent dokumentation fra mobilen.
@@ -1 +0,0 @@
Første App Store-version af Truck Wash Kundeportal.
-1
View File
@@ -1 +0,0 @@
Kundeportal til truckvask
-1
View File
@@ -1 +0,0 @@
https://truckwash.io/support
-7
View File
@@ -1,7 +0,0 @@
# App Review information
Review credentials are deliberately not stored in Git. Configure the durable,
sanitized review account directly in App Store Connect. The review notes must
explain the customer/driver login path, any 2FA bypass for that account, camera
and location denial fallbacks, QR/hardware-dependent behavior, and that any
payments cover physical truck-wash services rather than digital content.
-1
View File
@@ -1 +0,0 @@
+2 -2
View File
@@ -19,7 +19,7 @@
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<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" />
@@ -436,7 +436,7 @@
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession;
var hasStoredSession = false;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
+3 -20
View File
@@ -16,7 +16,6 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A17D5C4A2E8F000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */; };
A17D5C4A2E8F000100000003 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A17D5C4A2E8F000100000004 /* InfoPlist.strings */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -31,8 +30,6 @@
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A17D5C4A2E8F000100000005 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
A17D5C4A2E8F000100000006 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -73,7 +70,6 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
A17D5C4A2E8F000100000004 /* InfoPlist.strings */,
A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
@@ -126,7 +122,6 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
da,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
@@ -151,7 +146,6 @@
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
A17D5C4A2E8F000100000001 /* PrivacyInfo.xcprivacy in Resources */,
A17D5C4A2E8F000100000003 /* InfoPlist.strings in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
@@ -188,15 +182,6 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
A17D5C4A2E8F000100000004 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
A17D5C4A2E8F000100000005 /* en */,
A17D5C4A2E8F000100000006 /* da */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@@ -232,7 +217,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -313,7 +298,6 @@
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;
@@ -325,8 +309,8 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug;
PRODUCT_NAME = TruckWashDebug;
PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -336,7 +320,6 @@
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APP_DISPLAY_NAME = "Truck Wash";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

@@ -1,23 +1,14 @@
{
"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" }
"images" : [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info": { "author": "xcode", "version": 1 }
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+1 -3
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
<string>Truck Wash</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -24,8 +24,6 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
<key>NSLocationWhenInUseUsageDescription</key>
+1 -114
View File
@@ -5,121 +5,8 @@
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeName</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhoneNumber</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhysicalAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeUserID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePurchaseHistory</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePaymentInfo</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCustomerSupport</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
</array>
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>
-3
View File
@@ -1,3 +0,0 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash bruger kameraet til at scanne QR-koder og registreringsnumre, når du vælger en scanningsfunktion.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash bruger din placering, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling.";
-3
View File
@@ -1,3 +0,0 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash uses the camera to scan QR codes and vehicle registration plates when you choose a scanning feature.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash uses your location while the app is open to find or confirm the nearest Truck Wash department.";
-5
View File
@@ -1,5 +0,0 @@
{
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
+2378 -1414
View File
File diff suppressed because it is too large Load Diff
+6 -19
View File
@@ -1,7 +1,6 @@
{
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"web-types": "./web-types.json",
"private": true,
"type": "module",
"scripts": {
@@ -50,11 +49,6 @@
"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",
@@ -73,14 +67,11 @@
"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",
"mobile:ios:storefront:check": "node scripts/mobile/validate-app-store.mjs",
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
},
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.24.1",
"@bubblewrap/cli": "^1.23.0",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@creativebulma/bulma-badge": "^1.0.1",
@@ -100,7 +91,7 @@
"@xterm/addon-fit": "^0.11.0",
"animate.css": "^4.1.1",
"apexcharts": "^5.10.4",
"axios": "1.18.1",
"axios": "1.13.5",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
"bulma-block-list": "^1.1.0",
@@ -131,7 +122,7 @@
"vue3-apexcharts": "^1.11.1",
"vue3-cookies": "^1.0.6",
"vuex": "^4.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"xlsx": "^0.18.5",
"xterm": "^5.3.0"
},
"devDependencies": {
@@ -158,13 +149,9 @@
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "8.1.5",
"vite-plugin-pwa": "^1.3.0",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.3.0"
},
"overrides": {
"tar": "7.5.19"
"vitest": "^2.1.9"
}
}
-17
View File
@@ -1,5 +1,3 @@
DirectoryIndex index.html
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
@@ -8,21 +6,6 @@ DirectoryIndex index.html
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_headers.c>
# Fingerprinted build assets are content-addressed and safe to retain across
# atomic release switches. Mutable application shells and PWA control files
# below override this policy and must always be revalidated.
<FilesMatch "[._-][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^(?:index\.html|manifest\.json|manifest\.webmanifest|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js)$">
Header set Cache-Control "no-cache, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+40 -44
View File
@@ -1,50 +1,46 @@
#-------------------------------------------------------------------------------#
# 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.recommended
name: qodana.starter
bootstrap: npm ci --legacy-peer-deps
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
include:
- name: Eslint
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
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
#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
+2 -2
View File
@@ -9,8 +9,8 @@ const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)$/u;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
+11 -2
View File
@@ -7,8 +7,8 @@ const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\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,6 +138,15 @@ 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) => {
-285
View File
@@ -1,285 +0,0 @@
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
import { appendFileSync } from "node:fs";
import { argv, env, exit } from "node:process";
const command = argv[2];
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
const required = (name) => {
const value = env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const base64url = (value) => Buffer.from(value).toString("base64url");
const token = () => {
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
if (!key.includes("PRIVATE KEY"))
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
const now = Math.floor(Date.now() / 1000);
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
else payload.sub = "user";
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
const encodedPayload = base64url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(key),
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64url(signature)}`;
};
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const request = async (path, options = {}, attempt = 1) => {
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text };
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
return request(path, options, attempt + 1);
}
const detail =
body?.errors
?.map((error) => error.detail || error.title)
.filter(Boolean)
.join("; ") ||
body?.raw ||
response.statusText;
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
}
return body;
};
const appId = () => required("APP_STORE_CONNECT_APP_ID");
const bundleId = () => env.IOS_BUNDLE_ID || "io.truckwash.app";
const version = () => required("IOS_MARKETING_VERSION");
const buildNumber = () => required("IOS_BUILD_NUMBER");
const writeOutput = (key, value) => {
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
else console.log(`${key}=${value}`);
};
const verifyCredentials = async () => {
const app = await request(`/apps/${encodeURIComponent(appId())}`);
const actualBundleId = app?.data?.attributes?.bundleId;
if (actualBundleId !== bundleId()) {
throw new Error(
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
);
}
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[preReleaseVersion.version]": version(),
limit: "200",
});
let url = `${baseUrl}/builds?${params}`;
const builds = [];
while (url) {
const page = await request(url);
builds.push(...(page?.data ?? []));
url = page?.links?.next ?? null;
}
return builds;
};
const findExactBuild = async () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
const storeVersionParams = new URLSearchParams({
"filter[app]": appId(),
"filter[platform]": "IOS",
"filter[versionString]": version(),
limit: "10",
});
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
const storeVersion = (storeVersions?.data ?? []).find(
(candidate) => candidate?.attributes?.versionString === version()
);
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
throw new Error(
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
);
}
const builds = await allBuildsForVersion();
const numbers = builds
.map((build) => Number.parseInt(build?.attributes?.version, 10))
.filter((number) => Number.isSafeInteger(number) && number > 0);
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
writeOutput("build_number", next);
console.log(`Next App Store Connect build for ${version()} is ${next}.`);
};
const waitForBuild = async () => {
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
while (Date.now() < deadline) {
build = await findExactBuild();
const state = build?.attributes?.processingState;
if (state === "VALID") return build;
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
console.log(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleep(30_000);
}
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
};
const waitAndDistribute = async () => {
const build = await waitForBuild();
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0];
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
if (existingLocalization) {
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
method: "PATCH",
body: JSON.stringify({
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
}),
});
} else {
await request("/betaBuildLocalizations", {
method: "POST",
body: JSON.stringify({
data: {
type: "betaBuildLocalizations",
attributes: { locale: "da-DK", whatsNew },
relationships: { build: { data: { type: "builds", id: build.id } } },
},
}),
});
}
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
if (!alreadyAssigned) {
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
method: "POST",
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
});
}
writeOutput("app_store_build_id", build.id);
console.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
);
};
const verifyCandidate = async () => {
await verifyCredentials();
const build = await findExactBuild();
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
if (build.attributes?.processingState !== "VALID") {
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
}
if (env.EXPECTED_APP_STORE_BUILD_ID && build.id !== env.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", build.id);
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
};
const verifyStoreVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[platform]": "IOS",
"filter[versionString]": version(),
include: "build",
limit: "10",
});
const response = await request(`/appStoreVersions?${params}`);
const storeVersion = (response?.data ?? []).find((candidate) => candidate?.attributes?.versionString === version());
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
const includedBuild = (response?.included ?? []).find(
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
);
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
}
writeOutput("app_store_version_id", storeVersion.id);
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
console.log(
`Verified App Store version ${version()} with exact build ${buildNumber()} in ${
storeVersion.attributes?.appStoreState || "unknown state"
}.`
);
};
const selfTestJwt = async () => {
const original = {
keyId: env.APP_STORE_CONNECT_API_KEY_ID,
issuer: env.APP_STORE_CONNECT_ISSUER_ID,
key: env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64,
};
try {
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
privateKey.export({ type: "pkcs8", format: "pem" })
).toString("base64");
delete env.APP_STORE_CONNECT_ISSUER_ID;
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
if (individual.sub !== "user" || individual.iss !== undefined)
throw new Error("Individual API JWT claim test failed");
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
throw new Error("Team API JWT claim test failed");
console.log("App Store Connect individual and team JWT claim tests passed.");
} finally {
if (original.keyId === undefined) delete env.APP_STORE_CONNECT_API_KEY_ID;
else env.APP_STORE_CONNECT_API_KEY_ID = original.keyId;
if (original.issuer === undefined) delete env.APP_STORE_CONNECT_ISSUER_ID;
else env.APP_STORE_CONNECT_ISSUER_ID = original.issuer;
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
}
};
const commands = {
"verify-credentials": verifyCredentials,
"next-build-number": nextBuildNumber,
"wait-and-distribute": waitAndDistribute,
"verify-candidate": verifyCandidate,
"verify-store-version": verifyStoreVersion,
"self-test-jwt": selfTestJwt,
};
if (!commands[command]) {
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
exit(2);
}
commands[command]().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
@@ -1,93 +0,0 @@
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.");
+8 -28
View File
@@ -21,20 +21,11 @@ const decodeBase64 = (name) => {
return null;
}
try {
const encoded = env[name].replace(/\s/g, "");
if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
failures.push(`${name} is not valid base64`);
return null;
}
const decoded = Buffer.from(encoded, "base64");
const decoded = Buffer.from(env[name], "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
if (decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
failures.push(`${name} is not canonical base64`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
@@ -111,32 +102,21 @@ const checkAndroid = () => {
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_PASSWORD");
requireVariable("IOS_APP_STORE_PROFILE_BASE64");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("APPLE_TEAM_ID");
requireVariable("IOS_BUNDLE_ID");
if (env.MOBILE_VERSION_NAME && !/^\d+\.\d+\.\d+$/.test(env.MOBILE_VERSION_NAME)) {
failures.push("MOBILE_VERSION_NAME must be numeric SemVer (X.Y.Z)");
}
if (env.MOBILE_VERSION_CODE && !/^[1-9][0-9]*$/.test(env.MOBILE_VERSION_CODE)) {
failures.push("MOBILE_VERSION_CODE must be a positive integer");
}
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");
}
decodeBase64("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
decodeBase64("IOS_APP_STORE_PROFILE_BASE64");
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_APP_ID");
requireVariable("TESTFLIGHT_INTERNAL_GROUP_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");
@@ -1,36 +0,0 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { env, exit } from "node:process";
const required = (name) => {
if (!env[name]) throw new Error(`Missing ${name}`);
return env[name];
};
try {
const ipaPath = required("IOS_IPA_PATH");
const output = env.IOS_RELEASE_MANIFEST_PATH || "output/ios-release/ios-release-manifest.json";
const manifest = {
schemaVersion: 1,
repository: required("GITHUB_REPOSITORY"),
sourceSha: required("IOS_SOURCE_SHA").toLowerCase(),
marketingVersion: required("IOS_MARKETING_VERSION"),
buildNumber: required("IOS_BUILD_NUMBER"),
appStoreBuildId: required("APP_STORE_BUILD_ID"),
appStoreConnectAppId: required("APP_STORE_CONNECT_APP_ID"),
bundleId: required("IOS_BUNDLE_ID"),
xcodeVersion: required("XCODE_VERSION"),
sdkVersion: required("IOS_SDK_VERSION"),
workflowRunId: required("GITHUB_RUN_ID"),
workflowRunAttempt: required("GITHUB_RUN_ATTEMPT"),
ipaSha256: createHash("sha256").update(readFileSync(ipaPath)).digest("hex"),
createdAt: new Date().toISOString(),
};
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
console.log(`Created ${output}.`);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
exit(1);
}
+7 -10
View File
@@ -5,12 +5,9 @@ import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(
projectRoot,
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
);
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#FFFFFF";
const launcherBackground = "#0787BB";
const densityScale = {
mdpi: 1,
@@ -21,9 +18,9 @@ const densityScale = {
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512 },
{ relativePath: "public/icons/icon-512x512.png", size: 512, copySource: true },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512 },
{ relativePath: "store_icon.png", size: 512, copySource: true },
];
for (const [density, scale] of Object.entries(densityScale)) {
@@ -93,14 +90,14 @@ async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = await renderPng(sourceImage, target.size);
const buffer = target.copySource ? sourceBuffer : await renderPng(sourceImage, target.size);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
-856
View File
@@ -1,856 +0,0 @@
#!/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();
}
-165
View File
@@ -1,165 +0,0 @@
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, extname, join, relative } from "node:path";
import { argv, exit } from "node:process";
const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
const readText = (path) => {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
return "";
}
const value = readFileSync(path, "utf8").trim();
if (!value) fail(`${relative(root, path)} must not be empty`);
return value;
};
let release;
try {
release = JSON.parse(readFileSync(join(root, "ios/release.json"), "utf8"));
} catch {
fail("ios/release.json must be valid JSON");
release = {};
}
if (!/^\d+\.\d+\.\d+$/.test(release.marketingVersion ?? "")) {
fail("ios/release.json marketingVersion must be numeric SemVer (X.Y.Z)");
}
if (release.bundleId !== "io.truckwash.app") fail("ios/release.json bundleId must be io.truckwash.app");
if (release.minimumIosVersion !== "15.0")
fail("ios/release.json minimumIosVersion must remain 15.0 unless compatibility is intentionally changed");
const name = readText(join(metadataRoot, "name.txt"));
const subtitle = readText(join(metadataRoot, "subtitle.txt"));
const promotionalText = readText(join(metadataRoot, "promotional_text.txt"));
const keywords = readText(join(metadataRoot, "keywords.txt"));
const description = readText(join(metadataRoot, "description.txt"));
readText(join(metadataRoot, "release_notes.txt"));
readText(join(root, "fastlane/metadata/copyright.txt"));
if ([...name].length > 30) fail("App Store name exceeds 30 characters");
if ([...subtitle].length > 30) fail("App Store subtitle exceeds 30 characters");
if ([...promotionalText].length > 170) fail("Promotional text exceeds 170 characters");
if (Buffer.byteLength(keywords, "utf8") > 100) fail("Keywords exceed Apple's 100-byte limit");
if ([...description].length > 4000) fail("Description exceeds 4,000 characters");
for (const file of ["support_url.txt", "privacy_url.txt", "marketing_url.txt"]) {
const value = readText(join(metadataRoot, file));
try {
const url = new URL(value);
if (url.protocol !== "https:") fail(`${file} must use HTTPS`);
} catch {
fail(`${file} must contain a valid URL`);
}
}
const knownCapacitorArtwork = new Set([
"29e4777e319de3ee5a52c3a8004ec19d0568414004257e36d7c94a077d71c93b",
"1b5002b74a5500e697298ced06ca2811ac33f2771f236f3c720ff23243890530",
]);
for (const path of [
join(root, "ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"),
join(root, "ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png"),
]) {
if (!existsSync(path)) {
fail(`Missing ${relative(root, path)}`);
continue;
}
const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
if (knownCapacitorArtwork.has(digest)) {
fail(`${relative(root, path)} is still the default Capacitor artwork`);
}
}
const pngInfo = (buffer) => {
if (buffer.length < 33 || buffer.subarray(1, 4).toString("ascii") !== "PNG") return null;
const colorType = buffer[25];
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
hasAlpha: colorType === 4 || colorType === 6,
};
};
const jpegInfo = (buffer) => {
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker === 0xd8 || marker === 0xd9) {
offset += 2;
continue;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > buffer.length) break;
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5), hasAlpha: false };
}
offset += 2 + length;
}
return null;
};
const screenshots = existsSync(screenshotRoot)
? readdirSync(screenshotRoot)
.filter((file) => [".png", ".jpg", ".jpeg"].includes(extname(file).toLowerCase()))
.map((file) => join(screenshotRoot, file))
: [];
const groups = { iphone: [], ipad: [] };
const hashes = new Map();
for (const path of screenshots) {
const filename = basename(path);
const group = filename.startsWith("iphone-6.9-") ? "iphone" : filename.startsWith("ipad-13-") ? "ipad" : null;
if (!group) {
fail(`${filename} must start with iphone-6.9- or ipad-13-`);
continue;
}
if (statSync(path).size === 0) {
fail(`${filename} is empty`);
continue;
}
const buffer = readFileSync(path);
const info = pngInfo(buffer) ?? jpegInfo(buffer);
if (!info) {
fail(`${filename} is not a readable PNG or JPEG`);
continue;
}
const expected = group === "iphone" ? [1320, 2868] : [2064, 2752];
if (info.width !== expected[0] || info.height !== expected[1]) {
fail(`${filename} is ${info.width}x${info.height}; expected ${expected[0]}x${expected[1]}`);
}
if (info.hasAlpha) fail(`${filename} has an alpha channel, which App Store screenshots must not use`);
const digest = createHash("sha256").update(buffer).digest("hex");
if (hashes.has(digest)) fail(`${filename} duplicates ${hashes.get(digest)}`);
hashes.set(digest, filename);
groups[group].push(filename);
}
for (const [group, files] of Object.entries(groups)) {
if (files.length !== 6) {
const message = `Expected 6 ${group === "iphone" ? "iPhone 6.9-inch" : "iPad 13-inch"} screenshots; found ${
files.length
}`;
if (strict) fail(message);
else warn(message);
}
}
for (const message of warnings) console.warn(`Storefront readiness warning: ${message}`);
if (failures.length > 0) {
console.error("App Store validation failed:");
for (const message of failures) console.error(`- ${message}`);
exit(1);
}
console.log(`App Store metadata is valid${strict ? " and candidate assets are complete" : ""}.`);
-27
View File
@@ -108,33 +108,6 @@ 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: [
-399
View File
@@ -1,399 +0,0 @@
#!/bin/sh
set -u
umask 077
script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
activation_root=${CPANEL_ACTIVATION_ROOT:-$script_directory}
activation_key_file=${CPANEL_ACTIVATION_KEY_FILE:-/etc/pleno-release-activator/truckwash.key}
case "$activation_root" in
""|"/") exit 1 ;;
esac
if [ ! -f "$activation_key_file" ]; then
exit 1
fi
IFS= read -r activation_key <"$activation_key_file" || [ -n "$activation_key" ] || exit 1
sha256_value_pending=$activation_key
if ! printf '%s\n' "$sha256_value_pending" | grep -Eq '^[a-f0-9]{64}$'; then
exit 1
fi
requests_directory="$activation_root/activation-requests"
results_directory="$activation_root/activation-results"
processed_directory="$requests_directory/processed"
state_directory="$activation_root/activation-state"
mkdir -p -- \
"$requests_directory" \
"$results_directory" \
"$processed_directory" \
"$state_directory" \
"$activation_root/archives" \
"$activation_root/releases" \
"$activation_root/staging" || exit 1
safe_component() {
printf '%s\n' "$1" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,179}$'
}
full_sha() {
printf '%s\n' "$1" | grep -Eq '^[a-f0-9]{40}$'
}
sha256_value() {
printf '%s\n' "$1" | grep -Eq '^[a-f0-9]{64}$'
}
decimal_timestamp() {
printf '%s\n' "$1" | grep -Eq '^[0-9]{10,12}$'
}
valid_target() {
candidate_target=$1
candidate_release=${candidate_target#releases/}
candidate_release=${candidate_release%/dist}
[ "$candidate_target" = "releases/$candidate_release/dist" ] &&
safe_component "$candidate_release"
}
target_identity() {
identity_target=$1
identity_release=${identity_target#releases/}
identity_release=${identity_release%/dist}
identity_commit=${identity_release%%-*}
identity_build=${identity_release#*-}
full_sha "$identity_commit" &&
[ "$identity_build" != "$identity_release" ] &&
safe_component "$identity_build"
}
validate_release() {
validated_target=$1
valid_target "$validated_target" || return 1
target_identity "$validated_target" || return 1
validated_dist="$activation_root/$validated_target"
[ -f "$validated_dist/index.html" ] &&
[ -f "$validated_dist/.htaccess" ] &&
[ -f "$validated_dist/release-manifest.json" ] &&
[ -f "$validated_dist/release-entry.json" ] &&
grep -Eq '^DirectoryIndex[[:space:]]+index\.html([[:space:]]|$)' "$validated_dist/.htaccess" &&
jq -e --arg commit "$identity_commit" --arg build "$identity_build" \
'.commit_sha == $commit and .build_id == $build' \
"$validated_dist/release-manifest.json" >/dev/null
}
write_result() {
result_status=$1
result_target=$2
result_message=$3
result_part="$results_directory/$request_id.result.part"
result_path="$results_directory/$request_id.result"
{
printf 'schema_version=1\n'
printf 'request_id=%s\n' "$request_id"
printf 'status=%s\n' "$result_status"
printf 'target=%s\n' "$result_target"
printf 'message=%s\n' "$result_message"
} >"$result_part" && mv -Tf -- "$result_part" "$result_path"
}
write_state() {
state_phase=$1
state_part="$state_directory/$request_id.state.part"
{
printf 'schema_version=1\n'
printf 'request_id=%s\n' "$request_id"
printf 'previous_target=%s\n' "$previous_target"
printf 'target=%s\n' "$target"
printf 'phase=%s\n' "$state_phase"
} >"$state_part" && mv -Tf -- "$state_part" "$state_path"
}
finish_request() {
final_status=$1
final_message=$2
write_result "$final_status" "$target" "$final_message" || exit 1
mv -Tf -- "$processing_path" "$processed_directory/$request_id.processed" || exit 1
[ "$final_status" = "success" ]
}
restore_previous() {
rollback_link="$activation_root/current.$request_id.rollback"
[ "$previous_target" != "$target" ] || return 1
validate_release "$previous_target" || return 1
if [ -L "$rollback_link" ]; then
[ "$(readlink "$rollback_link")" = "$previous_target" ] || return 1
elif [ -e "$rollback_link" ]; then
return 1
else
ln -s "$previous_target" "$rollback_link" || return 1
fi
mv -Tf -- "$rollback_link" "$activation_root/current" &&
[ "$(readlink "$activation_root/current")" = "$previous_target" ]
}
set -- "$requests_directory"/*.processing
if [ -e "$1" ]; then
processing_path=$1
request_name=${processing_path##*/}
request_id=${request_name%.processing}
else
set -- "$requests_directory"/*.request
[ -e "$1" ] || exit 0
request_path=$1
request_name=${request_path##*/}
request_id=${request_name%.request}
processing_path="$requests_directory/$request_id.processing"
if ! safe_component "$request_id" || [ -e "$processing_path" ]; then
exit 1
fi
mv -T -- "$request_path" "$processing_path" || exit 1
fi
if ! safe_component "$request_id"; then
exit 1
fi
schema_version=
parsed_request_id=
action=
release_id=
commit_sha=
build_id=
archive_name=
archive_sha256=
expires_at=
request_hmac=
parse_error=0
while IFS='=' read -r key value; do
case "$key" in
schema_version) [ -z "$schema_version" ] && schema_version=$value || parse_error=1 ;;
request_id) [ -z "$parsed_request_id" ] && parsed_request_id=$value || parse_error=1 ;;
action) [ -z "$action" ] && action=$value || parse_error=1 ;;
release_id) [ -z "$release_id" ] && release_id=$value || parse_error=1 ;;
commit_sha) [ -z "$commit_sha" ] && commit_sha=$value || parse_error=1 ;;
build_id) [ -z "$build_id" ] && build_id=$value || parse_error=1 ;;
archive_name) [ -z "$archive_name" ] && archive_name=$value || parse_error=1 ;;
archive_sha256) [ -z "$archive_sha256" ] && archive_sha256=$value || parse_error=1 ;;
expires_at) [ -z "$expires_at" ] && expires_at=$value || parse_error=1 ;;
request_hmac) [ -z "$request_hmac" ] && request_hmac=$value || parse_error=1 ;;
*) parse_error=1 ;;
esac
done <"$processing_path"
target="invalid"
activation_step=request_validation
activation_ok=0
expected_hmac=$(
{
printf 'schema_version=%s\n' "$schema_version"
printf 'request_id=%s\n' "$parsed_request_id"
printf 'action=%s\n' "$action"
printf 'release_id=%s\n' "$release_id"
printf 'commit_sha=%s\n' "$commit_sha"
printf 'build_id=%s\n' "$build_id"
printf 'archive_name=%s\n' "$archive_name"
printf 'archive_sha256=%s\n' "$archive_sha256"
printf 'expires_at=%s\n' "$expires_at"
} | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$activation_key" 2>/dev/null | awk '{print $NF}'
)
if [ "$parse_error" -eq 0 ] &&
[ "$schema_version" = "1" ] &&
[ "$parsed_request_id" = "$request_id" ] &&
safe_component "$release_id" &&
[ "$release_id" = "$commit_sha-$build_id" ] &&
full_sha "$commit_sha" &&
safe_component "$build_id" &&
decimal_timestamp "$expires_at" &&
sha256_value "$request_hmac" &&
[ "$request_hmac" = "$expected_hmac" ] &&
{ [ "$action" = "stage" ] || [ "$action" = "switch" ]; }; then
target="releases/$release_id/dist"
activation_ok=1
fi
state_path="$state_directory/$request_id.state"
previous_target=
state_phase=
if [ "$activation_ok" -eq 1 ] && [ -f "$state_path" ]; then
state_schema=
state_request_id=
state_previous_target=
state_target=
state_parse_error=0
while IFS='=' read -r key value; do
case "$key" in
schema_version) [ -z "$state_schema" ] && state_schema=$value || state_parse_error=1 ;;
request_id) [ -z "$state_request_id" ] && state_request_id=$value || state_parse_error=1 ;;
previous_target) [ -z "$state_previous_target" ] && state_previous_target=$value || state_parse_error=1 ;;
target) [ -z "$state_target" ] && state_target=$value || state_parse_error=1 ;;
phase) [ -z "$state_phase" ] && state_phase=$value || state_parse_error=1 ;;
*) state_parse_error=1 ;;
esac
done <"$state_path"
if [ "$state_parse_error" -ne 0 ] ||
[ "$state_schema" != "1" ] ||
[ "$state_request_id" != "$request_id" ] ||
[ "$state_target" != "$target" ] ||
! valid_target "$state_previous_target" ||
{ [ "$state_phase" != "prepared" ] && [ "$state_phase" != "activated" ]; }; then
activation_step=state_validation
activation_ok=0
else
previous_target=$state_previous_target
fi
fi
if [ "$activation_ok" -eq 1 ] && [ -z "$previous_target" ]; then
activation_step=current_validation
if [ ! -L "$activation_root/current" ]; then
activation_ok=0
else
previous_target=$(readlink "$activation_root/current")
if ! validate_release "$previous_target" || ! write_state prepared; then
activation_ok=0
else
state_phase=prepared
fi
fi
fi
if [ "$activation_ok" -eq 1 ] && [ "$(readlink "$activation_root/current" 2>/dev/null || true)" = "$target" ]; then
if validate_release "$target"; then
write_state activated || exit 1
finish_request success activated
exit $?
fi
if restore_previous; then
finish_request failure post_activation_validation
else
finish_request failure rollback_failed
fi
exit $?
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=request_expired
current_epoch=$(date +%s)
if [ "$expires_at" -lt "$current_epoch" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=atomic_preflight
probe="$activation_root/staging/.activation-preflight-$request_id"
if [ -e "$probe" ] || [ -L "$probe" ]; then
activation_ok=0
elif mkdir -p -- "$probe/first" "$probe/second" &&
: >"$probe/first/first-marker" &&
: >"$probe/second/second-marker" &&
ln -s first "$probe/current" &&
ln -s second "$probe/current.next" &&
mv -Tf -- "$probe/current.next" "$probe/current" &&
[ -f "$probe/current/second-marker" ] &&
[ ! -e "$probe/current/first-marker" ]; then
if ! rm -rf -- "$probe"; then
activation_ok=0
fi
else
activation_ok=0
fi
fi
release_root="$activation_root/releases/$release_id"
release_dist="$release_root/dist"
if [ "$activation_ok" -eq 1 ] && [ "$action" = "stage" ]; then
activation_step=archive_validation
if ! safe_component "$archive_name" ||
! sha256_value "$archive_sha256" ||
[ "${archive_name##*.}" != "zip" ]; then
activation_ok=0
fi
archive_path="$activation_root/archives/$archive_name"
staging_root="$activation_root/staging/$release_id.$request_id.pending"
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_already_exists
if [ -e "$release_root" ] || [ -L "$release_root" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=archive_extraction
if [ -e "$staging_root" ] || [ -L "$staging_root" ] ||
[ ! -f "$archive_path" ] ||
[ "$(sha256sum "$archive_path" | awk '{print $1}')" != "$archive_sha256" ] ||
! mkdir -p -- "$staging_root" ||
! unzip -q "$archive_path" -d "$staging_root"; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_validation
if [ -f "$staging_root/dist/index.html" ] &&
[ -f "$staging_root/dist/.htaccess" ] &&
[ -f "$staging_root/dist/release-manifest.json" ] &&
[ -f "$staging_root/dist/release-entry.json" ] &&
grep -Eq '^DirectoryIndex[[:space:]]+index\.html([[:space:]]|$)' "$staging_root/dist/.htaccess" &&
jq -e --arg commit "$commit_sha" --arg build "$build_id" \
'.commit_sha == $commit and .build_id == $build' \
"$staging_root/dist/release-manifest.json" >/dev/null &&
mv -T -- "$staging_root" "$release_root"; then
chmod 0755 "$release_dist"
else
activation_ok=0
fi
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=release_validation
if ! validate_release "$target"; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=request_expired
current_epoch=$(date +%s)
if [ "$expires_at" -lt "$current_epoch" ]; then
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
activation_step=atomic_activation
next_link="$activation_root/current.$request_id.next"
if [ -e "$next_link" ] || [ -L "$next_link" ]; then
activation_ok=0
elif ln -s "$target" "$next_link" &&
mv -Tf -- "$next_link" "$activation_root/current"; then
write_state activated || true
if [ "$(readlink "$activation_root/current")" = "$target" ] &&
validate_release "$target"; then
activation_ok=1
else
activation_ok=0
activation_step=post_activation_validation
if ! restore_previous; then
activation_step=rollback_failed
fi
fi
else
activation_ok=0
fi
fi
if [ "$activation_ok" -eq 1 ]; then
finish_request success activated
else
finish_request failure "$activation_step"
fi
File diff suppressed because it is too large Load Diff

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