Harden hosted releases and mobile store gates (#205)
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
name: cPanel Root Audit and Restore
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: Audit is read-only; restore exchanges public_html with a retained recovery entry.
|
||||
required: true
|
||||
default: audit
|
||||
type: choice
|
||||
options:
|
||||
- audit
|
||||
- restore
|
||||
recovery:
|
||||
description: Exact recovery entry reported by an audit, for example public_html.recovery-20260720.
|
||||
required: false
|
||||
type: string
|
||||
state_token:
|
||||
description: Exact 64-character audit-metadata state token reported by the audit.
|
||||
required: false
|
||||
type: string
|
||||
confirmation:
|
||||
description: For restore, type RESTORE <recovery> TO <webroot> STATE <state-token> exactly.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
audit-or-restore:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: frontend-production
|
||||
url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Audit cPanel primary webroot
|
||||
if: inputs.mode == 'audit'
|
||||
id: audit
|
||||
run: node scripts/release/cpanel-root.mjs audit
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/audit.json
|
||||
|
||||
- name: Validate restore inputs
|
||||
if: inputs.mode == 'restore'
|
||||
env:
|
||||
RECOVERY: ${{ inputs.recovery }}
|
||||
STATE_TOKEN: ${{ inputs.state_token }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
run: |
|
||||
test -n "$RECOVERY"
|
||||
[[ "$STATE_TOKEN" =~ ^[a-f0-9]{64}$ ]]
|
||||
test "$CONFIRMATION" = "RESTORE $RECOVERY TO $WEBROOT STATE $STATE_TOKEN"
|
||||
|
||||
- name: Restore retained cPanel webroot
|
||||
if: inputs.mode == 'restore'
|
||||
run: node scripts/release/cpanel-root.mjs restore
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_CPANEL_WEBROOT: ${{ vars.PRODUCTION_CPANEL_WEBROOT || 'public_html' }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
CPANEL_ROOT_RECOVERY: ${{ inputs.recovery }}
|
||||
CPANEL_ROOT_STATE_TOKEN: ${{ inputs.state_token }}
|
||||
CPANEL_ROOT_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
CPANEL_ROOT_REPORT_PATH: output/cpanel-root/restore.json
|
||||
|
||||
- name: Upload cPanel root report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cpanel-root-${{ inputs.mode }}-${{ github.run_id }}
|
||||
path: output/cpanel-root
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -82,6 +82,14 @@ jobs:
|
||||
echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Require green WebKit mobile tests before App Store upload
|
||||
if: steps.resolve.outputs.enabled == 'true' && steps.resolve.outputs.current == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform apple
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
deliver:
|
||||
name: Sign, upload, process, and distribute
|
||||
needs: prepare
|
||||
|
||||
@@ -49,6 +49,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: android-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
|
||||
@@ -87,11 +88,11 @@ jobs:
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
UPLOAD_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current=true
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
latest_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
@@ -100,16 +101,16 @@ jobs:
|
||||
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
|
||||
current=false
|
||||
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
|
||||
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
|
||||
else
|
||||
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
|
||||
fi
|
||||
else
|
||||
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
|
||||
fi
|
||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
@@ -118,6 +119,15 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Require green Chromium mobile tests before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
run: node scripts/mobile/verify-store-test-gate.mjs --platform android
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
TEST_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || '' }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
@@ -212,6 +222,24 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Recheck live master before Play upload
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
EXPECTED_SHA: ${{ steps.release-guard.outputs.source_sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
live_master_sha="$(curl --fail --silent --show-error --location \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
|
||||
[[ "$live_master_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Could not resolve origin/$DEFAULT_BRANCH." >&2; exit 1; }
|
||||
[[ "$live_master_sha" == "$EXPECTED_SHA" ]] || {
|
||||
echo "$DEFAULT_BRANCH advanced while the Android bundle was building; refusing Play upload." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Upload Android App Bundle to Google Play
|
||||
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
|
||||
env:
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'master' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
deploy-frontend-production:
|
||||
needs: build-release
|
||||
if: needs.build-release.outputs.current == 'true'
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
environment:
|
||||
name: frontend-production
|
||||
@@ -274,10 +274,6 @@ jobs:
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
RELEASE_GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -315,10 +311,6 @@ jobs:
|
||||
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
|
||||
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
|
||||
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
|
||||
PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }}
|
||||
PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }}
|
||||
PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }}
|
||||
PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }}
|
||||
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
|
||||
@@ -46,33 +46,22 @@ Add these environment **secrets**:
|
||||
- `PRODUCTION_FTP_PASSWORD`
|
||||
- `PRODUCTION_FTP_PATH`
|
||||
- `PRODUCTION_ACTIVATION_KEY`
|
||||
- `PRODUCTION_CPANEL_USER`
|
||||
- `PRODUCTION_CPANEL_API_TOKEN`
|
||||
|
||||
The API `.env` contains legacy values under the first four names, but production
|
||||
frontend deployment uses a dedicated cPanel FTP account jailed to
|
||||
`/home/truckwash/frontend-deployments`. Leave the API `.env` and the API
|
||||
deployment unchanged.
|
||||
|
||||
The cPanel token is separate from the FTP password. Create it in cPanel under
|
||||
**Security -> Manage API Tokens** for `PRODUCTION_CPANEL_USER`. The deployment
|
||||
uses the token for fail-closed directory and release-state inspection. It does
|
||||
not use legacy Fileman mutation calls to replace symlinks: on this server those
|
||||
calls can follow the target instead of renaming the link itself. Revoke and
|
||||
rotate the token if it is ever exposed.
|
||||
The hosted release path deliberately does not call the remote cPanel API.
|
||||
Imunify360 blocks standard GitHub-hosted runner addresses, so release safety is
|
||||
provided by the jailed FTPS transport, the HMAC-authenticated account-scoped
|
||||
activator, exact inventory comparison, and public manifest verification.
|
||||
|
||||
Add these environment **variables**:
|
||||
|
||||
- `PRODUCTION_CPANEL_API_URL`: `https://server.red-block.com:2083`
|
||||
- `PRODUCTION_CPANEL_PATH`: `frontend-deployments`
|
||||
- `PRODUCTION_FRONTEND_URL`: `https://truckwash.io`
|
||||
|
||||
Only `PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io`
|
||||
fallback. The cPanel URL and path deliberately fail closed when absent. The
|
||||
production environment must keep the explicit
|
||||
`https://server.red-block.com:2083` cPanel origin: the public origin serves
|
||||
frontend HTML at `/json-api/cpanel`, while the dedicated TLS origin exposes the
|
||||
cPanel JSON API.
|
||||
`PRODUCTION_FRONTEND_URL` has the requested `https://truckwash.io` fallback.
|
||||
|
||||
### Create the dedicated FTP credentials
|
||||
|
||||
@@ -88,53 +77,26 @@ cPanel JSON API.
|
||||
6. Verify explicit FTPS login and directory listing before merging. Never copy
|
||||
these frontend-only credentials back into the API `.env`.
|
||||
|
||||
### Create the missing cPanel credentials
|
||||
|
||||
The API `.env` supplies only the four FTP values. Create the two cPanel secrets
|
||||
separately; do not reuse the FTP password as an API token.
|
||||
|
||||
1. Sign in to the cPanel account that owns the frontend deployment root.
|
||||
2. Record the exact cPanel account username shown in **General Information**.
|
||||
Add it to the `frontend-production` environment as the
|
||||
`PRODUCTION_CPANEL_USER` secret.
|
||||
3. Open **Security -> Manage API Tokens**. If the item is missing, ask the
|
||||
hosting provider to enable API Tokens in WHM Feature Manager.
|
||||
4. Click **Create**, name the token `github-pleno-vue-production`, and choose an
|
||||
expiration date that matches the team's rotation policy. Expiration cannot
|
||||
be edited later, so add a reminder before that date.
|
||||
5. Click **Create**, copy the token immediately, and add it to the same GitHub
|
||||
environment as `PRODUCTION_CPANEL_API_TOKEN`. cPanel will not show the token
|
||||
again after leaving the page.
|
||||
6. Confirm **Yes, I Saved My Token**, then close any local plaintext copy after
|
||||
the GitHub secret has been saved.
|
||||
7. Before merging, run the deployment audit against the configured API origin.
|
||||
It must be able to list `PRODUCTION_CPANEL_PATH`, `current`, and immutable
|
||||
releases. Do not broaden the token or deployment root beyond this cPanel
|
||||
account.
|
||||
|
||||
The current production token is named `github-pleno-vue-production` and
|
||||
expires on 20 July 2027 at 23:59:59 server time. Rotate the GitHub environment
|
||||
secret before that date, then revoke the replaced token in cPanel.
|
||||
|
||||
In GitHub, navigate to **Settings -> Environments -> frontend-production**.
|
||||
Use **Add secret** for credentials and **Add variable** for the two URLs and the
|
||||
cPanel deployment path.
|
||||
Use **Add secret** for credentials and **Add variable** for the frontend URL.
|
||||
Environment values are available only to the deployment job that names this
|
||||
environment, and configured protection rules are evaluated before its secrets
|
||||
are released.
|
||||
|
||||
The existing live-test, Release Manager, and server-version secrets used by
|
||||
`release.yml` must remain configured. The self-hosted deployment job installs
|
||||
`lftp` job-locally when needed and installs Playwright Chromium. Its runner
|
||||
still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and `sha256sum`.
|
||||
`release.yml` must remain configured. The GitHub-hosted deployment job installs
|
||||
`lftp` job-locally when needed, configures Node 22, and installs Playwright
|
||||
Chromium. The hosted image must provide npm, `zip`, `unzip`, GNU `find`, `stat`,
|
||||
and `sha256sum`.
|
||||
The cPanel account host needs `/bin/sh`, `flock`, `unzip`, `jq`, and
|
||||
`sha256sum` for the account-scoped activator.
|
||||
|
||||
## cPanel layout and one-time bootstrap
|
||||
|
||||
The production FTP account is jailed directly to the deployment root, so its
|
||||
`PRODUCTION_FTP_PATH` is `/`. `PRODUCTION_CPANEL_PATH` names that same directory
|
||||
relative to the cPanel account home. The helper creates this layout below it:
|
||||
`PRODUCTION_FTP_PATH` is `/`. On cPanel that jail maps to the
|
||||
`frontend-deployments` directory below the account home. The helper creates
|
||||
this layout below it:
|
||||
|
||||
```text
|
||||
archives/
|
||||
@@ -146,7 +108,7 @@ current -> releases/<release-id>/dist
|
||||
```
|
||||
|
||||
The domain's document root must resolve to
|
||||
`<cPanel account home>/<PRODUCTION_CPANEL_PATH>/current`, not to the deployment
|
||||
`<cPanel account home>/frontend-deployments/current`, not to the deployment
|
||||
root itself. This stable document-root path is what makes replacing `current`
|
||||
atomic: every HTTP request resolves either the complete old release or the
|
||||
complete new release, never a partly uploaded directory.
|
||||
@@ -172,9 +134,8 @@ Before merging the workflow change, perform a one-time bootstrap in cPanel:
|
||||
listing.
|
||||
7. Confirm `/release-manifest.json`, `/release-entry.json`, a deep Vue route,
|
||||
and the API health request work at `PRODUCTION_FRONTEND_URL`.
|
||||
8. Test the cPanel token against the exact host and port. The workflow performs
|
||||
read-only state checks and refuses deployment if `current` or the captured
|
||||
rollback release is missing.
|
||||
8. The server-side activator, rather than the hosted runner, validates that
|
||||
`current` and the captured rollback release exist before every switch.
|
||||
9. Generate a dedicated 32-byte random activation key. Store its 64-character
|
||||
hexadecimal form in the protected `frontend-production` environment as
|
||||
`PRODUCTION_ACTIVATION_KEY`. On the server, install the same value at
|
||||
@@ -205,47 +166,33 @@ first automated run into an unreviewed production cutover.
|
||||
|
||||
### Auditing or restoring the primary webroot
|
||||
|
||||
Use the protected **cPanel Root Audit and Restore** workflow if the primary
|
||||
domain starts showing a directory index or returns 404 for files that cPanel
|
||||
lists in `public_html`. The `audit` mode is read-only: it reports the exact
|
||||
`public_html` entry, whether the internal `current` link can serve the required
|
||||
release files, domain document roots, and retained recovery candidates without
|
||||
printing the cPanel token. API2 does not expose a documented symlink-target
|
||||
field, so the audit deliberately reports `rootTargetVerified: false` instead
|
||||
of claiming that an arbitrary `public_html` link follows `current`; the live
|
||||
HTTP checks remain the source of truth for service health. The audit fails
|
||||
closed if any domain record lacks an identity or document root, and restore is
|
||||
blocked while an addon or subdomain is rooted below `public_html`.
|
||||
There is no GitHub Actions root-audit or root-restore job. Imunify360 blocks
|
||||
standard GitHub-hosted runner addresses, and this GitHub Team organization
|
||||
cannot assign static egress to a larger hosted runner. Keeping a configurable
|
||||
runner label would risk sending production cPanel secrets to a self-hosted
|
||||
runner, so that workflow has been removed.
|
||||
|
||||
If the regression followed the one-time webroot exchange and both the active
|
||||
webroot and selected recovery are physical directories, select `restore`
|
||||
and copy one exact recovery entry from the audit, including the retained
|
||||
`public_html.before-atomic-*` entry created by the bootstrap when applicable.
|
||||
The workflow requires the
|
||||
typed phrase `RESTORE <recovery> TO public_html STATE <state-token>`, using the
|
||||
exact token string from that audit. The token is an optimistic-concurrency
|
||||
guard over the cPanel metadata visible to the audit; it is not a content hash
|
||||
or a substitute for validating the selected recovery. Restore also rejects an
|
||||
unreadable physical directory. Restore also rejects symbolic-link roots and
|
||||
recoveries because legacy cPanel Fileman may follow their targets rather than
|
||||
rename the links. It renames the current physical entry to a run-specific
|
||||
`public_html.failed-*` path, restores the retained entry, and
|
||||
checks `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. If
|
||||
any mutation response is lost or any check fails, it reconciles the observed
|
||||
account-home entries and reinstates the pre-restore cPanel state. It never
|
||||
deletes the recovery or displaced webroot, and reports manual intervention if
|
||||
the expected entries cannot be proven after compensation.
|
||||
If the primary domain starts showing a directory index or returns 404 for files
|
||||
visible in `public_html`, inspect and recover it through the cPanel web interface
|
||||
or the hosting provider. Before replacing anything, confirm the exact
|
||||
`public_html` entry, the `frontend-deployments/current` link and required release
|
||||
files, all domain document roots, and retained `public_html.recovery-*`,
|
||||
`public_html.backup-*`, or `public_html.before-atomic-*` candidates. Do not
|
||||
replace the root while an addon or subdomain document root is nested below it.
|
||||
Restore only a verified physical directory, retain the displaced webroot, and
|
||||
verify `/`, `/index.html`, `/release-manifest.json`, and a deep Vue route. Normal
|
||||
releases do not depend on remote cPanel API access.
|
||||
|
||||
## Caching and compatibility
|
||||
|
||||
The release `.htaccess` gives exact eight-character Vite-fingerprinted assets a
|
||||
one-year immutable policy. `index.html`, release metadata, web manifests, and
|
||||
service-worker control files always revalidate. The deployer retains at least
|
||||
the active and rollback releases and keeps five recent release directories by
|
||||
default (`RELEASE_RETAIN_COUNT` can be set from 2 through 25). Once a release
|
||||
falls outside that validated retention set, its directory and matching ZIP and
|
||||
checksum are removed over FTPS. Cleanup failure is reported without rolling
|
||||
back an otherwise verified deployment.
|
||||
service-worker control files always revalidate. The deployer retains every
|
||||
immutable release while hosted runners cannot query reliable cPanel
|
||||
modification metadata. Each successful run reports that retention cleanup is
|
||||
deferred. Periodically review disk usage in cPanel and remove only inactive
|
||||
releases and their matching archives; never remove the active or recorded
|
||||
rollback target.
|
||||
|
||||
Because the document root switches as one symlink, an already-loaded page may
|
||||
still request an asset from its previous release after activation. The current
|
||||
|
||||
@@ -9,10 +9,15 @@ must never publish an Android production artifact.
|
||||
`.github/workflows/mobile-artifacts.yml`. It builds the Capacitor Android package
|
||||
`io.truckwash.twa` and supports:
|
||||
|
||||
- Automatic delivery after successful current-master `Automated Tests`.
|
||||
- Automatic delivery after successful current-master `Automated Tests`, with
|
||||
all six full Chromium-mobile role shards explicitly verified as green.
|
||||
- Manual dispatch with version, version code, upload toggle, track, and status.
|
||||
- Existing `mobile-v*` tags for the Android workflow.
|
||||
|
||||
Every Google Play upload path must resolve an exact completed `Automated Tests`
|
||||
push run for the same current-master commit. Manual no-upload artifact builds
|
||||
remain available for safe CI validation without invoking the store gate.
|
||||
|
||||
The Android job continues using GitHub environment `mobile-store-production`.
|
||||
Its required secrets are:
|
||||
|
||||
@@ -37,6 +42,11 @@ iOS uses three separate workflows:
|
||||
storefront candidate, without rebuilding or submission.
|
||||
- `iOS Credential Health`: weekly identity, access, and expiry preflight.
|
||||
|
||||
Before signing or uploading to TestFlight, the workflow resolves the exact
|
||||
current-master test run and requires all six full WebKit-mobile role shards to
|
||||
be green. App Store candidates reuse that gated TestFlight build and do not
|
||||
rebuild it.
|
||||
|
||||
The GitHub environments and variables are documented in
|
||||
`docs/app-store-release.md`. The repository-level
|
||||
`APP_STORE_AUTOMATION_ENABLED` variable gates all access to them and must remain
|
||||
|
||||
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
|
||||
|
||||
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||
const output = outputText(result);
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
/Playwright does not support .* on /i.test(output)
|
||||
);
|
||||
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
|
||||
};
|
||||
|
||||
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||
@@ -78,7 +75,7 @@ console.warn(
|
||||
[
|
||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||
"The self-hosted runner image must provide the required browser system libraries.",
|
||||
"The runner image must provide the required browser system libraries.",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const roleShards = new Map([
|
||||
["superuser", 2],
|
||||
["admin", 2],
|
||||
["customer", 1],
|
||||
["subuser", 1],
|
||||
]);
|
||||
|
||||
export function expectedStoreGateJobs(platform) {
|
||||
const normalized = String(platform || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const browser = normalized === "android" ? "Chromium" : normalized === "apple" ? "WebKit" : null;
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(`Unsupported store platform: ${platform || "<empty>"}`);
|
||||
}
|
||||
|
||||
return [...roleShards].flatMap(([role, shardTotal]) =>
|
||||
Array.from(
|
||||
{ length: shardTotal },
|
||||
(_, index) => `E2E-full-${browser}-mobile-${role}-shard-${index + 1}-of-${shardTotal}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isNewerJobExecution(candidate, current) {
|
||||
const candidateAttempt = Number(candidate?.run_attempt || 0);
|
||||
const currentAttempt = Number(current?.run_attempt || 0);
|
||||
if (candidateAttempt !== currentAttempt) {
|
||||
return candidateAttempt > currentAttempt;
|
||||
}
|
||||
return Number(candidate?.id || 0) > Number(current?.id || 0);
|
||||
}
|
||||
|
||||
export function evaluateStoreGate(platform, jobs) {
|
||||
const required = expectedStoreGateJobs(platform);
|
||||
const latestByName = new Map();
|
||||
|
||||
for (const job of jobs || []) {
|
||||
if (job?.name && (!latestByName.has(job.name) || isNewerJobExecution(job, latestByName.get(job.name)))) {
|
||||
latestByName.set(job.name, job);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = required.flatMap((name) => {
|
||||
const job = latestByName.get(name);
|
||||
if (!job) {
|
||||
return [`${name}:missing`];
|
||||
}
|
||||
if (job.status !== "completed" || job.conclusion !== "success") {
|
||||
return [`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return { required, failures, passed: failures.length === 0 };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (!value.startsWith("--")) {
|
||||
throw new Error(`Unexpected argument: ${value}`);
|
||||
}
|
||||
const [rawName, inlineValue] = value.slice(2).split("=", 2);
|
||||
const nextValue = inlineValue ?? argv[index + 1];
|
||||
if (inlineValue === undefined) {
|
||||
index += 1;
|
||||
}
|
||||
args[rawName] = nextValue;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function required(value, name) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createGitHubClient({ apiUrl, repository, token, fetchImpl = fetch }) {
|
||||
const request = async (path) => {
|
||||
const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${response.status} for ${path}: ${await response.text()}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
return { request };
|
||||
}
|
||||
|
||||
function validateRun(run, { sourceSha, defaultBranch }) {
|
||||
if (run?.name !== "Automated Tests") {
|
||||
throw new Error(`Run ${run?.id || "<unknown>"} is not the Automated Tests workflow.`);
|
||||
}
|
||||
if (run.head_sha !== sourceSha) {
|
||||
throw new Error(`Run ${run.id} tested ${run.head_sha || "<unknown>"}, not ${sourceSha}.`);
|
||||
}
|
||||
if (run.event !== "push" || run.head_branch !== defaultBranch) {
|
||||
throw new Error(`Run ${run.id} is not a push run for ${defaultBranch}.`);
|
||||
}
|
||||
if (run.status !== "completed") {
|
||||
throw new Error(`Run ${run.id} is not complete.`);
|
||||
}
|
||||
if (run.conclusion !== "success") {
|
||||
throw new Error(`Run ${run.id} did not succeed (${run.conclusion || "none"}).`);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) {
|
||||
if (runId) {
|
||||
const run = await client.request(`/actions/runs/${encodeURIComponent(runId)}`);
|
||||
return validateRun(run, { sourceSha, defaultBranch });
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
branch: defaultBranch,
|
||||
event: "push",
|
||||
status: "completed",
|
||||
per_page: "100",
|
||||
});
|
||||
const response = await client.request(`/actions/workflows/tests.yml/runs?${query}`);
|
||||
const run = response.workflow_runs?.find((candidate) => candidate.head_sha === sourceSha);
|
||||
if (!run) {
|
||||
throw new Error(`No completed Automated Tests push run was found for ${sourceSha} on ${defaultBranch}.`);
|
||||
}
|
||||
return validateRun(run, { sourceSha, defaultBranch });
|
||||
}
|
||||
|
||||
async function readAllAttemptJobs(client, runId) {
|
||||
const jobs = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const query = new URLSearchParams({ filter: "all", per_page: "100", page: String(page) });
|
||||
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`);
|
||||
const pageJobs = response.jobs || [];
|
||||
jobs.push(...pageJobs);
|
||||
if (pageJobs.length < 100) {
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyStoreTestGate({
|
||||
platform,
|
||||
sourceSha,
|
||||
runId,
|
||||
defaultBranch = "master",
|
||||
apiUrl = "https://api.github.com",
|
||||
repository,
|
||||
token,
|
||||
fetchImpl,
|
||||
}) {
|
||||
const normalizedSha = required(sourceSha, "source SHA").toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/u.test(normalizedSha)) {
|
||||
throw new Error("source SHA must be a full lowercase commit SHA.");
|
||||
}
|
||||
|
||||
const client = createGitHubClient({
|
||||
apiUrl: required(apiUrl, "GitHub API URL").replace(/\/$/u, ""),
|
||||
repository: required(repository, "GitHub repository"),
|
||||
token: required(token, "GitHub token"),
|
||||
fetchImpl,
|
||||
});
|
||||
const run = await resolveTestRun(client, {
|
||||
runId: String(runId || "").trim(),
|
||||
sourceSha: normalizedSha,
|
||||
defaultBranch: required(defaultBranch, "default branch"),
|
||||
});
|
||||
const jobs = await readAllAttemptJobs(client, run.id);
|
||||
const result = evaluateStoreGate(platform, jobs);
|
||||
|
||||
if (!result.passed) {
|
||||
throw new Error(`${platform} store test gate failed for run ${run.id}: ${result.failures.join(", ")}`);
|
||||
}
|
||||
|
||||
return { ...result, runId: run.id, sourceSha: normalizedSha };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = await verifyStoreTestGate({
|
||||
platform: args.platform,
|
||||
sourceSha: args["source-sha"] || process.env.STORE_SOURCE_SHA,
|
||||
runId: args["run-id"] || process.env.TEST_WORKFLOW_RUN_ID,
|
||||
defaultBranch: args["default-branch"] || process.env.DEFAULT_BRANCH || "master",
|
||||
apiUrl: process.env.GITHUB_API_URL,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
token: process.env.GH_TOKEN,
|
||||
});
|
||||
console.log(
|
||||
`${args.platform} store gate passed for Automated Tests run ${result.runId}: ${result.required.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -18,10 +18,6 @@ const REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
"RELEASE_ARCHIVE_PATH",
|
||||
"RELEASE_INVENTORY_PATH",
|
||||
"RELEASE_EXPECTED_COMMIT",
|
||||
@@ -33,10 +29,6 @@ const ROLLBACK_REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
];
|
||||
|
||||
export class DeploymentError extends Error {
|
||||
@@ -153,16 +145,29 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
requireString(env, name);
|
||||
}
|
||||
|
||||
const cpanelNames = [
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
];
|
||||
const configuredCpanelNames = cpanelNames.filter((name) => env[name] !== undefined && env[name] !== "");
|
||||
let cpanel = null;
|
||||
if (configuredCpanelNames.length > 0) {
|
||||
if (configuredCpanelNames.length !== cpanelNames.length) {
|
||||
throw new DeploymentError("Set all PRODUCTION_CPANEL_* values together or omit them from the FTPS release path.");
|
||||
}
|
||||
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
||||
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
||||
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
||||
}
|
||||
const cpanel = {
|
||||
cpanel = {
|
||||
user: cpanelUser,
|
||||
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
||||
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
||||
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
|
||||
};
|
||||
}
|
||||
const host = requireString(env, "PRODUCTION_FTP_HOST");
|
||||
if (!SAFE_HOST.test(host) || host.includes("..")) {
|
||||
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
|
||||
@@ -178,7 +183,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
|
||||
}
|
||||
if (rollbackOnly) {
|
||||
return { ftp, cpanel, activationKey };
|
||||
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
|
||||
}
|
||||
|
||||
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
||||
@@ -224,7 +229,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
|
||||
return {
|
||||
ftp,
|
||||
cpanel,
|
||||
...(cpanel ? { cpanel } : {}),
|
||||
activationKey,
|
||||
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
||||
checksumPath: path.resolve(checksumPath),
|
||||
@@ -824,17 +829,6 @@ export async function assertReleaseTargetExists(client, config, target) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCurrentLink(client, config) {
|
||||
const entries = await client.list(config.cpanel.root);
|
||||
const current = findEntry(entries, "current");
|
||||
if (!current) {
|
||||
throw new DeploymentError("cPanel does not have a current frontend release pointer.");
|
||||
}
|
||||
if (current.type !== "link") {
|
||||
throw new DeploymentError("cPanel current is not a symbolic link.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function capturePublishedReleaseTarget(config, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
|
||||
@@ -909,17 +903,6 @@ export async function assertExpectedCommitCurrent(config, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) {
|
||||
const archiveRoot = containedRemotePath(config.cpanel.root, "archives");
|
||||
if (!findEntry(await client.list(config.cpanel.root), "archives")) {
|
||||
throw new DeploymentError("cPanel deployment archives directory is missing; bootstrap is incomplete.");
|
||||
}
|
||||
const entries = await client.list(archiveRoot);
|
||||
if (findEntry(entries, archiveName) || findEntry(entries, checksumName)) {
|
||||
throw new DeploymentError("The immutable release archive name already exists on cPanel.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPublicVerification(config, options = {}) {
|
||||
const runner = options.runner || defaultProcessRunner;
|
||||
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
|
||||
@@ -991,23 +974,16 @@ export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
|
||||
}
|
||||
|
||||
export async function deployRelease(config, dependencies = {}) {
|
||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const verify = dependencies.verify || runPublicVerification;
|
||||
const prune = dependencies.prune || pruneInactiveReleases;
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
||||
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
||||
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
||||
|
||||
await checkCurrent(config, dependencies);
|
||||
await assertCurrentLink(client, config);
|
||||
const previousTarget = await capturePrevious(config, dependencies);
|
||||
await assertReleaseTargetExists(client, config, previousTarget);
|
||||
|
||||
const archiveName = safeArchiveName(config.archivePath);
|
||||
const checksumName = `${archiveName}.sha256`;
|
||||
await assertArchiveNamesAvailable(client, config, archiveName, checksumName);
|
||||
const uploaded = await transport.uploadArchive();
|
||||
await checkCurrent(config, dependencies);
|
||||
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
||||
@@ -1033,7 +1009,6 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
|
||||
try {
|
||||
await assertReleaseTargetExists(client, config, newTarget);
|
||||
await transport.verifyRelease(newTarget);
|
||||
await verify(config, dependencies);
|
||||
} catch (error) {
|
||||
@@ -1050,28 +1025,19 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
await assertCurrentLink(client, config);
|
||||
let removed = [];
|
||||
let retentionWarning = "";
|
||||
try {
|
||||
removed = await prune(client, config, [newTarget, previousTarget], {
|
||||
removeRelease: async (releaseId) => {
|
||||
await transport.removeArchives([releaseId]);
|
||||
await transport.removeRelease(releaseId);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
retentionWarning = "Verified deployment succeeded, but old release retention cleanup failed.";
|
||||
}
|
||||
return { previousTarget, activeTarget: newTarget, removed, retentionWarning };
|
||||
return {
|
||||
previousTarget,
|
||||
activeTarget: newTarget,
|
||||
removed: [],
|
||||
retentionWarning:
|
||||
"Verified deployment succeeded; inactive release cleanup is deferred because hosted runners cannot use the cPanel metadata API.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function rollbackRelease(config, target, dependencies = {}) {
|
||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const safeTarget = validateReleaseTarget(target);
|
||||
await assertReleaseTargetExists(client, config, safeTarget);
|
||||
await transport.activateExisting(safeTarget);
|
||||
publish({
|
||||
RELEASE_ACTIVE_TARGET: safeTarget,
|
||||
|
||||
@@ -440,6 +440,14 @@ function validateShardSelection(role, matchingTests, shardTests) {
|
||||
}
|
||||
}
|
||||
|
||||
export function requireMatchingTests(role, project, matchingTests) {
|
||||
if (matchingTests.length === 0) {
|
||||
throw new Error(`Full Playwright slice resolved zero ${role} tests for ${project}.`);
|
||||
}
|
||||
|
||||
return matchingTests;
|
||||
}
|
||||
|
||||
async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
const args = ["test", `--project=${project}`, `--test-list=${testListPath}`, ...forwardedArgs];
|
||||
|
||||
@@ -487,10 +495,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
`Resolved ${matchingTests.length} ${options.role} test(s) out of ${classifiedTests.length} listed test(s) for ${options.project}.`
|
||||
);
|
||||
|
||||
if (matchingTests.length === 0) {
|
||||
console.log(`No ${options.role} tests matched for ${options.project}. Nothing to run.`);
|
||||
return;
|
||||
}
|
||||
requireMatchingTests(options.role, options.project, matchingTests);
|
||||
|
||||
if (options.listOnly && !options.shard.explicit) {
|
||||
for (const testEntry of matchingTests) {
|
||||
|
||||
@@ -130,6 +130,23 @@ describe("cPanel deployment configuration", () => {
|
||||
});
|
||||
expect(result.cpanel.root).toBe("public_html/frontend");
|
||||
});
|
||||
|
||||
it("does not require cPanel API access for the hosted FTPS release path", () => {
|
||||
const env = validEnv();
|
||||
for (const name of [
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
]) {
|
||||
delete env[name];
|
||||
}
|
||||
|
||||
const result = readDeploymentConfig(env);
|
||||
|
||||
expect(result.cpanel).toBeUndefined();
|
||||
expect(result.ftp.root).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secure FTPS archive upload", () => {
|
||||
@@ -417,6 +434,14 @@ describe("account-scoped release activator", () => {
|
||||
await writeRequest("activation-switch", "unused", releaseId, "123-1", "switch");
|
||||
await execFileAsync("sh", [activator], { env: activatorEnvironment });
|
||||
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
|
||||
|
||||
const missingReleaseId = `${COMMIT_SHA}-missing`;
|
||||
await writeRequest("activation-missing", "unused", missingReleaseId, "missing", "switch");
|
||||
await expect(execFileAsync("sh", [activator], { env: activatorEnvironment })).rejects.toThrow();
|
||||
expect(await fs.readlink(path.join(activationRoot, "current"))).toBe(target);
|
||||
expect(
|
||||
await fs.readFile(path.join(activationRoot, "activation-results", "activation-missing.result"), "utf8")
|
||||
).toContain("message=release_validation");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -624,15 +649,14 @@ describe("activation, rollback, and retention", () => {
|
||||
|
||||
it("reconciles a lost activation result before verifying the committed release", async () => {
|
||||
const deploymentConfig = config();
|
||||
delete deploymentConfig.cpanel;
|
||||
const previousTarget = `releases/${"a".repeat(40)}-122-1/dist`;
|
||||
const newTarget = `releases/${deploymentConfig.releaseId}/dist`;
|
||||
const client = inMemorySwitchClient(deploymentConfig, previousTarget);
|
||||
const verifyRelease = vi.fn();
|
||||
const verify = vi.fn();
|
||||
const transport = {
|
||||
uploadArchive: async () => ({ archiveName: "release.zip", sha256: "a".repeat(64) }),
|
||||
stageAndActivate: async () => {
|
||||
client.setCurrent(newTarget);
|
||||
throw new DeploymentError("Timed out waiting for the account-scoped release activator.");
|
||||
},
|
||||
activateExisting: vi.fn(),
|
||||
@@ -641,7 +665,6 @@ describe("activation, rollback, and retention", () => {
|
||||
|
||||
await expect(
|
||||
deployRelease(deploymentConfig, {
|
||||
client,
|
||||
transport,
|
||||
capturePrevious: async () => previousTarget,
|
||||
captureActive: async () => newTarget,
|
||||
@@ -675,18 +698,21 @@ describe("activation, rollback, and retention", () => {
|
||||
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ RELEASE_ACTIVE_TARGET: target }));
|
||||
});
|
||||
|
||||
it("rejects a rollback-only target that no longer exists before running the preflight", async () => {
|
||||
it("propagates server-side rollback target validation failures without publishing outputs", async () => {
|
||||
const deploymentConfig = config();
|
||||
const activateExisting = vi.fn();
|
||||
const client = { list: vi.fn(async () => []) };
|
||||
const activateExisting = vi.fn(async () => {
|
||||
throw new DeploymentError("Server-side release activation failed during release_validation.");
|
||||
});
|
||||
const publish = vi.fn();
|
||||
|
||||
await expect(
|
||||
rollbackRelease(deploymentConfig, "releases/missing-release/dist", {
|
||||
client,
|
||||
transport: { activateExisting },
|
||||
publish,
|
||||
})
|
||||
).rejects.toThrow("rollback release directory does not exist");
|
||||
expect(activateExisting).not.toHaveBeenCalled();
|
||||
).rejects.toThrow("release_validation");
|
||||
expect(activateExisting).toHaveBeenCalledWith("releases/missing-release/dist");
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes only inactive releases and preserves active and rollback targets", async () => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
evaluateStoreGate,
|
||||
expectedStoreGateJobs,
|
||||
verifyStoreTestGate,
|
||||
} from "../../scripts/mobile/verify-store-test-gate.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const successfulJobs = (platform) =>
|
||||
expectedStoreGateJobs(platform).map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
run_attempt: 1,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
}));
|
||||
|
||||
describe("mobile store test gates", () => {
|
||||
it("maps Android to every Chromium mobile role shard", () => {
|
||||
expect(expectedStoreGateJobs("android")).toEqual([
|
||||
"E2E-full-Chromium-mobile-superuser-shard-1-of-2",
|
||||
"E2E-full-Chromium-mobile-superuser-shard-2-of-2",
|
||||
"E2E-full-Chromium-mobile-admin-shard-1-of-2",
|
||||
"E2E-full-Chromium-mobile-admin-shard-2-of-2",
|
||||
"E2E-full-Chromium-mobile-customer-shard-1-of-1",
|
||||
"E2E-full-Chromium-mobile-subuser-shard-1-of-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps Apple to every WebKit mobile role shard", () => {
|
||||
expect(expectedStoreGateJobs("apple")).toEqual([
|
||||
"E2E-full-WebKit-mobile-superuser-shard-1-of-2",
|
||||
"E2E-full-WebKit-mobile-superuser-shard-2-of-2",
|
||||
"E2E-full-WebKit-mobile-admin-shard-1-of-2",
|
||||
"E2E-full-WebKit-mobile-admin-shard-2-of-2",
|
||||
"E2E-full-WebKit-mobile-customer-shard-1-of-1",
|
||||
"E2E-full-WebKit-mobile-subuser-shard-1-of-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a missing, skipped, or failed required slice", () => {
|
||||
const jobs = successfulJobs("android");
|
||||
jobs.splice(3, 1);
|
||||
jobs[3] = { ...jobs[3], conclusion: "failure" };
|
||||
|
||||
expect(evaluateStoreGate("android", jobs)).toMatchObject({
|
||||
passed: false,
|
||||
failures: [
|
||||
"E2E-full-Chromium-mobile-admin-shard-2-of-2:missing",
|
||||
"E2E-full-Chromium-mobile-customer-shard-1-of-1:completed/failure",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the newest execution of a rerun job while carrying over successful jobs from earlier attempts", () => {
|
||||
const jobs = successfulJobs("android");
|
||||
jobs.push({ ...jobs[0], id: 100, run_attempt: 2 });
|
||||
|
||||
expect(evaluateStoreGate("android", jobs)).toMatchObject({ passed: true, failures: [] });
|
||||
|
||||
jobs.push({ ...jobs[0], id: 101, run_attempt: 3, conclusion: "failure" });
|
||||
expect(evaluateStoreGate("android", jobs)).toMatchObject({
|
||||
passed: false,
|
||||
failures: ["E2E-full-Chromium-mobile-superuser-shard-1-of-2:completed/failure"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts only a completed push run for the exact source SHA", async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 123,
|
||||
name: "Automated Tests",
|
||||
head_sha: sha,
|
||||
head_branch: "master",
|
||||
event: "push",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
},
|
||||
{ jobs: successfulJobs("apple") },
|
||||
];
|
||||
const paths = [];
|
||||
const fetchImpl = async (url) => {
|
||||
paths.push(new URL(url).pathname + new URL(url).search);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => responses.shift(),
|
||||
};
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyStoreTestGate({
|
||||
platform: "apple",
|
||||
sourceSha: sha,
|
||||
runId: "123",
|
||||
defaultBranch: "master",
|
||||
repository: "owner/repo",
|
||||
token: "test-token",
|
||||
fetchImpl,
|
||||
})
|
||||
).resolves.toMatchObject({ passed: true, runId: 123, sourceSha: sha });
|
||||
expect(paths).toEqual([
|
||||
"/repos/owner/repo/actions/runs/123",
|
||||
"/repos/owner/repo/actions/runs/123/jobs?filter=all&per_page=100&page=1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts carried-over successful jobs after a partial rerun", async () => {
|
||||
const jobs = successfulJobs("apple");
|
||||
jobs.push({ ...jobs[0], id: 100, run_attempt: 2 });
|
||||
const responses = [
|
||||
{
|
||||
id: 123,
|
||||
name: "Automated Tests",
|
||||
head_sha: sha,
|
||||
head_branch: "master",
|
||||
event: "push",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
},
|
||||
{ jobs },
|
||||
];
|
||||
const paths = [];
|
||||
const fetchImpl = async (url) => {
|
||||
paths.push(new URL(url).pathname + new URL(url).search);
|
||||
return { ok: true, json: async () => responses.shift() };
|
||||
};
|
||||
|
||||
await expect(
|
||||
verifyStoreTestGate({
|
||||
platform: "apple",
|
||||
sourceSha: sha,
|
||||
runId: "123",
|
||||
defaultBranch: "master",
|
||||
repository: "owner/repo",
|
||||
token: "test-token",
|
||||
fetchImpl,
|
||||
})
|
||||
).resolves.toMatchObject({ passed: true });
|
||||
expect(paths[1]).toContain("filter=all");
|
||||
});
|
||||
|
||||
it("rejects a completed test run whose overall conclusion failed", async () => {
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 123,
|
||||
name: "Automated Tests",
|
||||
head_sha: sha,
|
||||
head_branch: "master",
|
||||
event: "push",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyStoreTestGate({
|
||||
platform: "android",
|
||||
sourceSha: sha,
|
||||
runId: "123",
|
||||
defaultBranch: "master",
|
||||
repository: "owner/repo",
|
||||
token: "test-token",
|
||||
fetchImpl,
|
||||
})
|
||||
).rejects.toThrow("did not succeed (failure)");
|
||||
});
|
||||
|
||||
it("fails closed when the run belongs to another branch", async () => {
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 123,
|
||||
name: "Automated Tests",
|
||||
head_sha: sha,
|
||||
head_branch: "dev",
|
||||
event: "push",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyStoreTestGate({
|
||||
platform: "android",
|
||||
sourceSha: sha,
|
||||
runId: "123",
|
||||
defaultBranch: "master",
|
||||
repository: "owner/repo",
|
||||
token: "test-token",
|
||||
fetchImpl,
|
||||
})
|
||||
).rejects.toThrow("not a push run for master");
|
||||
});
|
||||
|
||||
it("resolves current master before any manual or tag Play upload", () => {
|
||||
const source = readFileSync(join(process.cwd(), ".github/workflows/mobile-artifacts.yml"), "utf8");
|
||||
|
||||
expect(source).toContain("UPLOAD_TO_PLAY:");
|
||||
expect(source).toContain("git/ref/heads/$DEFAULT_BRANCH");
|
||||
expect(source).toContain('[[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]');
|
||||
expect(source).toContain('echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"');
|
||||
expect(source).toContain("STORE_SOURCE_SHA: ${{ steps.release-guard.outputs.source_sha }}");
|
||||
expect(source).toContain("Recheck live master before Play upload");
|
||||
expect(source).toContain('[[ "$live_master_sha" == "$EXPECTED_SHA" ]]');
|
||||
expect(source.indexOf("Recheck live master before Play upload")).toBeLessThan(
|
||||
source.indexOf("Upload Android App Bundle to Google Play")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getPrimaryTestListPath,
|
||||
ownedFilesByRole,
|
||||
parseListedTests,
|
||||
requireMatchingTests,
|
||||
roles,
|
||||
titleRules,
|
||||
writeTestList,
|
||||
@@ -139,6 +140,13 @@ describe("Playwright full-slice ownership", () => {
|
||||
expect(readFileSync(primaryPath, "utf8")).toBe(`${matchingTests[0].listLine}\n`);
|
||||
expect(readFileSync(legacyPath, "utf8")).toBe(`${matchingTests[0].listLine}\n`);
|
||||
});
|
||||
|
||||
it("fails closed when a required project-role slice resolves zero tests", () => {
|
||||
expect(() => requireMatchingTests("customer", "webkit-mobile", [])).toThrow(
|
||||
"Full Playwright slice resolved zero customer tests for webkit-mobile."
|
||||
);
|
||||
expect(requireMatchingTests("customer", "webkit-mobile", [{ listLine: "one test" }])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playwright full-suite project order", () => {
|
||||
|
||||
Reference in New Issue
Block a user