diff --git a/.github/workflows/cpanel-root-restore.yml b/.github/workflows/cpanel-root-restore.yml deleted file mode 100644 index 16b812dd..00000000 --- a/.github/workflows/cpanel-root-restore.yml +++ /dev/null @@ -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 TO STATE 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 diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 5e2c4768..ecc8ab2a 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -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 diff --git a/.github/workflows/mobile-artifacts.yml b/.github/workflows/mobile-artifacts.yml index 355fc0a6..1ae00696 100644 --- a/.github/workflows/mobile-artifacts.yml +++ b/.github/workflows/mobile-artifacts.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98b1ad89..9422a023 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/docs/cpanel-frontend-deployment.md b/docs/cpanel-frontend-deployment.md index 82f6b71a..6909aee9 100644 --- a/docs/cpanel-frontend-deployment.md +++ b/docs/cpanel-frontend-deployment.md @@ -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//dist ``` The domain's document root must resolve to -`//current`, not to the deployment +`/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 TO public_html STATE `, 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 diff --git a/docs/mobile-artifacts.md b/docs/mobile-artifacts.md index 7735d77e..e74953e1 100644 --- a/docs/mobile-artifacts.md +++ b/docs/mobile-artifacts.md @@ -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 diff --git a/scripts/install-playwright-browsers.mjs b/scripts/install-playwright-browsers.mjs index 2a86ed76..0c717a04 100644 --- a/scripts/install-playwright-browsers.mjs +++ b/scripts/install-playwright-browsers.mjs @@ -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") ); diff --git a/scripts/mobile/verify-store-test-gate.mjs b/scripts/mobile/verify-store-test-gate.mjs new file mode 100644 index 00000000..c075569f --- /dev/null +++ b/scripts/mobile/verify-store-test-gate.mjs @@ -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 || ""}`); + } + + 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 || ""} is not the Automated Tests workflow.`); + } + if (run.head_sha !== sourceSha) { + throw new Error(`Run ${run.id} tested ${run.head_sha || ""}, 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; + }); +} diff --git a/scripts/release/cpanel-deploy-lib.mjs b/scripts/release/cpanel-deploy-lib.mjs index 984b5c1c..d7058a16 100644 --- a/scripts/release/cpanel-deploy-lib.mjs +++ b/scripts/release/cpanel-deploy-lib.mjs @@ -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, diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index 71f52603..fa06533c 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -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) { diff --git a/tests/unit/cpanel-deploy.spec.js b/tests/unit/cpanel-deploy.spec.js index 4ca21be8..c8f298f9 100644 --- a/tests/unit/cpanel-deploy.spec.js +++ b/tests/unit/cpanel-deploy.spec.js @@ -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 () => { diff --git a/tests/unit/mobile-store-test-gate.spec.js b/tests/unit/mobile-store-test-gate.spec.js new file mode 100644 index 00000000..ca93d838 --- /dev/null +++ b/tests/unit/mobile-store-test-gate.spec.js @@ -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") + ); + }); +}); diff --git a/tests/unit/playwright-full-slice-ownership.spec.js b/tests/unit/playwright-full-slice-ownership.spec.js index 03761762..446e96bb 100644 --- a/tests/unit/playwright-full-slice-ownership.spec.js +++ b/tests/unit/playwright-full-slice-ownership.spec.js @@ -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", () => {