Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 24f2f3ba3f Merge master into format app icon test fix 2026-07-20 18:50:43 +02:00
Jeppe Bundgaard 9263c249b2 Make app icon alpha check efficient 2026-07-20 18:21:03 +02:00
Jeppe B 1a959c2ce8 Activate cPanel releases with a pinned account runner (#193)
Replace unsafe legacy Fileman symlink activation with an authenticated, root-owned account runner and crash-safe pointer reconciliation.
2026-07-20 18:20:03 +02:00
Jeppe Bundgaard 56235e1636 Format app icon background unit test 2026-07-20 18:11:46 +02:00
Jeppe B 88eda43560 Automate signed iOS App Store releases (#192)
## What changed

- adds production iOS identity, localized storefront metadata, native
privacy declarations, App Store-safe artwork, and account-deletion UX
- mirrors the live Danish Google Play title, short description, and long
description in the App Store metadata source
- generates Android launcher/store icons from the opaque iOS marketing
master so both platforms use the same white background
- adds guarded GitHub Actions workflows for storefront readiness,
credential health, signed TestFlight uploads, and App Store candidate
preparation
- adds pinned Fastlane configuration with a committed dependency lock,
release manifest tooling, and an operational App Store runbook
- preserves the upstream iOS safe-area implementation while retaining
opaque App Store icon assets

## Why

The repository previously supported development-signed device bundles
but had no production App Store identity, reproducible storefront source
of truth, or protected signed-release pipeline. Apple also requires
in-app account deletion for apps that support account creation. The
Android icon master was transparent, which rendered as black on dark
store/device surfaces.

## Impact

Automation remains fail-closed behind
`APP_STORE_AUTOMATION_ENABLED=false`. No build can upload to TestFlight
or change App Store metadata until the switch is deliberately enabled
after merge and the remaining release gates are satisfied.

## Validation

- focused App Store, iOS icon, and cross-platform icon-background tests
pass
- every generated Android store/launcher icon is opaque with pure-white
corners; iOS marketing artwork is checked the same way
- Android icon drift check passes for all 19 generated files
- production Vite build and the broader focused release checks completed
successfully
- storefront metadata is valid; only the two expected screenshot-set
warnings remain
- App Store Readiness is green at head `4445fecc`
- Apple Distribution certificate and App Store profile were
independently verified for `HP3FJ4GVL7.io.truckwash.app`
- live App Store Connect API authentication succeeded for app
`6792777794`
- App Store record, free Denmark-only availability, and automatic
`Internal QA` TestFlight group are configured
- EU trader status, Content Rights, 4+ age rating, and the published App
Privacy label are completed in App Store Connect
- iPhone and iPad accessibility declarations are configured honestly as
pre-release drafts

## Remaining external gates

- reviewed iPhone and iPad screenshot sets are still required
- an App Review login must be supplied without creating or exposing
customer credentials
- the first signed TestFlight candidate must run after merge and
deliberate automation enablement
2026-07-20 17:59:43 +02:00
Jeppe B 133e53cfa6 Fix iPhone mobile header safe-area spacing
Add iOS safe-area viewport support and inset-aware mobile header/spacer
sizing so controls clear the notch or Dynamic Island. Desktop and tablet
headers remain unchanged. Includes focused unit and mobile browser
regression coverage.
2026-07-20 16:16:56 +02:00
121 changed files with 5123 additions and 776 deletions
+21
View File
@@ -0,0 +1,21 @@
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]
+72
View File
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,179 @@
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
@@ -0,0 +1,117 @@
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"
+423
View File
@@ -0,0 +1,423 @@
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."
+14 -279
View File
@@ -1,4 +1,4 @@
name: Mobile Store Artifacts
name: Android Store Artifacts
on:
workflow_dispatch:
@@ -16,11 +16,6 @@ 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
@@ -56,7 +51,7 @@ permissions:
contents: read
concurrency:
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
@@ -79,9 +74,10 @@ jobs:
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false
- name: Guard current master release
id: release-guard
@@ -91,12 +87,16 @@ 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="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
@@ -113,21 +113,21 @@ jobs:
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: 21
- name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # 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@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ env.ANDROID_AAB_PATH }}
@@ -217,268 +217,3 @@ 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
+52 -9
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Check release commit is current
id: branch-head
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ github.token }}
script: |
@@ -60,7 +60,7 @@ jobs:
- name: Checkout tested commit
if: steps.branch-head.outputs.current == 'true'
uses: actions/checkout@v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
@@ -68,7 +68,7 @@ jobs:
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
@@ -147,7 +147,7 @@ jobs:
- name: Upload release package
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.package-names.outputs.artifact_name }}
path: |
@@ -183,14 +183,14 @@ jobs:
RELEASE_POLL_INTERVAL_SECONDS: 5
steps:
- name: Checkout tested commit
uses: actions/checkout@v5
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@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
@@ -217,7 +217,7 @@ jobs:
run: node scripts/install-playwright-browsers.mjs chromium
- name: Download validated release package
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ needs.build-release.outputs.artifact_name }}
path: release-artifacts
@@ -243,7 +243,7 @@ jobs:
- name: Check release commit is still current
id: branch-head
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ github.token }}
script: |
@@ -272,6 +272,7 @@ jobs:
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 }}
@@ -312,6 +313,7 @@ jobs:
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 }}
@@ -341,10 +343,51 @@ 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@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
+4
View File
@@ -0,0 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.229.1"
+235
View File
@@ -0,0 +1,235 @@
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 -2
View File
@@ -170,10 +170,11 @@ 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 source image for the native launcher icon is:
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:
```text
public/favicons/web-app-manifest-512x512.png
ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
```
Regenerate the checked-in launcher assets after changing that source image:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0787BB</color>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
+181 -80
View File
@@ -1,102 +1,203 @@
# Apple App Store Release Runbook
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
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.
## Account And App Record
## Storefront 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.
Create or reconcile one App Store Connect record:
## Build And Upload
| 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 |
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.
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.
The same workflow also runs automatically after a successful `Automated Tests`
run on current `master`. It skips stale workflow-run commits if `master` has
advanced before the mobile jobs start.
The Account Holder or Admin must complete these console-only items before the
first candidate:
The iOS workflow expects these environment secrets:
- 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.
- `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`
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`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The workflow installs the signing certificate and provisioning profile in a
temporary keychain on the `macos-15` runner, archives the Capacitor Xcode
project, exports an App Store IPA, validates it with `xcrun altool`, uploads it
with the App Store Connect API key, and removes temporary signing assets in the
cleanup step.
Environment variables:
## Product Page Defaults
- `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`
- 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.
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.
Use real iOS simulator or device screenshots. Provide at least:
## Enablement and first canary
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
Keep `APP_STORE_AUTOMATION_ENABLED=false` while configuring Apple/GitHub state.
Then:
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.
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.
## Privacy And Review Notes
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.
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.
## Continuous TestFlight delivery
Review notes must include:
`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`.
- 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.
`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.
## TestFlight And Release
Outputs include:
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.
- 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.
+54 -25
View File
@@ -20,13 +20,15 @@ workflow succeeds for a push to `master` in this repository. It then:
`master` immediately before deployment.
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
uploaded `.part` files are downloaded and hashed before they are renamed.
9. Uses the cPanel Fileman API to extract into a new inactive release. The
extracted tree is downloaded and compared byte-for-byte with the validated
inventory, then `master` is checked again through the read-only workflow
token.
10. Replaces the `current` symlink with a single server-side rename. Public
manifest, asset-integrity, cache-header, API-ping, and role gates run after
activation. A failed public or role gate restores the previous symlink.
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.
@@ -43,6 +45,7 @@ Add these environment **secrets**:
- `PRODUCTION_FTP_USER`
- `PRODUCTION_FTP_PASSWORD`
- `PRODUCTION_FTP_PATH`
- `PRODUCTION_ACTIVATION_KEY`
- `PRODUCTION_CPANEL_USER`
- `PRODUCTION_CPANEL_API_TOKEN`
@@ -53,9 +56,10 @@ deployment unchanged.
The cPanel token is separate from the FTP password. Create it in cPanel under
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
uses cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI
replacement for the required extract, symlink, and rename operations. Revoke
and rotate the token if it is ever exposed.
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**:
@@ -103,11 +107,10 @@ separately; do not reuse the FTP password as an API token.
again after leaving the page.
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
the GitHub secret has been saved.
7. Before merging, run the deployment preflight against the configured API
origin. It must be able to call cPanel API2 `Fileman::fileop` for extract,
symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider
restricts those operations, request the required account feature access;
do not broaden the token or deployment root beyond this cPanel account.
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
@@ -121,10 +124,11 @@ environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. GitHub-hosted deploy runners install
`lftp` and Playwright Chromium during the job; the existing self-hosted build
runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and
`sha256sum`.
`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
@@ -169,8 +173,31 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
and the API health request work at `PRODUCTION_FRONTEND_URL`.
8. Test the cPanel token against the exact host and port. The workflow performs
a disposable symlink-replacement preflight and refuses deployment if the
filesystem or hosting policy cannot replace a symlink atomically.
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
@@ -190,7 +217,8 @@ HTTP checks remain the source of truth for service health. The audit fails
closed if any domain record lacks an identity or document root, and restore is
blocked while an addon or subdomain is rooted below `public_html`.
If the regression followed the one-time webroot exchange, select `restore`
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
@@ -198,9 +226,10 @@ typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
exact token string from that audit. The token is an optimistic-concurrency
guard over the cPanel metadata visible to the audit; it is not a content hash
or a substitute for validating the selected recovery. Restore also rejects an
unreadable physical directory. An unreadable root is eligible only when the
independent account-home listing identifies it as a symbolic link. It renames
the current entry to a run-specific `public_html.failed-*` path, restores the retained entry, and
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
+37 -84
View File
@@ -1,35 +1,20 @@
# Mobile Store Artifacts
# Mobile Store Delivery
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 and iOS delivery are intentionally independent. An iOS release or tag
must never publish an Android production artifact.
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
## Triggers
`Android Store Artifacts` remains in
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
`io.truckwash.twa` and supports:
- 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.
- 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.
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:
The Android job continues using GitHub environment `mobile-store-production`.
Its required secrets are:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
@@ -37,75 +22,43 @@ Android:
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
iOS:
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_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
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 uses three separate workflows:
## Local Checks
- `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.
Run the native permission validation after changing Capacitor, native manifests, or store metadata:
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:
```sh
npm run mobile:permissions:check
npm run mobile:ios:storefront:check
```
Build a local unsigned Android App Bundle for packaging verification:
Strict candidate asset check:
```sh
npm run mobile:android:bundle:unsigned
npm run mobile:ios:storefront:check-strict
```
Build a signed Play Console upload bundle after exporting the Android upload-key variables:
Version identity is deliberately different between platforms:
```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
```
- Android package: `io.truckwash.twa`
- iOS App Store bundle: `io.truckwash.app`
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.
The iOS release build verifies the final signed IPA rather than relying on the
Capacitor `appId`, which remains the Android package identifier.
+3
View File
@@ -0,0 +1,3 @@
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
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,55 @@
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
@@ -0,0 +1 @@
2026 Truck Wash ApS
+12
View File
@@ -0,0 +1,12 @@
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
@@ -0,0 +1 @@
lastbilvask,truckvask,vask,booking,kundeportal,køretøjer
@@ -0,0 +1 @@
https://truckwash.io/
+1
View File
@@ -0,0 +1 @@
Truck Wash
+1
View File
@@ -0,0 +1 @@
https://truckwash.io/privacy-policy
@@ -0,0 +1 @@
Start selvvask, book tid og hent dokumentation fra mobilen.
@@ -0,0 +1 @@
Første App Store-version af Truck Wash Kundeportal.
+1
View File
@@ -0,0 +1 @@
Kundeportal til truckvask
+1
View File
@@ -0,0 +1 @@
https://truckwash.io/support
+7
View File
@@ -0,0 +1,7 @@
# 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
@@ -0,0 +1 @@
+1 -1
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">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<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" />
+15
View File
@@ -16,6 +16,7 @@
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 */
@@ -30,6 +31,8 @@
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 */
@@ -70,6 +73,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
A17D5C4A2E8F000100000004 /* InfoPlist.strings */,
A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
@@ -122,6 +126,7 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
da,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
@@ -146,6 +151,7 @@
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 */,
@@ -182,6 +188,15 @@
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 */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 249 KiB

+2
View File
@@ -24,6 +24,8 @@
<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>
+114 -1
View File
@@ -5,8 +5,121 @@
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<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>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
"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
@@ -0,0 +1,3 @@
"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
@@ -0,0 +1,5 @@
{
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
+2
View File
@@ -74,6 +74,8 @@
"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": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+285
View File
@@ -0,0 +1,285 @@
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);
});
+28 -8
View File
@@ -21,11 +21,20 @@ const decodeBase64 = (name) => {
return null;
}
try {
const decoded = Buffer.from(env[name], "base64");
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");
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`);
@@ -102,21 +111,32 @@ const checkAndroid = () => {
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_PASSWORD");
requireVariable("IOS_APP_STORE_PROFILE_BASE64");
requireVariable("APPLE_TEAM_ID");
requireVariable("IOS_BUNDLE_ID");
decodeBase64("IOS_CERTIFICATE_BASE64");
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
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");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_ISSUER_ID");
requireVariable("APP_STORE_CONNECT_APP_ID");
requireVariable("TESTFLIGHT_INTERNAL_GROUP_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
@@ -0,0 +1,36 @@
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);
}
+10 -7
View File
@@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const sourcePath = path.join(
projectRoot,
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
);
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#0787BB";
const launcherBackground = "#FFFFFF";
const densityScale = {
mdpi: 1,
@@ -18,9 +21,9 @@ const densityScale = {
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512, copySource: true },
{ relativePath: "public/icons/icon-512x512.png", size: 512 },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512, copySource: true },
{ relativePath: "store_icon.png", size: 512 },
];
for (const [density, scale] of Object.entries(densityScale)) {
@@ -90,14 +93,14 @@ async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = target.copySource ? sourceBuffer : await renderPng(sourceImage, target.size);
const buffer = await renderPng(sourceImage, target.size);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
+165
View File
@@ -0,0 +1,165 @@
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" : ""}.`);
+399
View File
@@ -0,0 +1,399 @@
#!/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
+188 -191
View File
@@ -17,6 +17,7 @@ const REQUIRED_ENV = [
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
@@ -27,6 +28,11 @@ const REQUIRED_ENV = [
"RELEASE_EXPECTED_BUILD_ID",
];
const ROLLBACK_REQUIRED_ENV = [
"PRODUCTION_FTP_HOST",
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
@@ -157,11 +163,22 @@ export function readDeploymentConfig(env = process.env, options = {}) {
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
};
const host = requireString(env, "PRODUCTION_FTP_HOST");
if (!SAFE_HOST.test(host) || host.includes("..")) {
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
}
const ftp = {
host,
user: requireString(env, "PRODUCTION_FTP_USER"),
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
root: normalizeFtpRoot(requireString(env, "PRODUCTION_FTP_PATH")),
};
const activationKey = requireString(env, "PRODUCTION_ACTIVATION_KEY").toLowerCase();
if (!SHA256.test(activationKey)) {
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
}
if (rollbackOnly) {
return {
ftp: { host: "", user: "", password: "", root: "" },
cpanel,
};
return { ftp, cpanel, activationKey };
}
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
@@ -172,10 +189,6 @@ export function readDeploymentConfig(env = process.env, options = {}) {
throw new DeploymentError("RELEASE_ARCHIVE_SHA256_PATH contains unsupported control characters.");
}
const host = requireString(env, "PRODUCTION_FTP_HOST");
if (!SAFE_HOST.test(host) || host.includes("..")) {
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
}
const expectedCommit = requireString(env, "RELEASE_EXPECTED_COMMIT");
if (!/^[a-f0-9]{40}$/i.test(expectedCommit)) {
throw new DeploymentError("RELEASE_EXPECTED_COMMIT must be a full Git commit hash.");
@@ -192,7 +205,6 @@ export function readDeploymentConfig(env = process.env, options = {}) {
throw new DeploymentError("Set PRODUCTION_FRONTEND_URL, RELEASE_BASE_URL, or PLAYWRIGHT_BASE_URL.");
}
const ftpPath = requireString(env, "PRODUCTION_FTP_PATH");
const githubRepository = env.RELEASE_GITHUB_REPOSITORY || "";
const githubToken = env.RELEASE_GITHUB_TOKEN || "";
let github = null;
@@ -211,13 +223,9 @@ export function readDeploymentConfig(env = process.env, options = {}) {
}
return {
ftp: {
host,
user: requireString(env, "PRODUCTION_FTP_USER"),
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
root: normalizeFtpRoot(ftpPath),
},
ftp,
cpanel,
activationKey,
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
checksumPath: path.resolve(checksumPath),
inventoryPath: path.resolve(requireString(env, "RELEASE_INVENTORY_PATH")),
@@ -359,10 +367,53 @@ async function readExpectedInventory(config, fsApi = fsPromises) {
return inventory;
}
function releaseIdentityFromTarget(target) {
const safeTarget = validateReleaseTarget(target);
const releaseId = safeTarget.split("/")[1];
const match = releaseId.match(/^([a-f0-9]{40})-(.+)$/i);
if (!match) {
throw new DeploymentError("Release target does not contain a full commit and build identity.");
}
validateReleaseId(match[2], "release target build ID");
return { target: safeTarget, releaseId, commit: match[1].toLowerCase(), buildId: match[2] };
}
function parseActivationResult(content, requestId, expectedTarget) {
const values = new Map();
for (const line of String(content).trim().split("\n")) {
const separator = line.indexOf("=");
if (separator <= 0) throw new DeploymentError("Server-side activation returned an invalid result.");
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (values.has(key) || !new Set(["schema_version", "request_id", "status", "target", "message"]).has(key)) {
throw new DeploymentError("Server-side activation returned an invalid result.");
}
values.set(key, value);
}
if (
values.size !== 5 ||
values.get("schema_version") !== "1" ||
values.get("request_id") !== requestId ||
values.get("target") !== expectedTarget ||
!SAFE_COMPONENT.test(values.get("message") || "")
) {
throw new DeploymentError("Server-side activation result did not match the requested release.");
}
if (values.get("status") !== "success") {
throw new DeploymentError(`Server-side release activation failed during ${values.get("message")}.`);
}
}
export function createLftpTransport(config, dependencies = {}) {
const runner = dependencies.runner || defaultProcessRunner;
const fsApi = dependencies.fs || fsPromises;
const streamFs = dependencies.streamFs || fs;
const activationTimeoutMs = dependencies.activationTimeoutMs || 180_000;
const activationPollIntervalMs = dependencies.activationPollIntervalMs || 5_000;
const activationRequestTtlSeconds = dependencies.activationRequestTtlSeconds || 150;
const now = dependencies.now || Date.now;
const sleep =
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds)));
async function run(commands) {
try {
@@ -375,6 +426,87 @@ export function createLftpTransport(config, dependencies = {}) {
}
}
async function requestActivation(identity, action, archive = {}) {
const requestId = validateReleaseId(
dependencies.activationRequestId || `gha-${String(process.env.GITHUB_RUN_ID || now())}-${crypto.randomUUID()}`,
"activation request ID"
);
const archiveName =
action === "stage" ? validateReleaseId(archive.archiveName, "activation archive name") : "unused";
const archiveSha256 = action === "stage" ? String(archive.sha256 || "").toLowerCase() : "unused";
if (action === "stage" && (!archiveName.endsWith(".zip") || !SHA256.test(archiveSha256))) {
throw new DeploymentError("Activation archive metadata is invalid.");
}
if (!Number.isSafeInteger(activationRequestTtlSeconds) || activationRequestTtlSeconds < 60) {
throw new DeploymentError("Activation request TTL must be at least 60 seconds.");
}
const expiresAt = Math.floor(now() / 1000) + activationRequestTtlSeconds;
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-activation-"));
const requestPath = path.join(temporaryDirectory, `${requestId}.request`);
const resultPath = path.join(temporaryDirectory, `${requestId}.result`);
const requestBody = [
"schema_version=1",
`request_id=${requestId}`,
`action=${action}`,
`release_id=${identity.releaseId}`,
`commit_sha=${identity.commit}`,
`build_id=${identity.buildId}`,
`archive_name=${archiveName}`,
`archive_sha256=${archiveSha256}`,
`expires_at=${expiresAt}`,
"",
].join("\n");
const requestHmac = crypto
.createHmac("sha256", Buffer.from(config.activationKey, "hex"))
.update(requestBody)
.digest("hex");
const request = `${requestBody}request_hmac=${requestHmac}\n`;
try {
await fsApi.writeFile(requestPath, request, { mode: 0o600 });
const remoteRequest = `activation-requests/${requestId}.request`;
const remoteResult = `activation-results/${requestId}.result`;
let queueError;
try {
await run([
`mkdir -p ${lftpQuote("activation-requests")}`,
`mkdir -p ${lftpQuote("activation-results")}`,
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
]);
} catch (error) {
// The server may have committed the final rename before the FTPS
// connection failed. Poll through the request lifetime so an
// ambiguous upload cannot activate after this workflow exits.
queueError = error;
}
const deadline = now() + activationTimeoutMs;
let resultDownloaded = false;
while (now() < deadline) {
try {
await run([`get ${lftpQuote(remoteResult)} -o ${lftpQuote(resultPath)}`]);
resultDownloaded = true;
break;
} catch {
await sleep(activationPollIntervalMs);
}
}
if (!resultDownloaded) {
throw new DeploymentError(
queueError
? "Could not confirm whether the account-scoped activation request was queued before it expired."
: "Timed out waiting for the account-scoped release activator.",
queueError ? { cause: queueError } : undefined
);
}
parseActivationResult(await fsApi.readFile(resultPath, "utf8"), requestId, identity.target);
return identity.target;
} finally {
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
}
}
return {
async uploadArchive() {
const archiveName = safeArchiveName(config.archivePath);
@@ -458,6 +590,20 @@ export function createLftpTransport(config, dependencies = {}) {
}
},
async stageAndActivate(uploaded) {
const identity = {
target: `releases/${config.releaseId}/dist`,
releaseId: config.releaseId,
commit: config.expectedCommit,
buildId: config.expectedBuildId,
};
return await requestActivation(identity, "stage", uploaded);
},
async activateExisting(target) {
return await requestActivation(releaseIdentityFromTarget(target), "switch");
},
async removeRelease(releaseId) {
const safeReleaseId = validateReleaseId(releaseId, "release retention ID");
await run([`rm -r -f ${lftpQuote(`releases/${safeReleaseId}`)}`]);
@@ -629,35 +775,6 @@ export class CpanelFilemanClient {
return result.data;
}
async mkdir(directory) {
const safePath = this.assertContained(directory);
return await this.call("mkdir", {
path: path.posix.dirname(safePath),
name: path.posix.basename(safePath),
permissions: "0755",
});
}
async copy(source, destination) {
return await this.fileOp("copy", source, destination);
}
async extract(source, destination) {
return await this.fileOp("extract", source, destination);
}
async link(source, destination) {
return await this.fileOp("link", source, destination);
}
async rename(source, destination) {
return await this.fileOp("rename", source, destination);
}
async unlink(source) {
return await this.fileOp("unlink", source);
}
async remove(source) {
return await this.fileOp("trash", source);
}
@@ -707,24 +824,6 @@ export async function assertReleaseTargetExists(client, config, target) {
}
}
async function ensureDirectory(client, root, relativeDirectory) {
const components = relativeDirectory.split("/");
let parent = root;
for (const component of components) {
const entries = await client.list(parent);
if (!findEntry(entries, component)) {
await client.mkdir(containedRemotePath(parent, component));
}
parent = containedRemotePath(parent, component);
}
}
async function assertAbsent(client, parent, name, description) {
if (findEntry(await client.list(parent), name)) {
throw new DeploymentError(`${description} already exists; refusing to overwrite immutable data.`);
}
}
async function assertCurrentLink(client, config) {
const entries = await client.list(config.cpanel.root);
const current = findEntry(entries, "current");
@@ -736,16 +835,6 @@ async function assertCurrentLink(client, config) {
}
}
async function confirmLink(client, config, name) {
const entry = findEntry(await client.list(config.cpanel.root), name);
if (!entry) {
throw new DeploymentError(`cPanel did not create ${name}.`);
}
if (entry.type !== "link") {
throw new DeploymentError(`cPanel ${name} is not a symbolic link.`);
}
}
export async function capturePublishedReleaseTarget(config, options = {}) {
const fetchImpl = options.fetchImpl || globalThis.fetch;
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
@@ -820,121 +909,17 @@ export async function assertExpectedCommitCurrent(config, options = {}) {
}
}
export async function atomicSwitch(client, config, target) {
const safeTarget = validateReleaseTarget(target);
const root = config.cpanel.root;
const nextPath = containedRemotePath(root, "current.next");
const currentPath = containedRemotePath(root, "current");
const targetPath = containedRemotePath(root, safeTarget);
const entries = await client.list(root);
if (findEntry(entries, "current.next")) {
await client.unlink(nextPath);
}
await client.link(targetPath, nextPath);
await confirmLink(client, config, "current.next");
await client.rename(nextPath, currentPath);
await confirmLink(client, config, "current");
}
export async function preflightAtomicSwitch(client, config, options = {}) {
const root = config.cpanel.root;
await ensureDirectory(client, root, "staging");
const probeId = options.probeId || `preflight-${crypto.randomUUID()}`;
validateReleaseId(probeId, "preflight ID");
const probe = containedRemotePath(root, "staging", probeId);
await client.mkdir(probe);
let operationError;
try {
const first = containedRemotePath(probe, "first");
const second = containedRemotePath(probe, "second");
await client.mkdir(first);
await client.mkdir(second);
await client.mkdir(containedRemotePath(first, "first-marker"));
await client.mkdir(containedRemotePath(second, "second-marker"));
await client.link(first, containedRemotePath(probe, "current"));
await client.link(second, containedRemotePath(probe, "current.next"));
await client.rename(containedRemotePath(probe, "current.next"), containedRemotePath(probe, "current"));
const currentEntries = await client.list(probe);
const current = findEntry(currentEntries, "current");
if (!current || current.type !== "link") {
throw new DeploymentError("cPanel atomic replacement preflight did not leave a current link.");
}
if (findEntry(currentEntries, "current.next")) {
throw new DeploymentError("cPanel atomic replacement preflight left current.next behind.");
}
const activeEntries = await client.list(containedRemotePath(probe, "current"));
if (!findEntry(activeEntries, "second-marker") || findEntry(activeEntries, "first-marker")) {
throw new DeploymentError("cPanel atomic replacement preflight did not activate the new link target.");
}
} catch (error) {
operationError = error;
}
let cleanupError;
try {
await client.remove(probe);
} catch (error) {
cleanupError = error;
}
if (operationError) {
throw new DeploymentError("cPanel does not support the required atomic symlink replacement.", {
cause: operationError,
});
}
if (cleanupError) {
throw new DeploymentError("Could not clean up the cPanel atomicity preflight directory.", {
cause: cleanupError,
});
}
}
async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) {
const archiveRoot = containedRemotePath(config.cpanel.root, "archives");
await ensureDirectory(client, config.cpanel.root, "archives");
if (!findEntry(await client.list(config.cpanel.root), "archives")) {
throw new DeploymentError("cPanel deployment archives directory is missing; bootstrap is incomplete.");
}
const entries = await client.list(archiveRoot);
if (findEntry(entries, archiveName) || findEntry(entries, checksumName)) {
throw new DeploymentError("The immutable release archive name already exists on cPanel.");
}
}
export async function stageRelease(client, config, archiveName) {
const root = config.cpanel.root;
await ensureDirectory(client, root, "releases");
await ensureDirectory(client, root, "staging");
const stagingRoot = containedRemotePath(root, "staging");
const releasesRoot = containedRemotePath(root, "releases");
const stagingName = `${config.releaseId}.pending`;
await assertAbsent(client, stagingRoot, stagingName, "Release staging directory");
await assertAbsent(client, releasesRoot, config.releaseId, "Release directory");
const stagingPath = containedRemotePath(stagingRoot, stagingName);
const releasePath = containedRemotePath(releasesRoot, config.releaseId);
await client.mkdir(stagingPath);
const sourceArchive = containedRemotePath(root, "archives", archiveName);
await client.copy(sourceArchive, stagingPath);
const stagedArchive = containedRemotePath(stagingPath, archiveName);
await client.extract(stagedArchive, stagingPath);
await client.remove(stagedArchive);
const stagingEntries = await client.list(stagingPath);
if (!findEntry(stagingEntries, "dist")) {
throw new DeploymentError("Extracted release did not contain a top-level dist directory.");
}
const distPath = containedRemotePath(stagingPath, "dist");
const distEntries = await client.list(distPath);
for (const requiredFile of ["index.html", "release-manifest.json", "release-entry.json"]) {
if (!findEntry(distEntries, requiredFile)) {
throw new DeploymentError(`Extracted release was missing ${requiredFile}.`);
}
}
await client.rename(stagingPath, releasePath);
if (!findEntry(await client.list(releasesRoot), config.releaseId)) {
throw new DeploymentError("cPanel did not finalize the immutable release directory.");
}
return `releases/${config.releaseId}/dist`;
}
export async function runPublicVerification(config, options = {}) {
const runner = options.runner || defaultProcessRunner;
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
@@ -1008,16 +993,14 @@ export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
export async function deployRelease(config, dependencies = {}) {
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
const transport = dependencies.transport || createLftpTransport(config, dependencies);
const preflight = dependencies.preflight || preflightAtomicSwitch;
const stage = dependencies.stage || stageRelease;
const verify = dependencies.verify || runPublicVerification;
const prune = dependencies.prune || pruneInactiveReleases;
const publish = dependencies.publish || emitDeploymentOutputs;
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
await checkCurrent(config, dependencies);
await preflight(client, config);
await assertCurrentLink(client, config);
const previousTarget = await capturePrevious(config, dependencies);
await assertReleaseTargetExists(client, config, previousTarget);
@@ -1026,10 +1009,23 @@ export async function deployRelease(config, dependencies = {}) {
const checksumName = `${archiveName}.sha256`;
await assertArchiveNamesAvailable(client, config, archiveName, checksumName);
const uploaded = await transport.uploadArchive();
const newTarget = await stage(client, config, uploaded.archiveName);
await transport.verifyRelease(newTarget);
await checkCurrent(config, dependencies);
await atomicSwitch(client, config, newTarget);
const expectedNewTarget = `releases/${config.releaseId}/dist`;
let newTarget;
try {
newTarget = await transport.stageAndActivate(uploaded);
} catch (error) {
let observedTarget;
try {
observedTarget = await captureActive(config, dependencies);
} catch {
throw error;
}
if (observedTarget !== expectedNewTarget) {
throw error;
}
newTarget = observedTarget;
}
publish({
RELEASE_ROLLBACK_TARGET: previousTarget,
RELEASE_ACTIVE_TARGET: newTarget,
@@ -1037,17 +1033,19 @@ export async function deployRelease(config, dependencies = {}) {
});
try {
await assertReleaseTargetExists(client, config, newTarget);
await transport.verifyRelease(newTarget);
await verify(config, dependencies);
} catch (error) {
try {
await atomicSwitch(client, config, previousTarget);
await transport.activateExisting(previousTarget);
} catch (rollbackError) {
throw new DeploymentError(
"Public verification failed and automatic rollback also failed; production requires immediate attention.",
"Release verification failed and automatic rollback also failed; production requires immediate attention.",
{ cause: new AggregateError([error, rollbackError]) }
);
}
throw new DeploymentError("Public verification failed; the previous release was restored.", {
throw new DeploymentError("Release verification failed; the previous release was restored.", {
cause: error,
});
}
@@ -1070,12 +1068,11 @@ export async function deployRelease(config, dependencies = {}) {
export async function rollbackRelease(config, target, dependencies = {}) {
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
const preflight = dependencies.preflight || preflightAtomicSwitch;
const transport = dependencies.transport || createLftpTransport(config, dependencies);
const publish = dependencies.publish || emitDeploymentOutputs;
const safeTarget = validateReleaseTarget(target);
await assertReleaseTargetExists(client, config, safeTarget);
await preflight(client, config);
await atomicSwitch(client, config, safeTarget);
await transport.activateExisting(safeTarget);
publish({
RELEASE_ACTIVE_TARGET: safeTarget,
RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1],
+12 -6
View File
@@ -464,19 +464,25 @@ export async function restoreRoot(config, recovery, expectedStateToken, confirma
if (before.stateToken !== expectedStateToken) {
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
}
if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) {
const selectedRecovery = before.recoveryCandidates.find((entry) => entry.name === safeRecovery);
if (!selectedRecovery) {
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
}
if (selectedRecovery.type !== "dir") {
throw new DeploymentError(
"Automatic restore requires a physical retained directory; cPanel Fileman may follow symbolic links."
);
}
if (!before.webroot)
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
if (!new Set(["dir", "link"]).has(before.webroot.type)) {
throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore.");
}
if (!before.webrootAccess.accessible && before.webroot.type !== "link") {
if (before.webroot.type !== "dir") {
throw new DeploymentError(
"The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery."
"Automatic restore requires a physical current webroot; cPanel Fileman may follow symbolic links."
);
}
if (!before.webrootAccess.accessible) {
throw new DeploymentError("The current public_html directory could not be inspected.");
}
if (before.nestedDomainRoots.length > 0) {
throw new DeploymentError(
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots
+1
View File
@@ -27,6 +27,7 @@ export const ownedFilesByRole = {
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"limited-backoffice.spec.ts",
"mobile-header-safe-area.spec.ts",
"navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
+1
View File
@@ -19,6 +19,7 @@ textarea.has-sharp-edges {
/*}*/
:root {
--pleno-mobile-safe-top: env(safe-area-inset-top, 0px);
/* 1. Primary Brand Colors (HSL) */
--bulma-primary-h: 197deg !important;
--bulma-primary-l: 35% !important;
@@ -117,10 +117,13 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
margin-left: 1rem;
}
.mobile-header-section {
height: 80px;
height: calc(80px + var(--pleno-mobile-safe-top));
min-height: calc(80px + var(--pleno-mobile-safe-top));
width: 100%;
display: flex;
align-items: center;
padding-top: var(--pleno-mobile-safe-top);
box-sizing: border-box;
background-color: white;
}
:deep(.mobile-queue-monitor.queue-monitor-navbar-item) {
@@ -152,9 +155,9 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
margin-right: 1rem;
}
.navbar-spacer {
height: 80px; /* Same height as the navbar */
height: calc(80px + var(--pleno-mobile-safe-top));
min-width: 100%;
min-height: 80px; /* Ensures the spacer is always at least as tall as the navbar */
min-height: calc(80px + var(--pleno-mobile-safe-top));
background-color: transparent; /* Transparent to avoid visual interference */
position: relative; /* Allows for proper stacking context */
}
@@ -191,9 +194,11 @@ const canShowProfileActions = computed(() => showProfile.value && (canShowAdminS
position: absolute;
width: 100%;
height: 80px;
height: calc(80px + var(--pleno-mobile-safe-top));
left: 0px;
top: 0px;
padding-top: var(--pleno-mobile-safe-top);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
+123
View File
@@ -1159,6 +1159,7 @@
"help_self_wash": "Start @:{'words.generated.og'} afslutning @:{'words.generated.af'} @:{'words.generated.selvvask'}.",
"help_payments": "Ordrer, fakturaer @:{'words.generated.og'} kortbetalinger.",
"help_account": "Login, @:{'words.generated.adgang'} @:{'words.generated.og'} køretøjer på @:{'words.generated.din'} konto.",
"help_deletion": "Hjælp til privatliv, personoplysninger og kontosletning.",
"privacy_prefix": "Læs også vores",
"privacy_link": "privatlivspolitik",
"privacy_suffix": "@:{'words.generated.for'} oplysninger om @:{'words.generated.data'} @:{'words.generated.og'} rettigheder."
@@ -5181,6 +5182,63 @@
"wash_certificate": "@.capitalize:{'words.generated.vaskecertifikat'}",
"wash_type": "@.capitalize:{'words.generated.vasketype'}"
},
"privacy_policy": {
"changes_body": "Vi kan opdatere denne politik, når tjenesten, leverandørerne eller lovkrav ændres. Den aktuelle version og dato offentliggøres her.",
"changes_title": "11. Ændringer",
"contact_body": "Kontakt os om privatliv, rettigheder eller kontosletning på",
"contact_title": "12. Kontakt",
"controller_intro": "Truck Wash ApS er dataansvarlig for behandlingen i Truck Wash kundeportal og app.",
"controller_title": "1. Dataansvarlig",
"data": {
"account": "Konto- og kontaktoplysninger, herunder navn, virksomhed, adresse, e-mail, telefonnummer, kundenummer, brugernavn og sikkerhedsoplysninger.",
"content": "Indhold, du indsender, herunder supportbeskeder, billeder, bilag og oplysninger i fejlrapporter.",
"service": "Køretøjer og registreringsnumre, bookinger, vaskehistorik, ordrer, fakturaer, abonnementer samt betalings- og transaktionsstatus.",
"technical": "Session-, enheds-, browser-, netværks-, sikkerheds- og diagnostikoplysninger, når de er nødvendige for drift, fejlfinding og beskyttelse mod misbrug."
},
"data_intro": "Afhængigt af de funktioner og den adgang, du bruger, behandler vi:",
"data_title": "3. Oplysninger vi behandler",
"deletion_body": "Når du er logget ind, kan du starte kontosletning under Profil → Slet konto. Din identitet bekræftes med adgangskode og eventuel totrinsbekræftelse. Når anmodningen accepteres, spærres din adgang og aktive sessioner straks, og personlige profiloplysninger slettes eller anonymiseres gennem vores sletteproces.",
"deletion_retained": "Fakturaer, betalingsdokumentation, ordrer, tilknyttet vaskehistorik og begrænsede sikkerheds- eller revisionslogs kan bevares i den periode, som regnskabs-, sikkerheds- eller andre lovkrav kræver. De bruges ikke til markedsføring efter sletteanmodningen.",
"deletion_title": "7. Kontosletning",
"meta_description": "Privatlivspolitik for Truck Wash kundeportal og iOS-app.",
"permissions": "Kameraet bruges kun, når du vælger at scanne QR-koder eller registreringsnumre. Placering bruges, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling. Adgang kan afvises i enhedens indstillinger.",
"processors": {
"economic": "e-conomic til kunde-, ordre-, faktura- og regnskabsbehandling.",
"hosting": "Drifts-, hosting-, kommunikations- og sikkerhedsleverandører, som behandler oplysninger på vores vegne.",
"microsoft": "Microsoft, hvis du frivilligt forbinder Microsoft-kalender eller -login.",
"recaptcha": "Google reCAPTCHA, når risikovurdering eller beskyttelse mod automatiseret misbrug aktiveres.",
"stripe": "Stripe, når en afdeling eller betaling bruger Stripe til betalingsbehandling."
},
"purposes_body": "Vi behandler oplysninger for at oprette og sikre konti, levere booking og vask, administrere køretøjer, ordrer og fakturaer, gennemføre betalinger, sende nødvendige servicebeskeder, yde support, forebygge misbrug og overholde regnskabs- og andre lovkrav. Retsgrundlaget er opfyldelse af aftale, legitim interesse, retlig forpligtelse og samtykke, hvor det er påkrævet.",
"purposes_title": "4. Formål og retsgrundlag",
"retention_body": "Profil- og servicedata opbevares, mens kontoen eller kundeforholdet er aktivt, og derefter kun så længe det er nødvendigt for dokumenterede formål, tvister og gældende lov. Sikkerhedslogs opbevares i en begrænset periode. Lovpligtige regnskabs- og transaktionsdata kan have en længere opbevaringsperiode.",
"retention_title": "6. Opbevaring",
"rights": {
"access": "Indsigt i de personoplysninger, vi behandler om dig.",
"complaint": "Klage til Datatilsynet eller en anden kompetent tilsynsmyndighed.",
"correction": "Rettelse af urigtige eller ufuldstændige oplysninger.",
"erasure": "Sletning, når behandlingen ikke længere er nødvendig eller lovpligtig.",
"objection": "Indsigelse mod behandling baseret på legitim interesse.",
"portability": "Dataportabilitet, hvor reglerne giver ret til det.",
"restriction": "Begrænsning af behandling i de tilfælde, loven fastsætter."
},
"rights_intro": "Afhængigt af situationen har du ret til:",
"rights_title": "10. Dine rettigheder",
"scope_body": "Politikken gælder for truckwash.io, Truck Wash kundeportal og den native iOS-app. Den dækker både kunder og chauffører samt de administrative funktioner, som en bruger har fået adgang til.",
"scope_title": "2. Omfang",
"security_body": "Vi bruger adgangskontrol, krypteret transport, sessionsbeskyttelse, logning og organisatoriske procedurer til at beskytte oplysninger. Ingen tjeneste kan garanteres fuldstændig sikker; kontakt os straks ved mistanke om misbrug.",
"security_title": "8. Sikkerhed",
"sharing_intro": "Vi videregiver kun oplysninger, når det er nødvendigt for tjenesten, vores aftale, sikkerhed eller lovkrav. Relevante kategorier af databehandlere og selvstændige dataansvarlige omfatter:",
"sharing_limit": "Vi sælger ikke personoplysninger og bruger dem ikke til tredjepartsannoncering eller sporing på tværs af andre virksomheders apps og websites.",
"sharing_title": "5. Leverandører og deling",
"support_link": "Gå til support",
"title": "Privatlivspolitik",
"tracking_body": "Portalen bruger nødvendig lokal lagring og sessionsmekanismer til login, sikkerhed, sprog og funktionalitet. Eksterne login-, betalings- eller misbrugsbeskyttelsestjenester kan behandle tekniske oplysninger, når deres funktion bruges. Vi bruger ikke reklamecookies eller markedsføringsanalyse i appen.",
"tracking_title": "Cookies, lokal lagring og sporing",
"transfers_body": "Hvis en leverandør behandler oplysninger uden for EU/EØS, kræver vi et gyldigt overførselsgrundlag, såsom en tilstrækkelighedsafgørelse eller EU-standardkontraktbestemmelser, samt relevante supplerende sikkerhedsforanstaltninger.",
"transfers_title": "9. Internationale overførsler",
"updated": "Senest opdateret: 20. juli 2026"
},
"products": {
"admin": {
"subtitle": "@.capitalize:{'words.generated.her'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.handtere'} @:{'words.generated.produkter'}",
@@ -6618,6 +6676,71 @@
"title": "@:{'templates.generated.compat.user_menu.my_washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Din anmodning er modtaget. Du bliver nu logget ud, og din adgang er blevet spærret.",
"accepted_title": "Sletning er anmodet",
"button": "Slet min konto",
"cancel": "Annuller",
"close": "Luk",
"confirmation_description": "Skriv {phrase} præcist for at fortsætte.",
"confirmation_invalid": "Teksten skal være præcis: {phrase}",
"confirmation_label": "Bekræftelsestekst",
"confirmation_title": "Bekræft kontosletning",
"continue": "Fortsæt",
"description": "Anmod om permanent sletning af din adgang og dine personlige profiloplysninger.",
"error_generic": "Anmodningen kunne ikke gennemføres. Kontrollér oplysningerne, og prøv igen.",
"error_title": "Kontoen blev ikke slettet",
"immediate_effect": "Når anmodningen accepteres, tilbagekaldes dine aktive sessioner med det samme. Handlingen kan ikke fortrydes fra appen.",
"impact": {
"customer": "Din kundeadgang og de adgange, der faktureres til kundekontoen, bliver deaktiveret. Chaufførers selvstændige identiteter og andre kundeadgange slettes ikke.",
"subuser": "Din chaufføridentitet og alle dine kundeadgange bliver deaktiveret. Kundernes virksomheds-, ordre- og fakturadata slettes ikke."
},
"load_error": "Oplysninger om kontosletning kunne ikke hentes. Din konto er ikke ændret.",
"loading": "Henter oplysninger om kontosletning...",
"password_description": "Bekræft din identitet med din nuværende adgangskode.",
"password_label": "Adgangskode",
"password_required": "Indtast din adgangskode.",
"password_title": "Bekræft din identitet",
"privacy_link": "Læs privatlivspolitikken og oplysningerne om sletning",
"retained": {
"audit_logs": "Revisionsspor, som skal bevares af sikkerheds- eller lovhensyn",
"customer_reference": "En begrænset reference til kundekontoen, når historiske bilag kræver det",
"driver_reference": "En begrænset chaufførreference, når historiske bilag kræver det",
"invoices": "Fakturaer og lovpligtige regnskabsbilag",
"invoices_payments_accounting": "Fakturaer, betalingsdokumentation og lovpligtige regnskabsbilag",
"legal_obligations": "Oplysninger, som skal bevares efter gældende lov eller for retskrav",
"orders": "Ordrer, der indgår i regnskabs- eller aftalehistorikken",
"orders_wash_history": "Ordrer og den vaskehistorik, der indgår i regnskabs- eller aftalehistorikken",
"other": "Oplysninger, som skal bevares efter gældende lov eller for retskrav",
"payments": "Betalings- og transaktionsdokumentation",
"security_audit_logs": "Begrænsede sikkerheds- og revisionslogs",
"security_logs": "Begrænsede sikkerheds- og revisionslogs",
"wash_records": "Vaskehistorik, der er knyttet til ordrer og fakturaer"
},
"retained_title": "Disse forretningsoplysninger kan blive bevaret i den lovpligtige periode:",
"retention_acknowledgement": "Jeg forstår, at lovpligtige regnskabs-, betalings- og sikkerhedsoplysninger kan blive bevaret, mens min profil og adgang slettes.",
"retention_description": "Personlige profiloplysninger slettes eller anonymiseres, men Truck Wash skal fortsat bevare visse forretningsoplysninger efter gældende lov.",
"retention_required": "Du skal bekræfte opbevaringen for at fortsætte.",
"retention_title": "Bekræft lovpligtig opbevaring",
"retry": "Prøv igen",
"start": "Fortsæt med sletning",
"status": {
"available": "Ingen aktiv sletteanmodning",
"completed": "Sletningen er gennemført",
"failed": "Sletningen kræver manuel behandling",
"processing": "Sletningen behandles",
"requested": "Sletningen er anmodet",
"unavailable": "Status kunne ikke hentes"
},
"status_label": "Status",
"submit": "Anmod om sletning",
"title": "Slet konto",
"two_factor_description": "Indtast den sekscifrede kode fra din authenticator-app.",
"two_factor_invalid": "Koden skal være seks cifre.",
"two_factor_label": "Sekscifret kode",
"two_factor_title": "Totrinsbekræftelse",
"warning_title": "Dette fjerner din adgang permanent"
},
"default_department": {
"description": "@.capitalize:{'words.generated.din'} @:{'words.generated.standard'} @:{'words.generated.afdeling'}",
"title": "@.capitalize:{'words.generated.standard'} @:{'words.generated.afdeling'}"
+66
View File
@@ -1269,6 +1269,7 @@
"help_self_wash": "Start @:{'words.generated.und'} Abschluss @:{'words.generated.von'} Selbstbedienungswaeschen.",
"help_payments": "Bestellungen, @:{'words.generated.rechnungen'} @:{'words.generated.und'} Kartenzahlungen.",
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
"help_deletion": "Hilfe zu Datenschutz, personenbezogenen Daten und Kontoloeschung.",
"privacy_prefix": "Lesen Sie auch unsere",
"privacy_link": "Datenschutzerklaerung",
"privacy_suffix": "@:{'words.generated.fuer'} @:{'words.generated.informationen'} zu Daten @:{'words.generated.und'} Rechten."
@@ -6728,6 +6729,71 @@
"title": "@:{'templates.generated.compat.user_menu.my_washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Your request has been received. You will now be signed out and your access has been revoked.",
"accepted_title": "Deletion requested",
"button": "Delete my account",
"cancel": "Cancel",
"close": "Close",
"confirmation_description": "Enter {phrase} exactly to continue.",
"confirmation_invalid": "The text must exactly match: {phrase}",
"confirmation_label": "Confirmation text",
"confirmation_title": "Confirm account deletion",
"continue": "Continue",
"description": "Request permanent deletion of your access and personal profile information.",
"error_generic": "The request could not be completed. Check your information and try again.",
"error_title": "The account was not deleted",
"immediate_effect": "Once accepted, your active sessions are revoked immediately. This action cannot be undone from the app.",
"impact": {
"customer": "Your customer access and grants billed to the customer account will be disabled. Drivers' independent identities and access to other customers are not deleted.",
"subuser": "Your driver identity and all of your customer grants will be disabled. Customers' company, order, and invoice data are not deleted."
},
"load_error": "Account-deletion information could not be loaded. Your account has not been changed.",
"loading": "Loading account-deletion information...",
"password_description": "Confirm your identity with your current password.",
"password_label": "Password",
"password_required": "Enter your password.",
"password_title": "Confirm your identity",
"privacy_link": "Read the privacy policy and deletion information",
"retained": {
"audit_logs": "Audit trails that must be retained for security or legal reasons",
"customer_reference": "A limited customer-account reference where historical records require it",
"driver_reference": "A limited driver reference where historical records require it",
"invoices": "Invoices and legally required accounting records",
"invoices_payments_accounting": "Invoices, payment documentation, and legally required accounting records",
"legal_obligations": "Information that must be retained under applicable law or for legal claims",
"orders": "Orders included in accounting or contract history",
"orders_wash_history": "Orders and connected wash history included in accounting or contract history",
"other": "Information that must be retained under applicable law or for legal claims",
"payments": "Payment and transaction documentation",
"security_audit_logs": "Limited security and audit logs",
"security_logs": "Limited security and audit logs",
"wash_records": "Wash history connected to orders and invoices"
},
"retained_title": "These business records may be retained for the legally required period:",
"retention_acknowledgement": "I understand that required accounting, payment, and security records may be retained while my profile and access are deleted.",
"retention_description": "Personal profile information is deleted or anonymized, but Truck Wash must continue to retain certain business records under applicable law.",
"retention_required": "You must acknowledge the retention information to continue.",
"retention_title": "Acknowledge required retention",
"retry": "Try again",
"start": "Continue with deletion",
"status": {
"available": "No active deletion request",
"completed": "Deletion completed",
"failed": "Deletion requires manual handling",
"processing": "Deletion is being processed",
"requested": "Deletion requested",
"unavailable": "Status could not be loaded"
},
"status_label": "Status",
"submit": "Request deletion",
"title": "Delete account",
"two_factor_description": "Enter the six-digit code from your authenticator app.",
"two_factor_invalid": "The code must contain six digits.",
"two_factor_label": "Six-digit code",
"two_factor_title": "Two-factor authentication",
"warning_title": "This permanently removes your access"
},
"default_department": {
"description": "@.capitalize:{'words.generated.ihre'} @:{'words.generated.standardabteilung'}",
"title": "@:{'words.generated.standardabteilung'}"
+123
View File
@@ -990,6 +990,7 @@
"help_self_wash": "Starting @:{'words.generated.and'} ending @:{'words.generated.self'}-@:{'words.generated.service'} @:{'words.generated.wash'} sessions.",
"help_payments": "@.capitalize:{'words.generated.orders'}, @:{'words.generated.invoices'}, @:{'words.generated.and'} @:{'words.generated.card'} @:{'words.generated.payments'}.",
"help_account": "Login, @:{'words.generated.access'}, @:{'words.generated.and'} @:{'words.generated.vehicles'} on your @:{'words.generated.account'}.",
"help_deletion": "Help with privacy, personal information, and account deletion.",
"privacy_prefix": "Also read our",
"privacy_link": "privacy policy",
"privacy_suffix": "@:{'words.generated.for'} @:{'words.generated.details'} @:{'words.generated.about'} @:{'words.generated.data'} @:{'words.generated.and'} @:{'words.generated.rights'}."
@@ -5012,6 +5013,63 @@
"wash_certificate": "@.capitalize:{'words.generated.washing'} @:{'words.generated.certificate'}",
"wash_type": "@.capitalize:{'words.generated.washing'} @:{'words.generated.type'}"
},
"privacy_policy": {
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
"changes_title": "11. Changes",
"contact_body": "Contact us about privacy, rights, or account deletion at",
"contact_title": "12. Contact",
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
"controller_title": "1. Data controller",
"data": {
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
},
"data_intro": "Depending on the features and access you use, we process:",
"data_title": "3. Information we process",
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
"deletion_title": "7. Account deletion",
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
"processors": {
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
"stripe": "Stripe when a department or payment uses Stripe payment processing."
},
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
"purposes_title": "4. Purposes and legal bases",
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
"retention_title": "6. Retention",
"rights": {
"access": "Access to the personal information we process about you.",
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
"correction": "Correction of inaccurate or incomplete information.",
"erasure": "Erasure when processing is no longer necessary or legally required.",
"objection": "Object to processing based on legitimate interests.",
"portability": "Data portability where the rules provide that right.",
"restriction": "Restriction of processing in the situations provided by law."
},
"rights_intro": "Depending on the circumstances, you have the right to:",
"rights_title": "10. Your rights",
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
"scope_title": "2. Scope",
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
"security_title": "8. Security",
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
"sharing_title": "5. Providers and sharing",
"support_link": "Go to support",
"title": "Privacy policy",
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
"tracking_title": "Cookies, local storage, and tracking",
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
"transfers_title": "9. International transfers",
"updated": "Last updated: July 20, 2026"
},
"products": {
"admin": {
"subtitle": "@.capitalize:{'words.generated.here'} @:{'words.generated.you'} @:{'words.generated.can'} @:{'words.generated.handle'} @:{'words.generated.products'}",
@@ -6449,6 +6507,71 @@
"title": "@:{'words.generated.my'} @:{'words.generated.washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Your request has been received. You will now be signed out and your access has been revoked.",
"accepted_title": "Deletion requested",
"button": "Delete my account",
"cancel": "Cancel",
"close": "Close",
"confirmation_description": "Enter {phrase} exactly to continue.",
"confirmation_invalid": "The text must exactly match: {phrase}",
"confirmation_label": "Confirmation text",
"confirmation_title": "Confirm account deletion",
"continue": "Continue",
"description": "Request permanent deletion of your access and personal profile information.",
"error_generic": "The request could not be completed. Check your information and try again.",
"error_title": "The account was not deleted",
"immediate_effect": "Once accepted, your active sessions are revoked immediately. This action cannot be undone from the app.",
"impact": {
"customer": "Your customer access and grants billed to the customer account will be disabled. Drivers' independent identities and access to other customers are not deleted.",
"subuser": "Your driver identity and all of your customer grants will be disabled. Customers' company, order, and invoice data are not deleted."
},
"load_error": "Account-deletion information could not be loaded. Your account has not been changed.",
"loading": "Loading account-deletion information...",
"password_description": "Confirm your identity with your current password.",
"password_label": "Password",
"password_required": "Enter your password.",
"password_title": "Confirm your identity",
"privacy_link": "Read the privacy policy and deletion information",
"retained": {
"audit_logs": "Audit trails that must be retained for security or legal reasons",
"customer_reference": "A limited customer-account reference where historical records require it",
"driver_reference": "A limited driver reference where historical records require it",
"invoices": "Invoices and legally required accounting records",
"invoices_payments_accounting": "Invoices, payment documentation, and legally required accounting records",
"legal_obligations": "Information that must be retained under applicable law or for legal claims",
"orders": "Orders included in accounting or contract history",
"orders_wash_history": "Orders and connected wash history included in accounting or contract history",
"other": "Information that must be retained under applicable law or for legal claims",
"payments": "Payment and transaction documentation",
"security_audit_logs": "Limited security and audit logs",
"security_logs": "Limited security and audit logs",
"wash_records": "Wash history connected to orders and invoices"
},
"retained_title": "These business records may be retained for the legally required period:",
"retention_acknowledgement": "I understand that required accounting, payment, and security records may be retained while my profile and access are deleted.",
"retention_description": "Personal profile information is deleted or anonymized, but Truck Wash must continue to retain certain business records under applicable law.",
"retention_required": "You must acknowledge the retention information to continue.",
"retention_title": "Acknowledge required retention",
"retry": "Try again",
"start": "Continue with deletion",
"status": {
"available": "No active deletion request",
"completed": "Deletion completed",
"failed": "Deletion requires manual handling",
"processing": "Deletion is being processed",
"requested": "Deletion requested",
"unavailable": "Status could not be loaded"
},
"status_label": "Status",
"submit": "Request deletion",
"title": "Delete account",
"two_factor_description": "Enter the six-digit code from your authenticator app.",
"two_factor_invalid": "The code must contain six digits.",
"two_factor_label": "Six-digit code",
"two_factor_title": "Two-factor authentication",
"warning_title": "This permanently removes your access"
},
"default_department": {
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.default'} @:{'words.generated.department'}",
"title": "@.capitalize:{'words.generated.default'} @:{'words.generated.department'}"
+123
View File
@@ -4569,6 +4569,63 @@
"wash_certificate": "@:{'templates.generated.compat.pos.wash_certificate'}",
"wash_type": "@:{'templates.generated.compat.pos.wash_type'}"
},
"privacy_policy": {
"changes_body": "@:{'templates.generated.compat.privacy_policy.changes_body'}",
"changes_title": "@:{'templates.generated.compat.privacy_policy.changes_title'}",
"contact_body": "@:{'templates.generated.compat.privacy_policy.contact_body'}",
"contact_title": "@:{'templates.generated.compat.privacy_policy.contact_title'}",
"controller_intro": "@:{'templates.generated.compat.privacy_policy.controller_intro'}",
"controller_title": "@:{'templates.generated.compat.privacy_policy.controller_title'}",
"data": {
"account": "@:{'templates.generated.compat.privacy_policy.data.account'}",
"content": "@:{'templates.generated.compat.privacy_policy.data.content'}",
"service": "@:{'templates.generated.compat.privacy_policy.data.service'}",
"technical": "@:{'templates.generated.compat.privacy_policy.data.technical'}"
},
"data_intro": "@:{'templates.generated.compat.privacy_policy.data_intro'}",
"data_title": "@:{'templates.generated.compat.privacy_policy.data_title'}",
"deletion_body": "@:{'templates.generated.compat.privacy_policy.deletion_body'}",
"deletion_retained": "@:{'templates.generated.compat.privacy_policy.deletion_retained'}",
"deletion_title": "@:{'templates.generated.compat.privacy_policy.deletion_title'}",
"meta_description": "@:{'templates.generated.compat.privacy_policy.meta_description'}",
"permissions": "@:{'templates.generated.compat.privacy_policy.permissions'}",
"processors": {
"economic": "@:{'templates.generated.compat.privacy_policy.processors.economic'}",
"hosting": "@:{'templates.generated.compat.privacy_policy.processors.hosting'}",
"microsoft": "@:{'templates.generated.compat.privacy_policy.processors.microsoft'}",
"recaptcha": "@:{'templates.generated.compat.privacy_policy.processors.recaptcha'}",
"stripe": "@:{'templates.generated.compat.privacy_policy.processors.stripe'}"
},
"purposes_body": "@:{'templates.generated.compat.privacy_policy.purposes_body'}",
"purposes_title": "@:{'templates.generated.compat.privacy_policy.purposes_title'}",
"retention_body": "@:{'templates.generated.compat.privacy_policy.retention_body'}",
"retention_title": "@:{'templates.generated.compat.privacy_policy.retention_title'}",
"rights": {
"access": "@:{'templates.generated.compat.privacy_policy.rights.access'}",
"complaint": "@:{'templates.generated.compat.privacy_policy.rights.complaint'}",
"correction": "@:{'templates.generated.compat.privacy_policy.rights.correction'}",
"erasure": "@:{'templates.generated.compat.privacy_policy.rights.erasure'}",
"objection": "@:{'templates.generated.compat.privacy_policy.rights.objection'}",
"portability": "@:{'templates.generated.compat.privacy_policy.rights.portability'}",
"restriction": "@:{'templates.generated.compat.privacy_policy.rights.restriction'}"
},
"rights_intro": "@:{'templates.generated.compat.privacy_policy.rights_intro'}",
"rights_title": "@:{'templates.generated.compat.privacy_policy.rights_title'}",
"scope_body": "@:{'templates.generated.compat.privacy_policy.scope_body'}",
"scope_title": "@:{'templates.generated.compat.privacy_policy.scope_title'}",
"security_body": "@:{'templates.generated.compat.privacy_policy.security_body'}",
"security_title": "@:{'templates.generated.compat.privacy_policy.security_title'}",
"sharing_intro": "@:{'templates.generated.compat.privacy_policy.sharing_intro'}",
"sharing_limit": "@:{'templates.generated.compat.privacy_policy.sharing_limit'}",
"sharing_title": "@:{'templates.generated.compat.privacy_policy.sharing_title'}",
"support_link": "@:{'templates.generated.compat.privacy_policy.support_link'}",
"title": "@:{'templates.generated.compat.privacy_policy.title'}",
"tracking_body": "@:{'templates.generated.compat.privacy_policy.tracking_body'}",
"tracking_title": "@:{'templates.generated.compat.privacy_policy.tracking_title'}",
"transfers_body": "@:{'templates.generated.compat.privacy_policy.transfers_body'}",
"transfers_title": "@:{'templates.generated.compat.privacy_policy.transfers_title'}",
"updated": "@:{'templates.generated.compat.privacy_policy.updated'}"
},
"products": {
"actions": "@:common.actions",
"admin": {
@@ -6101,6 +6158,7 @@
"help_self_wash": "@:templates.support.help_self_wash",
"help_payments": "@:templates.support.help_payments",
"help_account": "@:templates.support.help_account",
"help_deletion": "@:templates.support.help_deletion",
"privacy_prefix": "@:templates.support.privacy_prefix",
"privacy_link": "@:templates.support.privacy_link",
"privacy_suffix": "@:templates.support.privacy_suffix"
@@ -6769,6 +6827,71 @@
"title": "@:{'templates.generated.compat.user_dashboard.orders.title'}"
},
"profile": {
"deletion": {
"accepted_description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.accepted_description'}",
"accepted_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.accepted_title'}",
"button": "@:{'templates.generated.compat.user_dashboard.profile.deletion.button'}",
"cancel": "@:{'templates.generated.compat.user_dashboard.profile.deletion.cancel'}",
"close": "@:{'templates.generated.compat.user_dashboard.profile.deletion.close'}",
"confirmation_description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.confirmation_description'}",
"confirmation_invalid": "@:{'templates.generated.compat.user_dashboard.profile.deletion.confirmation_invalid'}",
"confirmation_label": "@:{'templates.generated.compat.user_dashboard.profile.deletion.confirmation_label'}",
"confirmation_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.confirmation_title'}",
"continue": "@:{'templates.generated.compat.user_dashboard.profile.deletion.continue'}",
"description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.description'}",
"error_generic": "@:{'templates.generated.compat.user_dashboard.profile.deletion.error_generic'}",
"error_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.error_title'}",
"immediate_effect": "@:{'templates.generated.compat.user_dashboard.profile.deletion.immediate_effect'}",
"impact": {
"customer": "@:{'templates.generated.compat.user_dashboard.profile.deletion.impact.customer'}",
"subuser": "@:{'templates.generated.compat.user_dashboard.profile.deletion.impact.subuser'}"
},
"load_error": "@:{'templates.generated.compat.user_dashboard.profile.deletion.load_error'}",
"loading": "@:{'templates.generated.compat.user_dashboard.profile.deletion.loading'}",
"password_description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.password_description'}",
"password_label": "@:{'templates.generated.compat.user_dashboard.profile.deletion.password_label'}",
"password_required": "@:{'templates.generated.compat.user_dashboard.profile.deletion.password_required'}",
"password_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.password_title'}",
"privacy_link": "@:{'templates.generated.compat.user_dashboard.profile.deletion.privacy_link'}",
"retained": {
"audit_logs": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.audit_logs'}",
"customer_reference": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.customer_reference'}",
"driver_reference": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.driver_reference'}",
"invoices": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.invoices'}",
"invoices_payments_accounting": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.invoices_payments_accounting'}",
"legal_obligations": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.legal_obligations'}",
"orders": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.orders'}",
"orders_wash_history": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.orders_wash_history'}",
"other": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.other'}",
"payments": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.payments'}",
"security_audit_logs": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.security_audit_logs'}",
"security_logs": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.security_logs'}",
"wash_records": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained.wash_records'}"
},
"retained_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retained_title'}",
"retention_acknowledgement": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retention_acknowledgement'}",
"retention_description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retention_description'}",
"retention_required": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retention_required'}",
"retention_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retention_title'}",
"retry": "@:{'templates.generated.compat.user_dashboard.profile.deletion.retry'}",
"start": "@:{'templates.generated.compat.user_dashboard.profile.deletion.start'}",
"status": {
"available": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.available'}",
"completed": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.completed'}",
"failed": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.failed'}",
"processing": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.processing'}",
"requested": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.requested'}",
"unavailable": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status.unavailable'}"
},
"status_label": "@:{'templates.generated.compat.user_dashboard.profile.deletion.status_label'}",
"submit": "@:{'templates.generated.compat.user_dashboard.profile.deletion.submit'}",
"title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.title'}",
"two_factor_description": "@:{'templates.generated.compat.user_dashboard.profile.deletion.two_factor_description'}",
"two_factor_invalid": "@:{'templates.generated.compat.user_dashboard.profile.deletion.two_factor_invalid'}",
"two_factor_label": "@:{'templates.generated.compat.user_dashboard.profile.deletion.two_factor_label'}",
"two_factor_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.two_factor_title'}",
"warning_title": "@:{'templates.generated.compat.user_dashboard.profile.deletion.warning_title'}"
},
"default_department": {
"description": "@:{'templates.generated.compat.user_dashboard.profile.default_department.description'}",
"title": "@:{'templates.generated.compat.user_dashboard.profile.default_department.title'}"
+66
View File
@@ -1272,6 +1272,7 @@
"help_self_wash": "Start @:{'words.generated.og'} avslutning @:{'words.generated.av'} @:{'words.generated.selvvask'}.",
"help_payments": "@.capitalize:{'words.generated.ordrer'}, @:{'words.generated.fakturaer'} @:{'words.generated.og'} kortbetalinger.",
"help_account": "@.capitalize:{'words.generated.innlogging'}, @:{'words.generated.tilgang'} @:{'words.generated.og'} kjoretoy paa kontoen @:{'words.generated.din'}.",
"help_deletion": "Hjelp med personvern, personopplysninger og sletting av konto.",
"privacy_prefix": "Les ogsaa vaar",
"privacy_link": "personvernerklaering",
"privacy_suffix": "@:{'words.generated.for'} @:{'words.generated.informasjon'} om @:{'words.generated.data'} @:{'words.generated.og'} rettigheter."
@@ -6731,6 +6732,71 @@
"title": "@:{'templates.generated.compat.user_menu.my_washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Your request has been received. You will now be signed out and your access has been revoked.",
"accepted_title": "Deletion requested",
"button": "Delete my account",
"cancel": "Cancel",
"close": "Close",
"confirmation_description": "Enter {phrase} exactly to continue.",
"confirmation_invalid": "The text must exactly match: {phrase}",
"confirmation_label": "Confirmation text",
"confirmation_title": "Confirm account deletion",
"continue": "Continue",
"description": "Request permanent deletion of your access and personal profile information.",
"error_generic": "The request could not be completed. Check your information and try again.",
"error_title": "The account was not deleted",
"immediate_effect": "Once accepted, your active sessions are revoked immediately. This action cannot be undone from the app.",
"impact": {
"customer": "Your customer access and grants billed to the customer account will be disabled. Drivers' independent identities and access to other customers are not deleted.",
"subuser": "Your driver identity and all of your customer grants will be disabled. Customers' company, order, and invoice data are not deleted."
},
"load_error": "Account-deletion information could not be loaded. Your account has not been changed.",
"loading": "Loading account-deletion information...",
"password_description": "Confirm your identity with your current password.",
"password_label": "Password",
"password_required": "Enter your password.",
"password_title": "Confirm your identity",
"privacy_link": "Read the privacy policy and deletion information",
"retained": {
"audit_logs": "Audit trails that must be retained for security or legal reasons",
"customer_reference": "A limited customer-account reference where historical records require it",
"driver_reference": "A limited driver reference where historical records require it",
"invoices": "Invoices and legally required accounting records",
"invoices_payments_accounting": "Invoices, payment documentation, and legally required accounting records",
"legal_obligations": "Information that must be retained under applicable law or for legal claims",
"orders": "Orders included in accounting or contract history",
"orders_wash_history": "Orders and connected wash history included in accounting or contract history",
"other": "Information that must be retained under applicable law or for legal claims",
"payments": "Payment and transaction documentation",
"security_audit_logs": "Limited security and audit logs",
"security_logs": "Limited security and audit logs",
"wash_records": "Wash history connected to orders and invoices"
},
"retained_title": "These business records may be retained for the legally required period:",
"retention_acknowledgement": "I understand that required accounting, payment, and security records may be retained while my profile and access are deleted.",
"retention_description": "Personal profile information is deleted or anonymized, but Truck Wash must continue to retain certain business records under applicable law.",
"retention_required": "You must acknowledge the retention information to continue.",
"retention_title": "Acknowledge required retention",
"retry": "Try again",
"start": "Continue with deletion",
"status": {
"available": "No active deletion request",
"completed": "Deletion completed",
"failed": "Deletion requires manual handling",
"processing": "Deletion is being processed",
"requested": "Deletion requested",
"unavailable": "Status could not be loaded"
},
"status_label": "Status",
"submit": "Request deletion",
"title": "Delete account",
"two_factor_description": "Enter the six-digit code from your authenticator app.",
"two_factor_invalid": "The code must contain six digits.",
"two_factor_label": "Six-digit code",
"two_factor_title": "Two-factor authentication",
"warning_title": "This permanently removes your access"
},
"default_department": {
"description": "@.capitalize:{'words.generated.din'} @:{'words.generated.standardavdeling'}",
"title": "@.capitalize:{'words.generated.standard'} @:{'words.generated.avdeling'}"
+66
View File
@@ -1322,6 +1322,7 @@
"help_self_wash": "Start @:{'words.generated.och'} avslut @:{'words.generated.av'} @:{'words.generated.sjalvtvatt'}.",
"help_payments": "Ordrar, fakturor @:{'words.generated.och'} kortbetalningar.",
"help_account": "@.capitalize:{'words.generated.inloggning'}, @:{'words.generated.atkomst'} @:{'words.generated.och'} @:{'words.generated.fordon'} pa ditt @:{'words.generated.konto'}.",
"help_deletion": "Hjalp med integritet, personuppgifter och borttagning av konto.",
"privacy_prefix": "Las ocksa var",
"privacy_link": "integritetspolicy",
"privacy_suffix": "@:{'words.generated.for_2'} @:{'words.generated.information'} @:{'words.generated.om'} @:{'words.generated.data'} @:{'words.generated.och'} rattigheter."
@@ -6781,6 +6782,71 @@
"title": "@:{'words.generated.my'} @:{'words.generated.washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Your request has been received. You will now be signed out and your access has been revoked.",
"accepted_title": "Deletion requested",
"button": "Delete my account",
"cancel": "Cancel",
"close": "Close",
"confirmation_description": "Enter {phrase} exactly to continue.",
"confirmation_invalid": "The text must exactly match: {phrase}",
"confirmation_label": "Confirmation text",
"confirmation_title": "Confirm account deletion",
"continue": "Continue",
"description": "Request permanent deletion of your access and personal profile information.",
"error_generic": "The request could not be completed. Check your information and try again.",
"error_title": "The account was not deleted",
"immediate_effect": "Once accepted, your active sessions are revoked immediately. This action cannot be undone from the app.",
"impact": {
"customer": "Your customer access and grants billed to the customer account will be disabled. Drivers' independent identities and access to other customers are not deleted.",
"subuser": "Your driver identity and all of your customer grants will be disabled. Customers' company, order, and invoice data are not deleted."
},
"load_error": "Account-deletion information could not be loaded. Your account has not been changed.",
"loading": "Loading account-deletion information...",
"password_description": "Confirm your identity with your current password.",
"password_label": "Password",
"password_required": "Enter your password.",
"password_title": "Confirm your identity",
"privacy_link": "Read the privacy policy and deletion information",
"retained": {
"audit_logs": "Audit trails that must be retained for security or legal reasons",
"customer_reference": "A limited customer-account reference where historical records require it",
"driver_reference": "A limited driver reference where historical records require it",
"invoices": "Invoices and legally required accounting records",
"invoices_payments_accounting": "Invoices, payment documentation, and legally required accounting records",
"legal_obligations": "Information that must be retained under applicable law or for legal claims",
"orders": "Orders included in accounting or contract history",
"orders_wash_history": "Orders and connected wash history included in accounting or contract history",
"other": "Information that must be retained under applicable law or for legal claims",
"payments": "Payment and transaction documentation",
"security_audit_logs": "Limited security and audit logs",
"security_logs": "Limited security and audit logs",
"wash_records": "Wash history connected to orders and invoices"
},
"retained_title": "These business records may be retained for the legally required period:",
"retention_acknowledgement": "I understand that required accounting, payment, and security records may be retained while my profile and access are deleted.",
"retention_description": "Personal profile information is deleted or anonymized, but Truck Wash must continue to retain certain business records under applicable law.",
"retention_required": "You must acknowledge the retention information to continue.",
"retention_title": "Acknowledge required retention",
"retry": "Try again",
"start": "Continue with deletion",
"status": {
"available": "No active deletion request",
"completed": "Deletion completed",
"failed": "Deletion requires manual handling",
"processing": "Deletion is being processed",
"requested": "Deletion requested",
"unavailable": "Status could not be loaded"
},
"status_label": "Status",
"submit": "Request deletion",
"title": "Delete account",
"two_factor_description": "Enter the six-digit code from your authenticator app.",
"two_factor_invalid": "The code must contain six digits.",
"two_factor_label": "Six-digit code",
"two_factor_title": "Two-factor authentication",
"warning_title": "This permanently removes your access"
},
"default_department": {
"description": "@.capitalize:{'words.generated.din'} @:{'words.generated.standardavdelning'}",
"title": "@.capitalize:{'words.generated.standardavdelning'}"
@@ -0,0 +1,61 @@
{
"compat": {
"privacy_policy": {
"changes_body": "Vi kan opdatere denne politik, når tjenesten, leverandørerne eller lovkrav ændres. Den aktuelle version og dato offentliggøres her.",
"changes_title": "11. Ændringer",
"contact_body": "Kontakt os om privatliv, rettigheder eller kontosletning på",
"contact_title": "12. Kontakt",
"controller_intro": "Truck Wash ApS er dataansvarlig for behandlingen i Truck Wash kundeportal og app.",
"controller_title": "1. Dataansvarlig",
"data": {
"account": "Konto- og kontaktoplysninger, herunder navn, virksomhed, adresse, e-mail, telefonnummer, kundenummer, brugernavn og sikkerhedsoplysninger.",
"content": "Indhold, du indsender, herunder supportbeskeder, billeder, bilag og oplysninger i fejlrapporter.",
"service": "Køretøjer og registreringsnumre, bookinger, vaskehistorik, ordrer, fakturaer, abonnementer samt betalings- og transaktionsstatus.",
"technical": "Session-, enheds-, browser-, netværks-, sikkerheds- og diagnostikoplysninger, når de er nødvendige for drift, fejlfinding og beskyttelse mod misbrug."
},
"data_intro": "Afhængigt af de funktioner og den adgang, du bruger, behandler vi:",
"data_title": "3. Oplysninger vi behandler",
"deletion_body": "Når du er logget ind, kan du starte kontosletning under Profil → Slet konto. Din identitet bekræftes med adgangskode og eventuel totrinsbekræftelse. Når anmodningen accepteres, spærres din adgang og aktive sessioner straks, og personlige profiloplysninger slettes eller anonymiseres gennem vores sletteproces.",
"deletion_retained": "Fakturaer, betalingsdokumentation, ordrer, tilknyttet vaskehistorik og begrænsede sikkerheds- eller revisionslogs kan bevares i den periode, som regnskabs-, sikkerheds- eller andre lovkrav kræver. De bruges ikke til markedsføring efter sletteanmodningen.",
"deletion_title": "7. Kontosletning",
"meta_description": "Privatlivspolitik for Truck Wash kundeportal og iOS-app.",
"permissions": "Kameraet bruges kun, når du vælger at scanne QR-koder eller registreringsnumre. Placering bruges, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling. Adgang kan afvises i enhedens indstillinger.",
"processors": {
"economic": "e-conomic til kunde-, ordre-, faktura- og regnskabsbehandling.",
"hosting": "Drifts-, hosting-, kommunikations- og sikkerhedsleverandører, som behandler oplysninger på vores vegne.",
"microsoft": "Microsoft, hvis du frivilligt forbinder Microsoft-kalender eller -login.",
"recaptcha": "Google reCAPTCHA, når risikovurdering eller beskyttelse mod automatiseret misbrug aktiveres.",
"stripe": "Stripe, når en afdeling eller betaling bruger Stripe til betalingsbehandling."
},
"purposes_body": "Vi behandler oplysninger for at oprette og sikre konti, levere booking og vask, administrere køretøjer, ordrer og fakturaer, gennemføre betalinger, sende nødvendige servicebeskeder, yde support, forebygge misbrug og overholde regnskabs- og andre lovkrav. Retsgrundlaget er opfyldelse af aftale, legitim interesse, retlig forpligtelse og samtykke, hvor det er påkrævet.",
"purposes_title": "4. Formål og retsgrundlag",
"retention_body": "Profil- og servicedata opbevares, mens kontoen eller kundeforholdet er aktivt, og derefter kun så længe det er nødvendigt for dokumenterede formål, tvister og gældende lov. Sikkerhedslogs opbevares i en begrænset periode. Lovpligtige regnskabs- og transaktionsdata kan have en længere opbevaringsperiode.",
"retention_title": "6. Opbevaring",
"rights": {
"access": "Indsigt i de personoplysninger, vi behandler om dig.",
"complaint": "Klage til Datatilsynet eller en anden kompetent tilsynsmyndighed.",
"correction": "Rettelse af urigtige eller ufuldstændige oplysninger.",
"erasure": "Sletning, når behandlingen ikke længere er nødvendig eller lovpligtig.",
"objection": "Indsigelse mod behandling baseret på legitim interesse.",
"portability": "Dataportabilitet, hvor reglerne giver ret til det.",
"restriction": "Begrænsning af behandling i de tilfælde, loven fastsætter."
},
"rights_intro": "Afhængigt af situationen har du ret til:",
"rights_title": "10. Dine rettigheder",
"scope_body": "Politikken gælder for truckwash.io, Truck Wash kundeportal og den native iOS-app. Den dækker både kunder og chauffører samt de administrative funktioner, som en bruger har fået adgang til.",
"scope_title": "2. Omfang",
"security_body": "Vi bruger adgangskontrol, krypteret transport, sessionsbeskyttelse, logning og organisatoriske procedurer til at beskytte oplysninger. Ingen tjeneste kan garanteres fuldstændig sikker; kontakt os straks ved mistanke om misbrug.",
"security_title": "8. Sikkerhed",
"sharing_intro": "Vi videregiver kun oplysninger, når det er nødvendigt for tjenesten, vores aftale, sikkerhed eller lovkrav. Relevante kategorier af databehandlere og selvstændige dataansvarlige omfatter:",
"sharing_limit": "Vi sælger ikke personoplysninger og bruger dem ikke til tredjepartsannoncering eller sporing på tværs af andre virksomheders apps og websites.",
"sharing_title": "5. Leverandører og deling",
"support_link": "Gå til support",
"title": "Privatlivspolitik",
"tracking_body": "Portalen bruger nødvendig lokal lagring og sessionsmekanismer til login, sikkerhed, sprog og funktionalitet. Eksterne login-, betalings- eller misbrugsbeskyttelsestjenester kan behandle tekniske oplysninger, når deres funktion bruges. Vi bruger ikke reklamecookies eller markedsføringsanalyse i appen.",
"tracking_title": "Cookies, lokal lagring og sporing",
"transfers_body": "Hvis en leverandør behandler oplysninger uden for EU/EØS, kræver vi et gyldigt overførselsgrundlag, såsom en tilstrækkelighedsafgørelse eller EU-standardkontraktbestemmelser, samt relevante supplerende sikkerhedsforanstaltninger.",
"transfers_title": "9. Internationale overførsler",
"updated": "Senest opdateret: 20. juli 2026"
}
}
}
@@ -13,6 +13,7 @@
"help_self_wash": "Start @:{'terms.glossary.og'} afslutning @:{'terms.glossary.af'} @:{'terms.glossary.selvvask'}.",
"help_payments": "Ordrer, fakturaer @:{'terms.glossary.og'} kortbetalinger.",
"help_account": "Login, @:{'terms.glossary.adgang'} @:{'terms.glossary.og'} køretøjer på @:{'terms.glossary.din'} konto.",
"help_deletion": "Hjælp til privatliv, personoplysninger og kontosletning.",
"privacy_prefix": "Læs også vores",
"privacy_link": "privatlivspolitik",
"privacy_suffix": "@:{'terms.glossary.for'} oplysninger om @:{'terms.glossary.data'} @:{'terms.glossary.og'} rettigheder."
@@ -59,6 +59,71 @@
"title": "@:{'phrases.compat.user_menu.my_washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Din anmodning er modtaget. Du bliver nu logget ud, og din adgang er blevet spærret.",
"accepted_title": "Sletning er anmodet",
"button": "Slet min konto",
"cancel": "Annuller",
"close": "Luk",
"confirmation_description": "Skriv {phrase} præcist for at fortsætte.",
"confirmation_invalid": "Teksten skal være præcis: {phrase}",
"confirmation_label": "Bekræftelsestekst",
"confirmation_title": "Bekræft kontosletning",
"continue": "Fortsæt",
"description": "Anmod om permanent sletning af din adgang og dine personlige profiloplysninger.",
"error_generic": "Anmodningen kunne ikke gennemføres. Kontrollér oplysningerne, og prøv igen.",
"error_title": "Kontoen blev ikke slettet",
"immediate_effect": "Når anmodningen accepteres, tilbagekaldes dine aktive sessioner med det samme. Handlingen kan ikke fortrydes fra appen.",
"impact": {
"customer": "Din kundeadgang og de adgange, der faktureres til kundekontoen, bliver deaktiveret. Chaufførers selvstændige identiteter og andre kundeadgange slettes ikke.",
"subuser": "Din chaufføridentitet og alle dine kundeadgange bliver deaktiveret. Kundernes virksomheds-, ordre- og fakturadata slettes ikke."
},
"load_error": "Oplysninger om kontosletning kunne ikke hentes. Din konto er ikke ændret.",
"loading": "Henter oplysninger om kontosletning...",
"password_description": "Bekræft din identitet med din nuværende adgangskode.",
"password_label": "Adgangskode",
"password_required": "Indtast din adgangskode.",
"password_title": "Bekræft din identitet",
"privacy_link": "Læs privatlivspolitikken og oplysningerne om sletning",
"retained": {
"audit_logs": "Revisionsspor, som skal bevares af sikkerheds- eller lovhensyn",
"customer_reference": "En begrænset reference til kundekontoen, når historiske bilag kræver det",
"driver_reference": "En begrænset chaufførreference, når historiske bilag kræver det",
"invoices": "Fakturaer og lovpligtige regnskabsbilag",
"invoices_payments_accounting": "Fakturaer, betalingsdokumentation og lovpligtige regnskabsbilag",
"legal_obligations": "Oplysninger, som skal bevares efter gældende lov eller for retskrav",
"orders": "Ordrer, der indgår i regnskabs- eller aftalehistorikken",
"orders_wash_history": "Ordrer og den vaskehistorik, der indgår i regnskabs- eller aftalehistorikken",
"other": "Oplysninger, som skal bevares efter gældende lov eller for retskrav",
"payments": "Betalings- og transaktionsdokumentation",
"security_audit_logs": "Begrænsede sikkerheds- og revisionslogs",
"security_logs": "Begrænsede sikkerheds- og revisionslogs",
"wash_records": "Vaskehistorik, der er knyttet til ordrer og fakturaer"
},
"retained_title": "Disse forretningsoplysninger kan blive bevaret i den lovpligtige periode:",
"retention_acknowledgement": "Jeg forstår, at lovpligtige regnskabs-, betalings- og sikkerhedsoplysninger kan blive bevaret, mens min profil og adgang slettes.",
"retention_description": "Personlige profiloplysninger slettes eller anonymiseres, men Truck Wash skal fortsat bevare visse forretningsoplysninger efter gældende lov.",
"retention_required": "Du skal bekræfte opbevaringen for at fortsætte.",
"retention_title": "Bekræft lovpligtig opbevaring",
"retry": "Prøv igen",
"start": "Fortsæt med sletning",
"status": {
"available": "Ingen aktiv sletteanmodning",
"completed": "Sletningen er gennemført",
"failed": "Sletningen kræver manuel behandling",
"processing": "Sletningen behandles",
"requested": "Sletningen er anmodet",
"unavailable": "Status kunne ikke hentes"
},
"status_label": "Status",
"submit": "Anmod om sletning",
"title": "Slet konto",
"two_factor_description": "Indtast den sekscifrede kode fra din authenticator-app.",
"two_factor_invalid": "Koden skal være seks cifre.",
"two_factor_label": "Sekscifret kode",
"two_factor_title": "Totrinsbekræftelse",
"warning_title": "Dette fjerner din adgang permanent"
},
"default_department": {
"description": "@.capitalize:{'terms.glossary.din'} @:{'terms.glossary.standard'} @:{'terms.glossary.afdeling'}",
"title": "@.capitalize:{'terms.glossary.standard'} @:{'terms.glossary.afdeling'}"
@@ -13,6 +13,7 @@
"help_self_wash": "Start @:{'terms.glossary.und'} Abschluss @:{'terms.glossary.von'} Selbstbedienungswaeschen.",
"help_payments": "Bestellungen, @:{'terms.glossary.rechnungen'} @:{'terms.glossary.und'} Kartenzahlungen.",
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
"help_deletion": "Hilfe zu Datenschutz, personenbezogenen Daten und Kontoloeschung.",
"privacy_prefix": "Lesen Sie auch unsere",
"privacy_link": "Datenschutzerklaerung",
"privacy_suffix": "@:{'terms.glossary.fuer'} @:{'terms.glossary.informationen'} zu Daten @:{'terms.glossary.und'} Rechten."
@@ -59,6 +59,64 @@
"title": "@:{'phrases.compat.user_menu.my_washes'}"
},
"profile": {
"deletion": {
"accepted_description": "Your request has been received. You will now be signed out and your access has been revoked.",
"accepted_title": "Deletion requested",
"button": "Delete my account",
"cancel": "Cancel",
"close": "Close",
"confirmation_description": "Enter {phrase} exactly to continue.",
"confirmation_invalid": "The text must exactly match: {phrase}",
"confirmation_label": "Confirmation text",
"confirmation_title": "Confirm account deletion",
"continue": "Continue",
"description": "Request permanent deletion of your access and personal profile information.",
"error_generic": "The request could not be completed. Check your information and try again.",
"error_title": "The account was not deleted",
"immediate_effect": "Once accepted, your active sessions are revoked immediately. This action cannot be undone from the app.",
"impact": {
"customer": "Your customer access and grants billed to the customer account will be disabled. Drivers' independent identities and access to other customers are not deleted.",
"subuser": "Your driver identity and all of your customer grants will be disabled. Customers' company, order, and invoice data are not deleted."
},
"load_error": "Account-deletion information could not be loaded. Your account has not been changed.",
"loading": "Loading account-deletion information...",
"password_description": "Confirm your identity with your current password.",
"password_label": "Password",
"password_required": "Enter your password.",
"password_title": "Confirm your identity",
"privacy_link": "Read the privacy policy and deletion information",
"retained": {
"audit_logs": "Audit trails that must be retained for security or legal reasons",
"customer_reference": "A limited customer-account reference where historical records require it",
"driver_reference": "A limited driver reference where historical records require it",
"invoices": "Invoices and legally required accounting records",
"invoices_payments_accounting": "Invoices, payment documentation, and legally required accounting records",
"legal_obligations": "Information that must be retained under applicable law or for legal claims",
"orders": "Orders included in accounting or contract history",
"orders_wash_history": "Orders and connected wash history included in accounting or contract history",
"other": "Information that must be retained under applicable law or for legal claims",
"payments": "Payment and transaction documentation",
"security_audit_logs": "Limited security and audit logs",
"security_logs": "Limited security and audit logs",
"wash_records": "Wash history connected to orders and invoices"
},
"retained_title": "These business records may be retained for the legally required period:",
"retention_acknowledgement": "I understand that required accounting, payment, and security records may be retained while my profile and access are deleted.",
"retention_description": "Personal profile information is deleted or anonymized, but Truck Wash must continue to retain certain business records under applicable law.",
"retention_required": "You must acknowledge the retention information to continue.",
"retention_title": "Acknowledge required retention",
"retry": "Try again",
"start": "Continue with deletion",
"status": { "available": "No active deletion request", "completed": "Deletion completed", "failed": "Deletion requires manual handling", "processing": "Deletion is being processed", "requested": "Deletion requested", "unavailable": "Status could not be loaded" },
"status_label": "Status",
"submit": "Request deletion",
"title": "Delete account",
"two_factor_description": "Enter the six-digit code from your authenticator app.",
"two_factor_invalid": "The code must contain six digits.",
"two_factor_label": "Six-digit code",
"two_factor_title": "Two-factor authentication",
"warning_title": "This permanently removes your access"
},
"default_department": {
"description": "@.capitalize:{'terms.glossary.ihre'} @:{'terms.glossary.standardabteilung'}",
"title": "@:{'terms.glossary.standardabteilung'}"
@@ -0,0 +1,61 @@
{
"compat": {
"privacy_policy": {
"changes_body": "We may update this policy when the service, providers, or legal requirements change. The current version and date are published here.",
"changes_title": "11. Changes",
"contact_body": "Contact us about privacy, rights, or account deletion at",
"contact_title": "12. Contact",
"controller_intro": "Truck Wash ApS is the data controller for processing in the Truck Wash customer portal and app.",
"controller_title": "1. Data controller",
"data": {
"account": "Account and contact information, including name, company, address, email, phone number, customer number, username, and security information.",
"content": "Content you submit, including support messages, photos, attachments, and information included in error reports.",
"service": "Vehicles and registration plates, bookings, wash history, orders, invoices, subscriptions, and payment or transaction status.",
"technical": "Session, device, browser, network, security, and diagnostic information when needed to operate, troubleshoot, and protect the service."
},
"data_intro": "Depending on the features and access you use, we process:",
"data_title": "3. Information we process",
"deletion_body": "When signed in, you can start account deletion under Profile → Delete account. Your identity is confirmed using your password and two-factor authentication when enabled. Once accepted, access and active sessions are revoked immediately, and personal profile information is deleted or anonymized through our deletion process.",
"deletion_retained": "Invoices, payment documentation, orders, connected wash history, and limited security or audit logs may be retained for the period required by accounting, security, or other applicable laws. They are not used for marketing after the deletion request.",
"deletion_title": "7. Account deletion",
"meta_description": "Privacy policy for the Truck Wash customer portal and iOS app.",
"permissions": "The camera is used only when you choose to scan QR codes or registration plates. Location is used while the app is open to find or confirm the nearest Truck Wash department. Access can be denied in the device settings.",
"processors": {
"economic": "e-conomic for customer, order, invoice, and accounting processing.",
"hosting": "Operations, hosting, communications, and security providers that process information on our behalf.",
"microsoft": "Microsoft when you voluntarily connect Microsoft calendar or sign-in.",
"recaptcha": "Google reCAPTCHA when risk assessment or protection against automated abuse is activated.",
"stripe": "Stripe when a department or payment uses Stripe payment processing."
},
"purposes_body": "We process information to create and secure accounts, provide booking and wash services, manage vehicles, orders and invoices, process payments, send necessary service messages, provide support, prevent abuse, and comply with accounting and other legal duties. The legal bases are contract performance, legitimate interests, legal obligations, and consent where required.",
"purposes_title": "4. Purposes and legal bases",
"retention_body": "Profile and service data are retained while the account or customer relationship is active and then only as long as necessary for documented purposes, disputes, and applicable law. Security logs are kept for a limited period. Legally required accounting and transaction data may have a longer retention period.",
"retention_title": "6. Retention",
"rights": {
"access": "Access to the personal information we process about you.",
"complaint": "Complain to the Danish Data Protection Agency or another competent supervisory authority.",
"correction": "Correction of inaccurate or incomplete information.",
"erasure": "Erasure when processing is no longer necessary or legally required.",
"objection": "Object to processing based on legitimate interests.",
"portability": "Data portability where the rules provide that right.",
"restriction": "Restriction of processing in the situations provided by law."
},
"rights_intro": "Depending on the circumstances, you have the right to:",
"rights_title": "10. Your rights",
"scope_body": "This policy applies to truckwash.io, the Truck Wash customer portal, and the native iOS app. It covers customers and drivers as well as administrative functions to which a user has been granted access.",
"scope_title": "2. Scope",
"security_body": "We use access control, encrypted transport, session protection, logging, and organizational procedures to protect information. No service can be guaranteed completely secure; contact us immediately if you suspect misuse.",
"security_title": "8. Security",
"sharing_intro": "We disclose information only when necessary for the service, our agreement, security, or legal requirements. Relevant categories of processors and independent controllers include:",
"sharing_limit": "We do not sell personal information or use it for third-party advertising or tracking across other companies' apps and websites.",
"sharing_title": "5. Providers and sharing",
"support_link": "Go to support",
"title": "Privacy policy",
"tracking_body": "The portal uses necessary local storage and session mechanisms for sign-in, security, language, and functionality. External sign-in, payment, or abuse-prevention services may process technical information when their feature is used. We do not use advertising cookies or marketing analytics in the app.",
"tracking_title": "Cookies, local storage, and tracking",
"transfers_body": "If a provider processes information outside the EU/EEA, we require a valid transfer mechanism, such as an adequacy decision or EU Standard Contractual Clauses, together with appropriate supplementary safeguards.",
"transfers_title": "9. International transfers",
"updated": "Last updated: July 20, 2026"
}
}
}
@@ -13,6 +13,7 @@
"help_self_wash": "Starting @:{'terms.glossary.and'} ending @:{'terms.glossary.self'}-@:{'terms.glossary.service'} @:{'terms.glossary.wash'} sessions.",
"help_payments": "@.capitalize:{'terms.glossary.orders'}, @:{'terms.glossary.invoices'}, @:{'terms.glossary.and'} @:{'terms.glossary.card'} @:{'terms.glossary.payments'}.",
"help_account": "Login, @:{'terms.glossary.access'}, @:{'terms.glossary.and'} @:{'terms.glossary.vehicles'} on your @:{'terms.glossary.account'}.",
"help_deletion": "Help with privacy, personal information, and account deletion.",
"privacy_prefix": "Also read our",
"privacy_link": "privacy policy",
"privacy_suffix": "@:{'terms.glossary.for'} @:{'terms.glossary.details'} @:{'terms.glossary.about'} @:{'terms.glossary.data'} @:{'terms.glossary.and'} @:{'terms.glossary.rights'}."

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