Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7563c15d | ||
|
|
0b1df55728 | ||
|
|
398a0a146f | ||
|
|
dfaca1e8a5 | ||
|
|
a4a3b4adb1 | ||
|
|
b2f77b45e5 | ||
|
|
f0b3fc4675 | ||
|
|
1e7298245d | ||
|
|
0369664a96 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a | ||
|
|
cc7d5cf4ff | ||
|
|
8d9f1e6fde | ||
|
|
d61d91b6ae | ||
|
|
ba92bc4cb6 | ||
|
|
c353bfac3a | ||
|
|
9024a5a1fa | ||
|
|
35e4bba859 | ||
|
|
50535dbed0 | ||
|
|
f5ccb2a2a9 | ||
|
|
29ef97a86c | ||
|
|
8d646ce770 | ||
|
|
f63e51c96e | ||
|
|
4810e113f3 | ||
|
|
82c95d32c0 | ||
|
|
d4f92cd259 | ||
|
|
6b44835347 | ||
|
|
683196ddf5 | ||
|
|
1548ae8cd5 | ||
|
|
fd8b896c56 | ||
|
|
2e95608b05 | ||
|
|
d393c8c175 | ||
|
|
668e240e12 | ||
|
|
0831d37d3c | ||
|
|
60dff74507 | ||
|
|
f995440098 | ||
|
|
7a5ee1aa5b | ||
|
|
3639527b0e | ||
|
|
f4816124c2 | ||
|
|
664b50d4ef | ||
|
|
1768f5a38e | ||
|
|
f2453ba0a3 | ||
|
|
7b769eeb24 | ||
|
|
391e0c8a6f | ||
|
|
0149e06c42 | ||
|
|
832b362254 | ||
|
|
eee9ba1c13 | ||
|
|
aaecffbdfa | ||
|
|
917c10c1d3 | ||
|
|
14a0d65a01 | ||
|
|
468d436d3e | ||
|
|
c85a82b9ac | ||
|
|
3de5215b5e | ||
|
|
5ffd471a45 | ||
|
|
1da6fbd1c4 | ||
|
|
5204536f92 | ||
|
|
7a84cd9162 | ||
|
|
4f26ddd2cc | ||
|
|
b7859d4ede | ||
|
|
cbdca71e3f | ||
|
|
fd31609cb3 | ||
|
|
009519ee62 | ||
|
|
b6f9b5a3a4 | ||
|
|
7387a2b56e | ||
|
|
9ff103d4d0 | ||
|
|
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 | ||
|
|
c69f4f7fc7 | ||
|
|
10f993e686 | ||
|
|
a02ddfbfbc | ||
|
|
7a85c93631 | ||
|
|
a3d11cfca3 | ||
|
|
74279fc443 | ||
|
|
7e3a72961f | ||
|
|
ca2179352c | ||
|
|
8ddac065c6 | ||
|
|
a930bd35a5 | ||
|
|
6b8f3ff806 | ||
|
|
3323f392e3 | ||
|
|
f8f2b80641 | ||
|
|
1729443cc3 | ||
|
|
6b5ac8a8b3 | ||
|
|
b12aca2757 | ||
|
|
39cc3a780a | ||
|
|
18302723e8 | ||
|
|
20c383d22d | ||
|
|
77a0c4e018 | ||
|
|
9a5c8b193d | ||
|
|
0562ba8ab0 | ||
|
|
906e860c86 | ||
|
|
516e1fb58d | ||
|
|
7da1307cf6 | ||
|
|
ea4893d815 | ||
|
|
f2e0079b59 | ||
|
|
47d8baa496 | ||
|
|
6d96d64811 | ||
|
|
92fb998e5a | ||
|
|
bed6030e2b | ||
|
|
328a85bad1 | ||
|
|
394375e429 | ||
|
|
ca744adfd9 | ||
|
|
5e42f55570 | ||
|
|
021da1a074 | ||
|
|
0e55f45e91 | ||
|
|
9749837506 | ||
|
|
4fbb0b98c3 | ||
|
|
0edf8afcaf | ||
|
|
e2ee54578c | ||
|
|
b35e4484de | ||
|
|
c8368612eb | ||
|
|
26266e724b | ||
|
|
6ccfea9eb4 | ||
|
|
d4349c20a7 | ||
|
|
63f1cc2450 | ||
|
|
acec2a694a | ||
|
|
f15d7224e0 | ||
|
|
236f552a22 | ||
|
|
321b6f2c64 | ||
|
|
218ead9a31 | ||
|
|
60b6e82c88 | ||
|
|
bbe4ad938a | ||
|
|
9e27380a10 | ||
|
|
baf83fd079 | ||
|
|
dfe3163692 | ||
|
|
4452bd4088 | ||
|
|
6f93b840a9 | ||
|
|
443f50c144 | ||
|
|
5a7f902d01 | ||
|
|
02cc82cee1 | ||
|
|
fa9b05958d | ||
|
|
6fb2c9e7df | ||
|
|
54e59b4b3e | ||
|
|
8a0ea6cae5 | ||
|
|
1bbd816e83 | ||
|
|
4e56191b7e |
@@ -0,0 +1,58 @@
|
||||
# Default branch protection
|
||||
|
||||
The intended repository ruleset is stored in
|
||||
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json).
|
||||
It targets the configured default branch and requires pull requests, the strict
|
||||
`Required CI` check from GitHub Actions, resolved review conversations,
|
||||
squash-only merges, and linear history. Branch deletion and force pushes are
|
||||
blocked. Qodana remains advisory and is not part of the required gate.
|
||||
|
||||
The ruleset's `RepositoryRole` actor ID `5` is GitHub's built-in Administrator
|
||||
role. Its `pull_request` bypass mode permits an administrator to bypass rules
|
||||
only while merging an existing pull request; it does not permit a direct push.
|
||||
|
||||
## Repository settings
|
||||
|
||||
Keep squash merge enabled and disable merge commits and rebase merge. Enable
|
||||
auto-merge, the update-branch option, and automatic deletion of merged head
|
||||
branches. Keep the Actions token read-only and do not allow Actions to approve
|
||||
pull-request reviews.
|
||||
|
||||
## Activation and verification
|
||||
|
||||
1. Confirm a pull request and a `master` push each produce exactly one
|
||||
successful `Required CI` check from GitHub Actions integration `15368`.
|
||||
2. For the initial ruleset POST, override the committed JSON's `enforcement`
|
||||
value to `disabled`, then compare GitHub's normalized API response with this
|
||||
file.
|
||||
3. PUT the exact committed JSON to the inspected ruleset to activate it.
|
||||
4. Open a canary pull request and confirm that pending or failing CI, unresolved
|
||||
conversations, and an out-of-date branch block merging; only squash merge is
|
||||
available.
|
||||
5. After merging, confirm the head branch is deleted and the post-merge full
|
||||
E2E, frontend release, and mobile release guards still run.
|
||||
|
||||
If validation exposes a blocker, disable the ruleset rather than deleting it so
|
||||
its configuration and history remain available.
|
||||
|
||||
## Activation record
|
||||
|
||||
Repository ruleset `19051697` was activated on 2026-07-16 after preparation
|
||||
PR #172 established `Required CI`, remediation PR #174 passed the strict
|
||||
current-base gate, and merged master run `29501928124` completed with all 43
|
||||
executed jobs successful. This documentation update is the after-activation
|
||||
canary for the normal protected pull-request path.
|
||||
|
||||
## Normal publishing flow
|
||||
|
||||
Create a scoped feature branch, open a pull request to `master`, wait for
|
||||
`Required CI`, update the branch if `master` advanced, resolve every review
|
||||
conversation, and squash-merge. For waits expected to exceed 90 seconds, use
|
||||
the workspace `scripts/ci-watch.sh` helper instead of repeatedly polling GitHub.
|
||||
|
||||
## Break glass
|
||||
|
||||
For an incident, an administrator must still open a pull request. Document the
|
||||
incident and why the normal gate cannot complete, then use the PR-only bypass
|
||||
when merging. Monitor all post-merge workflows and open a follow-up pull request
|
||||
for any validation or remediation deferred during the incident.
|
||||
@@ -0,0 +1,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,53 @@
|
||||
{
|
||||
"name": "Protect default branch",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "pull_request"
|
||||
}
|
||||
],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["~DEFAULT_BRANCH"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"type": "deletion"
|
||||
},
|
||||
{
|
||||
"type": "non_fast_forward"
|
||||
},
|
||||
{
|
||||
"type": "required_linear_history"
|
||||
},
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"allowed_merge_methods": ["squash"],
|
||||
"dismiss_stale_reviews_on_push": false,
|
||||
"require_code_owner_review": false,
|
||||
"require_last_push_approval": false,
|
||||
"required_approving_review_count": 0,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"do_not_enforce_on_create": false,
|
||||
"required_status_checks": [
|
||||
{
|
||||
"context": "Required CI",
|
||||
"integration_id": 15368
|
||||
}
|
||||
],
|
||||
"strict_required_status_checks_policy": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
- name: Resolve the pinned Fastlane dependency graph
|
||||
run: bundle lock
|
||||
|
||||
- name: Check the committed Fastlane dependency lock
|
||||
id: fastlane-lock
|
||||
continue-on-error: true
|
||||
run: test -z "$(git status --porcelain -- Gemfile.lock)"
|
||||
|
||||
- name: Preserve a generated lock for review
|
||||
if: steps.fastlane-lock.outcome == 'failure'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: generated-fastlane-lock
|
||||
path: Gemfile.lock
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Require a current committed Fastlane dependency lock
|
||||
if: steps.fastlane-lock.outcome == 'failure'
|
||||
run: |
|
||||
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
||||
exit 1
|
||||
|
||||
- name: Install the pinned Fastlane dependency graph
|
||||
run: bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Validate strict App Store metadata and candidate assets
|
||||
run: node scripts/mobile/validate-app-store.mjs --strict
|
||||
|
||||
- name: Validate native mobile permissions
|
||||
run: node scripts/mobile/check-permissions.mjs
|
||||
|
||||
- name: Test App Store Connect automation
|
||||
run: node --test tests/node/app-store-connect.test.mjs
|
||||
|
||||
- name: Validate Fastlane configuration
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Validate JavaScript syntax
|
||||
run: |
|
||||
node --check scripts/mobile/validate-app-store.mjs
|
||||
node --check scripts/mobile/app-store-connect.mjs
|
||||
node --check scripts/mobile/create-ios-release-manifest.mjs
|
||||
node --check tests/node/app-store-connect.test.mjs
|
||||
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||
@@ -1,32 +1,66 @@
|
||||
name: Qodana Configuration Upload
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [master, beta, canary, internal]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [master, beta, canary, internal]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
qodana:
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
# v5.0.1
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
- name: Require Qodana project token
|
||||
shell: bash
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$(pwd):/workspace" \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN:-}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
|
||||
with:
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
upload-result: false
|
||||
use-annotations: true
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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"
|
||||
@@ -0,0 +1,624 @@
|
||||
name: iOS Device Debug IPA
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: Same-repository branch, tag, or commit to build
|
||||
required: true
|
||||
default: master
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Full 40-character SHA that source_ref must resolve to
|
||||
required: true
|
||||
type: string
|
||||
confirmation:
|
||||
description: Type SIGN IOS DEBUG IPA
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ios-device-debug-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: Resolve and verify source
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.resolve.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Validate dispatch confirmation
|
||||
shell: bash
|
||||
env:
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]; then
|
||||
echo "The signing workflow must be dispatched from the master workflow ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONFIRMATION" != "SIGN IOS DEBUG IPA" ]]; then
|
||||
echo "confirmation must exactly match SIGN IOS DEBUG IPA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character commit SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$SOURCE_REF" || "$SOURCE_REF" =~ [[:space:]] ]]; then
|
||||
echo "source_ref must be non-empty and contain no whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SOURCE_REF" == refs/pull/* || "$SOURCE_REF" == pull/* ]]; then
|
||||
echo "Pull-request refs are not eligible for device-debug signing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout same-repository history
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve immutable commit
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
|
||||
if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
||||
candidate="$SOURCE_REF"
|
||||
elif [[ "$SOURCE_REF" == refs/heads/* ]]; then
|
||||
candidate="refs/remotes/origin/${SOURCE_REF#refs/heads/}"
|
||||
elif [[ "$SOURCE_REF" == refs/tags/* ]]; then
|
||||
candidate="$SOURCE_REF"
|
||||
elif git show-ref --verify --quiet "refs/remotes/origin/$SOURCE_REF"; then
|
||||
candidate="refs/remotes/origin/$SOURCE_REF"
|
||||
elif git show-ref --verify --quiet "refs/tags/$SOURCE_REF"; then
|
||||
candidate="refs/tags/$SOURCE_REF"
|
||||
else
|
||||
echo "source_ref does not identify a same-repository branch, tag, or fetched commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_sha="$(git rev-parse --verify "${candidate}^{commit}" 2>/dev/null || true)"
|
||||
source_sha="${source_sha,,}"
|
||||
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "source_ref could not be resolved to a commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$source_sha" != "$expected_sha" ]]; then
|
||||
echo "source_ref resolved to a SHA different from expected_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reachable=false
|
||||
while IFS= read -r repository_ref; do
|
||||
if git merge-base --is-ancestor "$source_sha" "$repository_ref" 2>/dev/null; then
|
||||
reachable=true
|
||||
break
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin refs/tags)
|
||||
if [[ "$reachable" != true ]]; then
|
||||
echo "The requested commit is not reachable from a same-repository branch or tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved source_ref to $source_sha" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
build:
|
||||
name: Build development-signed IPA
|
||||
needs: resolve
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 90
|
||||
environment:
|
||||
name: mobile-device-debug
|
||||
env:
|
||||
IOS_PROJECT_PATH: ios/App/App.xcodeproj
|
||||
IOS_SCHEME: App
|
||||
IOS_DEBUG_BUNDLE_ID: ${{ vars.IOS_DEBUG_BUNDLE_ID || 'io.truckwash.app.debug' }}
|
||||
IOS_DEBUG_API_URL: ${{ vars.IOS_DEBUG_API_URL || 'https://api-v2.truckwash.io/master/api' }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
MOBILE_VERSION_NAME: 0.0.${{ github.run_number }}
|
||||
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout resolved source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.source_sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify runner and resolve build number
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
xcode_version_output="$(xcodebuild -version)"
|
||||
IFS= read -r xcode_version <<< "$xcode_version_output"
|
||||
xcode_major="$(awk '{split($2, version, "."); print version[1]}' <<< "$xcode_version")"
|
||||
if [[ ! "$xcode_major" =~ ^[0-9]+$ ]] || (( xcode_major < 26 )); then
|
||||
echo "Xcode 26 or newer is required; found $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_number="$((10#$GITHUB_RUN_NUMBER * 100 + 10#$GITHUB_RUN_ATTEMPT))"
|
||||
if [[ ! "$build_number" =~ ^[1-9][0-9]{0,17}$ ]]; then
|
||||
echo "Derived build number is outside Apple's supported integer format" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "MOBILE_VERSION_CODE=$build_number" >> "$GITHUB_ENV"
|
||||
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Verify stable API and Capacitor iOS CORS
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api_base="${IOS_DEBUG_API_URL%/}"
|
||||
curl --fail --silent --show-error --location \
|
||||
--connect-timeout 10 --max-time 20 \
|
||||
--header 'Accept: application/json' \
|
||||
--output /dev/null \
|
||||
"$api_base/ping"
|
||||
|
||||
cors_headers="$RUNNER_TEMP/ios-debug-cors-headers.txt"
|
||||
cors_body="$RUNNER_TEMP/ios-debug-cors-body.txt"
|
||||
cors_status="$(curl --silent --show-error \
|
||||
--connect-timeout 10 --max-time 20 \
|
||||
--request OPTIONS \
|
||||
--header 'Origin: capacitor://localhost' \
|
||||
--header 'Access-Control-Request-Method: POST' \
|
||||
--header 'Access-Control-Request-Headers: authorization,content-type' \
|
||||
--dump-header "$cors_headers" \
|
||||
--output "$cors_body" \
|
||||
--write-out '%{http_code}' \
|
||||
"$api_base/ping")"
|
||||
if [[ ! "$cors_status" =~ ^2[0-9][0-9]$ ]]; then
|
||||
echo "Stable API rejected the Capacitor iOS CORS preflight with HTTP $cors_status" >&2
|
||||
sed -n '1,20p' "$cors_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eiq '^access-control-allow-origin:[[:space:]]*capacitor://localhost[[:space:]]*$' "$cors_headers"; then
|
||||
echo "Stable API did not allow the exact capacitor://localhost origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eiq '^access-control-allow-credentials:[[:space:]]*true[[:space:]]*$' "$cors_headers"; then
|
||||
echo "Stable API did not allow credentialed Capacitor requests" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eiq '^access-control-allow-methods:.*[[:space:],]POST([[:space:],]|$)' "$cors_headers"; then
|
||||
echo "Stable API did not allow POST from the Capacitor origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Authorization([[:space:],]|$)' "$cors_headers"; then
|
||||
echo "Stable API did not allow the Authorization header from the Capacitor origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Content-Type([[:space:],]|$)' "$cors_headers"; then
|
||||
echo "Stable API did not allow the Content-Type header from the Capacitor origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build stable production web payload
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}
|
||||
VITE_API_URL: ${{ env.IOS_DEBUG_API_URL }}
|
||||
VITE_RELEASE_MANAGER_CONTROL_API_URL: ${{ env.IOS_DEBUG_API_URL }}
|
||||
VITE_RELEASE_PUBLIC_GATEWAY_API_URL: https://api-v2.truckwash.io
|
||||
run: |
|
||||
npm run build
|
||||
node -e "const manifest = require('./dist/release-manifest.json'); if (manifest.commit_sha !== process.env.RESOLVED_SOURCE_SHA) { throw new Error('Web release manifest source SHA mismatch'); }"
|
||||
|
||||
- name: Sync and validate iOS shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npx cap sync ios
|
||||
npm run mobile:permissions:check
|
||||
if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then
|
||||
echo "Unexpected Xcode shell-script build phase detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
|
||||
|
||||
- name: Validate native Debug and Release settings
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
debug_settings="$RUNNER_TEMP/ios-debug-build-settings.txt"
|
||||
release_settings="$RUNNER_TEMP/ios-release-build-settings.txt"
|
||||
xcodebuild -showBuildSettings \
|
||||
-project "$IOS_PROJECT_PATH" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-configuration Debug \
|
||||
CODE_SIGNING_ALLOWED=NO > "$debug_settings"
|
||||
xcodebuild -showBuildSettings \
|
||||
-project "$IOS_PROJECT_PATH" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-configuration Release \
|
||||
CODE_SIGNING_ALLOWED=NO > "$release_settings"
|
||||
|
||||
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app\.debug$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash Debug$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*PRODUCT_NAME = TruckWashDebug$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*CAPACITOR_DEBUG = true$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*DEBUG_INFORMATION_FORMAT = dwarf-with-dsym$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$debug_settings"
|
||||
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app$' "$release_settings"
|
||||
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$release_settings"
|
||||
grep -Eq '^[[:space:]]*PRODUCT_NAME = App$' "$release_settings"
|
||||
|
||||
- name: Install and validate Apple development signing assets
|
||||
shell: bash
|
||||
env:
|
||||
IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}
|
||||
IOS_DEBUG_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEBUG_CERTIFICATE_PASSWORD }}
|
||||
IOS_DEBUG_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_DEBUG_PROVISION_PROFILE_BASE64 }}
|
||||
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/mobile/check-ios-debug-signing-env.mjs
|
||||
|
||||
certificate_path="$RUNNER_TEMP/ios-debug-development.p12"
|
||||
profile_path="$RUNNER_TEMP/ios-debug-development.mobileprovision"
|
||||
profile_plist="$RUNNER_TEMP/ios-debug-development-profile.plist"
|
||||
keychain_path="$RUNNER_TEMP/ios-debug-signing.keychain-db"
|
||||
keychain_password="$(openssl rand -base64 48 | tr -d '\n')"
|
||||
echo "::add-mask::$keychain_password"
|
||||
|
||||
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_CERTIFICATE_BASE64.replace(/\\s/g, ''), 'base64'))" "$certificate_path"
|
||||
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_PROVISION_PROFILE_BASE64.replace(/\\s/g, ''), 'base64'))" "$profile_path"
|
||||
chmod 600 "$certificate_path" "$profile_path"
|
||||
security cms -D -i "$profile_path" > "$profile_plist"
|
||||
|
||||
security create-keychain -p "$keychain_password" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$keychain_path"
|
||||
security unlock-keychain -p "$keychain_password" "$keychain_path"
|
||||
security import "$certificate_path" \
|
||||
-P "$IOS_DEBUG_CERTIFICATE_PASSWORD" \
|
||||
-A \
|
||||
-t cert \
|
||||
-f pkcs12 \
|
||||
-k "$keychain_path"
|
||||
security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
|
||||
security set-key-partition-list \
|
||||
-S apple-tool:,apple: \
|
||||
-s \
|
||||
-k "$keychain_password" \
|
||||
"$keychain_path"
|
||||
|
||||
signing_identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Development/ {print $2; exit}')"
|
||||
if [[ ! "$signing_identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
|
||||
echo "The PKCS#12 file does not contain a valid Apple Development signing identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IOS_SIGNING_IDENTITY_SHA="$signing_identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY'
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
|
||||
profile = plistlib.load(handle)
|
||||
|
||||
team_id = os.environ["APPLE_TEAM_ID"]
|
||||
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
|
||||
entitlements = profile.get("Entitlements", {})
|
||||
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
|
||||
provisioned = set(profile.get("ProvisionedDevices", []))
|
||||
expiration = profile.get("ExpirationDate")
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if expiration and expiration.tzinfo is None:
|
||||
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
checks = {
|
||||
"profile team identifier": team_id in profile.get("TeamIdentifier", []),
|
||||
"application identifier": entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
|
||||
"entitlement team identifier": entitlements.get("com.apple.developer.team-identifier") == team_id,
|
||||
"development entitlement": entitlements.get("get-task-allow") is True,
|
||||
"profile expiration": expiration is not None and expiration > now,
|
||||
"registered devices": bool(allowed) and allowed <= provisioned,
|
||||
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
|
||||
"developer certificate": bool(profile.get("DeveloperCertificates")),
|
||||
"profile UUID": isinstance(profile.get("UUID"), str) and re.fullmatch(r"[0-9A-Fa-f-]{36}", profile["UUID"]) is not None,
|
||||
"safe profile name": isinstance(profile.get("Name"), str) and not any(char in profile["Name"] for char in "\r\n"),
|
||||
}
|
||||
identity_sha = os.environ["IOS_SIGNING_IDENTITY_SHA"].upper()
|
||||
certificate_hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}
|
||||
checks["certificate belongs to profile"] = identity_sha in certificate_hashes
|
||||
|
||||
failures = [label for label, passed in checks.items() if not passed]
|
||||
if failures:
|
||||
print("Development provisioning profile validation failed:", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")"
|
||||
profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$profile_plist")"
|
||||
profile_expiration="$(PROFILE_PLIST="$profile_plist" python3 - <<'PY'
|
||||
import datetime
|
||||
import os
|
||||
import plistlib
|
||||
|
||||
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
|
||||
expiration = plistlib.load(handle)["ExpirationDate"]
|
||||
if expiration.tzinfo is None:
|
||||
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
||||
print(expiration.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"))
|
||||
PY
|
||||
)"
|
||||
profile_install_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
profile_install_path="$profile_install_dir/$profile_uuid.mobileprovision"
|
||||
mkdir -p "$profile_install_dir"
|
||||
cp "$profile_path" "$profile_install_path"
|
||||
|
||||
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
|
||||
echo "IOS_PROFILE_INSTALL_PATH=$profile_install_path" >> "$GITHUB_ENV"
|
||||
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
|
||||
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
|
||||
echo "IOS_PROFILE_EXPIRATION=$profile_expiration" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Archive Debug app with Apple Development signing
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive_path="$RUNNER_TEMP/TruckWashDebug.xcarchive"
|
||||
xcodebuild \
|
||||
-project "$IOS_PROJECT_PATH" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath "$archive_path" \
|
||||
archive \
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Development" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$IOS_DEBUG_BUNDLE_ID" \
|
||||
MARKETING_VERSION="$MOBILE_VERSION_NAME" \
|
||||
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" \
|
||||
DEBUG_INFORMATION_FORMAT="dwarf-with-dsym" \
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
echo "IOS_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Export development IPA
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export_options="$RUNNER_TEMP/ios-debug-ExportOptions.plist"
|
||||
export_path="$RUNNER_TEMP/ios-debug-export"
|
||||
EXPORT_OPTIONS="$export_options" python3 <<'PY'
|
||||
import os
|
||||
import plistlib
|
||||
|
||||
options = {
|
||||
"method": "development",
|
||||
"signingStyle": "manual",
|
||||
"teamID": os.environ["APPLE_TEAM_ID"],
|
||||
"provisioningProfiles": {
|
||||
os.environ["IOS_DEBUG_BUNDLE_ID"]: os.environ["IOS_PROFILE_NAME"],
|
||||
},
|
||||
"stripSwiftSymbols": True,
|
||||
"manageAppVersionAndBuildNumber": False,
|
||||
}
|
||||
with open(os.environ["EXPORT_OPTIONS"], "wb") as handle:
|
||||
plistlib.dump(options, handle)
|
||||
PY
|
||||
xcodebuild \
|
||||
-exportArchive \
|
||||
-archivePath "$IOS_ARCHIVE_PATH" \
|
||||
-exportPath "$export_path" \
|
||||
-exportOptionsPlist "$export_options"
|
||||
|
||||
shopt -s nullglob
|
||||
ipa_files=("$export_path"/*.ipa)
|
||||
if [[ ${#ipa_files[@]} -ne 1 ]]; then
|
||||
echo "Expected exactly one exported IPA; found ${#ipa_files[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "IOS_EXPORTED_IPA=${ipa_files[0]}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate exported IPA and embedded signature
|
||||
shell: bash
|
||||
env:
|
||||
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
inspect_dir="$RUNNER_TEMP/ios-debug-inspect"
|
||||
mkdir -p "$inspect_dir"
|
||||
unzip -q "$IOS_EXPORTED_IPA" -d "$inspect_dir"
|
||||
shopt -s nullglob
|
||||
app_bundles=("$inspect_dir"/Payload/*.app)
|
||||
if [[ ${#app_bundles[@]} -ne 1 ]]; then
|
||||
echo "Expected exactly one Payload app; found ${#app_bundles[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_path="${app_bundles[0]}"
|
||||
app_info="$app_path/Info.plist"
|
||||
embedded_profile="$RUNNER_TEMP/ios-debug-embedded-profile.plist"
|
||||
signature_entitlements="$RUNNER_TEMP/ios-debug-signature-entitlements.plist"
|
||||
signature_details="$RUNNER_TEMP/ios-debug-signature-details.txt"
|
||||
security cms -D -i "$app_path/embedded.mobileprovision" > "$embedded_profile"
|
||||
codesign --verify --deep --strict "$app_path"
|
||||
codesign -d --entitlements :- "$app_path" > "$signature_entitlements"
|
||||
codesign -dvv "$app_path" > /dev/null 2> "$signature_details"
|
||||
grep -Fq "TeamIdentifier=$APPLE_TEAM_ID" "$signature_details"
|
||||
grep -Eq '^Authority=Apple Development:' "$signature_details"
|
||||
|
||||
executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_info")"
|
||||
architectures="$(lipo -archs "$app_path/$executable_name")"
|
||||
if [[ " $architectures " != *" arm64 "* ]]; then
|
||||
echo "Exported executable does not contain arm64" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_INFO="$app_info" PROFILE_PLIST="$embedded_profile" SIGNATURE_ENTITLEMENTS="$signature_entitlements" python3 <<'PY'
|
||||
import datetime
|
||||
import os
|
||||
import plistlib
|
||||
import sys
|
||||
|
||||
def load(path):
|
||||
with open(path, "rb") as handle:
|
||||
return plistlib.load(handle)
|
||||
|
||||
info = load(os.environ["APP_INFO"])
|
||||
profile = load(os.environ["PROFILE_PLIST"])
|
||||
signature = load(os.environ["SIGNATURE_ENTITLEMENTS"])
|
||||
profile_entitlements = profile.get("Entitlements", {})
|
||||
team_id = os.environ["APPLE_TEAM_ID"]
|
||||
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
|
||||
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
|
||||
provisioned = set(profile.get("ProvisionedDevices", []))
|
||||
expiration = profile.get("ExpirationDate")
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if expiration and expiration.tzinfo is None:
|
||||
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
checks = {
|
||||
"bundle identifier": info.get("CFBundleIdentifier") == bundle_id,
|
||||
"display name": info.get("CFBundleDisplayName") == "Truck Wash Debug",
|
||||
"debug executable": info.get("CFBundleExecutable") == "TruckWashDebug",
|
||||
"marketing version": info.get("CFBundleShortVersionString") == os.environ["MOBILE_VERSION_NAME"],
|
||||
"build number": info.get("CFBundleVersion") == os.environ["MOBILE_VERSION_CODE"],
|
||||
"minimum iOS": info.get("MinimumOSVersion") == "15.0",
|
||||
"profile UUID": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"],
|
||||
"profile team": team_id in profile.get("TeamIdentifier", []),
|
||||
"profile application identifier": profile_entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
|
||||
"development profile": profile_entitlements.get("get-task-allow") is True,
|
||||
"signature application identifier": signature.get("application-identifier") == f"{team_id}.{bundle_id}",
|
||||
"signature team identifier": signature.get("com.apple.developer.team-identifier") == team_id,
|
||||
"debuggable signature": signature.get("get-task-allow") is True,
|
||||
"profile expiration": expiration is not None and expiration > now,
|
||||
"registered devices": bool(allowed) and allowed <= provisioned,
|
||||
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
|
||||
}
|
||||
failures = [label for label, passed in checks.items() if not passed]
|
||||
if failures:
|
||||
print("Exported development IPA validation failed:", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f"- {failure}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Assemble debug artifact
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short_sha="${RESOLVED_SOURCE_SHA:0:12}"
|
||||
artifact_name="truck-wash-debug-${MOBILE_VERSION_NAME}-${short_sha}"
|
||||
artifact_dir="$RUNNER_TEMP/device-debug-artifact"
|
||||
ipa_filename="$artifact_name.ipa"
|
||||
dsym_filename="$artifact_name.dSYM.zip"
|
||||
mkdir -p "$artifact_dir"
|
||||
cp "$IOS_EXPORTED_IPA" "$artifact_dir/$ipa_filename"
|
||||
|
||||
shopt -s nullglob
|
||||
dsym_bundles=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM)
|
||||
if [[ ${#dsym_bundles[@]} -eq 0 ]]; then
|
||||
echo "The Debug archive did not contain any dSYM bundles" >&2
|
||||
exit 1
|
||||
fi
|
||||
ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact_dir/$dsym_filename"
|
||||
|
||||
capacitor_version="$(node -p "require('./node_modules/@capacitor/core/package.json').version")"
|
||||
BUILT_AT_UTC="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
||||
CAPACITOR_VERSION="$capacitor_version" \
|
||||
DSYM_FILENAME="$dsym_filename" \
|
||||
IPA_FILENAME="$ipa_filename" \
|
||||
MANIFEST_PATH="$artifact_dir/manifest.json" \
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"repository": os.environ["GITHUB_REPOSITORY"],
|
||||
"source_ref": os.environ["SOURCE_REF"],
|
||||
"source_sha": os.environ["RESOLVED_SOURCE_SHA"],
|
||||
"workflow_run": int(os.environ["GITHUB_RUN_NUMBER"]),
|
||||
"workflow_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
|
||||
"built_at_utc": os.environ["BUILT_AT_UTC"],
|
||||
"api_url": os.environ["IOS_DEBUG_API_URL"],
|
||||
"release_manager_control_api_url": os.environ["IOS_DEBUG_API_URL"],
|
||||
"bundle_id": os.environ["IOS_DEBUG_BUNDLE_ID"],
|
||||
"display_name": "Truck Wash Debug",
|
||||
"executable_name": "TruckWashDebug",
|
||||
"version": os.environ["MOBILE_VERSION_NAME"],
|
||||
"build": os.environ["MOBILE_VERSION_CODE"],
|
||||
"minimum_ios": "15.0",
|
||||
"capacitor_version": os.environ["CAPACITOR_VERSION"],
|
||||
"xcode_version": os.environ["XCODE_VERSION"],
|
||||
"signing_method": "development",
|
||||
"profile_expiration_utc": os.environ["IOS_PROFILE_EXPIRATION"],
|
||||
"ipa_filename": os.environ["IPA_FILENAME"],
|
||||
"dsym_filename": os.environ["DSYM_FILENAME"],
|
||||
}
|
||||
with open(os.environ["MANIFEST_PATH"], "w", encoding="utf-8") as handle:
|
||||
json.dump(manifest, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
PY
|
||||
|
||||
(
|
||||
cd "$artifact_dir"
|
||||
shasum -a 256 "$ipa_filename" "$dsym_filename" manifest.json > SHA256SUMS
|
||||
)
|
||||
echo "IOS_DEBUG_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV"
|
||||
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload device-debug artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
||||
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Clean up Apple signing assets
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then
|
||||
security delete-keychain "$IOS_KEYCHAIN_PATH" || true
|
||||
else
|
||||
security delete-keychain "$RUNNER_TEMP/ios-debug-signing.keychain-db" || true
|
||||
fi
|
||||
if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then
|
||||
rm -f "$IOS_PROFILE_INSTALL_PATH"
|
||||
fi
|
||||
rm -f \
|
||||
"$RUNNER_TEMP/ios-debug-development.p12" \
|
||||
"$RUNNER_TEMP/ios-debug-development.mobileprovision" \
|
||||
"$RUNNER_TEMP/ios-debug-development-profile.plist" \
|
||||
"$RUNNER_TEMP/ios-debug-embedded-profile.plist" \
|
||||
"$RUNNER_TEMP/ios-debug-signature-entitlements.plist" \
|
||||
"$RUNNER_TEMP/ios-debug-signature-details.txt"
|
||||
@@ -0,0 +1,437 @@
|
||||
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 === 2,
|
||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
||||
exactSource: proof.sha === process.env.IOS_SOURCE_SHA,
|
||||
releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0,
|
||||
archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""),
|
||||
activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0,
|
||||
verification: proof.verificationState === "verified",
|
||||
publicGate: proof.livePublicGate === "passed",
|
||||
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||
managerGate: proof.releaseManagerGate === "passed",
|
||||
serverVersion: proof.serverVersionUpdated === true,
|
||||
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||
};
|
||||
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Ruby and pinned Fastlane
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # 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:
|
||||
workflow_dispatch:
|
||||
@@ -11,55 +11,124 @@ on:
|
||||
description: Store build number/version code
|
||||
required: false
|
||||
type: string
|
||||
upload_android_to_play:
|
||||
description: Upload the signed Android App Bundle to Google Play
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
android_track:
|
||||
description: Google Play track for manual dispatches
|
||||
required: false
|
||||
type: choice
|
||||
default: production
|
||||
options:
|
||||
- production
|
||||
- beta
|
||||
- alpha
|
||||
- internal
|
||||
android_release_status:
|
||||
description: Google Play release status for manual dispatches
|
||||
required: false
|
||||
type: choice
|
||||
default: inProgress
|
||||
options:
|
||||
- inProgress
|
||||
- draft
|
||||
- halted
|
||||
push:
|
||||
tags:
|
||||
- "mobile-v*"
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Automated Tests
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: mobile-store-artifacts-${{ github.ref_name }}
|
||||
group: android-store-artifacts-${{ github.ref_name || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android AAB
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == github.event.repository.default_branch)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
name: Android AAB and Play upload
|
||||
runs-on: ubuntu-24.04
|
||||
environment: mobile-store-production
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
||||
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
|
||||
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
|
||||
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
|
||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
|
||||
PLAY_STORE_USER_FRACTION: "0.01"
|
||||
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Guard current master release
|
||||
id: release-guard
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current=true
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||
else
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Require green Chromium mobile tests before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
TEST_WORKFLOW_RUN_ID: ""
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -67,10 +136,11 @@ jobs:
|
||||
sdkmanager "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
- name: Resolve mobile version
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$INPUT_VERSION_NAME"
|
||||
@@ -84,10 +154,22 @@ jobs:
|
||||
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
|
||||
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check Android store environment
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||
run: node scripts/mobile/check-store-upload-env.mjs --android
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Decode Android signing key
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -96,10 +178,6 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
test -n "$ANDROID_KEY_ALIAS"
|
||||
test -n "$ANDROID_KEY_PASSWORD"
|
||||
keystore_path="$RUNNER_TEMP/android-release.keystore"
|
||||
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path"
|
||||
echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
|
||||
@@ -108,184 +186,79 @@ jobs:
|
||||
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sync Android shell
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
npm run mobile:android:sync
|
||||
npm run mobile:permissions:check
|
||||
npm run mobile:android:signing:check
|
||||
|
||||
- name: Build signed Android App Bundle
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
working-directory: android
|
||||
run: ./gradlew --no-daemon bundleRelease
|
||||
|
||||
- name: Verify Android App Bundle signature
|
||||
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
||||
|
||||
- name: 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
|
||||
uses: actions/upload-artifact@v4
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
path: android/app/build/outputs/bundle/release/app-release.aab
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||
path: ${{ env.ANDROID_AAB_PATH }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
ios:
|
||||
name: iOS IPA
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Resolve mobile version
|
||||
shell: bash
|
||||
- name: Recheck live master before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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"
|
||||
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: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build and sync iOS shell
|
||||
run: |
|
||||
npm run build
|
||||
npx cap sync ios
|
||||
npm run mobile:permissions:check
|
||||
|
||||
- name: Install Apple signing assets
|
||||
shell: bash
|
||||
- name: Upload Android App Bundle to Google Play
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
id: play-upload
|
||||
env:
|
||||
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 }}
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||
run: npm run mobile:android:play-upload
|
||||
|
||||
- name: Record Google Play submission proof
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
ARTIFACT_SHA256: ${{ steps.artifact-proof.outputs.sha256 }}
|
||||
PLAY_EDIT_ID: ${{ steps.play-upload.outputs.play_edit_id }}
|
||||
PLAY_VERSION_CODE: ${{ steps.play-upload.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$IOS_CERTIFICATE_BASE64"
|
||||
test -n "$IOS_CERTIFICATE_PASSWORD"
|
||||
test -n "$IOS_PROVISION_PROFILE_BASE64"
|
||||
test -n "$IOS_KEYCHAIN_PASSWORD"
|
||||
test -n "$APPLE_TEAM_ID"
|
||||
|
||||
certificate_path="$RUNNER_TEMP/apple-distribution.p12"
|
||||
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
|
||||
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
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: Resolve Swift packages
|
||||
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App
|
||||
|
||||
- name: Archive iOS app
|
||||
run: |
|
||||
xcodebuild \
|
||||
-project ios/App/App.xcodeproj \
|
||||
-scheme App \
|
||||
-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
|
||||
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>io.truckwash.app</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"
|
||||
|
||||
- name: Upload iOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/ios-export/*.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- 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
|
||||
test -n "$ARTIFACT_SHA256"
|
||||
test -n "$PLAY_EDIT_ID"
|
||||
test -n "$PLAY_VERSION_CODE"
|
||||
printf 'Google Play submission proof: platform=android applicationId=%s version=%s buildNumber=%s artifactSha256=%s signingIdentityRef=%s storeSubmissionId=%s status=%s fraction=%s\n' \
|
||||
"$ANDROID_PACKAGE_NAME" "$MOBILE_VERSION_NAME" "$PLAY_VERSION_CODE" \
|
||||
"$ARTIFACT_SHA256" "$ANDROID_SIGNING_IDENTITY_REF" "$PLAY_EDIT_ID" \
|
||||
"$PLAY_STORE_RELEASE_STATUS" "$PLAY_STORE_USER_FRACTION" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
name: Frontend Release Recovery
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: Verify the active release or roll back before verification
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- reverify
|
||||
- rollback
|
||||
source_sha:
|
||||
description: Exact 40-character commit SHA expected after recovery
|
||||
required: true
|
||||
type: string
|
||||
rollback_target:
|
||||
description: Immutable releases/.../dist target; required for rollback
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
name: Protected production recovery
|
||||
runs-on: ubuntu-latest
|
||||
environment: frontend-production
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
steps:
|
||||
- name: Validate exact recovery target
|
||||
shell: bash
|
||||
env:
|
||||
RECOVERY_ACTION: ${{ inputs.action }}
|
||||
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$RECOVERY_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||
if [[ "$RECOVERY_ACTION" == "rollback" ]]; then
|
||||
[[ "$RECOVERY_TARGET" =~ ^releases/[A-Za-z0-9._-]+/dist$ ]]
|
||||
else
|
||||
[[ -z "$RECOVERY_TARGET" ]]
|
||||
fi
|
||||
|
||||
- name: Checkout exact recovery source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.source_sha }}
|
||||
|
||||
- name: Authorize source from successful release proof
|
||||
id: authorize
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RECOVERY_ACTION: ${{ inputs.action }}
|
||||
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
runs="$RUNNER_TEMP/recovery-runs.json"
|
||||
artifacts="$RUNNER_TEMP/recovery-artifacts.json"
|
||||
curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?head_sha=$RECOVERY_SHA&status=success&per_page=20" \
|
||||
> "$runs"
|
||||
release_run_id="$(jq -r '[.workflow_runs[] | select(.event == "workflow_run")] | first | .id // empty' "$runs")"
|
||||
[[ "$release_run_id" =~ ^[0-9]+$ ]]
|
||||
artifact_name="frontend-release-proof-$RECOVERY_SHA"
|
||||
curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$release_run_id/artifacts?name=$artifact_name&per_page=20" \
|
||||
> "$artifacts"
|
||||
artifact_id="$(jq -r '[.artifacts[] | select(.expired == false)] | first | .id // empty' "$artifacts")"
|
||||
[[ "$artifact_id" =~ ^[0-9]+$ ]]
|
||||
mkdir -p "$RUNNER_TEMP/recovery-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/recovery-proof.zip"
|
||||
unzip -q "$RUNNER_TEMP/recovery-proof.zip" -d "$RUNNER_TEMP/recovery-proof"
|
||||
PROOF_PATH="$RUNNER_TEMP/recovery-proof/frontend-release-proof.json" \
|
||||
RELEASE_RUN_ID="$release_run_id" node <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require("node:fs");
|
||||
const proof = JSON.parse(readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||
const sha = process.env.RECOVERY_SHA;
|
||||
const target = process.env.RECOVERY_TARGET;
|
||||
const expectedPrefix = `releases/${sha}-`;
|
||||
const valid = proof.schemaVersion === 2
|
||||
&& proof.repository === process.env.GITHUB_REPOSITORY
|
||||
&& proof.sha === sha
|
||||
&& proof.sourceSha === sha
|
||||
&& proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID
|
||||
&& proof.verificationState === "verified"
|
||||
&& proof.livePublicGate === "passed"
|
||||
&& ["passed", "not-configured"].includes(proof.liveCredentialedGate)
|
||||
&& proof.releaseManagerGate === "passed"
|
||||
&& proof.serverVersionUpdated === true
|
||||
&& proof.serverVersionReadBack === "passed"
|
||||
&& /^[1-9][0-9]*-[1-9][0-9]*$/.test(String(proof.buildId || ""))
|
||||
&& typeof proof.activeTarget === "string"
|
||||
&& proof.activeTarget.startsWith(expectedPrefix)
|
||||
&& proof.activeTarget.endsWith("/dist");
|
||||
if (!valid) throw new Error("Recovery source does not have valid exact-release proof.");
|
||||
if (process.env.RECOVERY_ACTION === "rollback" && target !== proof.activeTarget) {
|
||||
throw new Error("Rollback target does not match the verified release proof.");
|
||||
}
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `verified_target=${proof.activeTarget}\n`);
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `build_id=${proof.buildId}\n`);
|
||||
NODE
|
||||
|
||||
- name: Capture current immutable target
|
||||
id: current
|
||||
shell: bash
|
||||
env:
|
||||
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { appendFileSync } from "node:fs";
|
||||
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Active manifest returned HTTP ${response.status}.`);
|
||||
const manifest = await response.json();
|
||||
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||
const build = String(manifest.build_id || "");
|
||||
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[A-Za-z0-9._-]{1,180}$/.test(build)) {
|
||||
throw new Error("Active manifest has invalid release identity.");
|
||||
}
|
||||
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(build)) {
|
||||
throw new Error("Active manifest build id is not a release run identity.");
|
||||
}
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `previous_sha=${sha}\nprevious_build_id=${build}\nprevious_target=releases/${sha}-${build}/dist\n`);
|
||||
NODE
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install secure FTP client without system changes
|
||||
run: |
|
||||
if command -v lftp >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
package_root="$RUNNER_TEMP/lftp-package"
|
||||
mkdir -p "$package_root"
|
||||
(
|
||||
cd "$package_root"
|
||||
apt-get download lftp
|
||||
dpkg-deb --extract ./lftp_*.deb root
|
||||
)
|
||||
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Roll back atomically
|
||||
if: inputs.action == 'rollback'
|
||||
id: rollback
|
||||
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ inputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
|
||||
- name: Verify active manifest matches authorized release
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.source_sha }}
|
||||
EXPECTED_TARGET: ${{ steps.authorize.outputs.verified_target }}
|
||||
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
const deadline = Date.now() + 300_000;
|
||||
let actual = "";
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const manifest = await response.json();
|
||||
const manifestSha = String(manifest.commit_sha || "").toLowerCase();
|
||||
actual = `releases/${manifestSha}-${String(manifest.build_id || "")}/dist`;
|
||||
if (manifestSha === process.env.EXPECTED_SHA && actual === process.env.EXPECTED_TARGET) process.exit(0);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
}
|
||||
throw new Error(`Active release identity did not converge to the authorized target; observed ${actual || "unavailable"}.`);
|
||||
NODE
|
||||
|
||||
- name: Public live verification
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live verification
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record verified server version
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ inputs.source_sha }}
|
||||
RELEASE_BUILD_ID: ${{ steps.authorize.outputs.build_id }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Publish recovery audit
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-recovery-${{ inputs.source_sha }}-${{ github.run_id }}
|
||||
path: |
|
||||
test-results
|
||||
playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Restore pre-recovery target after downstream failure
|
||||
if: >-
|
||||
failure() && inputs.action == 'rollback'
|
||||
shell: bash
|
||||
run: |
|
||||
node scripts/release/deploy-cpanel.mjs --rollback
|
||||
node --input-type=module <<'NODE'
|
||||
const deadline = Date.now() + 300_000;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(new URL(`release-manifest.json?restore=${Date.now()}`, process.env.PRODUCTION_FRONTEND_URL), {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const manifest = await response.json();
|
||||
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||
const target = `releases/${sha}-${String(manifest.build_id || "")}/dist`;
|
||||
if (sha === process.env.RELEASE_VERSION && target === process.env.RELEASE_ROLLBACK_TARGET) process.exit(0);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
}
|
||||
throw new Error("Failed to restore and verify the pre-recovery target.");
|
||||
NODE
|
||||
npm run release:update-server-version
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.current.outputs.previous_target }}
|
||||
RELEASE_VERSION: ${{ steps.current.outputs.previous_sha }}
|
||||
RELEASE_BUILD_ID: ${{ steps.current.outputs.previous_build_id }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
@@ -14,53 +14,62 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-release-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
group: frontend-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-upload-and-verify:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
build-release:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'master' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
outputs:
|
||||
current: ${{ steps.branch-head.outputs.current }}
|
||||
build_id: ${{ steps.package.outputs.build_id }}
|
||||
artifact_name: ${{ steps.package-names.outputs.artifact_name }}
|
||||
archive_name: ${{ steps.package.outputs.archive_name }}
|
||||
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||
release_id: ${{ steps.package.outputs.release_id }}
|
||||
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Check release commit is current
|
||||
id: branch-head
|
||||
run: |
|
||||
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
|
||||
if [[ -z "$latest_sha" ]]; then
|
||||
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
|
||||
echo "current=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
|
||||
exit 0
|
||||
fi
|
||||
echo "current=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release commit is current for $RELEASE_BRANCH."
|
||||
env:
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { data: branch } = await github.rest.repos.getBranch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: "master",
|
||||
});
|
||||
const expected = process.env.RELEASE_EXPECTED_COMMIT;
|
||||
const current = branch.commit.sha === expected;
|
||||
core.setOutput("current", String(current));
|
||||
core.info(
|
||||
current
|
||||
? `Release commit ${expected} is current for master.`
|
||||
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
|
||||
);
|
||||
|
||||
- name: Checkout tested commit
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -79,16 +88,22 @@ jobs:
|
||||
npm run text:check-encoding
|
||||
npm run i18n:v2:source-check
|
||||
|
||||
- name: Unit tests
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
- name: Build release artifact
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run build
|
||||
|
||||
- name: Record pre-gate dist inventory
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: |
|
||||
inventory="$RUNNER_TEMP/dist-before-production-gate.txt"
|
||||
while IFS= read -r -d '' file; do
|
||||
relative_path="${file#dist/}"
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"$(stat --format='%s' "$file")" \
|
||||
"$relative_path"
|
||||
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
@@ -96,60 +111,209 @@ jobs:
|
||||
- name: Production Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:prod
|
||||
env:
|
||||
PLAYWRIGHT_PROD_PREBUILT: "1"
|
||||
PLAYWRIGHT_PROD_WEBKIT: "0"
|
||||
|
||||
- name: Upload dist artifact
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||
path: dist
|
||||
retention-days: 3
|
||||
|
||||
- name: Request Release Manager auto sync
|
||||
- name: Confirm production gate did not mutate dist
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
response_file="$(mktemp)"
|
||||
status_code="$(curl --show-error --silent \
|
||||
--output "$response_file" \
|
||||
--write-out "%{http_code}" \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||
if [[ "$status_code" =~ ^2 ]]; then
|
||||
cat "$response_file"
|
||||
elif [[ "$status_code" == "504" ]]; then
|
||||
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
|
||||
else
|
||||
cat "$response_file" >&2
|
||||
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
inventory="$RUNNER_TEMP/dist-after-production-gate.txt"
|
||||
while IFS= read -r -d '' file; do
|
||||
relative_path="${file#dist/}"
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"$(stat --format='%s' "$file")" \
|
||||
"$relative_path"
|
||||
done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory"
|
||||
cmp "$RUNNER_TEMP/dist-before-production-gate.txt" "$inventory"
|
||||
|
||||
- name: Wait for Coolify release artifact
|
||||
- name: Package and validate release
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run release:verify-upload
|
||||
id: package
|
||||
run: node scripts/release/package-dist.mjs
|
||||
env:
|
||||
RELEASE_OUTPUT_DIR: release-artifacts
|
||||
|
||||
- name: Resolve package metadata
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: package-names
|
||||
env:
|
||||
BUILD_ID: ${{ steps.package.outputs.build_id }}
|
||||
CHECKSUM_PATH: ${{ steps.package.outputs.checksum_path }}
|
||||
INVENTORY_PATH: ${{ steps.package.outputs.inventory_path }}
|
||||
run: |
|
||||
echo "artifact_name=frontend-release-$BUILD_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "checksum_name=$(basename -- "$CHECKSUM_PATH")" >> "$GITHUB_OUTPUT"
|
||||
echo "inventory_name=$(basename -- "$INVENTORY_PATH")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload release package
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ steps.package-names.outputs.artifact_name }}
|
||||
path: |
|
||||
${{ steps.package.outputs.archive_path }}
|
||||
${{ steps.package.outputs.checksum_path }}
|
||||
${{ steps.package.outputs.inventory_path }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
deploy-frontend-production:
|
||||
needs: build-release
|
||||
if: needs.build-release.outputs.current == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
environment:
|
||||
name: frontend-production
|
||||
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
env:
|
||||
RELEASE_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ needs.build-release.outputs.build_id }}
|
||||
RELEASE_ID: ${{ needs.build-release.outputs.release_id }}
|
||||
RELEASE_STRICT_BUILD_ID: "true"
|
||||
RELEASE_REQUIRE_CACHE_HEADERS: "true"
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 0
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 300
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 5
|
||||
steps:
|
||||
- name: Checkout tested commit
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install secure FTP client without system changes
|
||||
run: |
|
||||
if command -v lftp >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
package_root="$RUNNER_TEMP/lftp-package"
|
||||
mkdir -p "$package_root"
|
||||
(
|
||||
cd "$package_root"
|
||||
apt-get download lftp
|
||||
dpkg-deb --extract ./lftp_*.deb root
|
||||
)
|
||||
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Download validated release package
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||
path: release-artifacts
|
||||
|
||||
- name: Resolve downloaded release package
|
||||
env:
|
||||
ARCHIVE_NAME: ${{ needs.build-release.outputs.archive_name }}
|
||||
CHECKSUM_NAME: ${{ needs.build-release.outputs.checksum_name }}
|
||||
INVENTORY_NAME: ${{ needs.build-release.outputs.inventory_name }}
|
||||
run: |
|
||||
[[ -n "$ARCHIVE_NAME" && "$ARCHIVE_NAME" == "$(basename -- "$ARCHIVE_NAME")" ]]
|
||||
[[ -n "$CHECKSUM_NAME" && "$CHECKSUM_NAME" == "$(basename -- "$CHECKSUM_NAME")" ]]
|
||||
[[ -n "$INVENTORY_NAME" && "$INVENTORY_NAME" == "$(basename -- "$INVENTORY_NAME")" ]]
|
||||
|
||||
archive_path="$GITHUB_WORKSPACE/release-artifacts/$ARCHIVE_NAME"
|
||||
checksum_path="$GITHUB_WORKSPACE/release-artifacts/$CHECKSUM_NAME"
|
||||
inventory_path="$GITHUB_WORKSPACE/release-artifacts/$INVENTORY_NAME"
|
||||
[[ -f "$archive_path" && -f "$checksum_path" && -f "$inventory_path" ]]
|
||||
|
||||
echo "RELEASE_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_ARCHIVE_SHA256_PATH=$checksum_path" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_INVENTORY_PATH=$inventory_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check release commit is still current
|
||||
id: branch-head
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const { data: branch } = await github.rest.repos.getBranch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: "master",
|
||||
});
|
||||
const expected = process.env.RELEASE_EXPECTED_COMMIT;
|
||||
const current = branch.commit.sha === expected;
|
||||
core.setOutput("current", String(current));
|
||||
core.info(
|
||||
current
|
||||
? `Release commit ${expected} is current immediately before activation.`
|
||||
: `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`,
|
||||
);
|
||||
|
||||
- name: Deploy atomically and verify cPanel release
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: deploy
|
||||
timeout-minutes: 15
|
||||
run: node scripts/release/deploy-cpanel.mjs
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Public live Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: public_live
|
||||
timeout-minutes: 10
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
- name: Detect credentialed live gate configuration
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: credentialed_live_config
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" &&
|
||||
-n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
|
||||
- name: Credentialed live Playwright gate (when configured)
|
||||
if: >-
|
||||
steps.branch-head.outputs.current == 'true' &&
|
||||
steps.credentialed_live_config.outputs.configured == 'true'
|
||||
id: credentialed_live
|
||||
timeout-minutes: 15
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
@@ -158,7 +322,9 @@ jobs:
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
id: release_manager
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||
@@ -166,12 +332,11 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: Update server version after verification
|
||||
@@ -179,14 +344,111 @@ jobs:
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- 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 requireSuccessfulStep = (name) => {
|
||||
const outcome = required(name);
|
||||
if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`);
|
||||
return "passed";
|
||||
};
|
||||
const credentialedGate = () => {
|
||||
const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED");
|
||||
if (configured === "false") return "not-configured";
|
||||
if (configured !== "true") {
|
||||
throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`);
|
||||
}
|
||||
return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME");
|
||||
};
|
||||
const proof = {
|
||||
schemaVersion: 2,
|
||||
releaseId: required("RELEASE_ID"),
|
||||
sha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||
archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(),
|
||||
activeTarget: required("RELEASE_ACTIVE_TARGET"),
|
||||
rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null,
|
||||
verificationState: "verified",
|
||||
observedAt: new Date().toISOString(),
|
||||
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: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||
liveCredentialedGate: credentialedGate(),
|
||||
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||
serverVersionUpdated: true,
|
||||
serverVersionReadBack: "passed",
|
||||
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 }}
|
||||
RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }}
|
||||
RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }}
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }}
|
||||
LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }}
|
||||
LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }}
|
||||
RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }}
|
||||
|
||||
- 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: Roll back after any post-deployment verification failure
|
||||
if: >-
|
||||
failure() && steps.branch-head.outputs.current == 'true' &&
|
||||
steps.deploy.outcome == 'success'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release/deploy-cpanel.mjs --rollback
|
||||
[[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]]
|
||||
export RELEASE_VERSION="${BASH_REMATCH[1]}"
|
||||
export RELEASE_BUILD_ID="${BASH_REMATCH[2]}"
|
||||
npm run release:update-server-version
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
retention-days: 14
|
||||
|
||||
@@ -48,15 +48,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
||||
# - FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL: full-suite parallelism while PR E2E runs beside it.
|
||||
# GitHub-hosted target: ["ubuntu-24.04"], with PR parallelism 10, concurrent full parallelism 26,
|
||||
# and standalone scheduled full parallelism 36. This keeps the workflow peak at 36 hosted jobs.
|
||||
jobs:
|
||||
format-tests:
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
@@ -78,10 +80,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -94,10 +96,15 @@ jobs:
|
||||
- name: Check frontend test formatting
|
||||
run: npm run format:tests:check
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
quality-checks:
|
||||
name: Quality-${{ matrix.check }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
check: [lint, i18n, build, unit-fast, unit-serial]
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
@@ -115,29 +122,68 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check i18n source consistency
|
||||
run: npm run i18n:v2:check
|
||||
|
||||
- name: Build sanity check
|
||||
run: npm run build
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Run quality check
|
||||
shell: bash
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
MATRIX_CHECK: ${{ matrix.check }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_CHECK" in
|
||||
lint)
|
||||
npm run lint
|
||||
;;
|
||||
i18n)
|
||||
npm run i18n:v2:check
|
||||
;;
|
||||
build)
|
||||
npm run build
|
||||
;;
|
||||
unit-fast)
|
||||
npm run text:check-encoding
|
||||
npm run test:unit:fast
|
||||
;;
|
||||
unit-serial)
|
||||
VITEST_BATCH_SIZE=5 npm run test:unit:serial
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported quality check: $MATRIX_CHECK" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
build-and-unit:
|
||||
if: ${{ always() }}
|
||||
name: Build and unit summary
|
||||
needs: [format-tests, quality-checks]
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify quality jobs succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for required_job in FORMAT_TESTS_RESULT QUALITY_CHECKS_RESULT; do
|
||||
result="${!required_job:-missing}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "${required_job}=${result}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
e2e-targeted:
|
||||
if: >
|
||||
@@ -177,10 +223,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -301,7 +347,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-targeted-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -329,7 +375,7 @@ jobs:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
@@ -353,7 +399,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -390,7 +436,7 @@ jobs:
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -405,8 +451,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_SUITE" in
|
||||
core) suite_offset=0 ;;
|
||||
changed) suite_offset=10 ;;
|
||||
changed-1-of-2) suite_offset=0 ;;
|
||||
changed-2-of-2) suite_offset=10 ;;
|
||||
smoke) suite_offset=20 ;;
|
||||
pr) suite_offset=30 ;;
|
||||
ct) suite_offset=40 ;;
|
||||
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$MATRIX_PROJECT" in
|
||||
@@ -414,7 +463,7 @@ jobs:
|
||||
chromium-mobile) project_offset=2 ;;
|
||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
port_seed=$((21000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -485,13 +534,27 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
||||
fi
|
||||
case "$MATRIX_SUITE" in
|
||||
ct)
|
||||
npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
smoke)
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
pr)
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
changed-1-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=1/2 --pass-with-no-tests
|
||||
;;
|
||||
changed-2-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=2/2 --pass-with-no-tests
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
@@ -502,7 +565,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -511,29 +574,66 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
required-ci:
|
||||
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
|
||||
name: Required CI
|
||||
needs: [format-tests, build-and-unit, e2e-pr]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||
BUILD_AND_UNIT_RESULT: ${{ needs.build-and-unit.result }}
|
||||
E2E_PR_RESULT: ${{ needs.e2e-pr.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for required_job in FORMAT_TESTS_RESULT BUILD_AND_UNIT_RESULT E2E_PR_RESULT; do
|
||||
result="${!required_job:-missing}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "${required_job}=${result}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
e2e-full:
|
||||
if: >
|
||||
always() &&
|
||||
(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 == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
||||
(
|
||||
github.event_name != 'workflow_dispatch' ||
|
||||
inputs.mode == 'full' ||
|
||||
needs.e2e-targeted.result == 'success'
|
||||
)
|
||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
needs: [build-and-unit, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') > 0 && github.event_name == 'schedule' && fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') || fromJSON(vars.FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL || '26') }}
|
||||
matrix:
|
||||
browser: [chromium, webkit, firefox]
|
||||
device: [mobile, desktop, tablet]
|
||||
role: [superuser, admin, customer, subuser]
|
||||
shard_index: [1, 2]
|
||||
browser: [chromium, webkit, firefox]
|
||||
exclude:
|
||||
- role: customer
|
||||
shard_index: 2
|
||||
- role: subuser
|
||||
shard_index: 2
|
||||
include:
|
||||
- browser: chromium
|
||||
browser_label: Chromium
|
||||
@@ -545,7 +645,7 @@ jobs:
|
||||
browser_label: Firefox
|
||||
browser_install: firefox
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: off
|
||||
@@ -566,10 +666,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -579,6 +679,8 @@ jobs:
|
||||
MATRIX_ROLE: ${{ matrix.role }}
|
||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||
MATRIX_DEVICE: ${{ matrix.device }}
|
||||
MATRIX_SHARD_INDEX: ${{ matrix.shard_index }}
|
||||
MATRIX_SHARD_TOTAL: ${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -601,7 +703,12 @@ jobs:
|
||||
tablet) device_offset=3 ;;
|
||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
case "$MATRIX_SHARD_INDEX" in
|
||||
1) shard_offset=0 ;;
|
||||
2) shard_offset=400 ;;
|
||||
*) echo "Unsupported Playwright shard index: $MATRIX_SHARD_INDEX" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((22000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset + shard_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -635,8 +742,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" \
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" \
|
||||
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
@@ -651,6 +758,8 @@ jobs:
|
||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||
--env MATRIX_SHARD_INDEX="$MATRIX_SHARD_INDEX" \
|
||||
--env MATRIX_SHARD_TOTAL="$MATRIX_SHARD_TOTAL" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
@@ -671,23 +780,52 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE" --shard="$MATRIX_SHARD_INDEX/$MATRIX_SHARD_TOTAL"
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}"
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}/shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
full-e2e-summary:
|
||||
if: >
|
||||
always() &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(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
|
||||
|
||||
@@ -15,6 +15,7 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
dev-dist
|
||||
.playwright-cli/
|
||||
|
||||
# Mobile build and signing outputs
|
||||
/app/build/
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
npm install
|
||||
```
|
||||
|
||||
## Contributing Changes
|
||||
|
||||
Create a scoped feature branch, push it, and open a pull request targeting
|
||||
`master`. Do not push directly to `master`. Merge only after the `Required CI`
|
||||
check succeeds, all review conversations are resolved, and the branch is up to
|
||||
date. Use squash merge so `master` retains linear history.
|
||||
|
||||
See [`.github/BRANCH_PROTECTION.md`](.github/BRANCH_PROTECTION.md) for the
|
||||
repository policy, rollout checks, and emergency bypass procedure.
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
@@ -160,10 +170,11 @@ The Play Store Android package is built from the Capacitor project in `android/`
|
||||
The legacy Bubblewrap/TWA project at the repository root is not used by
|
||||
`npm run mobile:android:bundle`.
|
||||
|
||||
The source image for the native launcher icon is:
|
||||
The native launcher and store icons use the opaque iOS marketing icon as their
|
||||
shared master so Android and iOS keep the same white background:
|
||||
|
||||
```text
|
||||
public/favicons/web-app-manifest-512x512.png
|
||||
ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
|
||||
```
|
||||
|
||||
Regenerate the checked-in launcher assets after changing that source image:
|
||||
@@ -183,6 +194,19 @@ the Capacitor Android project. The generator updates `android/app/src/main/res`
|
||||
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
|
||||
and `store_icon.png`.
|
||||
|
||||
## Mobile Store Releases
|
||||
|
||||
Signed Android and iOS store artifacts are built through the GitHub Actions
|
||||
`Mobile Store Artifacts` workflow. By default, current `master` after green
|
||||
`Automated Tests` uploads Android to Google Play production and uploads iOS to
|
||||
App Store Connect.
|
||||
|
||||
See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and
|
||||
local mobile checks. See `docs/app-store-release.md` for App Store Connect
|
||||
release preparation and review notes. For a separate development-signed IPA
|
||||
that can be installed on an approved iPhone from Ubuntu over USB, see
|
||||
`docs/ios-device-debug.md`.
|
||||
|
||||
## Bubblewrap (TWA) Build and Install
|
||||
|
||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||
|
||||
|
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"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0787BB</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
||||
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
||||
server: {
|
||||
androidScheme: "https",
|
||||
},
|
||||
plugins: {
|
||||
StatusBar: {
|
||||
overlaysWebView: false,
|
||||
style: "LIGHT",
|
||||
backgroundColor: "#FFFFFFFF",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Apple App Store Release Runbook
|
||||
|
||||
This is the operating runbook for the public iOS application and its signed
|
||||
GitHub Actions delivery. Public review submission remains a human action in App
|
||||
Store Connect; the approved version releases automatically after Apple approval.
|
||||
|
||||
## Storefront record
|
||||
|
||||
Create or reconcile one App Store Connect record:
|
||||
|
||||
| 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 |
|
||||
|
||||
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
||||
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||
been tested deliberately. Do not enable preorder or phased release for version
|
||||
`1.0.0`, and disable automatic availability in newly added territories.
|
||||
|
||||
The Account Holder or Admin must complete these console-only items before the
|
||||
first candidate:
|
||||
|
||||
- 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.
|
||||
|
||||
Review notes must explain customer and driver login, the review account's 2FA
|
||||
path, QR/hardware behavior, camera/location denial fallbacks, and the physical
|
||||
service payment model.
|
||||
|
||||
## Metadata and assets in Git
|
||||
|
||||
`fastlane/metadata/da-DK/` is the Danish storefront source of truth.
|
||||
`ios/release.json` is the release-version source of truth. Its version is
|
||||
numeric `X.Y.Z`; its bundle ID must remain `io.truckwash.app`.
|
||||
|
||||
Run the readiness validation locally:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:storefront:check
|
||||
```
|
||||
|
||||
Readiness mode validates all present assets and reports missing screenshot sets
|
||||
as warnings. A candidate tag runs strict mode and requires exactly six reviewed
|
||||
images in each set:
|
||||
|
||||
- `fastlane/screenshots/da-DK/iphone-6.9-01-*.png` through `06`, 1320×2868.
|
||||
- `fastlane/screenshots/da-DK/ipad-13-01-*.png` through `06`, 2064×2752.
|
||||
|
||||
Use Xcode 26 simulators and the real Capacitor app. Capture dashboard, booking,
|
||||
self-wash/QR, vehicles, orders/history, and invoices. Screenshots must contain
|
||||
sanitized fixture data, no alpha channel, no real customer data, and no
|
||||
placeholder content. Linux CI cannot honestly synthesize authenticated native
|
||||
captures; capture and approve them on a controlled macOS machine before tagging.
|
||||
|
||||
The validator also rejects the known default Capacitor icon and splash artwork.
|
||||
Native permission strings must exist in Danish and English and are included via
|
||||
the `InfoPlist.strings` Xcode variant group.
|
||||
|
||||
## Apple identities and GitHub configuration
|
||||
|
||||
Create:
|
||||
|
||||
1. A dedicated App Store Connect team API key named `GitHub App Store CI` with
|
||||
the App Manager role. Team JWTs use the account issuer ID in the `iss` claim.
|
||||
2. A dedicated Apple Distribution certificate for CI.
|
||||
3. An App Store distribution provisioning profile for `io.truckwash.app`.
|
||||
4. An internal TestFlight group named `Internal QA` with automatic distribution.
|
||||
|
||||
Configure two GitHub environments:
|
||||
|
||||
- `app-store-signing`, branch policy limited to protected `master`.
|
||||
- `app-store-candidate`, tag policy limited to protected `ios-v*` tags.
|
||||
|
||||
Private repositories on the Team plan cannot rely on environment required
|
||||
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
||||
limited to release managers. Manual App Review submission is the final human
|
||||
approval. App Store Connect API readback must show `AFTER_APPROVAL`, Denmark
|
||||
(`DNK`) as the only available territory, preorder disabled, and automatic
|
||||
future territories disabled.
|
||||
|
||||
Environment secrets:
|
||||
|
||||
- `IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64`
|
||||
- `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD`
|
||||
- `IOS_APP_STORE_PROFILE_BASE64`
|
||||
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `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`
|
||||
|
||||
The repository variable `APP_STORE_AUTOMATION_ENABLED` is the authoritative
|
||||
activation switch. Missing or any value other than `true` makes all signing,
|
||||
credential-health, and candidate workflows succeed as safe no-ops without
|
||||
selecting an App Store environment or reading Apple secrets.
|
||||
|
||||
## Enablement and first canary
|
||||
|
||||
Keep `APP_STORE_AUTOMATION_ENABLED=false` while configuring Apple/GitHub state.
|
||||
Then:
|
||||
|
||||
1. Merge all product-readiness work and confirm `App Store Readiness` passes.
|
||||
After its first successful default-branch run, add that job to the protected
|
||||
master ruleset's required status checks.
|
||||
2. Verify the privacy policy, account-deletion flow, icons, localized permission
|
||||
copy, and privacy manifest on a device.
|
||||
3. Set the repository switch to `true` during a controlled release window.
|
||||
4. Dispatch `iOS Internal TestFlight` from the `master` workflow definition,
|
||||
supplying the full current master SHA and confirmation
|
||||
`UPLOAD IOS INTERNAL BUILD`.
|
||||
5. Confirm the workflow validates Xcode 26.3/iOS 26, certificate/profile
|
||||
identity and expiry, the signed IPA, App Store processing, and exact Internal
|
||||
QA assignment.
|
||||
6. Install the result on a clean supported iPhone and iPad. Verify fresh install,
|
||||
upgrade, login, resume, offline/reconnect, permission allow/deny, booking,
|
||||
self-wash/QR, vehicles, orders, invoices, support/privacy, and account
|
||||
deletion.
|
||||
7. Leave the switch enabled only after the canary is accepted.
|
||||
|
||||
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.
|
||||
|
||||
## Continuous TestFlight delivery
|
||||
|
||||
`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`.
|
||||
|
||||
`iOS Internal TestFlight` consumes that exact proof. It refuses a stale SHA,
|
||||
uses `/Applications/Xcode_26.3.app`, requires an iOS 26 SDK, queries App Store
|
||||
Connect for the next build number under serialized concurrency, signs and
|
||||
inspects the IPA, uploads through pinned Fastlane, waits for processing, and
|
||||
idempotently assigns the exact build to Internal QA.
|
||||
|
||||
Outputs include:
|
||||
|
||||
- Signed IPA, retained for 30 days.
|
||||
- dSYMs, SHA-256 checksums, and `ios-release-manifest.json`, retained for 90
|
||||
days.
|
||||
- Source SHA, marketing/build versions, App Store build ID, Xcode/SDK versions,
|
||||
and workflow identity in the manifest.
|
||||
|
||||
Every successful future Frontend Release for current `master` triggers this
|
||||
delivery automatically. Stale or proofless releases do not sign or upload.
|
||||
|
||||
## Select a public candidate
|
||||
|
||||
1. Verify the desired TestFlight build on iPhone and iPad.
|
||||
2. Confirm its commit's `ios/release.json` contains the public version.
|
||||
3. Create a new protected tag such as `ios-v1.0.0` on that exact commit. Never
|
||||
move or reuse an existing release tag.
|
||||
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
|
||||
verifies the exact processed App Store build, enforces complete screenshots,
|
||||
synchronizes Danish metadata, attaches the existing build, and reads it back.
|
||||
It also writes and verifies automatic release after approval, then verifies
|
||||
Denmark-only availability and no preorder. It does not rebuild or submit for
|
||||
review.
|
||||
5. In App Store Connect, review the rendered product page, review account,
|
||||
privacy/export/age answers, and candidate build. Submit manually.
|
||||
6. Submit version `1.0.0` for review. Apple releases it automatically after
|
||||
approval. Do not use phased release for `1.0.0`; use phased release for later
|
||||
updates unless there is a reason not to.
|
||||
7. Merge the next `ios/release.json` version bump before further delivery after
|
||||
Apple closes the released version to new builds.
|
||||
|
||||
## Rotation and recovery
|
||||
|
||||
`iOS Credential Health` runs every Monday and fails when certificate/profile
|
||||
identity drifts or either expires within 30 days. Rotate one credential at a
|
||||
time, keep automation disabled during rotation, and repeat the canary.
|
||||
|
||||
- Bad TestFlight build: expire it, fix master, and produce a new build number.
|
||||
- Bad candidate: detach it in App Store Connect and tag a corrected tested SHA
|
||||
with a new version; never move the tag.
|
||||
- Bad phased update: pause the phase.
|
||||
- Compromised key/certificate: disable automation, revoke it in Apple, rotate
|
||||
GitHub secrets, inspect audit logs, and run a fresh canary.
|
||||
- Public emergency: remove from sale only when necessary and prepare an
|
||||
expedited corrective version.
|
||||
@@ -0,0 +1,223 @@
|
||||
# cPanel frontend deployment
|
||||
|
||||
This runbook covers the production deployment of `pleno-vue` only. The API is
|
||||
not uploaded to cPanel and continues to use its existing release process and
|
||||
hosts.
|
||||
|
||||
## Release flow
|
||||
|
||||
`.github/workflows/release.yml` starts only after the `Automated Tests`
|
||||
workflow succeeds for a push to `master` in this repository. It then:
|
||||
|
||||
1. Rechecks that the tested commit is still the head of `master`.
|
||||
2. Checks out that exact commit without persisting GitHub credentials.
|
||||
3. Installs dependencies, runs source checks, and builds `dist` once.
|
||||
4. Runs the local-production Playwright gate against that existing `dist`.
|
||||
5. Creates an immutable ZIP, SHA-256 sidecar, and file inventory, then verifies
|
||||
a local archive round trip.
|
||||
6. Uploads the package as a required GitHub Actions artifact.
|
||||
7. Enters the protected `frontend-production` GitHub environment and rechecks
|
||||
`master` immediately before deployment.
|
||||
8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The
|
||||
uploaded `.part` files are downloaded and hashed before they are renamed.
|
||||
9. Uploads an authenticated, bounded-lifetime request into the jailed
|
||||
deployment directory. A root-owned account-scoped activator validates the
|
||||
request and archive, extracts an inactive release, verifies its manifest
|
||||
identity and required files, and replaces `current` with a local
|
||||
single-filesystem rename.
|
||||
10. Downloads the extracted tree and compares it byte-for-byte with the
|
||||
validated inventory. Public manifest, asset-integrity, cache-header,
|
||||
API-ping, and role gates then run against the active release. A failed gate
|
||||
asks the same activator to restore the previous immutable target.
|
||||
|
||||
The fixed `frontend-production` concurrency group is not cancellable. A newer
|
||||
push therefore cannot interrupt an in-progress switch or rollback.
|
||||
|
||||
## GitHub environment
|
||||
|
||||
Create the environment `frontend-production`, restrict deployment branches to
|
||||
protected branches, and keep `master` protected by the required CI checks.
|
||||
Production approvals can be added as an environment protection rule.
|
||||
|
||||
Add these environment **secrets**:
|
||||
|
||||
- `PRODUCTION_FTP_HOST`
|
||||
- `PRODUCTION_FTP_USER`
|
||||
- `PRODUCTION_FTP_PASSWORD`
|
||||
- `PRODUCTION_FTP_PATH`
|
||||
- `PRODUCTION_ACTIVATION_KEY`
|
||||
|
||||
The API `.env` contains legacy values under the first four names, but production
|
||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||
deployment unchanged.
|
||||
|
||||
The hosted release path deliberately does not call the remote cPanel API.
|
||||
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||
activator, exact inventory comparison, and public manifest verification.
|
||||
|
||||
Add these environment **variables**:
|
||||
|
||||
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||
|
||||
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||
|
||||
### Create the dedicated FTP credentials
|
||||
|
||||
1. Open **Files -> FTP Accounts** in the `truckwash` cPanel account.
|
||||
2. Create `github-pleno-vue@truckwash.io` with a generated, unique password.
|
||||
3. Set its directory to `frontend-deployments`, which cPanel resolves to
|
||||
`/home/truckwash/frontend-deployments`, and leave quota unlimited.
|
||||
4. Add `server.red-block.com` as `PRODUCTION_FTP_HOST`. Do not use
|
||||
`truckwash.io`: the FTPS certificate is issued to the server hostname.
|
||||
5. Add the full account login as `PRODUCTION_FTP_USER`, the generated password
|
||||
as `PRODUCTION_FTP_PASSWORD`, and `/` as `PRODUCTION_FTP_PATH`. `/` is the
|
||||
root of this jailed FTP account, not the cPanel account home.
|
||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||
these frontend-only credentials back into the API `.env`.
|
||||
|
||||
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||
Environment values are available only to the deployment job that names this
|
||||
environment, and configured protection rules are evaluated before its secrets
|
||||
are released.
|
||||
|
||||
The existing live-test, Release Manager, and server-version secrets used by
|
||||
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||
and `sha256sum`.
|
||||
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
||||
`sha256sum` for the account-scoped activator.
|
||||
|
||||
## cPanel layout and one-time bootstrap
|
||||
|
||||
The production FTP account is jailed directly to the deployment root, so its
|
||||
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||
`frontend-deployments` directory below the account home. The helper creates
|
||||
this layout below it:
|
||||
|
||||
```text
|
||||
archives/
|
||||
releases/
|
||||
<commit>-<github-run>-<attempt>/
|
||||
dist/
|
||||
staging/
|
||||
current -> releases/<release-id>/dist
|
||||
```
|
||||
|
||||
The domain's document root must resolve to
|
||||
`<cPanel account home>/frontend-deployments/current`, not to the deployment
|
||||
root itself. This stable document-root path is what makes replacing `current`
|
||||
atomic: every HTTP request resolves either the complete old release or the
|
||||
complete new release, never a partly uploaded directory.
|
||||
|
||||
Before merging the workflow change, perform a one-time bootstrap in cPanel:
|
||||
|
||||
1. Back up the existing cPanel webroot and confirm the frontend hostname does
|
||||
not serve API/PHP files from this location.
|
||||
2. Create `archives`, `releases`, and `staging` below the dedicated deployment
|
||||
root.
|
||||
3. Put one complete, validated frontend build at
|
||||
`releases/<commit>-<build-id>/dist`. Its `release-manifest.json` must contain
|
||||
that full 40-character commit and the same build ID used in the directory
|
||||
name.
|
||||
4. Create `current` as a relative symlink to that release's `dist` directory.
|
||||
5. Make the frontend domain document root resolve to the stable `current` path.
|
||||
For a cPanel primary domain whose configured document root remains
|
||||
`/home/truckwash/public_html`, make `public_html` a symlink to
|
||||
`frontend-deployments/current`. Exchange the old directory and prepared
|
||||
symlink atomically, and retain the old directory as a recovery copy.
|
||||
6. Confirm the release `.htaccess` contains `DirectoryIndex index.html` so a
|
||||
symlinked primary-domain root serves the Vue shell instead of a directory
|
||||
listing.
|
||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||
8. The server-side activator, rather than the hosted runner, validates that
|
||||
`current` and the captured rollback release exist before every switch.
|
||||
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
||||
hexadecimal form in the protected `frontend-production` environment as
|
||||
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
||||
`/etc/pleno-release-activator/truckwash.key`, owned by `root:truckwash` and
|
||||
mode `0440`. The FTPS jail must not expose this key.
|
||||
10. As `root`, install `scripts/release/cpanel-activate.sh` out of band at
|
||||
`/usr/local/sbin/truckwash-release-activate.sh`, owned by `root:root` and
|
||||
mode `0755`. The FTPS principal must not be able to replace or modify this
|
||||
executable. Then install this one `truckwash` account cron entry without
|
||||
replacing any other account cron lines:
|
||||
|
||||
```cron
|
||||
* * * * * /bin/flock -n /home/truckwash/frontend-deployments/.activation.lock /usr/bin/env CPANEL_ACTIVATION_ROOT=/home/truckwash/frontend-deployments CPANEL_ACTIVATION_KEY_FILE=/etc/pleno-release-activator/truckwash.key /bin/sh /usr/local/sbin/truckwash-release-activate.sh >/dev/null 2>&1
|
||||
```
|
||||
|
||||
The workflow can upload release data and bounded-lifetime request files, but
|
||||
it cannot replace the root-owned executable or read the activation key. The
|
||||
script authenticates each bounded-lifetime request with HMAC-SHA-256,
|
||||
accepts only strict filename components and hashes, validates the archive
|
||||
and manifest, runs a disposable local symlink preflight, journals the prior
|
||||
pointer for crash recovery, and writes a request-specific result. It runs as
|
||||
`truckwash`; it does not need root or a shell credential in GitHub. The host
|
||||
must provide `/bin/sh`, `flock`, `openssl`, `unzip`, `jq`, and `sha256sum`.
|
||||
|
||||
The automatic deployer intentionally refuses to create the first `current`
|
||||
pointer. This prevents a missing or misconfigured bootstrap from turning the
|
||||
first automated run into an unreviewed production cutover.
|
||||
|
||||
### Auditing or restoring the primary webroot
|
||||
|
||||
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||
runner label would risk sending production cPanel secrets to a self-hosted
|
||||
runner, so that workflow has been removed.
|
||||
|
||||
If the primary domain starts showing a directory index or returns 404 for files
|
||||
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||
or the hosting provider. Before replacing anything, confirm the exact
|
||||
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||
replace the root while an addon or subdomain document root is nested below it.
|
||||
Restore only a verified physical directory, retain the displaced webroot, and
|
||||
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||
releases do not depend on remote cPanel API access.
|
||||
|
||||
## Caching and compatibility
|
||||
|
||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||
service-worker control files always revalidate. The deployer retains every
|
||||
immutable release while hosted runners cannot query reliable cPanel
|
||||
modification metadata. Each successful run reports that retention cleanup is
|
||||
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||
releases and their matching archives; never remove the active or recorded
|
||||
rollback target.
|
||||
|
||||
Because the document root switches as one symlink, an already-loaded page may
|
||||
still request an asset from its previous release after activation. The current
|
||||
implementation keeps previous release directories for rollback, but does not
|
||||
publish their asset paths through the new `current` pointer. Treat long-lived
|
||||
open-tab compatibility as a separate CDN/shared-assets enhancement if product
|
||||
usage requires it; the deployment itself does not serve mixed files.
|
||||
|
||||
## Failure and rollback behavior
|
||||
|
||||
- Any error before the symlink rename leaves the current release untouched.
|
||||
- The deploy helper immediately verifies the public release after the rename.
|
||||
A failure restores the captured previous release.
|
||||
- A later public or credentialed Playwright failure runs the explicit rollback
|
||||
step with the previous immutable target emitted by the deploy step.
|
||||
- A stale workflow run exits before activation when `master` has advanced.
|
||||
- Release Manager is record-only (`auto_sync: false`); it no longer deploys the
|
||||
frontend through the API/Coolify path.
|
||||
|
||||
For manual rollback from a controlled runner, provide the same GitHub
|
||||
environment settings plus the target recorded in the successful deployment:
|
||||
|
||||
```bash
|
||||
RELEASE_ROLLBACK_TARGET=releases/<release-id>/dist npm run release:deploy:cpanel:rollback
|
||||
```
|
||||
|
||||
Never point this command outside `releases/<release-id>/dist`; the helper rejects
|
||||
path traversal and operations outside the configured deployment root.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Customer attributes refactor and migration plan
|
||||
|
||||
## Problem statement
|
||||
|
||||
Customer attributes are currently represented as loosely typed string flags and evaluated in several UI, POS, and invoicing paths. This makes product restrictions vulnerable to broad category heuristics. The immediate defect is that `restrictAdditionalServices` ("Begræns tillægsydelser") treats related booking add-ons as additional services, so interior wash add-ons plus trailer/dolly additions are blocked even though that attribute is intended to cover standalone additional services only.
|
||||
|
||||
## Target behavior matrix
|
||||
|
||||
| Attribute | Canonical intent | Product availability behavior | Invoice/workflow behavior |
|
||||
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `restrictAdditionalServices` | Block standalone additional services/tillægsydelser. | Block standalone additional-service catalog items; do not block related booking add-ons such as interior wash, trailer, or dolly. | Flag only order lines that are standalone additional services. |
|
||||
| `restrictTankCleaning` | Block tank-cleaning services. | Block products whose category or legacy name identifies tank cleaning. | Flag tank-cleaning order lines. |
|
||||
| `restrictSpotFree` | Block Spot Free/RO rinse products. | Block canonical Spot Free product IDs and legacy Spot Free/RO naming. | Flag Spot Free order lines. |
|
||||
| `restrictInteriorCleaning` | Block interior wash services. | Block products whose names/categories explicitly identify interior wash. | Flag interior-wash order lines. |
|
||||
| `onlyTankCleaning` | Allow only tank-cleaning services. | Block every non-tank-cleaning product while keeping tank-cleaning products available. | Flag non-tank-cleaning order lines. |
|
||||
| `requiresReferenceNumber` | Require an order reference. | No product filtering. | Flag orders missing a required reference. |
|
||||
| `requiresRegistrationNumbersInvoice` | Require registration numbers on invoice/order context. | No product filtering. | Flag orders missing required registration numbers. |
|
||||
| `invoiceAllOrdersIndividually` | Prevent grouped invoicing. | No product filtering. | Split/flag invoice collections containing multiple orders for the customer. |
|
||||
| `invoiceWithStripe` | Invoice through Stripe workflow. | No product filtering. | Route the customer through Stripe invoicing/payment handling. |
|
||||
| `showPricesOnBookingPage` | Show customer prices during booking. | No product filtering. | Presentation-only booking behavior. |
|
||||
| `usePONumbers` | Use/prompt for PO numbers. | No product filtering. | Require or expose PO-number workflow where configured. |
|
||||
| `exemptFromAdministrationFee` | Do not charge administration fees. | No product filtering. | Suppress/flag administration-fee order lines for this customer. |
|
||||
|
||||
## Refactor plan
|
||||
|
||||
1. **Create a canonical customer-rule domain module**
|
||||
|
||||
- Keep `CUSTOMER_RULE_DEFINITIONS` as the registry of public attributes, but extend each entry with a typed evaluator contract: product predicate, category predicate, invoice predicate, and UI impact metadata.
|
||||
- Replace scattered string comparisons with registry lookups so every surface uses the same semantics.
|
||||
- Add explicit names for ambiguous categories: `standaloneAdditionalService`, `relatedAddon`, `primaryProduct`, `tankCleaning`, `spotFree`, and `interiorCleaning`.
|
||||
|
||||
2. **Normalize product classification once**
|
||||
|
||||
- Build a `classifyCustomerRuleProduct(product, context)` helper returning booleans for each product class.
|
||||
- Treat related add-ons (`isRelatedAddon`, `relatedItemId`) as context, not as proof that the item is a standalone additional service.
|
||||
- Reserve `restrictAdditionalServices` for category 8/standalone service context or explicit additional-service labels, not numeric booking add-on category 4.
|
||||
|
||||
3. **Migrate rule evaluation paths**
|
||||
|
||||
- POS product cards and mobile flows should call `getCustomerProductRestriction` only with the normalized product context.
|
||||
- Customer-rule tooltips should derive blocked/available products from the same evaluator used by POS.
|
||||
- Invoicing-period flag generation should use the same classification vocabulary as product availability so historical and current orders are flagged consistently.
|
||||
|
||||
4. **Backfill and data migration**
|
||||
|
||||
- Keep existing attribute keys unchanged to avoid a destructive migration.
|
||||
- Add a one-time data audit/report listing customers with `restrictAdditionalServices` and recent orders containing interior wash, trailer, or dolly add-ons. These rows should be verified as no longer violating the rule after deployment.
|
||||
- If any historical invoice flags were created solely because related add-ons were treated as additional services, provide an idempotent cleanup command to recalculate customer-rule violations for affected invoice periods.
|
||||
|
||||
5. **Regression test coverage**
|
||||
|
||||
- Unit-test every attribute in the target behavior matrix.
|
||||
- Add focused cases for the defect: interior wash related add-on, trailer related add-on, and dolly related add-on must remain available under `restrictAdditionalServices`.
|
||||
- Add invoice-flag fixtures mirroring the same products so invoicing behavior cannot drift from POS behavior.
|
||||
- Keep tooltip tests aligned with the evaluator, showing standalone additional services under `restrictAdditionalServices` and not showing related add-ons.
|
||||
|
||||
6. **Rollout and verification**
|
||||
- Ship the evaluator patch behind the existing attribute keys.
|
||||
- Run unit tests and targeted POS/customer-rule e2e tests.
|
||||
- Verify with production-like catalog data that `restrictAdditionalServices` blocks only standalone additional services while `restrictInteriorCleaning`, `restrictTankCleaning`, `restrictSpotFree`, and `onlyTankCleaning` continue to behave exactly as listed above.
|
||||
@@ -0,0 +1,477 @@
|
||||
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
|
||||
|
||||
## Goal
|
||||
|
||||
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
|
||||
"Tankrengøring") only appear when the user is already on the matching view
|
||||
tab. On the "Alle" tab the chips never show, even when a customer actually
|
||||
belongs to several categories.
|
||||
|
||||
We want every chip to render on every subpage whenever the customer belongs
|
||||
to that category — independent of which view tab is active.
|
||||
|
||||
---
|
||||
|
||||
## 1. Root cause (already confirmed by investigation)
|
||||
|
||||
### Front-end rendering path
|
||||
|
||||
* `Right.vue` (line ~300+) declares view tabs and fetches
|
||||
`/superuser/invoicing/period` with the corresponding `periodView` query
|
||||
param (`all`, `invoice_per_order`, …).
|
||||
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
|
||||
(including `all`). It reads the active bucket via
|
||||
`view.variables.sharedVariables.value.types[componentName]`.
|
||||
* For each customer card it mounts
|
||||
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
|
||||
`list_views_with_customer`:
|
||||
|
||||
```ts
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
return view_type && view_type.some(
|
||||
(v: any) => v.customer_number === props.customer.customer_number,
|
||||
);
|
||||
});
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
It only treats a customer as belonging to a view if
|
||||
`types[view_key]` contains an entry with the same `customer_number`.
|
||||
|
||||
### Back-end paging path
|
||||
|
||||
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
|
||||
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
|
||||
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
|
||||
all) holds full customer cards.
|
||||
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
|
||||
truncates the response so that ONLY the bucket matching `$periodView`
|
||||
carries the full card data; every other bucket becomes `[]`.
|
||||
|
||||
```php
|
||||
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||
if ($isAllLimit) {
|
||||
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||
} else {
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||
}
|
||||
```
|
||||
|
||||
* The frontend then iterates over the (empty) non-active buckets and finds
|
||||
no customer entries → no chip is rendered → the bug.
|
||||
|
||||
### Why the existing e2e test missed it
|
||||
|
||||
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
|
||||
~864) returns FULL customer data for every type in the mock payload.
|
||||
Because the mock already mimics the "pre-fix" backend behaviour (every type
|
||||
populated), the chip-rendering path is exercised even when the real backend
|
||||
strips the data. Updating the mock to mirror the new, real backend shape
|
||||
gives us an end-to-end safety net.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix strategy
|
||||
|
||||
We want one round trip, no N+1 calls, and a payload that stays bounded.
|
||||
|
||||
**Approach: lightweight membership entries**
|
||||
|
||||
Extend `applyPeriodPagination` so that, after pagination, every non-active
|
||||
view bucket is populated with "membership only" entries — each entry is
|
||||
just `{ customer_number }` so the frontend can resolve membership via the
|
||||
existing `view_type.some(v => v.customer_number === …)` check.
|
||||
|
||||
* The **active view** continues to carry full customer cards (transactions,
|
||||
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
|
||||
it.
|
||||
* **Every other view** carries a `{customer_number: N}` array (one per
|
||||
matching customer after all filters / search / sort / pagination). No
|
||||
transactions or auxiliary fields — keeping the payload small.
|
||||
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
|
||||
`type_counts` (already computed before pagination) keeps the totals per
|
||||
view, so tab counters remain correct.
|
||||
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
|
||||
stores the full `periodResult` verbatim, so cached responses naturally
|
||||
retain the new lightweight entries.
|
||||
|
||||
### Why this option wins
|
||||
|
||||
| Approach | Network | Payload | Schema change | UX consistency |
|
||||
|---|---|---|---|---|
|
||||
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
|
||||
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
|
||||
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
|
||||
|
||||
---
|
||||
|
||||
## 3. Concrete code changes
|
||||
|
||||
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
|
||||
|
||||
In `applyPeriodPagination(...)` (around line 730-742), after the active
|
||||
bucket is sliced, populate every non-active bucket with lightweight
|
||||
memberships derived from the already-filtered/searched/sorted `$types`
|
||||
arrays:
|
||||
|
||||
```php
|
||||
// Existing pagination of the active bucket
|
||||
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||
if ($isAllLimit) {
|
||||
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||
} else {
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||
}
|
||||
|
||||
// NEW: lightweight memberships for every non-active view so the front-end
|
||||
// can render category chips regardless of which tab is active.
|
||||
foreach ($types as $typeName => $customers) {
|
||||
if ($typeName === $periodView) {
|
||||
continue;
|
||||
}
|
||||
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
||||
is_array($customers) ? $customers : []
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Add a new helper:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Return a minimal `{customer_number: N}` array per customer so the
|
||||
* front-end can determine which non-active view buckets the customer
|
||||
* belongs to without us shipping full transaction/queue data.
|
||||
*
|
||||
* Filters, searches, sort and visibility rules have already been applied
|
||||
* to `$customers` by the time we run, so we just de-duplicate and emit.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $customers
|
||||
* @return array<int, array{customer_number: int, membership_only: true}>
|
||||
*/
|
||||
private static function summarizePeriodCustomerMemberships(array $customers): array
|
||||
{
|
||||
$memberships = [];
|
||||
$seen = [];
|
||||
foreach ($customers as $customer) {
|
||||
if (!is_array($customer)) {
|
||||
continue;
|
||||
}
|
||||
$customerNumber = (int) ($customer['customer_number'] ?? 0);
|
||||
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$customerNumber] = true;
|
||||
$memberships[] = [
|
||||
'customer_number' => $customerNumber,
|
||||
'membership_only' => true,
|
||||
];
|
||||
}
|
||||
return $memberships;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* We deduplicate on `customer_number` so a customer appearing twice in a
|
||||
bucket (rare but possible — multiple PO transactions for the same
|
||||
customer in `invoice_per_order`) still only emits one membership.
|
||||
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
|
||||
so consumers that iterate `Object.keys(types)` still see every view
|
||||
even when the filtered list ends up empty.
|
||||
* The active bucket's structure is **unchanged** — the front-end
|
||||
`customersInCurrentView` and `list_views_with_customer` paths continue to
|
||||
work as before.
|
||||
* `type_counts` and `type_totals` are computed before pagination (see
|
||||
`summarizePeriodTypes`) and remain authoritative for tab counters.
|
||||
|
||||
### 3.2 OpenAPI specs
|
||||
|
||||
Both repositories carry a copy of the schema and must stay in lock-step.
|
||||
|
||||
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
|
||||
|
||||
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
|
||||
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
|
||||
`transactions`, `invoice_collections`. Membership entries don't carry those
|
||||
fields, so we need to relax the `required` constraint on non-active buckets
|
||||
and document the new shape.
|
||||
|
||||
Add a new sibling component:
|
||||
|
||||
```yaml
|
||||
InvoicingPeriodCustomerMembership:
|
||||
type: object
|
||||
description: >-
|
||||
Lightweight customer marker returned for every non-active view bucket.
|
||||
Used only by the front-end to render category chips (e.g. "Faktura pr.
|
||||
ordre") regardless of which tab is active. Full transaction / queue
|
||||
data is intentionally omitted; see InvoicingPeriodCustomer for the
|
||||
shape returned for the active bucket.
|
||||
additionalProperties: false
|
||||
required: [customer_number, membership_only]
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
membership_only:
|
||||
type: true
|
||||
enum: [true]
|
||||
```
|
||||
|
||||
In the `InvoicingPeriod` schema, switch the `types` property from
|
||||
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
|
||||
|
||||
```yaml
|
||||
types:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||
discriminator:
|
||||
propertyName: membership_only
|
||||
```
|
||||
|
||||
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
|
||||
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
|
||||
longer `required` (they remain documented in `properties`). The active
|
||||
bucket still emits them, but the union makes the membership shape valid.
|
||||
|
||||
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||
|
||||
After the backend fix, the chip rendering logic in
|
||||
`list_views_with_customer` will start working on every subpage. To keep
|
||||
performance bounded when buckets grow large, we also turn the membership
|
||||
arrays into `Set<number>` lookups via a small `computed`:
|
||||
|
||||
```ts
|
||||
const membershipIndexes = computed(() => {
|
||||
const result: Record<string, Set<number>> = {};
|
||||
for (const view_key of view_keys.value) {
|
||||
if (view_key === 'all') continue;
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
if (!Array.isArray(view_type)) {
|
||||
result[view_key] = new Set<number>();
|
||||
continue;
|
||||
}
|
||||
result[view_key] = new Set(
|
||||
view_type
|
||||
.map((entry) => Number(entry?.customer_number ?? 0))
|
||||
.filter((n) => Number.isInteger(n) && n > 0),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
|
||||
});
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
Behavioural impact:
|
||||
|
||||
* Same chip set as today, now visible on every subpage including `Alle`.
|
||||
* Lookup is O(1) per (view × customer) instead of O(bucket size).
|
||||
* Defensive against the lightweight entries (no `customer_name`,
|
||||
`transactions`, etc. fields) — the chip only needs the view's friendly
|
||||
name, which already comes from `view.computed.getViewFriendlyName(...)`.
|
||||
|
||||
### 3.4 Front-end — e2e mock
|
||||
|
||||
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
|
||||
(line ~864) currently mocks every bucket as fully populated. Update the
|
||||
mock so that:
|
||||
|
||||
* The **active** bucket (whichever the page requested) carries full
|
||||
customer cards (unchanged).
|
||||
* Every **other** bucket carries membership-only entries
|
||||
(`{customer_number, membership_only: true}`).
|
||||
|
||||
This mirrors the real backend so the existing chip-stacking test
|
||||
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
|
||||
guards the membership path.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tests to add / update
|
||||
|
||||
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||
|
||||
Existing assertion at line 292:
|
||||
|
||||
```php
|
||||
expect($result['period']['types']['fixed_pricing'])->toBe([]);
|
||||
```
|
||||
|
||||
…becomes:
|
||||
|
||||
```php
|
||||
expect($result['period']['types']['fixed_pricing'])
|
||||
->toBe(array_map(
|
||||
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
|
||||
[1001], // the test fixture's other-bucket membership
|
||||
));
|
||||
```
|
||||
|
||||
Add a new test that, given a period with two customers in `all` and one
|
||||
in `invoice_per_order`, paging `periodView=all` yields:
|
||||
|
||||
* `types.all` — full customer cards (existing behaviour preserved)
|
||||
* `types.invoice_per_order` — one lightweight membership entry
|
||||
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
|
||||
matching customers, so nothing to emit)
|
||||
|
||||
Add a search-aware test: searching for "Beta" while paging
|
||||
`periodView=all` must surface the lightweight membership only for
|
||||
customers that pass the filter, mirroring the active bucket.
|
||||
|
||||
Add a flag-tab-aware test: the `red` flag filter must propagate to the
|
||||
membership arrays just as it does to `type_counts`.
|
||||
|
||||
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
|
||||
|
||||
Add a focused Vitest spec
|
||||
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
|
||||
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
|
||||
`sharedVariables.value.types` containing:
|
||||
|
||||
```ts
|
||||
{
|
||||
all: [...full cards],
|
||||
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
|
||||
fixed_pricing: [],
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
|
||||
other categories the stubbed customer is a member of), independent of
|
||||
which view tab is "active" in the stub.
|
||||
|
||||
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
|
||||
|
||||
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
|
||||
membership-only entries for non-active buckets — matching the real
|
||||
backend contract.
|
||||
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
|
||||
that on the `Alle` tab the rendered customer cards include the
|
||||
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
|
||||
* Add a new spec scenario:
|
||||
`Given: Alle tab with mixed customers across categories. When: page
|
||||
loads. Then: every customer card shows chips for every category it
|
||||
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
|
||||
|
||||
### 4.4 OpenAPI consistency
|
||||
|
||||
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
|
||||
script in `scripts/sync-ai-workflow.mjs` to verify that the two
|
||||
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
|
||||
it so future schema edits surface in CI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verification steps (manual + automated)
|
||||
|
||||
### 5.1 Manual smoke test (in dev)
|
||||
|
||||
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
|
||||
bring up the API stack.
|
||||
2. `cd /workspace/pleno-vue && npm run dev`.
|
||||
3. Sign in as a superuser that owns customers spanning multiple categories
|
||||
(fixed_pricing + invoice_per_order, for instance).
|
||||
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
|
||||
5. On the **Alle** tab confirm every customer card shows every chip it
|
||||
qualifies for.
|
||||
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
|
||||
render (sans the active tab's own chip).
|
||||
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
|
||||
8. Apply the search box; chips should update with the filter.
|
||||
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
|
||||
flagged subset.
|
||||
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
|
||||
consistent across pages.
|
||||
11. Reload the page — chips must persist from the cache layer
|
||||
(`setCachedPeriodPage`) and not flash empty.
|
||||
|
||||
### 5.2 Automated
|
||||
|
||||
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
|
||||
inside `php1` container per `scripts/setup.sh`).
|
||||
* Backend static analysis: `composer analyse` (phpstan).
|
||||
* Backend rector dry-run: `composer rector:dry-run`.
|
||||
* Front-end unit: `npm run test:unit`.
|
||||
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
|
||||
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
|
||||
* Front-end lint: `npm run lint:strict`.
|
||||
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
|
||||
|
||||
### 5.3 CI checks to watch
|
||||
|
||||
* `.github/workflows/tests.yml` (api) — PHP matrix
|
||||
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
|
||||
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
|
||||
* `.github/workflows/code_quality.yml` — Qodana scan.
|
||||
|
||||
---
|
||||
|
||||
## 6. Roll-out plan
|
||||
|
||||
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
|
||||
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
|
||||
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
|
||||
3. OpenAPI updates (3.2) in both repos.
|
||||
4. Frontend attribute component (3.3) — add the `Set` index, keep the
|
||||
array `.some()` fallback for back-compat.
|
||||
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
|
||||
6. Run the full verification suite (5.2) locally before pushing.
|
||||
7. Open the PR; CI should turn green; Qodana should not flag the new
|
||||
memberships (they are deliberate additive fields).
|
||||
8. After merge, monitor the period page in staging for payload size and
|
||||
chip rendering parity.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk assessment
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
|
||||
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
|
||||
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
|
||||
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
|
||||
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Files touched (summary)
|
||||
|
||||
**Backend (`/workspace/api`):**
|
||||
|
||||
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
|
||||
`summarizePeriodCustomerMemberships`, populate non-active buckets.
|
||||
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||
— relax line 292, add membership / search / flag-tab tests.
|
||||
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
|
||||
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
|
||||
|
||||
**Front-end (`/workspace/pleno-vue`):**
|
||||
|
||||
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||
— `Set`-based membership index.
|
||||
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
|
||||
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
|
||||
shape, extended chip-stacking assertions.
|
||||
* `openapi.yaml` — mirror backend schema edits.
|
||||
@@ -0,0 +1,400 @@
|
||||
# Cable-Connected iPhone Debug IPA Runbook
|
||||
|
||||
This runbook covers development-signed iOS builds installed from an Ubuntu
|
||||
workstation over USB. It is separate from the public App Store release path in
|
||||
`docs/app-store-release.md`.
|
||||
|
||||
The device build is deliberately a second app:
|
||||
|
||||
- Debug bundle ID: `io.truckwash.app.debug`
|
||||
- Debug display name: `Truck Wash Debug`
|
||||
- Production bundle ID: `io.truckwash.app`
|
||||
- Capacitor/Android app ID: `io.truckwash.twa`
|
||||
- API: `https://api-v2.truckwash.io/master/api`
|
||||
|
||||
Installing or uninstalling the debug app must not replace or remove the
|
||||
production app. The debug workflow builds the Vue application in production
|
||||
mode against the stable API; it does not use Vite's development `/api` default
|
||||
or a live-reload server.
|
||||
|
||||
## What Ubuntu Can And Cannot Do
|
||||
|
||||
The current Ubuntu workstation already has `usbmuxd`, the libimobiledevice
|
||||
utilities, and `ideviceinstaller`. The current iPhone has previously been
|
||||
trusted and paired. Run the repository doctor before every install because the
|
||||
phone can still be locked, trust can be reset, or Developer Mode can be off.
|
||||
|
||||
This workflow supports:
|
||||
|
||||
- Inspecting pairing, activation, lock, Developer Mode, and install-service
|
||||
readiness.
|
||||
- Installing and upgrading a valid development-signed IPA.
|
||||
- Reading filtered device syslog and copying crash reports.
|
||||
|
||||
Apple does not provide Xcode, LLDB device debugging, or Safari Web Inspector on
|
||||
Linux. `CAPACITOR_DEBUG` and `get-task-allow` make the IPA suitable for a
|
||||
development device, but they do not create an official Linux LLDB or WebKit
|
||||
debugger. Use a physical Mac with Xcode/Safari for breakpoints or Web Inspector.
|
||||
Use TestFlight or a physical Mac if a new or beta iOS release is incompatible
|
||||
with libimobiledevice; never weaken device security or signing validation as a
|
||||
workaround.
|
||||
|
||||
## One-Time iPhone Preparation
|
||||
|
||||
1. Connect the iPhone directly with a data-capable USB cable.
|
||||
2. Unlock the phone and keep it awake. Tap **Trust** if iOS asks whether to
|
||||
trust this computer, then enter the device passcode.
|
||||
3. On iOS 16 or newer, open **Settings -> Privacy & Security -> Developer
|
||||
Mode**, turn Developer Mode on, and accept the restart. iOS 15 does not have
|
||||
this setting and the helper does not require it there.
|
||||
4. After the restart, unlock the phone, confirm **Turn On** in the Developer
|
||||
Mode prompt, and enter the passcode again.
|
||||
5. Reconnect the cable and run the doctor described below.
|
||||
|
||||
Developer Mode is an iOS security control and cannot be bypassed from Ubuntu.
|
||||
If the Developer Mode setting is absent, connect the phone once to a physical
|
||||
Mac and use Apple's supported Xcode or Apple Configurator device preparation,
|
||||
then return to Ubuntu after the phone has restarted and Developer Mode is on.
|
||||
|
||||
Trust, pairing, and Developer Mode can be cleared by device resets, iOS updates,
|
||||
or privacy/location resets. Repeat these steps if the doctor reports that the
|
||||
previously working device is no longer ready.
|
||||
|
||||
## Apple Developer Setup
|
||||
|
||||
This requires the paid Truck Wash ApS Apple Developer team and a user permitted
|
||||
to manage certificates, identifiers, and devices.
|
||||
|
||||
### Register the device and debug App ID
|
||||
|
||||
1. Connect and unlock the iPhone, then get its UDID locally with
|
||||
`idevice_id -l`. Treat the full UDID as sensitive operational data: do not
|
||||
commit it or paste it into ordinary build logs.
|
||||
2. In Apple Developer **Certificates, Identifiers & Profiles -> Devices**, add
|
||||
the iPhone using that UDID.
|
||||
3. Under **Identifiers**, create an explicit App ID for
|
||||
`io.truckwash.app.debug`.
|
||||
4. Enable only capabilities required by the current Xcode project. Do not copy
|
||||
unrelated production entitlements into the debug App ID.
|
||||
|
||||
### Create the certificate and development profile
|
||||
|
||||
1. Create a dedicated **Apple Development** certificate for CI device-debug
|
||||
signing. Keep its private key under the team's normal credential controls.
|
||||
2. Export the certificate and private key together as a password-protected
|
||||
`.p12` file.
|
||||
3. Create an **iOS App Development** provisioning profile that selects:
|
||||
- App ID `io.truckwash.app.debug`
|
||||
- The dedicated Apple Development certificate
|
||||
- Every approved physical test iPhone, including the cable-connected device
|
||||
4. Download the `.mobileprovision` file.
|
||||
5. Confirm the profile has not expired, includes the intended device UDIDs, and
|
||||
grants `get-task-allow=true`. An App Store or ad-hoc profile is not valid for
|
||||
this workflow.
|
||||
|
||||
Base64-encode both files without line wrapping before adding them to GitHub. On
|
||||
Ubuntu, for example:
|
||||
|
||||
```sh
|
||||
base64 -w 0 TruckWashDebug.p12 > TruckWashDebug.p12.base64
|
||||
base64 -w 0 TruckWashDebug.mobileprovision > TruckWashDebug.mobileprovision.base64
|
||||
```
|
||||
|
||||
Store the encoded values in GitHub immediately, verify one successful build,
|
||||
then securely remove the local `.p12`, profile, encoded copies, CSR, and any
|
||||
other private-key intermediates that are no longer required. Never commit
|
||||
signing files or their encoded contents.
|
||||
|
||||
## GitHub Environment And Dispatch Approval
|
||||
|
||||
Create a repository environment named `mobile-device-debug`. Store the debug
|
||||
signing configuration only in that environment. Required environment reviewers
|
||||
are not available for this private repository's current GitHub plan, so the
|
||||
manual `workflow_dispatch` inputs are the signing approval boundary.
|
||||
|
||||
Restrict the environment's custom deployment branches to the exact `master`
|
||||
branch. The checked-in workflow also refuses any other workflow ref. This keeps
|
||||
signing secrets behind the reviewed workflow on `master`, while `source_ref`
|
||||
can still select a separately inspected same-repository commit to build.
|
||||
|
||||
Add these environment variables exactly:
|
||||
|
||||
- `APPLE_TEAM_ID`
|
||||
- `IOS_DEBUG_BUNDLE_ID=io.truckwash.app.debug`
|
||||
- `IOS_DEBUG_API_URL=https://api-v2.truckwash.io/master/api`
|
||||
|
||||
Add these environment secrets exactly:
|
||||
|
||||
- `IOS_DEBUG_CERTIFICATE_BASE64`: base64 of the password-protected `.p12`
|
||||
- `IOS_DEBUG_CERTIFICATE_PASSWORD`: password used to export the `.p12`
|
||||
- `IOS_DEBUG_PROVISION_PROFILE_BASE64`: base64 of the development
|
||||
`.mobileprovision`
|
||||
- `IOS_DEBUG_ALLOWED_UDIDS`: newline-delimited UDIDs for every device that the
|
||||
profile is expected to contain
|
||||
|
||||
The workflow generates and masks a new random password for its temporary macOS
|
||||
keychain on every run. Do not create or store an
|
||||
`IOS_DEBUG_KEYCHAIN_PASSWORD` secret.
|
||||
|
||||
Do not reuse the `mobile-store-production` distribution secrets. The debug job
|
||||
must use an Apple Development certificate and iOS App Development profile; the
|
||||
existing `io.truckwash.app` App Store workflow remains unchanged.
|
||||
|
||||
The person dispatching a run must inspect the intended commit first. Do not
|
||||
dispatch when:
|
||||
|
||||
- The exact 40-character SHA is not the branch, tag, or commit intended.
|
||||
- The source comes from a fork or another repository.
|
||||
- The requested change is not appropriate to sign for a physical device.
|
||||
- The signing profile is expired or no longer covers the intended device.
|
||||
|
||||
The workflow independently resolves `source_ref` inside this repository and
|
||||
requires it to equal `expected_sha`. It also requires the exact typed
|
||||
confirmation `SIGN IOS DEBUG IPA`. A missing/mismatched SHA or confirmation
|
||||
stops the unprivileged resolver before the environment signing secrets are used.
|
||||
|
||||
## Build And Download A Debug IPA
|
||||
|
||||
1. Open **Actions -> iOS Device Debug IPA -> Run workflow** and keep **Use
|
||||
workflow from** set to `master`.
|
||||
2. Inspect the intended commit and copy its complete 40-character SHA.
|
||||
3. Enter `source_ref`. It may be a branch, tag, or commit in this repository and
|
||||
defaults to `master`.
|
||||
4. Enter the complete SHA as `expected_sha` and enter the exact confirmation
|
||||
`SIGN IOS DEBUG IPA`. Submitting these inputs is approval to sign that source.
|
||||
5. The resolver pins `source_ref` inside this repository and verifies it equals
|
||||
`expected_sha`. A mismatch stops the run before signing.
|
||||
6. Wait for the signed macOS job to finish. It builds a Debug archive against
|
||||
`https://api-v2.truckwash.io/master/api`, exports it with method
|
||||
`development`, validates the embedded profile and app identity, and never
|
||||
uploads the result to App Store Connect.
|
||||
7. Download the `truck-wash-debug-<version>-<12-character-SHA>` GitHub Actions
|
||||
artifact for the run. Keep its same-prefix `.ipa`, `.dSYM.zip`,
|
||||
`manifest.json`, and `SHA256SUMS` together in one directory.
|
||||
8. From that directory, verify the download before connecting it to a device:
|
||||
|
||||
```sh
|
||||
sha256sum --check SHA256SUMS
|
||||
```
|
||||
|
||||
Do not install an artifact after a checksum failure. The manifest records the
|
||||
source ref and SHA, build/run numbers, bundle identity, stable API, minimum iOS,
|
||||
Xcode/Capacitor versions, signing method, and provisioning-profile expiration.
|
||||
It intentionally does not contain device UDIDs or secrets.
|
||||
|
||||
Artifacts are retained for seven days. Keep the zipped dSYM with any crash
|
||||
report from that build so a Mac/Xcode crash-symbolication path remains
|
||||
available.
|
||||
|
||||
## Ubuntu Device Commands
|
||||
|
||||
Run commands from the repository root. The npm interface is:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- <command>
|
||||
```
|
||||
|
||||
If the npm wrapper is unavailable, use the equivalent direct entrypoint:
|
||||
|
||||
```sh
|
||||
node scripts/mobile/ios-device.mjs <command>
|
||||
```
|
||||
|
||||
The helper uses USB devices only. With one connected iPhone, omit `--udid`.
|
||||
With multiple devices connected, provide `--udid ID`; the command fails instead
|
||||
of guessing. Normal output redacts full UDIDs.
|
||||
|
||||
### Check readiness
|
||||
|
||||
Unlock the phone and run:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- doctor
|
||||
```
|
||||
|
||||
The doctor verifies required host commands, USB discovery, pairing, activation,
|
||||
unlocked state, Developer Mode, and installation-proxy access. Resolve every
|
||||
reported failure before attempting an install.
|
||||
|
||||
For a specific connected device:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- doctor --udid DEVICE_UDID
|
||||
```
|
||||
|
||||
### Install or upgrade
|
||||
|
||||
Keep the downloaded artifact files together and run:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- install ./truck-wash-debug-VERSION-SHA.ipa --manifest ./manifest.json
|
||||
```
|
||||
|
||||
The helper requires and verifies `SHA256SUMS` and the complete workflow
|
||||
manifest, then inspects the IPA and its embedded profile. It rejects missing or
|
||||
mismatched artifact metadata, the wrong repository/source/API/bundle/executable,
|
||||
an App Store/ad-hoc or expired profile, `get-task-allow=false`, a profile
|
||||
missing the connected UDID, or an invalid app payload before calling
|
||||
`ideviceinstaller`.
|
||||
|
||||
If `io.truckwash.app.debug` is absent, the helper installs it. If it is already
|
||||
present, the helper upgrades it and confirms the resulting version/build on the
|
||||
phone. It never uninstalls or replaces `io.truckwash.app`.
|
||||
|
||||
Launch **Truck Wash Debug** manually from the iPhone Home Screen. Keep the phone
|
||||
online for the first launch so iOS can perform Apple's PPQ validation for the
|
||||
provisioning profile. A firewall, DNS filter, VPN, or captive portal that
|
||||
blocks Apple's validation service can prevent a correctly signed development
|
||||
app from opening.
|
||||
|
||||
### Collect filtered logs
|
||||
|
||||
Start logging, then reproduce the issue on the phone:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- logs
|
||||
npm run mobile:ios:device -- logs --output ./truck-wash-debug.log
|
||||
```
|
||||
|
||||
The debug executable is deliberately named `TruckWashDebug`, distinct from the
|
||||
production executable. The helper verifies that exact name and filters
|
||||
`idevicesyslog` output for it. It streams child-tool output through UDID
|
||||
redaction; `--output` files are written by the helper with mode `0600` after
|
||||
redaction. Logs should make it possible to correlate the app with its source SHA
|
||||
and stable API target without exposing signing secrets or full device IDs.
|
||||
|
||||
### Copy crash reports
|
||||
|
||||
Create a destination directory and copy reports from the phone:
|
||||
|
||||
```sh
|
||||
mkdir -p ./ios-crashes
|
||||
npm run mobile:ios:device -- crashes ./ios-crashes
|
||||
```
|
||||
|
||||
Crash retrieval always keeps the original reports on the iPhone. Preserve the
|
||||
matching IPA manifest and dSYM with each report.
|
||||
|
||||
### Remove only the debug app
|
||||
|
||||
Uninstall requires the exact debug bundle ID as typed confirmation:
|
||||
|
||||
```sh
|
||||
npm run mobile:ios:device -- uninstall --confirm io.truckwash.app.debug
|
||||
```
|
||||
|
||||
The helper refuses to remove the production bundle or any other bundle ID.
|
||||
|
||||
## Adding Devices And Renewing Signing
|
||||
|
||||
A provisioning profile is a snapshot. Registering another iPhone in Apple
|
||||
Developer does not update an already downloaded profile.
|
||||
|
||||
When adding a device:
|
||||
|
||||
1. Obtain its UDID locally and register it in the Apple Developer portal.
|
||||
2. Regenerate the `io.truckwash.app.debug` iOS App Development profile with the
|
||||
new and existing approved devices selected.
|
||||
3. Replace `IOS_DEBUG_PROVISION_PROFILE_BASE64`.
|
||||
4. Add the UDID to the newline-delimited `IOS_DEBUG_ALLOWED_UDIDS` secret.
|
||||
5. Dispatch a new build; an existing IPA does not gain access to the new device.
|
||||
|
||||
Monitor the profile expiration recorded in each artifact manifest and the Apple
|
||||
Development certificate expiration in the portal. Before either expires,
|
||||
create/renew the signing material, regenerate the profile, replace the affected
|
||||
GitHub secrets, and prove the result with a new build and real-device install.
|
||||
Revoked or expired signing material invalidates later installation and can stop
|
||||
an already installed development build from launching.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No device, device locked, or installation proxy unavailable
|
||||
|
||||
- Use a direct data-capable cable and avoid an unreliable hub.
|
||||
- Unlock the iPhone, keep its screen awake, reconnect it, and rerun `doctor`.
|
||||
- Close other tools that may be exclusively interacting with the device.
|
||||
- Do not repeatedly retry installation while the doctor reports a lock/service
|
||||
failure.
|
||||
|
||||
### Pairing or trust failure
|
||||
|
||||
- Unlock the phone and accept the Trust prompt.
|
||||
- If no prompt appears and `doctor` reports invalid pairing, use the explicit
|
||||
repair guidance printed by the helper, reconnect, and confirm trust again.
|
||||
- Device privacy resets and some iOS updates require a new trust decision.
|
||||
|
||||
### Developer Mode is disabled or absent on iOS 16 or newer
|
||||
|
||||
- Enable it under **Settings -> Privacy & Security -> Developer Mode**, restart,
|
||||
and confirm after the reboot.
|
||||
- If the switch is absent, use a Mac with Xcode or Apple Configurator for
|
||||
Apple's supported one-time preparation. There is no Ubuntu bypass.
|
||||
|
||||
### IPA, profile, certificate, or UDID mismatch
|
||||
|
||||
- Confirm the IPA is from **iOS Device Debug IPA**, not **Mobile Store Artifacts**.
|
||||
- Check `manifest.json` for `io.truckwash.app.debug`, development signing, the
|
||||
intended source SHA, and a future profile expiration.
|
||||
- Regenerate the development profile when a device was added, a certificate was
|
||||
replaced, or the profile expired; then replace the GitHub secret and rebuild.
|
||||
- Never suppress the helper's profile, entitlement, bundle, or checksum checks.
|
||||
|
||||
### App installs but will not launch
|
||||
|
||||
- Keep the phone online for Apple's initial PPQ validation.
|
||||
- Check whether VPN, DNS, firewall, captive-portal, or device-management policy
|
||||
blocks Apple developer-app verification.
|
||||
- Confirm Developer Mode is still on and the certificate/profile has not expired
|
||||
or been revoked.
|
||||
- Collect syslog and crash reports before reinstalling so evidence is preserved.
|
||||
|
||||
### App reports `No response was received`
|
||||
|
||||
- Keep the API base set to `https://api-v2.truckwash.io/master/api`. The bare
|
||||
`https://api-v2.truckwash.io` host is the public gateway, not the application
|
||||
API base.
|
||||
- Confirm `https://api-v2.truckwash.io/master/api/ping` responds before
|
||||
investigating the device or app.
|
||||
- Capacitor serves bundled iOS content from `capacitor://localhost`. The stable
|
||||
API must return `Access-Control-Allow-Origin: capacitor://localhost` for that
|
||||
exact origin, including authenticated preflight requests.
|
||||
- The signing workflow checks API reachability and this CORS contract before
|
||||
compiling or signing. If it fails, deploy the backend CORS policy fix before
|
||||
dispatching another IPA; do not replace the API URL or use an unsupported
|
||||
HTTP/HTTPS `iosScheme` workaround.
|
||||
- An already-built IPA starts using a corrected server-side CORS policy without
|
||||
modification. Build and install a higher version when recording a verified
|
||||
device-test result for the fix.
|
||||
|
||||
### iOS beta or new major iOS version breaks device tools
|
||||
|
||||
- Record the device model, exact iOS version, helper error, source SHA, and IPA
|
||||
checksum.
|
||||
- Update libimobiledevice only through a trusted package/source and rerun the
|
||||
doctor. Do not install arbitrary device images or disable signing checks.
|
||||
- If compatibility remains broken, distribute through TestFlight or install and
|
||||
debug from a physical Mac with a compatible Xcode version.
|
||||
|
||||
## Real-Device Acceptance Checklist
|
||||
|
||||
For the first setup, after signing changes, and after major iOS upgrades:
|
||||
|
||||
- `doctor` passes while the phone is unlocked.
|
||||
- `SHA256SUMS` verifies and the manifest identifies the intended immutable SHA.
|
||||
- **Truck Wash Debug** installs as `io.truckwash.app.debug` while the production
|
||||
app and its data remain unchanged.
|
||||
- Authentication, camera/QR permission, and foreground-location behavior work.
|
||||
- Logs show the expected build/source context and stable API target.
|
||||
- The debug app still launches and reaches the API after the cable is removed.
|
||||
- A higher-numbered IPA upgrades the debug app without clearing its local state.
|
||||
- Crash reports are copied without being deleted from the phone.
|
||||
- The test record includes artifact checksum, source SHA, device model, iOS
|
||||
version, outcome, and any residual iOS/libimobiledevice compatibility risk.
|
||||
|
||||
## References
|
||||
|
||||
- [Apple: enable Developer Mode on a device](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device)
|
||||
- [Apple: run an app on a physical device](https://developer.apple.com/documentation/Xcode/running-your-app-on-simulated-or-physical-devices)
|
||||
- [Apple: register a single device](https://developer.apple.com/help/account/devices/register-a-single-device/)
|
||||
- [Apple: create a development provisioning profile](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile/)
|
||||
- [libimobiledevice project](https://github.com/libimobiledevice/libimobiledevice)
|
||||
@@ -1,73 +1,74 @@
|
||||
# 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.
|
||||
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` and `version_code`.
|
||||
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
|
||||
- Automatic Android Play Store artifact: after the `Automated Tests` workflow completes successfully on `master`, GitHub Actions builds and uploads a signed Android App Bundle from the tested commit.
|
||||
- Automatic delivery after successful current-master `Automated Tests`, with
|
||||
all six full Chromium-mobile role shards explicitly verified as green.
|
||||
- Manual dispatch with version, version code, upload toggle, track, and status.
|
||||
- Existing `mobile-v*` tags for the Android workflow.
|
||||
|
||||
## Required Secrets
|
||||
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:
|
||||
The Android job continues using GitHub environment `mobile-store-production`.
|
||||
Its required secrets are:
|
||||
|
||||
- `ANDROID_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
|
||||
|
||||
iOS:
|
||||
Its variables are `ANDROID_PACKAGE_NAME`, `ANDROID_AAB_PATH`,
|
||||
`PLAY_STORE_TRACK`, `PLAY_STORE_RELEASE_STATUS`, and optional
|
||||
`PLAY_STORE_USER_FRACTION`. See the Google Play Console runbook for production
|
||||
track policy.
|
||||
|
||||
- `IOS_CERTIFICATE_BASE64`
|
||||
- `IOS_CERTIFICATE_PASSWORD`
|
||||
- `IOS_PROVISION_PROFILE_BASE64`
|
||||
- `IOS_KEYCHAIN_PASSWORD`
|
||||
- `APPLE_TEAM_ID`
|
||||
## iOS
|
||||
|
||||
## Local Checks
|
||||
iOS uses three separate workflows:
|
||||
|
||||
Run the native permission validation after changing Capacitor, native manifests, or store metadata:
|
||||
- `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.
|
||||
|
||||
Before signing or uploading to TestFlight, the workflow resolves the exact
|
||||
current-master test run and requires all six full WebKit-mobile role shards to
|
||||
be green. App Store candidates reuse that gated TestFlight build and do not
|
||||
rebuild it.
|
||||
|
||||
The GitHub environments and variables are documented in
|
||||
`docs/app-store-release.md`. The repository-level
|
||||
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
||||
`false` until the signed credential canary is approved.
|
||||
|
||||
Local source/storefront checks:
|
||||
|
||||
```sh
|
||||
npm run mobile:permissions:check
|
||||
npm run mobile:ios:storefront:check
|
||||
```
|
||||
|
||||
Build a local unsigned Android App Bundle for packaging verification:
|
||||
Strict candidate asset check:
|
||||
|
||||
```sh
|
||||
npm run mobile:android:bundle:unsigned
|
||||
npm run mobile:ios:storefront:check-strict
|
||||
```
|
||||
|
||||
Build a signed Play Console upload bundle after exporting the Android upload-key variables:
|
||||
Version identity is deliberately different between platforms:
|
||||
|
||||
```sh
|
||||
export ANDROID_KEYSTORE_FILE=/path/to/upload-key.jks
|
||||
export ANDROID_KEYSTORE_PASSWORD=...
|
||||
export ANDROID_KEY_ALIAS=...
|
||||
export ANDROID_KEY_PASSWORD=...
|
||||
npm run mobile:android:bundle
|
||||
```
|
||||
- Android package: `io.truckwash.twa`
|
||||
- iOS App Store bundle: `io.truckwash.app`
|
||||
|
||||
The signed Android bundle is written to:
|
||||
|
||||
```text
|
||||
android/app/build/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`.
|
||||
|
||||
The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk
|
||||
|
||||
Play Store graphics are generated in the workspace-level `playstoregraphics/` folder:
|
||||
|
||||
- App icon: `playstoregraphics/universal/app-icon/truck-wash-icon-512.png`
|
||||
- Feature graphic: `playstoregraphics/universal/feature-graphic/truck-wash-feature-1024x500.jpg`
|
||||
- Phone screenshots: `playstoregraphics/phone/screenshots/`
|
||||
- 7-inch tablet screenshots: `playstoregraphics/tablet-7/screenshots/`
|
||||
- 10-inch tablet screenshots: `playstoregraphics/tablet-10/screenshots/`
|
||||
- Chromebook screenshots: `playstoregraphics/chromebook/screenshots/`
|
||||
|
||||
The native manifests declare camera and foreground location access for the store binaries. Keep the App Store Connect and Play Console privacy questionnaires aligned with the app's actual camera and location data handling before submitting a release.
|
||||
The iOS release build verifies the final signed IPA rather than relying on the
|
||||
Capacitor `appId`, which remains the Android package identifier.
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "VisualEvidenceManifest",
|
||||
"taskId": "a0edd464-44c0-4d77-96c1-d3562496a1b7",
|
||||
"repository": "copenhagentruckwash/pleno-vue",
|
||||
"baseSha": "eee9ba1c138f0c88c772ea284a5a97a46cb9412c",
|
||||
"subjectSha": "89dec2c5690f9eaf1e0e34651bb40b7f441b85fb",
|
||||
"views": [
|
||||
{
|
||||
"id": "invoicing-period-review",
|
||||
"name": "Superuser invoice period review workspace",
|
||||
"description": "Replaces the long mixed invoice-period page with grouped review navigation, compact totals, explicit review filters, and a responsive master-detail workspace while preserving every invoice category and action.",
|
||||
"route": "/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all",
|
||||
"fixture": "Synthetic superuser invoice-period fixture with three fictional customers and no production data",
|
||||
"comparisons": {
|
||||
"mobile": {
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-mobile.png",
|
||||
"sha256": "b3c25c072f45e912358cc61d21577bbf61d5216b114a6911f900ca19b90d477e",
|
||||
"bytes": 28914,
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period mobile view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-mobile.png",
|
||||
"sha256": "257db0e6e295b6b13ba5d0b29509c2a8b7170244ad6539a8e2ff18d2c9c0ffba",
|
||||
"bytes": 31339,
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period mobile view after the review workspace redesign"
|
||||
}
|
||||
},
|
||||
"tablet": {
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-tablet.png",
|
||||
"sha256": "8cd17ea1e384ab6a9711a643d4e50e971cabcf704c3672d8f90cb2f5f2d36ba0",
|
||||
"bytes": 38508,
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period tablet view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-tablet.png",
|
||||
"sha256": "1c627c30ade435ee004b8cdcd0223022594a351658639edec1f6e5396efaba18",
|
||||
"bytes": 46062,
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period tablet view after the review workspace redesign"
|
||||
}
|
||||
},
|
||||
"desktop": {
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"before": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-desktop.png",
|
||||
"sha256": "849ea0eedabc7f1e52172e26602cddfddbeed32c91d04672dfe8b713343a54fa",
|
||||
"bytes": 142738,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period desktop view before the review workspace redesign"
|
||||
},
|
||||
"after": {
|
||||
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-desktop.png",
|
||||
"sha256": "d026925bc4f6ced62686ae7f8252594e3a3fe9bd559a7f53f38a1bfcb0b96892",
|
||||
"bytes": 108740,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"mimeType": "image/png",
|
||||
"alt": "Invoice period desktop view after the review workspace redesign"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Customer and subuser lifecycle visual comparisons
|
||||
|
||||
## Forgot-password account selection
|
||||
|
||||
The reset page previously accepted only a customer number. It now lets the
|
||||
visitor choose a customer or chauffeur account. Chauffeur recovery uses the
|
||||
country code and phone number and sends the one-time reset link by SMS.
|
||||
|
||||
- Mobile: [before](before-mobile.png) / [after](after-mobile.png)
|
||||
- Tablet: [before](before-tablet.png) / [after](after-tablet.png)
|
||||
- Desktop: [before](before-desktop.png) / [after](after-desktop.png)
|
||||
|
||||
## Pre-authorized customer access decision
|
||||
|
||||
The SMS link previously had no destination view. It now opens a read-only
|
||||
request preview, identifies the chauffeur and customer, and requires an
|
||||
explicit approve or deny action before the one-time token mutates access.
|
||||
|
||||
- Mobile: [before](access-before-mobile.png) / [after](access-after-mobile.png)
|
||||
- Tablet: [before](access-before-tablet.png) / [after](access-after-tablet.png)
|
||||
- Desktop: [before](access-before-desktop.png) / [after](access-after-desktop.png)
|
||||
|
||||
The related signed-in profile and customer grant selector use the same
|
||||
responsive components. The selector is deduplicated by customer number and
|
||||
uses colored permission indicators for vehicles, tools, calendar, orders,
|
||||
and driver access.
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,28 @@
|
||||
# Automatic cron execution visual comparison
|
||||
|
||||
The Cron workers panel now exposes machine-verifiable scheduler cadence.
|
||||
The summary reports how many active workers have maintained the required
|
||||
once-per-minute cadence, and each worker row shows its latest loop gap and
|
||||
consecutive qualifying loops.
|
||||
|
||||
A worker is verified only after two consecutive loops, with no gap above
|
||||
60 seconds, while the worker is running and its latest observation is no more
|
||||
than 60 seconds old.
|
||||
|
||||
## Mobile
|
||||
|
||||
- [Before](before-mobile.png)
|
||||
- [After](after-mobile.png)
|
||||
|
||||
## Tablet
|
||||
|
||||
- [Before](before-tablet.png)
|
||||
- [After](after-tablet.png)
|
||||
|
||||
## Desktop
|
||||
|
||||
- [Before](before-desktop.png)
|
||||
- [After](after-desktop.png)
|
||||
|
||||
The updated state tags use explicit foreground colors so success and warning
|
||||
labels remain readable against their backgrounds.
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,48 @@
|
||||
# Stripe cleanup visual evidence
|
||||
|
||||
Authentic browser captures compare `origin/master` at
|
||||
`fd31609cb379cda36fb16ef7077fc9db3c91eb2b` with the feature at
|
||||
`6e795b253062606e6122cc7e630e17651b9b7efd`.
|
||||
|
||||
Both revisions were rendered by their own Vite applications and exercised with
|
||||
the repository's mocked Playwright API support. No production API, Stripe
|
||||
account, or product-source modification was used to create the evidence.
|
||||
|
||||
## Regular POS card-payment view
|
||||
|
||||
The baseline identifies the integration as Stripe and offers an email payment.
|
||||
The feature uses provider-neutral card-terminal wording and removes the hosted
|
||||
email-payment action while preserving terminal payment.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-pos-card-payment-mobile.png) | [After](after-pos-card-payment-mobile.png) |
|
||||
| Tablet | [Before](before-pos-card-payment-tablet.png) | [After](after-pos-card-payment-tablet.png) |
|
||||
| Desktop | [Before](before-pos-card-payment-desktop.png) | [After](after-pos-card-payment-desktop.png) |
|
||||
|
||||
## Authorized payment capture
|
||||
|
||||
Both revisions receive a mocked payment intent in `requires_capture` state. The
|
||||
baseline exposes a manual capture action. The feature automatically issues the
|
||||
capture request and shows its in-progress state without a second manual action.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-payment-capture-mobile.png) | [After](after-payment-capture-mobile.png) |
|
||||
| Tablet | [Before](before-payment-capture-tablet.png) | [After](after-payment-capture-tablet.png) |
|
||||
| Desktop | [Before](before-payment-capture-desktop.png) | [After](after-payment-capture-desktop.png) |
|
||||
|
||||
## Order-dashboard action rail
|
||||
|
||||
The baseline action rail includes the hosted Stripe invoice/payment-link
|
||||
action. The feature removes it while preserving receipts, ordinary order
|
||||
completion, and navigation.
|
||||
|
||||
| Device | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Mobile | [Before](before-order-dashboard-mobile.png) | [After](after-order-dashboard-mobile.png) |
|
||||
| Tablet | [Before](before-order-dashboard-tablet.png) | [After](after-order-dashboard-tablet.png) |
|
||||
| Desktop | [Before](before-order-dashboard-desktop.png) | [After](after-order-dashboard-desktop.png) |
|
||||
|
||||
All nine paired states passed their relevant DOM assertions across Chromium
|
||||
mobile, tablet, and desktop projects.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"kind": "VisualEvidenceManifestV1",
|
||||
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||
"view": "POS extra sale audit",
|
||||
"files": [
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 138 KiB |