Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c82d9f523 | ||
|
|
e1092b80af | ||
|
|
ab75f134fd | ||
|
|
f2b209f078 | ||
|
|
32418b42a0 | ||
|
|
bf2208e77b | ||
|
|
5702d45bc6 | ||
|
|
42352b4c2d | ||
|
|
729416e5ef | ||
|
|
41b3be926a | ||
|
|
fc67e7cf0b | ||
|
|
9b3c06fc6f | ||
|
|
f0e3c4812b | ||
|
|
4c7d8c6f2e | ||
|
|
74dd8e3691 | ||
|
|
7782d93fe9 | ||
|
|
fd26b0ee81 | ||
|
|
a01902356d | ||
|
|
0692cb3aea | ||
|
|
de3f067372 | ||
|
|
a0c11e4bb7 | ||
|
|
71e7fac555 | ||
|
|
6dc27a355f | ||
|
|
eeec725f08 | ||
|
|
97df3193e3 | ||
|
|
ed2d67934c | ||
|
|
1a959c2ce8 | ||
|
|
88eda43560 | ||
|
|
133e53cfa6 |
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
# v5.0.1
|
# v5.0.1
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout same-repository history
|
- name: Checkout same-repository history
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -136,7 +136,7 @@ jobs:
|
|||||||
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout resolved source
|
- name: Checkout resolved source
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.resolve.outputs.source_sha }}
|
ref: ${{ needs.resolve.outputs.source_sha }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
@@ -596,7 +596,7 @@ jobs:
|
|||||||
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Upload device-debug artifact
|
- name: Upload device-debug artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
||||||
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Mobile Store Artifacts
|
name: Android Store Artifacts
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -16,11 +16,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
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:
|
android_track:
|
||||||
description: Google Play track for manual dispatches
|
description: Google Play track for manual dispatches
|
||||||
required: false
|
required: false
|
||||||
@@ -35,99 +30,102 @@ on:
|
|||||||
description: Google Play release status for manual dispatches
|
description: Google Play release status for manual dispatches
|
||||||
required: false
|
required: false
|
||||||
type: choice
|
type: choice
|
||||||
default: completed
|
default: inProgress
|
||||||
options:
|
options:
|
||||||
- completed
|
|
||||||
- draft
|
|
||||||
- inProgress
|
- inProgress
|
||||||
|
- draft
|
||||||
- halted
|
- halted
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "mobile-v*"
|
- "mobile-v*"
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- Automated Tests
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
android:
|
android:
|
||||||
name: Android AAB and Play upload
|
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
|
runs-on: ubuntu-24.04
|
||||||
environment: mobile-store-production
|
environment: mobile-store-production
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
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_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_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_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
|
||||||
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
|
PLAY_STORE_USER_FRACTION: "0.01"
|
||||||
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
ref: ${{ github.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Guard current master release
|
- name: Guard current master release
|
||||||
id: release-guard
|
id: release-guard
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
EXPECTED_SHA: ${{ github.sha }}
|
||||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
current=true
|
current=true
|
||||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
latest_sha="$(curl --fail --silent --show-error --location \
|
||||||
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
if [[ -z "$latest_sha" ]]; then
|
-H "Accept: application/vnd.github+json" \
|
||||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||||
exit 1
|
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
fi
|
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||||
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
exit 1
|
||||||
current=false
|
fi
|
||||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||||
else
|
current=false
|
||||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||||
fi
|
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||||
|
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||||
else
|
else
|
||||||
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
|
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||||
fi
|
fi
|
||||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
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
|
- name: Setup Java
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
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
|
- name: Install Android SDK packages
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
@@ -203,282 +201,64 @@ jobs:
|
|||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
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
|
- name: Upload Android artifact
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
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 }}
|
path: ${{ env.ANDROID_AAB_PATH }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
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
|
- name: Upload Android App Bundle to Google Play
|
||||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||||
|
id: play-upload
|
||||||
env:
|
env:
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||||
run: npm run mobile:android:play-upload
|
run: npm run mobile:android:play-upload
|
||||||
|
|
||||||
ios:
|
- name: Record Google Play submission proof
|
||||||
name: iOS IPA and App Store upload
|
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||||
if: >
|
|
||||||
github.event_name != 'workflow_run' ||
|
|
||||||
(github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_branch == github.event.repository.default_branch)
|
|
||||||
runs-on: macos-15
|
|
||||||
environment: mobile-store-production
|
|
||||||
timeout-minutes: 90
|
|
||||||
env:
|
|
||||||
IOS_PROJECT_PATH: ios/App/App.xcodeproj
|
|
||||||
IOS_SCHEME: App
|
|
||||||
IOS_BUNDLE_ID: io.truckwash.app
|
|
||||||
UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
||||||
|
|
||||||
- name: Guard current master release
|
|
||||||
id: release-guard
|
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
ARTIFACT_SHA256: ${{ steps.artifact-proof.outputs.sha256 }}
|
||||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
PLAY_EDIT_ID: ${{ steps.play-upload.outputs.play_edit_id }}
|
||||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
PLAY_VERSION_CODE: ${{ steps.play-upload.outputs.version_code }}
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
current=true
|
test -n "$ARTIFACT_SHA256"
|
||||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
test -n "$PLAY_EDIT_ID"
|
||||||
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
|
test -n "$PLAY_VERSION_CODE"
|
||||||
if [[ -z "$latest_sha" ]]; then
|
printf 'Google Play submission proof: platform=android applicationId=%s version=%s buildNumber=%s artifactSha256=%s signingIdentityRef=%s storeSubmissionId=%s status=%s fraction=%s\n' \
|
||||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
"$ANDROID_PACKAGE_NAME" "$MOBILE_VERSION_NAME" "$PLAY_VERSION_CODE" \
|
||||||
exit 1
|
"$ARTIFACT_SHA256" "$ANDROID_SIGNING_IDENTITY_REF" "$PLAY_EDIT_ID" \
|
||||||
fi
|
"$PLAY_STORE_RELEASE_STATUS" "$PLAY_STORE_USER_FRACTION" >> "$GITHUB_STEP_SUMMARY"
|
||||||
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
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
github.event.workflow_run.head_branch == 'master' &&
|
github.event.workflow_run.head_branch == 'master' &&
|
||||||
github.event.workflow_run.head_repository.full_name == github.repository
|
github.event.workflow_run.head_repository.full_name == github.repository
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check release commit is current
|
- name: Check release commit is current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout tested commit
|
- name: Checkout tested commit
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -111,6 +111,7 @@ jobs:
|
|||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:prod
|
run: npm run test:e2e:prod
|
||||||
env:
|
env:
|
||||||
|
PLAYWRIGHT_PROD_PREBUILT: "1"
|
||||||
PLAYWRIGHT_PROD_WEBKIT: "0"
|
PLAYWRIGHT_PROD_WEBKIT: "0"
|
||||||
|
|
||||||
- name: Confirm production gate did not mutate dist
|
- name: Confirm production gate did not mutate dist
|
||||||
@@ -147,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload release package
|
- name: Upload release package
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.package-names.outputs.artifact_name }}
|
name: ${{ steps.package-names.outputs.artifact_name }}
|
||||||
path: |
|
path: |
|
||||||
@@ -160,7 +161,7 @@ jobs:
|
|||||||
deploy-frontend-production:
|
deploy-frontend-production:
|
||||||
needs: build-release
|
needs: build-release
|
||||||
if: needs.build-release.outputs.current == 'true'
|
if: needs.build-release.outputs.current == 'true'
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
environment:
|
environment:
|
||||||
name: frontend-production
|
name: frontend-production
|
||||||
@@ -183,14 +184,14 @@ jobs:
|
|||||||
RELEASE_POLL_INTERVAL_SECONDS: 5
|
RELEASE_POLL_INTERVAL_SECONDS: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tested commit
|
- name: Checkout tested commit
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -217,7 +218,7 @@ jobs:
|
|||||||
run: node scripts/install-playwright-browsers.mjs chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Download validated release package
|
- name: Download validated release package
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
with:
|
with:
|
||||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
@@ -243,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check release commit is still current
|
- name: Check release commit is still current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -272,28 +273,26 @@ jobs:
|
|||||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
|
||||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
|
||||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
|
||||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
- name: Public live Playwright gate
|
- name: Public live Playwright gate
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
id: public_live
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npm run test:e2e:live:public
|
run: npm run test:e2e:live:public
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
- name: Credentialed live Playwright gate
|
- name: Credentialed live Playwright gate (when configured)
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
id: credentialed_live
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
|
||||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||||
@@ -302,7 +301,10 @@ jobs:
|
|||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
- name: Roll back after live verification failure
|
- 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
|
timeout-minutes: 10
|
||||||
run: node scripts/release/deploy-cpanel.mjs --rollback
|
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||||
env:
|
env:
|
||||||
@@ -312,14 +314,13 @@ jobs:
|
|||||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
|
||||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
|
||||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
|
||||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
|
||||||
- name: Record Release Manager gate
|
- name: Record Release Manager gate
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
timeout-minutes: 5
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
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}"
|
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||||
@@ -327,7 +328,7 @@ jobs:
|
|||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
@@ -341,10 +342,51 @@ jobs:
|
|||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
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
|
- name: Upload Playwright report
|
||||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: output/playwright
|
path: output/playwright
|
||||||
|
|||||||
@@ -48,15 +48,17 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
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' }}
|
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||||
|
|
||||||
# Repository variables used as CI runner and credit controls:
|
# Repository variables used as CI runner and credit controls:
|
||||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
# - 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_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite 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:
|
jobs:
|
||||||
format-tests:
|
format-tests:
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||||
@@ -78,7 +80,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
@@ -94,10 +96,15 @@ jobs:
|
|||||||
- name: Check frontend test formatting
|
- name: Check frontend test formatting
|
||||||
run: npm run format:tests:check
|
run: npm run format:tests:check
|
||||||
|
|
||||||
build-and-unit:
|
quality-checks:
|
||||||
needs: format-tests
|
name: Quality-${{ matrix.check }}
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 5
|
||||||
|
matrix:
|
||||||
|
check: [lint, i18n, build, unit-fast, unit-serial]
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||||
@@ -115,7 +122,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
@@ -125,19 +132,58 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Lint
|
- name: Run quality check
|
||||||
run: npm run lint
|
shell: bash
|
||||||
|
|
||||||
- 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
|
|
||||||
env:
|
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:
|
e2e-targeted:
|
||||||
if: >
|
if: >
|
||||||
@@ -177,7 +223,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
@@ -301,7 +347,7 @@ jobs:
|
|||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-targeted-${{ matrix.project }}
|
name: playwright-report-targeted-${{ matrix.project }}
|
||||||
path: |
|
path: |
|
||||||
@@ -329,7 +375,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||||
matrix:
|
matrix:
|
||||||
suite: [core, changed]
|
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
|
||||||
project: [chromium-desktop, chromium-mobile]
|
project: [chromium-desktop, chromium-mobile]
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
@@ -353,7 +399,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -405,8 +451,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
case "$MATRIX_SUITE" in
|
case "$MATRIX_SUITE" in
|
||||||
core) suite_offset=0 ;;
|
changed-1-of-2) suite_offset=0 ;;
|
||||||
changed) suite_offset=10 ;;
|
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 ;;
|
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
case "$MATRIX_PROJECT" in
|
case "$MATRIX_PROJECT" in
|
||||||
@@ -414,7 +463,7 @@ jobs:
|
|||||||
chromium-mobile) project_offset=2 ;;
|
chromium-mobile) project_offset=2 ;;
|
||||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||||
esac
|
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}"
|
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||||
mkdir -p "$lock_root"
|
mkdir -p "$lock_root"
|
||||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||||
@@ -485,13 +534,27 @@ jobs:
|
|||||||
}
|
}
|
||||||
install_dependencies
|
install_dependencies
|
||||||
ulimit -n 16384 || true
|
ulimit -n 16384 || true
|
||||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
case "$MATRIX_SUITE" in
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
ct)
|
||||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
;;
|
||||||
else
|
smoke)
|
||||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||||
fi
|
;;
|
||||||
|
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
|
- name: Runner diagnostics after Playwright failure
|
||||||
@@ -502,7 +565,7 @@ jobs:
|
|||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
path: |
|
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 == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||||
needs.build-and-unit.result == 'success' &&
|
needs.build-and-unit.result == 'success' &&
|
||||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
|
||||||
(
|
(
|
||||||
github.event_name != 'workflow_dispatch' ||
|
github.event_name != 'workflow_dispatch' ||
|
||||||
inputs.mode == 'full' ||
|
inputs.mode == 'full' ||
|
||||||
needs.e2e-targeted.result == 'success'
|
needs.e2e-targeted.result == 'success'
|
||||||
)
|
)
|
||||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
needs: [build-and-unit, e2e-targeted]
|
||||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
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"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
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:
|
matrix:
|
||||||
browser: [chromium, webkit, firefox]
|
|
||||||
device: [mobile, desktop, tablet]
|
device: [mobile, desktop, tablet]
|
||||||
role: [superuser, admin, customer, subuser]
|
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:
|
include:
|
||||||
- browser: chromium
|
- browser: chromium
|
||||||
browser_label: Chromium
|
browser_label: Chromium
|
||||||
@@ -570,7 +638,7 @@ jobs:
|
|||||||
browser_label: Firefox
|
browser_label: Firefox
|
||||||
browser_install: firefox
|
browser_install: firefox
|
||||||
env:
|
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_REPORTER_MODE: line-html
|
||||||
PLAYWRIGHT_WORKERS: 1
|
PLAYWRIGHT_WORKERS: 1
|
||||||
PLAYWRIGHT_VIDEO_MODE: off
|
PLAYWRIGHT_VIDEO_MODE: off
|
||||||
@@ -591,7 +659,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
@@ -604,6 +672,8 @@ jobs:
|
|||||||
MATRIX_ROLE: ${{ matrix.role }}
|
MATRIX_ROLE: ${{ matrix.role }}
|
||||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||||
MATRIX_DEVICE: ${{ matrix.device }}
|
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_ID: ${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -626,7 +696,12 @@ jobs:
|
|||||||
tablet) device_offset=3 ;;
|
tablet) device_offset=3 ;;
|
||||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||||
esac
|
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}"
|
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||||
mkdir -p "$lock_root"
|
mkdir -p "$lock_root"
|
||||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||||
@@ -660,8 +735,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
mkdir -p output/playwright
|
mkdir -p output/playwright
|
||||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
|
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}" \
|
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 \
|
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||||
--volume "$PWD:/source:ro" \
|
--volume "$PWD:/source:ro" \
|
||||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||||
@@ -676,6 +751,8 @@ jobs:
|
|||||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
--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 \
|
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -696,23 +773,45 @@ jobs:
|
|||||||
}
|
}
|
||||||
install_dependencies
|
install_dependencies
|
||||||
ulimit -n 16384 || true
|
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
|
- name: Runner diagnostics after Playwright failure
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
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
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
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: |
|
path: |
|
||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
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 }}.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
|
if-no-files-found: ignore
|
||||||
retention-days: 1
|
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
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
ruby ">= 3.2", "< 3.5"
|
||||||
|
gem "fastlane", "2.237.0"
|
||||||
@@ -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
|
||||||
@@ -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
|
The legacy Bubblewrap/TWA project at the repository root is not used by
|
||||||
`npm run mobile:android:bundle`.
|
`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
|
```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:
|
Regenerate the checked-in launcher assets after changing that source image:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0787BB</color>
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
|||||||
server: {
|
server: {
|
||||||
androidScheme: "https",
|
androidScheme: "https",
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
StatusBar: {
|
||||||
|
overlaysWebView: false,
|
||||||
|
style: "LIGHT",
|
||||||
|
backgroundColor: "#FFFFFFFF",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,102 +1,209 @@
|
|||||||
# Apple App Store Release Runbook
|
# Apple App Store Release Runbook
|
||||||
|
|
||||||
This runbook covers the public iOS App Store release path for the Truck Wash
|
This is the operating runbook for the public iOS application and its signed
|
||||||
Capacitor app.
|
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
|
Create or reconcile one App Store Connect record:
|
||||||
the latest Apple agreements before builds can be uploaded.
|
|
||||||
- Create or verify the App Store Connect app record:
|
|
||||||
- Platform: iOS
|
|
||||||
- Name: Truck Wash Kundeportal
|
|
||||||
- Bundle ID: `io.truckwash.app`
|
|
||||||
- SKU: `truckwash-ios`
|
|
||||||
- Primary language: Danish
|
|
||||||
- Category: Business
|
|
||||||
- Price: Free
|
|
||||||
- Initial availability: Denmark
|
|
||||||
- Keep the GitHub environment `mobile-store-production` configured with the
|
|
||||||
iOS signing, App Store Connect, Android signing, and Google Play upload
|
|
||||||
secrets used by the mobile workflow.
|
|
||||||
|
|
||||||
## Build And Upload
|
| 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`.
|
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
||||||
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
|
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||||
3. Create a release tag such as `mobile-v1.0.0`.
|
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||||
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts from
|
been tested deliberately. Do not enable preorder or phased release for version
|
||||||
the tested commit. By default it uploads Android to the Google Play
|
`1.0.0`, and disable automatic availability in newly added territories.
|
||||||
production track and uploads the iOS IPA to App Store Connect.
|
|
||||||
5. For a manual upload, dispatch `Mobile Store Artifacts` with `version_name`
|
|
||||||
and `version_code`. Leave `upload_ios_to_app_store` enabled for the iOS
|
|
||||||
upload, or disable it to produce only the signed GitHub artifact.
|
|
||||||
|
|
||||||
The same workflow also runs automatically after a successful `Automated Tests`
|
The Account Holder or Admin must complete these console-only items before the
|
||||||
run on current `master`. It skips stale workflow-run commits if `master` has
|
first candidate:
|
||||||
advanced before the mobile jobs start.
|
|
||||||
|
|
||||||
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`
|
Review notes must explain customer and driver login, the review account's 2FA
|
||||||
- `IOS_CERTIFICATE_PASSWORD`
|
path, QR/hardware behavior, camera/location denial fallbacks, and the physical
|
||||||
- `IOS_PROVISION_PROFILE_BASE64`
|
service payment model.
|
||||||
- `IOS_KEYCHAIN_PASSWORD`
|
|
||||||
- `APPLE_TEAM_ID`
|
## Metadata and assets in Git
|
||||||
- `APP_STORE_CONNECT_API_KEY_ID`
|
|
||||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
`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`
|
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
|
||||||
|
|
||||||
The workflow installs the signing certificate and provisioning profile in a
|
Environment variables:
|
||||||
temporary keychain on the `macos-15` runner, archives the Capacitor Xcode
|
|
||||||
project, exports an App Store IPA, validates it with `xcrun altool`, uploads it
|
|
||||||
with the App Store Connect API key, and removes temporary signing assets in the
|
|
||||||
cleanup step.
|
|
||||||
|
|
||||||
## Product Page Defaults
|
- `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`
|
The repository variable `APP_STORE_AUTOMATION_ENABLED` is the authoritative
|
||||||
- Privacy URL: `https://truckwash.io/privacy-policy`
|
activation switch. Missing or any value other than `true` makes all signing,
|
||||||
- Subtitle: `Book og start truckvask`
|
credential-health, and candidate workflows succeed as safe no-ops without
|
||||||
- Promotional text: `Administrer vask, koeretoejer, ordrer og fakturaer fra mobilen.`
|
selecting an App Store environment or reading Apple secrets.
|
||||||
- Keywords: `truck wash,lastbilvask,vask,booking,kundeportal`
|
|
||||||
- Expected age rating: 4+, subject to the App Store Connect questionnaire.
|
|
||||||
|
|
||||||
Use real iOS simulator or device screenshots. Provide at least:
|
## Enablement and first canary
|
||||||
|
|
||||||
- iPhone 6.9-inch portrait screenshots
|
Keep `APP_STORE_AUTOMATION_ENABLED=false` while configuring Apple/GitHub state.
|
||||||
- iPad 13-inch portrait screenshots
|
Then:
|
||||||
|
|
||||||
Recommended screenshot scenes: dashboard, booking flow, self-service wash start,
|
1. Merge all product-readiness work and confirm `App Store Readiness` passes.
|
||||||
vehicles/orders, and invoices/payment history. Do not include real customer
|
After its first successful default-branch run, add that job to the protected
|
||||||
data, private tokens, or placeholder copy.
|
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.
|
## Continuous TestFlight delivery
|
||||||
Expected minimum disclosures include account/contact data, identifiers such as
|
|
||||||
customer number, vehicle/license plate data, order and invoice history, payment
|
|
||||||
state, approximate/precise location when used, and photos or attachments when
|
|
||||||
users upload them. Tracking should remain false unless analytics/ad tracking is
|
|
||||||
introduced.
|
|
||||||
|
|
||||||
Review notes must include:
|
`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.
|
`iOS Internal TestFlight` consumes that exact proof. It refuses a stale SHA,
|
||||||
- OTP/2FA/passkey fallback instructions when enabled for the account.
|
uses `/Applications/Xcode_26.3.app`, requires an iOS 26 SDK, queries App Store
|
||||||
- A clear statement that Stripe/card payments are for physical truck-wash
|
Connect for the next build number under serialized concurrency, signs and
|
||||||
services consumed outside the app, so Apple in-app purchase is not used.
|
inspects the IPA, uploads through pinned Fastlane, waits for processing, and
|
||||||
- Any hardware-dependent functionality that reviewers cannot reproduce, with a
|
idempotently assigns the exact build to Internal QA.
|
||||||
short demo video if needed.
|
|
||||||
- Confirmation that the backend environment is online for the whole review
|
|
||||||
window.
|
|
||||||
|
|
||||||
## TestFlight And Release
|
Outputs include:
|
||||||
|
|
||||||
1. Wait for App Store Connect processing to finish.
|
- Signed IPA, retained for 30 days.
|
||||||
2. Distribute the processed build to internal TestFlight testers.
|
- dSYMs, SHA-256 checksums, and `ios-release-manifest.json`, retained for 90
|
||||||
3. Run clean-device QA on iPhone and iPad.
|
days.
|
||||||
4. Fix issues using the same marketing version and an incremented build number.
|
- Source SHA, marketing/build versions, App Store build ID, Xcode/SDK versions,
|
||||||
5. Submit for App Review with manual release after approval.
|
and workflow identity in the manifest.
|
||||||
6. After approval, release to Denmark first and monitor crashes, support mail,
|
|
||||||
and App Store Connect feedback before expanding availability.
|
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.
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ workflow succeeds for a push to `master` in this repository. It then:
|
|||||||
`master` immediately before deployment.
|
`master` immediately before deployment.
|
||||||
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
|
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
|
||||||
uploaded `.part` files are downloaded and hashed before they are renamed.
|
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
|
9. Uploads an authenticated, bounded-lifetime request into the jailed
|
||||||
extracted tree is downloaded and compared byte-for-byte with the validated
|
deployment directory. A root-owned account-scoped activator validates the
|
||||||
inventory, then `master` is checked again through the read-only workflow
|
request and archive, extracts an inactive release, verifies its manifest
|
||||||
token.
|
identity and required files, and replaces `current` with a local
|
||||||
10. Replaces the `current` symlink with a single server-side rename. Public
|
single-filesystem rename.
|
||||||
manifest, asset-integrity, cache-header, API-ping, and role gates run after
|
10. Downloads the extracted tree and compares it byte-for-byte with the
|
||||||
activation. A failed public or role gate restores the previous symlink.
|
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
|
The fixed `frontend-production` concurrency group is not cancellable. A newer
|
||||||
push therefore cannot interrupt an in-progress switch or rollback.
|
push therefore cannot interrupt an in-progress switch or rollback.
|
||||||
@@ -43,32 +45,23 @@ Add these environment **secrets**:
|
|||||||
- `PRODUCTION_FTP_USER`
|
- `PRODUCTION_FTP_USER`
|
||||||
- `PRODUCTION_FTP_PASSWORD`
|
- `PRODUCTION_FTP_PASSWORD`
|
||||||
- `PRODUCTION_FTP_PATH`
|
- `PRODUCTION_FTP_PATH`
|
||||||
- `PRODUCTION_CPANEL_USER`
|
- `PRODUCTION_ACTIVATION_KEY`
|
||||||
- `PRODUCTION_CPANEL_API_TOKEN`
|
|
||||||
|
|
||||||
The API `.env` contains legacy values under the first four names, but production
|
The API `.env` contains legacy values under the first four names, but production
|
||||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||||
deployment unchanged.
|
deployment unchanged.
|
||||||
|
|
||||||
The cPanel token is separate from the FTP password. Create it in cPanel under
|
The hosted release path deliberately does not call the remote cPanel API.
|
||||||
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
|
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||||
uses cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI
|
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||||
replacement for the required extract, symlink, and rename operations. Revoke
|
activator, exact inventory comparison, and public manifest verification.
|
||||||
and rotate the token if it is ever exposed.
|
|
||||||
|
|
||||||
Add these environment **variables**:
|
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`
|
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||||
|
|
||||||
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
|
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||||
fallback. The cPanel URL and path deliberately fail closed when absent. The
|
|
||||||
production environment must keep the explicit
|
|
||||||
`https://server.red-block.com:2083` cPanel origin: the public origin serves
|
|
||||||
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
|
|
||||||
cPanel JSON API.
|
|
||||||
|
|
||||||
### Create the dedicated FTP credentials
|
### Create the dedicated FTP credentials
|
||||||
|
|
||||||
@@ -84,53 +77,26 @@ cPanel JSON API.
|
|||||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||||
these frontend-only credentials back into the API `.env`.
|
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**.
|
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||||
Use **Add secret** for credentials and **Add variable** for the two URLs and the
|
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||||
cPanel deployment path.
|
|
||||||
Environment values are available only to the deployment job that names this
|
Environment values are available only to the deployment job that names this
|
||||||
environment, and configured protection rules are evaluated before its secrets
|
environment, and configured protection rules are evaluated before its secrets
|
||||||
are released.
|
are released.
|
||||||
|
|
||||||
The existing live-test, Release Manager, and server-version secrets used by
|
The existing live-test, Release Manager, and server-version secrets used by
|
||||||
`release.yml` must remain configured. GitHub-hosted deploy runners install
|
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||||
`lftp` and Playwright Chromium during the job; the existing self-hosted build
|
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||||
runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and
|
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||||
`sha256sum`.
|
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
|
## cPanel layout and one-time bootstrap
|
||||||
|
|
||||||
The production FTP account is jailed directly to the deployment root, so its
|
The production FTP account is jailed directly to the deployment root, so its
|
||||||
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
|
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||||
relative to the cPanel account home. The helper creates this layout below it:
|
`frontend-deployments` directory below the account home. The helper creates
|
||||||
|
this layout below it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
archives/
|
archives/
|
||||||
@@ -142,7 +108,7 @@ current -> releases/<release-id>/dist
|
|||||||
```
|
```
|
||||||
|
|
||||||
The domain's document root must resolve to
|
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`
|
root itself. This stable document-root path is what makes replacing `current`
|
||||||
atomic: every HTTP request resolves either the complete old release or the
|
atomic: every HTTP request resolves either the complete old release or the
|
||||||
complete new release, never a partly uploaded directory.
|
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.
|
listing.
|
||||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||||
8. Test the cPanel token against the exact host and port. The workflow performs
|
8. The server-side activator, rather than the hosted runner, validates that
|
||||||
a disposable symlink-replacement preflight and refuses deployment if the
|
`current` and the captured rollback release exist before every switch.
|
||||||
filesystem or hosting policy cannot replace a symlink atomically.
|
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`
|
The automatic deployer intentionally refuses to create the first `current`
|
||||||
pointer. This prevents a missing or misconfigured bootstrap from turning the
|
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
|
### Auditing or restoring the primary webroot
|
||||||
|
|
||||||
Use the protected **cPanel Root Audit and Restore** workflow if the primary
|
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||||
domain starts showing a directory index or returns 404 for files that cPanel
|
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||||
lists in `public_html`. The `audit` mode is read-only: it reports the exact
|
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||||
`public_html` entry, whether the internal `current` link can serve the required
|
runner label would risk sending production cPanel secrets to a self-hosted
|
||||||
release files, domain document roots, and retained recovery candidates without
|
runner, so that workflow has been removed.
|
||||||
printing the cPanel token. API2 does not expose a documented symlink-target
|
|
||||||
field, so the audit deliberately reports `rootTargetVerified: false` instead
|
|
||||||
of claiming that an arbitrary `public_html` link follows `current`; the live
|
|
||||||
HTTP checks remain the source of truth for service health. The audit fails
|
|
||||||
closed if any domain record lacks an identity or document root, and restore is
|
|
||||||
blocked while an addon or subdomain is rooted below `public_html`.
|
|
||||||
|
|
||||||
If the regression followed the one-time webroot exchange, select `restore`
|
If the primary domain starts showing a directory index or returns 404 for files
|
||||||
and copy one exact recovery entry from the audit, including the retained
|
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||||
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
|
or the hosting provider. Before replacing anything, confirm the exact
|
||||||
The workflow requires the
|
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||||
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
|
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||||
exact token string from that audit. The token is an optimistic-concurrency
|
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||||
guard over the cPanel metadata visible to the audit; it is not a content hash
|
replace the root while an addon or subdomain document root is nested below it.
|
||||||
or a substitute for validating the selected recovery. Restore also rejects an
|
Restore only a verified physical directory, retain the displaced webroot, and
|
||||||
unreadable physical directory. An unreadable root is eligible only when the
|
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||||
independent account-home listing identifies it as a symbolic link. It renames
|
releases do not depend on remote cPanel API access.
|
||||||
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.
|
|
||||||
|
|
||||||
## Caching and compatibility
|
## Caching and compatibility
|
||||||
|
|
||||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||||
service-worker control files always revalidate. The deployer retains at least
|
service-worker control files always revalidate. The deployer retains every
|
||||||
the active and rollback releases and keeps five recent release directories by
|
immutable release while hosted runners cannot query reliable cPanel
|
||||||
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
|
modification metadata. Each successful run reports that retention cleanup is
|
||||||
falls outside that validated retention set, its directory and matching ZIP and
|
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||||
checksum are removed over FTPS. Cleanup failure is reported without rolling
|
releases and their matching archives; never remove the active or recorded
|
||||||
back an otherwise verified deployment.
|
rollback target.
|
||||||
|
|
||||||
Because the document root switches as one symlink, an already-loaded page may
|
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
|
still request an asset from its previous release after activation. The current
|
||||||
|
|||||||
@@ -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.
|
- Automatic delivery after successful current-master `Automated Tests`, with
|
||||||
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
|
all six full Chromium-mobile role shards explicitly verified as green.
|
||||||
- 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.
|
- Manual dispatch with version, version code, upload toggle, track, and status.
|
||||||
- Stale workflow-run protection: if a newer commit reaches `master` before the mobile workflow runs, both store-upload jobs skip the stale commit.
|
- 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`.
|
The Android job continues using GitHub environment `mobile-store-production`.
|
||||||
- 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.
|
Its required secrets are:
|
||||||
- Manual dispatch can disable either upload path while still producing signed GitHub artifacts.
|
|
||||||
|
|
||||||
## Required Secrets
|
|
||||||
|
|
||||||
Store secrets are expected in the GitHub environment `mobile-store-production`.
|
|
||||||
|
|
||||||
Non-secret environment variables:
|
|
||||||
|
|
||||||
- `ANDROID_PACKAGE_NAME=io.truckwash.twa`
|
|
||||||
- `ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab`
|
|
||||||
- `PLAY_STORE_TRACK=production`
|
|
||||||
- `PLAY_STORE_RELEASE_STATUS=completed`
|
|
||||||
- `PLAY_STORE_USER_FRACTION` only when using `PLAY_STORE_RELEASE_STATUS=inProgress`
|
|
||||||
|
|
||||||
Android:
|
|
||||||
|
|
||||||
- `ANDROID_KEYSTORE_BASE64`
|
- `ANDROID_KEYSTORE_BASE64`
|
||||||
- `ANDROID_KEYSTORE_PASSWORD`
|
- `ANDROID_KEYSTORE_PASSWORD`
|
||||||
@@ -37,75 +27,48 @@ Android:
|
|||||||
- `ANDROID_KEY_PASSWORD`
|
- `ANDROID_KEY_PASSWORD`
|
||||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
|
- `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
|
||||||
- `IOS_CERTIFICATE_PASSWORD`
|
|
||||||
- `IOS_PROVISION_PROFILE_BASE64`
|
|
||||||
- `IOS_KEYCHAIN_PASSWORD`
|
|
||||||
- `APPLE_TEAM_ID`
|
|
||||||
- `APP_STORE_CONNECT_API_KEY_ID`
|
|
||||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
|
||||||
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
|
|
||||||
|
|
||||||
The 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
|
```sh
|
||||||
npm run mobile:permissions:check
|
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
|
```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
|
- Android package: `io.truckwash.twa`
|
||||||
export ANDROID_KEYSTORE_FILE=/path/to/upload-key.jks
|
- iOS App Store bundle: `io.truckwash.app`
|
||||||
export ANDROID_KEYSTORE_PASSWORD=...
|
|
||||||
export ANDROID_KEY_ALIAS=...
|
|
||||||
export ANDROID_KEY_PASSWORD=...
|
|
||||||
npm run mobile:android:bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
The signed Android bundle is written to:
|
The iOS release build verifies the final signed IPA rather than relying on the
|
||||||
|
Capacitor `appId`, which remains the Android package identifier.
|
||||||
```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.
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2026 Truck Wash ApS
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
lastbilvask,truckvask,vask,booking,kundeportal,køretøjer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://truckwash.io/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Truck Wash
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Kundeportal til truckvask
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://truckwash.io/support
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -19,7 +19,7 @@
|
|||||||
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
|
<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" />
|
<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="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="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="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" />
|
<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" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
A17D5C4A2E8F000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -30,6 +31,8 @@
|
|||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
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; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -70,6 +73,7 @@
|
|||||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||||
504EC3131FED79650016851F /* Info.plist */,
|
504EC3131FED79650016851F /* Info.plist */,
|
||||||
|
A17D5C4A2E8F000100000004 /* InfoPlist.strings */,
|
||||||
A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */,
|
A17D5C4A2E8F000100000002 /* PrivacyInfo.xcprivacy */,
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||||
50B271D01FEDC1A000F3C39B /* public */,
|
50B271D01FEDC1A000F3C39B /* public */,
|
||||||
@@ -122,6 +126,7 @@
|
|||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
da,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 504EC2FB1FED79650016851F;
|
mainGroup = 504EC2FB1FED79650016851F;
|
||||||
@@ -146,6 +151,7 @@
|
|||||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||||
A17D5C4A2E8F000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
A17D5C4A2E8F000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
A17D5C4A2E8F000100000003 /* InfoPlist.strings in Resources */,
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||||
@@ -182,6 +188,15 @@
|
|||||||
name = LaunchScreen.storyboard;
|
name = LaunchScreen.storyboard;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A17D5C4A2E8F000100000004 /* InfoPlist.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
A17D5C4A2E8F000100000005 /* en */,
|
||||||
|
A17D5C4A2E8F000100000006 /* da */,
|
||||||
|
);
|
||||||
|
name = InfoPlist.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXVariantGroup section */
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 861 B |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 249 KiB |
@@ -24,8 +24,12 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
|
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
|
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -5,8 +5,121 @@
|
|||||||
<key>NSPrivacyAccessedAPITypes</key>
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
<array/>
|
<array/>
|
||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<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>
|
<key>NSPrivacyTracking</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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.";
|
||||||
@@ -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.";
|
||||||
@@ -12,7 +12,8 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
.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: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -20,7 +21,8 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
.product(name: "Cordova", 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")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"marketingVersion": "1.0.0",
|
||||||
|
"bundleId": "io.truckwash.app",
|
||||||
|
"minimumIosVersion": "15.0"
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"@bubblewrap/cli": "^1.24.1",
|
"@bubblewrap/cli": "^1.24.1",
|
||||||
"@capacitor/core": "^8.4.1",
|
"@capacitor/core": "^8.4.1",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.3",
|
||||||
"@creativebulma/bulma-badge": "^1.0.1",
|
"@creativebulma/bulma-badge": "^1.0.1",
|
||||||
"@fullcalendar/core": "^6.1.17",
|
"@fullcalendar/core": "^6.1.17",
|
||||||
"@fullcalendar/daygrid": "^6.1.17",
|
"@fullcalendar/daygrid": "^6.1.17",
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
"jimp": "0.22.12",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
@@ -2131,6 +2133,14 @@
|
|||||||
"@capacitor/core": "^8.4.0"
|
"@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": {
|
"node_modules/@capacitor/synapse": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||||
@@ -10087,6 +10097,12 @@
|
|||||||
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
|
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -10994,6 +11010,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
@@ -11062,6 +11090,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -13627,6 +13664,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
"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:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||||
"mobile:ios:device": "node scripts/mobile/ios-device.mjs",
|
"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"
|
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -81,6 +84,7 @@
|
|||||||
"@bubblewrap/cli": "^1.24.1",
|
"@bubblewrap/cli": "^1.24.1",
|
||||||
"@capacitor/core": "^8.4.1",
|
"@capacitor/core": "^8.4.1",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.3",
|
||||||
"@creativebulma/bulma-badge": "^1.0.1",
|
"@creativebulma/bulma-badge": "^1.0.1",
|
||||||
"@fullcalendar/core": "^6.1.17",
|
"@fullcalendar/core": "^6.1.17",
|
||||||
"@fullcalendar/daygrid": "^6.1.17",
|
"@fullcalendar/daygrid": "^6.1.17",
|
||||||
@@ -151,6 +155,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
"jimp": "0.22.12",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const baseURL = "http://127.0.0.1:4173";
|
const baseURL = "http://127.0.0.1:4173";
|
||||||
const isCI = !!process.env.CI;
|
const isCI = !!process.env.CI;
|
||||||
|
const usePrebuiltDist = ["1", "true"].includes(
|
||||||
|
String(process.env.PLAYWRIGHT_PROD_PREBUILT || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@ export default defineConfig({
|
|||||||
video: "retain-on-failure",
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run preview:prod",
|
command: usePrebuiltDist ? "npm run preview -- --host 127.0.0.1 --port 4173" : "npm run preview:prod",
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
reuseExistingServer: !isCI,
|
reuseExistingServer: !isCI,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 57 KiB |
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
|
|||||||
|
|
||||||
const hasUnsupportedHostPlatformFailure = (result) => {
|
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||||
const output = outputText(result);
|
const output = outputText(result);
|
||||||
return (
|
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
|
||||||
result.status !== 0 &&
|
|
||||||
/Playwright does not support .* on /i.test(output)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||||
@@ -78,7 +75,7 @@ console.warn(
|
|||||||
[
|
[
|
||||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||||
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
`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")
|
].join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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, 'applicationId "io.truckwash.twa"');
|
||||||
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
|
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
|
||||||
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
|
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
|
||||||
|
requireText("Info.plist", iosInfoPlist, "NSLocationAlwaysAndWhenInUseUsageDescription");
|
||||||
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
|
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
|
||||||
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
|
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
|
||||||
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
|
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
|
||||||
|
|||||||
@@ -21,11 +21,20 @@ const decodeBase64 = (name) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
if (decoded.length === 0) {
|
||||||
failures.push(`${name} is empty after base64 decoding`);
|
failures.push(`${name} is empty after base64 decoding`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (decoded.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
|
||||||
|
failures.push(`${name} is not canonical base64`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return decoded;
|
return decoded;
|
||||||
} catch {
|
} catch {
|
||||||
failures.push(`${name} is not valid base64`);
|
failures.push(`${name} is not valid base64`);
|
||||||
@@ -73,7 +82,7 @@ const checkAndroid = () => {
|
|||||||
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
||||||
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
||||||
const track = env.PLAY_STORE_TRACK || "production";
|
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)) {
|
if (!validTracks.has(track)) {
|
||||||
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
|
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
|
||||||
@@ -102,21 +111,32 @@ const checkAndroid = () => {
|
|||||||
const checkIos = () => {
|
const checkIos = () => {
|
||||||
requireVariable("MOBILE_VERSION_NAME");
|
requireVariable("MOBILE_VERSION_NAME");
|
||||||
requireVariable("MOBILE_VERSION_CODE");
|
requireVariable("MOBILE_VERSION_CODE");
|
||||||
requireVariable("IOS_CERTIFICATE_BASE64");
|
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64");
|
||||||
requireVariable("IOS_CERTIFICATE_PASSWORD");
|
requireVariable("IOS_DISTRIBUTION_CERTIFICATE_PASSWORD");
|
||||||
requireVariable("IOS_PROVISION_PROFILE_BASE64");
|
requireVariable("IOS_APP_STORE_PROFILE_BASE64");
|
||||||
requireVariable("IOS_KEYCHAIN_PASSWORD");
|
|
||||||
requireVariable("APPLE_TEAM_ID");
|
requireVariable("APPLE_TEAM_ID");
|
||||||
|
requireVariable("IOS_BUNDLE_ID");
|
||||||
|
|
||||||
decodeBase64("IOS_CERTIFICATE_BASE64");
|
if (env.MOBILE_VERSION_NAME && !/^\d+\.\d+\.\d+$/.test(env.MOBILE_VERSION_NAME)) {
|
||||||
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
|
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")) {
|
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
|
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");
|
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
|
||||||
|
|
||||||
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
|
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
|
||||||
|
|||||||