Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 7c82d9f523 fix(i18n): compile location permission compatibility paths 2026-07-23 20:01:25 +02:00
Jeppe Bundgaard e1092b80af test(ios): cover visible location fallback action 2026-07-23 19:06:54 +02:00
Jeppe Bundgaard ab75f134fd style(tests): apply repository formatting 2026-07-23 19:05:32 +02:00
Jeppe Bundgaard f2b209f078 fix(ios): respect status area and persist location consent
Keep the native status bar outside the web view and replace the startup geolocation watcher with a silent permission check plus an explicit location action.
2026-07-23 19:01:49 +02:00
Jeppe B 32418b42a0 fix(ios): add required location purpose string (#223)
## What changed
- add `NSLocationAlwaysAndWhenInUseUsageDescription` to the iOS app
- localize the purpose string in Danish and English
- enforce the key in mobile permission validation and App Store
readiness tests

## Why
App Store Connect accepted builds 1 and 2 but emitted `ITMS-90683`,
stating that the final app references APIs requiring this purpose
string. Shipping a corrected binary avoids submitting a candidate with a
known Apple delivery warning.

## Validation
- `node scripts/mobile/check-permissions.mjs`
- `node scripts/mobile/validate-app-store.mjs --strict`
- `vitest run tests/unit/app-store-product-readiness.spec.js` (5 tests)
- Prettier check for changed JS files
- `git diff --check`
2026-07-23 16:36:35 +00:00
Jeppe B bf2208e77b fix(ios): use valid Danish TestFlight locale (#222)
Use Apple's supported `da` beta locale and cover the localization/distribution flow with a regression test.

The first signed upload already processed version 1.0.0 build 1 successfully; this fixes the post-processing localization failure before the controlled retry.
2026-07-23 16:50:37 +02:00
Jeppe B 5702d45bc6 fix(ios): harden App Store release automation (#221)
## Summary

- replace the unsupported top-level App Store version collection with
Apple's app-scoped version endpoint
- add tested release-policy and availability readback for exact
version/build, `AFTER_APPROVAL`, Denmark only, no preorder, and no
automatic future territories
- strengthen the stable `App Store Readiness` check and align
Fastlane/candidate handoff with the approved 1.0.0 release policy

## Task contract

`truckwash-ios-release-20260723` — R4 (`ci-policy`, `release-policy`,
`credential-handling`, `branch-protection-or-rules`,
`mobile-store-submission`). The user explicitly approved implementation,
protected-master delivery, and the App Store release path.

## Changed files

- App Store Connect client and dependency-free Node tests
- App Store readiness and candidate workflows
- Fastlane candidate release configuration
- Apple App Store release runbook

## Verification

- `node --test tests/node/app-store-connect.test.mjs` — 10 passed
- `node scripts/mobile/validate-app-store.mjs --strict` — passed
- `node scripts/mobile/check-permissions.mjs` — passed
- App Store product-readiness Vitest — 5 passed
- ESLint on changed Node files — passed
- workflow YAML parsing — passed
- `git diff --check` — passed
- local Fastlane validation unavailable because Ruby/Bundler is not
installed on this host; `App Store Readiness` runs it on GitHub

## Release target

- iOS App Store
- bundle `io.truckwash.app`
- version `1.0.0`
- App Store Connect app `6792777794`
- Denmark only
- automatic release after approval
- no preorder or phased release for 1.0.0

The repository App Store automation switch remains disabled until this
change is merged and credential health is reverified.
2026-07-23 12:59:17 +00:00
Jeppe BandJeppe Bundgaard 42352b4c2d fix(release): isolate post-deploy bookkeeping (#220)
Scopes rollback to actual public or credentialed live-gate failure.
Release Manager recording becomes non-blocking post-deploy observability
and records only the API check because the mandatory Playwright gate
already verifies the exact full static inventory. This avoids the
duplicate synchronous static verification that exceeded the API proxy
timeout and rolled back a verified release.\n\nVerification:\n- git diff
--check\n- release.yml parsed with the checked-in YAML dependency\n-
credentialed role gate without secrets: 2 skipped, exit 0

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 06:18:01 +02:00
Jeppe BandJeppe Bundgaard 729416e5ef fix(release): skip role smoke without credentials (#219)
Makes the credentialed live Playwright gate conditional on its secrets
being configured. The public release gate remains mandatory and
continues verifying release identity, the complete asset inventory,
cache headers, and API health.\n\nVerification:\n- git diff --check\n-
PLAYWRIGHT_BASE_URL=https://truckwash.io Playwright @role-live gate: 2
skipped, exit 0

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 04:53:51 +02:00
Jeppe BandJeppe Bundgaard 41b3be926a test(release): allow full live asset inventory (#218)
## Summary
- give the complete public asset inventory gate a five-minute test
budget
- keep the API and rendered guest-flow smoke tests on the existing
default timeout

## Rationale
The production manifest contains the full release inventory.
Sequentially downloading and hashing it takes longer than the global
60-second Playwright timeout from GitHub-hosted runners, causing false
failures even though release identity, API, and guest-flow checks pass.

## Verification
- ESLint on the changed spec
- Playwright live configuration test discovery

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 02:59:40 +02:00
Jeppe BandJeppe Bundgaard fc67e7cf0b Fix idempotent FTPS directory setup (#217)
The production FTPS deploy now authenticates, but lftp exits when cPanel
returns 550 File exists for pre-created deployment directories. Use lftp
mkdir -p -f for the archive and activation directories so retries remain
idempotent while subsequent upload operations still surface real access
failures.\n\nVerification:\n- vitest tests/unit/cpanel-deploy.spec.js
(26 passed)\n- eslint scripts/release/cpanel-deploy-lib.mjs
tests/unit/cpanel-deploy.spec.js\n- git diff --check

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 00:00:48 +02:00
Jeppe B 9b3c06fc6f fix(release): stream lftp commands directly (#216)
## Summary
- feed the generated lftp command script directly over stdin
- avoid reopening `/dev/stdin`, which fails on the production hosted
runner
- keep FTP credentials out of process arguments

## Verification
- `vitest run tests/unit/cpanel-deploy.spec.js` (26/26)
- ESLint on changed files
- `git diff --check`

Supersedes the failed production release run 29948809036.
2026-07-22 21:24:04 +02:00
Jeppe B f0e3c4812b Require fresh passkeys for passwordless account deletion (#215)
## Summary
- Complete the frontend contract for hardened backend account deletion
(#319).
- For passwordless accounts, request a fresh deletion-specific WebAuthn
challenge and submit its serialized assertion.
- Reuse the existing passkey assertion serializer instead of duplicating
WebAuthn conversion logic.
- Accept the durable `manual_review` backend state while presenting the
existing safe failure copy.

## Verification
- Account deletion unit tests: 9/9.
- Focused ESLint passed for all four changed files.
- Node syntax checks and `git diff --check` passed.

Backend rollout flags remain default-off; this UI is inert until #319
schema checks and explicit API enablement are completed.
2026-07-22 20:05:37 +02:00
Jeppe B 4c7d8c6f2e Consolidate verified CI and Fastlane upgrades (#214)
## Summary
- Consolidate the intended changes from #194–#199 onto current master.
- Upgrade checkout 7.0.1, upload-artifact 7.0.1, setup-android 4.0.1,
setup-java 5.6.0, github-script 9.0.0, and Fastlane 2.237.0.
- Pin every upgraded workflow action to its verified immutable commit
SHA.
- Exclude the abandoned dependency-aware test-graph ancestor entirely.

## Verification
- All five action families matched live upstream tag commits
(`github-script` uses the peeled annotated-tag commit).
- Workflow YAML parse passed.
- AI workflow generated-output check passed.
- 22 focused mobile/Playwright workflow unit tests passed.
- `git diff --check` passed.
- Ruby/Bundler is unavailable locally; Linux/macOS Fastlane resolution
remains a required CI gate.
2026-07-22 19:43:57 +02:00
Jeppe B 74dd8e3691 Preserve redacted FTPS failure diagnostics (#213)
## Summary
- Preserve lftp stdout/stderr when the process exits non-zero.
- Surface bounded, whitespace-normalized diagnostics through the deploy
error.
- Redact FTPS host, username, password, path, URL userinfo, and encoded
secret forms.

## Verification
- `vitest run tests/unit/cpanel-deploy.spec.js` (25/25)
- `node --check scripts/release/cpanel-deploy-lib.mjs`
- `git diff --check`

This is the prerequisite diagnostic repair for failed Frontend Release
run 29854900889. Production was not switched during that failure.
2026-07-22 19:23:04 +02:00
Jeppe B 7782d93fe9 Gate mobile releases behind explicit phased rollout (#212)
Require explicit mobile-v* tags or manual dispatch, gate exact tested master SHAs, and default Google Play production submissions to an initial 1% in-progress rollout.
2026-07-22 18:51:40 +02:00
Jeppe BandJeppe Bundgaard fd26b0ee81 Package frontend releases without host zip tools (#211)
## Summary

- remove the release packager's undeclared dependency on host `zip` and
`unzip` executables
- create and round-trip validate ZIP artifacts in Node with explicit
paths, permissions, timestamps, CRC checks, and resource limits
- preserve the existing archive filename, checksum, inventory, and
top-level `dist/` contract

## Root cause

After the prebuilt-dist integrity repair passed on master, Frontend
Release reached packaging and failed with `spawn zip ENOENT` on the
self-hosted runner. The workflow never installed or checked either
archive executable.

## Verification

- focused release/deployment tests: 57/57 passed
- packager tests: 9/9 passed, including empty `PATH`, cross-timezone
determinism, exact archive entries, permissions, and oversized
sparse-file rejection
- real production build: 735 files packaged successfully with an empty
`PATH`
- repeated real packaging produced byte-identical archives
- Info-ZIP test/list/checksum validation passed
- extraction under `umask 077`: every directory is `0755`; all 735 files
extracted
- extracted inventory exactly matches the source inventory
- ESLint and Prettier passed

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-21 16:43:19 +00:00
Jeppe B a01902356d Harden hosted releases and mobile store gates (#205) 2026-07-21 18:17:47 +02:00
Jeppe BandJeppe Bundgaard 0692cb3aea Preserve prebuilt dist during release gate (#210)
## Summary

- serve the release workflow's already-built `dist` from the production
Playwright gate
- preserve the existing auto-build behavior for standalone local
production tests
- keep the pre/post `dist` inventory guard strict and unchanged

## Root cause

The release workflow built and fingerprinted `dist`, but Playwright then
launched `preview:prod`, which ran a second Vite build. Timestamped
build metadata changed hashed chunks and caused the integrity comparison
to fail after all 26 production browser tests had passed.

## Verification

- production Playwright gate: 26/26 passed
- pre/post inventory: 735 files, zero changes
- release package validation: 735 files passed
- ESLint passed
- Prettier passed
- release package unit tests: 7/7 passed
- workflow YAML parsed successfully
- no test files changed

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-21 15:23:41 +00:00
Jeppe B de3f067372 Run frontend tests concurrently on GitHub-hosted runners (#209)
Parallelize quality and Playwright jobs while preserving the existing test suite and required CI contracts.
2026-07-21 16:03:57 +02:00
Jeppe B a0c11e4bb7 Fix POS release-blocking customer rules and completion (#208)
Resolve direct-order product reconciliation context, preserve valid selections when restricted products are activated, remove the redundant certificate request, and add focused regression coverage.
2026-07-21 15:42:50 +02:00
Jeppe B 71e7fac555 Fix literal translation coverage (#206)
Replace finite dynamic view translations with explicit literal-key mappings so the i18n integrity gate can verify locale coverage without changing rendered copy or tests.
2026-07-20 23:54:04 +02:00
Jeppe BandJeppe Bundgaard 6dc27a355f Fix privacy fallbacks and mobile safe-area test (#204)
Restore a green `master` baseline before processing the Dependabot
queue.

This PR:
- replaces the accidental Danish privacy-policy fallback in German,
Norwegian, and Swedish catalogs with an explicit English fallback;
- keeps the generated locale catalogs synchronized and preserves the
glossary de-duplication from #203;
- updates the mobile safe-area E2E setup from `/login` to public
`/guest/home`, which renders the header geometry the test asserts.

Verification on the final rebased tree:
- i18n compile/check and all catalog audits
- focused Vitest: 5/5
- Chromium mobile Playwright: 13/13
- Prettier, ESLint, encoding, and `git diff --check`

WebKit remains covered by GitHub CI because the local host lacks its
required runtime libraries.

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-20 19:28:32 +00:00
Jeppe B eeec725f08 Restore complete privacy-policy locale coverage (#203)
## Summary
- add the privacy-policy compatibility source to German, Norwegian, and
Swedish so every active locale has the same runtime key set
- reuse existing English and German glossary tokens so the raw v2
catalog keeps its word-deduplication invariant
- regenerate the affected runtime locale catalogs

## Verification
- `playwright test tests/e2e/i18n-v2-integrity.spec.ts
--project=chromium-mobile` (12 passed)
- `vitest run tests/unit/app-store-product-readiness.spec.js` (5 passed)
- i18n source compile check and all three catalog audits
- Prettier, encoding, and `git diff --check`
2026-07-20 20:51:32 +02:00
Jeppe B 97df3193e3 Complete App Store artwork and signed release automation (#200)
Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
2026-07-20 18:19:47 +00:00
Jeppe B ed2d67934c Unblock app icon validation in CI (#202)
Format the app icon background test and avoid per-pixel assertion overhead so the full unit suite remains bounded.
2026-07-20 19:20:26 +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 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
180 changed files with 8721 additions and 1317 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]
+88
View File
@@ -0,0 +1,88 @@
name: App Store Readiness
on:
pull_request:
push:
branches: [master]
paths:
- "fastlane/**"
- "ios/**"
- "scripts/mobile/**"
- "tests/node/app-store-connect.test.mjs"
- ".github/workflows/app-store-readiness.yml"
- "Gemfile*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: app-store-readiness-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
name: App Store Readiness
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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: Install the pinned Fastlane dependency graph
run: bundle install --jobs 4 --retry 3
- name: Validate strict App Store metadata and candidate assets
run: node scripts/mobile/validate-app-store.mjs --strict
- name: Validate native mobile permissions
run: node scripts/mobile/check-permissions.mjs
- name: Test App Store Connect automation
run: node --test tests/node/app-store-connect.test.mjs
- name: Validate Fastlane configuration
run: bundle exec fastlane lanes
- name: Validate JavaScript syntax
run: |
node --check scripts/mobile/validate-app-store.mjs
node --check scripts/mobile/app-store-connect.mjs
node --check scripts/mobile/create-ios-release-manifest.mjs
node --check tests/node/app-store-connect.test.mjs
node scripts/mobile/app-store-connect.mjs self-test-jwt
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
-99
View File
@@ -1,99 +0,0 @@
name: cPanel Root Audit and Restore
on:
workflow_dispatch:
inputs:
mode:
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
required: true
default: audit
type: choice
options:
- audit
- restore
recovery:
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
required: false
type: string
state_token:
description: Exact 64-character audit-metadata state token reported by the audit.
required: false
type: string
confirmation:
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
required: false
type: string
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-restore:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Audit cPanel primary webroot
if: inputs.mode == 'audit'
id: audit
run: node scripts/release/cpanel-root.mjs audit
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
- name: Validate restore inputs
if: inputs.mode == 'restore'
env:
RECOVERY: ${{ inputs.recovery }}
STATE_TOKEN: ${{ inputs.state_token }}
CONFIRMATION: ${{ inputs.confirmation }}
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
run: |
test -n "$RECOVERY"
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
- name: Restore retained cPanel webroot
if: inputs.mode == 'restore'
run: node scripts/release/cpanel-root.mjs restore
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
- name: Upload cPanel root report
if: always()
uses: actions/upload-artifact@v4
with:
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root
if-no-files-found: ignore
retention-days: 30
@@ -0,0 +1,190 @@
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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 and verify App Store candidate
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.IOS_SOURCE_SHA }}
persist-credentials: false
- name: Setup Ruby and pinned Fastlane
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
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: Configure automatic release after approval
run: node scripts/mobile/app-store-connect.mjs configure-release-policy
- name: Read back exact App Store candidate
id: readback
run: node scripts/mobile/app-store-connect.mjs verify-store-version
- name: Verify Denmark-only availability and no preorder
id: availability
run: node scripts/mobile/app-store-connect.mjs verify-availability
- name: Write candidate handoff
env:
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
RELEASE_TYPE: ${{ steps.readback.outputs.release_type }}
AVAILABLE_TERRITORIES: ${{ steps.availability.outputs.available_territories }}
run: |
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Release policy: \`$RELEASE_TYPE\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Availability: \`$AVAILABLE_TERRITORIES\` only; preorder disabled" >> "$GITHUB_STEP_SUMMARY"
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
echo "- App Review submission remains manual; Apple will release automatically after approval." >> "$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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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"
+3 -3
View File
@@ -63,7 +63,7 @@ jobs:
fi
- name: Checkout same-repository history
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -136,7 +136,7 @@ jobs:
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
steps:
- name: Checkout resolved source
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve.outputs.source_sha }}
fetch-depth: 1
@@ -596,7 +596,7 @@ jobs:
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
- name: Upload device-debug artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
+431
View File
@@ -0,0 +1,431 @@
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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
- name: Require green WebKit mobile tests before App Store upload
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
deliver:
name: Sign, upload, process, and distribute
needs: prepare
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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."
+87 -307
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
@@ -35,99 +30,102 @@ on:
description: Google Play release status for manual dispatches
required: false
type: choice
default: completed
default: inProgress
options:
- completed
- draft
- inProgress
- draft
- halted
push:
tags:
- "mobile-v*"
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
permissions:
contents: read
actions: read
concurrency:
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
group: android-store-artifacts-${{ github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
android:
name: Android AAB and Play upload
if: >
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-24.04
environment: mobile-store-production
timeout-minutes: 60
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
PLAY_STORE_USER_FRACTION: "0.01"
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
ref: ${{ github.sha }}
persist-credentials: false
- name: Guard current master release
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 }}
EXPECTED_SHA: ${{ github.sha }}
RELEASE_BRANCH: ${{ github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(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
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- name: Require green Chromium mobile tests before Play upload
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
TEST_WORKFLOW_RUN_ID: ""
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
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@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
@@ -203,282 +201,64 @@ jobs:
if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Record immutable Android artifact proof
if: steps.release-guard.outputs.current == 'true'
id: artifact-proof
shell: bash
run: |
set -euo pipefail
artifact_sha256="$(sha256sum "$ANDROID_AAB_PATH" | awk '{print $1}')"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "ANDROID_AAB_SHA256=$artifact_sha256" >> "$GITHUB_ENV"
echo "sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
echo "Android AAB SHA-256: \`$artifact_sha256\`" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error
retention-days: 14
- name: Recheck live master before Play upload
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
live_master_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
exit 1
}
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
id: play-upload
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
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
- name: Record Google Play submission proof
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
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 }}
ARTIFACT_SHA256: ${{ steps.artifact-proof.outputs.sha256 }}
PLAY_EDIT_ID: ${{ steps.play-upload.outputs.play_edit_id }}
PLAY_VERSION_CODE: ${{ steps.play-upload.outputs.version_code }}
run: |
set -euo pipefail
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
test -n "$ARTIFACT_SHA256"
test -n "$PLAY_EDIT_ID"
test -n "$PLAY_VERSION_CODE"
printf 'Google Play submission proof: platform=android applicationId=%s version=%s buildNumber=%s artifactSha256=%s signingIdentityRef=%s storeSubmissionId=%s status=%s fraction=%s\n' \
"$ANDROID_PACKAGE_NAME" "$MOBILE_VERSION_NAME" "$PLAY_VERSION_CODE" \
"$ARTIFACT_SHA256" "$ANDROID_SIGNING_IDENTITY_REF" "$PLAY_EDIT_ID" \
"$PLAY_STORE_RELEASE_STATUS" "$PLAY_STORE_USER_FRACTION" >> "$GITHUB_STEP_SUMMARY"
+65 -23
View File
@@ -24,7 +24,7 @@ jobs:
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'master' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-24.04
env:
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Check release commit is current
id: branch-head
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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
@@ -111,6 +111,7 @@ jobs:
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
env:
PLAYWRIGHT_PROD_PREBUILT: "1"
PLAYWRIGHT_PROD_WEBKIT: "0"
- name: Confirm production gate did not mutate dist
@@ -147,7 +148,7 @@ jobs:
- name: Upload release package
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.package-names.outputs.artifact_name }}
path: |
@@ -160,7 +161,7 @@ jobs:
deploy-frontend-production:
needs: build-release
if: needs.build-release.outputs.current == 'true'
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-24.04
timeout-minutes: 90
environment:
name: frontend-production
@@ -183,14 +184,14 @@ jobs:
RELEASE_POLL_INTERVAL_SECONDS: 5
steps:
- name: Checkout tested commit
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
@@ -217,7 +218,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 +244,7 @@ jobs:
- name: Check release commit is still current
id: branch-head
uses: actions/github-script@v7
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ github.token }}
script: |
@@ -272,28 +273,26 @@ jobs:
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_GITHUB_TOKEN: ${{ github.token }}
- name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
id: public_live
timeout-minutes: 10
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
- name: Credentialed live Playwright gate (when configured)
if: steps.branch-head.outputs.current == 'true'
id: credentialed_live
timeout-minutes: 15
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
@@ -302,7 +301,10 @@ jobs:
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Roll back after live verification failure
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
if: >-
failure() && steps.branch-head.outputs.current == 'true' &&
steps.deploy.outcome == 'success' &&
(steps.public_live.outcome == 'failure' || steps.credentialed_live.outcome == 'failure')
timeout-minutes: 10
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
@@ -312,14 +314,13 @@ jobs:
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
timeout-minutes: 5
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
@@ -327,7 +328,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
@@ -341,10 +342,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
path: output/frontend-release-proof/frontend-release-proof.json
if-no-files-found: error
retention-days: 30
- name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
+147 -48
View File
@@ -48,15 +48,17 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
# - FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL: full-suite parallelism while PR E2E runs beside it.
# GitHub-hosted target: ["ubuntu-24.04"], with PR parallelism 10, concurrent full parallelism 26,
# and standalone scheduled full parallelism 36. This keeps the workflow peak at 36 hosted jobs.
jobs:
format-tests:
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
@@ -78,7 +80,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -94,10 +96,15 @@ jobs:
- name: Check frontend test formatting
run: npm run format:tests:check
build-and-unit:
needs: format-tests
quality-checks:
name: Quality-${{ matrix.check }}
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 5
matrix:
check: [lint, i18n, build, unit-fast, unit-serial]
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
@@ -115,7 +122,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -125,19 +132,58 @@ jobs:
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check
run: npm run build
- name: Unit tests
run: npm run test:unit
- name: Run quality check
shell: bash
env:
VITEST_BATCH_SIZE: 5
MATRIX_CHECK: ${{ matrix.check }}
run: |
set -euo pipefail
case "$MATRIX_CHECK" in
lint)
npm run lint
;;
i18n)
npm run i18n:v2:check
;;
build)
npm run build
;;
unit-fast)
npm run text:check-encoding
npm run test:unit:fast
;;
unit-serial)
VITEST_BATCH_SIZE=5 npm run test:unit:serial
;;
*)
echo "Unsupported quality check: $MATRIX_CHECK" >&2
exit 1
;;
esac
build-and-unit:
if: ${{ always() }}
name: Build and unit summary
needs: [format-tests, quality-checks]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 5
steps:
- name: Verify quality jobs succeeded
shell: bash
env:
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
run: |
set -euo pipefail
failed=0
for required_job in FORMAT_TESTS_RESULT QUALITY_CHECKS_RESULT; do
result="${!required_job:-missing}"
if [[ "$result" != "success" ]]; then
echo "${required_job}=${result}" >&2
failed=1
fi
done
exit "$failed"
e2e-targeted:
if: >
@@ -177,7 +223,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -301,7 +347,7 @@ jobs:
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-targeted-${{ matrix.project }}
path: |
@@ -329,7 +375,7 @@ jobs:
fail-fast: false
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
matrix:
suite: [core, changed]
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
@@ -353,7 +399,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -405,8 +451,11 @@ jobs:
run: |
set -euo pipefail
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
changed-1-of-2) suite_offset=0 ;;
changed-2-of-2) suite_offset=10 ;;
smoke) suite_offset=20 ;;
pr) suite_offset=30 ;;
ct) suite_offset=40 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
@@ -414,7 +463,7 @@ jobs:
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
port_seed=$((21000 + (RUN_ID % 20000) + suite_offset + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
@@ -485,13 +534,27 @@ jobs:
}
install_dependencies
ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
fi
case "$MATRIX_SUITE" in
ct)
npm run test:ct -- --project="$MATRIX_PROJECT"
;;
smoke)
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
;;
pr)
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
;;
changed-1-of-2)
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=1/2 --pass-with-no-tests
;;
changed-2-of-2)
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=2/2 --pass-with-no-tests
;;
*)
echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2
exit 1
;;
esac
'
- name: Runner diagnostics after Playwright failure
@@ -502,7 +565,7 @@ jobs:
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
@@ -542,23 +605,28 @@ jobs:
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-pr, e2e-targeted]
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
needs: [build-and-unit, e2e-targeted]
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') > 0 && github.event_name == 'schedule' && fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') || fromJSON(vars.FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL || '26') }}
matrix:
browser: [chromium, webkit, firefox]
device: [mobile, desktop, tablet]
role: [superuser, admin, customer, subuser]
shard_index: [1, 2]
browser: [chromium, webkit, firefox]
exclude:
- role: customer
shard_index: 2
- role: subuser
shard_index: 2
include:
- browser: chromium
browser_label: Chromium
@@ -570,7 +638,7 @@ jobs:
browser_label: Firefox
browser_install: firefox
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: off
@@ -591,7 +659,7 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -604,6 +672,8 @@ jobs:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
MATRIX_SHARD_INDEX: ${{ matrix.shard_index }}
MATRIX_SHARD_TOTAL: ${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
@@ -626,7 +696,12 @@ jobs:
tablet) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
case "$MATRIX_SHARD_INDEX" in
1) shard_offset=0 ;;
2) shard_offset=400 ;;
*) echo "Unsupported Playwright shard index: $MATRIX_SHARD_INDEX" >&2; exit 1 ;;
esac
port_seed=$((22000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset + shard_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
@@ -660,8 +735,8 @@ jobs:
exit 1
fi
mkdir -p output/playwright
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" \
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" \
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
@@ -676,6 +751,8 @@ jobs:
--env MATRIX_ROLE="$MATRIX_ROLE" \
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
--env MATRIX_SHARD_INDEX="$MATRIX_SHARD_INDEX" \
--env MATRIX_SHARD_TOTAL="$MATRIX_SHARD_TOTAL" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
@@ -696,23 +773,45 @@ jobs:
}
install_dependencies
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE" --shard="$MATRIX_SHARD_INDEX/$MATRIX_SHARD_TOTAL"
'
- name: Runner diagnostics after Playwright failure
if: failure() || cancelled()
continue-on-error: true
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}"
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}/shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}.txt
if-no-files-found: ignore
retention-days: 1
full-e2e-summary:
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
name: Full E2E summary
needs: [e2e-full]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify full E2E succeeded
shell: bash
env:
FULL_E2E_RESULT: ${{ needs.e2e-full.result }}
run: |
set -euo pipefail
if [[ "$FULL_E2E_RESULT" != "success" ]]; then
echo "E2E_FULL_RESULT=${FULL_E2E_RESULT:-missing}" >&2
exit 1
fi
+4
View File
@@ -0,0 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.237.0"
+248
View File
@@ -0,0 +1,248 @@
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.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
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 (1.6.0)
logger
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.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.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.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
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.105.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.1)
jwt (3.2.0)
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.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pstore (0.2.1)
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.237.0)
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: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 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>
+7
View File
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
server: {
androidScheme: "https",
},
plugins: {
StatusBar: {
overlaysWebView: false,
style: "LIGHT",
backgroundColor: "#FFFFFFFF",
},
},
};
export default config;
+187 -80
View File
@@ -1,102 +1,209 @@
# 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 remains a human action in App
Store Connect; the approved version releases automatically after Apple approval.
## 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 |
| Bundle ID | `io.truckwash.app` |
| SKU | `truckwash-ios` |
| Primary language | Danish |
| Category | Business |
| Price | Free |
| Availability | Denmark only |
| Support URL | `https://truckwash.io/support` |
| Privacy URL | `https://truckwash.io/privacy-policy` |
| Marketing URL | `https://truckwash.io/` |
| Release | Automatically after approval |
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. Do not enable preorder or phased release for version
`1.0.0`, and disable automatic availability in newly added territories.
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. App Store Connect API readback must show `AFTER_APPROVAL`, Denmark
(`DNK`) as the only available territory, preorder disabled, and automatic
future territories disabled.
Environment secrets:
- `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 also writes and verifies automatic release after approval, then verifies
Denmark-only availability and no preorder. It does not rebuild or submit for
review.
5. In App Store Connect, review the rendered product page, review account,
privacy/export/age answers, and candidate build. Submit manually.
6. Submit version `1.0.0` for review. Apple releases it automatically after
approval. Do not use phased release for `1.0.0`; use phased release for later
updates unless there is a reason not to.
7. Merge the next `ios/release.json` version bump before further delivery after
Apple closes the released version to new builds.
## 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.
+72 -96
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,32 +45,23 @@ Add these environment **secrets**:
- `PRODUCTION_FTP_USER`
- `PRODUCTION_FTP_PASSWORD`
- `PRODUCTION_FTP_PATH`
- `PRODUCTION_CPANEL_USER`
- `PRODUCTION_CPANEL_API_TOKEN`
- `PRODUCTION_ACTIVATION_KEY`
The API `.env` contains legacy values under the first four names, but production
frontend deployment uses a dedicated cPanel FTP account jailed to
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
deployment unchanged.
The cPanel token is separate from the FTP password. Create it in cPanel under
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
uses cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI
replacement for the required extract, symlink, and rename operations. Revoke
and rotate the token if it is ever exposed.
The hosted release path deliberately does not call the remote cPanel API.
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
activator, exact inventory comparison, and public manifest verification.
Add these environment **variables**:
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
fallback. The cPanel URL and path deliberately fail closed when absent. The
production environment must keep the explicit
`https://server.red-block.com:2083` cPanel origin: the public origin serves
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
cPanel JSON API.
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
### Create the dedicated FTP credentials
@@ -84,53 +77,26 @@ cPanel JSON API.
6. Verify explicit FTPS login and directory listing before merging. Never copy
these frontend-only credentials back into the API `.env`.
### Create the missing cPanel credentials
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
separately; do not reuse the FTP password as an API token.
1. Sign in to the cPanel account that owns the frontend deployment root.
2. Record the exact cPanel account username shown in **General Information**.
Add it to the `frontend-production` environment as the
`PRODUCTION_CPANEL_USER` secret.
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
hosting provider to enable API Tokens in WHM Feature Manager.
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
expiration date that matches the team's rotation policy. Expiration cannot
be edited later, so add a reminder before that date.
5. Click **Create**, copy the token immediately, and add it to the same GitHub
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
again after leaving the page.
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
the GitHub secret has been saved.
7. Before merging, run the deployment preflight against the configured API
origin. It must be able to call cPanel API2 `Fileman::fileop` for extract,
symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider
restricts those operations, request the required account feature access;
do not broaden the token or deployment root beyond this cPanel account.
The current production token is named `github-pleno-vue-production` and
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
secret before that date, then revoke the replaced token in cPanel.
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
Use **Add secret** for credentials and **Add variable** for the two URLs and the
cPanel deployment path.
Use **Add secret** for credentials and **Add variable** for the frontend URL.
Environment values are available only to the deployment job that names this
environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. 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 GitHub-hosted deployment job installs
`lftp` job-locally when needed, configures Node 22, and installs Playwright
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
and `sha256sum`.
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
`sha256sum` for the account-scoped activator.
## cPanel layout and one-time bootstrap
The production FTP account is jailed directly to the deployment root, so its
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
relative to the cPanel account home. The helper creates this layout below it:
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
`frontend-deployments` directory below the account home. The helper creates
this layout below it:
```text
archives/
@@ -142,7 +108,7 @@ current -> releases/<release-id>/dist
```
The domain's document root must resolve to
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
`<cPanel account home>/frontend-deployments/current`, not to the deployment
root itself. This stable document-root path is what makes replacing `current`
atomic: every HTTP request resolves either the complete old release or the
complete new release, never a partly uploaded directory.
@@ -168,9 +134,31 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
listing.
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
and the API health request work at `PRODUCTION_FRONTEND_URL`.
8. Test the cPanel token against the exact host and port. The workflow performs
a disposable symlink-replacement preflight and refuses deployment if the
filesystem or hosting policy cannot replace a symlink atomically.
8. The server-side activator, rather than the hosted runner, validates that
`current` and the captured rollback release exist before every switch.
9. Generate a dedicated 32-byte random activation key. Store its 64-character
hexadecimal form in the protected `frontend-production` environment as
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
`/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
@@ -178,45 +166,33 @@ first automated run into an unreviewed production cutover.
### Auditing or restoring the primary webroot
Use the protected **cPanel Root Audit and Restore** workflow if the primary
domain starts showing a directory index or returns 404 for files that cPanel
lists in `public_html`. The `audit` mode is read-only: it reports the exact
`public_html` entry, whether the internal `current` link can serve the required
release files, domain document roots, and retained recovery candidates without
printing the cPanel token. API2 does not expose a documented symlink-target
field, so the audit deliberately reports `rootTargetVerified: false` instead
of claiming that an arbitrary `public_html` link follows `current`; the live
HTTP checks remain the source of truth for service health. The audit fails
closed if any domain record lacks an identity or document root, and restore is
blocked while an addon or subdomain is rooted below `public_html`.
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
standard GitHub-hosted runner addresses, and this GitHub Team organization
cannot assign static egress to a larger hosted runner. Keeping a configurable
runner label would risk sending production cPanel secrets to a self-hosted
runner, so that workflow has been removed.
If the regression followed the one-time webroot exchange, select `restore`
and copy one exact recovery entry from the audit, including the retained
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
The workflow requires the
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
exact token string from that audit. The token is an optimistic-concurrency
guard over the cPanel metadata visible to the audit; it is not a content hash
or a substitute for validating the selected recovery. Restore also rejects an
unreadable physical directory. An unreadable root is eligible only when the
independent account-home listing identifies it as a symbolic link. It renames
the current entry to a run-specific `public_html.failed-*` path, restores the retained entry, and
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
any mutation response is lost or any check fails, it reconciles the observed
account-home entries and reinstates the pre-restore cPanel state. It never
deletes the recovery or displaced webroot, and reports manual intervention if
the expected entries cannot be proven after compensation.
If the primary domain starts showing a directory index or returns 404 for files
visible in `public_html`, inspect and recover it through the cPanel web interface
or the hosting provider. Before replacing anything, confirm the exact
`public_html` entry, the `frontend-deployments/current` link and required release
files, all domain document roots, and retained `public_html.recovery-*`,
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
replace the root while an addon or subdomain document root is nested below it.
Restore only a verified physical directory, retain the displaced webroot, and
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
releases do not depend on remote cPanel API access.
## Caching and compatibility
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
one-year immutable policy. `index.html`, release metadata, web manifests, and
service-worker control files always revalidate. The deployer retains at least
the active and rollback releases and keeps five recent release directories by
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
falls outside that validated retention set, its directory and matching ZIP and
checksum are removed over FTPS. Cleanup failure is reported without rolling
back an otherwise verified deployment.
service-worker control files always revalidate. The deployer retains every
immutable release while hosted runners cannot query reliable cPanel
modification metadata. Each successful run reports that retention cleanup is
deferred. Periodically review disk usage in cPanel and remove only inactive
releases and their matching archives; never remove the active or recorded
rollback target.
Because the document root switches as one symlink, an already-loaded page may
still request an asset from its previous release after activation. The current
+46 -83
View File
@@ -1,35 +1,25 @@
# 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`, with
all six full Chromium-mobile role shards explicitly verified as green.
- Manual dispatch with version, version code, upload toggle, track, and status.
- Existing `mobile-v*` tags for the Android workflow.
Default upload behavior:
Every Google Play upload path must resolve an exact completed `Automated Tests`
push run for the same current-master commit. Manual no-upload artifact builds
remain available for safe CI validation without invoking the store gate.
- 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 +27,48 @@ 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:
Before signing or uploading to TestFlight, the workflow resolves the exact
current-master test run and requires all six full WebKit-mobile role shards to
be green. App Store candidates reuse that gated TestFlight build and do not
rebuild it.
The GitHub environments and variables are documented in
`docs/app-store-release.md`. The repository-level
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
`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: true,
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 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+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: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 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

+4
View File
@@ -24,8 +24,12 @@
<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>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Truck Wash uses your location to find or confirm the nearest truck wash department when you choose a location feature; the app does not track your location in the background.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
<key>UILaunchStoryboardName</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>
+4
View File
@@ -0,0 +1,4 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash bruger kameraet til at scanne QR-koder og registreringsnumre, når du vælger en scanningsfunktion.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash bruger din placering til at finde eller bekræfte den nærmeste Truck Wash-afdeling, når du vælger en placeringsfunktion; appen sporer ikke din placering i baggrunden.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash bruger din placering, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling.";
+4
View File
@@ -0,0 +1,4 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash uses the camera to scan QR codes and vehicle registration plates when you choose a scanning feature.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash uses your location to find or confirm the nearest Truck Wash department when you choose a location feature; the app does not track your location in the background.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash uses your location while the app is open to find or confirm the nearest Truck Wash department.";
+4 -2
View File
@@ -12,7 +12,8 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation")
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
.target(
@@ -20,7 +21,8 @@ let package = Package(
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation")
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
]
+5
View File
@@ -0,0 +1,5 @@
{
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
+43
View File
@@ -12,6 +12,7 @@
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
@@ -82,6 +83,7 @@
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
@@ -2131,6 +2133,14 @@
"@capacitor/core": "^8.4.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
"integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
@@ -10087,6 +10097,12 @@
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -10994,6 +11010,18 @@
"node": ">=0.10.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -11062,6 +11090,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -13627,6 +13664,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+5
View File
@@ -74,6 +74,9 @@
"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:screenshots": "node scripts/playstore/generate-graphics.mjs --app-store-screenshots",
"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": {
@@ -81,6 +84,7 @@
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
@@ -151,6 +155,7 @@
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
+6 -1
View File
@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
const isCI = !!process.env.CI;
const usePrebuiltDist = ["1", "true"].includes(
String(process.env.PLAYWRIGHT_PROD_PREBUILT || "")
.trim()
.toLowerCase()
);
process.env.PLAYWRIGHT_BASE_URL = baseURL;
@@ -73,7 +78,7 @@ export default defineConfig({
video: "retain-on-failure",
},
webServer: {
command: "npm run preview:prod",
command: usePrebuiltDist ? "npm run preview -- --host 127.0.0.1 --port 4173" : "npm run preview:prod",
url: baseURL,
timeout: 240_000,
reuseExistingServer: !isCI,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+2 -5
View File
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
@@ -78,7 +75,7 @@ console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
"The runner image must provide the required browser system libraries.",
].join("\n")
);
+420
View File
@@ -0,0 +1,420 @@
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { argv, env, exit } from "node:process";
import { fileURLToPath } from "node:url";
export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
export const TESTFLIGHT_BETA_LOCALE = "da";
export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
const base64url = (value) => Buffer.from(value).toString("base64url");
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
const params = new URLSearchParams({
"filter[platform]": "IOS",
"filter[versionString]": version,
limit: "10",
});
if (includeBuild) params.set("include", "build");
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
};
export const createAppStoreConnectClient = ({
environment = env,
fetchImpl = globalThis.fetch,
sleepImpl = defaultSleep,
now = () => Date.now(),
logger = console,
tokenProvider,
outputWriter,
} = {}) => {
const required = (name) => {
const value = environment[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const appId = () => required("APP_STORE_CONNECT_APP_ID");
const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
const version = () => required("IOS_MARKETING_VERSION");
const buildNumber = () => required("IOS_BUILD_NUMBER");
const token = () => {
if (tokenProvider) return tokenProvider();
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 issuedAt = Math.floor(now() / 1_000);
const payload = { aud: "appstoreconnect-v1", iat: issuedAt, exp: issuedAt + 1_200 };
if (environment.APP_STORE_CONNECT_ISSUER_ID) payload.iss = environment.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 writeOutput = (key, value) => {
if (outputWriter) outputWriter(key, String(value));
else if (environment.GITHUB_OUTPUT) appendFileSync(environment.GITHUB_OUTPUT, `${key}=${value}\n`);
else logger.log(`${key}=${value}`);
};
const request = async (path, options = {}, attempt = 1) => {
const response = await fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${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) {
const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") || "", 10);
const delay = Number.isSafeInteger(retryAfter)
? Math.min(30_000, retryAfter * 1_000)
: Math.min(30_000, 2 ** attempt * 1_000);
await sleepImpl(delay);
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 collectPages = async (path) => {
let url = path;
const data = [];
const included = [];
while (url) {
const page = await request(url);
data.push(...(page?.data ?? []));
included.push(...(page?.included ?? []));
url = page?.links?.next ?? null;
}
return { data, included };
};
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()}`
);
}
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
return app.data;
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[preReleaseVersion.version]": version(),
limit: "200",
});
const response = await collectPages(`/builds?${params}`);
return response.data;
};
const allStoreVersions = async ({ includeBuild = false } = {}) =>
collectPages(appStoreVersionsPath({ appId: appId(), version: version(), includeBuild }));
const findStoreVersion = async ({ includeBuild = false } = {}) => {
const response = await allStoreVersions({ includeBuild });
return {
storeVersion: response.data.find((candidate) => candidate?.attributes?.versionString === version()),
included: response.included,
};
};
const findExactBuild = async () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
const { storeVersion } = await findStoreVersion();
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);
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
return next;
};
const waitForBuild = async () => {
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
while (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}`);
}
logger.log(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleepImpl(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]": TESTFLIGHT_BETA_LOCALE,
});
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0];
const whatsNew = environment.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: TESTFLIGHT_BETA_LOCALE, 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);
logger.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
);
return build;
};
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 (environment.EXPECTED_APP_STORE_BUILD_ID && build.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`Candidate App Store build ID ${build.id} does not match release manifest ${environment.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", build.id);
logger.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
return build;
};
const configureReleasePolicy = async () => {
await verifyCredentials();
const { storeVersion } = await findStoreVersion();
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
if (storeVersion.attributes?.appStoreState === "READY_FOR_SALE") {
throw new Error(`App Store version ${version()} is already released and cannot change release policy`);
}
await request(`/appStoreVersions/${encodeURIComponent(storeVersion.id)}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "appStoreVersions",
id: storeVersion.id,
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
},
}),
});
writeOutput("app_store_version_id", storeVersion.id);
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
return storeVersion.id;
};
const verifyStoreVersion = async () => {
await verifyCredentials();
const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
const includedBuild = 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()}`);
}
if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`App Store version ${version()} is attached to ${includedBuild.id}, expected ${
environment.EXPECTED_APP_STORE_BUILD_ID
}`
);
}
if (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
throw new Error(
`App Store version ${version()} release type is ${
storeVersion.attributes?.releaseType || "unknown"
}, expected ${EXPECTED_RELEASE_TYPE}`
);
}
writeOutput("app_store_version_id", storeVersion.id);
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
writeOutput("release_type", storeVersion.attributes.releaseType);
logger.log(
`Verified App Store version ${version()} with exact build ${buildNumber()} and ${EXPECTED_RELEASE_TYPE} release policy in ${
storeVersion.attributes?.appStoreState || "unknown state"
}.`
);
return storeVersion;
};
const verifyAvailability = async () => {
await verifyCredentials();
const availability = await request(`/apps/${encodeURIComponent(appId())}/appAvailabilityV2`);
const availabilityId = availability?.data?.id;
if (!availabilityId) throw new Error("App Store availability was not configured");
if (availability?.data?.attributes?.availableInNewTerritories !== false) {
throw new Error("App Store availability must not automatically include new territories");
}
const params = new URLSearchParams({ include: "territory", limit: "200" });
const territories = await collectPages(
`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/${encodeURIComponent(
availabilityId
)}/territoryAvailabilities?${params}`
);
const available = territories.data
.filter((territory) => territory?.attributes?.available === true)
.map((territory) => territory?.relationships?.territory?.data?.id)
.filter(Boolean)
.sort();
if (territories.data.some((territory) => territory?.attributes?.preOrderEnabled === true)) {
throw new Error("App Store preorder must remain disabled for version 1.0.0");
}
if (JSON.stringify(available) !== JSON.stringify(EXPECTED_AVAILABLE_TERRITORIES)) {
throw new Error(
`App Store availability is ${
available.join(", ") || "empty"
}, expected Denmark only (${EXPECTED_AVAILABLE_TERRITORIES.join(", ")})`
);
}
writeOutput("available_territories", available.join(","));
logger.log("Verified Denmark-only App Store availability with preorder disabled.");
return available;
};
return {
request,
verifyCredentials,
allBuildsForVersion,
allStoreVersions,
findExactBuild,
nextBuildNumber,
waitForBuild,
waitAndDistribute,
verifyCandidate,
configureReleasePolicy,
verifyStoreVersion,
verifyAvailability,
token,
};
};
export const selfTestJwt = async () => {
const environment = {};
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
environment.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
environment.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
privateKey.export({ type: "pkcs8", format: "pem" })
).toString("base64");
const individual = JSON.parse(
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
);
if (individual.sub !== "user" || individual.iss !== undefined) {
throw new Error("Individual API JWT claim test failed");
}
environment.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
const team = JSON.parse(
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
);
if (team.iss !== environment.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.");
};
export const runCli = async (command = argv[2]) => {
const client = createAppStoreConnectClient();
const commands = {
"verify-credentials": client.verifyCredentials,
"next-build-number": client.nextBuildNumber,
"wait-and-distribute": client.waitAndDistribute,
"verify-candidate": client.verifyCandidate,
"configure-release-policy": client.configureReleasePolicy,
"verify-store-version": client.verifyStoreVersion,
"verify-availability": client.verifyAvailability,
"self-test-jwt": selfTestJwt,
};
if (!commands[command]) {
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
}
await commands[command]();
};
const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
});
}
+1
View File
@@ -28,6 +28,7 @@ requireText("AndroidManifest.xml", androidManifest, 'android:required="false"');
requireText("android/app/build.gradle", androidBuild, 'applicationId "io.truckwash.twa"');
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
requireText("Info.plist", iosInfoPlist, "NSLocationAlwaysAndWhenInUseUsageDescription");
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
+29 -9
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`);
@@ -73,7 +82,7 @@ const checkAndroid = () => {
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
@@ -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");

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