name: Frontend Release on: workflow_run: workflows: - Automated Tests types: - completed branches: - master permissions: contents: read actions: read concurrency: group: frontend-production cancel-in-progress: false jobs: 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: ubuntu-24.04 env: RELEASE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }} 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 }} archive_sha256: ${{ steps.package.outputs.archive_sha256 }} steps: - name: Check release commit is current id: branch-head uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ env.RELEASE_COMMIT_SHA }} - name: Setup Node.js if: steps.branch-head.outputs.current == 'true' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm - name: Install dependencies if: steps.branch-head.outputs.current == 'true' run: npm ci --legacy-peer-deps - name: Check AI workflow sync if: steps.branch-head.outputs.current == 'true' run: node scripts/sync-ai-workflow.mjs --check - name: Source and i18n checks if: steps.branch-head.outputs.current == 'true' run: | npm run text:check-encoding npm run i18n:v2:source-check - 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 - name: Production Playwright gate if: steps.branch-head.outputs.current == 'true' run: npm run test:e2e:prod env: PLAYWRIGHT_PROD_PREBUILT: "1" PLAYWRIGHT_PROD_WEBKIT: "0" - name: Confirm production gate did not mutate dist if: steps.branch-head.outputs.current == 'true' run: | 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: Package and validate release if: steps.branch-head.outputs.current == 'true' 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ env.RELEASE_COMMIT_SHA }} - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm - name: Install dependencies run: npm ci --legacy-peer-deps - name: Install secure FTP client without system changes run: | if command -v lftp >/dev/null 2>&1; then exit 0 fi package_root="$RUNNER_TEMP/lftp-package" mkdir -p "$package_root" ( cd "$package_root" apt-get download lftp dpkg-deb --extract ./lftp_*.deb root ) echo "$package_root/root/usr/bin" >> "$GITHUB_PATH" - name: Install Playwright Chromium run: node scripts/install-playwright-browsers.mjs chromium - name: Download validated release package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 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_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} 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' id: public_live timeout-minutes: 10 run: npm run test:e2e:live:public env: NODE_OPTIONS: --use-system-ca - name: Detect credentialed live gate configuration if: steps.branch-head.outputs.current == 'true' id: credentialed_live_config shell: bash run: | set -euo pipefail if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" && -n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" fi env: CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }} CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }} OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }} OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }} - name: Credentialed live Playwright gate (when configured) if: >- steps.branch-head.outputs.current == 'true' && steps.credentialed_live_config.outputs.configured == 'true' id: credentialed_live timeout-minutes: 15 run: npm run test:e2e:live:roles env: NODE_OPTIONS: --use-system-ca PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }} PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }} PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }} PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }} PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }} PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }} - name: Record Release Manager gate id: release_manager if: steps.branch-head.outputs.current == 'true' timeout-minutes: 5 run: | test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}" curl --fail --show-error --silent \ -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\":\"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\":[\"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_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Update server version after verification if: steps.branch-head.outputs.current == 'true' run: npm run release:update-server-version env: SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }} RELEASE_VERSION_UPDATE_REQUIRED: "true" - name: Create verified frontend release proof if: steps.branch-head.outputs.current == 'true' shell: bash run: | set -euo pipefail proof_dir="output/frontend-release-proof" mkdir -p "$proof_dir" PROOF_PATH="$proof_dir/frontend-release-proof.json" node <<'NODE' const { writeFileSync } = require("node:fs"); const required = (name) => { if (!process.env[name]) throw new Error(`Missing ${name}`); return process.env[name]; }; const requireSuccessfulStep = (name) => { const outcome = required(name); if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`); return "passed"; }; const credentialedGate = () => { const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED"); if (configured === "false") return "not-configured"; if (configured !== "true") { throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`); } return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME"); }; const proof = { schemaVersion: 2, releaseId: required("RELEASE_ID"), sha: required("RELEASE_COMMIT_SHA").toLowerCase(), archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(), activeTarget: required("RELEASE_ACTIVE_TARGET"), rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null, verificationState: "verified", observedAt: new Date().toISOString(), repository: required("GITHUB_REPOSITORY"), sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(), testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"), frontendReleaseRunId: required("GITHUB_RUN_ID"), frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"), buildId: required("RELEASE_BUILD_ID"), livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"), liveCredentialedGate: credentialedGate(), releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"), serverVersionUpdated: true, serverVersionReadBack: "passed", completedAt: new Date().toISOString(), }; writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 }); NODE env: TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }} RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }} RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }} LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }} LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }} LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }} RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }} - name: Publish verified frontend release proof if: steps.branch-head.outputs.current == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: frontend-release-proof-${{ env.RELEASE_COMMIT_SHA }} path: output/frontend-release-proof/frontend-release-proof.json if-no-files-found: error retention-days: 30 - name: Roll back after any post-deployment verification failure if: >- failure() && steps.branch-head.outputs.current == 'true' && steps.deploy.outcome == 'success' timeout-minutes: 10 shell: bash run: | set -euo pipefail node scripts/release/deploy-cpanel.mjs --rollback [[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]] export RELEASE_VERSION="${BASH_REMATCH[1]}" export RELEASE_BUILD_ID="${BASH_REMATCH[2]}" npm run release:update-server-version env: NODE_OPTIONS: --use-system-ca RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }} PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }} PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }} PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }} PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }} PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_VERSION_UPDATE_REQUIRED: "true" - name: Upload Playwright report if: failure() && steps.branch-head.outputs.current == 'true' continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }} path: output/playwright if-no-files-found: ignore retention-days: 14