Compare commits

..
Author SHA1 Message Date
Jeppe B 12a2d73b32 Replace monolithic E2E matrices with dependency-aware component CI
Generate small reusable component partitions with explicit provider dependencies, GitHub-hosted concurrency, fail-closed evidence, and exact mobile store gates. Include the literal-i18n fix from #206 so validation covers the exact post-merge tree.
2026-07-20 23:52:15 +02:00
417 changed files with 13850 additions and 25707 deletions
+7 -23
View File
@@ -8,8 +8,6 @@ on:
- "fastlane/**"
- "ios/**"
- "scripts/mobile/**"
- "tests/node/app-store-connect.test.mjs"
- ".github/workflows/app-store-readiness.yml"
- "Gemfile*"
workflow_dispatch:
@@ -22,22 +20,21 @@ concurrency:
jobs:
validate:
name: App Store Readiness
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
@@ -51,7 +48,7 @@ jobs:
- name: Preserve a generated lock for review
if: steps.fastlane-lock.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: generated-fastlane-lock
path: Gemfile.lock
@@ -64,25 +61,12 @@ jobs:
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 App Store metadata and available assets
run: node scripts/mobile/validate-app-store.mjs
- name: Validate JavaScript syntax
run: |
node --check scripts/mobile/validate-app-store.mjs
node --check scripts/mobile/app-store-connect.mjs
node --check scripts/mobile/create-ios-release-manifest.mjs
node --check tests/node/app-store-connect.test.mjs
node scripts/mobile/app-store-connect.mjs self-test-jwt
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
@@ -52,7 +52,7 @@ jobs:
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
with:
use-caches: true
cache-default-branch-only: true
+99
View File
@@ -0,0 +1,99 @@
name: cPanel Root Audit and Restore
on:
workflow_dispatch:
inputs:
mode:
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
required: true
default: audit
type: choice
options:
- audit
- restore
recovery:
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
required: false
type: string
state_token:
description: Exact 64-character audit-metadata state token reported by the audit.
required: false
type: string
confirmation:
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
required: false
type: string
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-restore:
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: frontend-production
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Audit cPanel primary webroot
if: inputs.mode == 'audit'
id: audit
run: node scripts/release/cpanel-root.mjs audit
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
- name: Validate restore inputs
if: inputs.mode == 'restore'
env:
RECOVERY: ${{ inputs.recovery }}
STATE_TOKEN: ${{ inputs.state_token }}
CONFIRMATION: ${{ inputs.confirmation }}
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
run: |
test -n "$RECOVERY"
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
- name: Restore retained cPanel webroot
if: inputs.mode == 'restore'
run: node scripts/release/cpanel-root.mjs restore
env:
NODE_OPTIONS: --use-system-ca
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
- name: Upload cPanel root report
if: always()
uses: actions/upload-artifact@v4
with:
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
path: output/cpanel-root
if-no-files-found: ignore
retention-days: 30
+16 -18
View File
@@ -25,14 +25,14 @@ jobs:
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
steps:
- name: Checkout tagged source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
@@ -50,6 +50,7 @@ jobs:
[[ "$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; }
[[ "$(git rev-parse origin/master)" == "$source_sha" ]] || { echo "Tagged commit is not the exact current master SHA." >&2; exit 1; }
enabled=false
[[ "$AUTOMATION_ENABLED" == true ]] && enabled=true
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
@@ -60,6 +61,14 @@ jobs:
echo 'No App Store environment or credentials were accessed. Enable only after the signed canary.' >> "$GITHUB_STEP_SUMMARY"
fi
- name: Require complete WebKit mobile tests
if: steps.resolve.outputs.enabled == 'true'
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
DEFAULT_BRANCH: master
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
- name: Download exact TestFlight release manifest
if: steps.resolve.outputs.enabled == 'true'
id: manifest
@@ -100,7 +109,7 @@ jobs:
NODE
promote:
name: Sync and verify App Store candidate
name: Sync storefront and prepare manual review
needs: resolve
if: needs.resolve.outputs.enabled == 'true'
runs-on: macos-15
@@ -119,19 +128,19 @@ jobs:
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
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ env.IOS_SOURCE_SHA }}
persist-credentials: false
- name: Setup Ruby and pinned Fastlane
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
@@ -153,33 +162,22 @@ jobs:
- 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"
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
disabled:
name: Promotion disabled
+2 -2
View File
@@ -44,12 +44,12 @@ jobs:
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
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
+4 -4
View File
@@ -63,7 +63,7 @@ jobs:
fi
- name: Checkout same-repository history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
@@ -136,7 +136,7 @@ jobs:
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
steps:
- name: Checkout resolved source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v5
with:
ref: ${{ needs.resolve.outputs.source_sha }}
fetch-depth: 1
@@ -163,7 +163,7 @@ jobs:
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -596,7 +596,7 @@ jobs:
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
- name: Upload device-debug artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v4
with:
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
+17 -17
View File
@@ -41,7 +41,7 @@ jobs:
current: ${{ steps.resolve.outputs.current }}
steps:
- name: Checkout repository history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
@@ -82,13 +82,19 @@ jobs:
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Require green WebKit mobile tests before App Store upload
- name: Setup Node.js
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Require complete WebKit mobile tests
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 }}
DEFAULT_BRANCH: master
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
deliver:
name: Sign, upload, process, and distribute
@@ -111,7 +117,7 @@ jobs:
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
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ env.IOS_SOURCE_SHA }}
fetch-depth: 1
@@ -142,19 +148,13 @@ jobs:
const fs = require("node:fs");
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
const checks = {
schema: proof.schemaVersion === 2,
schema: proof.schemaVersion === 1,
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),
credentialedGate: proof.liveCredentialedGate === "passed",
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(", ")}`);
@@ -173,13 +173,13 @@ jobs:
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- name: Setup Ruby and pinned Fastlane
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
with:
ruby-version: "3.3"
bundler-cache: true
@@ -402,7 +402,7 @@ jobs:
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
- name: Upload signed IPA
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
path: output/ios-release/*.ipa
@@ -410,7 +410,7 @@ jobs:
retention-days: 30
- name: Upload release manifest, dSYM, and checksums
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
path: |
+33 -74
View File
@@ -30,53 +30,62 @@ on:
description: Google Play release status for manual dispatches
required: false
type: choice
default: inProgress
default: completed
options:
- inProgress
- completed
- draft
- inProgress
- halted
push:
tags:
- "mobile-v*"
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
permissions:
contents: read
actions: read
concurrency:
group: android-store-artifacts-${{ github.ref_name || github.run_id }}
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
android:
name: Android AAB and Play upload
if: >
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-24.04
environment: mobile-store-production
timeout-minutes: 60
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
PLAY_STORE_USER_FRACTION: "0.01"
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.sha }}
ref: ${{ github.event.workflow_run.head_sha || 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 }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
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
@@ -89,43 +98,40 @@ jobs:
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; 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."
echo "Mobile upload commit is the exact current $DEFAULT_BRANCH SHA."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
- name: Require green Chromium mobile tests before Play upload
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
- name: Require complete Chromium mobile tests
if: steps.release-guard.outputs.current == 'true'
env:
GH_TOKEN: ${{ github.token }}
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
TEST_WORKFLOW_RUN_ID: ""
STORE_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TEST_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || '' }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: temurin
java-version: 21
- name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
- name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
@@ -201,64 +207,17 @@ jobs:
if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Record immutable Android artifact proof
if: steps.release-guard.outputs.current == 'true'
id: artifact-proof
shell: bash
run: |
set -euo pipefail
artifact_sha256="$(sha256sum "$ANDROID_AAB_PATH" | awk '{print $1}')"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]]
echo "ANDROID_AAB_SHA256=$artifact_sha256" >> "$GITHUB_ENV"
echo "sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
echo "Android AAB SHA-256: \`$artifact_sha256\`" >> "$GITHUB_STEP_SUMMARY"
- name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error
retention-days: 14
- name: Recheck live master before Play upload
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
live_master_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
exit 1
}
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
id: play-upload
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
- 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 "$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"
-283
View File
@@ -1,283 +0,0 @@
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 }}
+39 -98
View File
@@ -37,11 +37,10 @@ jobs:
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: Check release commit is current
id: branch-head
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ github.token }}
script: |
@@ -61,7 +60,7 @@ jobs:
- name: Checkout tested commit
if: steps.branch-head.outputs.current == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
@@ -69,7 +68,7 @@ jobs:
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
@@ -112,7 +111,6 @@ jobs:
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
env:
PLAYWRIGHT_PROD_PREBUILT: "1"
PLAYWRIGHT_PROD_WEBKIT: "0"
- name: Confirm production gate did not mutate dist
@@ -149,7 +147,7 @@ jobs:
- name: Upload release package
if: steps.branch-head.outputs.current == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.package-names.outputs.artifact_name }}
path: |
@@ -185,14 +183,14 @@ jobs:
RELEASE_POLL_INTERVAL_SECONDS: 5
steps:
- name: Checkout tested commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_COMMIT_SHA }}
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
cache: npm
@@ -219,7 +217,7 @@ jobs:
run: node scripts/install-playwright-browsers.mjs chromium
- name: Download validated release package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ needs.build-release.outputs.artifact_name }}
path: release-artifacts
@@ -245,7 +243,7 @@ jobs:
- name: Check release commit is still current
id: branch-head
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ github.token }}
script: |
@@ -275,45 +273,28 @@ jobs:
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_GITHUB_TOKEN: ${{ github.token }}
- 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: Detect credentialed live gate configuration
- name: Credentialed live Playwright gate
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 }}
@@ -321,10 +302,26 @@ jobs:
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Roll back after live verification failure
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
timeout-minutes: 10
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Record Release Manager gate
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}"
@@ -332,7 +329,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"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\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
@@ -344,9 +341,7 @@ 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'
@@ -361,92 +356,38 @@ jobs:
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(),
schemaVersion: 1,
repository: required("GITHUB_REPOSITORY"),
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
frontendReleaseRunId: required("GITHUB_RUN_ID"),
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
buildId: required("RELEASE_BUILD_ID"),
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
liveCredentialedGate: credentialedGate(),
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
livePublicGate: "passed",
liveCredentialedGate: "passed",
releaseManagerGate: "passed",
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
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
path: output/frontend-release-proof/frontend-release-proof.json
if-no-files-found: error
retention-days: 30
- name: 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
+84
View File
@@ -0,0 +1,84 @@
name: Dependency Graph Node
on:
workflow_call:
inputs:
node:
description: Stable test-graph node identifier.
required: true
type: string
lane:
description: Contract or Playwright project lane.
required: true
type: string
partitions:
description: JSON array of one-based partition numbers.
required: false
default: "[1]"
type: string
permissions:
contents: read
jobs:
test:
name: ${{ inputs.node }} / ${{ inputs.lane }} / ${{ matrix.partition }}
runs-on: ubuntu-24.04
timeout-minutes: 45
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
strategy:
fail-fast: false
max-parallel: 100
matrix:
partition: ${{ fromJSON(inputs.partitions) }}
env:
CI: "true"
PLAYWRIGHT_ARTIFACT_NAMESPACE: graph-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_VIDEO_MODE: off
PLAYWRIGHT_WORKERS: 3
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run dependency graph node
run: >-
node scripts/test-graph/run-node-tests.mjs
--node "${{ inputs.node }}"
--lane "${{ inputs.lane }}"
--partition "${{ matrix.partition }}"
--partitions "${{ strategy.job-total }}"
- name: Upload dependency graph result
if: always()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: test-graph-result-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}-${{ github.run_id }}-${{ github.run_attempt }}
path: output/test-graph-results
if-no-files-found: warn
retention-days: 3
- name: Upload failure diagnostics
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: test-graph-${{ inputs.node }}-${{ inputs.lane }}-${{ matrix.partition }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
if-no-files-found: ignore
retention-days: 3
+3378 -772
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
legacy-peer-deps=true
+1 -1
View File
@@ -1,4 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.237.0"
gem "fastlane", "2.229.1"
+40 -53
View File
@@ -27,8 +27,7 @@ GEM
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
base64 (0.2.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
@@ -42,8 +41,7 @@ GEM
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (1.6.0)
logger
excon (0.112.0)
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -73,45 +71,41 @@ GEM
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)
fastlane (2.229.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2)
nkf (~> 0.2.0)
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)
@@ -126,46 +120,41 @@ GEM
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)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 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)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
google-cloud-storage (1.47.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-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
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)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
@@ -174,7 +163,7 @@ GEM
mutex_m
jmespath (1.6.2)
json (2.21.1)
jwt (3.2.0)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
@@ -184,12 +173,10 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
nkf (0.2.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)
@@ -239,7 +226,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane (= 2.237.0)
fastlane (= 2.229.1)
RUBY VERSION
ruby 3.3.12p206
-7
View File
@@ -7,13 +7,6 @@ const config: CapacitorConfig = {
server: {
androidScheme: "https",
},
plugins: {
StatusBar: {
overlaysWebView: false,
style: "LIGHT",
backgroundColor: "#FFFFFFFF",
},
},
};
export default config;
-4
View File
@@ -1,4 +0,0 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
+20 -26
View File
@@ -1,32 +1,31 @@
# 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.
GitHub Actions delivery. Public review submission and public release remain
human actions in App Store Connect.
## 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 |
| Setting | Value |
| --- | --- |
| Name | Truck Wash Kundeportal |
| Bundle ID | `io.truckwash.app` |
| SKU | `truckwash-ios` |
| Primary language | Danish |
| Category | Business |
| Price | Free |
| Availability | Denmark only |
| Support URL | `https://truckwash.io/support` |
| Privacy URL | `https://truckwash.io/privacy-policy` |
| Marketing URL | `https://truckwash.io/` |
| Release | Manual after approval |
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.
been tested deliberately.
The Account Holder or Admin must complete these console-only items before the
first candidate:
@@ -96,9 +95,7 @@ Configure two GitHub environments:
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.
approval.
Environment secrets:
@@ -182,14 +179,11 @@ delivery automatically. Stale or proofless releases do not sign or upload.
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.
It does not rebuild, submit for review, or release publicly.
5. In App Store Connect, review the rendered product page, review account,
privacy/export/age answers, and candidate build. Submit manually.
6. 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.
6. Release the first Denmark version manually after approval. Use phased release
for later updates unless there is a reason not to.
7. Merge the next `ios/release.json` version bump before further delivery after
Apple closes the released version to new builds.
+89 -36
View File
@@ -46,22 +46,33 @@ Add these environment **secrets**:
- `PRODUCTION_FTP_PASSWORD`
- `PRODUCTION_FTP_PATH`
- `PRODUCTION_ACTIVATION_KEY`
- `PRODUCTION_CPANEL_USER`
- `PRODUCTION_CPANEL_API_TOKEN`
The API `.env` contains legacy values under the first four names, but production
frontend deployment uses a dedicated cPanel FTP account jailed to
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
deployment unchanged.
The 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.
The cPanel token is separate from the FTP password. Create it in cPanel under
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
uses the token for fail-closed directory and release-state inspection. It does
not use legacy Fileman mutation calls to replace symlinks: on this server those
calls can follow the target instead of renaming the link itself. Revoke and
rotate the token if it is ever exposed.
Add these environment **variables**:
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
fallback. The cPanel URL and path deliberately fail closed when absent. The
production environment must keep the explicit
`https://server.red-block.com:2083` cPanel origin: the public origin serves
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
cPanel JSON API.
### Create the dedicated FTP credentials
@@ -77,26 +88,53 @@ Add these environment **variables**:
6. Verify explicit FTPS login and directory listing before merging. Never copy
these frontend-only credentials back into the API `.env`.
### Create the missing cPanel credentials
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
separately; do not reuse the FTP password as an API token.
1. Sign in to the cPanel account that owns the frontend deployment root.
2. Record the exact cPanel account username shown in **General Information**.
Add it to the `frontend-production` environment as the
`PRODUCTION_CPANEL_USER` secret.
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
hosting provider to enable API Tokens in WHM Feature Manager.
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
expiration date that matches the team's rotation policy. Expiration cannot
be edited later, so add a reminder before that date.
5. Click **Create**, copy the token immediately, and add it to the same GitHub
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
again after leaving the page.
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
the GitHub secret has been saved.
7. Before merging, run the deployment audit against the configured API origin.
It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable
releases. Do not broaden the token or deployment root beyond this cPanel
account.
The current production token is named `github-pleno-vue-production` and
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
secret before that date, then revoke the replaced token in cPanel.
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
Use **Add secret** for credentials and **Add variable** for the frontend URL.
Use **Add secret** for credentials and **Add variable** for the two URLs and the
cPanel deployment path.
Environment values are available only to the deployment job that names this
environment, and configured protection rules are evaluated before its secrets
are released.
The existing live-test, Release Manager, and server-version secrets used by
`release.yml` must remain configured. The 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`.
`lftp` job-locally when needed and installs Playwright Chromium. The workflow
also uses Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
`sha256sum` for the account-scoped activator.
## cPanel layout and one-time bootstrap
The production FTP account is jailed directly to the deployment root, so its
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
`frontend-deployments` directory below the account home. The helper creates
this layout below it:
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
relative to the cPanel account home. The helper creates this layout below it:
```text
archives/
@@ -108,7 +146,7 @@ current -> releases/<release-id>/dist
```
The domain's document root must resolve to
`<cPanel account home>/frontend-deployments/current`, not to the deployment
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
root itself. This stable document-root path is what makes replacing `current`
atomic: every HTTP request resolves either the complete old release or the
complete new release, never a partly uploaded directory.
@@ -134,8 +172,9 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
listing.
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
and the API health request work at `PRODUCTION_FRONTEND_URL`.
8. The server-side activator, rather than the hosted runner, validates that
`current` and the captured rollback release exist before every switch.
8. Test the cPanel token against the exact host and port. The workflow performs
read-only state checks and refuses deployment if `current` or the captured
rollback release is missing.
9. Generate a dedicated 32-byte random activation key. Store its 64-character
hexadecimal form in the protected `frontend-production` environment as
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
@@ -166,33 +205,47 @@ 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.
Use the protected **cPanel Root Audit and Restore** workflow if the primary
domain starts showing a directory index or returns 404 for files that cPanel
lists in `public_html`. The `audit` mode is read-only: it reports the exact
`public_html` entry, whether the internal `current` link can serve the required
release files, domain document roots, and retained recovery candidates without
printing the cPanel token. API2 does not expose a documented symlink-target
field, so the audit deliberately reports `rootTargetVerified: false` instead
of claiming that an arbitrary `public_html` link follows `current`; the live
HTTP checks remain the source of truth for service health. The audit fails
closed if any domain record lacks an identity or document root, and restore is
blocked while an addon or subdomain is rooted below `public_html`.
If the 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.
If the regression followed the one-time webroot exchange and both the active
webroot and selected recovery are physical directories, select `restore`
and copy one exact recovery entry from the audit, including the retained
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
The workflow requires the
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
exact token string from that audit. The token is an optimistic-concurrency
guard over the cPanel metadata visible to the audit; it is not a content hash
or a substitute for validating the selected recovery. Restore also rejects an
unreadable physical directory. Restore also rejects symbolic-link roots and
recoveries because legacy cPanel Fileman may follow their targets rather than
rename the links. It renames the current physical entry to a run-specific
`public_html.failed-*` path, restores the retained entry, and
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
any mutation response is lost or any check fails, it reconciles the observed
account-home entries and reinstates the pre-restore cPanel state. It never
deletes the recovery or displaced webroot, and reports manual intervention if
the expected entries cannot be proven after compensation.
## Caching and compatibility
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
one-year immutable policy. `index.html`, release metadata, web manifests, and
service-worker control files always revalidate. The deployer retains 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.
service-worker control files always revalidate. The deployer retains at least
the active and rollback releases and keeps five recent release directories by
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
falls outside that validated retention set, its directory and matching ZIP and
checksum are removed over FTPS. Cleanup failure is reported without rolling
back an otherwise verified deployment.
Because the document root switches as one symlink, an already-loaded page may
still request an asset from its previous release after activation. The current
+75
View File
@@ -0,0 +1,75 @@
# Dependency-aware frontend CI
The `Automated Tests` workflow is generated from a repository-owned dependency graph. It replaces the former
`E2E-PR`, `E2E-targeted`, and `E2E-full` matrices with small component-and-lane jobs that can start concurrently
as soon as their actual prerequisites pass.
## Source of truth
- `scripts/test-graph/component-node-catalog.mjs` declares stable component, composable, feature, service, store,
middleware, router, runtime, and view boundaries. It also records test ownership, supported lanes, and runtime
dependencies that cannot be inferred from imports.
- `scripts/test-graph/import-resolver.mjs` scans JavaScript, TypeScript, and Vue imports. The graph builder combines
those inferred edges with the explicit catalog edges and collapses a real strongly connected component into one
atomic execution node if a cycle is ever introduced.
- `scripts/test-graph/test-inventory.mjs` proves that each active unit, component, and E2E spec has exactly one owner
and records the exact expected files and role coverage for every runnable lane. The runner lists each browser file
to capture its exact test count before execution.
- `.github/workflows/tests.yml` is generated by `scripts/test-graph/automated-tests-workflow.mjs`. Do not hand-edit
its component jobs.
Run these checks after changing the graph or test ownership:
```bash
npm run test:graph:validate
npm run test:graph:generate
npm run test:graph:check
```
## Selection rules
Pull requests map changed source files to owning nodes. The plan selects each changed owner, every transitive
consumer that could be affected, and every prerequisite needed to test those consumers. A test-only change selects
its owner and prerequisites but deliberately does not fan out to consumers. An unknown runtime, dependency,
configuration, fixture, or graph change fails closed to the full graph.
Manual targeted runs accept stable node IDs through `target_nodes`. The compatibility `target_components` and
`target_specs` inputs resolve into the same graph. Explicit node and spec targets include both reverse dependents
and prerequisites. `target_projects` narrows browser lanes, while their required contract jobs remain selected. An
unmappable grep request runs the full graph rather than claiming unsafe pruning.
Default-branch pushes, schedules, and `targeted-then-full` dispatches select every runnable contract and all nine
browser/device lanes: Chromium, WebKit, and Firefox on mobile, desktop, and tablet.
## Execution and failure propagation
Each static component/lane caller invokes `.github/workflows/test-graph-node.yml`. Every test spec is an independent
matrix child. The matrix has `fail-fast: false`, `max-parallel: 100`, and three Playwright workers per runner. Jobs
list their direct component dependencies through `needs`; browser and component-test lanes also depend on the build.
A provider failure therefore prevents only its selected consumers from allocating runners. Independent branches
continue.
All Linux jobs are pinned to the GitHub-hosted `ubuntu-24.04` label; Apple build and signing jobs use GitHub-hosted
macOS labels. No repository variable can redirect the dependency graph to a self-hosted machine. Repository and
organization concurrency quotas still determine how many jobs GitHub can start simultaneously. A newer run for the
same event type and pull request or ref cancels its superseded run so hosted capacity goes to the exact commit that
can still merge or release. Push, schedule, and manual runs use separate groups so they cannot cancel the exact
default-branch push proof required by store delivery.
Each partition writes evidence containing its exact listed, green, and intentionally skipped test outcomes, roles, and
run attempt; unexpected outcomes fail the partition. Artifact names include the node, lane, partition, run ID, and
attempt so reruns cannot collide.
Aggregation uses the newest evidence for each partition while retaining successful evidence from earlier attempts.
`Required CI` fails closed if the plan, quality matrix, build, any selected caller, or any evidence/count check is
failed, cancelled, blocked, or missing. Its name remains stable for branch protection.
## Mobile store release gates
The aggregate result artifact is versioned, namespaced by run attempt, and tied to the exact source SHA. Android
release workflows accept only a completed full `master` push whose `Required CI` job succeeded and whose complete
`chromium-mobile` and `ct-chromium-mobile` inventories passed with exact counts and all roles. Apple release workflows
apply the same rules to the complete `webkit-mobile` inventory. Missing nodes, duplicate or unexpected results,
partial profiles, and stale graph versions fail the release gate.
The legacy role-job fallback is temporary migration compatibility and is used only if an older test run has no
dependency result artifact. A present but invalid graph artifact never falls back.
@@ -1,477 +0,0 @@
# 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.
+14 -10
View File
@@ -9,16 +9,15 @@ must never publish an Android production artifact.
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
`io.truckwash.twa` and supports:
- Automatic delivery after successful current-master `Automated Tests`, with
all six full Chromium-mobile role shards explicitly verified as green.
- Automatic delivery after successful current-master `Automated Tests`.
- Manual dispatch with version, version code, upload toggle, track, and status.
- Existing `mobile-v*` tags for the Android workflow.
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.
The Android job continues using GitHub environment `mobile-store-production`.
Before any build or upload, it verifies the exact current `master` SHA, the
overall `Required CI` job, and every manifest-expected `chromium-mobile` and
`ct-chromium-mobile` dependency-graph result. Failed, incomplete, cancelled,
missing, or dependency-blocked mobile results prevent Google Play delivery.
Its required secrets are:
- `ANDROID_KEYSTORE_BASE64`
@@ -42,10 +41,15 @@ iOS uses three separate workflows:
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.
TestFlight delivery and App Store candidate promotion both require the exact
current `master` SHA, the overall `Required CI` job, and every
manifest-expected `webkit-mobile` dependency-graph result. The verifier reads
schema-version 2 from the latest artifact
`dependency-ci-results-<source-sha>-<run-attempt>` and file
`dependency-ci-results.json`. Legacy unnumbered graph artifacts remain readable.
Legacy full-matrix job names are accepted only when that artifact is absent
during the dependency-graph migration; an invalid or failing graph artifact
never falls back to legacy jobs.
The GitHub environments and variables are documented in
`docs/app-store-release.md`. The repository-level
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

@@ -1,85 +0,0 @@
{
"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"
}
}
}
}
]
}
@@ -1,26 +0,0 @@
# 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

@@ -1,28 +0,0 @@
# 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

-48
View File
@@ -1,48 +0,0 @@
# 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

@@ -1,49 +0,0 @@
{
"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
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

@@ -1,85 +0,0 @@
{
"schemaVersion": 1,
"kind": "VisualEvidenceManifest",
"taskId": "xlvask-autopilot-20260803",
"repository": "copenhagentruckwash/pleno-vue",
"baseSha": "f995440098f9e3f3b5ff122a55c8c4e018cc716e",
"subjectSha": "d5c291e6f7f81d6992aa63375d77521c2983f8e5",
"views": [
{
"id": "invoice-period-self-wash",
"name": "Invoice period XL-Vask autopilot",
"description": "Shows the previous self-wash import beside the new state-separated, evidence-led autopilot review workspace.",
"route": "/superuser/invoices?tab=period&periodView=self_wash&startDate=2026-03-01&endDate=2026-03-31",
"fixture": "Playwright mocked March 2026 invoice period with one uncertain match and one failed match",
"comparisons": {
"mobile": {
"width": 1081,
"height": 1999,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-mobile.png",
"sha256": "8c67915dd48847ef553db32f3fc2481e58af7490dab9a41c49728003b84df5a7",
"bytes": 141379,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-mobile.png",
"sha256": "02b5d6560566350cbf702ff995602084a70ffdda13290c3754438885c3730a20",
"bytes": 142027,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile XL-Vask autopilot summary, filters, and visible review states"
}
},
"tablet": {
"width": 1536,
"height": 2048,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-tablet.png",
"sha256": "5a20daa875e211e1bfbfdd4e17282956720954ce14a9e76f1002b1355f795dd4",
"bytes": 179803,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-tablet.png",
"sha256": "1916b97a6d3f7c0084130b3514fe67b5a8f729e23bca37c1ad5c7ed75f821d44",
"bytes": 198536,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet XL-Vask autopilot summary, filters, rows, and pagination"
}
},
"desktop": {
"width": 1280,
"height": 720,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-desktop.png",
"sha256": "617095bcb916dd2f25b5cd0e8d77e7b0b05f1b5db662aeb810b52d606bcbe0af",
"bytes": 94026,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-desktop.png",
"sha256": "dd33f7e07ca426ee63c4708d9994e2403375bae9a86c119d0d296f52e5e90a9a",
"bytes": 117715,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop XL-Vask autopilot summary and state-separated review rows"
}
}
}
}
]
}
+1 -1
View File
@@ -46,7 +46,7 @@ platform :ios do
overwrite_screenshots: true,
force: true,
submit_for_review: false,
automatic_release: true,
automatic_release: false,
phased_release: false,
run_precheck_before_submit: false,
precheck_include_in_app_purchases: false
-2
View File
@@ -28,8 +28,6 @@
<false/>
<key>NSCameraUsageDescription</key>
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Truck Wash uses your location to find or confirm the nearest truck wash department when you choose a location feature; the app does not track your location in the background.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
<key>UILaunchStoryboardName</key>
-1
View File
@@ -1,4 +1,3 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash bruger kameraet til at scanne QR-koder og registreringsnumre, når du vælger en scanningsfunktion.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash bruger din placering til at finde eller bekræfte den nærmeste Truck Wash-afdeling, når du vælger en placeringsfunktion; appen sporer ikke din placering i baggrunden.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash bruger din placering, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling.";
-1
View File
@@ -1,4 +1,3 @@
"CFBundleDisplayName" = "Truck Wash";
"NSCameraUsageDescription" = "Truck Wash uses the camera to scan QR codes and vehicle registration plates when you choose a scanning feature.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash uses your location to find or confirm the nearest Truck Wash department when you choose a location feature; the app does not track your location in the background.";
"NSLocationWhenInUseUsageDescription" = "Truck Wash uses your location while the app is open to find or confirm the nearest Truck Wash department.";
+2 -4
View File
@@ -12,8 +12,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation")
],
targets: [
.target(
@@ -21,8 +20,7 @@ let package = Package(
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation")
]
)
]
+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.0.1",
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-28
View File
@@ -3534,19 +3534,6 @@ paths:
properties:
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
put:
@@ -3579,21 +3566,6 @@ paths:
properties:
message:
type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
-43
View File
@@ -12,7 +12,6 @@
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
@@ -83,7 +82,6 @@
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
@@ -2133,14 +2131,6 @@
"@capacitor/core": "^8.4.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
"integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
@@ -10097,12 +10087,6 @@
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -11010,18 +10994,6 @@
"node": ">=0.10.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -11090,15 +11062,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -13664,12 +13627,6 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+6 -2
View File
@@ -50,6 +50,12 @@
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"test:graph:validate": "node scripts/test-graph/cli.mjs validate",
"test:graph:plan": "node scripts/test-graph/cli.mjs plan",
"test:graph:generate": "node scripts/test-graph/automated-tests-workflow.mjs",
"test:graph:check": "node scripts/test-graph/automated-tests-workflow.mjs --check",
"test:graph:run-node": "node scripts/test-graph/run-node-tests.mjs",
"test:graph:aggregate": "node scripts/test-graph/aggregate-results.mjs",
"release:package": "node scripts/release/package-dist.mjs",
"release:deploy:cpanel": "node scripts/release/deploy-cpanel.mjs",
"release:deploy:cpanel:rollback": "node scripts/release/deploy-cpanel.mjs --rollback",
@@ -84,7 +90,6 @@
"@bubblewrap/cli": "^1.24.1",
"@capacitor/core": "^8.4.1",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/status-bar": "^8.0.3",
"@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
@@ -155,7 +160,6 @@
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
+6 -4
View File
@@ -16,10 +16,12 @@ const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const configuredVideoMode = (process.env.PLAYWRIGHT_VIDEO_MODE || "retain-on-failure").trim();
const allowedVideoModes = new Set(["off", "on", "retain-on-failure", "on-first-retry"]);
const videoMode = allowedVideoModes.has(configuredVideoMode) ? configuredVideoMode : "retain-on-failure";
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]]
: [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]];
const jsonOutputFile = (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE || "").trim();
const reporter = [
[reporterMode === "line-html" ? "line" : "list"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
...(jsonOutputFile ? [["json", { outputFile: jsonOutputFile }]] : []),
];
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
const { defaultBrowserType: _defaultBrowserType, ...device } = devices[deviceName];
+6 -4
View File
@@ -6,10 +6,12 @@ const projectRoot = fileURLToPath(new URL(".", import.meta.url));
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim();
const artifactRoot = path.join("output", "playwright", artifactNamespace);
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]
: [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]];
const jsonOutputFile = (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE || "").trim();
const reporter = [
[reporterMode === "line-html" ? "line" : "list"],
["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }],
...(jsonOutputFile ? [["json", { outputFile: jsonOutputFile }]] : []),
];
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
+1 -6
View File
@@ -3,11 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
const isCI = !!process.env.CI;
const usePrebuiltDist = ["1", "true"].includes(
String(process.env.PLAYWRIGHT_PROD_PREBUILT || "")
.trim()
.toLowerCase()
);
process.env.PLAYWRIGHT_BASE_URL = baseURL;
@@ -78,7 +73,7 @@ export default defineConfig({
video: "retain-on-failure",
},
webServer: {
command: usePrebuiltDist ? "npm run preview -- --host 127.0.0.1 --port 4173" : "npm run preview:prod",
command: "npm run preview:prod",
url: baseURL,
timeout: 240_000,
reuseExistingServer: !isCI,
+5 -2
View File
@@ -44,7 +44,10 @@ const writeOutput = (result) => {
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
@@ -75,7 +78,7 @@ console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The runner image must provide the required browser system libraries.",
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
+254 -389
View File
@@ -1,420 +1,285 @@
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { argv, env, exit } from "node:process";
import { fileURLToPath } from "node:url";
export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
export const TESTFLIGHT_BETA_LOCALE = "da";
export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
const command = argv[2];
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
const required = (name) => {
const value = env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const base64url = (value) => Buffer.from(value).toString("base64url");
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
const params = new URLSearchParams({
"filter[platform]": "IOS",
"filter[versionString]": version,
limit: "10",
const token = () => {
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
if (!key.includes("PRIVATE KEY"))
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
const now = Math.floor(Date.now() / 1000);
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
else payload.sub = "user";
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
const encodedPayload = base64url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(key),
dsaEncoding: "ieee-p1363",
});
if (includeBuild) params.set("include", "build");
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
return `${signingInput}.${base64url(signature)}`;
};
export const createAppStoreConnectClient = ({
environment = env,
fetchImpl = globalThis.fetch,
sleepImpl = defaultSleep,
now = () => Date.now(),
logger = console,
tokenProvider,
outputWriter,
} = {}) => {
const required = (name) => {
const value = environment[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
};
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const appId = () => required("APP_STORE_CONNECT_APP_ID");
const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
const version = () => required("IOS_MARKETING_VERSION");
const buildNumber = () => required("IOS_BUILD_NUMBER");
const token = () => {
if (tokenProvider) return tokenProvider();
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
if (!key.includes("PRIVATE KEY")) {
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
const request = async (path, options = {}, attempt = 1) => {
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text };
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
return request(path, options, attempt + 1);
}
const issuedAt = Math.floor(now() / 1_000);
const payload = { aud: "appstoreconnect-v1", iat: issuedAt, exp: issuedAt + 1_200 };
if (environment.APP_STORE_CONNECT_ISSUER_ID) payload.iss = environment.APP_STORE_CONNECT_ISSUER_ID;
else payload.sub = "user";
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
const encodedPayload = base64url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("sha256", Buffer.from(signingInput), {
key: createPrivateKey(key),
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64url(signature)}`;
};
const detail =
body?.errors
?.map((error) => error.detail || error.title)
.filter(Boolean)
.join("; ") ||
body?.raw ||
response.statusText;
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
}
return body;
};
const writeOutput = (key, value) => {
if (outputWriter) outputWriter(key, String(value));
else if (environment.GITHUB_OUTPUT) appendFileSync(environment.GITHUB_OUTPUT, `${key}=${value}\n`);
else logger.log(`${key}=${value}`);
};
const appId = () => required("APP_STORE_CONNECT_APP_ID");
const bundleId = () => env.IOS_BUNDLE_ID || "io.truckwash.app";
const version = () => required("IOS_MARKETING_VERSION");
const buildNumber = () => required("IOS_BUILD_NUMBER");
const request = async (path, options = {}, attempt = 1) => {
const response = await fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text };
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") || "", 10);
const delay = Number.isSafeInteger(retryAfter)
? Math.min(30_000, retryAfter * 1_000)
: Math.min(30_000, 2 ** attempt * 1_000);
await sleepImpl(delay);
return request(path, options, attempt + 1);
}
const detail =
body?.errors
?.map((error) => error.detail || error.title)
.filter(Boolean)
.join("; ") ||
body?.raw ||
response.statusText;
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
}
return body;
};
const writeOutput = (key, value) => {
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
else console.log(`${key}=${value}`);
};
const collectPages = async (path) => {
let url = path;
const data = [];
const included = [];
while (url) {
const page = await request(url);
data.push(...(page?.data ?? []));
included.push(...(page?.included ?? []));
url = page?.links?.next ?? null;
}
return { data, included };
};
const verifyCredentials = async () => {
const app = await request(`/apps/${encodeURIComponent(appId())}`);
const actualBundleId = app?.data?.attributes?.bundleId;
if (actualBundleId !== bundleId()) {
throw new Error(
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
);
}
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
return app.data;
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[preReleaseVersion.version]": version(),
limit: "200",
});
const response = await collectPages(`/builds?${params}`);
return response.data;
};
const allStoreVersions = async ({ includeBuild = false } = {}) =>
collectPages(appStoreVersionsPath({ appId: appId(), version: version(), includeBuild }));
const findStoreVersion = async ({ includeBuild = false } = {}) => {
const response = await allStoreVersions({ includeBuild });
return {
storeVersion: response.data.find((candidate) => candidate?.attributes?.versionString === version()),
included: response.included,
};
};
const findExactBuild = async () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
const { storeVersion } = await findStoreVersion();
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
throw new Error(
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
);
}
const builds = await allBuildsForVersion();
const numbers = builds
.map((build) => Number.parseInt(build?.attributes?.version, 10))
.filter((number) => Number.isSafeInteger(number) && number > 0);
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
writeOutput("build_number", next);
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
return next;
};
const waitForBuild = async () => {
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
while (now() < deadline) {
build = await findExactBuild();
const state = build?.attributes?.processingState;
if (state === "VALID") return build;
if (["FAILED", "INVALID"].includes(state)) {
throw new Error(`App Store Connect processing ended in ${state}`);
}
logger.log(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleepImpl(30_000);
}
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
};
const waitAndDistribute = async () => {
const build = await waitForBuild();
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
const localizationParams = new URLSearchParams({
"filter[build]": build.id,
"filter[locale]": TESTFLIGHT_BETA_LOCALE,
});
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0];
const whatsNew = environment.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
if (existingLocalization) {
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
method: "PATCH",
body: JSON.stringify({
data: {
type: "betaBuildLocalizations",
id: existingLocalization.id,
attributes: { whatsNew },
},
}),
});
} else {
await request("/betaBuildLocalizations", {
method: "POST",
body: JSON.stringify({
data: {
type: "betaBuildLocalizations",
attributes: { locale: TESTFLIGHT_BETA_LOCALE, whatsNew },
relationships: { build: { data: { type: "builds", id: build.id } } },
},
}),
});
}
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
if (!alreadyAssigned) {
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
method: "POST",
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
});
}
writeOutput("app_store_build_id", build.id);
logger.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
const verifyCredentials = async () => {
const app = await request(`/apps/${encodeURIComponent(appId())}`);
const actualBundleId = app?.data?.attributes?.bundleId;
if (actualBundleId !== bundleId()) {
throw new Error(
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
);
return build;
};
}
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
};
const verifyCandidate = async () => {
await verifyCredentials();
const build = await findExactBuild();
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
if (build.attributes?.processingState !== "VALID") {
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
}
if (environment.EXPECTED_APP_STORE_BUILD_ID && build.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`Candidate App Store build ID ${build.id} does not match release manifest ${environment.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", build.id);
logger.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
return build;
};
const allBuildsForVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[preReleaseVersion.version]": version(),
limit: "200",
});
let url = `${baseUrl}/builds?${params}`;
const builds = [];
while (url) {
const page = await request(url);
builds.push(...(page?.data ?? []));
url = page?.links?.next ?? null;
}
return builds;
};
const configureReleasePolicy = async () => {
await verifyCredentials();
const { storeVersion } = await findStoreVersion();
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
if (storeVersion.attributes?.appStoreState === "READY_FOR_SALE") {
throw new Error(`App Store version ${version()} is already released and cannot change release policy`);
}
await request(`/appStoreVersions/${encodeURIComponent(storeVersion.id)}`, {
const findExactBuild = async () => {
const builds = await allBuildsForVersion();
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
};
const nextBuildNumber = async () => {
await verifyCredentials();
const storeVersionParams = new URLSearchParams({
"filter[app]": appId(),
"filter[platform]": "IOS",
"filter[versionString]": version(),
limit: "10",
});
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
const storeVersion = (storeVersions?.data ?? []).find(
(candidate) => candidate?.attributes?.versionString === version()
);
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
throw new Error(
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
);
}
const builds = await allBuildsForVersion();
const numbers = builds
.map((build) => Number.parseInt(build?.attributes?.version, 10))
.filter((number) => Number.isSafeInteger(number) && number > 0);
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
writeOutput("build_number", next);
console.log(`Next App Store Connect build for ${version()} is ${next}.`);
};
const waitForBuild = async () => {
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
let build = null;
while (Date.now() < deadline) {
build = await findExactBuild();
const state = build?.attributes?.processingState;
if (state === "VALID") return build;
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
console.log(
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
);
await sleep(30_000);
}
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
};
const waitAndDistribute = async () => {
const build = await waitForBuild();
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
const existingLocalization = (localizations?.data ?? [])[0];
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
if (existingLocalization) {
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
method: "PATCH",
body: JSON.stringify({
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
}),
});
} else {
await request("/betaBuildLocalizations", {
method: "POST",
body: JSON.stringify({
data: {
type: "appStoreVersions",
id: storeVersion.id,
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
type: "betaBuildLocalizations",
attributes: { locale: "da-DK", whatsNew },
relationships: { build: { data: { type: "builds", id: build.id } } },
},
}),
});
writeOutput("app_store_version_id", storeVersion.id);
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
return storeVersion.id;
};
const verifyStoreVersion = async () => {
await verifyCredentials();
const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
const includedBuild = included.find(
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
);
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
}
if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`App Store version ${version()} is attached to ${includedBuild.id}, expected ${
environment.EXPECTED_APP_STORE_BUILD_ID
}`
);
}
if (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
throw new Error(
`App Store version ${version()} release type is ${
storeVersion.attributes?.releaseType || "unknown"
}, expected ${EXPECTED_RELEASE_TYPE}`
);
}
writeOutput("app_store_version_id", storeVersion.id);
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
writeOutput("release_type", storeVersion.attributes.releaseType);
logger.log(
`Verified App Store version ${version()} with exact build ${buildNumber()} and ${EXPECTED_RELEASE_TYPE} release policy in ${
storeVersion.attributes?.appStoreState || "unknown state"
}.`
);
return storeVersion;
};
const verifyAvailability = async () => {
await verifyCredentials();
const availability = await request(`/apps/${encodeURIComponent(appId())}/appAvailabilityV2`);
const availabilityId = availability?.data?.id;
if (!availabilityId) throw new Error("App Store availability was not configured");
if (availability?.data?.attributes?.availableInNewTerritories !== false) {
throw new Error("App Store availability must not automatically include new territories");
}
const params = new URLSearchParams({ include: "territory", limit: "200" });
const territories = await collectPages(
`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/${encodeURIComponent(
availabilityId
)}/territoryAvailabilities?${params}`
);
const available = territories.data
.filter((territory) => territory?.attributes?.available === true)
.map((territory) => territory?.relationships?.territory?.data?.id)
.filter(Boolean)
.sort();
if (territories.data.some((territory) => territory?.attributes?.preOrderEnabled === true)) {
throw new Error("App Store preorder must remain disabled for version 1.0.0");
}
if (JSON.stringify(available) !== JSON.stringify(EXPECTED_AVAILABLE_TERRITORIES)) {
throw new Error(
`App Store availability is ${
available.join(", ") || "empty"
}, expected Denmark only (${EXPECTED_AVAILABLE_TERRITORIES.join(", ")})`
);
}
writeOutput("available_territories", available.join(","));
logger.log("Verified Denmark-only App Store availability with preorder disabled.");
return available;
};
return {
request,
verifyCredentials,
allBuildsForVersion,
allStoreVersions,
findExactBuild,
nextBuildNumber,
waitForBuild,
waitAndDistribute,
verifyCandidate,
configureReleasePolicy,
verifyStoreVersion,
verifyAvailability,
token,
};
};
export const selfTestJwt = async () => {
const environment = {};
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
environment.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
environment.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
privateKey.export({ type: "pkcs8", format: "pem" })
).toString("base64");
const individual = JSON.parse(
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
}
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
if (!alreadyAssigned) {
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
method: "POST",
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
});
}
writeOutput("app_store_build_id", build.id);
console.log(
`${
alreadyAssigned ? "Verified" : "Assigned"
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
);
if (individual.sub !== "user" || individual.iss !== undefined) {
throw new Error("Individual API JWT claim test failed");
}
environment.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
const team = JSON.parse(
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
);
if (team.iss !== environment.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined) {
throw new Error("Team API JWT claim test failed");
}
console.log("App Store Connect individual and team JWT claim tests passed.");
};
export const runCli = async (command = argv[2]) => {
const client = createAppStoreConnectClient();
const commands = {
"verify-credentials": client.verifyCredentials,
"next-build-number": client.nextBuildNumber,
"wait-and-distribute": client.waitAndDistribute,
"verify-candidate": client.verifyCandidate,
"configure-release-policy": client.configureReleasePolicy,
"verify-store-version": client.verifyStoreVersion,
"verify-availability": client.verifyAvailability,
"self-test-jwt": selfTestJwt,
};
if (!commands[command]) {
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
const verifyCandidate = async () => {
await verifyCredentials();
const build = await findExactBuild();
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
if (build.attributes?.processingState !== "VALID") {
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
}
await commands[command]();
if (env.EXPECTED_APP_STORE_BUILD_ID && build.id !== env.EXPECTED_APP_STORE_BUILD_ID) {
throw new Error(
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
);
}
writeOutput("app_store_build_id", build.id);
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
};
const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
runCli().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
const verifyStoreVersion = async () => {
const params = new URLSearchParams({
"filter[app]": appId(),
"filter[platform]": "IOS",
"filter[versionString]": version(),
include: "build",
limit: "10",
});
const response = await request(`/appStoreVersions?${params}`);
const storeVersion = (response?.data ?? []).find((candidate) => candidate?.attributes?.versionString === version());
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
const includedBuild = (response?.included ?? []).find(
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
);
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
}
writeOutput("app_store_version_id", storeVersion.id);
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
console.log(
`Verified App Store version ${version()} with exact build ${buildNumber()} in ${
storeVersion.attributes?.appStoreState || "unknown state"
}.`
);
};
const selfTestJwt = async () => {
const original = {
keyId: env.APP_STORE_CONNECT_API_KEY_ID,
issuer: env.APP_STORE_CONNECT_ISSUER_ID,
key: env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64,
};
try {
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
privateKey.export({ type: "pkcs8", format: "pem" })
).toString("base64");
delete env.APP_STORE_CONNECT_ISSUER_ID;
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
if (individual.sub !== "user" || individual.iss !== undefined)
throw new Error("Individual API JWT claim test failed");
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
throw new Error("Team API JWT claim test failed");
console.log("App Store Connect individual and team JWT claim tests passed.");
} finally {
if (original.keyId === undefined) delete env.APP_STORE_CONNECT_API_KEY_ID;
else env.APP_STORE_CONNECT_API_KEY_ID = original.keyId;
if (original.issuer === undefined) delete env.APP_STORE_CONNECT_ISSUER_ID;
else env.APP_STORE_CONNECT_ISSUER_ID = original.issuer;
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
}
};
const commands = {
"verify-credentials": verifyCredentials,
"next-build-number": nextBuildNumber,
"wait-and-distribute": waitAndDistribute,
"verify-candidate": verifyCandidate,
"verify-store-version": verifyStoreVersion,
"self-test-jwt": selfTestJwt,
};
if (!commands[command]) {
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
exit(2);
}
commands[command]().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
-1
View File
@@ -28,7 +28,6 @@ requireText("AndroidManifest.xml", androidManifest, 'android:required="false"');
requireText("android/app/build.gradle", androidBuild, 'applicationId "io.truckwash.twa"');
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
requireText("Info.plist", iosInfoPlist, "NSLocationAlwaysAndWhenInUseUsageDescription");
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
+1 -1
View File
@@ -82,7 +82,7 @@ const checkAndroid = () => {
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
+3 -13
View File
@@ -162,7 +162,7 @@ const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
@@ -222,12 +222,6 @@ const writeStepSummary = (summary) => {
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const writeOutput = (name, value) => {
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
};
const main = async () => {
requireEnvironment();
@@ -249,13 +243,9 @@ const main = async () => {
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
writeOutput("play_edit_id", editId);
writeOutput("version_code", versionCode);
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(
`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}; edit ${editId}; artifact SHA-256 ${env.ANDROID_AAB_SHA256 || "missing"}.`,
);
writeStepSummary(`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}.`);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
+219 -91
View File
@@ -1,61 +1,121 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { loadRepositoryTestGraph } from "../test-graph/load-graph.mjs";
import { DEPENDENCY_CI_ARTIFACT_PREFIX, DEPENDENCY_CI_RESULT_FILE } from "../test-graph/result-schema.mjs";
import { evaluatePlatformResult, normalizeStorePlatform } from "../test-graph/verify-platform-result.mjs";
const roleShards = new Map([
["superuser", 2],
["admin", 2],
["customer", 1],
["subuser", 1],
]);
const execFileAsync = promisify(execFile);
const requiredRoles = ["superuser", "admin", "customer", "subuser"];
export function expectedStoreGateJobs(platform) {
const normalized = String(platform || "")
.trim()
.toLowerCase();
const browser = normalized === "android" ? "Chromium" : normalized === "apple" ? "WebKit" : null;
if (!browser) {
throw new Error(`Unsupported store platform: ${platform || "<empty>"}`);
}
return [...roleShards].flatMap(([role, shardTotal]) =>
Array.from(
{ length: shardTotal },
(_, index) => `E2E-full-${browser}-mobile-${role}-shard-${index + 1}-of-${shardTotal}`
)
);
function normalizePlatform(platform) {
return normalizeStorePlatform(platform);
}
function isNewerJobExecution(candidate, current) {
const candidateAttempt = Number(candidate?.run_attempt || 0);
const currentAttempt = Number(current?.run_attempt || 0);
if (candidateAttempt !== currentAttempt) {
return candidateAttempt > currentAttempt;
}
return Number(candidate?.id || 0) > Number(current?.id || 0);
function platformBrowserLabel(platform) {
return normalizePlatform(platform) === "android" ? "Chromium" : "WebKit";
}
export function evaluateStoreGate(platform, jobs) {
const required = expectedStoreGateJobs(platform);
const latestByName = new Map();
function required(value, name) {
const normalized = String(value || "").trim();
if (!normalized) {
throw new Error(`${name} is required.`);
}
return normalized;
}
for (const job of jobs || []) {
if (job?.name && (!latestByName.has(job.name) || isNewerJobExecution(job, latestByName.get(job.name)))) {
latestByName.set(job.name, job);
function assertFullSha(value) {
const normalized = required(value, "source SHA").toLowerCase();
if (!/^[0-9a-f]{40}$/u.test(normalized)) {
throw new Error("source SHA must be a full lowercase commit SHA.");
}
return normalized;
}
function succeeded(job) {
return job?.status === "completed" && job?.conclusion === "success";
}
function requiredCiFailures(jobs) {
const matches = (jobs || []).filter((job) => job?.name === "Required CI");
if (matches.length !== 1) {
return [`Required CI:${matches.length === 0 ? "missing" : "ambiguous"}`];
}
return succeeded(matches[0])
? []
: [`Required CI:${matches[0].status || "unknown"}/${matches[0].conclusion || "none"}`];
}
export function evaluateDependencyManifest(platform, manifest, jobs = [], graph) {
const evaluated = evaluatePlatformResult(platform, manifest, graph);
const failures = [...requiredCiFailures(jobs), ...evaluated.failures];
return {
lane: evaluated.lanes[0],
lanes: evaluated.lanes,
required: evaluated.required,
failures,
passed: failures.length === 0,
source: "manifest",
};
}
export function expectedLegacyStoreGateJobs(platform) {
const browser = platformBrowserLabel(platform);
return requiredRoles.map((role) => `E2E-full-${browser}-mobile-${role}`);
}
export function evaluateLegacyStoreGate(platform, jobs = []) {
const browser = platformBrowserLabel(platform);
const failures = requiredCiFailures(jobs);
const required = [];
for (const role of requiredRoles) {
const prefix = `E2E-full-${browser}-mobile-${role}`;
const matches = jobs.filter((job) => job?.name === prefix || job?.name?.startsWith(`${prefix}-`));
if (matches.length === 0) {
failures.push(`${prefix}:missing`);
continue;
}
const exact = matches.filter((job) => job.name === prefix);
const shards = matches
.map((job) => ({ job, match: job.name.match(new RegExp(`^${prefix}-(\\d+)of(\\d+)$`, "u")) }))
.filter(({ match }) => match);
if (exact.length === 1 && shards.length === 0) {
required.push(prefix);
if (!succeeded(exact[0])) {
failures.push(`${prefix}:${exact[0].status || "unknown"}/${exact[0].conclusion || "none"}`);
}
continue;
}
if (exact.length > 0 || shards.length !== matches.length) {
failures.push(`${prefix}:ambiguous legacy jobs`);
continue;
}
const shardTotals = new Set(shards.map(({ match }) => Number(match[2])));
if (shardTotals.size !== 1) {
failures.push(`${prefix}:inconsistent shard totals`);
continue;
}
const total = [...shardTotals][0];
const byIndex = new Map(shards.map(({ job, match }) => [Number(match[1]), job]));
for (let shard = 1; shard <= total; shard += 1) {
const job = byIndex.get(shard);
const name = `${prefix}-${shard}of${total}`;
required.push(name);
if (!job) {
failures.push(`${name}:missing`);
} else if (!succeeded(job)) {
failures.push(`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`);
}
}
}
const failures = required.flatMap((name) => {
const job = latestByName.get(name);
if (!job) {
return [`${name}:missing`];
}
if (job.status !== "completed" || job.conclusion !== "success") {
return [`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`];
}
return [];
});
return { required, failures, passed: failures.length === 0 };
return { required, failures, passed: failures.length === 0, source: "legacy" };
}
function parseArgs(argv) {
@@ -75,29 +135,24 @@ function parseArgs(argv) {
return args;
}
function required(value, name) {
const normalized = String(value || "").trim();
if (!normalized) {
throw new Error(`${name} is required.`);
}
return normalized;
}
function createGitHubClient({ apiUrl, repository, token, fetchImpl = fetch }) {
const request = async (path) => {
const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API ${response.status} for ${path}: ${await response.text()}`);
}
return response.json();
const baseUrl = `${required(apiUrl, "GitHub API URL").replace(/\/$/u, "")}/repos/${required(
repository,
"GitHub repository"
)}`;
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${required(token, "GitHub token")}`,
"X-GitHub-Api-Version": "2022-11-28",
};
const request = async (requestPath, binary = false) => {
const response = await fetchImpl(`${baseUrl}${requestPath}`, { headers });
if (!response.ok) {
throw new Error(`GitHub API ${response.status} for ${requestPath}: ${await response.text()}`);
}
return binary ? Buffer.from(await response.arrayBuffer()) : response.json();
};
return { request };
}
@@ -114,16 +169,15 @@ function validateRun(run, { sourceSha, defaultBranch }) {
if (run.status !== "completed") {
throw new Error(`Run ${run.id} is not complete.`);
}
if (run.conclusion !== "success") {
throw new Error(`Run ${run.id} did not succeed (${run.conclusion || "none"}).`);
}
return run;
}
async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) {
if (runId) {
const run = await client.request(`/actions/runs/${encodeURIComponent(runId)}`);
return validateRun(run, { sourceSha, defaultBranch });
return validateRun(await client.request(`/actions/runs/${encodeURIComponent(runId)}`), {
sourceSha,
defaultBranch,
});
}
const query = new URLSearchParams({
@@ -140,10 +194,10 @@ async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) {
return validateRun(run, { sourceSha, defaultBranch });
}
async function readAllAttemptJobs(client, runId) {
async function readLatestAttemptJobs(client, runId) {
const jobs = [];
for (let page = 1; ; page += 1) {
const query = new URLSearchParams({ filter: "all", per_page: "100", page: String(page) });
const query = new URLSearchParams({ filter: "latest", per_page: "100", page: String(page) });
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`);
const pageJobs = response.jobs || [];
jobs.push(...pageJobs);
@@ -153,6 +207,60 @@ async function readAllAttemptJobs(client, runId) {
}
}
async function findManifestArtifact(client, runId, sourceSha) {
const artifacts = [];
for (let page = 1; ; page += 1) {
const query = new URLSearchParams({ per_page: "100", page: String(page) });
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/artifacts?${query}`);
const pageArtifacts = (response.artifacts || []).filter(
(artifact) => !artifact.expired && artifact.name?.startsWith(DEPENDENCY_CI_ARTIFACT_PREFIX)
);
artifacts.push(...pageArtifacts);
if ((response.artifacts || []).length < 100) {
break;
}
}
if (artifacts.length === 0) {
return null;
}
const exactName = `${DEPENDENCY_CI_ARTIFACT_PREFIX}${sourceSha}`;
const candidates = artifacts
.map((artifact) => {
if (artifact.name === exactName) return { artifact, attempt: 0 };
const match = artifact.name.match(new RegExp(`^${exactName}-(\\d+)$`, "u"));
return match ? { artifact, attempt: Number(match[1]) } : null;
})
.filter(Boolean)
.sort((left, right) => right.attempt - left.attempt || Number(right.artifact.id) - Number(left.artifact.id));
if (
candidates.length === 0 ||
candidates.filter((candidate) => candidate.attempt === candidates[0].attempt).length > 1
) {
throw new Error(`Automated Tests run ${runId} has ambiguous dependency result artifacts.`);
}
return candidates[0].artifact;
}
async function downloadManifest(client, artifact, extractManifest) {
const zip = await client.request(`/actions/artifacts/${encodeURIComponent(artifact.id)}/zip`, true);
if (extractManifest) {
return extractManifest(zip, artifact);
}
const directory = await mkdtemp(path.join(tmpdir(), "dependency-ci-results-"));
const zipPath = path.join(directory, "results.zip");
try {
await writeFile(zipPath, zip, { mode: 0o600 });
const { stdout } = await execFileAsync("unzip", ["-p", zipPath, DEPENDENCY_CI_RESULT_FILE], {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
return JSON.parse(stdout);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
export async function verifyStoreTestGate({
platform,
sourceSha,
@@ -162,31 +270,48 @@ export async function verifyStoreTestGate({
repository,
token,
fetchImpl,
extractManifest,
manifestPath,
}) {
const normalizedSha = required(sourceSha, "source SHA").toLowerCase();
if (!/^[0-9a-f]{40}$/u.test(normalizedSha)) {
throw new Error("source SHA must be a full lowercase commit SHA.");
}
const client = createGitHubClient({
apiUrl: required(apiUrl, "GitHub API URL").replace(/\/$/u, ""),
repository: required(repository, "GitHub repository"),
token: required(token, "GitHub token"),
fetchImpl,
});
const normalizedPlatform = normalizePlatform(platform);
const normalizedSha = assertFullSha(sourceSha);
const client = createGitHubClient({ apiUrl, repository, token, fetchImpl });
const run = await resolveTestRun(client, {
runId: String(runId || "").trim(),
sourceSha: normalizedSha,
defaultBranch: required(defaultBranch, "default branch"),
});
const jobs = await readAllAttemptJobs(client, run.id);
const result = evaluateStoreGate(platform, jobs);
const jobs = await readLatestAttemptJobs(client, run.id);
const { graph } = await loadRepositoryTestGraph(process.cwd());
if (!result.passed) {
throw new Error(`${platform} store test gate failed for run ${run.id}: ${result.failures.join(", ")}`);
let manifest = null;
let artifact = null;
if (manifestPath) {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} else {
artifact = await findManifestArtifact(client, run.id, normalizedSha);
if (artifact) {
manifest = await downloadManifest(client, artifact, extractManifest);
}
}
return { ...result, runId: run.id, sourceSha: normalizedSha };
if (manifest && manifest.sourceSha !== normalizedSha) {
throw new Error(`Dependency result manifest is for ${manifest.sourceSha || "<unknown>"}, not ${normalizedSha}.`);
}
const result = manifest
? evaluateDependencyManifest(normalizedPlatform, manifest, jobs, graph)
: evaluateLegacyStoreGate(normalizedPlatform, jobs);
if (!result.passed) {
throw new Error(
`${normalizedPlatform} store test gate failed for Automated Tests run ${run.id}: ${result.failures.join(", ")}`
);
}
return {
...result,
runId: run.id,
sourceSha: normalizedSha,
artifactName: artifact?.name || null,
};
}
async function main() {
@@ -199,9 +324,12 @@ async function main() {
apiUrl: process.env.GITHUB_API_URL,
repository: process.env.GITHUB_REPOSITORY,
token: process.env.GH_TOKEN,
manifestPath: args.manifest,
});
console.log(
`${args.platform} store gate passed for Automated Tests run ${result.runId}: ${result.required.join(", ")}`
`${args.platform} store gate passed for Automated Tests run ${result.runId} using ${
result.source
}: ${result.required.join(", ")}`
);
}

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