Harden hosted releases and mobile store gates (#205)

This commit is contained in:
Jeppe B
2026-07-21 18:17:47 +02:00
committed by GitHub
parent 0692cb3aea
commit a01902356d
13 changed files with 613 additions and 299 deletions
-99
View File
@@ -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
+8
View File
@@ -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
+44 -16
View File
@@ -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,29 +88,29 @@ 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" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
latest_sha="$(curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" | jq -r '.object.sha // empty')"
if [[ ! "$latest_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" && "$UPLOAD_TO_PLAY" == "true" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
elif [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
echo "Allowing artifact-only build for $EVENT_NAME on $RELEASE_BRANCH; store upload remains disabled."
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "source_sha=$latest_sha" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
@@ -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:
+2 -10
View File
@@ -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
+37 -90
View File
@@ -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
+11 -1
View File
@@ -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
+2 -5
View File
@@ -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")
);
+213
View File
@@ -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;
});
}
+31 -65
View File
@@ -18,10 +18,6 @@ const REQUIRED_ENV = [
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
"PRODUCTION_CPANEL_PATH",
"RELEASE_ARCHIVE_PATH",
"RELEASE_INVENTORY_PATH",
"RELEASE_EXPECTED_COMMIT",
@@ -33,10 +29,6 @@ const ROLLBACK_REQUIRED_ENV = [
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
"PRODUCTION_CPANEL_PATH",
];
export class DeploymentError extends Error {
@@ -153,16 +145,29 @@ export function readDeploymentConfig(env = process.env, options = {}) {
requireString(env, name);
}
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
if (!SAFE_COMPONENT.test(cpanelUser)) {
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
const cpanelNames = [
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
"PRODUCTION_CPANEL_PATH",
];
const configuredCpanelNames = cpanelNames.filter((name) => env[name] !== undefined && env[name] !== "");
let cpanel = null;
if (configuredCpanelNames.length > 0) {
if (configuredCpanelNames.length !== cpanelNames.length) {
throw new DeploymentError("Set all PRODUCTION_CPANEL_* values together or omit them from the FTPS release path.");
}
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
if (!SAFE_COMPONENT.test(cpanelUser)) {
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
}
cpanel = {
user: cpanelUser,
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
};
}
const cpanel = {
user: cpanelUser,
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
};
const host = requireString(env, "PRODUCTION_FTP_HOST");
if (!SAFE_HOST.test(host) || host.includes("..")) {
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
@@ -178,7 +183,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
}
if (rollbackOnly) {
return { ftp, cpanel, activationKey };
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
}
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
@@ -224,7 +229,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
return {
ftp,
cpanel,
...(cpanel ? { cpanel } : {}),
activationKey,
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
checksumPath: path.resolve(checksumPath),
@@ -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,
+9 -4
View File
@@ -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) {
+35 -9
View File
@@ -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 () => {
+213
View File
@@ -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", () => {