Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11b164188 | ||
|
|
882bc64fa6 | ||
|
|
7d5ec8894c | ||
|
|
f2fc7643a2 | ||
|
|
06b6498f7a | ||
|
|
c5c186242b | ||
|
|
3112a4f985 | ||
|
|
ddd31bd2dd | ||
|
|
f07c15972c | ||
|
|
210d7d051d | ||
|
|
0d2098449a | ||
|
|
cafe671e85 | ||
|
|
2401ebb674 | ||
|
|
6f39eb897c | ||
|
|
670746d70c | ||
|
|
a345d91ae4 | ||
|
|
19fbbcddce | ||
|
|
7817f0c13b | ||
|
|
c19aeffb98 | ||
|
|
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 |
@@ -32,12 +32,12 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
name: Qodana
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
branches: [master, beta, canary, internal]
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
push:
|
|
||||||
branches: [master, beta, canary, internal]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
qodana:
|
|
||||||
name: Qodana
|
|
||||||
if: >-
|
|
||||||
github.event_name != 'pull_request' ||
|
|
||||||
(
|
|
||||||
github.event.pull_request.draft == false &&
|
|
||||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
||||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
|
||||||
)
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
# v5.0.1
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Require Qodana project token
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${QODANA_TOKEN:-}" ]]; then
|
|
||||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Qodana
|
|
||||||
# v2026.1.3
|
|
||||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
|
||||||
with:
|
|
||||||
use-caches: true
|
|
||||||
cache-default-branch-only: true
|
|
||||||
upload-result: false
|
|
||||||
use-annotations: true
|
|
||||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
|
||||||
post-pr-comment: true
|
|
||||||
github-token: ${{ github.token }}
|
|
||||||
push-fixes: none
|
|
||||||
env:
|
|
||||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: Deploy pleno-vue to Hetzner (staging)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pleno-vue
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-deploy:
|
||||||
|
name: Build + Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install + Build
|
||||||
|
run: |
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Setup SSH
|
||||||
|
uses: webfactory/ssh-agent@v0.10.0
|
||||||
|
with:
|
||||||
|
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
||||||
|
- name: Add host key
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: Pre-deploy snapshot
|
||||||
|
id: pre
|
||||||
|
run: |
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||||
|
set -e
|
||||||
|
cd /opt/pleno-vue
|
||||||
|
git rev-parse HEAD > /tmp/last_deploy_sha
|
||||||
|
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||||
|
set -e
|
||||||
|
cd /opt/pleno-vue
|
||||||
|
git fetch origin master
|
||||||
|
git reset --hard origin/master
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
sudo systemctl reload nginx || true
|
||||||
|
sudo systemctl reload pleno-vue || true
|
||||||
|
echo "Deploy complete: $(git rev-parse --short HEAD)"
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
id: smoke
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
|
||||||
|
|
||||||
|
- name: Sergii Review Batch smoke test (TRU-96)
|
||||||
|
id: smoke_sergii
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
|
||||||
|
|
||||||
|
- name: Auto-rollback on smoke failure
|
||||||
|
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
|
||||||
|
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||||
|
set -e
|
||||||
|
cd /opt/pleno-vue
|
||||||
|
git reset --hard ${{ steps.pre.outputs.pre_sha }}
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
sudo systemctl reload nginx || true
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Post Slack status
|
||||||
|
if: always()
|
||||||
|
uses: slackapi/slack-github-action@v4.0.0
|
||||||
|
with:
|
||||||
|
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
|
||||||
|
}
|
||||||
|
env:
|
||||||
|
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -125,13 +125,13 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Ruby and pinned Fastlane
|
- name: Setup Ruby and pinned Fastlane
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ jobs:
|
|||||||
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|||||||
@@ -142,13 +142,19 @@ jobs:
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||||
const checks = {
|
const checks = {
|
||||||
schema: proof.schemaVersion === 1,
|
schema: proof.schemaVersion === 2,
|
||||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
||||||
|
exactSource: proof.sha === process.env.IOS_SOURCE_SHA,
|
||||||
|
releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0,
|
||||||
|
archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""),
|
||||||
|
activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0,
|
||||||
|
verification: proof.verificationState === "verified",
|
||||||
publicGate: proof.livePublicGate === "passed",
|
publicGate: proof.livePublicGate === "passed",
|
||||||
credentialedGate: proof.liveCredentialedGate === "passed",
|
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||||
managerGate: proof.releaseManagerGate === "passed",
|
managerGate: proof.releaseManagerGate === "passed",
|
||||||
serverVersion: proof.serverVersionUpdated === true,
|
serverVersion: proof.serverVersionUpdated === true,
|
||||||
|
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||||
};
|
};
|
||||||
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
||||||
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
||||||
@@ -167,13 +173,13 @@ jobs:
|
|||||||
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Setup Ruby and pinned Fastlane
|
- name: Setup Ruby and pinned Fastlane
|
||||||
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
|
||||||
with:
|
with:
|
||||||
ruby-version: "3.3"
|
ruby-version: "3.3"
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
java-version: 21
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||||
release_id: ${{ steps.package.outputs.release_id }}
|
release_id: ${{ steps.package.outputs.release_id }}
|
||||||
|
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check release commit is current
|
- name: Check release commit is current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
@@ -68,7 +69,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -191,7 +192,7 @@ jobs:
|
|||||||
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
ref: ${{ env.RELEASE_COMMIT_SHA }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@@ -218,7 +219,7 @@ jobs:
|
|||||||
run: node scripts/install-playwright-browsers.mjs chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Download validated release package
|
- name: Download validated release package
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: ${{ needs.build-release.outputs.artifact_name }}
|
name: ${{ needs.build-release.outputs.artifact_name }}
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
@@ -286,8 +287,28 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
- name: Credentialed live Playwright gate (when configured)
|
- name: Detect credentialed live gate configuration
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
id: credentialed_live_config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" &&
|
||||||
|
-n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then
|
||||||
|
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||||
|
CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||||
|
OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||||
|
OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Credentialed live Playwright gate (when configured)
|
||||||
|
if: >-
|
||||||
|
steps.branch-head.outputs.current == 'true' &&
|
||||||
|
steps.credentialed_live_config.outputs.configured == 'true'
|
||||||
id: credentialed_live
|
id: credentialed_live
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
@@ -300,26 +321,9 @@ jobs:
|
|||||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
- name: Roll back after live verification failure
|
|
||||||
if: >-
|
|
||||||
failure() && steps.branch-head.outputs.current == 'true' &&
|
|
||||||
steps.deploy.outcome == 'success' &&
|
|
||||||
(steps.public_live.outcome == 'failure' || steps.credentialed_live.outcome == 'failure')
|
|
||||||
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_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
|
||||||
|
|
||||||
- name: Record Release Manager gate
|
- name: Record Release Manager gate
|
||||||
|
id: release_manager
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
@@ -340,7 +344,9 @@ jobs:
|
|||||||
run: npm run release:update-server-version
|
run: npm run release:update-server-version
|
||||||
env:
|
env:
|
||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
|
||||||
- name: Create verified frontend release proof
|
- name: Create verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
@@ -355,24 +361,52 @@ jobs:
|
|||||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||||
return process.env[name];
|
return process.env[name];
|
||||||
};
|
};
|
||||||
|
const requireSuccessfulStep = (name) => {
|
||||||
|
const outcome = required(name);
|
||||||
|
if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`);
|
||||||
|
return "passed";
|
||||||
|
};
|
||||||
|
const credentialedGate = () => {
|
||||||
|
const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED");
|
||||||
|
if (configured === "false") return "not-configured";
|
||||||
|
if (configured !== "true") {
|
||||||
|
throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`);
|
||||||
|
}
|
||||||
|
return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME");
|
||||||
|
};
|
||||||
const proof = {
|
const proof = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
|
releaseId: required("RELEASE_ID"),
|
||||||
|
sha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||||
|
archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(),
|
||||||
|
activeTarget: required("RELEASE_ACTIVE_TARGET"),
|
||||||
|
rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null,
|
||||||
|
verificationState: "verified",
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
repository: required("GITHUB_REPOSITORY"),
|
repository: required("GITHUB_REPOSITORY"),
|
||||||
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||||
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
||||||
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
||||||
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
||||||
buildId: required("RELEASE_BUILD_ID"),
|
buildId: required("RELEASE_BUILD_ID"),
|
||||||
livePublicGate: "passed",
|
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||||
liveCredentialedGate: "passed",
|
liveCredentialedGate: credentialedGate(),
|
||||||
releaseManagerGate: "passed",
|
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||||
serverVersionUpdated: true,
|
serverVersionUpdated: true,
|
||||||
|
serverVersionReadBack: "passed",
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
||||||
NODE
|
NODE
|
||||||
env:
|
env:
|
||||||
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||||
|
RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }}
|
||||||
|
RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }}
|
||||||
|
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||||
|
LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }}
|
||||||
|
LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }}
|
||||||
|
LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }}
|
||||||
|
RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }}
|
||||||
|
|
||||||
- name: Publish verified frontend release proof
|
- name: Publish verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
@@ -383,6 +417,32 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Roll back after any post-deployment verification failure
|
||||||
|
if: >-
|
||||||
|
failure() && steps.branch-head.outputs.current == 'true' &&
|
||||||
|
steps.deploy.outcome == 'success'
|
||||||
|
timeout-minutes: 10
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node scripts/release/deploy-cpanel.mjs --rollback
|
||||||
|
[[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]]
|
||||||
|
export RELEASE_VERSION="${BASH_REMATCH[1]}"
|
||||||
|
export RELEASE_BUILD_ID="${BASH_REMATCH[2]}"
|
||||||
|
npm run release:update-server-version
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
|
||||||
|
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
|
||||||
|
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
|
||||||
|
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||||
|
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||||
|
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||||
|
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||||
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -436,7 +436,7 @@ jobs:
|
|||||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -602,7 +602,14 @@ jobs:
|
|||||||
e2e-full:
|
e2e-full:
|
||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||||
needs.build-and-unit.result == 'success' &&
|
needs.build-and-unit.result == 'success' &&
|
||||||
(
|
(
|
||||||
@@ -662,7 +669,7 @@ jobs:
|
|||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
@@ -798,7 +805,14 @@ jobs:
|
|||||||
full-e2e-summary:
|
full-e2e-summary:
|
||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||||
name: Full E2E summary
|
name: Full E2E summary
|
||||||
needs: [e2e-full]
|
needs: [e2e-full]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
ruby ">= 3.2", "< 3.5"
|
ruby ">= 3.2", "< 3.5"
|
||||||
gem "fastlane", "2.237.0"
|
gem "fastlane", "2.238.0"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ GEM
|
|||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1271.0)
|
aws-partitions (1.1281.0)
|
||||||
aws-sdk-core (3.254.0)
|
aws-sdk-core (3.254.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@@ -20,8 +20,8 @@ GEM
|
|||||||
aws-sdk-kms (1.130.0)
|
aws-sdk-kms (1.130.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.254.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.228.0)
|
aws-sdk-s3 (1.229.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.254.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
@@ -35,45 +35,33 @@ GEM
|
|||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
csv (3.3.5)
|
csv (3.3.6)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (1.6.0)
|
erb (6.0.7)
|
||||||
|
excon (1.7.0)
|
||||||
|
logger
|
||||||
|
faraday (2.14.3)
|
||||||
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
|
json
|
||||||
logger
|
logger
|
||||||
faraday (1.10.6)
|
|
||||||
faraday-em_http (~> 1.0)
|
|
||||||
faraday-em_synchrony (~> 1.0)
|
|
||||||
faraday-excon (~> 1.1)
|
|
||||||
faraday-httpclient (~> 1.0)
|
|
||||||
faraday-multipart (~> 1.0)
|
|
||||||
faraday-net_http (~> 1.0)
|
|
||||||
faraday-net_http_persistent (~> 1.0)
|
|
||||||
faraday-patron (~> 1.0)
|
|
||||||
faraday-rack (~> 1.0)
|
|
||||||
faraday-retry (~> 1.0)
|
|
||||||
ruby2_keywords (>= 0.0.4)
|
|
||||||
faraday-cookie_jar (0.0.8)
|
faraday-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (>= 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-follow_redirects (0.5.0)
|
||||||
faraday-em_synchrony (1.0.1)
|
faraday (>= 1, < 3)
|
||||||
faraday-excon (1.1.0)
|
|
||||||
faraday-httpclient (1.0.1)
|
|
||||||
faraday-multipart (1.2.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (3.4.4)
|
||||||
faraday-net_http_persistent (1.2.0)
|
net-http (~> 0.5)
|
||||||
faraday-patron (1.0.0)
|
faraday-retry (2.4.0)
|
||||||
faraday-rack (1.0.0)
|
faraday (~> 2.0)
|
||||||
faraday-retry (1.0.4)
|
|
||||||
faraday_middleware (1.2.1)
|
|
||||||
faraday (~> 1.0)
|
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.1)
|
||||||
fastlane (2.237.0)
|
fastlane (2.238.0)
|
||||||
CFPropertyList (>= 2.3, < 5.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1)
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.9.0, < 3.0.0)
|
addressable (>= 2.9.0, < 3.0.0)
|
||||||
@@ -89,9 +77,11 @@ GEM
|
|||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 2.0.0)
|
excon (>= 0.71.0, < 2.0.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 2.7)
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.8)
|
||||||
faraday_middleware (~> 1.0)
|
faraday-follow_redirects (~> 0.3)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-retry (~> 2.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.1.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
@@ -101,6 +91,7 @@ GEM
|
|||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
|
irb (>= 1.8)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.10.3, < 4)
|
jwt (>= 2.10.3, < 4)
|
||||||
logger (>= 1.6, < 2.0)
|
logger (>= 1.6, < 2.0)
|
||||||
@@ -117,7 +108,7 @@ GEM
|
|||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
simctl (~> 1.6.3)
|
simctl (~> 1.6.3)
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
terminal-table (~> 3)
|
terminal-table (~> 4)
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
@@ -126,21 +117,22 @@ GEM
|
|||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.105.0)
|
google-apis-androidpublisher_v3 (0.106.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (1.2.5)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.9)
|
||||||
googleauth (~> 1.9)
|
faraday (~> 2.13)
|
||||||
httpclient (>= 2.8.3, < 3.a)
|
faraday-follow_redirects (~> 0.3)
|
||||||
mini_mime (~> 1.0)
|
googleauth (~> 1.14)
|
||||||
mutex_m
|
mini_mime (~> 1.1)
|
||||||
|
multi_json (~> 1.11)
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 3.1, < 5.0)
|
||||||
google-apis-iamcredentials_v1 (0.28.0)
|
google-apis-iamcredentials_v1 (0.28.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.18.0)
|
google-apis-playcustomapp_v1 (0.18.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-storage_v1 (0.65.0)
|
google-apis-storage_v1 (0.66.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-cloud-core (1.9.0)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
@@ -159,7 +151,7 @@ GEM
|
|||||||
googleauth (~> 1.9)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
google-logging-utils (0.2.0)
|
google-logging-utils (0.2.0)
|
||||||
googleauth (1.17.1)
|
googleauth (1.17.3)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 2.2)
|
google-cloud-env (~> 2.2)
|
||||||
google-logging-utils (~> 0.1)
|
google-logging-utils (~> 0.1)
|
||||||
@@ -170,10 +162,14 @@ GEM
|
|||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.8)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
httpclient (2.9.0)
|
io-console (0.9.2)
|
||||||
mutex_m
|
irb (1.18.0)
|
||||||
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
|
rdoc (>= 4.0.0)
|
||||||
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.21.1)
|
json (2.21.2)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
@@ -184,22 +180,38 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
|
net-http (0.9.1)
|
||||||
|
uri (>= 0.11.1)
|
||||||
nkf (0.3.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
|
pp (0.6.4)
|
||||||
|
prettyprint
|
||||||
|
prettyprint (0.2.0)
|
||||||
|
prism (1.9.0)
|
||||||
pstore (0.2.1)
|
pstore (0.2.1)
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.5)
|
||||||
rake (13.4.2)
|
rake (13.4.2)
|
||||||
|
rbs (4.1.3)
|
||||||
|
logger
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
tsort
|
||||||
|
rdoc (8.0.0)
|
||||||
|
erb
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
rbs (>= 4.0.0)
|
||||||
|
tsort
|
||||||
|
reline (0.7.0)
|
||||||
|
io-console (~> 0.5)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.8.0)
|
retriable (4.2.0)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.22.0)
|
signet (0.22.0)
|
||||||
@@ -210,15 +222,19 @@ GEM
|
|||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
|
tsort (0.2.0)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.2)
|
tty-screen (0.8.2)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (3.2.0)
|
||||||
|
unicode-emoji (~> 4.1)
|
||||||
|
unicode-emoji (4.2.0)
|
||||||
|
uri (1.1.1)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.28.1)
|
xcodeproj (1.28.1)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
@@ -239,7 +255,7 @@ PLATFORMS
|
|||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (= 2.237.0)
|
fastlane (= 2.238.0)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.12p206
|
ruby 3.3.12p206
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
|
|||||||
server: {
|
server: {
|
||||||
androidScheme: "https",
|
androidScheme: "https",
|
||||||
},
|
},
|
||||||
|
plugins: {
|
||||||
|
StatusBar: {
|
||||||
|
overlaysWebView: false,
|
||||||
|
style: "LIGHT",
|
||||||
|
backgroundColor: "#FFFFFFFF",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# AGENT MCP SMOKE
|
||||||
|
|
||||||
|
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||||
|
Safe to close.
|
||||||
@@ -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.
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -45,11 +45,12 @@ platform :ios do
|
|||||||
skip_screenshots: false,
|
skip_screenshots: false,
|
||||||
overwrite_screenshots: true,
|
overwrite_screenshots: true,
|
||||||
force: true,
|
force: true,
|
||||||
submit_for_review: false,
|
submit_for_review: true,
|
||||||
automatic_release: true,
|
automatic_release: true,
|
||||||
phased_release: false,
|
phased_release: false,
|
||||||
run_precheck_before_submit: false,
|
run_precheck_before_submit: false,
|
||||||
precheck_include_in_app_purchases: false
|
precheck_include_in_app_purchases: false,
|
||||||
|
ignore_language_directory_validation: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |