diff --git a/.github/workflows/cpanel-root-restore.yml b/.github/workflows/cpanel-root-restore.yml new file mode 100644 index 00000000..ca7855cf --- /dev/null +++ b/.github/workflows/cpanel-root-restore.yml @@ -0,0 +1,99 @@ +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: ubuntu-24.04 + 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/release.yml b/.github/workflows/release.yml index 9bede6a2..497dcd49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,49 +14,57 @@ permissions: actions: read concurrency: - group: frontend-release-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: true + group: frontend-production + cancel-in-progress: false jobs: - build-upload-and-verify: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + build-release: + if: >- + github.event.workflow_run.conclusion == 'success' && + 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] env: - RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend - PLAYWRIGHT_BASE_URL: https://dev.truckwash.io - PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend - PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io - PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping - RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} - RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} + RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }} - RELEASE_WAIT_INITIAL_SECONDS: 45 - RELEASE_WAIT_TIMEOUT_SECONDS: 600 - RELEASE_POLL_INTERVAL_SECONDS: 10 + RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} + outputs: + current: ${{ steps.branch-head.outputs.current }} + build_id: ${{ steps.package.outputs.build_id }} + artifact_name: ${{ steps.package-names.outputs.artifact_name }} + archive_name: ${{ steps.package.outputs.archive_name }} + checksum_name: ${{ steps.package-names.outputs.checksum_name }} + inventory_name: ${{ steps.package-names.outputs.inventory_name }} + release_id: ${{ steps.package.outputs.release_id }} steps: - - name: Checkout repository + - name: Check release commit is current + id: branch-head + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const { data: branch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: "master", + }); + const expected = process.env.RELEASE_EXPECTED_COMMIT; + const current = branch.commit.sha === expected; + core.setOutput("current", String(current)); + core.info( + current + ? `Release commit ${expected} is current for master.` + : `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`, + ); + + - name: Checkout tested commit + if: steps.branch-head.outputs.current == 'true' uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha }} - - - name: Check release commit is current - id: branch-head - run: | - latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')" - if [[ -z "$latest_sha" ]]; then - echo "Could not resolve origin/$RELEASE_BRANCH." >&2 - exit 1 - fi - if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then - echo "current=false" >> "$GITHUB_OUTPUT" - echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha." - exit 0 - fi - echo "current=true" >> "$GITHUB_OUTPUT" - echo "Release commit is current for $RELEASE_BRANCH." - env: - RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} + persist-credentials: false + ref: ${{ env.RELEASE_COMMIT_SHA }} - name: Setup Node.js if: steps.branch-head.outputs.current == 'true' @@ -79,16 +87,22 @@ jobs: npm run text:check-encoding npm run i18n:v2:source-check - - name: Unit tests - if: steps.branch-head.outputs.current == 'true' - run: npm run test:unit - env: - VITEST_BATCH_SIZE: 5 - - name: Build release artifact if: steps.branch-head.outputs.current == 'true' run: npm run build + - name: Record pre-gate dist inventory + if: steps.branch-head.outputs.current == 'true' + run: | + inventory="$RUNNER_TEMP/dist-before-production-gate.txt" + while IFS= read -r -d '' file; do + relative_path="${file#dist/}" + printf '%s\t%s\t%s\n' \ + "$(sha256sum "$file" | awk '{print $1}')" \ + "$(stat --format='%s' "$file")" \ + "$relative_path" + done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory" + - name: Install Playwright Chromium if: steps.branch-head.outputs.current == 'true' run: node scripts/install-playwright-browsers.mjs chromium @@ -96,56 +110,176 @@ jobs: - name: Production Playwright gate if: steps.branch-head.outputs.current == 'true' run: npm run test:e2e:prod + env: + PLAYWRIGHT_PROD_WEBKIT: "0" - - name: Upload dist artifact - if: steps.branch-head.outputs.current == 'true' - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - name: frontend-dist-${{ env.RELEASE_BUILD_ID }} - path: dist - retention-days: 3 - - - name: Request Release Manager auto sync + - name: Confirm production gate did not mutate dist if: steps.branch-head.outputs.current == 'true' run: | - test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) - response_file="$(mktemp)" - status_code="$(curl --show-error --silent \ - --output "$response_file" \ - --write-out "%{http_code}" \ - -X POST "$RELEASE_MANAGER_GATE_URL" \ - -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ - -H "Content-Type: application/json" \ - --data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")" - if [[ "$status_code" =~ ^2 ]]; then - cat "$response_file" - elif [[ "$status_code" == "504" ]]; then - echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait." - else - cat "$response_file" >&2 - echo "Release Manager auto sync request failed with HTTP $status_code." >&2 - exit 1 - fi - env: - RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} - RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} - RELEASE_REPOSITORY: ${{ github.repository }} - RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} - RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + inventory="$RUNNER_TEMP/dist-after-production-gate.txt" + while IFS= read -r -d '' file; do + relative_path="${file#dist/}" + printf '%s\t%s\t%s\n' \ + "$(sha256sum "$file" | awk '{print $1}')" \ + "$(stat --format='%s' "$file")" \ + "$relative_path" + done < <(find dist -type f -print0 | LC_ALL=C sort -z) > "$inventory" + cmp "$RUNNER_TEMP/dist-before-production-gate.txt" "$inventory" - - name: Wait for Coolify release artifact + - name: Package and validate release if: steps.branch-head.outputs.current == 'true' - run: npm run release:verify-upload + id: package + run: node scripts/release/package-dist.mjs + env: + RELEASE_OUTPUT_DIR: release-artifacts + + - name: Resolve package metadata + if: steps.branch-head.outputs.current == 'true' + id: package-names + env: + BUILD_ID: ${{ steps.package.outputs.build_id }} + CHECKSUM_PATH: ${{ steps.package.outputs.checksum_path }} + INVENTORY_PATH: ${{ steps.package.outputs.inventory_path }} + run: | + echo "artifact_name=frontend-release-$BUILD_ID" >> "$GITHUB_OUTPUT" + echo "checksum_name=$(basename -- "$CHECKSUM_PATH")" >> "$GITHUB_OUTPUT" + echo "inventory_name=$(basename -- "$INVENTORY_PATH")" >> "$GITHUB_OUTPUT" + + - name: Upload release package + if: steps.branch-head.outputs.current == 'true' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package-names.outputs.artifact_name }} + path: | + ${{ steps.package.outputs.archive_path }} + ${{ steps.package.outputs.checksum_path }} + ${{ steps.package.outputs.inventory_path }} + if-no-files-found: error + retention-days: 14 + + deploy-frontend-production: + needs: build-release + if: needs.build-release.outputs.current == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + environment: + name: frontend-production + url: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + env: + RELEASE_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + PLAYWRIGHT_RELEASE_STATIC_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io + PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping + RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} + RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }} + RELEASE_BUILD_ID: ${{ needs.build-release.outputs.build_id }} + RELEASE_EXPECTED_BUILD_ID: ${{ needs.build-release.outputs.build_id }} + RELEASE_ID: ${{ needs.build-release.outputs.release_id }} + RELEASE_STRICT_BUILD_ID: "true" + RELEASE_REQUIRE_CACHE_HEADERS: "true" + RELEASE_WAIT_INITIAL_SECONDS: 0 + RELEASE_WAIT_TIMEOUT_SECONDS: 300 + RELEASE_POLL_INTERVAL_SECONDS: 5 + steps: + - name: Checkout tested commit + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ env.RELEASE_COMMIT_SHA }} + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Install secure FTP client + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends lftp + + - name: Install Playwright Chromium + run: node scripts/install-playwright-browsers.mjs chromium + + - name: Download validated release package + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-release.outputs.artifact_name }} + path: release-artifacts + + - name: Resolve downloaded release package + env: + ARCHIVE_NAME: ${{ needs.build-release.outputs.archive_name }} + CHECKSUM_NAME: ${{ needs.build-release.outputs.checksum_name }} + INVENTORY_NAME: ${{ needs.build-release.outputs.inventory_name }} + run: | + [[ -n "$ARCHIVE_NAME" && "$ARCHIVE_NAME" == "$(basename -- "$ARCHIVE_NAME")" ]] + [[ -n "$CHECKSUM_NAME" && "$CHECKSUM_NAME" == "$(basename -- "$CHECKSUM_NAME")" ]] + [[ -n "$INVENTORY_NAME" && "$INVENTORY_NAME" == "$(basename -- "$INVENTORY_NAME")" ]] + + archive_path="$GITHUB_WORKSPACE/release-artifacts/$ARCHIVE_NAME" + checksum_path="$GITHUB_WORKSPACE/release-artifacts/$CHECKSUM_NAME" + inventory_path="$GITHUB_WORKSPACE/release-artifacts/$INVENTORY_NAME" + [[ -f "$archive_path" && -f "$checksum_path" && -f "$inventory_path" ]] + + echo "RELEASE_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV" + echo "RELEASE_ARCHIVE_SHA256_PATH=$checksum_path" >> "$GITHUB_ENV" + echo "RELEASE_INVENTORY_PATH=$inventory_path" >> "$GITHUB_ENV" + + - name: Check release commit is still current + id: branch-head + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const { data: branch } = await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: "master", + }); + const expected = process.env.RELEASE_EXPECTED_COMMIT; + const current = branch.commit.sha === expected; + core.setOutput("current", String(current)); + core.info( + current + ? `Release commit ${expected} is current immediately before activation.` + : `Skipping stale release for ${expected}; origin/master is ${branch.commit.sha}.`, + ); + + - name: Deploy atomically and verify cPanel release + if: steps.branch-head.outputs.current == 'true' + id: deploy + timeout-minutes: 15 + run: node scripts/release/deploy-cpanel.mjs + env: + NODE_OPTIONS: --use-system-ca + PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }} + PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }} + PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }} + PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }} + PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }} + PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }} + PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }} + PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }} + PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + RELEASE_GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_GITHUB_TOKEN: ${{ github.token }} - name: Public live Playwright gate if: steps.branch-head.outputs.current == 'true' + timeout-minutes: 10 run: npm run test:e2e:live:public env: NODE_OPTIONS: --use-system-ca - name: Credentialed live Playwright gate if: steps.branch-head.outputs.current == 'true' + timeout-minutes: 15 run: npm run test:e2e:live:roles env: NODE_OPTIONS: --use-system-ca @@ -157,6 +291,23 @@ jobs: PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }} PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }} + - name: Roll back after live verification failure + if: failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success' + timeout-minutes: 10 + run: node scripts/release/deploy-cpanel.mjs --rollback + env: + NODE_OPTIONS: --use-system-ca + RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }} + PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }} + PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }} + PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }} + PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }} + PRODUCTION_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 if: steps.branch-head.outputs.current == 'true' run: | @@ -166,12 +317,11 @@ jobs: -X POST "$RELEASE_MANAGER_GATE_URL" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Content-Type: application/json" \ - --data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}" + --data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"master\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":false,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}" env: RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_REPOSITORY: ${{ github.repository }} - RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }} RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Update server version after verification @@ -189,4 +339,4 @@ jobs: name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }} path: output/playwright if-no-files-found: ignore - retention-days: 3 + retention-days: 14 diff --git a/docs/cpanel-frontend-deployment.md b/docs/cpanel-frontend-deployment.md new file mode 100644 index 00000000..5e7c9643 --- /dev/null +++ b/docs/cpanel-frontend-deployment.md @@ -0,0 +1,247 @@ +# cPanel frontend deployment + +This runbook covers the production deployment of `pleno-vue` only. The API is +not uploaded to cPanel and continues to use its existing release process and +hosts. + +## Release flow + +`.github/workflows/release.yml` starts only after the `Automated Tests` +workflow succeeds for a push to `master` in this repository. It then: + +1. Rechecks that the tested commit is still the head of `master`. +2. Checks out that exact commit without persisting GitHub credentials. +3. Installs dependencies, runs source checks, and builds `dist` once. +4. Runs the local-production Playwright gate against that existing `dist`. +5. Creates an immutable ZIP, SHA-256 sidecar, and file inventory, then verifies + a local archive round trip. +6. Uploads the package as a required GitHub Actions artifact. +7. Enters the protected `frontend-production` GitHub environment and rechecks + `master` immediately before deployment. +8. Uploads the ZIP and checksum over certificate-verified explicit FTPS. The + uploaded `.part` files are downloaded and hashed before they are renamed. +9. Uses the cPanel Fileman API to extract into a new inactive release. The + extracted tree is downloaded and compared byte-for-byte with the validated + inventory, then `master` is checked again through the read-only workflow + token. +10. Replaces the `current` symlink with a single server-side rename. Public + manifest, asset-integrity, cache-header, API-ping, and role gates run after + activation. A failed public or role gate restores the previous symlink. + +The fixed `frontend-production` concurrency group is not cancellable. A newer +push therefore cannot interrupt an in-progress switch or rollback. + +## GitHub environment + +Create the environment `frontend-production`, restrict deployment branches to +protected branches, and keep `master` protected by the required CI checks. +Production approvals can be added as an environment protection rule. + +Add these environment **secrets**: + +- `PRODUCTION_FTP_HOST` +- `PRODUCTION_FTP_USER` +- `PRODUCTION_FTP_PASSWORD` +- `PRODUCTION_FTP_PATH` +- `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 cPanel API2 `Fileman::fileop` because cPanel does not provide a UAPI +replacement for the required extract, symlink, and rename operations. Revoke +and rotate the token if it is ever exposed. + +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. + +### Create the dedicated FTP credentials + +1. Open **Files -> FTP Accounts** in the `truckwash` cPanel account. +2. Create `github-pleno-vue@truckwash.io` with a generated, unique password. +3. Set its directory to `frontend-deployments`, which cPanel resolves to + `/home/truckwash/frontend-deployments`, and leave quota unlimited. +4. Add `server.red-block.com` as `PRODUCTION_FTP_HOST`. Do not use + `truckwash.io`: the FTPS certificate is issued to the server hostname. +5. Add the full account login as `PRODUCTION_FTP_USER`, the generated password + as `PRODUCTION_FTP_PASSWORD`, and `/` as `PRODUCTION_FTP_PATH`. `/` is the + root of this jailed FTP account, not the cPanel account home. +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 preflight against the configured API + origin. It must be able to call cPanel API2 `Fileman::fileop` for extract, + symlink, and rename operations inside `PRODUCTION_CPANEL_PATH`. If the provider + restricts those operations, request the required account feature access; + 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. +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. GitHub-hosted deploy runners install +`lftp` and Playwright Chromium during the job; the existing self-hosted build +runner still needs Node 22, npm, `zip`, `unzip`, GNU `find`, `stat`, and +`sha256sum`. + +## 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: + +```text +archives/ +releases/ + --/ + dist/ +staging/ +current -> releases//dist +``` + +The domain's document root must resolve to +`//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. + +Before merging the workflow change, perform a one-time bootstrap in cPanel: + +1. Back up the existing cPanel webroot and confirm the frontend hostname does + not serve API/PHP files from this location. +2. Create `archives`, `releases`, and `staging` below the dedicated deployment + root. +3. Put one complete, validated frontend build at + `releases/-/dist`. Its `release-manifest.json` must contain + that full 40-character commit and the same build ID used in the directory + name. +4. Create `current` as a relative symlink to that release's `dist` directory. +5. Make the frontend domain document root resolve to the stable `current` path. + For a cPanel primary domain whose configured document root remains + `/home/truckwash/public_html`, make `public_html` a symlink to + `frontend-deployments/current`. Exchange the old directory and prepared + symlink atomically, and retain the old directory as a recovery copy. +6. Confirm the release `.htaccess` contains `DirectoryIndex index.html` so a + symlinked primary-domain root serves the Vue shell instead of a directory + 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 + a disposable symlink-replacement preflight and refuses deployment if the + filesystem or hosting policy cannot replace a symlink atomically. + +The automatic deployer intentionally refuses to create the first `current` +pointer. This prevents a missing or misconfigured bootstrap from turning the +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`. + +If the regression followed the one-time webroot exchange, 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. An unreadable root is eligible only when the +independent account-home listing identifies it as a symbolic link. It renames +the current 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. + +## 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. + +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 +implementation keeps previous release directories for rollback, but does not +publish their asset paths through the new `current` pointer. Treat long-lived +open-tab compatibility as a separate CDN/shared-assets enhancement if product +usage requires it; the deployment itself does not serve mixed files. + +## Failure and rollback behavior + +- Any error before the symlink rename leaves the current release untouched. +- The deploy helper immediately verifies the public release after the rename. + A failure restores the captured previous release. +- A later public or credentialed Playwright failure runs the explicit rollback + step with the previous immutable target emitted by the deploy step. +- A stale workflow run exits before activation when `master` has advanced. +- Release Manager is record-only (`auto_sync: false`); it no longer deploys the + frontend through the API/Coolify path. + +For manual rollback from a controlled runner, provide the same GitHub +environment settings plus the target recorded in the successful deployment: + +```bash +RELEASE_ROLLBACK_TARGET=releases//dist npm run release:deploy:cpanel:rollback +``` + +Never point this command outside `releases//dist`; the helper rejects +path traversal and operations outside the configured deployment root. diff --git a/package.json b/package.json index 40830c2b..959c8b2d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,11 @@ "test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live", "test:ct": "playwright test --config=playwright.ct.config.ts", "test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop", + "release:package": "node scripts/release/package-dist.mjs", + "release:deploy:cpanel": "node scripts/release/deploy-cpanel.mjs", + "release:deploy:cpanel:rollback": "node scripts/release/deploy-cpanel.mjs --rollback", + "release:cpanel-root:audit": "node scripts/release/cpanel-root.mjs audit", + "release:cpanel-root:restore": "node scripts/release/cpanel-root.mjs restore", "release:verify-upload": "node scripts/release/verify-upload.mjs", "release:update-server-version": "node scripts/release/update-server-version.mjs", "release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh", diff --git a/playwright.prod.config.ts b/playwright.prod.config.ts index 238a9683..c7789655 100644 --- a/playwright.prod.config.ts +++ b/playwright.prod.config.ts @@ -73,7 +73,7 @@ export default defineConfig({ video: "retain-on-failure", }, webServer: { - command: "npm run preview:prod", + command: "npm run preview -- --host 127.0.0.1 --port 4173", url: baseURL, timeout: 240_000, reuseExistingServer: !isCI, diff --git a/public/.htaccess b/public/.htaccess index 8db24b7b..f750882c 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,3 +1,5 @@ +DirectoryIndex index.html + Options -MultiViews @@ -6,6 +8,21 @@ AddType application/manifest+json .webmanifest + + # Fingerprinted build assets are content-addressed and safe to retain across + # atomic release switches. Mutable application shells and PWA control files + # below override this policy and must always be revalidated. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + + Header set Cache-Control "no-cache, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + + RewriteEngine On diff --git a/scripts/release/cpanel-deploy-lib.mjs b/scripts/release/cpanel-deploy-lib.mjs new file mode 100644 index 00000000..248b778f --- /dev/null +++ b/scripts/release/cpanel-deploy-lib.mjs @@ -0,0 +1,1036 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { collectDistInventory } from "./package-dist.mjs"; + +const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SAFE_HOST = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(?::[0-9]{1,5})?$/; +const SHA256 = /^[a-f0-9]{64}$/i; +const REQUIRED_ENV = [ + "PRODUCTION_FTP_HOST", + "PRODUCTION_FTP_USER", + "PRODUCTION_FTP_PASSWORD", + "PRODUCTION_FTP_PATH", + "PRODUCTION_CPANEL_USER", + "PRODUCTION_CPANEL_API_TOKEN", + "PRODUCTION_CPANEL_API_URL", + "PRODUCTION_CPANEL_PATH", + "RELEASE_ARCHIVE_PATH", + "RELEASE_INVENTORY_PATH", + "RELEASE_EXPECTED_COMMIT", + "RELEASE_EXPECTED_BUILD_ID", +]; + +export class DeploymentError extends Error { + constructor(message, options) { + super(message, options); + this.name = "DeploymentError"; + } +} + +function hasControlCharacters(value) { + return Array.from(String(value)).some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} + +function requireString(env, name) { + const value = env[name]; + if (typeof value !== "string" || value.length === 0) { + throw new DeploymentError(`Missing required environment variable ${name}.`); + } + if (hasControlCharacters(value)) { + throw new DeploymentError(`${name} contains unsupported control characters.`); + } + return value; +} + +export function validateReleaseId(value, label = "release ID") { + if (typeof value !== "string" || value.length > 180 || !SAFE_COMPONENT.test(value)) { + throw new DeploymentError(`${label} must be a safe filename component.`); + } + return value; +} + +export function normalizeRemoteRoot(value, name = "remote path") { + if (typeof value !== "string" || !value || value === "/") { + throw new DeploymentError(`${name} must identify a dedicated deployment directory.`); + } + if (/[\\,]/.test(value) || hasControlCharacters(value)) { + throw new DeploymentError(`${name} contains unsupported characters.`); + } + + const components = value.split("/").filter(Boolean); + if (components.length === 0 || components.some((component) => !SAFE_COMPONENT.test(component))) { + throw new DeploymentError(`${name} must contain only safe path components.`); + } + return components.join("/"); +} + +function normalizeFtpRoot(value) { + if (value === "/") { + return "/"; + } + const normalized = normalizeRemoteRoot(value, "PRODUCTION_FTP_PATH"); + return value.startsWith("/") ? `/${normalized}` : normalized; +} + +export function deriveCpanelRoot(cpanelPath, cpanelUser) { + const normalized = normalizeRemoteRoot(cpanelPath, "PRODUCTION_CPANEL_PATH"); + const homePrefix = `home/${cpanelUser}/`; + if (normalized.startsWith(homePrefix)) { + const relative = normalized.slice(homePrefix.length); + return normalizeRemoteRoot(relative); + } + if (normalized === `home/${cpanelUser}`) { + throw new DeploymentError("PRODUCTION_CPANEL_PATH may not be the cPanel account home directory."); + } + if (normalized.startsWith("home/")) { + throw new DeploymentError("PRODUCTION_CPANEL_PATH is not inside the configured cPanel account home."); + } + return normalized; +} + +export function containedRemotePath(root, ...components) { + const normalizedRoot = normalizeRemoteRoot(root); + const normalizedComponents = components.flatMap((component) => String(component).split("/")); + for (const component of normalizedComponents) { + validateReleaseId(component, "remote path component"); + } + const result = [normalizedRoot, ...normalizedComponents].join("/"); + if (result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) { + throw new DeploymentError("Remote path escaped the deployment root."); + } + return result; +} + +function validateHttpsUrl(value, name) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new DeploymentError(`${name} must be a valid HTTPS URL.`); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`); + } + return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`; +} + +function parseRetentionCount(value) { + if (value === undefined || value === "") { + return 5; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 2 || parsed > 25 || String(parsed) !== value) { + throw new DeploymentError("RELEASE_RETAIN_COUNT must be an integer between 2 and 25."); + } + return parsed; +} + +export function readDeploymentConfig(env = process.env) { + for (const name of REQUIRED_ENV) { + requireString(env, name); + } + const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH; + if (!checksumPath) { + throw new DeploymentError("Missing required environment variable RELEASE_ARCHIVE_SHA256_PATH."); + } + if (hasControlCharacters(checksumPath)) { + throw new DeploymentError("RELEASE_ARCHIVE_SHA256_PATH contains unsupported control characters."); + } + + 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."); + } + const expectedCommit = requireString(env, "RELEASE_EXPECTED_COMMIT"); + if (!/^[a-f0-9]{40}$/i.test(expectedCommit)) { + throw new DeploymentError("RELEASE_EXPECTED_COMMIT must be a full Git commit hash."); + } + const expectedBuildId = requireString(env, "RELEASE_EXPECTED_BUILD_ID"); + validateReleaseId(expectedBuildId, "RELEASE_EXPECTED_BUILD_ID"); + const releaseId = validateReleaseId( + env.RELEASE_ID || `${expectedCommit.toLowerCase()}-${expectedBuildId}`, + "RELEASE_ID" + ); + + const frontendUrl = env.PRODUCTION_FRONTEND_URL || env.RELEASE_BASE_URL || env.PLAYWRIGHT_BASE_URL; + if (!frontendUrl) { + throw new DeploymentError("Set PRODUCTION_FRONTEND_URL, RELEASE_BASE_URL, or PLAYWRIGHT_BASE_URL."); + } + + const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER"); + if (!SAFE_COMPONENT.test(cpanelUser)) { + throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters."); + } + const ftpPath = requireString(env, "PRODUCTION_FTP_PATH"); + const cpanelPath = requireString(env, "PRODUCTION_CPANEL_PATH"); + const githubRepository = env.RELEASE_GITHUB_REPOSITORY || ""; + const githubToken = env.RELEASE_GITHUB_TOKEN || ""; + let github = null; + if (githubRepository || githubToken) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(githubRepository)) { + throw new DeploymentError("RELEASE_GITHUB_REPOSITORY must use the owner/repository format."); + } + if (!githubToken || hasControlCharacters(githubToken)) { + throw new DeploymentError("RELEASE_GITHUB_TOKEN is missing or contains unsupported characters."); + } + github = { + repository: githubRepository, + token: githubToken, + apiUrl: validateHttpsUrl(env.GITHUB_API_URL || "https://api.github.com/", "GITHUB_API_URL"), + }; + } + + return { + ftp: { + host, + user: requireString(env, "PRODUCTION_FTP_USER"), + password: requireString(env, "PRODUCTION_FTP_PASSWORD"), + root: normalizeFtpRoot(ftpPath), + }, + cpanel: { + user: cpanelUser, + token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"), + apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"), + root: deriveCpanelRoot(cpanelPath, cpanelUser), + }, + archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")), + checksumPath: path.resolve(checksumPath), + inventoryPath: path.resolve(requireString(env, "RELEASE_INVENTORY_PATH")), + expectedCommit: expectedCommit.toLowerCase(), + expectedBuildId, + releaseId, + frontendUrl: validateHttpsUrl(frontendUrl, "PRODUCTION_FRONTEND_URL"), + retainCount: parseRetentionCount(env.RELEASE_RETAIN_COUNT), + github, + }; +} + +function lftpQuote(value) { + return `'${String(value).replaceAll("'", `'\\''`)}'`; +} + +export function buildLftpScript(config, commands) { + const { host, user, password, root } = config.ftp; + const lines = [ + "set cmd:fail-exit yes", + "set cmd:interactive no", + "set ftp:ssl-allow yes", + "set ftp:ssl-force yes", + "set ftp:ssl-protect-data yes", + "set ftp:list-options -a", + "set ssl:verify-certificate yes", + "set ssl:check-hostname yes", + "set net:max-retries 3", + "set net:timeout 20", + `open -u ${lftpQuote(user)},${lftpQuote(password)} ${lftpQuote(`ftp://${host}`)}`, + `cd ${lftpQuote(root)}`, + ...commands, + "bye", + ]; + return `${lines.join("\n")}\n`; +} + +export async function defaultProcessRunner(command, args, options = {}) { + return await new Promise((resolve, reject) => { + const inherit = options.inherit === true; + const child = spawn(command, args, { + env: options.env || process.env, + stdio: inherit ? "inherit" : ["pipe", "pipe", "pipe"], + }); + const output = []; + const errors = []; + if (!inherit) { + child.stdout.on("data", (chunk) => output.push(chunk)); + child.stderr.on("data", (chunk) => errors.push(chunk)); + child.stdin.end(options.input || ""); + } + child.on("error", (error) => { + reject(new DeploymentError(`${options.label || command} could not start.`, { cause: error })); + }); + child.on("close", (code) => { + if (code !== 0) { + reject(new DeploymentError(`${options.label || command} failed with exit code ${code}.`)); + return; + } + resolve({ + stdout: Buffer.concat(output).toString("utf8"), + stderr: Buffer.concat(errors).toString("utf8"), + }); + }); + }); +} + +async function fileSha256(filePath, fsApi = fs) { + return await new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fsApi.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +function safeArchiveName(filePath) { + const name = path.basename(filePath); + if (!SAFE_COMPONENT.test(name) || !name.endsWith(".zip")) { + throw new DeploymentError("RELEASE_ARCHIVE_PATH must name a safe .zip file."); + } + return name; +} + +async function readExpectedChecksum(config, fsApi = fsPromises) { + let content; + try { + content = await fsApi.readFile(config.checksumPath, "utf8"); + } catch (error) { + throw new DeploymentError("Could not read RELEASE_ARCHIVE_SHA256_PATH.", { cause: error }); + } + const match = content.trim().match(/^([a-f0-9]{64})(?:\s+\*?([^\s]+))?$/i); + if (!match) { + throw new DeploymentError("Release checksum sidecar is not valid SHA-256 output."); + } + if (match[2] && path.basename(match[2]) !== safeArchiveName(config.archivePath)) { + throw new DeploymentError("Release checksum sidecar names a different archive."); + } + return match[1].toLowerCase(); +} + +function validateInventoryPath(relativePath) { + if ( + typeof relativePath !== "string" || + !relativePath.startsWith("dist/") || + relativePath.includes("\\") || + hasControlCharacters(relativePath) || + relativePath.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new DeploymentError("Release inventory contains an unsafe path."); + } +} + +async function readExpectedInventory(config, fsApi = fsPromises) { + let inventory; + try { + inventory = JSON.parse(await fsApi.readFile(config.inventoryPath, "utf8")); + } catch (error) { + throw new DeploymentError("Could not read a valid RELEASE_INVENTORY_PATH.", { cause: error }); + } + if (inventory?.schema_version !== 1 || !Array.isArray(inventory.files) || inventory.files.length === 0) { + throw new DeploymentError("Release inventory does not use the supported schema."); + } + let previousPath = ""; + for (const file of inventory.files) { + validateInventoryPath(file?.path); + if ( + file.path <= previousPath || + !Number.isSafeInteger(file.bytes) || + file.bytes < 0 || + typeof file.sha256 !== "string" || + !SHA256.test(file.sha256) + ) { + throw new DeploymentError("Release inventory contains invalid or unsorted file metadata."); + } + previousPath = file.path; + } + return inventory; +} + +export function createLftpTransport(config, dependencies = {}) { + const runner = dependencies.runner || defaultProcessRunner; + const fsApi = dependencies.fs || fsPromises; + const streamFs = dependencies.streamFs || fs; + + async function run(commands) { + try { + await runner("lftp", ["-f", "/dev/stdin"], { + input: buildLftpScript(config, commands), + label: "FTPS operation", + }); + } catch (error) { + throw new DeploymentError("Secure FTPS operation failed.", { cause: error }); + } + } + + return { + async uploadArchive() { + const archiveName = safeArchiveName(config.archivePath); + const checksumName = `${archiveName}.sha256`; + const expectedHash = await readExpectedChecksum(config, fsApi); + let localHash; + try { + localHash = await fileSha256(config.archivePath, streamFs); + } catch (error) { + throw new DeploymentError("Could not hash the release archive.", { cause: error }); + } + if (localHash !== expectedHash) { + throw new DeploymentError("Release archive does not match its SHA-256 sidecar."); + } + + const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-ftps-")); + const downloadedArchive = path.join(temporaryDirectory, archiveName); + const downloadedChecksum = path.join(temporaryDirectory, checksumName); + try { + await run([ + `mkdir -p ${lftpQuote("archives")}`, + `rm -f ${lftpQuote(`archives/${archiveName}.part`)}`, + `rm -f ${lftpQuote(`archives/${checksumName}.part`)}`, + `put ${lftpQuote(config.archivePath)} -o ${lftpQuote(`archives/${archiveName}.part`)}`, + `put ${lftpQuote(config.checksumPath)} -o ${lftpQuote(`archives/${checksumName}.part`)}`, + `get ${lftpQuote(`archives/${archiveName}.part`)} -o ${lftpQuote(downloadedArchive)}`, + `get ${lftpQuote(`archives/${checksumName}.part`)} -o ${lftpQuote(downloadedChecksum)}`, + ]); + + const remoteHash = await fileSha256(downloadedArchive, streamFs); + const remoteChecksum = await fsApi.readFile(downloadedChecksum, "utf8"); + if (remoteHash !== expectedHash || remoteChecksum !== (await fsApi.readFile(config.checksumPath, "utf8"))) { + throw new DeploymentError("Remote release archive verification failed."); + } + + await run([ + `mv ${lftpQuote(`archives/${checksumName}.part`)} ${lftpQuote(`archives/${checksumName}`)}`, + `mv ${lftpQuote(`archives/${archiveName}.part`)} ${lftpQuote(`archives/${archiveName}`)}`, + ]); + return { + archiveName, + checksumName, + sha256: expectedHash, + }; + } catch (error) { + try { + await run([ + `rm -f ${lftpQuote(`archives/${archiveName}.part`)}`, + `rm -f ${lftpQuote(`archives/${checksumName}.part`)}`, + ]); + } catch { + // The original error is more actionable; stale .part files are safe and overwritten on retry. + } + throw error; + } finally { + await fsApi.rm(temporaryDirectory, { recursive: true, force: true }); + } + }, + + async verifyRelease(target) { + const safeTarget = validateReleaseTarget(target); + const expectedInventory = await readExpectedInventory(config, fsApi); + const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-release-verify-")); + const distDirectory = path.join(temporaryDirectory, "dist"); + try { + await run([`mirror --verbose=0 --parallel=4 ${lftpQuote(safeTarget)} ${lftpQuote(distDirectory)}`]); + let actualInventory; + try { + actualInventory = await collectDistInventory(distDirectory); + } catch (error) { + throw new DeploymentError("Could not inventory the extracted cPanel release.", { + cause: error, + }); + } + if (JSON.stringify(actualInventory) !== JSON.stringify(expectedInventory)) { + throw new DeploymentError("Extracted cPanel release does not match the validated inventory."); + } + return actualInventory; + } finally { + await fsApi.rm(temporaryDirectory, { recursive: true, force: true }); + } + }, + + async removeRelease(releaseId) { + const safeReleaseId = validateReleaseId(releaseId, "release retention ID"); + await run([`rm -r -f ${lftpQuote(`releases/${safeReleaseId}`)}`]); + }, + + async removeArchives(releaseIds) { + const safeReleaseIds = Array.from( + new Set(releaseIds.map((releaseId) => validateReleaseId(releaseId, "archive retention ID"))) + ); + if (safeReleaseIds.length === 0) { + return; + } + await run( + safeReleaseIds.flatMap((releaseId) => [ + `rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip`)}`, + `rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip.sha256`)}`, + ]) + ); + }, + }; +} + +function responseMessage(payload) { + const result = payload?.cpanelresult; + return ( + result?.error || + result?.event?.reason || + result?.data?.find?.((item) => item?.err || item?.reason)?.err || + result?.data?.find?.((item) => item?.err || item?.reason)?.reason || + "unknown server error" + ); +} + +export class CpanelFilemanClient { + constructor(config, options = {}) { + this.config = config; + this.root = normalizeRemoteRoot(config.cpanel.root); + this.fetch = options.fetchImpl || globalThis.fetch; + this.timeoutMs = options.timeoutMs || 30_000; + } + + redact(value) { + let result = String(value || ""); + const secrets = [ + this.config.ftp.host, + this.config.ftp.user, + this.config.ftp.password, + this.config.ftp.root, + this.config.cpanel.root, + this.config.cpanel.user, + this.config.cpanel.token, + ].filter(Boolean); + for (const secret of secrets) { + result = result.replaceAll(secret, "[redacted]"); + } + return result; + } + + assertContained(remotePath) { + const normalized = String(remotePath).replace(/^\/+/, ""); + if (normalized !== this.root && !normalized.startsWith(`${this.root}/`)) { + throw new DeploymentError("cPanel operation escaped the deployment root."); + } + if ( + /[\\,]/.test(normalized) || + hasControlCharacters(normalized) || + normalized.split("/").some((part) => !SAFE_COMPONENT.test(part)) + ) { + throw new DeploymentError("cPanel operation used an unsafe path."); + } + return normalized; + } + + async call(functionName, parameters) { + const endpoint = new URL("json-api/cpanel", this.config.cpanel.apiUrl); + endpoint.searchParams.set("cpanel_jsonapi_user", this.config.cpanel.user); + endpoint.searchParams.set("cpanel_jsonapi_apiversion", "2"); + endpoint.searchParams.set("cpanel_jsonapi_module", "Fileman"); + endpoint.searchParams.set("cpanel_jsonapi_func", functionName); + for (const [name, value] of Object.entries(parameters)) { + endpoint.searchParams.set(name, String(value)); + } + + let response; + try { + response = await this.fetch(endpoint, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `cpanel ${this.config.cpanel.user}:${this.config.cpanel.token}`, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + throw new DeploymentError(`cPanel Fileman ${functionName} request failed.`, { cause: error }); + } + if (!response.ok) { + throw new DeploymentError(`cPanel Fileman ${functionName} returned HTTP ${response.status}.`); + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + throw new DeploymentError(`cPanel Fileman ${functionName} returned invalid JSON.`, { cause: error }); + } + const result = payload?.cpanelresult; + const failedItem = result?.data?.find?.((item) => item?.result === 0 || item?.result === false); + if (result?.event?.result !== 1 || failedItem) { + throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseMessage(payload))}`); + } + if (Array.isArray(result?.data)) { + return result.data; + } + return [...(result?.files || []), ...(result?.dirs || [])]; + } + + async fileOp(operation, source, destination) { + const parameters = { + op: operation, + sourcefiles: this.assertContained(source), + doubledecode: 0, + }; + if (destination !== undefined) { + parameters.destfiles = this.assertContained(destination); + } + return await this.call("fileop", parameters); + } + + async list(directory) { + return await this.call("listfiles", { + dir: this.assertContained(directory), + filelist: 0, + needmime: 0, + showdotfiles: 1, + types: "dir|file|link", + }); + } + + async mkdir(directory) { + const safePath = this.assertContained(directory); + return await this.call("mkdir", { + path: path.posix.dirname(safePath), + name: path.posix.basename(safePath), + permissions: "0755", + }); + } + + async copy(source, destination) { + return await this.fileOp("copy", source, destination); + } + + async extract(source, destination) { + return await this.fileOp("extract", source, destination); + } + + async link(source, destination) { + return await this.fileOp("link", source, destination); + } + + async rename(source, destination) { + return await this.fileOp("rename", source, destination); + } + + async unlink(source) { + return await this.fileOp("unlink", source); + } + + async remove(source) { + return await this.fileOp("trash", source); + } +} + +function entryName(entry) { + return String(entry?.file || entry?.name || entry?.basename || ""); +} + +function findEntry(entries, name) { + return entries.find((entry) => entryName(entry) === name); +} + +export function targetRelativeToRoot(root, target) { + const normalizedRoot = normalizeRemoteRoot(root); + const normalizedTarget = String(target || "") + .replaceAll("\\", "/") + .replace(/\/+$/, ""); + const withoutLeading = normalizedTarget.replace(/^\/+/, ""); + if (withoutLeading.startsWith(`${normalizedRoot}/`)) { + return withoutLeading.slice(normalizedRoot.length + 1); + } + const marker = `/${normalizedRoot}/`; + const index = normalizedTarget.lastIndexOf(marker); + return index >= 0 ? normalizedTarget.slice(index + marker.length) : ""; +} + +export function validateReleaseTarget(value) { + const parts = String(value || "").split("/"); + if (parts.length !== 3 || parts[0] !== "releases" || parts[2] !== "dist") { + throw new DeploymentError("Release target was not a contained releases//dist path."); + } + validateReleaseId(parts[1], "release target ID"); + return parts.join("/"); +} + +export async function assertReleaseTargetExists(client, config, target) { + const safeTarget = validateReleaseTarget(target); + const releaseId = safeTarget.split("/")[1]; + const releasesRoot = containedRemotePath(config.cpanel.root, "releases"); + if (!findEntry(await client.list(releasesRoot), releaseId)) { + throw new DeploymentError("Captured rollback release directory does not exist on cPanel."); + } + const releaseRoot = containedRemotePath(releasesRoot, releaseId); + if (!findEntry(await client.list(releaseRoot), "dist")) { + throw new DeploymentError("Captured rollback release does not contain a dist directory."); + } +} + +async function ensureDirectory(client, root, relativeDirectory) { + const components = relativeDirectory.split("/"); + let parent = root; + for (const component of components) { + const entries = await client.list(parent); + if (!findEntry(entries, component)) { + await client.mkdir(containedRemotePath(parent, component)); + } + parent = containedRemotePath(parent, component); + } +} + +async function assertAbsent(client, parent, name, description) { + if (findEntry(await client.list(parent), name)) { + throw new DeploymentError(`${description} already exists; refusing to overwrite immutable data.`); + } +} + +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."); + } +} + +async function confirmLink(client, config, name) { + const entry = findEntry(await client.list(config.cpanel.root), name); + if (!entry) { + throw new DeploymentError(`cPanel did not create ${name}.`); + } + if (entry.type !== "link") { + throw new DeploymentError(`cPanel ${name} 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); + let response; + try { + response = await fetchImpl(manifestUrl, { + headers: { "Cache-Control": "no-cache", Pragma: "no-cache" }, + signal: AbortSignal.timeout(options.timeoutMs || 30_000), + }); + } catch (error) { + throw new DeploymentError("Could not capture the currently published release identity.", { + cause: error, + }); + } + if (!response.ok) { + throw new DeploymentError(`Current release-manifest.json returned HTTP ${response.status}; refusing deployment.`); + } + let manifest; + try { + manifest = await response.json(); + } catch (error) { + throw new DeploymentError("Current release-manifest.json was not valid JSON.", { cause: error }); + } + const commit = String(manifest?.commit_sha || "").toLowerCase(); + const buildId = String(manifest?.build_id || ""); + if (!/^[a-f0-9]{40}$/.test(commit)) { + throw new DeploymentError("Current release-manifest.json did not contain a full commit SHA."); + } + validateReleaseId(buildId, "current release build ID"); + return validateReleaseTarget(`releases/${commit}-${buildId}/dist`); +} + +export async function assertExpectedCommitCurrent(config, options = {}) { + if (!config.github) { + throw new DeploymentError("RELEASE_GITHUB_REPOSITORY and RELEASE_GITHUB_TOKEN are required for deployment."); + } + const fetchImpl = options.fetchImpl || globalThis.fetch; + const [owner, repository] = config.github.repository.split("/"); + const endpoint = new URL( + `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/master`, + config.github.apiUrl + ); + let response; + try { + response = await fetchImpl(endpoint, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${config.github.token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(options.timeoutMs || 30_000), + }); + } catch (error) { + throw new DeploymentError("Could not confirm the current master commit with GitHub.", { + cause: error, + }); + } + if (!response.ok) { + throw new DeploymentError(`GitHub branch check returned HTTP ${response.status}; refusing deployment.`); + } + let payload; + try { + payload = await response.json(); + } catch (error) { + throw new DeploymentError("GitHub branch check returned invalid JSON.", { cause: error }); + } + const currentCommit = String(payload?.commit?.sha || "").toLowerCase(); + if (currentCommit !== config.expectedCommit) { + throw new DeploymentError( + `Release commit is stale; master is ${currentCommit || "unknown"}, expected ${config.expectedCommit}.` + ); + } +} + +export async function atomicSwitch(client, config, target) { + const safeTarget = validateReleaseTarget(target); + const root = config.cpanel.root; + const nextPath = containedRemotePath(root, "current.next"); + const currentPath = containedRemotePath(root, "current"); + const targetPath = containedRemotePath(root, safeTarget); + const entries = await client.list(root); + if (findEntry(entries, "current.next")) { + await client.unlink(nextPath); + } + await client.link(targetPath, nextPath); + await confirmLink(client, config, "current.next"); + await client.rename(nextPath, currentPath); + await confirmLink(client, config, "current"); +} + +export async function preflightAtomicSwitch(client, config, options = {}) { + const root = config.cpanel.root; + await ensureDirectory(client, root, "staging"); + const probeId = options.probeId || `preflight-${crypto.randomUUID()}`; + validateReleaseId(probeId, "preflight ID"); + const probe = containedRemotePath(root, "staging", probeId); + await client.mkdir(probe); + let operationError; + try { + const first = containedRemotePath(probe, "first"); + const second = containedRemotePath(probe, "second"); + await client.mkdir(first); + await client.mkdir(second); + await client.mkdir(containedRemotePath(first, "first-marker")); + await client.mkdir(containedRemotePath(second, "second-marker")); + await client.link(first, containedRemotePath(probe, "current")); + await client.link(second, containedRemotePath(probe, "current.next")); + await client.rename(containedRemotePath(probe, "current.next"), containedRemotePath(probe, "current")); + const currentEntries = await client.list(probe); + const current = findEntry(currentEntries, "current"); + if (!current || current.type !== "link") { + throw new DeploymentError("cPanel atomic replacement preflight did not leave a current link."); + } + if (findEntry(currentEntries, "current.next")) { + throw new DeploymentError("cPanel atomic replacement preflight left current.next behind."); + } + const activeEntries = await client.list(containedRemotePath(probe, "current")); + if (!findEntry(activeEntries, "second-marker") || findEntry(activeEntries, "first-marker")) { + throw new DeploymentError("cPanel atomic replacement preflight did not activate the new link target."); + } + } catch (error) { + operationError = error; + } + + let cleanupError; + try { + await client.remove(probe); + } catch (error) { + cleanupError = error; + } + if (operationError) { + throw new DeploymentError("cPanel does not support the required atomic symlink replacement.", { + cause: operationError, + }); + } + if (cleanupError) { + throw new DeploymentError("Could not clean up the cPanel atomicity preflight directory.", { + cause: cleanupError, + }); + } +} + +async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) { + const archiveRoot = containedRemotePath(config.cpanel.root, "archives"); + await ensureDirectory(client, config.cpanel.root, "archives"); + 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 stageRelease(client, config, archiveName) { + const root = config.cpanel.root; + await ensureDirectory(client, root, "releases"); + await ensureDirectory(client, root, "staging"); + const stagingRoot = containedRemotePath(root, "staging"); + const releasesRoot = containedRemotePath(root, "releases"); + const stagingName = `${config.releaseId}.pending`; + await assertAbsent(client, stagingRoot, stagingName, "Release staging directory"); + await assertAbsent(client, releasesRoot, config.releaseId, "Release directory"); + + const stagingPath = containedRemotePath(stagingRoot, stagingName); + const releasePath = containedRemotePath(releasesRoot, config.releaseId); + await client.mkdir(stagingPath); + const sourceArchive = containedRemotePath(root, "archives", archiveName); + await client.copy(sourceArchive, stagingPath); + const stagedArchive = containedRemotePath(stagingPath, archiveName); + await client.extract(stagedArchive, stagingPath); + await client.remove(stagedArchive); + + const stagingEntries = await client.list(stagingPath); + if (!findEntry(stagingEntries, "dist")) { + throw new DeploymentError("Extracted release did not contain a top-level dist directory."); + } + const distPath = containedRemotePath(stagingPath, "dist"); + const distEntries = await client.list(distPath); + for (const requiredFile of ["index.html", "release-manifest.json", "release-entry.json"]) { + if (!findEntry(distEntries, requiredFile)) { + throw new DeploymentError(`Extracted release was missing ${requiredFile}.`); + } + } + + await client.rename(stagingPath, releasePath); + if (!findEntry(await client.list(releasesRoot), config.releaseId)) { + throw new DeploymentError("cPanel did not finalize the immutable release directory."); + } + return `releases/${config.releaseId}/dist`; +} + +export async function runPublicVerification(config, options = {}) { + const runner = options.runner || defaultProcessRunner; + const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url)); + await runner(process.execPath, [verifier], { + inherit: true, + label: "public release verification", + env: { + ...process.env, + RELEASE_BASE_URL: config.frontendUrl, + RELEASE_EXPECTED_COMMIT: config.expectedCommit, + RELEASE_EXPECTED_BUILD_ID: config.expectedBuildId, + RELEASE_STRICT_BUILD_ID: "1", + RELEASE_WAIT_INITIAL_SECONDS: process.env.RELEASE_WAIT_INITIAL_SECONDS || "0", + }, + }); +} + +function entryMtime(entry) { + const value = Number(entry?.mtime ?? entry?.modified ?? entry?.mtime_epoch); + return Number.isFinite(value) && value > 0 ? value : null; +} + +export async function pruneInactiveReleases(client, config, protectedTargets, options = {}) { + const releasesRoot = containedRemotePath(config.cpanel.root, "releases"); + const entries = await client.list(releasesRoot); + const releases = entries + .map((entry) => ({ id: entryName(entry), mtime: entryMtime(entry) })) + .filter(({ id }) => SAFE_COMPONENT.test(id)); + if (releases.some(({ mtime }) => mtime === null)) { + return []; + } + + const protectedIds = new Set( + protectedTargets.filter(Boolean).map((target) => validateReleaseTarget(target).split("/")[1]) + ); + const sorted = releases.sort((left, right) => right.mtime - left.mtime); + const keepIds = new Set([...protectedIds, ...sorted.slice(0, config.retainCount).map(({ id }) => id)]); + const removed = []; + const removeRelease = + options.removeRelease || (async (id) => await client.remove(containedRemotePath(releasesRoot, id))); + for (const { id } of sorted) { + if (!keepIds.has(id)) { + await removeRelease(id); + removed.push(id); + } + } + return removed; +} + +export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) { + const pairs = Object.entries(values).filter(([, value]) => value !== undefined && value !== ""); + if (env.GITHUB_ENV && pairs.length > 0) { + fsApi.appendFileSync(env.GITHUB_ENV, `${pairs.map(([key, value]) => `${key}=${value}`).join("\n")}\n`); + } + if (env.GITHUB_OUTPUT && pairs.length > 0) { + const outputNames = { + RELEASE_ROLLBACK_TARGET: "rollback_target", + RELEASE_ACTIVE_TARGET: "active_target", + RELEASE_DEPLOYED_RELEASE_ID: "release_id", + }; + const output = pairs + .filter(([key]) => outputNames[key]) + .map(([key, value]) => `${outputNames[key]}=${value}`) + .join("\n"); + if (output) { + fsApi.appendFileSync(env.GITHUB_OUTPUT, `${output}\n`); + } + } +} + +export async function deployRelease(config, dependencies = {}) { + const client = dependencies.client || new CpanelFilemanClient(config, dependencies); + const transport = dependencies.transport || createLftpTransport(config, dependencies); + const preflight = dependencies.preflight || preflightAtomicSwitch; + const stage = dependencies.stage || stageRelease; + const verify = dependencies.verify || runPublicVerification; + const prune = dependencies.prune || pruneInactiveReleases; + const publish = dependencies.publish || emitDeploymentOutputs; + const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget; + const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent; + + await checkCurrent(config, dependencies); + await preflight(client, config); + 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(); + const newTarget = await stage(client, config, uploaded.archiveName); + await transport.verifyRelease(newTarget); + await checkCurrent(config, dependencies); + await atomicSwitch(client, config, newTarget); + publish({ + RELEASE_ROLLBACK_TARGET: previousTarget, + RELEASE_ACTIVE_TARGET: newTarget, + RELEASE_DEPLOYED_RELEASE_ID: config.releaseId, + }); + + try { + await verify(config, dependencies); + } catch (error) { + try { + await atomicSwitch(client, config, previousTarget); + } catch (rollbackError) { + throw new DeploymentError( + "Public verification failed and automatic rollback also failed; production requires immediate attention.", + { cause: new AggregateError([error, rollbackError]) } + ); + } + throw new DeploymentError("Public verification failed; the previous release was restored.", { + cause: error, + }); + } + + 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 }; +} + +export async function rollbackRelease(config, target, dependencies = {}) { + const client = dependencies.client || new CpanelFilemanClient(config, dependencies); + const preflight = dependencies.preflight || preflightAtomicSwitch; + const publish = dependencies.publish || emitDeploymentOutputs; + const safeTarget = validateReleaseTarget(target); + await assertReleaseTargetExists(client, config, safeTarget); + await preflight(client, config); + await atomicSwitch(client, config, safeTarget); + publish({ + RELEASE_ACTIVE_TARGET: safeTarget, + RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1], + }); + return { activeTarget: safeTarget }; +} diff --git a/scripts/release/cpanel-root-lib.mjs b/scripts/release/cpanel-root-lib.mjs new file mode 100644 index 00000000..417cf91b --- /dev/null +++ b/scripts/release/cpanel-root-lib.mjs @@ -0,0 +1,473 @@ +import crypto from "node:crypto"; + +import { DeploymentError, deriveCpanelRoot, normalizeRemoteRoot } from "./cpanel-deploy-lib.mjs"; + +const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function required(env, name) { + const value = env[name]; + const hasControlCharacter = + typeof value === "string" && + Array.from(value).some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); + if (typeof value !== "string" || !value || hasControlCharacter) { + throw new DeploymentError(`Missing or invalid ${name}.`); + } + return value; +} + +function httpsUrl(value, name) { + let url; + try { + url = new URL(value); + } catch { + throw new DeploymentError(`${name} must be a valid HTTPS URL.`); + } + if (url.protocol !== "https:" || url.username || url.password) { + throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`); + } + return url.href.endsWith("/") ? url.href : `${url.href}/`; +} + +export function readRootConfig(env = process.env) { + const user = required(env, "PRODUCTION_CPANEL_USER"); + if (!SAFE_COMPONENT.test(user)) { + throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters."); + } + const webroot = normalizeRemoteRoot(env.PRODUCTION_CPANEL_WEBROOT || "public_html", "PRODUCTION_CPANEL_WEBROOT"); + if (webroot.includes("/")) { + throw new DeploymentError("PRODUCTION_CPANEL_WEBROOT must be one account-home entry."); + } + return { + user, + token: required(env, "PRODUCTION_CPANEL_API_TOKEN"), + apiUrl: httpsUrl(required(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"), + deploymentRoot: deriveCpanelRoot(required(env, "PRODUCTION_CPANEL_PATH"), user), + webroot, + frontendUrl: httpsUrl(env.PRODUCTION_FRONTEND_URL || "https://truckwash.io", "PRODUCTION_FRONTEND_URL"), + }; +} + +function entryName(entry) { + return String(entry?.file || entry?.name || entry?.basename || ""); +} + +function normalizedType(entry) { + const value = String(entry?.type || entry?.filetype || "").toLowerCase(); + if (value.includes("link") || entry?.islink) return "link"; + if (value.includes("dir") || entry?.isdir) return "dir"; + if (value.includes("file") || entry?.isfile) return "file"; + return value || "unknown"; +} + +function normalizeAccountPath(config, value) { + const normalized = String(value || "") + .replaceAll("\\", "/") + .replace(/^\/+|\/+$/g, ""); + const homePrefix = `home/${config.user}/`; + return normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : normalized; +} + +function safeAccountPath(value, label = "cPanel path") { + const normalized = String(value || "").replace(/^\/+/, ""); + if (!normalized || /[\\,]/.test(normalized) || normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))) { + throw new DeploymentError(`${label} is unsafe.`); + } + return normalized; +} + +function responseError(payload) { + return ( + payload?.cpanelresult?.error || + payload?.cpanelresult?.event?.reason || + payload?.result?.errors?.[0] || + "unknown cPanel error" + ); +} + +export class CpanelAccountClient { + constructor(config, options = {}) { + this.config = config; + this.fetch = options.fetchImpl || globalThis.fetch; + this.timeoutMs = options.timeoutMs || 30_000; + this.allowedMutable = new Set(options.allowedMutable || []); + } + + redact(value) { + let result = String(value || ""); + for (const secret of [this.config.user, this.config.token, this.config.apiUrl].filter(Boolean)) { + result = result.replaceAll(secret, "[redacted]"); + } + return result; + } + + async request(url) { + let response; + try { + response = await this.fetch(url, { + headers: { + Accept: "application/json", + Authorization: `cpanel ${this.config.user}:${this.config.token}`, + }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + throw new DeploymentError("cPanel request failed.", { cause: error }); + } + if (!response.ok) throw new DeploymentError(`cPanel returned HTTP ${response.status}.`); + try { + return await response.json(); + } catch (error) { + throw new DeploymentError("cPanel returned invalid JSON.", { cause: error }); + } + } + + async api2(functionName, parameters) { + const url = new URL("json-api/cpanel", this.config.apiUrl); + url.searchParams.set("cpanel_jsonapi_user", this.config.user); + url.searchParams.set("cpanel_jsonapi_apiversion", "2"); + url.searchParams.set("cpanel_jsonapi_module", "Fileman"); + url.searchParams.set("cpanel_jsonapi_func", functionName); + for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, String(value)); + const payload = await this.request(url); + const result = payload?.cpanelresult; + const failed = result?.data?.find?.((item) => item?.result === 0 || item?.result === false); + if (result?.event?.result !== 1 || failed) { + throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseError(payload))}`); + } + return [...(result?.data || []), ...(result?.files || []), ...(result?.dirs || [])]; + } + + async list(directory) { + const dir = directory === "." ? `/home/${this.config.user}` : safeAccountPath(directory); + return await this.api2("listfiles", { + dir, + filelist: 0, + needmime: 0, + showdotfiles: 1, + types: "dir|file|link", + }); + } + + async domains() { + const url = new URL("execute/DomainInfo/domains_data", this.config.apiUrl); + url.searchParams.set("format", "list"); + const payload = await this.request(url); + if (payload?.result?.status !== 1) { + throw new DeploymentError(`cPanel DomainInfo failed: ${responseError(payload)}`); + } + if (!Array.isArray(payload.result.data)) { + throw new DeploymentError("cPanel DomainInfo returned an unexpected data shape."); + } + return payload.result.data; + } + + assertMutable(remotePath) { + const safe = safeAccountPath(remotePath); + if (!this.allowedMutable.has(safe)) { + throw new DeploymentError(`cPanel mutation outside the explicit restore allowlist: ${safe}.`); + } + return safe; + } + + async rename(source, destination) { + return await this.api2("fileop", { + op: "rename", + sourcefiles: this.assertMutable(source), + destfiles: this.assertMutable(destination), + doubledecode: 0, + }); + } +} + +function find(entries, name) { + return entries.find((entry) => entryName(entry) === name); +} + +function publicEntry(entry) { + if (!entry) return null; + return { + name: entryName(entry), + type: normalizedType(entry), + mode: String(entry.mode || entry.permissions || ""), + modified: String(entry.mtime || entry.modified || ""), + size: String(entry.size ?? entry.filesize ?? ""), + }; +} + +function domainRoot(config, domain) { + return normalizeAccountPath(config, domain?.documentroot || domain?.document_root || domain?.docroot || ""); +} + +function recoveryPattern(webroot) { + return new RegExp(`^${webroot}[-._](?:recovery|backup|before[-._]atomic)[-._][A-Za-z0-9._-]+$`, "i"); +} + +function inspectedDomain(config, domain, index) { + const name = String(domain?.domain || domain?.servername || "").trim(); + const documentRoot = domainRoot(config, domain); + if (!name || !documentRoot) { + throw new DeploymentError(`cPanel DomainInfo returned incomplete domain data at index ${index}.`); + } + return { + domain: name, + type: String(domain?.domain_type || domain?.type || ""), + documentRoot, + }; +} + +export async function auditRoot(config, options = {}) { + const client = options.client || new CpanelAccountClient(config, options); + const [homeEntries, deploymentEntries, domains] = await Promise.all([ + client.list("."), + client.list(config.deploymentRoot), + client.domains(), + ]); + const currentEntry = find(deploymentEntries, "current"); + const currentPath = `${config.deploymentRoot}/current`; + const requiredReleaseFiles = ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"]; + const current = { + path: currentPath, + type: normalizedType(currentEntry), + accessible: false, + missingFiles: requiredReleaseFiles, + error: "", + }; + if (currentEntry && normalizedType(currentEntry) === "link") { + try { + const currentEntries = await client.list(currentPath); + current.accessible = true; + current.missingFiles = requiredReleaseFiles.filter((name) => !find(currentEntries, name)); + } catch (error) { + current.error = error instanceof Error ? error.message : "Could not follow the current link."; + } + } else { + current.error = currentEntry + ? "The deployment current entry is not a symbolic link." + : "The deployment current entry is missing."; + } + const root = publicEntry(find(homeEntries, config.webroot)); + const rootAccess = { accessible: false, error: "" }; + let rootEntries = []; + if (root) { + try { + rootEntries = await client.list(config.webroot); + rootAccess.accessible = true; + } catch (error) { + rootAccess.error = error instanceof Error ? error.message : "Could not inspect the primary webroot."; + } + } else { + rootAccess.error = "The primary webroot entry is missing."; + } + const recoveryCandidates = homeEntries + .map((entry) => publicEntry(entry)) + .filter((entry) => entry && recoveryPattern(config.webroot).test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)); + const domainRoots = domains + .map((domain, index) => inspectedDomain(config, domain, index)) + .sort((left, right) => + [left.domain, left.documentRoot, left.type] + .join("\0") + .localeCompare([right.domain, right.documentRoot, right.type].join("\0")) + ); + const nestedDomainRoots = domainRoots.filter(({ documentRoot }) => documentRoot.startsWith(`${config.webroot}/`)); + const state = { + webroot: root, + webrootAccess: rootAccess, + webrootEntries: rootEntries.map((entry) => publicEntry(entry)).filter(Boolean), + current, + recoveryCandidates, + domainRoots, + nestedDomainRoots, + }; + const stateToken = crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex"); + return { + ...state, + stateToken, + rootTargetVerified: false, + healthy: false, + }; +} + +export async function verifyFrontend(config, options = {}) { + const fetchImpl = options.fetchImpl || globalThis.fetch; + const checks = [ + ["", "html"], + ["index.html", "html"], + ["release-manifest.json", "json"], + ["guest/book/wash", "html"], + ]; + for (const [pathname, expected] of checks) { + const requestedUrl = new URL(pathname, config.frontendUrl); + const response = await fetchImpl(requestedUrl, { + headers: { "Cache-Control": "no-cache", Pragma: "no-cache" }, + redirect: "follow", + signal: AbortSignal.timeout(options.timeoutMs || 30_000), + }); + if (!response.ok) throw new DeploymentError(`Live ${pathname || "/"} returned HTTP ${response.status}.`); + if (response.url && new URL(response.url).origin !== requestedUrl.origin) { + throw new DeploymentError(`Live ${pathname || "/"} redirected outside the production frontend origin.`); + } + const body = await response.text(); + if (body.includes("Index of /")) throw new DeploymentError("The production root still exposes a directory index."); + if (expected === "json") { + let manifest; + try { + manifest = JSON.parse(body); + } catch { + throw new DeploymentError(`Live ${pathname} did not return JSON.`); + } + if (!/^[a-f0-9]{40}$/i.test(String(manifest?.commit_sha || "")) || !String(manifest?.build_id || "")) { + throw new DeploymentError(`Live ${pathname} did not identify a packaged frontend release.`); + } + } else if (!/[^<]*truck\s*wash/i.test(body)) { + throw new DeploymentError(`Live ${pathname || "/"} did not return the frontend HTML shell.`); + } + } +} + +async function homeNames(client) { + return new Set((await client.list(".")).map((entry) => entryName(entry)).filter(Boolean)); +} + +async function renameWithReconciliation(client, source, destination) { + try { + await client.rename(source, destination); + return; + } catch (error) { + let names; + try { + names = await homeNames(client); + } catch (inspectionError) { + throw new DeploymentError(`Could not reconcile the cPanel rename from ${source} to ${destination}.`, { + cause: new AggregateError([error, inspectionError]), + }); + } + const sourceExists = names.has(source); + const destinationExists = names.has(destination); + if (!sourceExists && destinationExists) return; + if (sourceExists && !destinationExists) { + throw new DeploymentError(`cPanel did not rename ${source} to ${destination}; the source remains in place.`, { + cause: error, + }); + } + throw new DeploymentError(`The cPanel rename from ${source} to ${destination} left an ambiguous account state.`, { + cause: error, + }); + } +} + +async function reinstatePreRestoreState(client, webroot, recovery, failed) { + const errors = []; + let names; + try { + names = await homeNames(client); + } catch (error) { + return [error]; + } + + const restored = () => names.has(webroot) && names.has(recovery) && !names.has(failed); + if (restored()) return errors; + + if (names.has(webroot) && !names.has(recovery) && names.has(failed)) { + try { + await renameWithReconciliation(client, webroot, recovery); + names = await homeNames(client); + } catch (error) { + errors.push(error); + return errors; + } + } + + if (!names.has(webroot) && names.has(recovery) && names.has(failed)) { + try { + await renameWithReconciliation(client, failed, webroot); + names = await homeNames(client); + } catch (error) { + errors.push(error); + return errors; + } + } + + if (!restored()) { + errors.push( + new DeploymentError( + `Automatic rollback could not prove the required entries: ${webroot}, ${recovery}, and no ${failed}.` + ) + ); + } + return errors; +} + +export async function restoreRoot(config, recovery, expectedStateToken, confirmation, options = {}) { + const safeRecovery = safeAccountPath(recovery, "recovery path"); + if (!recoveryPattern(config.webroot).test(safeRecovery)) { + throw new DeploymentError( + "Recovery path must name a retained public_html recovery, backup, or before-atomic entry." + ); + } + if (!/^[a-f0-9]{64}$/.test(expectedStateToken)) { + throw new DeploymentError("Restore requires the exact state token emitted by the audit."); + } + const expectedConfirmation = `RESTORE ${safeRecovery} TO ${config.webroot} STATE ${expectedStateToken}`; + if (confirmation !== expectedConfirmation) { + throw new DeploymentError(`Confirmation must exactly match: ${expectedConfirmation}`); + } + const runId = String( + options.runId || [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join("-") || Date.now() + ); + if (!SAFE_COMPONENT.test(runId)) throw new DeploymentError("Restore run ID is unsafe."); + const failed = `${config.webroot}.failed-${runId}`; + const allowedMutable = [config.webroot, safeRecovery, failed]; + const client = options.client || new CpanelAccountClient(config, { ...options, allowedMutable }); + if (client.allowedMutable instanceof Set) { + for (const item of allowedMutable) client.allowedMutable.add(item); + } + const before = await auditRoot(config, { ...options, client }); + if (before.stateToken !== expectedStateToken) { + throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring."); + } + if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) { + throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state."); + } + if (!before.webroot) + throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore."); + if (!new Set(["dir", "link"]).has(before.webroot.type)) { + throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore."); + } + if (!before.webrootAccess.accessible && before.webroot.type !== "link") { + throw new DeploymentError( + "The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery." + ); + } + if (before.nestedDomainRoots.length > 0) { + throw new DeploymentError( + `Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots + .map(({ domain, documentRoot }) => `${domain}=${documentRoot}`) + .join(", ")}.` + ); + } + if (find(await client.list("."), failed)) { + throw new DeploymentError(`The displaced-state path ${failed} already exists; refusing to overwrite it.`); + } + + try { + await renameWithReconciliation(client, config.webroot, failed); + await renameWithReconciliation(client, safeRecovery, config.webroot); + await (options.verify || verifyFrontend)(config, options); + } catch (error) { + const rollbackErrors = await reinstatePreRestoreState(client, config.webroot, safeRecovery, failed); + if (rollbackErrors.length > 0) { + throw new DeploymentError( + "The retained webroot failed and automatic rollback was incomplete; both retained cPanel entries were preserved for manual recovery.", + { cause: new AggregateError([error, ...rollbackErrors]) } + ); + } + throw new DeploymentError("The retained webroot failed live verification; the pre-restore state was reinstated.", { + cause: error, + }); + } + return { restored: safeRecovery, displaced: failed }; +} diff --git a/scripts/release/cpanel-root.mjs b/scripts/release/cpanel-root.mjs new file mode 100644 index 00000000..e2ebe845 --- /dev/null +++ b/scripts/release/cpanel-root.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; + +import { DeploymentError } from "./cpanel-deploy-lib.mjs"; +import { auditRoot, readRootConfig, restoreRoot } from "./cpanel-root-lib.mjs"; + +async function writeReport(report) { + const reportPath = process.env.CPANEL_ROOT_REPORT_PATH; + if (!reportPath) return; + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); +} + +async function main() { + const mode = process.argv[2] || "audit"; + if (!new Set(["audit", "restore"]).has(mode) || process.argv.length > 3) { + throw new DeploymentError("Usage: cpanel-root.mjs [audit|restore]"); + } + const config = readRootConfig(process.env); + if (mode === "audit") { + const report = await auditRoot(config); + await writeReport(report); + console.log(JSON.stringify(report, null, 2)); + if (process.env.GITHUB_OUTPUT) { + await fs.appendFile( + process.env.GITHUB_OUTPUT, + `healthy=${report.healthy}\nstate_token=${report.stateToken}\nrecovery_count=${report.recoveryCandidates.length}\n` + ); + } + return; + } + const result = await restoreRoot( + config, + process.env.CPANEL_ROOT_RECOVERY || "", + process.env.CPANEL_ROOT_STATE_TOKEN || "", + process.env.CPANEL_ROOT_CONFIRMATION || "" + ); + await writeReport(result); + console.log(`Restored ${result.restored}; retained displaced state as ${result.displaced}.`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : "Unknown cPanel root failure."); + process.exit(1); +}); diff --git a/scripts/release/deploy-cpanel.mjs b/scripts/release/deploy-cpanel.mjs new file mode 100644 index 00000000..4f0756e5 --- /dev/null +++ b/scripts/release/deploy-cpanel.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import { DeploymentError, deployRelease, readDeploymentConfig, rollbackRelease } from "./cpanel-deploy-lib.mjs"; + +async function main() { + const mode = process.argv[2] || "deploy"; + if (!new Set(["deploy", "--rollback"]).has(mode) || process.argv.length > 3) { + throw new DeploymentError("Usage: deploy-cpanel.mjs [--rollback]"); + } + + const config = readDeploymentConfig(process.env); + if (mode === "--rollback") { + const target = process.env.RELEASE_ROLLBACK_TARGET; + if (!target) { + throw new DeploymentError("RELEASE_ROLLBACK_TARGET is required for --rollback."); + } + const result = await rollbackRelease(config, target); + console.log(`Frontend rollback completed: active release ${result.activeTarget.split("/")[1]}.`); + return; + } + + const result = await deployRelease(config); + console.log( + `Frontend deployment completed: active release ${config.releaseId}; retained rollback release ${ + result.previousTarget.split("/")[1] + }.` + ); + if (result.removed.length > 0) { + console.log(`Pruned ${result.removed.length} inactive release(s).`); + } + if (result.retentionWarning) { + console.warn(result.retentionWarning); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : "Unknown deployment failure."; + console.error(message); + process.exit(1); +}); diff --git a/scripts/release/package-dist.mjs b/scripts/release/package-dist.mjs new file mode 100644 index 00000000..72be5453 --- /dev/null +++ b/scripts/release/package-dist.mjs @@ -0,0 +1,400 @@ +import crypto from "node:crypto"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; +const SERVER_EXECUTABLE_EXTENSION_PATTERN = + /(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i; + +function comparePaths(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sha256(contents) { + return crypto.createHash("sha256").update(contents).digest("hex"); +} + +function assertSafeRelativePath(relativePath, label = "release path") { + if (typeof relativePath !== "string" || relativePath.length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + if (relativePath.includes("\\") || /[\0\r\n]/.test(relativePath)) { + throw new Error(`${label} contains unsafe characters: ${JSON.stringify(relativePath)}`); + } + if (path.posix.isAbsolute(relativePath)) { + throw new Error(`${label} must be relative: ${relativePath}`); + } + + const segments = relativePath.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error(`${label} contains an unsafe path segment: ${relativePath}`); + } +} + +function assertSafeFileName(relativePath) { + assertSafeRelativePath(relativePath, "dist file path"); + if (relativePath !== ".htaccess" && SERVER_EXECUTABLE_EXTENSION_PATTERN.test(path.posix.basename(relativePath))) { + throw new Error(`dist contains a server-executable file: ${relativePath}`); + } +} + +function manifestFilePath(distDirectory, assetUrl, label) { + if (typeof assetUrl !== "string" || assetUrl.length === 0) { + throw new Error(`${label} must be a non-empty string.`); + } + if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl) || assetUrl.includes("?") || assetUrl.includes("#")) { + throw new Error(`${label} must reference a local release file: ${assetUrl}`); + } + + let decodedPath; + try { + decodedPath = decodeURIComponent(assetUrl.replace(/^\/+/, "")); + } catch { + throw new Error(`${label} is not valid URL-encoded text: ${assetUrl}`); + } + assertSafeFileName(decodedPath); + + const absolutePath = path.resolve(distDirectory, ...decodedPath.split("/")); + const root = path.resolve(distDirectory); + if (!absolutePath.startsWith(`${root}${path.sep}`)) { + throw new Error(`${label} escapes dist: ${assetUrl}`); + } + return { absolutePath, relativePath: decodedPath }; +} + +async function readJson(filePath, label) { + let contents; + try { + contents = await fsp.readFile(filePath, "utf8"); + } catch (error) { + throw new Error(`${label} could not be read: ${error instanceof Error ? error.message : error}`, { cause: error }); + } + + try { + return JSON.parse(contents); + } catch (error) { + throw new Error(`${label} does not contain valid JSON: ${error instanceof Error ? error.message : error}`, { + cause: error, + }); + } +} + +function assertStringArray(value, label) { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) { + throw new Error(`${label} must be an array of non-empty strings.`); + } + return value; +} + +export async function collectDistInventory(distDirectory) { + const root = path.resolve(distDirectory); + const rootStat = await fsp.lstat(root).catch(() => null); + if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`dist directory does not exist or is not a real directory: ${root}`); + } + + const files = []; + const walk = async (relativeDirectory = "") => { + const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root; + const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths); + + for (const name of names) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name; + assertSafeRelativePath(relativePath, "dist path"); + const absolutePath = path.join(root, ...relativePath.split("/")); + const stat = await fsp.lstat(absolutePath); + + if (stat.isSymbolicLink()) { + throw new Error(`dist contains a symbolic link: ${relativePath}`); + } + if (stat.isDirectory()) { + await walk(relativePath); + continue; + } + if (!stat.isFile()) { + throw new Error(`dist contains an unsupported filesystem entry: ${relativePath}`); + } + + assertSafeFileName(relativePath); + const contents = await fsp.readFile(absolutePath); + files.push({ + path: `dist/${relativePath}`, + bytes: contents.length, + sha256: sha256(contents), + }); + } + }; + + await walk(); + if (files.length === 0) { + throw new Error("dist does not contain any files."); + } + + files.sort((left, right) => comparePaths(left.path, right.path)); + + return { + schema_version: 1, + files, + }; +} + +export async function validateReleaseMetadata(distDirectory, { expectedCommitSha, expectedBuildId }) { + if (typeof expectedCommitSha !== "string" || !COMMIT_SHA_PATTERN.test(expectedCommitSha)) { + throw new Error("RELEASE_COMMIT_SHA must be the full 40-character hexadecimal commit SHA."); + } + if (typeof expectedBuildId !== "string" || expectedBuildId.length === 0) { + throw new Error("RELEASE_BUILD_ID is required."); + } + + const root = path.resolve(distDirectory); + const manifest = await readJson(path.join(root, "release-manifest.json"), "release-manifest.json"); + const releaseEntry = await readJson(path.join(root, "release-entry.json"), "release-entry.json"); + + if (manifest.schema_version !== 1) { + throw new Error(`release-manifest.json schema_version must be 1, got ${JSON.stringify(manifest.schema_version)}.`); + } + if (String(manifest.commit_sha || "").toLowerCase() !== expectedCommitSha.toLowerCase()) { + throw new Error( + `release-manifest.json commit_sha ${ + manifest.commit_sha || "(missing)" + } does not exactly match ${expectedCommitSha}.` + ); + } + if (manifest.build_id !== expectedBuildId) { + throw new Error( + `release-manifest.json build_id ${manifest.build_id || "(missing)"} does not exactly match ${expectedBuildId}.` + ); + } + if (typeof releaseEntry.entry !== "string" || releaseEntry.entry.length === 0) { + throw new Error("release-entry.json entry must be a non-empty string."); + } + + const entryCss = assertStringArray(releaseEntry.css || [], "release-entry.json css"); + const manifestCss = assertStringArray(manifest.css || [], "release-manifest.json css"); + if (releaseEntry.entry !== manifest.entry || JSON.stringify(entryCss) !== JSON.stringify(manifestCss)) { + throw new Error("release-entry.json entry/css does not match release-manifest.json."); + } + + const assetUrls = assertStringArray(manifest.asset_urls, "release-manifest.json asset_urls"); + if (!manifest.asset_hashes || typeof manifest.asset_hashes !== "object" || Array.isArray(manifest.asset_hashes)) { + throw new Error("release-manifest.json asset_hashes must be an object."); + } + + const requiredAssets = ["index.html", "release-entry.json", releaseEntry.entry, ...entryCss]; + for (const requiredAsset of requiredAssets) { + if (!assetUrls.includes(requiredAsset)) { + throw new Error(`release-manifest.json asset_urls is missing required asset ${requiredAsset}.`); + } + } + for (const assetUrl of assetUrls) { + if (!Object.hasOwn(manifest.asset_hashes, assetUrl)) { + throw new Error(`release-manifest.json asset_hashes is missing ${assetUrl}.`); + } + } + + const hashEntries = Object.entries(manifest.asset_hashes).sort(([left], [right]) => comparePaths(left, right)); + if (hashEntries.length === 0) { + throw new Error("release-manifest.json asset_hashes must not be empty."); + } + + for (const [assetUrl, expected] of hashEntries) { + const { absolutePath } = manifestFilePath(root, assetUrl, `release asset ${assetUrl}`); + const stat = await fsp.lstat(absolutePath).catch(() => null); + if (!stat?.isFile() || stat.isSymbolicLink()) { + throw new Error(`release asset is missing or is not a regular file: ${assetUrl}`); + } + if ( + !expected || + typeof expected !== "object" || + typeof expected.sha256 !== "string" || + !SHA256_PATTERN.test(expected.sha256) || + !Number.isSafeInteger(expected.bytes) || + expected.bytes < 0 + ) { + throw new Error(`release-manifest.json contains invalid hash metadata for ${assetUrl}.`); + } + + const contents = await fsp.readFile(absolutePath); + const actualHash = sha256(contents); + if (contents.length !== expected.bytes || actualHash !== expected.sha256.toLowerCase()) { + throw new Error( + `release asset integrity mismatch for ${assetUrl}: expected ${expected.bytes} bytes/${expected.sha256}, got ${contents.length} bytes/${actualHash}.` + ); + } + } + + return { manifest, releaseEntry }; +} + +function assertMatchingInventories(expected, actual, label) { + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + throw new Error(`${label} does not match the validated dist inventory.`); + } +} + +async function validateArchiveEntries(archivePath) { + const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 }); + const entries = stdout.split(/\r?\n/).filter(Boolean); + if (entries.length === 0) { + throw new Error("release archive is empty."); + } + + for (const archiveEntry of entries) { + const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry; + if (normalizedEntry === "dist") { + continue; + } + if (!normalizedEntry.startsWith("dist/")) { + throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`); + } + assertSafeRelativePath(normalizedEntry, "archive entry"); + } +} + +function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) { + if (!githubOutput) { + return; + } + const lines = Object.entries(values).map(([key, value]) => { + const normalizedValue = String(value); + if (/\r|\n/.test(normalizedValue)) { + throw new Error(`GitHub output ${key} contains a newline.`); + } + return `${key}=${normalizedValue}`; + }); + fs.appendFileSync(githubOutput, `${lines.join("\n")}\n`); +} + +export async function createReleaseArchive({ + distDirectory = "dist", + outputDirectory = "release-artifacts", + expectedCommitSha, + expectedBuildId, + runId, + runAttempt, + githubOutput, +} = {}) { + if (!/^\d+$/.test(String(runId || "")) || !/^\d+$/.test(String(runAttempt || ""))) { + throw new Error("GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be numeric."); + } + + const normalizedCommitSha = String(expectedCommitSha || "").toLowerCase(); + const expectedRunBuildId = `${runId}-${runAttempt}`; + if (expectedBuildId !== expectedRunBuildId) { + throw new Error(`RELEASE_BUILD_ID must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT (${expectedRunBuildId}).`); + } + + const resolvedDistDirectory = path.resolve(distDirectory); + const resolvedOutputDirectory = path.resolve(outputDirectory); + if ( + resolvedOutputDirectory === resolvedDistDirectory || + resolvedOutputDirectory.startsWith(`${resolvedDistDirectory}${path.sep}`) + ) { + throw new Error("RELEASE_OUTPUT_DIR must not be inside dist."); + } + + const inventory = await collectDistInventory(resolvedDistDirectory); + for (const requiredPath of [ + "dist/.htaccess", + "dist/index.html", + "dist/release-entry.json", + "dist/release-manifest.json", + ]) { + if (!inventory.files.some((file) => file.path === requiredPath)) { + throw new Error(`dist is missing required release file: ${requiredPath.slice("dist/".length)}`); + } + } + await validateReleaseMetadata(resolvedDistDirectory, { expectedCommitSha: normalizedCommitSha, expectedBuildId }); + + const releaseId = `${normalizedCommitSha}-${runId}-${runAttempt}`; + const archiveName = `pleno-vue-${releaseId}.zip`; + const archivePath = path.join(resolvedOutputDirectory, archiveName); + const checksumPath = `${archivePath}.sha256`; + const inventoryPath = path.join(resolvedOutputDirectory, `pleno-vue-${releaseId}.inventory.json`); + + await fsp.mkdir(resolvedOutputDirectory, { recursive: true }); + for (const outputPath of [archivePath, checksumPath, inventoryPath]) { + if (await fsp.lstat(outputPath).catch(() => null)) { + throw new Error(`refusing to overwrite existing release artifact: ${outputPath}`); + } + } + + const temporaryDirectory = await fsp.mkdtemp(path.join(path.dirname(resolvedOutputDirectory), ".pleno-release-")); + try { + const sourceRoot = path.join(temporaryDirectory, "source"); + const copiedDistDirectory = path.join(sourceRoot, "dist"); + const temporaryArchivePath = path.join(temporaryDirectory, archiveName); + const extractionRoot = path.join(temporaryDirectory, "extracted"); + await fsp.mkdir(sourceRoot, { recursive: true }); + await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true }); + + const copiedInventory = await collectDistInventory(copiedDistDirectory); + assertMatchingInventories(inventory, copiedInventory, "archive source"); + + await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], { + cwd: sourceRoot, + maxBuffer: 10 * 1024 * 1024, + }); + await validateArchiveEntries(temporaryArchivePath); + + await fsp.mkdir(extractionRoot); + await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], { + maxBuffer: 10 * 1024 * 1024, + }); + const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist")); + assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive"); + + const archiveContents = await fsp.readFile(temporaryArchivePath); + const archiveSha256 = sha256(archiveContents); + await fsp.copyFile(temporaryArchivePath, archivePath, fs.constants.COPYFILE_EXCL); + await fsp.writeFile(checksumPath, `${archiveSha256} ${archiveName}\n`, { flag: "wx" }); + await fsp.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" }); + + const outputs = { + build_id: expectedBuildId, + archive_name: archiveName, + archive_path: archivePath, + checksum_path: checksumPath, + inventory_path: inventoryPath, + release_id: releaseId, + archive_sha256: archiveSha256, + }; + appendGithubOutputs(outputs, githubOutput); + return { ...outputs, inventory }; + } catch (error) { + await Promise.all( + [archivePath, checksumPath, inventoryPath].map((outputPath) => fsp.unlink(outputPath).catch(() => {})) + ); + throw error; + } finally { + await fsp.rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +async function main() { + const result = await createReleaseArchive({ + distDirectory: process.env.RELEASE_DIST_DIR || "dist", + outputDirectory: process.env.RELEASE_OUTPUT_DIR || "release-artifacts", + expectedCommitSha: process.env.RELEASE_COMMIT_SHA, + expectedBuildId: process.env.RELEASE_BUILD_ID, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + }); + console.log( + `Validated and packaged ${result.inventory.files.length} files as ${result.archive_name} (${result.archive_sha256}).` + ); +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isCli) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/release/verify-upload.mjs b/scripts/release/verify-upload.mjs index e24a8a01..1405ff89 100644 --- a/scripts/release/verify-upload.mjs +++ b/scripts/release/verify-upload.mjs @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { pathToFileURL } from "node:url"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -40,12 +41,15 @@ function pathUrl(baseUrl, assetPath) { return new URL(path, baseUrl).href; } -async function fetchBytes(url) { +async function fetchBytes(url, deadline) { + const configuredTimeout = Math.max(1, numberEnv("RELEASE_FETCH_TIMEOUT_SECONDS", 30)) * 1000; + const remaining = deadline ? Math.max(1, deadline - Date.now()) : configuredTimeout; const response = await fetch(url, { headers: { "Cache-Control": "no-cache", Pragma: "no-cache", }, + signal: AbortSignal.timeout(Math.min(configuredTimeout, remaining)), }); const bytes = Buffer.from(await response.arrayBuffer()); return { @@ -55,9 +59,9 @@ async function fetchBytes(url) { }; } -async function fetchJson(baseUrl, assetPath) { +async function fetchJson(baseUrl, assetPath, deadline) { const url = pathUrl(baseUrl, assetPath); - const result = await fetchBytes(url); + const result = await fetchBytes(url, deadline); const contentType = result.response.headers.get("content-type") || ""; if (!result.response.ok) { throw new Error(`${assetPath} returned HTTP ${result.response.status}`); @@ -98,9 +102,39 @@ function shouldRejectHtml(assetPath) { return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath); } -async function verifyAsset(baseUrl, assetPath, expectedHash) { +function isMutableReleaseFile(assetPath) { + const normalized = String(assetPath || "").replace(/^\/+/, ""); + return /(?:^|\/)(?:index\.html|release-(?:entry|manifest)\.json|manifest(?:\.json|\.webmanifest)|registerSW\.js|sw\.js)$/i.test( + normalized + ); +} + +function isContentAddressedAsset(assetPath) { + const normalized = String(assetPath || "").replace(/^\/+/, ""); + return /(?:^|\/)(?:workbox-)?[^/]*[-.][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$/i.test( + normalized + ); +} + +function verifyCachePolicy(assetPath, response) { + if (!booleanEnv("RELEASE_REQUIRE_CACHE_HEADERS")) { + return; + } + + const cacheControl = response.headers.get("cache-control") || ""; + if (isMutableReleaseFile(assetPath) && !/(?:no-store|no-cache|max-age=0)/i.test(cacheControl)) { + throw new Error( + `${assetPath} must be served with a revalidating Cache-Control policy (got ${cacheControl || "missing"})` + ); + } + if (isContentAddressedAsset(assetPath) && !/immutable/i.test(cacheControl)) { + throw new Error(`${assetPath} must be served with immutable caching (got ${cacheControl || "missing"})`); + } +} + +async function verifyAsset(baseUrl, assetPath, expectedHash, deadline) { const url = pathUrl(baseUrl, assetPath); - const result = await fetchBytes(url); + const result = await fetchBytes(url, deadline); const contentType = result.response.headers.get("content-type") || ""; if (!result.response.ok) { @@ -112,6 +146,7 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) { if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) { throw new Error(`${assetPath} was served as HTML (${contentType})`); } + verifyCachePolicy(assetPath, result.response); if (expectedHash?.sha256) { const actualHash = sha256(result.bytes); if (actualHash !== expectedHash.sha256) { @@ -120,8 +155,8 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) { } } -async function verifyShell(baseUrl, shellPath) { - const result = await fetchBytes(pathUrl(baseUrl, shellPath)); +async function verifyShell(baseUrl, shellPath, deadline) { + const result = await fetchBytes(pathUrl(baseUrl, shellPath), deadline); const contentType = result.response.headers.get("content-type") || ""; const body = result.text(); @@ -134,23 +169,41 @@ async function verifyShell(baseUrl, shellPath) { if (body.replace(/\s+/g, "").length < 40) { throw new Error(`${shellPath} returned an empty app shell`); } - if (!body.includes('
')) { + if (!containsVueAppRoot(body)) { throw new Error(`${shellPath} did not include the Vue app root`); } } -async function verifyRelease(baseUrl) { +export function containsVueAppRoot(body) { + return /]*\bid=(["'])app\1[^>]*>/i.test(String(body)); +} + +async function runWithConcurrency(values, concurrency, operation) { + let nextIndex = 0; + const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (nextIndex < values.length) { + const index = nextIndex; + nextIndex += 1; + await operation(values[index]); + } + }); + await Promise.all(workers); +} + +async function verifyRelease(baseUrl, deadline) { const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || ""; const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || ""; const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID"); - const manifest = await fetchJson(baseUrl, "release-manifest.json"); - const releaseEntry = await fetchJson(baseUrl, "release-entry.json"); + const manifest = await fetchJson(baseUrl, "release-manifest.json", deadline); + const releaseEntry = await fetchJson(baseUrl, "release-entry.json", deadline); if (!manifest.build_id) { throw new Error("release-manifest.json is missing build_id"); } if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) { - throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`); + throw new Error( + `release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}` + ); } if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) { throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`); @@ -165,12 +218,15 @@ async function verifyRelease(baseUrl) { throw new Error("release-entry.json css does not match release-manifest.json"); } - const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim())); + const shellPaths = unique( + (process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()) + ); for (const shellPath of shellPaths) { - await verifyShell(baseUrl, shellPath); + await verifyShell(baseUrl, shellPath, deadline); } const assetUrls = unique([ + "index.html", "release-manifest.json", "release-entry.json", manifest.entry, @@ -180,12 +236,15 @@ async function verifyRelease(baseUrl) { ...(manifest.asset_urls || []), ]); - for (const assetUrl of assetUrls) { - if (assetUrl === "/index.html") { - continue; - } - await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]); - } + const concurrency = Math.max(1, Math.min(32, numberEnv("RELEASE_VERIFY_CONCURRENCY", 8))); + await runWithConcurrency(assetUrls, concurrency, async (assetUrl) => { + await verifyAsset( + baseUrl, + assetUrl, + manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`], + deadline + ); + }); return { build_id: manifest.build_id, @@ -212,7 +271,7 @@ async function main() { while (Date.now() <= deadline) { attempt += 1; try { - const result = await verifyRelease(baseUrl); + const result = await verifyRelease(baseUrl, deadline); appendGithubEnv({ RELEASE_VERIFIED_BUILD_ID: result.build_id, RELEASE_VERIFIED_COMMIT: result.commit_sha, @@ -228,14 +287,16 @@ async function main() { if (Date.now() > deadline) { break; } - await sleep(pollIntervalSeconds * 1000); + await sleep(Math.min(pollIntervalSeconds * 1000, Math.max(0, deadline - Date.now()))); } } throw lastError || new Error("Release upload verification timed out."); } -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : error); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : error); + process.exit(1); + }); +} diff --git a/tests/e2e/release/public-htaccess.local-prod.spec.ts b/tests/e2e/release/public-htaccess.local-prod.spec.ts index 6bc11269..34609ba1 100644 --- a/tests/e2e/release/public-htaccess.local-prod.spec.ts +++ b/tests/e2e/release/public-htaccess.local-prod.spec.ts @@ -282,6 +282,7 @@ test.describe("public .htaccess static fallback", () => { expect(source).not.toMatch(/RewriteBase\s+\//); expect(source).not.toContain("/index.html"); + expect(source).toContain("DirectoryIndex index.html"); expect(source).toContain("index.html [L]"); expect(source).toContain("R=404"); expect(source).toContain("AddType application/manifest+json .webmanifest"); diff --git a/tests/unit/cpanel-deploy.spec.js b/tests/unit/cpanel-deploy.spec.js new file mode 100644 index 00000000..5abdbc72 --- /dev/null +++ b/tests/unit/cpanel-deploy.spec.js @@ -0,0 +1,434 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CpanelFilemanClient, + DeploymentError, + assertExpectedCommitCurrent, + assertReleaseTargetExists, + buildLftpScript, + capturePublishedReleaseTarget, + containedRemotePath, + createLftpTransport, + deriveCpanelRoot, + deployRelease, + pruneInactiveReleases, + readDeploymentConfig, + rollbackRelease, + targetRelativeToRoot, + validateReleaseTarget, +} from "../../scripts/release/cpanel-deploy-lib.mjs"; + +const temporaryDirectories = []; +const COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +function validEnv(overrides = {}) { + return { + PRODUCTION_FTP_HOST: "ftp.example.test:21", + PRODUCTION_FTP_USER: "deploy-user", + PRODUCTION_FTP_PASSWORD: "password with ' quote", + PRODUCTION_FTP_PATH: "/", + PRODUCTION_CPANEL_USER: "cpanel-user", + PRODUCTION_CPANEL_API_TOKEN: "cpanel-token", + PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083/", + PRODUCTION_CPANEL_PATH: "/home/cpanel-user/public_html/frontend", + PRODUCTION_FRONTEND_URL: "https://app.example.test/", + RELEASE_ARCHIVE_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip", + RELEASE_ARCHIVE_SHA256_PATH: "/tmp/pleno-vue-deadbeef-123-1.zip.sha256", + RELEASE_INVENTORY_PATH: "/tmp/pleno-vue-deadbeef-123-1.inventory.json", + RELEASE_EXPECTED_COMMIT: COMMIT_SHA, + RELEASE_EXPECTED_BUILD_ID: "123-1", + RELEASE_GITHUB_REPOSITORY: "pleno-dev/pleno-vue", + RELEASE_GITHUB_TOKEN: "github-token", + ...overrides, + }; +} + +function config(overrides = {}) { + return { + ...readDeploymentConfig(validEnv()), + ...overrides, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })) + ); +}); + +describe("cPanel deployment configuration", () => { + it("validates every credential without putting values in errors", () => { + const secret = "never-print-this-token"; + expect(() => readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` }))).toThrowError( + "PRODUCTION_CPANEL_API_TOKEN contains unsupported control characters" + ); + + try { + readDeploymentConfig(validEnv({ PRODUCTION_CPANEL_API_TOKEN: `${secret}\ncommand` })); + } catch (error) { + expect(error.message).not.toContain(secret); + } + }); + + it("derives a safe immutable release ID and accepts the checksum compatibility alias", () => { + const env = validEnv(); + delete env.RELEASE_ARCHIVE_SHA256_PATH; + env.RELEASE_CHECKSUM_PATH = "/tmp/archive.zip.sha256"; + const result = readDeploymentConfig(env); + + expect(result.releaseId).toBe(`${COMMIT_SHA}-123-1`); + expect(result.checksumPath).toBe("/tmp/archive.zip.sha256"); + expect(result.ftp.root).toBe("/"); + expect(result.cpanel.root).toBe("public_html/frontend"); + }); + + it("rejects path traversal and targets outside immutable releases", () => { + expect(() => containedRemotePath("public_html/frontend", "..", "outside")).toThrow(DeploymentError); + expect(() => validateReleaseTarget("staging/release/dist")).toThrow(DeploymentError); + expect(targetRelativeToRoot("public_html/frontend", "/home/user/public_html/frontend/releases/r1/dist")).toBe( + "releases/r1/dist" + ); + }); + + it("maps an absolute FTP path to the API2 account-home-relative path", () => { + expect(deriveCpanelRoot("/home/cpanel-user/public_html/frontend", "cpanel-user")).toBe("public_html/frontend"); + expect(() => deriveCpanelRoot("/home/different-user/public_html/frontend", "cpanel-user")).toThrow( + "not inside the configured cPanel account home" + ); + }); + + it("does not require the workflow token for rollback-only configuration", () => { + const env = validEnv({ GITHUB_REPOSITORY: "pleno-dev/pleno-vue" }); + delete env.RELEASE_GITHUB_REPOSITORY; + delete env.RELEASE_GITHUB_TOKEN; + + expect(readDeploymentConfig(env).github).toBeNull(); + }); +}); + +describe("secure FTPS archive upload", () => { + it("forces verified TLS and keeps credentials out of command arguments", () => { + const deploymentConfig = config(); + const script = buildLftpScript(deploymentConfig, ["bye"]); + + expect(script).toContain("set ftp:ssl-force yes"); + expect(script).toContain("set ftp:ssl-protect-data yes"); + expect(script).toContain("set ftp:list-options -a"); + expect(script).toContain("set ssl:verify-certificate yes"); + expect(script).toContain("set ssl:check-hostname yes"); + expect(script).toContain("open -u 'deploy-user','password with '\\'' quote'"); + }); + + it("uploads .part files, downloads them for verification, then renames them", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cpanel-deploy-test-")); + temporaryDirectories.push(directory); + const archivePath = path.join(directory, "pleno-vue-deadbeef-123-1.zip"); + const checksumPath = `${archivePath}.sha256`; + const archive = Buffer.from("verified release archive"); + const hash = crypto.createHash("sha256").update(archive).digest("hex"); + await fs.writeFile(archivePath, archive); + await fs.writeFile(checksumPath, `${hash} ${path.basename(archivePath)}\n`); + + const calls = []; + const runner = vi.fn(async (command, args, options) => { + calls.push({ command, args, input: options.input }); + const downloads = [...options.input.matchAll(/get '[^']+' -o '([^']+)'/g)].map((match) => match[1]); + if (downloads.length === 2) { + await fs.copyFile(archivePath, downloads[0]); + await fs.copyFile(checksumPath, downloads[1]); + } + }); + const deploymentConfig = config({ archivePath, checksumPath }); + const transport = createLftpTransport(deploymentConfig, { runner }); + + await expect(transport.uploadArchive()).resolves.toMatchObject({ + archiveName: path.basename(archivePath), + sha256: hash, + }); + expect(calls).toHaveLength(2); + expect(calls[0].args).toEqual(["-f", "/dev/stdin"]); + expect(calls[0].input).toContain(`${path.basename(archivePath)}.part`); + expect(calls[0].input).toContain("get 'archives/"); + expect(calls[1].input).toContain( + `mv 'archives/${path.basename(archivePath)}.sha256.part' 'archives/${path.basename(archivePath)}.sha256'` + ); + expect(calls[1].input).toContain( + `mv 'archives/${path.basename(archivePath)}.part' 'archives/${path.basename(archivePath)}'` + ); + expect(calls[0].args.join(" ")).not.toContain(deploymentConfig.ftp.password); + }); + + it("permanently removes only validated inactive release and archive paths", async () => { + const scripts = []; + const runner = vi.fn(async (_command, _args, options) => scripts.push(options.input)); + const transport = createLftpTransport(config(), { runner }); + + await transport.removeRelease("old-release"); + await transport.removeArchives(["old-release"]); + + expect(scripts.join("\n")).toContain("rm -r -f 'releases/old-release'"); + expect(scripts.join("\n")).toContain("rm -f 'archives/pleno-vue-old-release.zip'"); + await expect(transport.removeRelease("../outside")).rejects.toThrow("safe filename component"); + }); +}); + +describe("cPanel Fileman adapter", () => { + it("uses token authentication and API2 Fileman parameters over HTTPS", async () => { + const requests = []; + const fetchImpl = vi.fn(async (url, options) => { + requests.push({ url: new URL(url), options }); + return { + ok: true, + status: 200, + async json() { + return { + cpanelresult: { + event: { result: 1 }, + data: [{ result: 1 }], + }, + }; + }, + }; + }); + const deploymentConfig = config(); + const client = new CpanelFilemanClient(deploymentConfig, { fetchImpl }); + + await client.extract("public_html/frontend/staging/release/archive.zip", "public_html/frontend/staging/release"); + + expect(requests).toHaveLength(1); + expect(requests[0].url.protocol).toBe("https:"); + expect(requests[0].url.searchParams.get("cpanel_jsonapi_apiversion")).toBe("2"); + expect(requests[0].url.searchParams.get("cpanel_jsonapi_module")).toBe("Fileman"); + expect(requests[0].url.searchParams.get("cpanel_jsonapi_func")).toBe("fileop"); + expect(requests[0].url.searchParams.get("op")).toBe("extract"); + expect(requests[0].url.searchParams.get("destfiles")).toBe("public_html/frontend/staging/release"); + expect(requests[0].options.headers.Authorization).toBe("cpanel cpanel-user:cpanel-token"); + }); + + it("redacts credentials echoed by a cPanel error", async () => { + const deploymentConfig = config(); + const client = new CpanelFilemanClient(deploymentConfig, { + fetchImpl: async () => ({ + ok: true, + status: 200, + async json() { + return { + cpanelresult: { + event: { result: 0, reason: `bad ${deploymentConfig.cpanel.token}` }, + data: [], + }, + }; + }, + }), + }); + + await expect(client.list(deploymentConfig.cpanel.root)).rejects.toThrow("bad [redacted]"); + }); + + it("normalizes API2 listfiles files and dirs arrays", async () => { + const client = new CpanelFilemanClient(config(), { + fetchImpl: async () => ({ + ok: true, + status: 200, + json: async () => ({ + cpanelresult: { + event: { result: 1 }, + files: [{ file: "current", type: "link" }], + dirs: [{ file: "releases", type: "dir" }], + }, + }), + }), + }); + + await expect(client.list(config().cpanel.root)).resolves.toEqual([ + { file: "current", type: "link" }, + { file: "releases", type: "dir" }, + ]); + }); + + it("captures the rollback target from the live release manifest", async () => { + const deploymentConfig = config(); + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ commit_sha: COMMIT_SHA, build_id: "122-1" }), + })); + + await expect(capturePublishedReleaseTarget(deploymentConfig, { fetchImpl })).resolves.toBe( + `releases/${COMMIT_SHA}-122-1/dist` + ); + expect(fetchImpl).toHaveBeenCalledWith( + new URL("release-manifest.json", deploymentConfig.frontendUrl), + expect.objectContaining({ headers: expect.objectContaining({ "Cache-Control": "no-cache" }) }) + ); + }); + + it("rejects a stale commit immediately before activation", async () => { + const deploymentConfig = config(); + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ commit: { sha: "0123456789abcdef0123456789abcdef01234567" } }), + })); + + await expect(assertExpectedCommitCurrent(deploymentConfig, { fetchImpl })).rejects.toThrow( + "Release commit is stale" + ); + }); +}); + +function inMemorySwitchClient(deploymentConfig, initialTarget) { + let current = initialTarget; + let next = ""; + const root = deploymentConfig.cpanel.root; + const entriesForRoot = () => [ + { file: "archives", type: "dir" }, + { file: "releases", type: "dir" }, + { file: "staging", type: "dir" }, + ...(current ? [{ file: "current", type: "link", link: `${root}/${current}` }] : []), + ...(next ? [{ file: "current.next", type: "link", link: `${root}/${next}` }] : []), + ]; + return { + get current() { + return current; + }, + async list(directory) { + if (directory === root) return entriesForRoot(); + if (directory === `${root}/archives`) return []; + if (directory === `${root}/releases`) { + return current ? [{ file: current.split("/")[1], type: "dir" }] : []; + } + if (current && directory === `${root}/releases/${current.split("/")[1]}`) { + return [{ file: "dist", type: "dir" }]; + } + return []; + }, + async link(source, destination) { + expect(destination).toBe(`${root}/current.next`); + next = targetRelativeToRoot(root, source); + }, + async rename(source, destination) { + expect(source).toBe(`${root}/current.next`); + expect(destination).toBe(`${root}/current`); + current = next; + next = ""; + }, + async unlink() { + next = ""; + }, + }; +} + +describe("activation, rollback, and retention", () => { + it("rejects a manifest-derived rollback target that is absent on cPanel", async () => { + const deploymentConfig = config(); + const client = { list: vi.fn(async () => []) }; + + await expect( + assertReleaseTargetExists(client, deploymentConfig, `releases/${COMMIT_SHA}-122-1/dist`) + ).rejects.toThrow("rollback release directory does not exist"); + }); + + it("restores the previous pointer when public verification fails", async () => { + const deploymentConfig = config(); + const previousTarget = "releases/previous-release/dist"; + const newTarget = `releases/${deploymentConfig.releaseId}/dist`; + const client = inMemorySwitchClient(deploymentConfig, previousTarget); + const outputs = []; + const verifyRelease = vi.fn(); + + await expect( + deployRelease(deploymentConfig, { + client, + transport: { + uploadArchive: async () => ({ archiveName: "release.zip" }), + verifyRelease, + }, + preflight: async () => {}, + capturePrevious: async () => previousTarget, + checkCurrent: async () => {}, + stage: async () => newTarget, + verify: async () => { + throw new Error("application gate failed"); + }, + prune: async () => [], + publish: (values) => outputs.push(values), + }) + ).rejects.toThrow("the previous release was restored"); + + expect(client.current).toBe(previousTarget); + expect(verifyRelease).toHaveBeenCalledWith(newTarget); + expect(outputs[0]).toMatchObject({ + RELEASE_ROLLBACK_TARGET: previousTarget, + RELEASE_ACTIVE_TARGET: newTarget, + }); + }); + + it("supports an idempotent rollback-only workflow step", async () => { + const deploymentConfig = config(); + const target = "releases/previous-release/dist"; + const client = inMemorySwitchClient(deploymentConfig, target); + const publish = vi.fn(); + + await expect( + rollbackRelease(deploymentConfig, target, { + client, + preflight: async () => {}, + publish, + }) + ).resolves.toMatchObject({ activeTarget: target }); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ RELEASE_ACTIVE_TARGET: target })); + }); + + it("rejects a rollback-only target that no longer exists before running the preflight", async () => { + const deploymentConfig = config(); + const preflight = vi.fn(); + const client = { list: vi.fn(async () => []) }; + + await expect( + rollbackRelease(deploymentConfig, "releases/missing-release/dist", { + client, + preflight, + }) + ).rejects.toThrow("rollback release directory does not exist"); + expect(preflight).not.toHaveBeenCalled(); + }); + + it("prunes only inactive releases and preserves active and rollback targets", async () => { + const deploymentConfig = config({ retainCount: 2 }); + const removed = []; + const client = { + async list() { + return [ + { file: "active", type: "dir", mtime: 50 }, + { file: "rollback", type: "dir", mtime: 40 }, + { file: "old", type: "dir", mtime: 30 }, + { file: ".trash", type: "dir", mtime: 1 }, + ]; + }, + async remove(remotePath) { + removed.push(remotePath); + }, + }; + + await expect( + pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist", "releases/rollback/dist"]) + ).resolves.toEqual(["old"]); + expect(removed).toEqual([`${deploymentConfig.cpanel.root}/releases/old`]); + }); + + it("does not prune when cPanel cannot provide reliable modification times", async () => { + const deploymentConfig = config({ retainCount: 2 }); + const client = { + list: async () => [{ file: "active", mtime: 50 }, { file: "unknown-age" }], + remove: vi.fn(), + }; + + await expect(pruneInactiveReleases(client, deploymentConfig, ["releases/active/dist"])).resolves.toEqual([]); + expect(client.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/cpanel-root.spec.js b/tests/unit/cpanel-root.spec.js new file mode 100644 index 00000000..1b108b8e --- /dev/null +++ b/tests/unit/cpanel-root.spec.js @@ -0,0 +1,346 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DeploymentError } from "../../scripts/release/cpanel-deploy-lib.mjs"; +import { CpanelAccountClient, auditRoot, readRootConfig, restoreRoot } from "../../scripts/release/cpanel-root-lib.mjs"; + +function environment(overrides = {}) { + return { + PRODUCTION_CPANEL_USER: "truckwash", + PRODUCTION_CPANEL_API_TOKEN: "secret-token", + PRODUCTION_CPANEL_API_URL: "https://cpanel.example.test:2083", + PRODUCTION_CPANEL_PATH: "frontend-deployments", + PRODUCTION_CPANEL_WEBROOT: "public_html", + PRODUCTION_FRONTEND_URL: "https://truckwash.io", + ...overrides, + }; +} + +function config() { + return readRootConfig(environment()); +} + +function auditClient(overrides = {}) { + const home = [ + { file: "public_html", type: "dir", mode: "0755" }, + { file: "public_html.recovery-before-bootstrap", type: "dir", mode: "0755" }, + ]; + const entries = new Map([ + [".", home], + ["frontend-deployments", [{ file: "current", type: "link" }]], + [ + "frontend-deployments/current", + ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"].map((file) => ({ + file, + type: "file", + })), + ], + ["public_html", [{ file: "index.html", type: "file", mode: "0600" }]], + ]); + const rename = vi.fn(async (source, destination) => { + const sourceIndex = home.findIndex((entry) => entry.file === source); + if (sourceIndex < 0) throw new Error(`missing source ${source}`); + if (home.some((entry) => entry.file === destination)) throw new Error(`existing destination ${destination}`); + home[sourceIndex] = { ...home[sourceIndex], file: destination }; + if (entries.has(source)) { + entries.set(destination, entries.get(source)); + entries.delete(source); + } + }); + return { + home, + entries, + allowedMutable: new Set(), + list: vi.fn(async (directory) => entries.get(directory) || []), + domains: vi.fn(async () => []), + rename, + ...overrides, + }; +} + +describe("cPanel primary webroot audit", () => { + it("validates account paths and keeps the API token out of failures", () => { + expect(readRootConfig(environment()).deploymentRoot).toBe("frontend-deployments"); + expect(() => readRootConfig(environment({ PRODUCTION_CPANEL_WEBROOT: "../public_html" }))).toThrow(DeploymentError); + expect(() => readRootConfig(environment({ PRODUCTION_CPANEL_API_TOKEN: "token\nvalue" }))).toThrow( + "Missing or invalid PRODUCTION_CPANEL_API_TOKEN" + ); + }); + + it("reports the retained pre-bootstrap root and the unreadable physical webroot", async () => { + const report = await auditRoot(config(), { client: auditClient() }); + + expect(report).toMatchObject({ + healthy: false, + rootTargetVerified: false, + webrootAccess: { accessible: true, error: "" }, + current: { + path: "frontend-deployments/current", + type: "link", + accessible: true, + missingFiles: [], + }, + webroot: { name: "public_html", type: "dir", mode: "0755" }, + }); + expect(report.webrootEntries).toContainEqual(expect.objectContaining({ name: "index.html", mode: "0600" })); + expect(report.recoveryCandidates.map(({ name }) => name)).toEqual(["public_html.recovery-before-bootstrap"]); + expect(report.stateToken).toMatch(/^[a-f0-9]{64}$/); + }); + + it("keeps recovery candidates when the current webroot cannot be followed", async () => { + const client = auditClient(); + client.list.mockImplementation(async (directory) => { + if (directory === "public_html") throw new Error("dangling root link"); + return client.entries.get(directory) || []; + }); + + const report = await auditRoot(config(), { client }); + expect(report.webrootAccess).toEqual({ accessible: false, error: "dangling root link" }); + expect(report.recoveryCandidates).toHaveLength(1); + expect(report.healthy).toBe(false); + }); + + it("recognizes the retained before-atomic bootstrap webroot", async () => { + const client = auditClient(); + client.home[1].file = "public_html.before-atomic-20260720T0819Z"; + + const report = await auditRoot(config(), { client }); + expect(report.recoveryCandidates.map(({ name }) => name)).toEqual(["public_html.before-atomic-20260720T0819Z"]); + }); + + it("reports an invalid current entry without hiding recovery candidates", async () => { + const client = auditClient(); + client.entries.set("frontend-deployments", [{ file: "current", type: "dir" }]); + + const report = await auditRoot(config(), { client }); + expect(report.current).toMatchObject({ type: "dir", accessible: false }); + expect(report.recoveryCandidates).toHaveLength(1); + expect(report.healthy).toBe(false); + }); + + it("limits Fileman mutations to an exact allowlist", async () => { + const client = new CpanelAccountClient(config(), { + allowedMutable: ["public_html", "public_html.failed-1"], + fetchImpl: vi.fn(), + }); + + await expect(client.rename("public_html", "unrelated")).rejects.toThrow("outside the explicit restore allowlist"); + expect(client.fetch).not.toHaveBeenCalled(); + }); + + it("fails closed when DomainInfo does not return the documented list shape", async () => { + const client = new CpanelAccountClient(config(), { + fetchImpl: vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ result: { status: 1, data: {} } }), + })), + }); + + await expect(client.domains()).rejects.toThrow("unexpected data shape"); + }); + + it("fails closed when a domain record omits its identity or document root", async () => { + const client = auditClient({ domains: vi.fn(async () => [{ domain: "files.example.test" }]) }); + + await expect(auditRoot(config(), { client })).rejects.toThrow("incomplete domain data"); + }); +}); + +describe("cPanel retained webroot restore", () => { + it("requires the exact recovery name and typed confirmation", async () => { + const client = auditClient(); + + await expect( + restoreRoot(config(), "public_html.recovery-before-bootstrap", "bad-state", "yes", { client, runId: "1" }) + ).rejects.toThrow("exact state token"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("preserves the broken state and activates the retained webroot before verification", async () => { + const client = auditClient(); + const verify = vi.fn(async () => {}); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "123", verify } + ) + ).resolves.toEqual({ + restored: "public_html.recovery-before-bootstrap", + displaced: "public_html.failed-123", + }); + expect(client.rename.mock.calls).toEqual([ + ["public_html", "public_html.failed-123"], + ["public_html.recovery-before-bootstrap", "public_html"], + ]); + expect(verify).toHaveBeenCalledOnce(); + }); + + it("refuses a restore when cPanel state changed after the audited token", async () => { + const client = auditClient(); + const state = await auditRoot(config(), { client }); + client.home[0].mode = "0700"; + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "stale" } + ) + ).rejects.toThrow("changed after the audit"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("refuses to overwrite a displaced-state entry from an earlier attempt", async () => { + const client = auditClient(); + client.home.push({ file: "public_html.failed-collision", type: "dir" }); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "collision" } + ) + ).rejects.toThrow("already exists"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("refuses an unreadable physical webroot while preserving audit recovery data", async () => { + const client = auditClient(); + client.list.mockImplementation(async (directory) => { + if (directory === "public_html") throw new Error("permission denied"); + return client.entries.get(directory) || []; + }); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "unreadable-dir" } + ) + ).rejects.toThrow("only a top-level symbolic link"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("refuses to replace a webroot that contains another domain document root", async () => { + const client = auditClient({ + domains: vi.fn(async () => [ + { + domain: "files.example.test", + domain_type: "addon", + documentroot: "/home/truckwash/public_html/files", + }, + ]), + }); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "nested-domain" } + ) + ).rejects.toThrow("nested domain document roots exist"); + expect(client.rename).not.toHaveBeenCalled(); + }); + + it("reconciles a committed first rename whose response was lost", async () => { + const client = auditClient(); + const rename = client.rename.getMockImplementation(); + let call = 0; + client.rename.mockImplementation(async (...args) => { + call += 1; + if (call === 1) { + await rename(...args); + throw new Error("response lost after commit"); + } + if (call === 2) throw new Error("activation rejected before commit"); + return await rename(...args); + }); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "ambiguous-first" } + ) + ).rejects.toThrow("pre-restore state was reinstated"); + expect(client.home.map(({ file }) => file).sort()).toEqual([ + "public_html", + "public_html.recovery-before-bootstrap", + ]); + expect(client.rename.mock.calls).toEqual([ + ["public_html", "public_html.failed-ambiguous-first"], + ["public_html.recovery-before-bootstrap", "public_html"], + ["public_html.failed-ambiguous-first", "public_html"], + ]); + }); + + it("reinstates the exact pre-restore state when live verification fails", async () => { + const client = auditClient(); + const verify = vi.fn(async () => { + throw new Error("still broken"); + }); + const state = await auditRoot(config(), { client }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { client, runId: "456", verify } + ) + ).rejects.toThrow("pre-restore state was reinstated"); + expect(client.rename.mock.calls).toEqual([ + ["public_html", "public_html.failed-456"], + ["public_html.recovery-before-bootstrap", "public_html"], + ["public_html", "public_html.recovery-before-bootstrap"], + ["public_html.failed-456", "public_html"], + ]); + }); + + it("reports incomplete compensation without claiming the old state was restored", async () => { + const client = auditClient(); + const state = await auditRoot(config(), { client }); + const rename = client.rename.getMockImplementation(); + client.rename.mockImplementation(async (...args) => { + if (client.rename.mock.calls.length === 3) throw new Error("compensation failed"); + return await rename(...args); + }); + + await expect( + restoreRoot( + config(), + "public_html.recovery-before-bootstrap", + state.stateToken, + `RESTORE public_html.recovery-before-bootstrap TO public_html STATE ${state.stateToken}`, + { + client, + runId: "rollback-failure", + verify: async () => { + throw new Error("site failed"); + }, + } + ) + ).rejects.toThrow("automatic rollback was incomplete"); + expect(client.rename).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/unit/release-package-dist.spec.js b/tests/unit/release-package-dist.spec.js new file mode 100644 index 00000000..480ee733 --- /dev/null +++ b/tests/unit/release-package-dist.spec.js @@ -0,0 +1,186 @@ +import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + collectDistInventory, + createReleaseArchive, + validateReleaseMetadata, +} from "../../scripts/release/package-dist.mjs"; + +const COMMIT_SHA = "0123456789abcdef0123456789abcdef01234567"; +const BUILD_ID = "123456-2"; + +function hashMetadata(contents) { + const bytes = Buffer.from(contents); + return { + sha256: crypto.createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; +} + +async function writeFixtureDist(root, options = {}) { + const distDirectory = path.join(root, "dist"); + const files = { + ".htaccess": "RewriteEngine On\n", + "index.html": '
\n', + "assets/main.js": "console.log('release');\n", + "assets/main.css": "body { color: #123; }\n", + ...(options.files || {}), + }; + const releaseEntry = { + entry: "assets/main.js", + css: ["assets/main.css"], + }; + files["release-entry.json"] = `${JSON.stringify(releaseEntry, null, 2)}\n`; + + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(distDirectory, ...relativePath.split("/")); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); + } + + const assetUrls = ["index.html", "release-entry.json", "assets/main.js", "assets/main.css"]; + if (options.extraAssetUrl) { + assetUrls.push(options.extraAssetUrl); + } + const assetHashes = {}; + for (const assetUrl of assetUrls) { + const contents = files[assetUrl]; + assetHashes[assetUrl] = contents === undefined ? hashMetadata("missing") : hashMetadata(contents); + } + + const manifest = { + schema_version: 1, + build_id: options.buildId || BUILD_ID, + commit_sha: options.commitSha || COMMIT_SHA, + created_at: "2026-07-20T00:00:00.000Z", + entry: releaseEntry.entry, + css: releaseEntry.css, + index_asset_urls: ["assets/main.js", "assets/main.css"], + pwa_asset_urls: [], + asset_urls: assetUrls, + asset_hashes: options.assetHashes || assetHashes, + }; + await fs.writeFile(path.join(distDirectory, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + return distDirectory; +} + +describe("release dist packager", () => { + let temporaryRoot; + + beforeEach(async () => { + temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pleno-package-dist-test-")); + }); + + afterEach(async () => { + await fs.rm(temporaryRoot, { recursive: true, force: true }); + }); + + it("creates a verified archive with a top-level dist, dotfiles, checksum, inventory, and GitHub outputs", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot, { + files: { + "assets.json": "{}\n", + "assets/z-last.svg": "\n", + "assets/a-first.json": "{}\n", + }, + }); + const outputDirectory = path.join(temporaryRoot, "artifacts"); + const githubOutput = path.join(temporaryRoot, "github-output.txt"); + await fs.writeFile(githubOutput, "existing=value\n"); + + const result = await createReleaseArchive({ + distDirectory, + outputDirectory, + expectedCommitSha: COMMIT_SHA, + expectedBuildId: BUILD_ID, + runId: "123456", + runAttempt: "2", + githubOutput, + }); + + expect(result.archive_name).toBe(`pleno-vue-${COMMIT_SHA}-123456-2.zip`); + expect(result.release_id).toBe(`${COMMIT_SHA}-123456-2`); + expect(result.archive_sha256).toMatch(/^[0-9a-f]{64}$/); + expect(execFileSync("unzip", ["-Z1", result.archive_path], { encoding: "utf8" })).toContain("dist/.htaccess"); + expect(await fs.readFile(result.checksum_path, "utf8")).toBe(`${result.archive_sha256} ${result.archive_name}\n`); + + const writtenInventory = JSON.parse(await fs.readFile(result.inventory_path, "utf8")); + expect(writtenInventory).toEqual(await collectDistInventory(distDirectory)); + expect(writtenInventory.files.map((file) => file.path)).toEqual( + [...writtenInventory.files.map((file) => file.path)].sort() + ); + + const outputText = await fs.readFile(githubOutput, "utf8"); + expect(outputText).toContain(`build_id=${BUILD_ID}\n`); + expect(outputText).toContain(`archive_name=${result.archive_name}\n`); + expect(outputText).toContain(`archive_path=${result.archive_path}\n`); + expect(outputText).toContain(`checksum_path=${result.checksum_path}\n`); + expect(outputText).toContain(`inventory_path=${result.inventory_path}\n`); + expect(outputText).toContain(`release_id=${result.release_id}\n`); + expect(outputText).toContain(`archive_sha256=${result.archive_sha256}\n`); + }); + + it("rejects release metadata that does not identify the exact tested commit and build", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot, { + commitSha: "fedcba9876543210fedcba9876543210fedcba98", + buildId: "654321-1", + }); + + await expect( + validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID }) + ).rejects.toThrow(/does not exactly match/); + }); + + it("rejects missing or tampered files recorded in release-manifest.json", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot); + await fs.writeFile(path.join(distDirectory, "assets/main.js"), "tampered\n"); + + await expect( + validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID }) + ).rejects.toThrow(/integrity mismatch for assets\/main\.js/); + }); + + it("rejects traversal paths declared by release metadata", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot, { extraAssetUrl: "../outside.js" }); + + await expect( + validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID }) + ).rejects.toThrow(/unsafe path segment/); + }); + + it("rejects server-executable files anywhere in dist", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot, { + files: { "uploads/payload.php.jpg": " { + const distDirectory = await writeFixtureDist(temporaryRoot); + await fs.symlink(path.join(distDirectory, "index.html"), path.join(distDirectory, "linked-index.html")); + + await expect(collectDistInventory(distDirectory)).rejects.toThrow(/symbolic link: linked-index\.html/); + }); + + it("requires the release build id to match the GitHub run identity", async () => { + const distDirectory = await writeFixtureDist(temporaryRoot); + + await expect( + createReleaseArchive({ + distDirectory, + outputDirectory: path.join(temporaryRoot, "artifacts"), + expectedCommitSha: COMMIT_SHA, + expectedBuildId: BUILD_ID, + runId: "123456", + runAttempt: "1", + }) + ).rejects.toThrow(/must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT/); + }); +}); diff --git a/tests/unit/release-verify-upload.spec.js b/tests/unit/release-verify-upload.spec.js new file mode 100644 index 00000000..c18530d6 --- /dev/null +++ b/tests/unit/release-verify-upload.spec.js @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { containsVueAppRoot } from "../../scripts/release/verify-upload.mjs"; + +describe("release upload app-shell verification", () => { + it("accepts the branded loader rendered inside the Vue app root", () => { + expect(containsVueAppRoot('
Loading
')).toBe(true); + }); + + it("accepts an empty Vue app root", () => { + expect(containsVueAppRoot('
')).toBe(true); + }); + + it("rejects a shell without the Vue app root", () => { + expect(containsVueAppRoot('
')).toBe(false); + }); +});