Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e9f253da | ||
|
|
eb47f82aeb | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a | ||
|
|
cc7d5cf4ff | ||
|
|
8d9f1e6fde | ||
|
|
d61d91b6ae | ||
|
|
ba92bc4cb6 | ||
|
|
c353bfac3a | ||
|
|
9024a5a1fa | ||
|
|
35e4bba859 | ||
|
|
50535dbed0 | ||
|
|
f5ccb2a2a9 | ||
|
|
29ef97a86c | ||
|
|
8d646ce770 | ||
|
|
f63e51c96e | ||
|
|
4810e113f3 | ||
|
|
82c95d32c0 | ||
|
|
d4f92cd259 | ||
|
|
6b44835347 | ||
|
|
683196ddf5 | ||
|
|
1548ae8cd5 | ||
|
|
fd8b896c56 | ||
|
|
2e95608b05 | ||
|
|
d393c8c175 | ||
|
|
668e240e12 | ||
|
|
0831d37d3c | ||
|
|
60dff74507 | ||
|
|
f995440098 | ||
|
|
7a5ee1aa5b | ||
|
|
3639527b0e | ||
|
|
f4816124c2 | ||
|
|
664b50d4ef | ||
|
|
1768f5a38e | ||
|
|
f2453ba0a3 | ||
|
|
7b769eeb24 | ||
|
|
391e0c8a6f | ||
|
|
0149e06c42 | ||
|
|
832b362254 | ||
|
|
eee9ba1c13 | ||
|
|
aaecffbdfa | ||
|
|
917c10c1d3 | ||
|
|
14a0d65a01 | ||
|
|
468d436d3e | ||
|
|
c85a82b9ac | ||
|
|
3de5215b5e | ||
|
|
5ffd471a45 | ||
|
|
1da6fbd1c4 | ||
|
|
5204536f92 | ||
|
|
7a84cd9162 | ||
|
|
4f26ddd2cc | ||
|
|
b7859d4ede | ||
|
|
cbdca71e3f | ||
|
|
fd31609cb3 | ||
|
|
009519ee62 | ||
|
|
b6f9b5a3a4 | ||
|
|
7387a2b56e | ||
|
|
9ff103d4d0 | ||
|
|
32418b42a0 | ||
|
|
bf2208e77b | ||
|
|
5702d45bc6 | ||
|
|
42352b4c2d | ||
|
|
729416e5ef | ||
|
|
41b3be926a | ||
|
|
fc67e7cf0b | ||
|
|
9b3c06fc6f | ||
|
|
f0e3c4812b | ||
|
|
4c7d8c6f2e | ||
|
|
74dd8e3691 | ||
|
|
7782d93fe9 | ||
|
|
fd26b0ee81 | ||
|
|
a01902356d | ||
|
|
0692cb3aea | ||
|
|
de3f067372 | ||
|
|
a0c11e4bb7 | ||
|
|
71e7fac555 |
@@ -8,6 +8,8 @@ on:
|
|||||||
- "fastlane/**"
|
- "fastlane/**"
|
||||||
- "ios/**"
|
- "ios/**"
|
||||||
- "scripts/mobile/**"
|
- "scripts/mobile/**"
|
||||||
|
- "tests/node/app-store-connect.test.mjs"
|
||||||
|
- ".github/workflows/app-store-readiness.yml"
|
||||||
- "Gemfile*"
|
- "Gemfile*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -20,21 +22,22 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
|
name: App Store Readiness
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Preserve a generated lock for review
|
- name: Preserve a generated lock for review
|
||||||
if: steps.fastlane-lock.outcome == 'failure'
|
if: steps.fastlane-lock.outcome == 'failure'
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: generated-fastlane-lock
|
name: generated-fastlane-lock
|
||||||
path: Gemfile.lock
|
path: Gemfile.lock
|
||||||
@@ -61,12 +64,25 @@ jobs:
|
|||||||
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Validate App Store metadata and available assets
|
- name: Install the pinned Fastlane dependency graph
|
||||||
run: node scripts/mobile/validate-app-store.mjs
|
run: bundle install --jobs 4 --retry 3
|
||||||
|
|
||||||
|
- name: Validate strict App Store metadata and candidate assets
|
||||||
|
run: node scripts/mobile/validate-app-store.mjs --strict
|
||||||
|
|
||||||
|
- name: Validate native mobile permissions
|
||||||
|
run: node scripts/mobile/check-permissions.mjs
|
||||||
|
|
||||||
|
- name: Test App Store Connect automation
|
||||||
|
run: node --test tests/node/app-store-connect.test.mjs
|
||||||
|
|
||||||
|
- name: Validate Fastlane configuration
|
||||||
|
run: bundle exec fastlane lanes
|
||||||
|
|
||||||
- name: Validate JavaScript syntax
|
- name: Validate JavaScript syntax
|
||||||
run: |
|
run: |
|
||||||
node --check scripts/mobile/validate-app-store.mjs
|
node --check scripts/mobile/validate-app-store.mjs
|
||||||
node --check scripts/mobile/app-store-connect.mjs
|
node --check scripts/mobile/app-store-connect.mjs
|
||||||
node --check scripts/mobile/create-ios-release-manifest.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
|
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
# v5.0.1
|
# v5.0.1
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Qodana
|
- name: Qodana
|
||||||
# v2026.1.3
|
# v2026.1.3
|
||||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
|
||||||
with:
|
with:
|
||||||
use-caches: true
|
use-caches: true
|
||||||
cache-default-branch-only: true
|
cache-default-branch-only: true
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
name: cPanel Root Audit and Restore
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
mode:
|
|
||||||
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
|
|
||||||
required: true
|
|
||||||
default: audit
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- audit
|
|
||||||
- restore
|
|
||||||
recovery:
|
|
||||||
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
state_token:
|
|
||||||
description: Exact 64-character audit-metadata state token reported by the audit.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
confirmation:
|
|
||||||
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: frontend-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit-or-restore:
|
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
|
||||||
timeout-minutes: 10
|
|
||||||
environment:
|
|
||||||
name: frontend-production
|
|
||||||
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v5
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Audit cPanel primary webroot
|
|
||||||
if: inputs.mode == 'audit'
|
|
||||||
id: audit
|
|
||||||
run: node scripts/release/cpanel-root.mjs audit
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --use-system-ca
|
|
||||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
|
||||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
|
||||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
|
||||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
|
||||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
|
||||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
|
||||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
|
|
||||||
|
|
||||||
- name: Validate restore inputs
|
|
||||||
if: inputs.mode == 'restore'
|
|
||||||
env:
|
|
||||||
RECOVERY: ${{ inputs.recovery }}
|
|
||||||
STATE_TOKEN: ${{ inputs.state_token }}
|
|
||||||
CONFIRMATION: ${{ inputs.confirmation }}
|
|
||||||
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
|
||||||
run: |
|
|
||||||
test -n "$RECOVERY"
|
|
||||||
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
|
|
||||||
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
|
|
||||||
|
|
||||||
- name: Restore retained cPanel webroot
|
|
||||||
if: inputs.mode == 'restore'
|
|
||||||
run: node scripts/release/cpanel-root.mjs restore
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --use-system-ca
|
|
||||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
|
||||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
|
||||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
|
||||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
|
||||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
|
||||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
|
||||||
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
|
|
||||||
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
|
|
||||||
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
|
|
||||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
|
|
||||||
|
|
||||||
- name: Upload cPanel root report
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
|
|
||||||
path: output/cpanel-root
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 30
|
|
||||||
@@ -25,14 +25,14 @@ jobs:
|
|||||||
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
|
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tagged source
|
- name: Checkout tagged source
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
NODE
|
NODE
|
||||||
|
|
||||||
promote:
|
promote:
|
||||||
name: Sync storefront and prepare manual review
|
name: Sync and verify App Store candidate
|
||||||
needs: resolve
|
needs: resolve
|
||||||
if: needs.resolve.outputs.enabled == 'true'
|
if: needs.resolve.outputs.enabled == 'true'
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
@@ -119,19 +119,19 @@ jobs:
|
|||||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout exact candidate source
|
- name: Checkout exact candidate source
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Ruby and pinned Fastlane
|
- name: Setup Ruby and pinned Fastlane
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -153,22 +153,33 @@ jobs:
|
|||||||
- name: Sync metadata and screenshots without App Review submission
|
- name: Sync metadata and screenshots without App Review submission
|
||||||
run: bundle exec fastlane ios prepare_candidate
|
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
|
- name: Read back exact App Store candidate
|
||||||
id: readback
|
id: readback
|
||||||
run: node scripts/mobile/app-store-connect.mjs verify-store-version
|
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
|
- name: Write candidate handoff
|
||||||
env:
|
env:
|
||||||
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
||||||
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
|
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: |
|
run: |
|
||||||
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$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 "- 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 state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$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 "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
|
echo "- App Review submission remains manual; Apple will release automatically after approval." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
disabled:
|
disabled:
|
||||||
name: Promotion disabled
|
name: Promotion disabled
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ jobs:
|
|||||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout same-repository history
|
- name: Checkout same-repository history
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -136,7 +136,7 @@ jobs:
|
|||||||
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout resolved source
|
- name: Checkout resolved source
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.resolve.outputs.source_sha }}
|
ref: ${{ needs.resolve.outputs.source_sha }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
@@ -163,7 +163,7 @@ jobs:
|
|||||||
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -596,7 +596,7 @@ jobs:
|
|||||||
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Upload device-debug artifact
|
- name: Upload device-debug artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
||||||
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
current: ${{ steps.resolve.outputs.current }}
|
current: ${{ steps.resolve.outputs.current }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository history
|
- name: Checkout repository history
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -82,6 +82,14 @@ jobs:
|
|||||||
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
|
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Require green WebKit mobile tests before App Store upload
|
||||||
|
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
|
||||||
|
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
deliver:
|
deliver:
|
||||||
name: Sign, upload, process, and distribute
|
name: Sign, upload, process, and distribute
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -103,7 +111,7 @@ jobs:
|
|||||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout verified source
|
- name: Checkout verified source
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
@@ -134,13 +142,19 @@ jobs:
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||||
const checks = {
|
const checks = {
|
||||||
schema: proof.schemaVersion === 1,
|
schema: proof.schemaVersion === 2,
|
||||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
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",
|
publicGate: proof.livePublicGate === "passed",
|
||||||
credentialedGate: proof.liveCredentialedGate === "passed",
|
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||||
managerGate: proof.releaseManagerGate === "passed",
|
managerGate: proof.releaseManagerGate === "passed",
|
||||||
serverVersion: proof.serverVersionUpdated === true,
|
serverVersion: proof.serverVersionUpdated === true,
|
||||||
|
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||||
};
|
};
|
||||||
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
||||||
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
||||||
@@ -159,13 +173,13 @@ jobs:
|
|||||||
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Setup Ruby and pinned Fastlane
|
- name: Setup Ruby and pinned Fastlane
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -388,7 +402,7 @@ jobs:
|
|||||||
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
|
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
|
||||||
|
|
||||||
- name: Upload signed IPA
|
- name: Upload signed IPA
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
|
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
|
||||||
path: output/ios-release/*.ipa
|
path: output/ios-release/*.ipa
|
||||||
@@ -396,7 +410,7 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload release manifest, dSYM, and checksums
|
- name: Upload release manifest, dSYM, and checksums
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
|
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -30,53 +30,42 @@ on:
|
|||||||
description: Google Play release status for manual dispatches
|
description: Google Play release status for manual dispatches
|
||||||
required: false
|
required: false
|
||||||
type: choice
|
type: choice
|
||||||
default: completed
|
default: inProgress
|
||||||
options:
|
options:
|
||||||
- completed
|
|
||||||
- draft
|
|
||||||
- inProgress
|
- inProgress
|
||||||
|
- draft
|
||||||
- halted
|
- halted
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "mobile-v*"
|
- "mobile-v*"
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- Automated Tests
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
|
group: android-store-artifacts-${{ github.ref_name || github.run_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
android:
|
android:
|
||||||
name: Android AAB and Play upload
|
name: Android AAB and Play upload
|
||||||
if: >
|
|
||||||
github.event_name != 'workflow_run' ||
|
|
||||||
(github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_branch == github.event.repository.default_branch)
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: mobile-store-production
|
environment: mobile-store-production
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
||||||
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
|
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
|
||||||
|
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
|
||||||
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
|
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
|
||||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
|
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
|
||||||
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
|
PLAY_STORE_USER_FRACTION: "0.01"
|
||||||
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
ref: ${{ github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Guard current master release
|
- name: Guard current master release
|
||||||
@@ -84,50 +73,59 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
EXPECTED_SHA: ${{ github.sha }}
|
||||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
current=true
|
current=true
|
||||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
latest_sha="$(curl --fail --silent --show-error --location \
|
||||||
latest_sha="$(curl --fail --silent --show-error --location \
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
-H "Authorization: Bearer $GH_TOKEN" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Accept: application/vnd.github+json" \
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
exit 1
|
||||||
exit 1
|
fi
|
||||||
fi
|
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||||
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
current=false
|
||||||
current=false
|
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||||
else
|
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
|
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||||
fi
|
fi
|
||||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
- name: Require green Chromium mobile tests before Play upload
|
||||||
|
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||||
|
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||||
|
TEST_WORKFLOW_RUN_ID: ""
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
@@ -203,17 +201,64 @@ jobs:
|
|||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
||||||
|
|
||||||
|
- name: Record immutable Android artifact proof
|
||||||
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
|
id: artifact-proof
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
artifact_sha256="$(sha256sum "$ANDROID_AAB_PATH" | awk '{print $1}')"
|
||||||
|
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||||||
|
echo "ANDROID_AAB_SHA256=$artifact_sha256" >> "$GITHUB_ENV"
|
||||||
|
echo "sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android AAB SHA-256: \`$artifact_sha256\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Upload Android artifact
|
- name: Upload Android artifact
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||||
path: ${{ env.ANDROID_AAB_PATH }}
|
path: ${{ env.ANDROID_AAB_PATH }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Recheck live master before Play upload
|
||||||
|
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||||
|
env:
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
live_master_sha="$(curl --fail --silent --show-error --location \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||||
|
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
|
||||||
|
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
|
||||||
|
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
- name: Upload Android App Bundle to Google Play
|
- name: Upload Android App Bundle to Google Play
|
||||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||||
|
id: play-upload
|
||||||
env:
|
env:
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||||
run: npm run mobile:android:play-upload
|
run: npm run mobile:android:play-upload
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
name: Frontend Release Recovery
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
action:
|
||||||
|
description: Verify the active release or roll back before verification
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- reverify
|
||||||
|
- rollback
|
||||||
|
source_sha:
|
||||||
|
description: Exact 40-character commit SHA expected after recovery
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
rollback_target:
|
||||||
|
description: Immutable releases/.../dist target; required for rollback
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frontend-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
recover:
|
||||||
|
name: Protected production recovery
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: frontend-production
|
||||||
|
timeout-minutes: 35
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
steps:
|
||||||
|
- name: Validate exact recovery target
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RECOVERY_ACTION: ${{ inputs.action }}
|
||||||
|
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||||
|
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "$RECOVERY_SHA" =~ ^[a-f0-9]{40}$ ]]
|
||||||
|
if [[ "$RECOVERY_ACTION" == "rollback" ]]; then
|
||||||
|
[[ "$RECOVERY_TARGET" =~ ^releases/[A-Za-z0-9._-]+/dist$ ]]
|
||||||
|
else
|
||||||
|
[[ -z "$RECOVERY_TARGET" ]]
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout exact recovery source
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
ref: ${{ inputs.source_sha }}
|
||||||
|
|
||||||
|
- name: Authorize source from successful release proof
|
||||||
|
id: authorize
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
RECOVERY_ACTION: ${{ inputs.action }}
|
||||||
|
RECOVERY_SHA: ${{ inputs.source_sha }}
|
||||||
|
RECOVERY_TARGET: ${{ inputs.rollback_target }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
runs="$RUNNER_TEMP/recovery-runs.json"
|
||||||
|
artifacts="$RUNNER_TEMP/recovery-artifacts.json"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||||
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?head_sha=$RECOVERY_SHA&status=success&per_page=20" \
|
||||||
|
> "$runs"
|
||||||
|
release_run_id="$(jq -r '[.workflow_runs[] | select(.event == "workflow_run")] | first | .id // empty' "$runs")"
|
||||||
|
[[ "$release_run_id" =~ ^[0-9]+$ ]]
|
||||||
|
artifact_name="frontend-release-proof-$RECOVERY_SHA"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||||
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$release_run_id/artifacts?name=$artifact_name&per_page=20" \
|
||||||
|
> "$artifacts"
|
||||||
|
artifact_id="$(jq -r '[.artifacts[] | select(.expired == false)] | first | .id // empty' "$artifacts")"
|
||||||
|
[[ "$artifact_id" =~ ^[0-9]+$ ]]
|
||||||
|
mkdir -p "$RUNNER_TEMP/recovery-proof"
|
||||||
|
curl --fail --silent --show-error --location \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||||
|
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" \
|
||||||
|
-o "$RUNNER_TEMP/recovery-proof.zip"
|
||||||
|
unzip -q "$RUNNER_TEMP/recovery-proof.zip" -d "$RUNNER_TEMP/recovery-proof"
|
||||||
|
PROOF_PATH="$RUNNER_TEMP/recovery-proof/frontend-release-proof.json" \
|
||||||
|
RELEASE_RUN_ID="$release_run_id" node <<'NODE'
|
||||||
|
const { appendFileSync, readFileSync } = require("node:fs");
|
||||||
|
const proof = JSON.parse(readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||||
|
const sha = process.env.RECOVERY_SHA;
|
||||||
|
const target = process.env.RECOVERY_TARGET;
|
||||||
|
const expectedPrefix = `releases/${sha}-`;
|
||||||
|
const valid = proof.schemaVersion === 2
|
||||||
|
&& proof.repository === process.env.GITHUB_REPOSITORY
|
||||||
|
&& proof.sha === sha
|
||||||
|
&& proof.sourceSha === sha
|
||||||
|
&& proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID
|
||||||
|
&& proof.verificationState === "verified"
|
||||||
|
&& proof.livePublicGate === "passed"
|
||||||
|
&& ["passed", "not-configured"].includes(proof.liveCredentialedGate)
|
||||||
|
&& proof.releaseManagerGate === "passed"
|
||||||
|
&& proof.serverVersionUpdated === true
|
||||||
|
&& proof.serverVersionReadBack === "passed"
|
||||||
|
&& /^[1-9][0-9]*-[1-9][0-9]*$/.test(String(proof.buildId || ""))
|
||||||
|
&& typeof proof.activeTarget === "string"
|
||||||
|
&& proof.activeTarget.startsWith(expectedPrefix)
|
||||||
|
&& proof.activeTarget.endsWith("/dist");
|
||||||
|
if (!valid) throw new Error("Recovery source does not have valid exact-release proof.");
|
||||||
|
if (process.env.RECOVERY_ACTION === "rollback" && target !== proof.activeTarget) {
|
||||||
|
throw new Error("Rollback target does not match the verified release proof.");
|
||||||
|
}
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `verified_target=${proof.activeTarget}\n`);
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `build_id=${proof.buildId}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Capture current immutable target
|
||||||
|
id: current
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
run: |
|
||||||
|
node --input-type=module <<'NODE'
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||||
|
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Active manifest returned HTTP ${response.status}.`);
|
||||||
|
const manifest = await response.json();
|
||||||
|
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||||
|
const build = String(manifest.build_id || "");
|
||||||
|
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[A-Za-z0-9._-]{1,180}$/.test(build)) {
|
||||||
|
throw new Error("Active manifest has invalid release identity.");
|
||||||
|
}
|
||||||
|
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(build)) {
|
||||||
|
throw new Error("Active manifest build id is not a release run identity.");
|
||||||
|
}
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `previous_sha=${sha}\nprevious_build_id=${build}\nprevious_target=releases/${sha}-${build}/dist\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Install secure FTP client without system changes
|
||||||
|
run: |
|
||||||
|
if command -v lftp >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
package_root="$RUNNER_TEMP/lftp-package"
|
||||||
|
mkdir -p "$package_root"
|
||||||
|
(
|
||||||
|
cd "$package_root"
|
||||||
|
apt-get download lftp
|
||||||
|
dpkg-deb --extract ./lftp_*.deb root
|
||||||
|
)
|
||||||
|
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
|
- name: Roll back atomically
|
||||||
|
if: inputs.action == 'rollback'
|
||||||
|
id: rollback
|
||||||
|
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
RELEASE_ROLLBACK_TARGET: ${{ inputs.rollback_target }}
|
||||||
|
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||||
|
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||||
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
|
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||||
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
|
||||||
|
- name: Verify active manifest matches authorized release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA: ${{ inputs.source_sha }}
|
||||||
|
EXPECTED_TARGET: ${{ steps.authorize.outputs.verified_target }}
|
||||||
|
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
run: |
|
||||||
|
node --input-type=module <<'NODE'
|
||||||
|
const deadline = Date.now() + 300_000;
|
||||||
|
let actual = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
|
||||||
|
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const manifest = await response.json();
|
||||||
|
const manifestSha = String(manifest.commit_sha || "").toLowerCase();
|
||||||
|
actual = `releases/${manifestSha}-${String(manifest.build_id || "")}/dist`;
|
||||||
|
if (manifestSha === process.env.EXPECTED_SHA && actual === process.env.EXPECTED_TARGET) process.exit(0);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||||
|
}
|
||||||
|
throw new Error(`Active release identity did not converge to the authorized target; observed ${actual || "unavailable"}.`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Public live verification
|
||||||
|
run: npm run test:e2e:live:public
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
|
- name: Credentialed live verification
|
||||||
|
run: npm run test:e2e:live:roles
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||||
|
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||||
|
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||||
|
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||||
|
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||||
|
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
|
- name: Record verified server version
|
||||||
|
run: npm run release:update-server-version
|
||||||
|
env:
|
||||||
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_VERSION: ${{ inputs.source_sha }}
|
||||||
|
RELEASE_BUILD_ID: ${{ steps.authorize.outputs.build_id }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
|
||||||
|
- name: Publish recovery audit
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: frontend-release-recovery-${{ inputs.source_sha }}-${{ github.run_id }}
|
||||||
|
path: |
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Restore pre-recovery target after downstream failure
|
||||||
|
if: >-
|
||||||
|
failure() && inputs.action == 'rollback'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node scripts/release/deploy-cpanel.mjs --rollback
|
||||||
|
node --input-type=module <<'NODE'
|
||||||
|
const deadline = Date.now() + 300_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const response = await fetch(new URL(`release-manifest.json?restore=${Date.now()}`, process.env.PRODUCTION_FRONTEND_URL), {
|
||||||
|
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const manifest = await response.json();
|
||||||
|
const sha = String(manifest.commit_sha || "").toLowerCase();
|
||||||
|
const target = `releases/${sha}-${String(manifest.build_id || "")}/dist`;
|
||||||
|
if (sha === process.env.RELEASE_VERSION && target === process.env.RELEASE_ROLLBACK_TARGET) process.exit(0);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||||
|
}
|
||||||
|
throw new Error("Failed to restore and verify the pre-recovery target.");
|
||||||
|
NODE
|
||||||
|
npm run release:update-server-version
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
RELEASE_ROLLBACK_TARGET: ${{ steps.current.outputs.previous_target }}
|
||||||
|
RELEASE_VERSION: ${{ steps.current.outputs.previous_sha }}
|
||||||
|
RELEASE_BUILD_ID: ${{ steps.current.outputs.previous_build_id }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||||
|
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||||
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
|
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||||
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
github.event.workflow_run.head_branch == 'master' &&
|
github.event.workflow_run.head_branch == 'master' &&
|
||||||
github.event.workflow_run.head_repository.full_name == github.repository
|
github.event.workflow_run.head_repository.full_name == github.repository
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||||
@@ -37,10 +37,11 @@ jobs:
|
|||||||
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||||
release_id: ${{ steps.package.outputs.release_id }}
|
release_id: ${{ steps.package.outputs.release_id }}
|
||||||
|
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check release commit is current
|
- name: Check release commit is current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -60,7 +61,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout tested commit
|
- name: Checkout tested commit
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -68,7 +69,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -111,6 +112,7 @@ jobs:
|
|||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:prod
|
run: npm run test:e2e:prod
|
||||||
env:
|
env:
|
||||||
|
PLAYWRIGHT_PROD_PREBUILT: "1"
|
||||||
PLAYWRIGHT_PROD_WEBKIT: "0"
|
PLAYWRIGHT_PROD_WEBKIT: "0"
|
||||||
|
|
||||||
- name: Confirm production gate did not mutate dist
|
- name: Confirm production gate did not mutate dist
|
||||||
@@ -147,7 +149,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload release package
|
- name: Upload release package
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.package-names.outputs.artifact_name }}
|
name: ${{ steps.package-names.outputs.artifact_name }}
|
||||||
path: |
|
path: |
|
||||||
@@ -160,7 +162,7 @@ jobs:
|
|||||||
deploy-frontend-production:
|
deploy-frontend-production:
|
||||||
needs: build-release
|
needs: build-release
|
||||||
if: needs.build-release.outputs.current == 'true'
|
if: needs.build-release.outputs.current == 'true'
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
environment:
|
environment:
|
||||||
name: frontend-production
|
name: frontend-production
|
||||||
@@ -183,14 +185,14 @@ jobs:
|
|||||||
RELEASE_POLL_INTERVAL_SECONDS: 5
|
RELEASE_POLL_INTERVAL_SECONDS: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tested commit
|
- name: Checkout tested commit
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -217,7 +219,7 @@ jobs:
|
|||||||
run: node scripts/install-playwright-browsers.mjs chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Download validated release package
|
- name: Download validated release package
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
@@ -243,7 +245,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check release commit is still current
|
- name: Check release commit is still current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
script: |
|
script: |
|
||||||
@@ -273,28 +275,45 @@ jobs:
|
|||||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
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' }}
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
- name: Public live Playwright gate
|
- name: Public live Playwright gate
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
id: public_live
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: npm run test:e2e:live:public
|
run: npm run test:e2e:live:public
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
- name: Credentialed live Playwright gate
|
- name: Detect credentialed live gate configuration
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
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
|
timeout-minutes: 15
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
|
||||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||||
@@ -302,26 +321,10 @@ jobs:
|
|||||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
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
|
- name: Record Release Manager gate
|
||||||
|
id: release_manager
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
timeout-minutes: 5
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||||
@@ -329,7 +332,7 @@ jobs:
|
|||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}"
|
||||||
env:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
@@ -341,7 +344,9 @@ jobs:
|
|||||||
run: npm run release:update-server-version
|
run: npm run release:update-server-version
|
||||||
env:
|
env:
|
||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
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: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
|
||||||
- name: Create verified frontend release proof
|
- name: Create verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
@@ -356,38 +361,92 @@ jobs:
|
|||||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||||
return process.env[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 = {
|
const proof = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
releaseId: required("RELEASE_ID"),
|
||||||
|
sha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||||
|
archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(),
|
||||||
|
activeTarget: required("RELEASE_ACTIVE_TARGET"),
|
||||||
|
rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null,
|
||||||
|
verificationState: "verified",
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
repository: required("GITHUB_REPOSITORY"),
|
repository: required("GITHUB_REPOSITORY"),
|
||||||
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||||
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
||||||
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
||||||
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
||||||
buildId: required("RELEASE_BUILD_ID"),
|
buildId: required("RELEASE_BUILD_ID"),
|
||||||
livePublicGate: "passed",
|
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||||
liveCredentialedGate: "passed",
|
liveCredentialedGate: credentialedGate(),
|
||||||
releaseManagerGate: "passed",
|
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||||
serverVersionUpdated: true,
|
serverVersionUpdated: true,
|
||||||
|
serverVersionReadBack: "passed",
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
||||||
NODE
|
NODE
|
||||||
env:
|
env:
|
||||||
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
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
|
- name: Publish verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
|
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
|
||||||
path: output/frontend-release-proof/frontend-release-proof.json
|
path: output/frontend-release-proof/frontend-release-proof.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
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
|
- name: Upload Playwright report
|
||||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: output/playwright
|
path: output/playwright
|
||||||
|
|||||||
@@ -48,15 +48,17 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.run_id }}
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||||
|
|
||||||
# Repository variables used as CI runner and credit controls:
|
# Repository variables used as CI runner and credit controls:
|
||||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
# - FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL: full-suite parallelism while PR E2E runs beside it.
|
||||||
|
# GitHub-hosted target: ["ubuntu-24.04"], with PR parallelism 10, concurrent full parallelism 26,
|
||||||
|
# and standalone scheduled full parallelism 36. This keeps the workflow peak at 36 hosted jobs.
|
||||||
jobs:
|
jobs:
|
||||||
format-tests:
|
format-tests:
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||||
@@ -78,10 +80,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -94,10 +96,15 @@ jobs:
|
|||||||
- name: Check frontend test formatting
|
- name: Check frontend test formatting
|
||||||
run: npm run format:tests:check
|
run: npm run format:tests:check
|
||||||
|
|
||||||
build-and-unit:
|
quality-checks:
|
||||||
needs: format-tests
|
name: Quality-${{ matrix.check }}
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 5
|
||||||
|
matrix:
|
||||||
|
check: [lint, i18n, build, unit-fast, unit-serial]
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||||
@@ -115,29 +122,68 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Lint
|
- name: Run quality check
|
||||||
run: npm run lint
|
shell: bash
|
||||||
|
|
||||||
- name: Check i18n source consistency
|
|
||||||
run: npm run i18n:v2:check
|
|
||||||
|
|
||||||
- name: Build sanity check
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: npm run test:unit
|
|
||||||
env:
|
env:
|
||||||
VITEST_BATCH_SIZE: 5
|
MATRIX_CHECK: ${{ matrix.check }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "$MATRIX_CHECK" in
|
||||||
|
lint)
|
||||||
|
npm run lint
|
||||||
|
;;
|
||||||
|
i18n)
|
||||||
|
npm run i18n:v2:check
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
npm run build
|
||||||
|
;;
|
||||||
|
unit-fast)
|
||||||
|
npm run text:check-encoding
|
||||||
|
npm run test:unit:fast
|
||||||
|
;;
|
||||||
|
unit-serial)
|
||||||
|
VITEST_BATCH_SIZE=5 npm run test:unit:serial
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported quality check: $MATRIX_CHECK" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
build-and-unit:
|
||||||
|
if: ${{ always() }}
|
||||||
|
name: Build and unit summary
|
||||||
|
needs: [format-tests, quality-checks]
|
||||||
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Verify quality jobs succeeded
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||||
|
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
failed=0
|
||||||
|
for required_job in FORMAT_TESTS_RESULT QUALITY_CHECKS_RESULT; do
|
||||||
|
result="${!required_job:-missing}"
|
||||||
|
if [[ "$result" != "success" ]]; then
|
||||||
|
echo "${required_job}=${result}" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit "$failed"
|
||||||
|
|
||||||
e2e-targeted:
|
e2e-targeted:
|
||||||
if: >
|
if: >
|
||||||
@@ -177,10 +223,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -301,7 +347,7 @@ jobs:
|
|||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-targeted-${{ matrix.project }}
|
name: playwright-report-targeted-${{ matrix.project }}
|
||||||
path: |
|
path: |
|
||||||
@@ -329,7 +375,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||||
matrix:
|
matrix:
|
||||||
suite: [core, changed]
|
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
|
||||||
project: [chromium-desktop, chromium-mobile]
|
project: [chromium-desktop, chromium-mobile]
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
@@ -353,7 +399,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -390,7 +436,7 @@ jobs:
|
|||||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -405,8 +451,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
case "$MATRIX_SUITE" in
|
case "$MATRIX_SUITE" in
|
||||||
core) suite_offset=0 ;;
|
changed-1-of-2) suite_offset=0 ;;
|
||||||
changed) suite_offset=10 ;;
|
changed-2-of-2) suite_offset=10 ;;
|
||||||
|
smoke) suite_offset=20 ;;
|
||||||
|
pr) suite_offset=30 ;;
|
||||||
|
ct) suite_offset=40 ;;
|
||||||
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
case "$MATRIX_PROJECT" in
|
case "$MATRIX_PROJECT" in
|
||||||
@@ -414,7 +463,7 @@ jobs:
|
|||||||
chromium-mobile) project_offset=2 ;;
|
chromium-mobile) project_offset=2 ;;
|
||||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
port_seed=$((21000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||||
mkdir -p "$lock_root"
|
mkdir -p "$lock_root"
|
||||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||||
@@ -485,13 +534,27 @@ jobs:
|
|||||||
}
|
}
|
||||||
install_dependencies
|
install_dependencies
|
||||||
ulimit -n 16384 || true
|
ulimit -n 16384 || true
|
||||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
case "$MATRIX_SUITE" in
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
ct)
|
||||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
;;
|
||||||
else
|
smoke)
|
||||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||||
fi
|
;;
|
||||||
|
pr)
|
||||||
|
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||||
|
;;
|
||||||
|
changed-1-of-2)
|
||||||
|
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=1/2 --pass-with-no-tests
|
||||||
|
;;
|
||||||
|
changed-2-of-2)
|
||||||
|
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=2/2 --pass-with-no-tests
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Runner diagnostics after Playwright failure
|
- name: Runner diagnostics after Playwright failure
|
||||||
@@ -502,7 +565,7 @@ jobs:
|
|||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
path: |
|
path: |
|
||||||
@@ -539,26 +602,38 @@ jobs:
|
|||||||
e2e-full:
|
e2e-full:
|
||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||||
needs.build-and-unit.result == 'success' &&
|
needs.build-and-unit.result == 'success' &&
|
||||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
|
||||||
(
|
(
|
||||||
github.event_name != 'workflow_dispatch' ||
|
github.event_name != 'workflow_dispatch' ||
|
||||||
inputs.mode == 'full' ||
|
inputs.mode == 'full' ||
|
||||||
needs.e2e-targeted.result == 'success'
|
needs.e2e-targeted.result == 'success'
|
||||||
)
|
)
|
||||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
needs: [build-and-unit, e2e-targeted]
|
||||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') > 0 && github.event_name == 'schedule' && fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') || fromJSON(vars.FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL || '26') }}
|
||||||
matrix:
|
matrix:
|
||||||
browser: [chromium, webkit, firefox]
|
|
||||||
device: [mobile, desktop, tablet]
|
device: [mobile, desktop, tablet]
|
||||||
role: [superuser, admin, customer, subuser]
|
role: [superuser, admin, customer, subuser]
|
||||||
|
shard_index: [1, 2]
|
||||||
|
browser: [chromium, webkit, firefox]
|
||||||
|
exclude:
|
||||||
|
- role: customer
|
||||||
|
shard_index: 2
|
||||||
|
- role: subuser
|
||||||
|
shard_index: 2
|
||||||
include:
|
include:
|
||||||
- browser: chromium
|
- browser: chromium
|
||||||
browser_label: Chromium
|
browser_label: Chromium
|
||||||
@@ -570,7 +645,7 @@ jobs:
|
|||||||
browser_label: Firefox
|
browser_label: Firefox
|
||||||
browser_install: firefox
|
browser_install: firefox
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||||
PLAYWRIGHT_WORKERS: 1
|
PLAYWRIGHT_WORKERS: 1
|
||||||
PLAYWRIGHT_VIDEO_MODE: off
|
PLAYWRIGHT_VIDEO_MODE: off
|
||||||
@@ -591,10 +666,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -604,6 +679,8 @@ jobs:
|
|||||||
MATRIX_ROLE: ${{ matrix.role }}
|
MATRIX_ROLE: ${{ matrix.role }}
|
||||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||||
MATRIX_DEVICE: ${{ matrix.device }}
|
MATRIX_DEVICE: ${{ matrix.device }}
|
||||||
|
MATRIX_SHARD_INDEX: ${{ matrix.shard_index }}
|
||||||
|
MATRIX_SHARD_TOTAL: ${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -626,7 +703,12 @@ jobs:
|
|||||||
tablet) device_offset=3 ;;
|
tablet) device_offset=3 ;;
|
||||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
case "$MATRIX_SHARD_INDEX" in
|
||||||
|
1) shard_offset=0 ;;
|
||||||
|
2) shard_offset=400 ;;
|
||||||
|
*) echo "Unsupported Playwright shard index: $MATRIX_SHARD_INDEX" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
port_seed=$((22000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset + shard_offset))
|
||||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||||
mkdir -p "$lock_root"
|
mkdir -p "$lock_root"
|
||||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||||
@@ -660,8 +742,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
mkdir -p output/playwright
|
mkdir -p output/playwright
|
||||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
|
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" -- "${docker_cmd[@]}"
|
||||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" \
|
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" \
|
||||||
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||||
--volume "$PWD:/source:ro" \
|
--volume "$PWD:/source:ro" \
|
||||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||||
@@ -676,6 +758,8 @@ jobs:
|
|||||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||||
|
--env MATRIX_SHARD_INDEX="$MATRIX_SHARD_INDEX" \
|
||||||
|
--env MATRIX_SHARD_TOTAL="$MATRIX_SHARD_TOTAL" \
|
||||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -696,23 +780,52 @@ jobs:
|
|||||||
}
|
}
|
||||||
install_dependencies
|
install_dependencies
|
||||||
ulimit -n 16384 || true
|
ulimit -n 16384 || true
|
||||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE" --shard="$MATRIX_SHARD_INDEX/$MATRIX_SHARD_TOTAL"
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Runner diagnostics after Playwright failure
|
- name: Runner diagnostics after Playwright failure
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}"
|
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}/shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() || cancelled()
|
if: failure() || cancelled()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||||
path: |
|
path: |
|
||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
|
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
|
||||||
|
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}.txt
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
full-e2e-summary:
|
||||||
|
if: >
|
||||||
|
always() &&
|
||||||
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||||
|
name: Full E2E summary
|
||||||
|
needs: [e2e-full]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Verify full E2E succeeded
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FULL_E2E_RESULT: ${{ needs.e2e-full.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$FULL_E2E_RESULT" != "success" ]]; then
|
||||||
|
echo "E2E_FULL_RESULT=${FULL_E2E_RESULT:-missing}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
ruby ">= 3.2", "< 3.5"
|
ruby ">= 3.2", "< 3.5"
|
||||||
gem "fastlane", "2.229.1"
|
gem "fastlane", "2.237.0"
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ GEM
|
|||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
|
benchmark (0.5.0)
|
||||||
bigdecimal (4.1.2)
|
bigdecimal (4.1.2)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
@@ -41,7 +42,8 @@ GEM
|
|||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.0)
|
excon (1.6.0)
|
||||||
|
logger
|
||||||
faraday (1.10.6)
|
faraday (1.10.6)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
@@ -71,41 +73,45 @@ GEM
|
|||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.1)
|
||||||
fastlane (2.229.1)
|
fastlane (2.237.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1.2)
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.9.0, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
base64 (~> 0.2.0)
|
base64 (~> 0.2)
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
benchmark (>= 0.1.0)
|
||||||
|
bundler (>= 2.4.0, < 5.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
csv (~> 3.3)
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 2.0.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
faraday_middleware (~> 1.0)
|
faraday_middleware (~> 1.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.0.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.10.3, < 4)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multi_json (~> 1.12)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
mutex_m (~> 0.3.0)
|
mutex_m (~> 0.3)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
nkf (~> 0.2.0)
|
nkf (~> 0.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
@@ -120,41 +126,46 @@ GEM
|
|||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.105.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.28.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.18.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.65.0)
|
||||||
google-apis-storage_v1 (0.31.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
|
||||||
google-cloud-core (1.9.0)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.2.2)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
base64 (~> 0.2)
|
||||||
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.7.0)
|
google-cloud-errors (1.7.0)
|
||||||
google-cloud-storage (1.47.0)
|
google-cloud-storage (1.62.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.31.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
google-logging-utils (0.2.0)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
googleauth (1.17.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
multi_json (~> 1.11)
|
google-cloud-env (~> 2.2)
|
||||||
|
google-logging-utils (~> 0.1)
|
||||||
|
jwt (>= 1.4, < 4.0)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
|
pstore (~> 0.1)
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.8)
|
||||||
@@ -163,7 +174,7 @@ GEM
|
|||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.21.1)
|
json (2.21.1)
|
||||||
jwt (2.10.3)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
@@ -173,10 +184,12 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
|
pstore (0.2.1)
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.5)
|
||||||
rake (13.4.2)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
@@ -226,7 +239,7 @@ PLATFORMS
|
|||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (= 2.229.1)
|
fastlane (= 2.237.0)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.12p206
|
ruby 3.3.12p206
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
|||||||
server: {
|
server: {
|
||||||
androidScheme: "https",
|
androidScheme: "https",
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
StatusBar: {
|
||||||
|
overlaysWebView: false,
|
||||||
|
style: "LIGHT",
|
||||||
|
backgroundColor: "#FFFFFFFF",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# AGENT MCP SMOKE
|
||||||
|
|
||||||
|
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||||
|
Safe to close.
|
||||||
@@ -1,31 +1,32 @@
|
|||||||
# Apple App Store Release Runbook
|
# Apple App Store Release Runbook
|
||||||
|
|
||||||
This is the operating runbook for the public iOS application and its signed
|
This is the operating runbook for the public iOS application and its signed
|
||||||
GitHub Actions delivery. Public review submission and public release remain
|
GitHub Actions delivery. Public review submission remains a human action in App
|
||||||
human actions in App Store Connect.
|
Store Connect; the approved version releases automatically after Apple approval.
|
||||||
|
|
||||||
## Storefront record
|
## Storefront record
|
||||||
|
|
||||||
Create or reconcile one App Store Connect record:
|
Create or reconcile one App Store Connect record:
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
| --- | --- |
|
| ---------------- | ------------------------------------- |
|
||||||
| Name | Truck Wash Kundeportal |
|
| Name | Truck Wash |
|
||||||
| Bundle ID | `io.truckwash.app` |
|
| Bundle ID | `io.truckwash.app` |
|
||||||
| SKU | `truckwash-ios` |
|
| SKU | `truckwash-ios` |
|
||||||
| Primary language | Danish |
|
| Primary language | Danish |
|
||||||
| Category | Business |
|
| Category | Business |
|
||||||
| Price | Free |
|
| Price | Free |
|
||||||
| Availability | Denmark only |
|
| Availability | Denmark only |
|
||||||
| Support URL | `https://truckwash.io/support` |
|
| Support URL | `https://truckwash.io/support` |
|
||||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||||
| Marketing URL | `https://truckwash.io/` |
|
| Marketing URL | `https://truckwash.io/` |
|
||||||
| Release | Manual after approval |
|
| Release | Automatically after approval |
|
||||||
|
|
||||||
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
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;
|
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||||
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||||
been tested deliberately.
|
been tested deliberately. Do not enable preorder or phased release for version
|
||||||
|
`1.0.0`, and disable automatic availability in newly added territories.
|
||||||
|
|
||||||
The Account Holder or Admin must complete these console-only items before the
|
The Account Holder or Admin must complete these console-only items before the
|
||||||
first candidate:
|
first candidate:
|
||||||
@@ -95,7 +96,9 @@ Configure two GitHub environments:
|
|||||||
Private repositories on the Team plan cannot rely on environment required
|
Private repositories on the Team plan cannot rely on environment required
|
||||||
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
||||||
limited to release managers. Manual App Review submission is the final human
|
limited to release managers. Manual App Review submission is the final human
|
||||||
approval.
|
approval. App Store Connect API readback must show `AFTER_APPROVAL`, Denmark
|
||||||
|
(`DNK`) as the only available territory, preorder disabled, and automatic
|
||||||
|
future territories disabled.
|
||||||
|
|
||||||
Environment secrets:
|
Environment secrets:
|
||||||
|
|
||||||
@@ -179,11 +182,14 @@ delivery automatically. Stale or proofless releases do not sign or upload.
|
|||||||
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
|
4. `iOS App Store Candidate` locates the release manifest for that exact SHA,
|
||||||
verifies the exact processed App Store build, enforces complete screenshots,
|
verifies the exact processed App Store build, enforces complete screenshots,
|
||||||
synchronizes Danish metadata, attaches the existing build, and reads it back.
|
synchronizes Danish metadata, attaches the existing build, and reads it back.
|
||||||
It does not rebuild, submit for review, or release publicly.
|
It also writes and verifies automatic release after approval, then verifies
|
||||||
|
Denmark-only availability and no preorder. It does not rebuild or submit for
|
||||||
|
review.
|
||||||
5. In App Store Connect, review the rendered product page, review account,
|
5. In App Store Connect, review the rendered product page, review account,
|
||||||
privacy/export/age answers, and candidate build. Submit manually.
|
privacy/export/age answers, and candidate build. Submit manually.
|
||||||
6. Release the first Denmark version manually after approval. Use phased release
|
6. Submit version `1.0.0` for review. Apple releases it automatically after
|
||||||
for later updates unless there is a reason not to.
|
approval. Do not use phased release for `1.0.0`; use phased release for later
|
||||||
|
updates unless there is a reason not to.
|
||||||
7. Merge the next `ios/release.json` version bump before further delivery after
|
7. Merge the next `ios/release.json` version bump before further delivery after
|
||||||
Apple closes the released version to new builds.
|
Apple closes the released version to new builds.
|
||||||
|
|
||||||
|
|||||||
@@ -46,33 +46,22 @@ Add these environment **secrets**:
|
|||||||
- `PRODUCTION_FTP_PASSWORD`
|
- `PRODUCTION_FTP_PASSWORD`
|
||||||
- `PRODUCTION_FTP_PATH`
|
- `PRODUCTION_FTP_PATH`
|
||||||
- `PRODUCTION_ACTIVATION_KEY`
|
- `PRODUCTION_ACTIVATION_KEY`
|
||||||
- `PRODUCTION_CPANEL_USER`
|
|
||||||
- `PRODUCTION_CPANEL_API_TOKEN`
|
|
||||||
|
|
||||||
The API `.env` contains legacy values under the first four names, but production
|
The API `.env` contains legacy values under the first four names, but production
|
||||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||||
deployment unchanged.
|
deployment unchanged.
|
||||||
|
|
||||||
The cPanel token is separate from the FTP password. Create it in cPanel under
|
The hosted release path deliberately does not call the remote cPanel API.
|
||||||
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
|
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||||
uses the token for fail-closed directory and release-state inspection. It does
|
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||||
not use legacy Fileman mutation calls to replace symlinks: on this server those
|
activator, exact inventory comparison, and public manifest verification.
|
||||||
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**:
|
Add these environment **variables**:
|
||||||
|
|
||||||
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
|
|
||||||
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
|
|
||||||
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||||
|
|
||||||
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
|
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||||
fallback. The cPanel URL and path deliberately fail closed when absent. The
|
|
||||||
production environment must keep the explicit
|
|
||||||
`https://server.red-block.com:2083` cPanel origin: the public origin serves
|
|
||||||
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
|
|
||||||
cPanel JSON API.
|
|
||||||
|
|
||||||
### Create the dedicated FTP credentials
|
### Create the dedicated FTP credentials
|
||||||
|
|
||||||
@@ -88,53 +77,26 @@ cPanel JSON API.
|
|||||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||||
these frontend-only credentials back into the API `.env`.
|
these frontend-only credentials back into the API `.env`.
|
||||||
|
|
||||||
### Create the missing cPanel credentials
|
|
||||||
|
|
||||||
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
|
|
||||||
separately; do not reuse the FTP password as an API token.
|
|
||||||
|
|
||||||
1. Sign in to the cPanel account that owns the frontend deployment root.
|
|
||||||
2. Record the exact cPanel account username shown in **General Information**.
|
|
||||||
Add it to the `frontend-production` environment as the
|
|
||||||
`PRODUCTION_CPANEL_USER` secret.
|
|
||||||
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
|
|
||||||
hosting provider to enable API Tokens in WHM Feature Manager.
|
|
||||||
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
|
|
||||||
expiration date that matches the team's rotation policy. Expiration cannot
|
|
||||||
be edited later, so add a reminder before that date.
|
|
||||||
5. Click **Create**, copy the token immediately, and add it to the same GitHub
|
|
||||||
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
|
|
||||||
again after leaving the page.
|
|
||||||
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
|
|
||||||
the GitHub secret has been saved.
|
|
||||||
7. Before merging, run the deployment 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**.
|
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||||
Use **Add secret** for credentials and **Add variable** for the two URLs and the
|
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||||
cPanel deployment path.
|
|
||||||
Environment values are available only to the deployment job that names this
|
Environment values are available only to the deployment job that names this
|
||||||
environment, and configured protection rules are evaluated before its secrets
|
environment, and configured protection rules are evaluated before its secrets
|
||||||
are released.
|
are released.
|
||||||
|
|
||||||
The existing live-test, Release Manager, and server-version secrets used by
|
The existing live-test, Release Manager, and server-version secrets used by
|
||||||
`release.yml` must remain configured. The self-hosted deployment job installs
|
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||||
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
|
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||||
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
|
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||||
|
and `sha256sum`.
|
||||||
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
||||||
`sha256sum` for the account-scoped activator.
|
`sha256sum` for the account-scoped activator.
|
||||||
|
|
||||||
## cPanel layout and one-time bootstrap
|
## cPanel layout and one-time bootstrap
|
||||||
|
|
||||||
The production FTP account is jailed directly to the deployment root, so its
|
The production FTP account is jailed directly to the deployment root, so its
|
||||||
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
|
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||||
relative to the cPanel account home. The helper creates this layout below it:
|
`frontend-deployments` directory below the account home. The helper creates
|
||||||
|
this layout below it:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
archives/
|
archives/
|
||||||
@@ -146,7 +108,7 @@ current -> releases/<release-id>/dist
|
|||||||
```
|
```
|
||||||
|
|
||||||
The domain's document root must resolve to
|
The domain's document root must resolve to
|
||||||
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
|
`<cPanel account home>/frontend-deployments/current`, not to the deployment
|
||||||
root itself. This stable document-root path is what makes replacing `current`
|
root itself. This stable document-root path is what makes replacing `current`
|
||||||
atomic: every HTTP request resolves either the complete old release or the
|
atomic: every HTTP request resolves either the complete old release or the
|
||||||
complete new release, never a partly uploaded directory.
|
complete new release, never a partly uploaded directory.
|
||||||
@@ -172,9 +134,8 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
|
|||||||
listing.
|
listing.
|
||||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||||
8. Test the cPanel token against the exact host and port. The workflow performs
|
8. The server-side activator, rather than the hosted runner, validates that
|
||||||
read-only state checks and refuses deployment if `current` or the captured
|
`current` and the captured rollback release exist before every switch.
|
||||||
rollback release is missing.
|
|
||||||
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
||||||
hexadecimal form in the protected `frontend-production` environment as
|
hexadecimal form in the protected `frontend-production` environment as
|
||||||
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
||||||
@@ -205,47 +166,33 @@ first automated run into an unreviewed production cutover.
|
|||||||
|
|
||||||
### Auditing or restoring the primary webroot
|
### Auditing or restoring the primary webroot
|
||||||
|
|
||||||
Use the protected **cPanel Root Audit and Restore** workflow if the primary
|
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||||
domain starts showing a directory index or returns 404 for files that cPanel
|
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||||
lists in `public_html`. The `audit` mode is read-only: it reports the exact
|
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||||
`public_html` entry, whether the internal `current` link can serve the required
|
runner label would risk sending production cPanel secrets to a self-hosted
|
||||||
release files, domain document roots, and retained recovery candidates without
|
runner, so that workflow has been removed.
|
||||||
printing the cPanel token. API2 does not expose a documented symlink-target
|
|
||||||
field, so the audit deliberately reports `rootTargetVerified: false` instead
|
|
||||||
of claiming that an arbitrary `public_html` link follows `current`; the live
|
|
||||||
HTTP checks remain the source of truth for service health. The audit fails
|
|
||||||
closed if any domain record lacks an identity or document root, and restore is
|
|
||||||
blocked while an addon or subdomain is rooted below `public_html`.
|
|
||||||
|
|
||||||
If the regression followed the one-time webroot exchange and both the active
|
If the primary domain starts showing a directory index or returns 404 for files
|
||||||
webroot and selected recovery are physical directories, select `restore`
|
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||||
and copy one exact recovery entry from the audit, including the retained
|
or the hosting provider. Before replacing anything, confirm the exact
|
||||||
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
|
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||||
The workflow requires the
|
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||||
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
|
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||||
exact token string from that audit. The token is an optimistic-concurrency
|
replace the root while an addon or subdomain document root is nested below it.
|
||||||
guard over the cPanel metadata visible to the audit; it is not a content hash
|
Restore only a verified physical directory, retain the displaced webroot, and
|
||||||
or a substitute for validating the selected recovery. Restore also rejects an
|
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||||
unreadable physical directory. Restore also rejects symbolic-link roots and
|
releases do not depend on remote cPanel API access.
|
||||||
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
|
## Caching and compatibility
|
||||||
|
|
||||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||||
service-worker control files always revalidate. The deployer retains at least
|
service-worker control files always revalidate. The deployer retains every
|
||||||
the active and rollback releases and keeps five recent release directories by
|
immutable release while hosted runners cannot query reliable cPanel
|
||||||
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
|
modification metadata. Each successful run reports that retention cleanup is
|
||||||
falls outside that validated retention set, its directory and matching ZIP and
|
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||||
checksum are removed over FTPS. Cleanup failure is reported without rolling
|
releases and their matching archives; never remove the active or recorded
|
||||||
back an otherwise verified deployment.
|
rollback target.
|
||||||
|
|
||||||
Because the document root switches as one symlink, an already-loaded page may
|
Because the document root switches as one symlink, an already-loaded page may
|
||||||
still request an asset from its previous release after activation. The current
|
still request an asset from its previous release after activation. The current
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
|
||||||
|
"Tankrengøring") only appear when the user is already on the matching view
|
||||||
|
tab. On the "Alle" tab the chips never show, even when a customer actually
|
||||||
|
belongs to several categories.
|
||||||
|
|
||||||
|
We want every chip to render on every subpage whenever the customer belongs
|
||||||
|
to that category — independent of which view tab is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Root cause (already confirmed by investigation)
|
||||||
|
|
||||||
|
### Front-end rendering path
|
||||||
|
|
||||||
|
* `Right.vue` (line ~300+) declares view tabs and fetches
|
||||||
|
`/superuser/invoicing/period` with the corresponding `periodView` query
|
||||||
|
param (`all`, `invoice_per_order`, …).
|
||||||
|
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
|
||||||
|
(including `all`). It reads the active bucket via
|
||||||
|
`view.variables.sharedVariables.value.types[componentName]`.
|
||||||
|
* For each customer card it mounts
|
||||||
|
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
|
||||||
|
`list_views_with_customer`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const list_views_with_customer = computed(() => {
|
||||||
|
const matched = view_keys.value.filter((view_key) => {
|
||||||
|
if (view_key === 'all') return false;
|
||||||
|
const view_type = sharedTypes.value[view_key];
|
||||||
|
return view_type && view_type.some(
|
||||||
|
(v: any) => v.customer_number === props.customer.customer_number,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
…
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
It only treats a customer as belonging to a view if
|
||||||
|
`types[view_key]` contains an entry with the same `customer_number`.
|
||||||
|
|
||||||
|
### Back-end paging path
|
||||||
|
|
||||||
|
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
|
||||||
|
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
|
||||||
|
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
|
||||||
|
all) holds full customer cards.
|
||||||
|
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
|
||||||
|
truncates the response so that ONLY the bucket matching `$periodView`
|
||||||
|
carries the full card data; every other bucket becomes `[]`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||||
|
if ($isAllLimit) {
|
||||||
|
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||||
|
} else {
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* The frontend then iterates over the (empty) non-active buckets and finds
|
||||||
|
no customer entries → no chip is rendered → the bug.
|
||||||
|
|
||||||
|
### Why the existing e2e test missed it
|
||||||
|
|
||||||
|
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
|
||||||
|
~864) returns FULL customer data for every type in the mock payload.
|
||||||
|
Because the mock already mimics the "pre-fix" backend behaviour (every type
|
||||||
|
populated), the chip-rendering path is exercised even when the real backend
|
||||||
|
strips the data. Updating the mock to mirror the new, real backend shape
|
||||||
|
gives us an end-to-end safety net.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fix strategy
|
||||||
|
|
||||||
|
We want one round trip, no N+1 calls, and a payload that stays bounded.
|
||||||
|
|
||||||
|
**Approach: lightweight membership entries**
|
||||||
|
|
||||||
|
Extend `applyPeriodPagination` so that, after pagination, every non-active
|
||||||
|
view bucket is populated with "membership only" entries — each entry is
|
||||||
|
just `{ customer_number }` so the frontend can resolve membership via the
|
||||||
|
existing `view_type.some(v => v.customer_number === …)` check.
|
||||||
|
|
||||||
|
* The **active view** continues to carry full customer cards (transactions,
|
||||||
|
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
|
||||||
|
it.
|
||||||
|
* **Every other view** carries a `{customer_number: N}` array (one per
|
||||||
|
matching customer after all filters / search / sort / pagination). No
|
||||||
|
transactions or auxiliary fields — keeping the payload small.
|
||||||
|
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
|
||||||
|
`type_counts` (already computed before pagination) keeps the totals per
|
||||||
|
view, so tab counters remain correct.
|
||||||
|
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
|
||||||
|
stores the full `periodResult` verbatim, so cached responses naturally
|
||||||
|
retain the new lightweight entries.
|
||||||
|
|
||||||
|
### Why this option wins
|
||||||
|
|
||||||
|
| Approach | Network | Payload | Schema change | UX consistency |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
|
||||||
|
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
|
||||||
|
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Concrete code changes
|
||||||
|
|
||||||
|
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
|
||||||
|
|
||||||
|
In `applyPeriodPagination(...)` (around line 730-742), after the active
|
||||||
|
bucket is sliced, populate every non-active bucket with lightweight
|
||||||
|
memberships derived from the already-filtered/searched/sorted `$types`
|
||||||
|
arrays:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Existing pagination of the active bucket
|
||||||
|
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||||
|
if ($isAllLimit) {
|
||||||
|
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||||
|
} else {
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: lightweight memberships for every non-active view so the front-end
|
||||||
|
// can render category chips regardless of which tab is active.
|
||||||
|
foreach ($types as $typeName => $customers) {
|
||||||
|
if ($typeName === $periodView) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
||||||
|
is_array($customers) ? $customers : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new helper:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Return a minimal `{customer_number: N}` array per customer so the
|
||||||
|
* front-end can determine which non-active view buckets the customer
|
||||||
|
* belongs to without us shipping full transaction/queue data.
|
||||||
|
*
|
||||||
|
* Filters, searches, sort and visibility rules have already been applied
|
||||||
|
* to `$customers` by the time we run, so we just de-duplicate and emit.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $customers
|
||||||
|
* @return array<int, array{customer_number: int, membership_only: true}>
|
||||||
|
*/
|
||||||
|
private static function summarizePeriodCustomerMemberships(array $customers): array
|
||||||
|
{
|
||||||
|
$memberships = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (!is_array($customer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$customerNumber = (int) ($customer['customer_number'] ?? 0);
|
||||||
|
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$customerNumber] = true;
|
||||||
|
$memberships[] = [
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'membership_only' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $memberships;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* We deduplicate on `customer_number` so a customer appearing twice in a
|
||||||
|
bucket (rare but possible — multiple PO transactions for the same
|
||||||
|
customer in `invoice_per_order`) still only emits one membership.
|
||||||
|
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
|
||||||
|
so consumers that iterate `Object.keys(types)` still see every view
|
||||||
|
even when the filtered list ends up empty.
|
||||||
|
* The active bucket's structure is **unchanged** — the front-end
|
||||||
|
`customersInCurrentView` and `list_views_with_customer` paths continue to
|
||||||
|
work as before.
|
||||||
|
* `type_counts` and `type_totals` are computed before pagination (see
|
||||||
|
`summarizePeriodTypes`) and remain authoritative for tab counters.
|
||||||
|
|
||||||
|
### 3.2 OpenAPI specs
|
||||||
|
|
||||||
|
Both repositories carry a copy of the schema and must stay in lock-step.
|
||||||
|
|
||||||
|
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
|
||||||
|
|
||||||
|
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
|
||||||
|
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
|
||||||
|
`transactions`, `invoice_collections`. Membership entries don't carry those
|
||||||
|
fields, so we need to relax the `required` constraint on non-active buckets
|
||||||
|
and document the new shape.
|
||||||
|
|
||||||
|
Add a new sibling component:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
InvoicingPeriodCustomerMembership:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Lightweight customer marker returned for every non-active view bucket.
|
||||||
|
Used only by the front-end to render category chips (e.g. "Faktura pr.
|
||||||
|
ordre") regardless of which tab is active. Full transaction / queue
|
||||||
|
data is intentionally omitted; see InvoicingPeriodCustomer for the
|
||||||
|
shape returned for the active bucket.
|
||||||
|
additionalProperties: false
|
||||||
|
required: [customer_number, membership_only]
|
||||||
|
properties:
|
||||||
|
customer_number:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
membership_only:
|
||||||
|
type: true
|
||||||
|
enum: [true]
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `InvoicingPeriod` schema, switch the `types` property from
|
||||||
|
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
types:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||||
|
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||||
|
discriminator:
|
||||||
|
propertyName: membership_only
|
||||||
|
```
|
||||||
|
|
||||||
|
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
|
||||||
|
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
|
||||||
|
longer `required` (they remain documented in `properties`). The active
|
||||||
|
bucket still emits them, but the union makes the membership shape valid.
|
||||||
|
|
||||||
|
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||||
|
|
||||||
|
After the backend fix, the chip rendering logic in
|
||||||
|
`list_views_with_customer` will start working on every subpage. To keep
|
||||||
|
performance bounded when buckets grow large, we also turn the membership
|
||||||
|
arrays into `Set<number>` lookups via a small `computed`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const membershipIndexes = computed(() => {
|
||||||
|
const result: Record<string, Set<number>> = {};
|
||||||
|
for (const view_key of view_keys.value) {
|
||||||
|
if (view_key === 'all') continue;
|
||||||
|
const view_type = sharedTypes.value[view_key];
|
||||||
|
if (!Array.isArray(view_type)) {
|
||||||
|
result[view_key] = new Set<number>();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[view_key] = new Set(
|
||||||
|
view_type
|
||||||
|
.map((entry) => Number(entry?.customer_number ?? 0))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const list_views_with_customer = computed(() => {
|
||||||
|
const matched = view_keys.value.filter((view_key) => {
|
||||||
|
if (view_key === 'all') return false;
|
||||||
|
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
|
||||||
|
});
|
||||||
|
…
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavioural impact:
|
||||||
|
|
||||||
|
* Same chip set as today, now visible on every subpage including `Alle`.
|
||||||
|
* Lookup is O(1) per (view × customer) instead of O(bucket size).
|
||||||
|
* Defensive against the lightweight entries (no `customer_name`,
|
||||||
|
`transactions`, etc. fields) — the chip only needs the view's friendly
|
||||||
|
name, which already comes from `view.computed.getViewFriendlyName(...)`.
|
||||||
|
|
||||||
|
### 3.4 Front-end — e2e mock
|
||||||
|
|
||||||
|
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
|
||||||
|
(line ~864) currently mocks every bucket as fully populated. Update the
|
||||||
|
mock so that:
|
||||||
|
|
||||||
|
* The **active** bucket (whichever the page requested) carries full
|
||||||
|
customer cards (unchanged).
|
||||||
|
* Every **other** bucket carries membership-only entries
|
||||||
|
(`{customer_number, membership_only: true}`).
|
||||||
|
|
||||||
|
This mirrors the real backend so the existing chip-stacking test
|
||||||
|
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
|
||||||
|
guards the membership path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tests to add / update
|
||||||
|
|
||||||
|
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||||
|
|
||||||
|
Existing assertion at line 292:
|
||||||
|
|
||||||
|
```php
|
||||||
|
expect($result['period']['types']['fixed_pricing'])->toBe([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
…becomes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
expect($result['period']['types']['fixed_pricing'])
|
||||||
|
->toBe(array_map(
|
||||||
|
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
|
||||||
|
[1001], // the test fixture's other-bucket membership
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new test that, given a period with two customers in `all` and one
|
||||||
|
in `invoice_per_order`, paging `periodView=all` yields:
|
||||||
|
|
||||||
|
* `types.all` — full customer cards (existing behaviour preserved)
|
||||||
|
* `types.invoice_per_order` — one lightweight membership entry
|
||||||
|
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
|
||||||
|
matching customers, so nothing to emit)
|
||||||
|
|
||||||
|
Add a search-aware test: searching for "Beta" while paging
|
||||||
|
`periodView=all` must surface the lightweight membership only for
|
||||||
|
customers that pass the filter, mirroring the active bucket.
|
||||||
|
|
||||||
|
Add a flag-tab-aware test: the `red` flag filter must propagate to the
|
||||||
|
membership arrays just as it does to `type_counts`.
|
||||||
|
|
||||||
|
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
|
||||||
|
|
||||||
|
Add a focused Vitest spec
|
||||||
|
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
|
||||||
|
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
|
||||||
|
`sharedVariables.value.types` containing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
all: [...full cards],
|
||||||
|
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
|
||||||
|
fixed_pricing: [],
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
|
||||||
|
other categories the stubbed customer is a member of), independent of
|
||||||
|
which view tab is "active" in the stub.
|
||||||
|
|
||||||
|
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
|
||||||
|
|
||||||
|
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
|
||||||
|
membership-only entries for non-active buckets — matching the real
|
||||||
|
backend contract.
|
||||||
|
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
|
||||||
|
that on the `Alle` tab the rendered customer cards include the
|
||||||
|
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
|
||||||
|
* Add a new spec scenario:
|
||||||
|
`Given: Alle tab with mixed customers across categories. When: page
|
||||||
|
loads. Then: every customer card shows chips for every category it
|
||||||
|
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
|
||||||
|
|
||||||
|
### 4.4 OpenAPI consistency
|
||||||
|
|
||||||
|
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
|
||||||
|
script in `scripts/sync-ai-workflow.mjs` to verify that the two
|
||||||
|
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
|
||||||
|
it so future schema edits surface in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verification steps (manual + automated)
|
||||||
|
|
||||||
|
### 5.1 Manual smoke test (in dev)
|
||||||
|
|
||||||
|
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
|
||||||
|
bring up the API stack.
|
||||||
|
2. `cd /workspace/pleno-vue && npm run dev`.
|
||||||
|
3. Sign in as a superuser that owns customers spanning multiple categories
|
||||||
|
(fixed_pricing + invoice_per_order, for instance).
|
||||||
|
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
|
||||||
|
5. On the **Alle** tab confirm every customer card shows every chip it
|
||||||
|
qualifies for.
|
||||||
|
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
|
||||||
|
render (sans the active tab's own chip).
|
||||||
|
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
|
||||||
|
8. Apply the search box; chips should update with the filter.
|
||||||
|
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
|
||||||
|
flagged subset.
|
||||||
|
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
|
||||||
|
consistent across pages.
|
||||||
|
11. Reload the page — chips must persist from the cache layer
|
||||||
|
(`setCachedPeriodPage`) and not flash empty.
|
||||||
|
|
||||||
|
### 5.2 Automated
|
||||||
|
|
||||||
|
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
|
||||||
|
inside `php1` container per `scripts/setup.sh`).
|
||||||
|
* Backend static analysis: `composer analyse` (phpstan).
|
||||||
|
* Backend rector dry-run: `composer rector:dry-run`.
|
||||||
|
* Front-end unit: `npm run test:unit`.
|
||||||
|
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
|
||||||
|
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
|
||||||
|
* Front-end lint: `npm run lint:strict`.
|
||||||
|
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
|
||||||
|
|
||||||
|
### 5.3 CI checks to watch
|
||||||
|
|
||||||
|
* `.github/workflows/tests.yml` (api) — PHP matrix
|
||||||
|
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
|
||||||
|
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
|
||||||
|
* `.github/workflows/code_quality.yml` — Qodana scan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Roll-out plan
|
||||||
|
|
||||||
|
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
|
||||||
|
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
|
||||||
|
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
|
||||||
|
3. OpenAPI updates (3.2) in both repos.
|
||||||
|
4. Frontend attribute component (3.3) — add the `Set` index, keep the
|
||||||
|
array `.some()` fallback for back-compat.
|
||||||
|
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
|
||||||
|
6. Run the full verification suite (5.2) locally before pushing.
|
||||||
|
7. Open the PR; CI should turn green; Qodana should not flag the new
|
||||||
|
memberships (they are deliberate additive fields).
|
||||||
|
8. After merge, monitor the period page in staging for payload size and
|
||||||
|
chip rendering parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risk assessment
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
|
||||||
|
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
|
||||||
|
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
|
||||||
|
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
|
||||||
|
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files touched (summary)
|
||||||
|
|
||||||
|
**Backend (`/workspace/api`):**
|
||||||
|
|
||||||
|
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
|
||||||
|
`summarizePeriodCustomerMemberships`, populate non-active buckets.
|
||||||
|
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||||
|
— relax line 292, add membership / search / flag-tab tests.
|
||||||
|
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
|
||||||
|
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
|
||||||
|
|
||||||
|
**Front-end (`/workspace/pleno-vue`):**
|
||||||
|
|
||||||
|
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||||
|
— `Set`-based membership index.
|
||||||
|
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
|
||||||
|
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
|
||||||
|
shape, extended chip-stacking assertions.
|
||||||
|
* `openapi.yaml` — mirror backend schema edits.
|
||||||
@@ -9,10 +9,15 @@ must never publish an Android production artifact.
|
|||||||
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
|
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
|
||||||
`io.truckwash.twa` and supports:
|
`io.truckwash.twa` and supports:
|
||||||
|
|
||||||
- Automatic delivery after successful current-master `Automated Tests`.
|
- Automatic delivery after successful current-master `Automated Tests`, with
|
||||||
|
all six full Chromium-mobile role shards explicitly verified as green.
|
||||||
- Manual dispatch with version, version code, upload toggle, track, and status.
|
- Manual dispatch with version, version code, upload toggle, track, and status.
|
||||||
- Existing `mobile-v*` tags for the Android workflow.
|
- 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`.
|
The Android job continues using GitHub environment `mobile-store-production`.
|
||||||
Its required secrets are:
|
Its required secrets are:
|
||||||
|
|
||||||
@@ -37,6 +42,11 @@ iOS uses three separate workflows:
|
|||||||
storefront candidate, without rebuilding or submission.
|
storefront candidate, without rebuilding or submission.
|
||||||
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
|
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
|
||||||
|
|
||||||
|
Before signing or uploading to TestFlight, the workflow resolves the exact
|
||||||
|
current-master test run and requires all six full WebKit-mobile role shards to
|
||||||
|
be green. App Store candidates reuse that gated TestFlight build and do not
|
||||||
|
rebuild it.
|
||||||
|
|
||||||
The GitHub environments and variables are documented in
|
The GitHub environments and variables are documented in
|
||||||
`docs/app-store-release.md`. The repository-level
|
`docs/app-store-release.md`. The repository-level
|
||||||
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "VisualEvidenceManifest",
|
||||||
|
"taskId": "a0edd464-44c0-4d77-96c1-d3562496a1b7",
|
||||||
|
"repository": "copenhagentruckwash/pleno-vue",
|
||||||
|
"baseSha": "eee9ba1c138f0c88c772ea284a5a97a46cb9412c",
|
||||||
|
"subjectSha": "89dec2c5690f9eaf1e0e34651bb40b7f441b85fb",
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "invoicing-period-review",
|
||||||
|
"name": "Superuser invoice period review workspace",
|
||||||
|
"description": "Replaces the long mixed invoice-period page with grouped review navigation, compact totals, explicit review filters, and a responsive master-detail workspace while preserving every invoice category and action.",
|
||||||
|
"route": "/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all",
|
||||||
|
"fixture": "Synthetic superuser invoice-period fixture with three fictional customers and no production data",
|
||||||
|
"comparisons": {
|
||||||
|
"mobile": {
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"before": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-mobile.png",
|
||||||
|
"sha256": "b3c25c072f45e912358cc61d21577bbf61d5216b114a6911f900ca19b90d477e",
|
||||||
|
"bytes": 28914,
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period mobile view before the review workspace redesign"
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-mobile.png",
|
||||||
|
"sha256": "257db0e6e295b6b13ba5d0b29509c2a8b7170244ad6539a8e2ff18d2c9c0ffba",
|
||||||
|
"bytes": 31339,
|
||||||
|
"width": 390,
|
||||||
|
"height": 844,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period mobile view after the review workspace redesign"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tablet": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"before": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-tablet.png",
|
||||||
|
"sha256": "8cd17ea1e384ab6a9711a643d4e50e971cabcf704c3672d8f90cb2f5f2d36ba0",
|
||||||
|
"bytes": 38508,
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period tablet view before the review workspace redesign"
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-tablet.png",
|
||||||
|
"sha256": "1c627c30ade435ee004b8cdcd0223022594a351658639edec1f6e5396efaba18",
|
||||||
|
"bytes": 46062,
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period tablet view after the review workspace redesign"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"before": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-desktop.png",
|
||||||
|
"sha256": "849ea0eedabc7f1e52172e26602cddfddbeed32c91d04672dfe8b713343a54fa",
|
||||||
|
"bytes": 142738,
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period desktop view before the review workspace redesign"
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-desktop.png",
|
||||||
|
"sha256": "d026925bc4f6ced62686ae7f8252594e3a3fe9bd559a7f53f38a1bfcb0b96892",
|
||||||
|
"bytes": 108740,
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"alt": "Invoice period desktop view after the review workspace redesign"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Customer and subuser lifecycle visual comparisons
|
||||||
|
|
||||||
|
## Forgot-password account selection
|
||||||
|
|
||||||
|
The reset page previously accepted only a customer number. It now lets the
|
||||||
|
visitor choose a customer or chauffeur account. Chauffeur recovery uses the
|
||||||
|
country code and phone number and sends the one-time reset link by SMS.
|
||||||
|
|
||||||
|
- Mobile: [before](before-mobile.png) / [after](after-mobile.png)
|
||||||
|
- Tablet: [before](before-tablet.png) / [after](after-tablet.png)
|
||||||
|
- Desktop: [before](before-desktop.png) / [after](after-desktop.png)
|
||||||
|
|
||||||
|
## Pre-authorized customer access decision
|
||||||
|
|
||||||
|
The SMS link previously had no destination view. It now opens a read-only
|
||||||
|
request preview, identifies the chauffeur and customer, and requires an
|
||||||
|
explicit approve or deny action before the one-time token mutates access.
|
||||||
|
|
||||||
|
- Mobile: [before](access-before-mobile.png) / [after](access-after-mobile.png)
|
||||||
|
- Tablet: [before](access-before-tablet.png) / [after](access-after-tablet.png)
|
||||||
|
- Desktop: [before](access-before-desktop.png) / [after](access-after-desktop.png)
|
||||||
|
|
||||||
|
The related signed-in profile and customer grant selector use the same
|
||||||
|
responsive components. The selector is deduplicated by customer number and
|
||||||
|
uses colored permission indicators for vehicles, tools, calendar, orders,
|
||||||
|
and driver access.
|
||||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
# Automatic cron execution visual comparison
|
||||||
|
|
||||||
|
The Cron workers panel now exposes machine-verifiable scheduler cadence.
|
||||||
|
The summary reports how many active workers have maintained the required
|
||||||
|
once-per-minute cadence, and each worker row shows its latest loop gap and
|
||||||
|
consecutive qualifying loops.
|
||||||
|
|
||||||
|
A worker is verified only after two consecutive loops, with no gap above
|
||||||
|
60 seconds, while the worker is running and its latest observation is no more
|
||||||
|
than 60 seconds old.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
- [Before](before-mobile.png)
|
||||||
|
- [After](after-mobile.png)
|
||||||
|
|
||||||
|
## Tablet
|
||||||
|
|
||||||
|
- [Before](before-tablet.png)
|
||||||
|
- [After](after-tablet.png)
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
- [Before](before-desktop.png)
|
||||||
|
- [After](after-desktop.png)
|
||||||
|
|
||||||
|
The updated state tags use explicit foreground colors so success and warning
|
||||||
|
labels remain readable against their backgrounds.
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
# Stripe cleanup visual evidence
|
||||||
|
|
||||||
|
Authentic browser captures compare `origin/master` at
|
||||||
|
`fd31609cb379cda36fb16ef7077fc9db3c91eb2b` with the feature at
|
||||||
|
`6e795b253062606e6122cc7e630e17651b9b7efd`.
|
||||||
|
|
||||||
|
Both revisions were rendered by their own Vite applications and exercised with
|
||||||
|
the repository's mocked Playwright API support. No production API, Stripe
|
||||||
|
account, or product-source modification was used to create the evidence.
|
||||||
|
|
||||||
|
## Regular POS card-payment view
|
||||||
|
|
||||||
|
The baseline identifies the integration as Stripe and offers an email payment.
|
||||||
|
The feature uses provider-neutral card-terminal wording and removes the hosted
|
||||||
|
email-payment action while preserving terminal payment.
|
||||||
|
|
||||||
|
| Device | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mobile | [Before](before-pos-card-payment-mobile.png) | [After](after-pos-card-payment-mobile.png) |
|
||||||
|
| Tablet | [Before](before-pos-card-payment-tablet.png) | [After](after-pos-card-payment-tablet.png) |
|
||||||
|
| Desktop | [Before](before-pos-card-payment-desktop.png) | [After](after-pos-card-payment-desktop.png) |
|
||||||
|
|
||||||
|
## Authorized payment capture
|
||||||
|
|
||||||
|
Both revisions receive a mocked payment intent in `requires_capture` state. The
|
||||||
|
baseline exposes a manual capture action. The feature automatically issues the
|
||||||
|
capture request and shows its in-progress state without a second manual action.
|
||||||
|
|
||||||
|
| Device | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mobile | [Before](before-payment-capture-mobile.png) | [After](after-payment-capture-mobile.png) |
|
||||||
|
| Tablet | [Before](before-payment-capture-tablet.png) | [After](after-payment-capture-tablet.png) |
|
||||||
|
| Desktop | [Before](before-payment-capture-desktop.png) | [After](after-payment-capture-desktop.png) |
|
||||||
|
|
||||||
|
## Order-dashboard action rail
|
||||||
|
|
||||||
|
The baseline action rail includes the hosted Stripe invoice/payment-link
|
||||||
|
action. The feature removes it while preserving receipts, ordinary order
|
||||||
|
completion, and navigation.
|
||||||
|
|
||||||
|
| Device | Before | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mobile | [Before](before-order-dashboard-mobile.png) | [After](after-order-dashboard-mobile.png) |
|
||||||
|
| Tablet | [Before](before-order-dashboard-tablet.png) | [After](after-order-dashboard-tablet.png) |
|
||||||
|
| Desktop | [Before](before-order-dashboard-desktop.png) | [After](after-order-dashboard-desktop.png) |
|
||||||
|
|
||||||
|
All nine paired states passed their relevant DOM assertions across Chromium
|
||||||
|
mobile, tablet, and desktop projects.
|
||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"kind": "VisualEvidenceManifestV1",
|
||||||
|
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||||
|
"view": "POS extra sale audit",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ platform :ios do
|
|||||||
overwrite_screenshots: true,
|
overwrite_screenshots: true,
|
||||||
force: true,
|
force: true,
|
||||||
submit_for_review: false,
|
submit_for_review: false,
|
||||||
automatic_release: false,
|
automatic_release: true,
|
||||||
phased_release: false,
|
phased_release: false,
|
||||||
run_precheck_before_submit: false,
|
run_precheck_before_submit: false,
|
||||||
precheck_include_in_app_purchases: false
|
precheck_include_in_app_purchases: false
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
|
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Truck Wash uses your location to find or confirm the nearest truck wash department when you choose a location feature; the app does not track your location in the background.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
|
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
"CFBundleDisplayName" = "Truck Wash";
|
"CFBundleDisplayName" = "Truck Wash";
|
||||||
"NSCameraUsageDescription" = "Truck Wash bruger kameraet til at scanne QR-koder og registreringsnumre, når du vælger en scanningsfunktion.";
|
"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.";
|
"NSLocationWhenInUseUsageDescription" = "Truck Wash bruger din placering, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling.";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
"CFBundleDisplayName" = "Truck Wash";
|
"CFBundleDisplayName" = "Truck Wash";
|
||||||
"NSCameraUsageDescription" = "Truck Wash uses the camera to scan QR codes and vehicle registration plates when you choose a scanning feature.";
|
"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.";
|
"NSLocationWhenInUseUsageDescription" = "Truck Wash uses your location while the app is open to find or confirm the nearest Truck Wash department.";
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
||||||
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation")
|
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation"),
|
||||||
|
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -20,7 +21,8 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||||
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation")
|
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"),
|
||||||
|
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"marketingVersion": "1.0.0",
|
"marketingVersion": "1.0.1",
|
||||||
"bundleId": "io.truckwash.app",
|
"bundleId": "io.truckwash.app",
|
||||||
"minimumIosVersion": "15.0"
|
"minimumIosVersion": "15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3534,6 +3534,19 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
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':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
put:
|
put:
|
||||||
@@ -3566,6 +3579,21 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
message:
|
message:
|
||||||
type: string
|
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':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@bubblewrap/cli": "^1.24.1",
|
"@bubblewrap/cli": "^1.24.1",
|
||||||
"@capacitor/core": "^8.4.1",
|
"@capacitor/core": "^8.4.1",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.3",
|
||||||
"@creativebulma/bulma-badge": "^1.0.1",
|
"@creativebulma/bulma-badge": "^1.0.1",
|
||||||
"@fullcalendar/core": "^6.1.17",
|
"@fullcalendar/core": "^6.1.17",
|
||||||
"@fullcalendar/daygrid": "^6.1.17",
|
"@fullcalendar/daygrid": "^6.1.17",
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
"jimp": "0.22.12",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
@@ -2131,6 +2133,14 @@
|
|||||||
"@capacitor/core": "^8.4.0"
|
"@capacitor/core": "^8.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/status-bar": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/synapse": {
|
"node_modules/@capacitor/synapse": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||||
@@ -10087,6 +10097,12 @@
|
|||||||
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
|
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -10994,6 +11010,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
@@ -11062,6 +11090,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -13627,6 +13664,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
"@bubblewrap/cli": "^1.24.1",
|
"@bubblewrap/cli": "^1.24.1",
|
||||||
"@capacitor/core": "^8.4.1",
|
"@capacitor/core": "^8.4.1",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.3",
|
||||||
"@creativebulma/bulma-badge": "^1.0.1",
|
"@creativebulma/bulma-badge": "^1.0.1",
|
||||||
"@fullcalendar/core": "^6.1.17",
|
"@fullcalendar/core": "^6.1.17",
|
||||||
"@fullcalendar/daygrid": "^6.1.17",
|
"@fullcalendar/daygrid": "^6.1.17",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
"jimp": "0.22.12",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
|
|
||||||
const baseURL = "http://127.0.0.1:4173";
|
const baseURL = "http://127.0.0.1:4173";
|
||||||
const isCI = !!process.env.CI;
|
const isCI = !!process.env.CI;
|
||||||
|
const usePrebuiltDist = ["1", "true"].includes(
|
||||||
|
String(process.env.PLAYWRIGHT_PROD_PREBUILT || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@ export default defineConfig({
|
|||||||
video: "retain-on-failure",
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run preview:prod",
|
command: usePrebuiltDist ? "npm run preview -- --host 127.0.0.1 --port 4173" : "npm run preview:prod",
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
reuseExistingServer: !isCI,
|
reuseExistingServer: !isCI,
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
|
|||||||
|
|
||||||
const hasUnsupportedHostPlatformFailure = (result) => {
|
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||||
const output = outputText(result);
|
const output = outputText(result);
|
||||||
return (
|
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
|
||||||
result.status !== 0 &&
|
|
||||||
/Playwright does not support .* on /i.test(output)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||||
@@ -78,7 +75,7 @@ console.warn(
|
|||||||
[
|
[
|
||||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||||
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||||
"The self-hosted runner image must provide the required browser system libraries.",
|
"The runner image must provide the required browser system libraries.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,285 +1,420 @@
|
|||||||
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { appendFileSync } from "node:fs";
|
import { appendFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { argv, env, exit } from "node:process";
|
import { argv, env, exit } from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const command = argv[2];
|
export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
|
||||||
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
|
export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
|
||||||
const required = (name) => {
|
export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
|
||||||
const value = env[name];
|
export const TESTFLIGHT_BETA_LOCALE = "da";
|
||||||
if (!value) throw new Error(`Missing ${name}`);
|
export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
|
||||||
return value;
|
|
||||||
};
|
const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
||||||
const base64url = (value) => Buffer.from(value).toString("base64url");
|
const base64url = (value) => Buffer.from(value).toString("base64url");
|
||||||
|
|
||||||
const token = () => {
|
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
|
||||||
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
|
||||||
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
|
||||||
if (!key.includes("PRIVATE KEY"))
|
|
||||||
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
|
|
||||||
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
|
|
||||||
else payload.sub = "user";
|
|
||||||
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
|
||||||
const encodedPayload = base64url(JSON.stringify(payload));
|
|
||||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
||||||
const signature = sign("sha256", Buffer.from(signingInput), {
|
|
||||||
key: createPrivateKey(key),
|
|
||||||
dsaEncoding: "ieee-p1363",
|
|
||||||
});
|
|
||||||
return `${signingInput}.${base64url(signature)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
||||||
|
|
||||||
const request = async (path, options = {}, attempt = 1) => {
|
|
||||||
const response = await fetch(path.startsWith("http") ? path : `${baseUrl}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token()}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(options.headers ?? {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
let body = null;
|
|
||||||
try {
|
|
||||||
body = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
body = { raw: text };
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
|
|
||||||
await sleep(Math.min(30_000, 2 ** attempt * 1_000));
|
|
||||||
return request(path, options, attempt + 1);
|
|
||||||
}
|
|
||||||
const detail =
|
|
||||||
body?.errors
|
|
||||||
?.map((error) => error.detail || error.title)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("; ") ||
|
|
||||||
body?.raw ||
|
|
||||||
response.statusText;
|
|
||||||
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
};
|
|
||||||
|
|
||||||
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
|
||||||
const bundleId = () => env.IOS_BUNDLE_ID || "io.truckwash.app";
|
|
||||||
const version = () => required("IOS_MARKETING_VERSION");
|
|
||||||
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
|
||||||
|
|
||||||
const writeOutput = (key, value) => {
|
|
||||||
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|
||||||
else console.log(`${key}=${value}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyCredentials = async () => {
|
|
||||||
const app = await request(`/apps/${encodeURIComponent(appId())}`);
|
|
||||||
const actualBundleId = app?.data?.attributes?.bundleId;
|
|
||||||
if (actualBundleId !== bundleId()) {
|
|
||||||
throw new Error(
|
|
||||||
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allBuildsForVersion = async () => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
"filter[app]": appId(),
|
|
||||||
"filter[preReleaseVersion.version]": version(),
|
|
||||||
limit: "200",
|
|
||||||
});
|
|
||||||
let url = `${baseUrl}/builds?${params}`;
|
|
||||||
const builds = [];
|
|
||||||
while (url) {
|
|
||||||
const page = await request(url);
|
|
||||||
builds.push(...(page?.data ?? []));
|
|
||||||
url = page?.links?.next ?? null;
|
|
||||||
}
|
|
||||||
return builds;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findExactBuild = async () => {
|
|
||||||
const builds = await allBuildsForVersion();
|
|
||||||
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextBuildNumber = async () => {
|
|
||||||
await verifyCredentials();
|
|
||||||
const storeVersionParams = new URLSearchParams({
|
|
||||||
"filter[app]": appId(),
|
|
||||||
"filter[platform]": "IOS",
|
"filter[platform]": "IOS",
|
||||||
"filter[versionString]": version(),
|
"filter[versionString]": version,
|
||||||
limit: "10",
|
limit: "10",
|
||||||
});
|
});
|
||||||
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
|
if (includeBuild) params.set("include", "build");
|
||||||
const storeVersion = (storeVersions?.data ?? []).find(
|
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
|
||||||
(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 () => {
|
export const createAppStoreConnectClient = ({
|
||||||
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
environment = env,
|
||||||
let build = null;
|
fetchImpl = globalThis.fetch,
|
||||||
while (Date.now() < deadline) {
|
sleepImpl = defaultSleep,
|
||||||
build = await findExactBuild();
|
now = () => Date.now(),
|
||||||
const state = build?.attributes?.processingState;
|
logger = console,
|
||||||
if (state === "VALID") return build;
|
tokenProvider,
|
||||||
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
|
outputWriter,
|
||||||
console.log(
|
} = {}) => {
|
||||||
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
const required = (name) => {
|
||||||
);
|
const value = environment[name];
|
||||||
await sleep(30_000);
|
if (!value) throw new Error(`Missing ${name}`);
|
||||||
}
|
return value;
|
||||||
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const waitAndDistribute = async () => {
|
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
||||||
const build = await waitForBuild();
|
const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
|
||||||
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
const version = () => required("IOS_MARKETING_VERSION");
|
||||||
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
|
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
||||||
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
|
||||||
const existingLocalization = (localizations?.data ?? [])[0];
|
const token = () => {
|
||||||
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
if (tokenProvider) return tokenProvider();
|
||||||
if (existingLocalization) {
|
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
||||||
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
||||||
|
if (!key.includes("PRIVATE KEY")) {
|
||||||
|
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
||||||
|
}
|
||||||
|
const issuedAt = Math.floor(now() / 1_000);
|
||||||
|
const payload = { aud: "appstoreconnect-v1", iat: issuedAt, exp: issuedAt + 1_200 };
|
||||||
|
if (environment.APP_STORE_CONNECT_ISSUER_ID) payload.iss = environment.APP_STORE_CONNECT_ISSUER_ID;
|
||||||
|
else payload.sub = "user";
|
||||||
|
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
||||||
|
const encodedPayload = base64url(JSON.stringify(payload));
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const signature = sign("sha256", Buffer.from(signingInput), {
|
||||||
|
key: createPrivateKey(key),
|
||||||
|
dsaEncoding: "ieee-p1363",
|
||||||
|
});
|
||||||
|
return `${signingInput}.${base64url(signature)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeOutput = (key, value) => {
|
||||||
|
if (outputWriter) outputWriter(key, String(value));
|
||||||
|
else if (environment.GITHUB_OUTPUT) appendFileSync(environment.GITHUB_OUTPUT, `${key}=${value}\n`);
|
||||||
|
else logger.log(`${key}=${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = async (path, options = {}, attempt = 1) => {
|
||||||
|
const response = await fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
|
||||||
|
const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") || "", 10);
|
||||||
|
const delay = Number.isSafeInteger(retryAfter)
|
||||||
|
? Math.min(30_000, retryAfter * 1_000)
|
||||||
|
: Math.min(30_000, 2 ** attempt * 1_000);
|
||||||
|
await sleepImpl(delay);
|
||||||
|
return request(path, options, attempt + 1);
|
||||||
|
}
|
||||||
|
const detail =
|
||||||
|
body?.errors
|
||||||
|
?.map((error) => error.detail || error.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ") ||
|
||||||
|
body?.raw ||
|
||||||
|
response.statusText;
|
||||||
|
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectPages = async (path) => {
|
||||||
|
let url = path;
|
||||||
|
const data = [];
|
||||||
|
const included = [];
|
||||||
|
while (url) {
|
||||||
|
const page = await request(url);
|
||||||
|
data.push(...(page?.data ?? []));
|
||||||
|
included.push(...(page?.included ?? []));
|
||||||
|
url = page?.links?.next ?? null;
|
||||||
|
}
|
||||||
|
return { data, included };
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyCredentials = async () => {
|
||||||
|
const app = await request(`/apps/${encodeURIComponent(appId())}`);
|
||||||
|
const actualBundleId = app?.data?.attributes?.bundleId;
|
||||||
|
if (actualBundleId !== bundleId()) {
|
||||||
|
throw new Error(
|
||||||
|
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
||||||
|
return app.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allBuildsForVersion = async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
"filter[app]": appId(),
|
||||||
|
"filter[preReleaseVersion.version]": version(),
|
||||||
|
limit: "200",
|
||||||
|
});
|
||||||
|
const response = await collectPages(`/builds?${params}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allStoreVersions = async ({ includeBuild = false } = {}) =>
|
||||||
|
collectPages(appStoreVersionsPath({ appId: appId(), version: version(), includeBuild }));
|
||||||
|
|
||||||
|
const findStoreVersion = async ({ includeBuild = false } = {}) => {
|
||||||
|
const response = await allStoreVersions({ includeBuild });
|
||||||
|
return {
|
||||||
|
storeVersion: response.data.find((candidate) => candidate?.attributes?.versionString === version()),
|
||||||
|
included: response.included,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findExactBuild = async () => {
|
||||||
|
const builds = await allBuildsForVersion();
|
||||||
|
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextBuildNumber = async () => {
|
||||||
|
await verifyCredentials();
|
||||||
|
const { storeVersion } = await findStoreVersion();
|
||||||
|
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||||
|
throw new Error(
|
||||||
|
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const builds = await allBuildsForVersion();
|
||||||
|
const numbers = builds
|
||||||
|
.map((build) => Number.parseInt(build?.attributes?.version, 10))
|
||||||
|
.filter((number) => Number.isSafeInteger(number) && number > 0);
|
||||||
|
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
||||||
|
writeOutput("build_number", next);
|
||||||
|
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForBuild = async () => {
|
||||||
|
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
||||||
|
let build = null;
|
||||||
|
while (now() < deadline) {
|
||||||
|
build = await findExactBuild();
|
||||||
|
const state = build?.attributes?.processingState;
|
||||||
|
if (state === "VALID") return build;
|
||||||
|
if (["FAILED", "INVALID"].includes(state)) {
|
||||||
|
throw new Error(`App Store Connect processing ended in ${state}`);
|
||||||
|
}
|
||||||
|
logger.log(
|
||||||
|
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
||||||
|
);
|
||||||
|
await sleepImpl(30_000);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitAndDistribute = async () => {
|
||||||
|
const build = await waitForBuild();
|
||||||
|
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
||||||
|
const localizationParams = new URLSearchParams({
|
||||||
|
"filter[build]": build.id,
|
||||||
|
"filter[locale]": TESTFLIGHT_BETA_LOCALE,
|
||||||
|
});
|
||||||
|
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
||||||
|
const existingLocalization = (localizations?.data ?? [])[0];
|
||||||
|
const whatsNew = environment.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
||||||
|
if (existingLocalization) {
|
||||||
|
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "betaBuildLocalizations",
|
||||||
|
id: existingLocalization.id,
|
||||||
|
attributes: { whatsNew },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await request("/betaBuildLocalizations", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "betaBuildLocalizations",
|
||||||
|
attributes: { locale: TESTFLIGHT_BETA_LOCALE, whatsNew },
|
||||||
|
relationships: { build: { data: { type: "builds", id: build.id } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
|
||||||
|
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
|
||||||
|
if (!alreadyAssigned) {
|
||||||
|
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
writeOutput("app_store_build_id", build.id);
|
||||||
|
logger.log(
|
||||||
|
`${
|
||||||
|
alreadyAssigned ? "Verified" : "Assigned"
|
||||||
|
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
||||||
|
);
|
||||||
|
return build;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyCandidate = async () => {
|
||||||
|
await verifyCredentials();
|
||||||
|
const build = await findExactBuild();
|
||||||
|
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
|
||||||
|
if (build.attributes?.processingState !== "VALID") {
|
||||||
|
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
|
||||||
|
}
|
||||||
|
if (environment.EXPECTED_APP_STORE_BUILD_ID && build.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||||
|
throw new Error(
|
||||||
|
`Candidate App Store build ID ${build.id} does not match release manifest ${environment.EXPECTED_APP_STORE_BUILD_ID}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeOutput("app_store_build_id", build.id);
|
||||||
|
logger.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
||||||
|
return build;
|
||||||
|
};
|
||||||
|
|
||||||
|
const configureReleasePolicy = async () => {
|
||||||
|
await verifyCredentials();
|
||||||
|
const { storeVersion } = await findStoreVersion();
|
||||||
|
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||||
|
if (storeVersion.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||||
|
throw new Error(`App Store version ${version()} is already released and cannot change release policy`);
|
||||||
|
}
|
||||||
|
await request(`/appStoreVersions/${encodeURIComponent(storeVersion.id)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({
|
|
||||||
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await request("/betaBuildLocalizations", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
data: {
|
data: {
|
||||||
type: "betaBuildLocalizations",
|
type: "appStoreVersions",
|
||||||
attributes: { locale: "da-DK", whatsNew },
|
id: storeVersion.id,
|
||||||
relationships: { build: { data: { type: "builds", id: build.id } } },
|
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
writeOutput("app_store_version_id", storeVersion.id);
|
||||||
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
|
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
|
||||||
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
|
return storeVersion.id;
|
||||||
if (!alreadyAssigned) {
|
|
||||||
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
writeOutput("app_store_build_id", build.id);
|
|
||||||
console.log(
|
|
||||||
`${
|
|
||||||
alreadyAssigned ? "Verified" : "Assigned"
|
|
||||||
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyCandidate = async () => {
|
|
||||||
await verifyCredentials();
|
|
||||||
const build = await findExactBuild();
|
|
||||||
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
|
|
||||||
if (build.attributes?.processingState !== "VALID") {
|
|
||||||
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
|
|
||||||
}
|
|
||||||
if (env.EXPECTED_APP_STORE_BUILD_ID && build.id !== env.EXPECTED_APP_STORE_BUILD_ID) {
|
|
||||||
throw new Error(
|
|
||||||
`Candidate App Store build ID ${build.id} does not match release manifest ${env.EXPECTED_APP_STORE_BUILD_ID}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
writeOutput("app_store_build_id", build.id);
|
|
||||||
console.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyStoreVersion = async () => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
"filter[app]": appId(),
|
|
||||||
"filter[platform]": "IOS",
|
|
||||||
"filter[versionString]": version(),
|
|
||||||
include: "build",
|
|
||||||
limit: "10",
|
|
||||||
});
|
|
||||||
const response = await request(`/appStoreVersions?${params}`);
|
|
||||||
const storeVersion = (response?.data ?? []).find((candidate) => candidate?.attributes?.versionString === version());
|
|
||||||
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
|
||||||
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
|
|
||||||
const includedBuild = (response?.included ?? []).find(
|
|
||||||
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
|
|
||||||
);
|
|
||||||
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
|
|
||||||
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
|
|
||||||
}
|
|
||||||
writeOutput("app_store_version_id", storeVersion.id);
|
|
||||||
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
|
|
||||||
console.log(
|
|
||||||
`Verified App Store version ${version()} with exact build ${buildNumber()} in ${
|
|
||||||
storeVersion.attributes?.appStoreState || "unknown state"
|
|
||||||
}.`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selfTestJwt = async () => {
|
|
||||||
const original = {
|
|
||||||
keyId: env.APP_STORE_CONNECT_API_KEY_ID,
|
|
||||||
issuer: env.APP_STORE_CONNECT_ISSUER_ID,
|
|
||||||
key: env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64,
|
|
||||||
};
|
};
|
||||||
try {
|
|
||||||
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
const verifyStoreVersion = async () => {
|
||||||
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
await verifyCredentials();
|
||||||
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
|
||||||
privateKey.export({ type: "pkcs8", format: "pem" })
|
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||||
).toString("base64");
|
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
|
||||||
delete env.APP_STORE_CONNECT_ISSUER_ID;
|
const includedBuild = included.find(
|
||||||
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
|
||||||
if (individual.sub !== "user" || individual.iss !== undefined)
|
);
|
||||||
throw new Error("Individual API JWT claim test failed");
|
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
|
||||||
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
|
||||||
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
}
|
||||||
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
|
if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||||
throw new Error("Team API JWT claim test failed");
|
throw new Error(
|
||||||
console.log("App Store Connect individual and team JWT claim tests passed.");
|
`App Store version ${version()} is attached to ${includedBuild.id}, expected ${
|
||||||
} finally {
|
environment.EXPECTED_APP_STORE_BUILD_ID
|
||||||
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 (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
|
||||||
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
|
throw new Error(
|
||||||
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
|
`App Store version ${version()} release type is ${
|
||||||
|
storeVersion.attributes?.releaseType || "unknown"
|
||||||
|
}, expected ${EXPECTED_RELEASE_TYPE}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeOutput("app_store_version_id", storeVersion.id);
|
||||||
|
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
|
||||||
|
writeOutput("release_type", storeVersion.attributes.releaseType);
|
||||||
|
logger.log(
|
||||||
|
`Verified App Store version ${version()} with exact build ${buildNumber()} and ${EXPECTED_RELEASE_TYPE} release policy in ${
|
||||||
|
storeVersion.attributes?.appStoreState || "unknown state"
|
||||||
|
}.`
|
||||||
|
);
|
||||||
|
return storeVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyAvailability = async () => {
|
||||||
|
await verifyCredentials();
|
||||||
|
const availability = await request(`/apps/${encodeURIComponent(appId())}/appAvailabilityV2`);
|
||||||
|
const availabilityId = availability?.data?.id;
|
||||||
|
if (!availabilityId) throw new Error("App Store availability was not configured");
|
||||||
|
if (availability?.data?.attributes?.availableInNewTerritories !== false) {
|
||||||
|
throw new Error("App Store availability must not automatically include new territories");
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ include: "territory", limit: "200" });
|
||||||
|
const territories = await collectPages(
|
||||||
|
`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/${encodeURIComponent(
|
||||||
|
availabilityId
|
||||||
|
)}/territoryAvailabilities?${params}`
|
||||||
|
);
|
||||||
|
const available = territories.data
|
||||||
|
.filter((territory) => territory?.attributes?.available === true)
|
||||||
|
.map((territory) => territory?.relationships?.territory?.data?.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
if (territories.data.some((territory) => territory?.attributes?.preOrderEnabled === true)) {
|
||||||
|
throw new Error("App Store preorder must remain disabled for version 1.0.0");
|
||||||
|
}
|
||||||
|
if (JSON.stringify(available) !== JSON.stringify(EXPECTED_AVAILABLE_TERRITORIES)) {
|
||||||
|
throw new Error(
|
||||||
|
`App Store availability is ${
|
||||||
|
available.join(", ") || "empty"
|
||||||
|
}, expected Denmark only (${EXPECTED_AVAILABLE_TERRITORIES.join(", ")})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeOutput("available_territories", available.join(","));
|
||||||
|
logger.log("Verified Denmark-only App Store availability with preorder disabled.");
|
||||||
|
return available;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
verifyCredentials,
|
||||||
|
allBuildsForVersion,
|
||||||
|
allStoreVersions,
|
||||||
|
findExactBuild,
|
||||||
|
nextBuildNumber,
|
||||||
|
waitForBuild,
|
||||||
|
waitAndDistribute,
|
||||||
|
verifyCandidate,
|
||||||
|
configureReleasePolicy,
|
||||||
|
verifyStoreVersion,
|
||||||
|
verifyAvailability,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selfTestJwt = async () => {
|
||||||
|
const environment = {};
|
||||||
|
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||||
|
environment.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
||||||
|
environment.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
||||||
|
privateKey.export({ type: "pkcs8", format: "pem" })
|
||||||
|
).toString("base64");
|
||||||
|
const individual = JSON.parse(
|
||||||
|
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||||
|
);
|
||||||
|
if (individual.sub !== "user" || individual.iss !== undefined) {
|
||||||
|
throw new Error("Individual API JWT claim test failed");
|
||||||
}
|
}
|
||||||
|
environment.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
const team = JSON.parse(
|
||||||
|
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||||
|
);
|
||||||
|
if (team.iss !== environment.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined) {
|
||||||
|
throw new Error("Team API JWT claim test failed");
|
||||||
|
}
|
||||||
|
console.log("App Store Connect individual and team JWT claim tests passed.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const commands = {
|
export const runCli = async (command = argv[2]) => {
|
||||||
"verify-credentials": verifyCredentials,
|
const client = createAppStoreConnectClient();
|
||||||
"next-build-number": nextBuildNumber,
|
const commands = {
|
||||||
"wait-and-distribute": waitAndDistribute,
|
"verify-credentials": client.verifyCredentials,
|
||||||
"verify-candidate": verifyCandidate,
|
"next-build-number": client.nextBuildNumber,
|
||||||
"verify-store-version": verifyStoreVersion,
|
"wait-and-distribute": client.waitAndDistribute,
|
||||||
"self-test-jwt": selfTestJwt,
|
"verify-candidate": client.verifyCandidate,
|
||||||
|
"configure-release-policy": client.configureReleasePolicy,
|
||||||
|
"verify-store-version": client.verifyStoreVersion,
|
||||||
|
"verify-availability": client.verifyAvailability,
|
||||||
|
"self-test-jwt": selfTestJwt,
|
||||||
|
};
|
||||||
|
if (!commands[command]) {
|
||||||
|
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
||||||
|
}
|
||||||
|
await commands[command]();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!commands[command]) {
|
const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
|
||||||
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
if (isMain) {
|
||||||
exit(2);
|
runCli().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
commands[command]().catch((error) => {
|
|
||||||
console.error(error instanceof Error ? error.message : error);
|
|
||||||
exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ requireText("AndroidManifest.xml", androidManifest, 'android:required="false"');
|
|||||||
requireText("android/app/build.gradle", androidBuild, 'applicationId "io.truckwash.twa"');
|
requireText("android/app/build.gradle", androidBuild, 'applicationId "io.truckwash.twa"');
|
||||||
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
|
requireText("android/app/build.gradle", androidBuild, "ANDROID_KEYSTORE_FILE");
|
||||||
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
|
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
|
||||||
|
requireText("Info.plist", iosInfoPlist, "NSLocationAlwaysAndWhenInUseUsageDescription");
|
||||||
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
|
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
|
||||||
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
|
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
|
||||||
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
|
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const checkAndroid = () => {
|
|||||||
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
||||||
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
||||||
const track = env.PLAY_STORE_TRACK || "production";
|
const track = env.PLAY_STORE_TRACK || "production";
|
||||||
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
|
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
|
||||||
|
|
||||||
if (!validTracks.has(track)) {
|
if (!validTracks.has(track)) {
|
||||||
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
|
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
|
|||||||
|
|
||||||
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
|
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
|
||||||
const track = env.PLAY_STORE_TRACK || "production";
|
const track = env.PLAY_STORE_TRACK || "production";
|
||||||
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
|
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
|
||||||
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
||||||
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
||||||
|
|
||||||
@@ -222,6 +222,12 @@ const writeStepSummary = (summary) => {
|
|||||||
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
|
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 () => {
|
const main = async () => {
|
||||||
requireEnvironment();
|
requireEnvironment();
|
||||||
|
|
||||||
@@ -243,9 +249,13 @@ const main = async () => {
|
|||||||
await commitEdit(accessToken, packageName, editId);
|
await commitEdit(accessToken, packageName, editId);
|
||||||
|
|
||||||
const track = env.PLAY_STORE_TRACK || "production";
|
const track = env.PLAY_STORE_TRACK || "production";
|
||||||
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
|
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
|
||||||
|
writeOutput("play_edit_id", editId);
|
||||||
|
writeOutput("version_code", versionCode);
|
||||||
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
|
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}.`);
|
writeStepSummary(
|
||||||
|
`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}; edit ${editId}; artifact SHA-256 ${env.ANDROID_AAB_SHA256 || "missing"}.`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (editId) {
|
if (editId) {
|
||||||
await deleteEdit(accessToken, packageName, editId);
|
await deleteEdit(accessToken, packageName, editId);
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const roleShards = new Map([
|
||||||
|
["superuser", 2],
|
||||||
|
["admin", 2],
|
||||||
|
["customer", 1],
|
||||||
|
["subuser", 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateStoreGate(platform, jobs) {
|
||||||
|
const required = expectedStoreGateJobs(platform);
|
||||||
|
const latestByName = new Map();
|
||||||
|
|
||||||
|
for (const job of jobs || []) {
|
||||||
|
if (job?.name && (!latestByName.has(job.name) || isNewerJobExecution(job, latestByName.get(job.name)))) {
|
||||||
|
latestByName.set(job.name, job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const value = argv[index];
|
||||||
|
if (!value.startsWith("--")) {
|
||||||
|
throw new Error(`Unexpected argument: ${value}`);
|
||||||
|
}
|
||||||
|
const [rawName, inlineValue] = value.slice(2).split("=", 2);
|
||||||
|
const nextValue = inlineValue ?? argv[index + 1];
|
||||||
|
if (inlineValue === undefined) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
args[rawName] = nextValue;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRun(run, { sourceSha, defaultBranch }) {
|
||||||
|
if (run?.name !== "Automated Tests") {
|
||||||
|
throw new Error(`Run ${run?.id || "<unknown>"} is not the Automated Tests workflow.`);
|
||||||
|
}
|
||||||
|
if (run.head_sha !== sourceSha) {
|
||||||
|
throw new Error(`Run ${run.id} tested ${run.head_sha || "<unknown>"}, not ${sourceSha}.`);
|
||||||
|
}
|
||||||
|
if (run.event !== "push" || run.head_branch !== defaultBranch) {
|
||||||
|
throw new Error(`Run ${run.id} is not a push run for ${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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
branch: defaultBranch,
|
||||||
|
event: "push",
|
||||||
|
status: "completed",
|
||||||
|
per_page: "100",
|
||||||
|
});
|
||||||
|
const response = await client.request(`/actions/workflows/tests.yml/runs?${query}`);
|
||||||
|
const run = response.workflow_runs?.find((candidate) => candidate.head_sha === sourceSha);
|
||||||
|
if (!run) {
|
||||||
|
throw new Error(`No completed Automated Tests push run was found for ${sourceSha} on ${defaultBranch}.`);
|
||||||
|
}
|
||||||
|
return validateRun(run, { sourceSha, defaultBranch });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAllAttemptJobs(client, runId) {
|
||||||
|
const jobs = [];
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const query = new URLSearchParams({ filter: "all", per_page: "100", page: String(page) });
|
||||||
|
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`);
|
||||||
|
const pageJobs = response.jobs || [];
|
||||||
|
jobs.push(...pageJobs);
|
||||||
|
if (pageJobs.length < 100) {
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyStoreTestGate({
|
||||||
|
platform,
|
||||||
|
sourceSha,
|
||||||
|
runId,
|
||||||
|
defaultBranch = "master",
|
||||||
|
apiUrl = "https://api.github.com",
|
||||||
|
repository,
|
||||||
|
token,
|
||||||
|
fetchImpl,
|
||||||
|
}) {
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
if (!result.passed) {
|
||||||
|
throw new Error(`${platform} store test gate failed for run ${run.id}: ${result.failures.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, runId: run.id, sourceSha: normalizedSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const result = await verifyStoreTestGate({
|
||||||
|
platform: args.platform,
|
||||||
|
sourceSha: args["source-sha"] || process.env.STORE_SOURCE_SHA,
|
||||||
|
runId: args["run-id"] || process.env.TEST_WORKFLOW_RUN_ID,
|
||||||
|
defaultBranch: args["default-branch"] || process.env.DEFAULT_BRANCH || "master",
|
||||||
|
apiUrl: process.env.GITHUB_API_URL,
|
||||||
|
repository: process.env.GITHUB_REPOSITORY,
|
||||||
|
token: process.env.GH_TOKEN,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${args.platform} store gate passed for Automated Tests run ${result.runId}: ${result.required.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
await main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -135,6 +135,12 @@ export const sourceMappings = [
|
|||||||
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
|
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "shared-pos-product-selection",
|
||||||
|
patterns: [/^src\/components\/forms\/department\/pos\/SelectProductsFormPOS\.vue$/u],
|
||||||
|
specs: ["tests/e2e/pos-customer-rules.spec.js", "tests/e2e/userBookWash.spec.ts"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "pos",
|
name: "pos",
|
||||||
patterns: [
|
patterns: [
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ const REQUIRED_ENV = [
|
|||||||
"PRODUCTION_FTP_PASSWORD",
|
"PRODUCTION_FTP_PASSWORD",
|
||||||
"PRODUCTION_FTP_PATH",
|
"PRODUCTION_FTP_PATH",
|
||||||
"PRODUCTION_ACTIVATION_KEY",
|
"PRODUCTION_ACTIVATION_KEY",
|
||||||
"PRODUCTION_CPANEL_USER",
|
|
||||||
"PRODUCTION_CPANEL_API_TOKEN",
|
|
||||||
"PRODUCTION_CPANEL_API_URL",
|
|
||||||
"PRODUCTION_CPANEL_PATH",
|
|
||||||
"RELEASE_ARCHIVE_PATH",
|
"RELEASE_ARCHIVE_PATH",
|
||||||
"RELEASE_INVENTORY_PATH",
|
"RELEASE_INVENTORY_PATH",
|
||||||
"RELEASE_EXPECTED_COMMIT",
|
"RELEASE_EXPECTED_COMMIT",
|
||||||
@@ -33,10 +29,6 @@ const ROLLBACK_REQUIRED_ENV = [
|
|||||||
"PRODUCTION_FTP_PASSWORD",
|
"PRODUCTION_FTP_PASSWORD",
|
||||||
"PRODUCTION_FTP_PATH",
|
"PRODUCTION_FTP_PATH",
|
||||||
"PRODUCTION_ACTIVATION_KEY",
|
"PRODUCTION_ACTIVATION_KEY",
|
||||||
"PRODUCTION_CPANEL_USER",
|
|
||||||
"PRODUCTION_CPANEL_API_TOKEN",
|
|
||||||
"PRODUCTION_CPANEL_API_URL",
|
|
||||||
"PRODUCTION_CPANEL_PATH",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DeploymentError extends Error {
|
export class DeploymentError extends Error {
|
||||||
@@ -153,16 +145,29 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
|||||||
requireString(env, name);
|
requireString(env, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
const cpanelNames = [
|
||||||
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
"PRODUCTION_CPANEL_USER",
|
||||||
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
"PRODUCTION_CPANEL_API_TOKEN",
|
||||||
|
"PRODUCTION_CPANEL_API_URL",
|
||||||
|
"PRODUCTION_CPANEL_PATH",
|
||||||
|
];
|
||||||
|
const configuredCpanelNames = cpanelNames.filter((name) => env[name] !== undefined && env[name] !== "");
|
||||||
|
let cpanel = null;
|
||||||
|
if (configuredCpanelNames.length > 0) {
|
||||||
|
if (configuredCpanelNames.length !== cpanelNames.length) {
|
||||||
|
throw new DeploymentError("Set all PRODUCTION_CPANEL_* values together or omit them from the FTPS release path.");
|
||||||
|
}
|
||||||
|
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
||||||
|
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
||||||
|
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
||||||
|
}
|
||||||
|
cpanel = {
|
||||||
|
user: cpanelUser,
|
||||||
|
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
||||||
|
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
||||||
|
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const cpanel = {
|
|
||||||
user: cpanelUser,
|
|
||||||
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
|
||||||
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
|
||||||
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
|
|
||||||
};
|
|
||||||
const host = requireString(env, "PRODUCTION_FTP_HOST");
|
const host = requireString(env, "PRODUCTION_FTP_HOST");
|
||||||
if (!SAFE_HOST.test(host) || host.includes("..")) {
|
if (!SAFE_HOST.test(host) || host.includes("..")) {
|
||||||
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
|
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
|
||||||
@@ -178,7 +183,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
|||||||
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
|
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
|
||||||
}
|
}
|
||||||
if (rollbackOnly) {
|
if (rollbackOnly) {
|
||||||
return { ftp, cpanel, activationKey };
|
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
||||||
@@ -224,7 +229,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ftp,
|
ftp,
|
||||||
cpanel,
|
...(cpanel ? { cpanel } : {}),
|
||||||
activationKey,
|
activationKey,
|
||||||
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
||||||
checksumPath: path.resolve(checksumPath),
|
checksumPath: path.resolve(checksumPath),
|
||||||
@@ -242,6 +247,10 @@ function lftpQuote(value) {
|
|||||||
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureLftpDirectory(value) {
|
||||||
|
return `mkdir -p -f ${lftpQuote(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildLftpScript(config, commands) {
|
export function buildLftpScript(config, commands) {
|
||||||
const { host, user, password, root } = config.ftp;
|
const { host, user, password, root } = config.ftp;
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -282,7 +291,10 @@ export async function defaultProcessRunner(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new DeploymentError(`${options.label || command} failed with exit code ${code}.`));
|
const error = new DeploymentError(`${options.label || command} failed with exit code ${code}.`);
|
||||||
|
error.stdout = Buffer.concat(output).toString("utf8");
|
||||||
|
error.stderr = Buffer.concat(errors).toString("utf8");
|
||||||
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
@@ -415,14 +427,36 @@ export function createLftpTransport(config, dependencies = {}) {
|
|||||||
const sleep =
|
const sleep =
|
||||||
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
||||||
|
|
||||||
|
function redactDiagnostic(value) {
|
||||||
|
let result = String(value || "");
|
||||||
|
const escapedHost = config.ftp.host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
result = result.replace(new RegExp(escapedHost, "gi"), "[redacted]");
|
||||||
|
const rawSecrets = [config.ftp.user, config.ftp.password, config.ftp.root]
|
||||||
|
.filter((secret) => secret && secret !== "/")
|
||||||
|
.flatMap((secret) => [secret, encodeURIComponent(secret)]);
|
||||||
|
const secrets = [...new Set(rawSecrets)].sort((left, right) => right.length - left.length);
|
||||||
|
for (const secret of secrets) {
|
||||||
|
result = result.replaceAll(secret, "[redacted]");
|
||||||
|
}
|
||||||
|
result = result.replace(/\b(?:ftp|ftps):\/\/[^\s@]+@/gi, "ftps://[redacted]@");
|
||||||
|
return result.replace(/\s+/g, " ").trim().slice(0, 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
async function run(commands) {
|
async function run(commands) {
|
||||||
try {
|
try {
|
||||||
await runner("lftp", ["-f", "/dev/stdin"], {
|
// lftp reads commands from standard input when no command source is
|
||||||
|
// specified. Avoid `-f /dev/stdin`: some hosted runners expose that
|
||||||
|
// path as a non-reopenable pipe, causing lftp's `source` command to fail.
|
||||||
|
await runner("lftp", [], {
|
||||||
input: buildLftpScript(config, commands),
|
input: buildLftpScript(config, commands),
|
||||||
label: "FTPS operation",
|
label: "FTPS operation",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DeploymentError("Secure FTPS operation failed.", { cause: error });
|
const diagnostic = redactDiagnostic(
|
||||||
|
[error?.stderr, error?.stdout, error instanceof Error ? error.message : error].filter(Boolean).join(" ")
|
||||||
|
);
|
||||||
|
const suffix = diagnostic ? `: ${diagnostic}` : ".";
|
||||||
|
throw new DeploymentError(`Secure FTPS operation failed${suffix}`, { cause: error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,8 +502,8 @@ export function createLftpTransport(config, dependencies = {}) {
|
|||||||
let queueError;
|
let queueError;
|
||||||
try {
|
try {
|
||||||
await run([
|
await run([
|
||||||
`mkdir -p ${lftpQuote("activation-requests")}`,
|
ensureLftpDirectory("activation-requests"),
|
||||||
`mkdir -p ${lftpQuote("activation-results")}`,
|
ensureLftpDirectory("activation-results"),
|
||||||
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
|
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
|
||||||
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
|
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
|
||||||
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
|
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
|
||||||
@@ -527,7 +561,7 @@ export function createLftpTransport(config, dependencies = {}) {
|
|||||||
const downloadedChecksum = path.join(temporaryDirectory, checksumName);
|
const downloadedChecksum = path.join(temporaryDirectory, checksumName);
|
||||||
try {
|
try {
|
||||||
await run([
|
await run([
|
||||||
`mkdir -p ${lftpQuote("archives")}`,
|
ensureLftpDirectory("archives"),
|
||||||
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
|
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
|
||||||
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
|
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
|
||||||
`put ${lftpQuote(config.archivePath)} -o ${lftpQuote(`archives/${archiveName}.part`)}`,
|
`put ${lftpQuote(config.archivePath)} -o ${lftpQuote(`archives/${archiveName}.part`)}`,
|
||||||
@@ -824,17 +858,6 @@ export async function assertReleaseTargetExists(client, config, target) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertCurrentLink(client, config) {
|
|
||||||
const entries = await client.list(config.cpanel.root);
|
|
||||||
const current = findEntry(entries, "current");
|
|
||||||
if (!current) {
|
|
||||||
throw new DeploymentError("cPanel does not have a current frontend release pointer.");
|
|
||||||
}
|
|
||||||
if (current.type !== "link") {
|
|
||||||
throw new DeploymentError("cPanel current is not a symbolic link.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function capturePublishedReleaseTarget(config, options = {}) {
|
export async function capturePublishedReleaseTarget(config, options = {}) {
|
||||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||||
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
|
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
|
||||||
@@ -909,17 +932,6 @@ export async function assertExpectedCommitCurrent(config, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) {
|
|
||||||
const archiveRoot = containedRemotePath(config.cpanel.root, "archives");
|
|
||||||
if (!findEntry(await client.list(config.cpanel.root), "archives")) {
|
|
||||||
throw new DeploymentError("cPanel deployment archives directory is missing; bootstrap is incomplete.");
|
|
||||||
}
|
|
||||||
const entries = await client.list(archiveRoot);
|
|
||||||
if (findEntry(entries, archiveName) || findEntry(entries, checksumName)) {
|
|
||||||
throw new DeploymentError("The immutable release archive name already exists on cPanel.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runPublicVerification(config, options = {}) {
|
export async function runPublicVerification(config, options = {}) {
|
||||||
const runner = options.runner || defaultProcessRunner;
|
const runner = options.runner || defaultProcessRunner;
|
||||||
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
|
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
|
||||||
@@ -991,23 +1003,16 @@ export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deployRelease(config, dependencies = {}) {
|
export async function deployRelease(config, dependencies = {}) {
|
||||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
|
||||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||||
const verify = dependencies.verify || runPublicVerification;
|
const verify = dependencies.verify || runPublicVerification;
|
||||||
const prune = dependencies.prune || pruneInactiveReleases;
|
|
||||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||||
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
||||||
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
||||||
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
||||||
|
|
||||||
await checkCurrent(config, dependencies);
|
await checkCurrent(config, dependencies);
|
||||||
await assertCurrentLink(client, config);
|
|
||||||
const previousTarget = await capturePrevious(config, dependencies);
|
const previousTarget = await capturePrevious(config, dependencies);
|
||||||
await assertReleaseTargetExists(client, config, previousTarget);
|
|
||||||
|
|
||||||
const archiveName = safeArchiveName(config.archivePath);
|
|
||||||
const checksumName = `${archiveName}.sha256`;
|
|
||||||
await assertArchiveNamesAvailable(client, config, archiveName, checksumName);
|
|
||||||
const uploaded = await transport.uploadArchive();
|
const uploaded = await transport.uploadArchive();
|
||||||
await checkCurrent(config, dependencies);
|
await checkCurrent(config, dependencies);
|
||||||
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
||||||
@@ -1033,7 +1038,6 @@ export async function deployRelease(config, dependencies = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assertReleaseTargetExists(client, config, newTarget);
|
|
||||||
await transport.verifyRelease(newTarget);
|
await transport.verifyRelease(newTarget);
|
||||||
await verify(config, dependencies);
|
await verify(config, dependencies);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1050,28 +1054,19 @@ export async function deployRelease(config, dependencies = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertCurrentLink(client, config);
|
return {
|
||||||
let removed = [];
|
previousTarget,
|
||||||
let retentionWarning = "";
|
activeTarget: newTarget,
|
||||||
try {
|
removed: [],
|
||||||
removed = await prune(client, config, [newTarget, previousTarget], {
|
retentionWarning:
|
||||||
removeRelease: async (releaseId) => {
|
"Verified deployment succeeded; inactive release cleanup is deferred because hosted runners cannot use the cPanel metadata API.",
|
||||||
await transport.removeArchives([releaseId]);
|
};
|
||||||
await transport.removeRelease(releaseId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
retentionWarning = "Verified deployment succeeded, but old release retention cleanup failed.";
|
|
||||||
}
|
|
||||||
return { previousTarget, activeTarget: newTarget, removed, retentionWarning };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rollbackRelease(config, target, dependencies = {}) {
|
export async function rollbackRelease(config, target, dependencies = {}) {
|
||||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
|
||||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||||
const safeTarget = validateReleaseTarget(target);
|
const safeTarget = validateReleaseTarget(target);
|
||||||
await assertReleaseTargetExists(client, config, safeTarget);
|
|
||||||
await transport.activateExisting(safeTarget);
|
await transport.activateExisting(safeTarget);
|
||||||
publish({
|
publish({
|
||||||
RELEASE_ACTIVE_TARGET: safeTarget,
|
RELEASE_ACTIVE_TARGET: safeTarget,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import fsp from "node:fs/promises";
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
|
||||||
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
||||||
|
const MAX_DIST_FILES = 10_000;
|
||||||
|
const MAX_DIST_BYTES = 256 * 1024 * 1024;
|
||||||
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
|
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
|
||||||
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
|
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
|
||||||
|
|
||||||
@@ -100,6 +100,7 @@ export async function collectDistInventory(distDirectory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files = [];
|
const files = [];
|
||||||
|
let totalBytes = 0;
|
||||||
const walk = async (relativeDirectory = "") => {
|
const walk = async (relativeDirectory = "") => {
|
||||||
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
|
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
|
||||||
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
|
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
|
||||||
@@ -122,7 +123,17 @@ export async function collectDistInventory(distDirectory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertSafeFileName(relativePath);
|
assertSafeFileName(relativePath);
|
||||||
|
if (files.length >= MAX_DIST_FILES) {
|
||||||
|
throw new Error(`dist contains more than the ${MAX_DIST_FILES}-file packaging limit.`);
|
||||||
|
}
|
||||||
|
if (totalBytes + stat.size > MAX_DIST_BYTES) {
|
||||||
|
throw new Error(`dist exceeds the ${MAX_DIST_BYTES}-byte packaging limit.`);
|
||||||
|
}
|
||||||
const contents = await fsp.readFile(absolutePath);
|
const contents = await fsp.readFile(absolutePath);
|
||||||
|
totalBytes += contents.length;
|
||||||
|
if (totalBytes > MAX_DIST_BYTES) {
|
||||||
|
throw new Error(`dist exceeds the ${MAX_DIST_BYTES}-byte packaging limit.`);
|
||||||
|
}
|
||||||
files.push({
|
files.push({
|
||||||
path: `dist/${relativePath}`,
|
path: `dist/${relativePath}`,
|
||||||
bytes: contents.length,
|
bytes: contents.length,
|
||||||
@@ -238,23 +249,111 @@ function assertMatchingInventories(expected, actual, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zipEntryDate() {
|
||||||
|
return new Date("2020-01-01T00:00:00.000Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveDirectoryPaths(inventory) {
|
||||||
|
const directories = new Set(["dist/"]);
|
||||||
|
for (const file of inventory.files) {
|
||||||
|
const segments = file.path.split("/");
|
||||||
|
for (let depth = 1; depth < segments.length; depth += 1) {
|
||||||
|
directories.add(`${segments.slice(0, depth).join("/")}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(directories).sort(comparePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createZipArchive(distDirectory, inventory, archivePath) {
|
||||||
|
const archive = new JSZip();
|
||||||
|
for (const directoryPath of archiveDirectoryPaths(inventory)) {
|
||||||
|
archive.file(directoryPath, null, {
|
||||||
|
createFolders: false,
|
||||||
|
date: zipEntryDate(),
|
||||||
|
dir: true,
|
||||||
|
unixPermissions: 0o40755,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const file of inventory.files) {
|
||||||
|
const relativePath = file.path.slice("dist/".length);
|
||||||
|
assertSafeFileName(relativePath);
|
||||||
|
const contents = await fsp.readFile(path.join(distDirectory, ...relativePath.split("/")));
|
||||||
|
archive.file(file.path, contents, {
|
||||||
|
binary: true,
|
||||||
|
createFolders: false,
|
||||||
|
date: zipEntryDate(),
|
||||||
|
unixPermissions: 0o100644,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = await archive.generateAsync({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
platform: "UNIX",
|
||||||
|
streamFiles: false,
|
||||||
|
});
|
||||||
|
await fsp.writeFile(archivePath, contents, { flag: "wx" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveEntryName(entry) {
|
||||||
|
return entry.unsafeOriginalName || entry.name;
|
||||||
|
}
|
||||||
|
|
||||||
async function validateArchiveEntries(archivePath) {
|
async function validateArchiveEntries(archivePath) {
|
||||||
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
|
let archive;
|
||||||
const entries = stdout.split(/\r?\n/).filter(Boolean);
|
try {
|
||||||
if (entries.length === 0) {
|
archive = await JSZip.loadAsync(await fsp.readFile(archivePath), { checkCRC32: true });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`release archive could not be read: ${error instanceof Error ? error.message : error}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.values(archive.files);
|
||||||
|
if (entries.length === 0 || entries.every((entry) => entry.dir)) {
|
||||||
throw new Error("release archive is empty.");
|
throw new Error("release archive is empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const archiveEntry of entries) {
|
for (const entry of entries) {
|
||||||
|
const archiveEntry = archiveEntryName(entry);
|
||||||
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
|
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
|
||||||
if (normalizedEntry === "dist") {
|
if (normalizedEntry === "dist") {
|
||||||
|
if (!entry.dir) {
|
||||||
|
throw new Error("release archive contains a file at the dist root path.");
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!normalizedEntry.startsWith("dist/")) {
|
if (!normalizedEntry.startsWith("dist/")) {
|
||||||
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
|
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
|
||||||
}
|
}
|
||||||
assertSafeRelativePath(normalizedEntry, "archive entry");
|
assertSafeRelativePath(normalizedEntry, "archive entry");
|
||||||
|
if (!entry.dir) {
|
||||||
|
assertSafeFileName(normalizedEntry.slice("dist/".length));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectArchiveInventory(archive) {
|
||||||
|
const files = [];
|
||||||
|
for (const entry of Object.values(archive.files)) {
|
||||||
|
if (entry.dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const archiveEntry = archiveEntryName(entry);
|
||||||
|
const relativePath = archiveEntry.slice("dist/".length);
|
||||||
|
assertSafeFileName(relativePath);
|
||||||
|
const contents = await entry.async("nodebuffer");
|
||||||
|
files.push({
|
||||||
|
path: `dist/${relativePath}`,
|
||||||
|
bytes: contents.length,
|
||||||
|
sha256: sha256(contents),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.sort((left, right) => comparePaths(left.path, right.path));
|
||||||
|
return { schema_version: 1, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
|
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
|
||||||
@@ -330,25 +429,16 @@ export async function createReleaseArchive({
|
|||||||
const sourceRoot = path.join(temporaryDirectory, "source");
|
const sourceRoot = path.join(temporaryDirectory, "source");
|
||||||
const copiedDistDirectory = path.join(sourceRoot, "dist");
|
const copiedDistDirectory = path.join(sourceRoot, "dist");
|
||||||
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
|
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
|
||||||
const extractionRoot = path.join(temporaryDirectory, "extracted");
|
|
||||||
await fsp.mkdir(sourceRoot, { recursive: true });
|
await fsp.mkdir(sourceRoot, { recursive: true });
|
||||||
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
|
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
|
||||||
|
|
||||||
const copiedInventory = await collectDistInventory(copiedDistDirectory);
|
const copiedInventory = await collectDistInventory(copiedDistDirectory);
|
||||||
assertMatchingInventories(inventory, copiedInventory, "archive source");
|
assertMatchingInventories(inventory, copiedInventory, "archive source");
|
||||||
|
|
||||||
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
|
await createZipArchive(copiedDistDirectory, copiedInventory, temporaryArchivePath);
|
||||||
cwd: sourceRoot,
|
const archive = await validateArchiveEntries(temporaryArchivePath);
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
const archivedInventory = await collectArchiveInventory(archive);
|
||||||
});
|
assertMatchingInventories(inventory, archivedInventory, "round-trip archive");
|
||||||
await validateArchiveEntries(temporaryArchivePath);
|
|
||||||
|
|
||||||
await fsp.mkdir(extractionRoot);
|
|
||||||
await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], {
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist"));
|
|
||||||
assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive");
|
|
||||||
|
|
||||||
const archiveContents = await fsp.readFile(temporaryArchivePath);
|
const archiveContents = await fsp.readFile(temporaryArchivePath);
|
||||||
const archiveSha256 = sha256(archiveContents);
|
const archiveSha256 = sha256(archiveContents);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
function gitCommit() {
|
function gitCommit() {
|
||||||
try {
|
try {
|
||||||
@@ -8,43 +9,140 @@ function gitCommit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function boundedJson(response, limit = 64 * 1024) {
|
||||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
const declared = Number(response.headers.get("content-length"));
|
||||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
if (Number.isFinite(declared) && declared > limit) throw new Error("Server version response is too large.");
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const chunks = [];
|
||||||
|
let bytes = 0;
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
bytes += value.byteLength;
|
||||||
|
if (bytes > limit) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
throw new Error("Server version response is too large.");
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServerVersion(env = process.env, fetchImpl = globalThis.fetch) {
|
||||||
|
const serverToken = String(env.SERVER_UPDATE_TOKEN || "").trim();
|
||||||
|
const releaseGateToken = String(env.RELEASE_MANAGER_GATE_TOKEN || "").trim();
|
||||||
|
const token = serverToken || releaseGateToken;
|
||||||
|
const useReleaseGate = !serverToken && Boolean(releaseGateToken);
|
||||||
|
const required = env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (required) {
|
if (required) {
|
||||||
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
throw new Error("SERVER_UPDATE_TOKEN or RELEASE_MANAGER_GATE_TOKEN is required after deploy verification.");
|
||||||
}
|
}
|
||||||
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
console.log("No server version credential is set. Skipping server version update.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
const version = String(
|
||||||
if (!version) {
|
env.RELEASE_VERSION || env.RELEASE_EXPECTED_COMMIT || env.GITHUB_SHA || gitCommit()
|
||||||
throw new Error("Could not determine release version for server update.");
|
).toLowerCase();
|
||||||
|
if (!/^[a-f0-9]{40}$/.test(version)) {
|
||||||
|
throw new Error("Server release version must be a full lowercase commit SHA.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
const baseUrl =
|
||||||
|
env.SERVER_UPDATE_URL ||
|
||||||
|
(useReleaseGate
|
||||||
|
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
|
||||||
|
: "https://api-v2.truckwash.io/master/api/worker/update-version");
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.searchParams.set("version", version);
|
if (url.protocol !== "https:" || url.username || url.password) {
|
||||||
|
throw new Error("SERVER_UPDATE_URL must be an HTTPS URL without embedded credentials.");
|
||||||
const response = await fetch(url, {
|
}
|
||||||
method: "GET",
|
const request = {
|
||||||
|
method: useReleaseGate ? "POST" : "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (useReleaseGate) {
|
||||||
const body = await response.text();
|
const repository = String(env.GITHUB_REPOSITORY || "copenhagentruckwash/pleno-vue").trim();
|
||||||
if (!response.ok) {
|
const branch = String(env.RELEASE_BRANCH || "master")
|
||||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const buildId = String(env.RELEASE_BUILD_ID || env.RELEASE_EXPECTED_BUILD_ID || "").trim();
|
||||||
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
||||||
|
throw new Error("Frontend release repository is invalid.");
|
||||||
|
}
|
||||||
|
if (branch !== "master") {
|
||||||
|
throw new Error("Frontend release branch must be master.");
|
||||||
|
}
|
||||||
|
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(buildId)) {
|
||||||
|
throw new Error("Frontend release build id is invalid.");
|
||||||
|
}
|
||||||
|
request.headers["Content-Type"] = "application/json";
|
||||||
|
request.body = JSON.stringify({ version, repository, branch, build_id: buildId });
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("version", version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(url, request);
|
||||||
|
|
||||||
|
const body = await boundedJson(response).catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server version update failed with HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readUrl = new URL(
|
||||||
|
env.SERVER_VERSION_READ_URL ||
|
||||||
|
(useReleaseGate
|
||||||
|
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
|
||||||
|
: "https://api-v2.truckwash.io/master/api/worker/version")
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
readUrl.protocol !== "https:" ||
|
||||||
|
readUrl.origin !== url.origin ||
|
||||||
|
readUrl.username ||
|
||||||
|
readUrl.password ||
|
||||||
|
readUrl.search ||
|
||||||
|
readUrl.hash
|
||||||
|
) {
|
||||||
|
throw new Error("SERVER_VERSION_READ_URL must be an exact HTTPS URL on the update origin.");
|
||||||
|
}
|
||||||
|
readUrl.searchParams.set("verify", `${Date.now()}`);
|
||||||
|
const verification = await fetchImpl(readUrl, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!verification.ok) {
|
||||||
|
throw new Error(`Server version read-back failed with HTTP ${verification.status}.`);
|
||||||
|
}
|
||||||
|
const verified = await boundedJson(verification);
|
||||||
|
const observed = String(verified?.data?.version ?? verified?.version ?? "").toLowerCase();
|
||||||
|
if (observed !== version) {
|
||||||
|
throw new Error(`Server version read-back did not match ${version}.`);
|
||||||
|
}
|
||||||
console.log(`Server version updated to ${version}.`);
|
console.log(`Server version updated to ${version}.`);
|
||||||
|
return { version, update: body, observed };
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
async function main() {
|
||||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
await updateServerVersion();
|
||||||
process.exit(1);
|
}
|
||||||
});
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||