Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37e925dc6 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 | ||
|
|
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/**"
|
||||
- "ios/**"
|
||||
- "scripts/mobile/**"
|
||||
- "tests/node/app-store-connect.test.mjs"
|
||||
- ".github/workflows/app-store-readiness.yml"
|
||||
- "Gemfile*"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -20,21 +22,22 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: App Store Readiness
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Preserve a generated lock for review
|
||||
if: steps.fastlane-lock.outcome == 'failure'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: generated-fastlane-lock
|
||||
path: Gemfile.lock
|
||||
@@ -61,12 +64,25 @@ jobs:
|
||||
echo 'Gemfile.lock is missing or stale. Download generated-fastlane-lock and commit it.' >&2
|
||||
exit 1
|
||||
|
||||
- name: Validate App Store metadata and available assets
|
||||
run: node scripts/mobile/validate-app-store.mjs
|
||||
- name: Install the pinned Fastlane dependency graph
|
||||
run: bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Validate strict App Store metadata and candidate assets
|
||||
run: node scripts/mobile/validate-app-store.mjs --strict
|
||||
|
||||
- name: Validate native mobile permissions
|
||||
run: node scripts/mobile/check-permissions.mjs
|
||||
|
||||
- name: Test App Store Connect automation
|
||||
run: node --test tests/node/app-store-connect.test.mjs
|
||||
|
||||
- name: Validate Fastlane configuration
|
||||
run: bundle exec fastlane lanes
|
||||
|
||||
- name: Validate JavaScript syntax
|
||||
run: |
|
||||
node --check scripts/mobile/validate-app-store.mjs
|
||||
node --check scripts/mobile/app-store-connect.mjs
|
||||
node --check scripts/mobile/create-ios-release-manifest.mjs
|
||||
node --check tests/node/app-store-connect.test.mjs
|
||||
node scripts/mobile/app-store-connect.mjs self-test-jwt
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
# v5.0.1
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
||||
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
|
||||
with:
|
||||
use-caches: 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 }}
|
||||
steps:
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
NODE
|
||||
|
||||
promote:
|
||||
name: Sync storefront and prepare manual review
|
||||
name: Sync and verify App Store candidate
|
||||
needs: resolve
|
||||
if: needs.resolve.outputs.enabled == 'true'
|
||||
runs-on: macos-15
|
||||
@@ -119,19 +119,19 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout exact candidate source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Ruby and pinned Fastlane
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -153,22 +153,33 @@ jobs:
|
||||
- name: Sync metadata and screenshots without App Review submission
|
||||
run: bundle exec fastlane ios prepare_candidate
|
||||
|
||||
- name: Configure automatic release after approval
|
||||
run: node scripts/mobile/app-store-connect.mjs configure-release-policy
|
||||
|
||||
- name: Read back exact App Store candidate
|
||||
id: readback
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-store-version
|
||||
|
||||
- name: Verify Denmark-only availability and no preorder
|
||||
id: availability
|
||||
run: node scripts/mobile/app-store-connect.mjs verify-availability
|
||||
|
||||
- name: Write candidate handoff
|
||||
env:
|
||||
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
||||
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
|
||||
RELEASE_TYPE: ${{ steps.readback.outputs.release_type }}
|
||||
AVAILABLE_TERRITORIES: ${{ steps.availability.outputs.available_territories }}
|
||||
run: |
|
||||
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release policy: \`$RELEASE_TYPE\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Availability: \`$AVAILABLE_TERRITORIES\` only; preorder disabled" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- App Review submission 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:
|
||||
name: Promotion disabled
|
||||
|
||||
@@ -44,12 +44,12 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout same-repository history
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout resolved source
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.source_sha }}
|
||||
fetch-depth: 1
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -596,7 +596,7 @@ jobs:
|
||||
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload device-debug artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
||||
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
current: ${{ steps.resolve.outputs.current }}
|
||||
steps:
|
||||
- name: Checkout repository history
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -82,6 +82,14 @@ jobs:
|
||||
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Require green WebKit mobile tests before App Store upload
|
||||
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
deliver:
|
||||
name: Sign, upload, process, and distribute
|
||||
needs: prepare
|
||||
@@ -103,7 +111,7 @@ jobs:
|
||||
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
|
||||
steps:
|
||||
- name: Checkout verified source
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.IOS_SOURCE_SHA }}
|
||||
fetch-depth: 1
|
||||
@@ -134,13 +142,19 @@ jobs:
|
||||
const fs = require("node:fs");
|
||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||
const checks = {
|
||||
schema: proof.schemaVersion === 1,
|
||||
schema: proof.schemaVersion === 2,
|
||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
||||
exactSource: proof.sha === process.env.IOS_SOURCE_SHA,
|
||||
releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0,
|
||||
archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""),
|
||||
activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0,
|
||||
verification: proof.verificationState === "verified",
|
||||
publicGate: proof.livePublicGate === "passed",
|
||||
credentialedGate: proof.liveCredentialedGate === "passed",
|
||||
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||
managerGate: proof.releaseManagerGate === "passed",
|
||||
serverVersion: proof.serverVersionUpdated === true,
|
||||
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||
};
|
||||
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
||||
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
||||
@@ -159,13 +173,13 @@ jobs:
|
||||
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Ruby and pinned Fastlane
|
||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
||||
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
@@ -388,7 +402,7 @@ jobs:
|
||||
(cd "$artifact" && shasum -a 256 -- * > SHA256SUMS)
|
||||
|
||||
- name: Upload signed IPA
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }}
|
||||
path: output/ios-release/*.ipa
|
||||
@@ -396,7 +410,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload release manifest, dSYM, and checksums
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }}
|
||||
path: |
|
||||
|
||||
@@ -30,53 +30,42 @@ on:
|
||||
description: Google Play release status for manual dispatches
|
||||
required: false
|
||||
type: choice
|
||||
default: completed
|
||||
default: inProgress
|
||||
options:
|
||||
- completed
|
||||
- draft
|
||||
- inProgress
|
||||
- draft
|
||||
- halted
|
||||
push:
|
||||
tags:
|
||||
- "mobile-v*"
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Automated Tests
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: 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
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android AAB and Play upload
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == github.event.repository.default_branch)
|
||||
runs-on: ubuntu-24.04
|
||||
environment: mobile-store-production
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
|
||||
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
|
||||
ANDROID_SIGNING_IDENTITY_REF: github-environment:mobile-store-production/android-keystore
|
||||
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
|
||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
|
||||
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
|
||||
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || 'inProgress' }}
|
||||
PLAY_STORE_USER_FRACTION: "0.01"
|
||||
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Guard current master release
|
||||
@@ -84,50 +73,59 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current=true
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
else
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||
else
|
||||
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Require green Chromium mobile tests before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
TEST_WORKFLOW_RUN_ID: ""
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
@@ -203,17 +201,64 @@ jobs:
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
|
||||
|
||||
- name: Record immutable Android artifact proof
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
id: artifact-proof
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_sha256="$(sha256sum "$ANDROID_AAB_PATH" | awk '{print $1}')"
|
||||
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]]
|
||||
echo "ANDROID_AAB_SHA256=$artifact_sha256" >> "$GITHUB_ENV"
|
||||
echo "sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
|
||||
echo "Android AAB SHA-256: \`$artifact_sha256\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Android artifact
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
|
||||
path: ${{ env.ANDROID_AAB_PATH }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Recheck live master before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
live_master_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
|
||||
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
|
||||
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload Android App Bundle to Google Play
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
id: play-upload
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
||||
run: npm run mobile:android:play-upload
|
||||
|
||||
- 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.head_branch == 'master' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||
release_id: ${{ steps.package.outputs.release_id }}
|
||||
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||
steps:
|
||||
- name: Check release commit is current
|
||||
id: branch-head
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
|
||||
- name: Checkout tested commit
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -68,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -111,6 +112,7 @@ jobs:
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:prod
|
||||
env:
|
||||
PLAYWRIGHT_PROD_PREBUILT: "1"
|
||||
PLAYWRIGHT_PROD_WEBKIT: "0"
|
||||
|
||||
- name: Confirm production gate did not mutate dist
|
||||
@@ -147,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Upload release package
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ steps.package-names.outputs.artifact_name }}
|
||||
path: |
|
||||
@@ -160,7 +162,7 @@ jobs:
|
||||
deploy-frontend-production:
|
||||
needs: build-release
|
||||
if: needs.build-release.outputs.current == 'true'
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
environment:
|
||||
name: frontend-production
|
||||
@@ -183,14 +185,14 @@ jobs:
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 5
|
||||
steps:
|
||||
- name: Checkout tested commit
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -217,7 +219,7 @@ jobs:
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Download validated release package
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||
path: release-artifacts
|
||||
@@ -243,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Check release commit is still current
|
||||
id: branch-head
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
@@ -273,28 +275,45 @@ jobs:
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Public live Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: public_live
|
||||
timeout-minutes: 10
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
- name: Detect credentialed live gate configuration
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
id: credentialed_live_config
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" &&
|
||||
-n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||
OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
|
||||
- name: Credentialed live Playwright gate (when configured)
|
||||
if: >-
|
||||
steps.branch-head.outputs.current == 'true' &&
|
||||
steps.credentialed_live_config.outputs.configured == 'true'
|
||||
id: credentialed_live
|
||||
timeout-minutes: 15
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||
@@ -302,26 +321,10 @@ jobs:
|
||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Roll back after live verification failure
|
||||
if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success'
|
||||
timeout-minutes: 10
|
||||
run: node scripts/release/deploy-cpanel.mjs --rollback
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
id: release_manager
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||
@@ -329,7 +332,7 @@ jobs:
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
@@ -341,7 +344,9 @@ jobs:
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Create verified frontend release proof
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
@@ -356,38 +361,92 @@ jobs:
|
||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||
return process.env[name];
|
||||
};
|
||||
const requireSuccessfulStep = (name) => {
|
||||
const outcome = required(name);
|
||||
if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`);
|
||||
return "passed";
|
||||
};
|
||||
const credentialedGate = () => {
|
||||
const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED");
|
||||
if (configured === "false") return "not-configured";
|
||||
if (configured !== "true") {
|
||||
throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`);
|
||||
}
|
||||
return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME");
|
||||
};
|
||||
const proof = {
|
||||
schemaVersion: 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"),
|
||||
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
||||
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
||||
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
||||
buildId: required("RELEASE_BUILD_ID"),
|
||||
livePublicGate: "passed",
|
||||
liveCredentialedGate: "passed",
|
||||
releaseManagerGate: "passed",
|
||||
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||
liveCredentialedGate: credentialedGate(),
|
||||
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||
serverVersionUpdated: true,
|
||||
serverVersionReadBack: "passed",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
||||
NODE
|
||||
env:
|
||||
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }}
|
||||
RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }}
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }}
|
||||
LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }}
|
||||
LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }}
|
||||
RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }}
|
||||
|
||||
- name: Publish verified frontend release proof
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }}
|
||||
path: output/frontend-release-proof/frontend-release-proof.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Roll back after any post-deployment verification failure
|
||||
if: >-
|
||||
failure() && steps.branch-head.outputs.current == 'true' &&
|
||||
steps.deploy.outcome == 'success'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release/deploy-cpanel.mjs --rollback
|
||||
[[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]]
|
||||
export RELEASE_VERSION="${BASH_REMATCH[1]}"
|
||||
export RELEASE_BUILD_ID="${BASH_REMATCH[2]}"
|
||||
npm run release:update-server-version
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
|
||||
@@ -48,15 +48,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.event_name == 'push' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
|
||||
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
|
||||
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
|
||||
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
|
||||
# - FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL: full-suite parallelism while PR E2E runs beside it.
|
||||
# GitHub-hosted target: ["ubuntu-24.04"], with PR parallelism 10, concurrent full parallelism 26,
|
||||
# and standalone scheduled full parallelism 36. This keeps the workflow peak at 36 hosted jobs.
|
||||
jobs:
|
||||
format-tests:
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
@@ -78,10 +80,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -94,10 +96,15 @@ jobs:
|
||||
- name: Check frontend test formatting
|
||||
run: npm run format:tests:check
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
quality-checks:
|
||||
name: Quality-${{ matrix.check }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
check: [lint, i18n, build, unit-fast, unit-serial]
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
||||
@@ -115,29 +122,68 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check i18n source consistency
|
||||
run: npm run i18n:v2:check
|
||||
|
||||
- name: Build sanity check
|
||||
run: npm run build
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Run quality check
|
||||
shell: bash
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
MATRIX_CHECK: ${{ matrix.check }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_CHECK" in
|
||||
lint)
|
||||
npm run lint
|
||||
;;
|
||||
i18n)
|
||||
npm run i18n:v2:check
|
||||
;;
|
||||
build)
|
||||
npm run build
|
||||
;;
|
||||
unit-fast)
|
||||
npm run text:check-encoding
|
||||
npm run test:unit:fast
|
||||
;;
|
||||
unit-serial)
|
||||
VITEST_BATCH_SIZE=5 npm run test:unit:serial
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported quality check: $MATRIX_CHECK" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
build-and-unit:
|
||||
if: ${{ always() }}
|
||||
name: Build and unit summary
|
||||
needs: [format-tests, quality-checks]
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify quality jobs succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for required_job in FORMAT_TESTS_RESULT QUALITY_CHECKS_RESULT; do
|
||||
result="${!required_job:-missing}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "${required_job}=${result}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
e2e-targeted:
|
||||
if: >
|
||||
@@ -177,10 +223,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -301,7 +347,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-targeted-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -329,7 +375,7 @@ jobs:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
suite: [changed-1-of-2, changed-2-of-2, smoke, pr, ct]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
@@ -353,7 +399,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -390,7 +436,7 @@ jobs:
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -405,8 +451,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$MATRIX_SUITE" in
|
||||
core) suite_offset=0 ;;
|
||||
changed) suite_offset=10 ;;
|
||||
changed-1-of-2) suite_offset=0 ;;
|
||||
changed-2-of-2) suite_offset=10 ;;
|
||||
smoke) suite_offset=20 ;;
|
||||
pr) suite_offset=30 ;;
|
||||
ct) suite_offset=40 ;;
|
||||
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$MATRIX_PROJECT" in
|
||||
@@ -414,7 +463,7 @@ jobs:
|
||||
chromium-mobile) project_offset=2 ;;
|
||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
port_seed=$((21000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -485,13 +534,27 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
||||
fi
|
||||
case "$MATRIX_SUITE" in
|
||||
ct)
|
||||
npm run test:ct -- --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
smoke)
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
pr)
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
;;
|
||||
changed-1-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=1/2 --pass-with-no-tests
|
||||
;;
|
||||
changed-2-of-2)
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF" -- --shard=2/2 --pass-with-no-tests
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
@@ -502,7 +565,7 @@ jobs:
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
path: |
|
||||
@@ -539,26 +602,38 @@ jobs:
|
||||
e2e-full:
|
||||
if: >
|
||||
always() &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
||||
(
|
||||
github.event_name != 'workflow_dispatch' ||
|
||||
inputs.mode == 'full' ||
|
||||
needs.e2e-targeted.result == 'success'
|
||||
)
|
||||
needs: [build-and-unit, e2e-pr, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
needs: [build-and-unit, e2e-targeted]
|
||||
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') > 0 && github.event_name == 'schedule' && fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '36') || fromJSON(vars.FRONTEND_CI_FULL_E2E_CONCURRENT_MAX_PARALLEL || '26') }}
|
||||
matrix:
|
||||
browser: [chromium, webkit, firefox]
|
||||
device: [mobile, desktop, tablet]
|
||||
role: [superuser, admin, customer, subuser]
|
||||
shard_index: [1, 2]
|
||||
browser: [chromium, webkit, firefox]
|
||||
exclude:
|
||||
- role: customer
|
||||
shard_index: 2
|
||||
- role: subuser
|
||||
shard_index: 2
|
||||
include:
|
||||
- browser: chromium
|
||||
browser_label: Chromium
|
||||
@@ -570,7 +645,7 @@ jobs:
|
||||
browser_label: Firefox
|
||||
browser_install: firefox
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: off
|
||||
@@ -591,10 +666,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -604,6 +679,8 @@ jobs:
|
||||
MATRIX_ROLE: ${{ matrix.role }}
|
||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||
MATRIX_DEVICE: ${{ matrix.device }}
|
||||
MATRIX_SHARD_INDEX: ${{ matrix.shard_index }}
|
||||
MATRIX_SHARD_TOTAL: ${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -626,7 +703,12 @@ jobs:
|
||||
tablet) device_offset=3 ;;
|
||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
case "$MATRIX_SHARD_INDEX" in
|
||||
1) shard_offset=0 ;;
|
||||
2) shard_offset=400 ;;
|
||||
*) echo "Unsupported Playwright shard index: $MATRIX_SHARD_INDEX" >&2; exit 1 ;;
|
||||
esac
|
||||
port_seed=$((22000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset + shard_offset))
|
||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
||||
mkdir -p "$lock_root"
|
||||
chmod 1777 "$lock_root" 2>/dev/null || true
|
||||
@@ -660,8 +742,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}" \
|
||||
scripts/ci/runner-diagnostics.sh "before Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" -- "${docker_cmd[@]}"
|
||||
SYSTEMD_INHIBIT_REASON="Frontend Playwright full ${MATRIX_BROWSER}/${MATRIX_DEVICE}/${MATRIX_ROLE}/shard-${MATRIX_SHARD_INDEX}-of-${MATRIX_SHARD_TOTAL}" \
|
||||
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
@@ -676,6 +758,8 @@ jobs:
|
||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||
--env MATRIX_SHARD_INDEX="$MATRIX_SHARD_INDEX" \
|
||||
--env MATRIX_SHARD_TOTAL="$MATRIX_SHARD_TOTAL" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
@@ -696,23 +780,52 @@ jobs:
|
||||
}
|
||||
install_dependencies
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE" --shard="$MATRIX_SHARD_INDEX/$MATRIX_SHARD_TOTAL"
|
||||
'
|
||||
|
||||
- name: Runner diagnostics after Playwright failure
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}"
|
||||
run: scripts/ci/runner-diagnostics.sh "after Playwright full ${{ matrix.browser }}/${{ matrix.device }}/${{ matrix.role }}/shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
|
||||
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
|
||||
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}-shard-${{ matrix.shard_index }}-of-${{ (matrix.role == 'superuser' || matrix.role == 'admin') && 2 || 1 }}.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
full-e2e-summary:
|
||||
if: >
|
||||
always() &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||
)
|
||||
) &&
|
||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||
name: Full E2E summary
|
||||
needs: [e2e-full]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify full E2E succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FULL_E2E_RESULT: ${{ needs.e2e-full.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$FULL_E2E_RESULT" != "success" ]]; then
|
||||
echo "E2E_FULL_RESULT=${FULL_E2E_RESULT:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
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-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
@@ -41,7 +42,8 @@ GEM
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
excon (1.6.0)
|
||||
logger
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
@@ -71,41 +73,45 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.229.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@@ -120,41 +126,46 @@ GEM
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
google-apis-androidpublisher_v3 (0.105.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.65.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
@@ -163,7 +174,7 @@ GEM
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (2.10.3)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
@@ -173,10 +184,12 @@ GEM
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
@@ -226,7 +239,7 @@ PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.229.1)
|
||||
fastlane (= 2.237.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.12p206
|
||||
|
||||
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
||||
server: {
|
||||
androidScheme: "https",
|
||||
},
|
||||
plugins: {
|
||||
StatusBar: {
|
||||
overlaysWebView: false,
|
||||
style: "LIGHT",
|
||||
backgroundColor: "#FFFFFFFF",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -1,31 +1,32 @@
|
||||
# Apple App Store Release Runbook
|
||||
|
||||
This is the operating runbook for the public iOS application and its signed
|
||||
GitHub Actions delivery. Public review submission and public release remain
|
||||
human actions in App Store Connect.
|
||||
GitHub Actions delivery. Public review submission remains a human action in App
|
||||
Store Connect; the approved version releases automatically after Apple approval.
|
||||
|
||||
## Storefront record
|
||||
|
||||
Create or reconcile one App Store Connect record:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Name | Truck Wash Kundeportal |
|
||||
| Bundle ID | `io.truckwash.app` |
|
||||
| SKU | `truckwash-ios` |
|
||||
| Primary language | Danish |
|
||||
| Category | Business |
|
||||
| Price | Free |
|
||||
| Availability | Denmark only |
|
||||
| Support URL | `https://truckwash.io/support` |
|
||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||
| Marketing URL | `https://truckwash.io/` |
|
||||
| Release | Manual after approval |
|
||||
| Setting | Value |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| Name | Truck Wash |
|
||||
| Bundle ID | `io.truckwash.app` |
|
||||
| SKU | `truckwash-ios` |
|
||||
| Primary language | Danish |
|
||||
| Category | Business |
|
||||
| Price | Free |
|
||||
| Availability | Denmark only |
|
||||
| Support URL | `https://truckwash.io/support` |
|
||||
| Privacy URL | `https://truckwash.io/privacy-policy` |
|
||||
| Marketing URL | `https://truckwash.io/` |
|
||||
| Release | Automatically after approval |
|
||||
|
||||
Use the standard Apple EULA and do not configure in-app purchases. Payments in
|
||||
the product cover physical truck-wash services. Keep iPhone and iPad enabled;
|
||||
disable Apple-silicon Mac and Vision Pro compatibility until those targets have
|
||||
been tested deliberately.
|
||||
been tested deliberately. Do not enable preorder or phased release for version
|
||||
`1.0.0`, and disable automatic availability in newly added territories.
|
||||
|
||||
The Account Holder or Admin must complete these console-only items before the
|
||||
first candidate:
|
||||
@@ -95,7 +96,9 @@ Configure two GitHub environments:
|
||||
Private repositories on the Team plan cannot rely on environment required
|
||||
reviewers. Protect `ios-v*` creation/update/deletion with a repository ruleset
|
||||
limited to release managers. Manual App Review submission is the final human
|
||||
approval.
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
verifies the exact processed App Store build, enforces complete screenshots,
|
||||
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,
|
||||
privacy/export/age answers, and candidate build. Submit manually.
|
||||
6. Release the first Denmark version manually after approval. Use phased release
|
||||
for later updates unless there is a reason not to.
|
||||
6. Submit version `1.0.0` for review. Apple releases it automatically after
|
||||
approval. Do not use phased release for `1.0.0`; use phased release for later
|
||||
updates unless there is a reason not to.
|
||||
7. Merge the next `ios/release.json` version bump before further delivery after
|
||||
Apple closes the released version to new builds.
|
||||
|
||||
|
||||
@@ -46,33 +46,22 @@ Add these environment **secrets**:
|
||||
- `PRODUCTION_FTP_PASSWORD`
|
||||
- `PRODUCTION_FTP_PATH`
|
||||
- `PRODUCTION_ACTIVATION_KEY`
|
||||
- `PRODUCTION_CPANEL_USER`
|
||||
- `PRODUCTION_CPANEL_API_TOKEN`
|
||||
|
||||
The API `.env` contains legacy values under the first four names, but production
|
||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||
deployment unchanged.
|
||||
|
||||
The cPanel token is separate from the FTP password. Create it in cPanel under
|
||||
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
|
||||
uses the token for fail-closed directory and release-state inspection. It does
|
||||
not use legacy Fileman mutation calls to replace symlinks: on this server those
|
||||
calls can follow the target instead of renaming the link itself. Revoke and
|
||||
rotate the token if it is ever exposed.
|
||||
The hosted release path deliberately does not call the remote cPanel API.
|
||||
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||
activator, exact inventory comparison, and public manifest verification.
|
||||
|
||||
Add these environment **variables**:
|
||||
|
||||
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
|
||||
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
|
||||
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||
|
||||
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
|
||||
fallback. The cPanel URL and path deliberately fail closed when absent. The
|
||||
production environment must keep the explicit
|
||||
`https://server.red-block.com:2083` cPanel origin: the public origin serves
|
||||
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
|
||||
cPanel JSON API.
|
||||
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||
|
||||
### Create the dedicated FTP credentials
|
||||
|
||||
@@ -88,53 +77,26 @@ cPanel JSON API.
|
||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||
these frontend-only credentials back into the API `.env`.
|
||||
|
||||
### Create the missing cPanel credentials
|
||||
|
||||
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
|
||||
separately; do not reuse the FTP password as an API token.
|
||||
|
||||
1. Sign in to the cPanel account that owns the frontend deployment root.
|
||||
2. Record the exact cPanel account username shown in **General Information**.
|
||||
Add it to the `frontend-production` environment as the
|
||||
`PRODUCTION_CPANEL_USER` secret.
|
||||
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
|
||||
hosting provider to enable API Tokens in WHM Feature Manager.
|
||||
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
|
||||
expiration date that matches the team's rotation policy. Expiration cannot
|
||||
be edited later, so add a reminder before that date.
|
||||
5. Click **Create**, copy the token immediately, and add it to the same GitHub
|
||||
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
|
||||
again after leaving the page.
|
||||
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
|
||||
the GitHub secret has been saved.
|
||||
7. Before merging, run the deployment audit against the configured API origin.
|
||||
It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable
|
||||
releases. Do not broaden the token or deployment root beyond this cPanel
|
||||
account.
|
||||
|
||||
The current production token is named `github-pleno-vue-production` and
|
||||
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
|
||||
secret before that date, then revoke the replaced token in cPanel.
|
||||
|
||||
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||
Use **Add secret** for credentials and **Add variable** for the two URLs and the
|
||||
cPanel deployment path.
|
||||
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||
Environment values are available only to the deployment job that names this
|
||||
environment, and configured protection rules are evaluated before its secrets
|
||||
are released.
|
||||
|
||||
The existing live-test, Release Manager, and server-version secrets used by
|
||||
`release.yml` must remain configured. The self-hosted deployment job installs
|
||||
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
|
||||
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
|
||||
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||
and `sha256sum`.
|
||||
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
||||
`sha256sum` for the account-scoped activator.
|
||||
|
||||
## cPanel layout and one-time bootstrap
|
||||
|
||||
The production FTP account is jailed directly to the deployment root, so its
|
||||
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
|
||||
relative to the cPanel account home. The helper creates this layout below it:
|
||||
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||
`frontend-deployments` directory below the account home. The helper creates
|
||||
this layout below it:
|
||||
|
||||
```text
|
||||
archives/
|
||||
@@ -146,7 +108,7 @@ current -> releases/<release-id>/dist
|
||||
```
|
||||
|
||||
The domain's document root must resolve to
|
||||
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
|
||||
`<cPanel account home>/frontend-deployments/current`, not to the deployment
|
||||
root itself. This stable document-root path is what makes replacing `current`
|
||||
atomic: every HTTP request resolves either the complete old release or the
|
||||
complete new release, never a partly uploaded directory.
|
||||
@@ -172,9 +134,8 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
|
||||
listing.
|
||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||
8. Test the cPanel token against the exact host and port. The workflow performs
|
||||
read-only state checks and refuses deployment if `current` or the captured
|
||||
rollback release is missing.
|
||||
8. The server-side activator, rather than the hosted runner, validates that
|
||||
`current` and the captured rollback release exist before every switch.
|
||||
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
||||
hexadecimal form in the protected `frontend-production` environment as
|
||||
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
||||
@@ -205,47 +166,33 @@ first automated run into an unreviewed production cutover.
|
||||
|
||||
### Auditing or restoring the primary webroot
|
||||
|
||||
Use the protected **cPanel Root Audit and Restore** workflow if the primary
|
||||
domain starts showing a directory index or returns 404 for files that cPanel
|
||||
lists in `public_html`. The `audit` mode is read-only: it reports the exact
|
||||
`public_html` entry, whether the internal `current` link can serve the required
|
||||
release files, domain document roots, and retained recovery candidates without
|
||||
printing the cPanel token. API2 does not expose a documented symlink-target
|
||||
field, so the audit deliberately reports `rootTargetVerified: false` instead
|
||||
of claiming that an arbitrary `public_html` link follows `current`; the live
|
||||
HTTP checks remain the source of truth for service health. The audit fails
|
||||
closed if any domain record lacks an identity or document root, and restore is
|
||||
blocked while an addon or subdomain is rooted below `public_html`.
|
||||
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||
runner label would risk sending production cPanel secrets to a self-hosted
|
||||
runner, so that workflow has been removed.
|
||||
|
||||
If the regression followed the one-time webroot exchange and both the active
|
||||
webroot and selected recovery are physical directories, select `restore`
|
||||
and copy one exact recovery entry from the audit, including the retained
|
||||
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
|
||||
The workflow requires the
|
||||
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
|
||||
exact token string from that audit. The token is an optimistic-concurrency
|
||||
guard over the cPanel metadata visible to the audit; it is not a content hash
|
||||
or a substitute for validating the selected recovery. Restore also rejects an
|
||||
unreadable physical directory. Restore also rejects symbolic-link roots and
|
||||
recoveries because legacy cPanel Fileman may follow their targets rather than
|
||||
rename the links. It renames the current physical entry to a run-specific
|
||||
`public_html.failed-*` path, restores the retained entry, and
|
||||
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
|
||||
any mutation response is lost or any check fails, it reconciles the observed
|
||||
account-home entries and reinstates the pre-restore cPanel state. It never
|
||||
deletes the recovery or displaced webroot, and reports manual intervention if
|
||||
the expected entries cannot be proven after compensation.
|
||||
If the primary domain starts showing a directory index or returns 404 for files
|
||||
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||
or the hosting provider. Before replacing anything, confirm the exact
|
||||
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||
replace the root while an addon or subdomain document root is nested below it.
|
||||
Restore only a verified physical directory, retain the displaced webroot, and
|
||||
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||
releases do not depend on remote cPanel API access.
|
||||
|
||||
## Caching and compatibility
|
||||
|
||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||
service-worker control files always revalidate. The deployer retains at least
|
||||
the active and rollback releases and keeps five recent release directories by
|
||||
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
|
||||
falls outside that validated retention set, its directory and matching ZIP and
|
||||
checksum are removed over FTPS. Cleanup failure is reported without rolling
|
||||
back an otherwise verified deployment.
|
||||
service-worker control files always revalidate. The deployer retains every
|
||||
immutable release while hosted runners cannot query reliable cPanel
|
||||
modification metadata. Each successful run reports that retention cleanup is
|
||||
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||
releases and their matching archives; never remove the active or recorded
|
||||
rollback target.
|
||||
|
||||
Because the document root switches as one symlink, an already-loaded page may
|
||||
still request an asset from its previous release after activation. The current
|
||||
|
||||
@@ -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
|
||||
`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.
|
||||
- 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`.
|
||||
Its required secrets are:
|
||||
|
||||
@@ -37,6 +42,11 @@ iOS uses three separate workflows:
|
||||
storefront candidate, without rebuilding or submission.
|
||||
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
|
||||
|
||||
Before signing or uploading to TestFlight, the workflow resolves the exact
|
||||
current-master test run and requires all six full WebKit-mobile role shards to
|
||||
be green. App Store candidates reuse that gated TestFlight build and do not
|
||||
rebuild it.
|
||||
|
||||
The GitHub environments and variables are documented in
|
||||
`docs/app-store-release.md`. The repository-level
|
||||
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
||||
|
||||
|
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,
|
||||
force: true,
|
||||
submit_for_review: false,
|
||||
automatic_release: false,
|
||||
automatic_release: true,
|
||||
phased_release: false,
|
||||
run_precheck_before_submit: false,
|
||||
precheck_include_in_app_purchases: false
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Truck Wash uses the camera to scan QR codes and vehicle registration plates.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Truck Wash uses your location to find or confirm the nearest truck wash department when you choose a location feature; the app does not track your location in the background.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Truck Wash uses your location while the app is open to find or confirm the nearest truck wash department.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"CFBundleDisplayName" = "Truck Wash";
|
||||
"NSCameraUsageDescription" = "Truck Wash bruger kameraet til at scanne QR-koder og registreringsnumre, når du vælger en scanningsfunktion.";
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash bruger din placering til at finde eller bekræfte den nærmeste Truck Wash-afdeling, når du vælger en placeringsfunktion; appen sporer ikke din placering i baggrunden.";
|
||||
"NSLocationWhenInUseUsageDescription" = "Truck Wash bruger din placering, mens appen er åben, til at finde eller bekræfte den nærmeste Truck Wash-afdeling.";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"CFBundleDisplayName" = "Truck Wash";
|
||||
"NSCameraUsageDescription" = "Truck Wash uses the camera to scan QR codes and vehicle registration plates when you choose a scanning feature.";
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription" = "Truck Wash uses your location to find or confirm the nearest Truck Wash department when you choose a location feature; the app does not track your location in the background.";
|
||||
"NSLocationWhenInUseUsageDescription" = "Truck Wash uses your location while the app is open to find or confirm the nearest Truck Wash department.";
|
||||
|
||||
@@ -12,7 +12,8 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.4.1"),
|
||||
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation")
|
||||
.package(name: "CapacitorGeolocation", path: "../../../node_modules/@capacitor/geolocation"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -20,7 +21,8 @@ let package = Package(
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation")
|
||||
.product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"marketingVersion": "1.0.0",
|
||||
"marketingVersion": "1.0.1",
|
||||
"bundleId": "io.truckwash.app",
|
||||
"minimumIosVersion": "15.0"
|
||||
}
|
||||
|
||||
@@ -3534,6 +3534,19 @@ paths:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
auto_deactivation:
|
||||
type: object
|
||||
required: [at, timezone, label]
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
timezone:
|
||||
type: string
|
||||
example: Europe/Copenhagen
|
||||
label:
|
||||
type: string
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
put:
|
||||
@@ -3566,6 +3579,21 @@ paths:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
auto_deactivation:
|
||||
type: object
|
||||
required: [at, timezone, label]
|
||||
properties:
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
timezone:
|
||||
type: string
|
||||
example: Europe/Copenhagen
|
||||
label:
|
||||
type: string
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@bubblewrap/cli": "^1.24.1",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/status-bar": "^8.0.3",
|
||||
"@creativebulma/bulma-badge": "^1.0.1",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
@@ -82,6 +83,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jimp": "0.22.12",
|
||||
"jsdom": "^29.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass-embedded": "^1.81.0",
|
||||
@@ -2131,6 +2133,14 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
|
||||
"integrity": "sha512-csSpfNeN49Hx9JaQBSJEIiEbOLtXg3kcc2IpScq2fu5L520h3AWEvsxoH8Srk1jxfRKepaJ4S4sqSC3foI4AgA==",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
@@ -10087,6 +10097,12 @@
|
||||
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -10994,6 +11010,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
@@ -11062,6 +11090,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -13627,6 +13664,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"@bubblewrap/cli": "^1.24.1",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/status-bar": "^8.0.3",
|
||||
"@creativebulma/bulma-badge": "^1.0.1",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
@@ -154,6 +155,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jimp": "0.22.12",
|
||||
"jsdom": "^29.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass-embedded": "^1.81.0",
|
||||
|
||||
@@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://127.0.0.1:4173";
|
||||
const isCI = !!process.env.CI;
|
||||
const usePrebuiltDist = ["1", "true"].includes(
|
||||
String(process.env.PLAYWRIGHT_PROD_PREBUILT || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
|
||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||
|
||||
@@ -73,7 +78,7 @@ export default defineConfig({
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run preview:prod",
|
||||
command: usePrebuiltDist ? "npm run preview -- --host 127.0.0.1 --port 4173" : "npm run preview:prod",
|
||||
url: baseURL,
|
||||
timeout: 240_000,
|
||||
reuseExistingServer: !isCI,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
// AUT-1 smoke run — capture the before/after visual diff for the
|
||||
// `primary-button hover state` change in `src/assets/main.css`.
|
||||
//
|
||||
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
|
||||
// against the project's bundled Bulma stylesheet, then takes two
|
||||
// screenshots:
|
||||
//
|
||||
// desktop-before.png — Bulma defaults, button idle (no hover effect).
|
||||
// desktop-after.png — With the AUT-1 CSS rules applied, button
|
||||
// hovered (lift + brightness shift visible).
|
||||
// mobile-before.png — Same as desktop-before, captured at the
|
||||
// Pixel 5 viewport.
|
||||
// mobile-after.png — Same as desktop-after, captured at the
|
||||
// Pixel 5 viewport.
|
||||
//
|
||||
// Output is written to tests/visual-previews/AUT-1/. The script is
|
||||
// idempotent: existing files are overwritten, not appended to.
|
||||
//
|
||||
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
|
||||
// (Chromium must already be installed via `npm run test:e2e:install`.)
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
const PREVIEW_HTML = path.join(
|
||||
REPO_ROOT,
|
||||
"tests/visual-previews/AUT-1/preview.html",
|
||||
);
|
||||
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
|
||||
|
||||
// The AUT-1 hover rules — kept in lockstep with the diff in
|
||||
// src/assets/main.css. We inject these via a <style> tag on the
|
||||
// "after" pass and leave them off for the "before" pass so the
|
||||
// screenshots show the same Bulma theme but with vs without the new
|
||||
// hover effect.
|
||||
const AUT1_HOVER_CSS = `
|
||||
.button.is-primary {
|
||||
transition:
|
||||
filter 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.button.is-primary:hover,
|
||||
.button.is-primary.is-hovered {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button.is-primary:active,
|
||||
.button.is-primary.is-active {
|
||||
filter: brightness(0.95);
|
||||
transform: translateY(0);
|
||||
}
|
||||
.button.is-primary:focus-visible {
|
||||
filter: brightness(1.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
|
||||
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(pathToFileUrl(PREVIEW_HTML));
|
||||
if (withHoverCss) {
|
||||
await page.addStyleTag({ content: AUT1_HOVER_CSS });
|
||||
}
|
||||
const target = page.locator(
|
||||
hover
|
||||
? '[data-testid="primary-button-hover"]'
|
||||
: '[data-testid="primary-button-idle"]',
|
||||
);
|
||||
const stage = page.locator(
|
||||
hover ? "#stage-hover" : "#stage-idle",
|
||||
);
|
||||
if (hover) {
|
||||
await target.hover();
|
||||
// Wait for the 0.15s transition to settle.
|
||||
await page.waitForTimeout(220);
|
||||
}
|
||||
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
|
||||
const destination = path.join(OUTPUT_DIR, filename);
|
||||
await stage.screenshot({ path: destination, type: "png" });
|
||||
await context.close();
|
||||
return destination;
|
||||
}
|
||||
|
||||
function pathToFileUrl(filePath) {
|
||||
// Playwright's `file://` URLs need absolute paths. On POSIX this is
|
||||
// straightforward; on Windows this helper keeps the script cross-platform
|
||||
// should it ever run there.
|
||||
const absolute = path.resolve(filePath);
|
||||
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const viewports = [
|
||||
{ label: "desktop", viewport: { width: 1280, height: 720 } },
|
||||
{
|
||||
label: "mobile",
|
||||
viewport: { width: 393, height: 851 },
|
||||
},
|
||||
];
|
||||
for (const { label, viewport } of viewports) {
|
||||
for (const withHoverCss of [false, true]) {
|
||||
for (const hover of [false, true]) {
|
||||
// We only want idle on the "before" pass and hover on the
|
||||
// "after" pass — skip the two redundant combinations.
|
||||
const isIdleShot = !hover;
|
||||
const isBeforeShot = !withHoverCss;
|
||||
if (isIdleShot !== isBeforeShot) continue;
|
||||
const file = await captureVariant({
|
||||
browser,
|
||||
viewport,
|
||||
label,
|
||||
withHoverCss,
|
||||
hover,
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("wrote", path.relative(REPO_ROOT, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("aut-1 capture failed:", err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
|
||||
|
||||
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||
const output = outputText(result);
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
/Playwright does not support .* on /i.test(output)
|
||||
);
|
||||
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
|
||||
};
|
||||
|
||||
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||
@@ -78,7 +75,7 @@ console.warn(
|
||||
[
|
||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||
"The self-hosted runner image must provide the required browser system libraries.",
|
||||
"The runner image must provide the required browser system libraries.",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
|
||||
@@ -1,285 +1,420 @@
|
||||
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { argv, env, exit } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const command = argv[2];
|
||||
const baseUrl = "https://api.appstoreconnect.apple.com/v1";
|
||||
const required = (name) => {
|
||||
const value = env[name];
|
||||
if (!value) throw new Error(`Missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
export const APP_STORE_CONNECT_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
|
||||
export const APP_STORE_CONNECT_V2_BASE_URL = "https://api.appstoreconnect.apple.com/v2";
|
||||
export const EXPECTED_RELEASE_TYPE = "AFTER_APPROVAL";
|
||||
export const TESTFLIGHT_BETA_LOCALE = "da";
|
||||
export const EXPECTED_AVAILABLE_TERRITORIES = ["DNK"];
|
||||
|
||||
const defaultSleep = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
||||
const base64url = (value) => Buffer.from(value).toString("base64url");
|
||||
|
||||
const token = () => {
|
||||
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
||||
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
||||
if (!key.includes("PRIVATE KEY"))
|
||||
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { aud: "appstoreconnect-v1", iat: now, exp: now + 1_200 };
|
||||
if (env.APP_STORE_CONNECT_ISSUER_ID) payload.iss = env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
else payload.sub = "user";
|
||||
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
||||
const encodedPayload = base64url(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = sign("sha256", Buffer.from(signingInput), {
|
||||
key: createPrivateKey(key),
|
||||
dsaEncoding: "ieee-p1363",
|
||||
});
|
||||
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 () => {
|
||||
export const appStoreVersionsPath = ({ appId, version, includeBuild = false }) => {
|
||||
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[versionString]": version(),
|
||||
"filter[versionString]": version,
|
||||
limit: "10",
|
||||
});
|
||||
const storeVersions = await request(`/appStoreVersions?${storeVersionParams}`);
|
||||
const storeVersion = (storeVersions?.data ?? []).find(
|
||||
(candidate) => candidate?.attributes?.versionString === version()
|
||||
);
|
||||
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||
throw new Error(
|
||||
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
|
||||
);
|
||||
}
|
||||
const builds = await allBuildsForVersion();
|
||||
const numbers = builds
|
||||
.map((build) => Number.parseInt(build?.attributes?.version, 10))
|
||||
.filter((number) => Number.isSafeInteger(number) && number > 0);
|
||||
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
||||
writeOutput("build_number", next);
|
||||
console.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
||||
if (includeBuild) params.set("include", "build");
|
||||
return `/apps/${encodeURIComponent(appId)}/appStoreVersions?${params}`;
|
||||
};
|
||||
|
||||
const waitForBuild = async () => {
|
||||
const deadline = Date.now() + Number(env.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
||||
let build = null;
|
||||
while (Date.now() < deadline) {
|
||||
build = await findExactBuild();
|
||||
const state = build?.attributes?.processingState;
|
||||
if (state === "VALID") return build;
|
||||
if (["FAILED", "INVALID"].includes(state)) throw new Error(`App Store Connect processing ended in ${state}`);
|
||||
console.log(
|
||||
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
||||
);
|
||||
await sleep(30_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
||||
};
|
||||
export const createAppStoreConnectClient = ({
|
||||
environment = env,
|
||||
fetchImpl = globalThis.fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
now = () => Date.now(),
|
||||
logger = console,
|
||||
tokenProvider,
|
||||
outputWriter,
|
||||
} = {}) => {
|
||||
const required = (name) => {
|
||||
const value = environment[name];
|
||||
if (!value) throw new Error(`Missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const waitAndDistribute = async () => {
|
||||
const build = await waitForBuild();
|
||||
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
||||
const localizationParams = new URLSearchParams({ "filter[build]": build.id, "filter[locale]": "da-DK" });
|
||||
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
||||
const existingLocalization = (localizations?.data ?? [])[0];
|
||||
const whatsNew = env.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
||||
if (existingLocalization) {
|
||||
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
||||
const appId = () => required("APP_STORE_CONNECT_APP_ID");
|
||||
const bundleId = () => environment.IOS_BUNDLE_ID || "io.truckwash.app";
|
||||
const version = () => required("IOS_MARKETING_VERSION");
|
||||
const buildNumber = () => required("IOS_BUILD_NUMBER");
|
||||
|
||||
const token = () => {
|
||||
if (tokenProvider) return tokenProvider();
|
||||
const keyId = required("APP_STORE_CONNECT_API_KEY_ID");
|
||||
const key = Buffer.from(required("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64"), "base64").toString("utf8");
|
||||
if (!key.includes("PRIVATE KEY")) {
|
||||
throw new Error("App Store Connect API key is not a base64-encoded .p8 private key");
|
||||
}
|
||||
const issuedAt = Math.floor(now() / 1_000);
|
||||
const payload = { aud: "appstoreconnect-v1", iat: issuedAt, exp: issuedAt + 1_200 };
|
||||
if (environment.APP_STORE_CONNECT_ISSUER_ID) payload.iss = environment.APP_STORE_CONNECT_ISSUER_ID;
|
||||
else payload.sub = "user";
|
||||
const encodedHeader = base64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
||||
const encodedPayload = base64url(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = sign("sha256", Buffer.from(signingInput), {
|
||||
key: createPrivateKey(key),
|
||||
dsaEncoding: "ieee-p1363",
|
||||
});
|
||||
return `${signingInput}.${base64url(signature)}`;
|
||||
};
|
||||
|
||||
const writeOutput = (key, value) => {
|
||||
if (outputWriter) outputWriter(key, String(value));
|
||||
else if (environment.GITHUB_OUTPUT) appendFileSync(environment.GITHUB_OUTPUT, `${key}=${value}\n`);
|
||||
else logger.log(`${key}=${value}`);
|
||||
};
|
||||
|
||||
const request = async (path, options = {}, attempt = 1) => {
|
||||
const response = await fetchImpl(path.startsWith("http") ? path : `${APP_STORE_CONNECT_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token()}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < 5) {
|
||||
const retryAfter = Number.parseInt(response.headers?.get?.("retry-after") || "", 10);
|
||||
const delay = Number.isSafeInteger(retryAfter)
|
||||
? Math.min(30_000, retryAfter * 1_000)
|
||||
: Math.min(30_000, 2 ** attempt * 1_000);
|
||||
await sleepImpl(delay);
|
||||
return request(path, options, attempt + 1);
|
||||
}
|
||||
const detail =
|
||||
body?.errors
|
||||
?.map((error) => error.detail || error.title)
|
||||
.filter(Boolean)
|
||||
.join("; ") ||
|
||||
body?.raw ||
|
||||
response.statusText;
|
||||
throw new Error(`App Store Connect ${options.method ?? "GET"} ${path} failed (${response.status}): ${detail}`);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const collectPages = async (path) => {
|
||||
let url = path;
|
||||
const data = [];
|
||||
const included = [];
|
||||
while (url) {
|
||||
const page = await request(url);
|
||||
data.push(...(page?.data ?? []));
|
||||
included.push(...(page?.included ?? []));
|
||||
url = page?.links?.next ?? null;
|
||||
}
|
||||
return { data, included };
|
||||
};
|
||||
|
||||
const verifyCredentials = async () => {
|
||||
const app = await request(`/apps/${encodeURIComponent(appId())}`);
|
||||
const actualBundleId = app?.data?.attributes?.bundleId;
|
||||
if (actualBundleId !== bundleId()) {
|
||||
throw new Error(
|
||||
`APP_STORE_CONNECT_APP_ID resolves to ${actualBundleId || "an unknown bundle"}, expected ${bundleId()}`
|
||||
);
|
||||
}
|
||||
logger.log(`Authenticated to App Store Connect for ${actualBundleId}.`);
|
||||
return app.data;
|
||||
};
|
||||
|
||||
const allBuildsForVersion = async () => {
|
||||
const params = new URLSearchParams({
|
||||
"filter[app]": appId(),
|
||||
"filter[preReleaseVersion.version]": version(),
|
||||
limit: "200",
|
||||
});
|
||||
const response = await collectPages(`/builds?${params}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const allStoreVersions = async ({ includeBuild = false } = {}) =>
|
||||
collectPages(appStoreVersionsPath({ appId: appId(), version: version(), includeBuild }));
|
||||
|
||||
const findStoreVersion = async ({ includeBuild = false } = {}) => {
|
||||
const response = await allStoreVersions({ includeBuild });
|
||||
return {
|
||||
storeVersion: response.data.find((candidate) => candidate?.attributes?.versionString === version()),
|
||||
included: response.included,
|
||||
};
|
||||
};
|
||||
|
||||
const findExactBuild = async () => {
|
||||
const builds = await allBuildsForVersion();
|
||||
return builds.find((build) => String(build?.attributes?.version) === buildNumber()) ?? null;
|
||||
};
|
||||
|
||||
const nextBuildNumber = async () => {
|
||||
await verifyCredentials();
|
||||
const { storeVersion } = await findStoreVersion();
|
||||
if (storeVersion?.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||
throw new Error(
|
||||
`App Store version ${version()} is already released; bump ios/release.json before delivering another master build`
|
||||
);
|
||||
}
|
||||
const builds = await allBuildsForVersion();
|
||||
const numbers = builds
|
||||
.map((build) => Number.parseInt(build?.attributes?.version, 10))
|
||||
.filter((number) => Number.isSafeInteger(number) && number > 0);
|
||||
const next = (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
|
||||
writeOutput("build_number", next);
|
||||
logger.log(`Next App Store Connect build for ${version()} is ${next}.`);
|
||||
return next;
|
||||
};
|
||||
|
||||
const waitForBuild = async () => {
|
||||
const deadline = now() + Number(environment.APP_STORE_PROCESSING_TIMEOUT_SECONDS || 3_600) * 1_000;
|
||||
let build = null;
|
||||
while (now() < deadline) {
|
||||
build = await findExactBuild();
|
||||
const state = build?.attributes?.processingState;
|
||||
if (state === "VALID") return build;
|
||||
if (["FAILED", "INVALID"].includes(state)) {
|
||||
throw new Error(`App Store Connect processing ended in ${state}`);
|
||||
}
|
||||
logger.log(
|
||||
build ? `Build ${buildNumber()} is ${state || "processing"}.` : `Waiting for build ${buildNumber()} to appear.`
|
||||
);
|
||||
await sleepImpl(30_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${version()} (${buildNumber()}) to process`);
|
||||
};
|
||||
|
||||
const waitAndDistribute = async () => {
|
||||
const build = await waitForBuild();
|
||||
const groupId = required("TESTFLIGHT_INTERNAL_GROUP_ID");
|
||||
const localizationParams = new URLSearchParams({
|
||||
"filter[build]": build.id,
|
||||
"filter[locale]": TESTFLIGHT_BETA_LOCALE,
|
||||
});
|
||||
const localizations = await request(`/betaBuildLocalizations?${localizationParams}`);
|
||||
const existingLocalization = (localizations?.data ?? [])[0];
|
||||
const whatsNew = environment.TESTFLIGHT_WHAT_TO_TEST || `Automatisk intern build ${version()} (${buildNumber()}).`;
|
||||
if (existingLocalization) {
|
||||
await request(`/betaBuildLocalizations/${encodeURIComponent(existingLocalization.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "betaBuildLocalizations",
|
||||
id: existingLocalization.id,
|
||||
attributes: { whatsNew },
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await request("/betaBuildLocalizations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "betaBuildLocalizations",
|
||||
attributes: { locale: TESTFLIGHT_BETA_LOCALE, whatsNew },
|
||||
relationships: { build: { data: { type: "builds", id: build.id } } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
|
||||
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
|
||||
if (!alreadyAssigned) {
|
||||
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
|
||||
});
|
||||
}
|
||||
writeOutput("app_store_build_id", build.id);
|
||||
logger.log(
|
||||
`${
|
||||
alreadyAssigned ? "Verified" : "Assigned"
|
||||
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
||||
);
|
||||
return build;
|
||||
};
|
||||
|
||||
const verifyCandidate = async () => {
|
||||
await verifyCredentials();
|
||||
const build = await findExactBuild();
|
||||
if (!build) throw new Error(`App Store Connect does not contain ${version()} (${buildNumber()})`);
|
||||
if (build.attributes?.processingState !== "VALID") {
|
||||
throw new Error(`Candidate build is ${build.attributes?.processingState || "not valid"}`);
|
||||
}
|
||||
if (environment.EXPECTED_APP_STORE_BUILD_ID && build.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||
throw new Error(
|
||||
`Candidate App Store build ID ${build.id} does not match release manifest ${environment.EXPECTED_APP_STORE_BUILD_ID}`
|
||||
);
|
||||
}
|
||||
writeOutput("app_store_build_id", build.id);
|
||||
logger.log(`Verified exact candidate ${version()} (${buildNumber()}) as ${build.id}.`);
|
||||
return build;
|
||||
};
|
||||
|
||||
const configureReleasePolicy = async () => {
|
||||
await verifyCredentials();
|
||||
const { storeVersion } = await findStoreVersion();
|
||||
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||
if (storeVersion.attributes?.appStoreState === "READY_FOR_SALE") {
|
||||
throw new Error(`App Store version ${version()} is already released and cannot change release policy`);
|
||||
}
|
||||
await request(`/appStoreVersions/${encodeURIComponent(storeVersion.id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: { type: "betaBuildLocalizations", id: existingLocalization.id, attributes: { whatsNew } },
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await request("/betaBuildLocalizations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "betaBuildLocalizations",
|
||||
attributes: { locale: "da-DK", whatsNew },
|
||||
relationships: { build: { data: { type: "builds", id: build.id } } },
|
||||
type: "appStoreVersions",
|
||||
id: storeVersion.id,
|
||||
attributes: { releaseType: EXPECTED_RELEASE_TYPE },
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
const relationship = await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds?limit=200`);
|
||||
const alreadyAssigned = (relationship?.data ?? []).some((candidate) => candidate.id === build.id);
|
||||
if (!alreadyAssigned) {
|
||||
await request(`/betaGroups/${encodeURIComponent(groupId)}/relationships/builds`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ data: [{ type: "builds", id: build.id }] }),
|
||||
});
|
||||
}
|
||||
writeOutput("app_store_build_id", build.id);
|
||||
console.log(
|
||||
`${
|
||||
alreadyAssigned ? "Verified" : "Assigned"
|
||||
} ${version()} (${buildNumber()}) in internal TestFlight group ${groupId}.`
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
writeOutput("app_store_version_id", storeVersion.id);
|
||||
logger.log(`Configured App Store version ${version()} to release automatically after approval.`);
|
||||
return storeVersion.id;
|
||||
};
|
||||
try {
|
||||
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
env.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
||||
env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
||||
privateKey.export({ type: "pkcs8", format: "pem" })
|
||||
).toString("base64");
|
||||
delete env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
const individual = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
||||
if (individual.sub !== "user" || individual.iss !== undefined)
|
||||
throw new Error("Individual API JWT claim test failed");
|
||||
env.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
const team = JSON.parse(Buffer.from(token().split(".")[1], "base64url").toString("utf8"));
|
||||
if (team.iss !== env.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined)
|
||||
throw new Error("Team API JWT claim test failed");
|
||||
console.log("App Store Connect individual and team JWT claim tests passed.");
|
||||
} finally {
|
||||
if (original.keyId === undefined) delete env.APP_STORE_CONNECT_API_KEY_ID;
|
||||
else env.APP_STORE_CONNECT_API_KEY_ID = original.keyId;
|
||||
if (original.issuer === undefined) delete env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
else env.APP_STORE_CONNECT_ISSUER_ID = original.issuer;
|
||||
if (original.key === undefined) delete env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64;
|
||||
else env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = original.key;
|
||||
|
||||
const verifyStoreVersion = async () => {
|
||||
await verifyCredentials();
|
||||
const { storeVersion, included } = await findStoreVersion({ includeBuild: true });
|
||||
if (!storeVersion) throw new Error(`App Store version ${version()} was not created`);
|
||||
const buildRelationshipId = storeVersion?.relationships?.build?.data?.id;
|
||||
const includedBuild = included.find(
|
||||
(candidate) => candidate.type === "builds" && candidate.id === buildRelationshipId
|
||||
);
|
||||
if (!includedBuild || String(includedBuild?.attributes?.version) !== buildNumber()) {
|
||||
throw new Error(`App Store version ${version()} is not attached to build ${buildNumber()}`);
|
||||
}
|
||||
if (environment.EXPECTED_APP_STORE_BUILD_ID && includedBuild.id !== environment.EXPECTED_APP_STORE_BUILD_ID) {
|
||||
throw new Error(
|
||||
`App Store version ${version()} is attached to ${includedBuild.id}, expected ${
|
||||
environment.EXPECTED_APP_STORE_BUILD_ID
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (storeVersion.attributes?.releaseType !== EXPECTED_RELEASE_TYPE) {
|
||||
throw new Error(
|
||||
`App Store version ${version()} release type is ${
|
||||
storeVersion.attributes?.releaseType || "unknown"
|
||||
}, expected ${EXPECTED_RELEASE_TYPE}`
|
||||
);
|
||||
}
|
||||
writeOutput("app_store_version_id", storeVersion.id);
|
||||
writeOutput("app_store_state", storeVersion.attributes?.appStoreState || "UNKNOWN");
|
||||
writeOutput("release_type", storeVersion.attributes.releaseType);
|
||||
logger.log(
|
||||
`Verified App Store version ${version()} with exact build ${buildNumber()} and ${EXPECTED_RELEASE_TYPE} release policy in ${
|
||||
storeVersion.attributes?.appStoreState || "unknown state"
|
||||
}.`
|
||||
);
|
||||
return storeVersion;
|
||||
};
|
||||
|
||||
const verifyAvailability = async () => {
|
||||
await verifyCredentials();
|
||||
const availability = await request(`/apps/${encodeURIComponent(appId())}/appAvailabilityV2`);
|
||||
const availabilityId = availability?.data?.id;
|
||||
if (!availabilityId) throw new Error("App Store availability was not configured");
|
||||
if (availability?.data?.attributes?.availableInNewTerritories !== false) {
|
||||
throw new Error("App Store availability must not automatically include new territories");
|
||||
}
|
||||
const params = new URLSearchParams({ include: "territory", limit: "200" });
|
||||
const territories = await collectPages(
|
||||
`${APP_STORE_CONNECT_V2_BASE_URL}/appAvailabilities/${encodeURIComponent(
|
||||
availabilityId
|
||||
)}/territoryAvailabilities?${params}`
|
||||
);
|
||||
const available = territories.data
|
||||
.filter((territory) => territory?.attributes?.available === true)
|
||||
.map((territory) => territory?.relationships?.territory?.data?.id)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
if (territories.data.some((territory) => territory?.attributes?.preOrderEnabled === true)) {
|
||||
throw new Error("App Store preorder must remain disabled for version 1.0.0");
|
||||
}
|
||||
if (JSON.stringify(available) !== JSON.stringify(EXPECTED_AVAILABLE_TERRITORIES)) {
|
||||
throw new Error(
|
||||
`App Store availability is ${
|
||||
available.join(", ") || "empty"
|
||||
}, expected Denmark only (${EXPECTED_AVAILABLE_TERRITORIES.join(", ")})`
|
||||
);
|
||||
}
|
||||
writeOutput("available_territories", available.join(","));
|
||||
logger.log("Verified Denmark-only App Store availability with preorder disabled.");
|
||||
return available;
|
||||
};
|
||||
|
||||
return {
|
||||
request,
|
||||
verifyCredentials,
|
||||
allBuildsForVersion,
|
||||
allStoreVersions,
|
||||
findExactBuild,
|
||||
nextBuildNumber,
|
||||
waitForBuild,
|
||||
waitAndDistribute,
|
||||
verifyCandidate,
|
||||
configureReleasePolicy,
|
||||
verifyStoreVersion,
|
||||
verifyAvailability,
|
||||
token,
|
||||
};
|
||||
};
|
||||
|
||||
export const selfTestJwt = async () => {
|
||||
const environment = {};
|
||||
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
environment.APP_STORE_CONNECT_API_KEY_ID = "TESTKEY123";
|
||||
environment.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 = Buffer.from(
|
||||
privateKey.export({ type: "pkcs8", format: "pem" })
|
||||
).toString("base64");
|
||||
const individual = JSON.parse(
|
||||
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
if (individual.sub !== "user" || individual.iss !== undefined) {
|
||||
throw new Error("Individual API JWT claim test failed");
|
||||
}
|
||||
environment.APP_STORE_CONNECT_ISSUER_ID = "00000000-0000-0000-0000-000000000000";
|
||||
const team = JSON.parse(
|
||||
Buffer.from(createAppStoreConnectClient({ environment }).token().split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
if (team.iss !== environment.APP_STORE_CONNECT_ISSUER_ID || team.sub !== undefined) {
|
||||
throw new Error("Team API JWT claim test failed");
|
||||
}
|
||||
console.log("App Store Connect individual and team JWT claim tests passed.");
|
||||
};
|
||||
|
||||
const commands = {
|
||||
"verify-credentials": verifyCredentials,
|
||||
"next-build-number": nextBuildNumber,
|
||||
"wait-and-distribute": waitAndDistribute,
|
||||
"verify-candidate": verifyCandidate,
|
||||
"verify-store-version": verifyStoreVersion,
|
||||
"self-test-jwt": selfTestJwt,
|
||||
export const runCli = async (command = argv[2]) => {
|
||||
const client = createAppStoreConnectClient();
|
||||
const commands = {
|
||||
"verify-credentials": client.verifyCredentials,
|
||||
"next-build-number": client.nextBuildNumber,
|
||||
"wait-and-distribute": client.waitAndDistribute,
|
||||
"verify-candidate": client.verifyCandidate,
|
||||
"configure-release-policy": client.configureReleasePolicy,
|
||||
"verify-store-version": client.verifyStoreVersion,
|
||||
"verify-availability": client.verifyAvailability,
|
||||
"self-test-jwt": selfTestJwt,
|
||||
};
|
||||
if (!commands[command]) {
|
||||
throw new Error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
||||
}
|
||||
await commands[command]();
|
||||
};
|
||||
|
||||
if (!commands[command]) {
|
||||
console.error(`Usage: node scripts/mobile/app-store-connect.mjs ${Object.keys(commands).join("|")}`);
|
||||
exit(2);
|
||||
const isMain = argv[1] && resolve(argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
exit(error?.message?.startsWith("Usage:") ? 2 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
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, "ANDROID_KEYSTORE_FILE");
|
||||
requireText("Info.plist", iosInfoPlist, "NSCameraUsageDescription");
|
||||
requireText("Info.plist", iosInfoPlist, "NSLocationAlwaysAndWhenInUseUsageDescription");
|
||||
requireText("Info.plist", iosInfoPlist, "NSLocationWhenInUseUsageDescription");
|
||||
requireText("project.pbxproj", iosProject, "PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;");
|
||||
requireText("project.pbxproj", iosProject, "PrivacyInfo.xcprivacy in Resources");
|
||||
|
||||
@@ -82,7 +82,7 @@ const checkAndroid = () => {
|
||||
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
||||
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
||||
const track = env.PLAY_STORE_TRACK || "production";
|
||||
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
|
||||
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
|
||||
|
||||
if (!validTracks.has(track)) {
|
||||
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
|
||||
|
||||
@@ -162,7 +162,7 @@ const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
|
||||
|
||||
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
|
||||
const track = env.PLAY_STORE_TRACK || "production";
|
||||
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
|
||||
const status = env.PLAY_STORE_RELEASE_STATUS || "inProgress";
|
||||
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
|
||||
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
|
||||
|
||||
@@ -222,6 +222,12 @@ const writeStepSummary = (summary) => {
|
||||
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
|
||||
};
|
||||
|
||||
const writeOutput = (name, value) => {
|
||||
if (env.GITHUB_OUTPUT) {
|
||||
appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
requireEnvironment();
|
||||
|
||||
@@ -243,9 +249,13 @@ const main = async () => {
|
||||
await commitEdit(accessToken, packageName, editId);
|
||||
|
||||
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}.`);
|
||||
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) {
|
||||
if (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"],
|
||||
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",
|
||||
patterns: [
|
||||
|
||||
@@ -18,10 +18,6 @@ const REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
"RELEASE_ARCHIVE_PATH",
|
||||
"RELEASE_INVENTORY_PATH",
|
||||
"RELEASE_EXPECTED_COMMIT",
|
||||
@@ -33,10 +29,6 @@ const ROLLBACK_REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
];
|
||||
|
||||
export class DeploymentError extends Error {
|
||||
@@ -153,16 +145,29 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
requireString(env, name);
|
||||
}
|
||||
|
||||
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
||||
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
||||
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
||||
const cpanelNames = [
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"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");
|
||||
if (!SAFE_HOST.test(host) || host.includes("..")) {
|
||||
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.");
|
||||
}
|
||||
if (rollbackOnly) {
|
||||
return { ftp, cpanel, activationKey };
|
||||
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
|
||||
}
|
||||
|
||||
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
||||
@@ -224,7 +229,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
|
||||
return {
|
||||
ftp,
|
||||
cpanel,
|
||||
...(cpanel ? { cpanel } : {}),
|
||||
activationKey,
|
||||
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
||||
checksumPath: path.resolve(checksumPath),
|
||||
@@ -242,6 +247,10 @@ function lftpQuote(value) {
|
||||
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function ensureLftpDirectory(value) {
|
||||
return `mkdir -p -f ${lftpQuote(value)}`;
|
||||
}
|
||||
|
||||
export function buildLftpScript(config, commands) {
|
||||
const { host, user, password, root } = config.ftp;
|
||||
const lines = [
|
||||
@@ -282,7 +291,10 @@ export async function defaultProcessRunner(command, args, options = {}) {
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
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;
|
||||
}
|
||||
resolve({
|
||||
@@ -415,14 +427,36 @@ export function createLftpTransport(config, dependencies = {}) {
|
||||
const sleep =
|
||||
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) {
|
||||
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),
|
||||
label: "FTPS operation",
|
||||
});
|
||||
} 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;
|
||||
try {
|
||||
await run([
|
||||
`mkdir -p ${lftpQuote("activation-requests")}`,
|
||||
`mkdir -p ${lftpQuote("activation-results")}`,
|
||||
ensureLftpDirectory("activation-requests"),
|
||||
ensureLftpDirectory("activation-results"),
|
||||
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
|
||||
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
|
||||
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
|
||||
@@ -527,7 +561,7 @@ export function createLftpTransport(config, dependencies = {}) {
|
||||
const downloadedChecksum = path.join(temporaryDirectory, checksumName);
|
||||
try {
|
||||
await run([
|
||||
`mkdir -p ${lftpQuote("archives")}`,
|
||||
ensureLftpDirectory("archives"),
|
||||
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
|
||||
`rm -f ${lftpQuote(`archives/${checksumName}.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 = {}) {
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||
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 = {}) {
|
||||
const runner = options.runner || defaultProcessRunner;
|
||||
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 = {}) {
|
||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const verify = dependencies.verify || runPublicVerification;
|
||||
const prune = dependencies.prune || pruneInactiveReleases;
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
||||
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
||||
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
||||
|
||||
await checkCurrent(config, dependencies);
|
||||
await assertCurrentLink(client, config);
|
||||
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();
|
||||
await checkCurrent(config, dependencies);
|
||||
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
||||
@@ -1033,7 +1038,6 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
|
||||
try {
|
||||
await assertReleaseTargetExists(client, config, newTarget);
|
||||
await transport.verifyRelease(newTarget);
|
||||
await verify(config, dependencies);
|
||||
} catch (error) {
|
||||
@@ -1050,28 +1054,19 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
await assertCurrentLink(client, config);
|
||||
let removed = [];
|
||||
let retentionWarning = "";
|
||||
try {
|
||||
removed = await prune(client, config, [newTarget, previousTarget], {
|
||||
removeRelease: async (releaseId) => {
|
||||
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 };
|
||||
return {
|
||||
previousTarget,
|
||||
activeTarget: newTarget,
|
||||
removed: [],
|
||||
retentionWarning:
|
||||
"Verified deployment succeeded; inactive release cleanup is deferred because hosted runners cannot use the cPanel metadata API.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackRelease(config, target, dependencies = {}) {
|
||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const safeTarget = validateReleaseTarget(target);
|
||||
await assertReleaseTargetExists(client, config, safeTarget);
|
||||
await transport.activateExisting(safeTarget);
|
||||
publish({
|
||||
RELEASE_ACTIVE_TARGET: safeTarget,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import crypto from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import JSZip from "jszip";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/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 =
|
||||
/(?:^|\.)(?: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 = [];
|
||||
let totalBytes = 0;
|
||||
const walk = async (relativeDirectory = "") => {
|
||||
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
|
||||
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
|
||||
@@ -122,7 +123,17 @@ export async function collectDistInventory(distDirectory) {
|
||||
}
|
||||
|
||||
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);
|
||||
totalBytes += contents.length;
|
||||
if (totalBytes > MAX_DIST_BYTES) {
|
||||
throw new Error(`dist exceeds the ${MAX_DIST_BYTES}-byte packaging limit.`);
|
||||
}
|
||||
files.push({
|
||||
path: `dist/${relativePath}`,
|
||||
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) {
|
||||
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
|
||||
const entries = stdout.split(/\r?\n/).filter(Boolean);
|
||||
if (entries.length === 0) {
|
||||
let archive;
|
||||
try {
|
||||
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.");
|
||||
}
|
||||
|
||||
for (const archiveEntry of entries) {
|
||||
for (const entry of entries) {
|
||||
const archiveEntry = archiveEntryName(entry);
|
||||
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
|
||||
if (normalizedEntry === "dist") {
|
||||
if (!entry.dir) {
|
||||
throw new Error("release archive contains a file at the dist root path.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!normalizedEntry.startsWith("dist/")) {
|
||||
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
|
||||
}
|
||||
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) {
|
||||
@@ -330,25 +429,16 @@ export async function createReleaseArchive({
|
||||
const sourceRoot = path.join(temporaryDirectory, "source");
|
||||
const copiedDistDirectory = path.join(sourceRoot, "dist");
|
||||
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
|
||||
const extractionRoot = path.join(temporaryDirectory, "extracted");
|
||||
await fsp.mkdir(sourceRoot, { recursive: true });
|
||||
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
|
||||
|
||||
const copiedInventory = await collectDistInventory(copiedDistDirectory);
|
||||
assertMatchingInventories(inventory, copiedInventory, "archive source");
|
||||
|
||||
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
|
||||
cwd: sourceRoot,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
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");
|
||||
await createZipArchive(copiedDistDirectory, copiedInventory, temporaryArchivePath);
|
||||
const archive = await validateArchiveEntries(temporaryArchivePath);
|
||||
const archivedInventory = await collectArchiveInventory(archive);
|
||||
assertMatchingInventories(inventory, archivedInventory, "round-trip archive");
|
||||
|
||||
const archiveContents = await fsp.readFile(temporaryArchivePath);
|
||||
const archiveSha256 = sha256(archiveContents);
|
||||
|
||||