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: [self-hosted, Linux, X64, default] 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 }} steps: - name: Check release commit is current id: branch-head uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 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@a0853c24544627f65ddf259abe73b1d18a591444 # v5 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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: [self-hosted, Linux, X64, default] 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 persist-credentials: false ref: ${{ env.RELEASE_COMMIT_SHA }} - name: Setup Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }} PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }} PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }} PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }} PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} RELEASE_GITHUB_REPOSITORY: ${{ github.repository }} RELEASE_GITHUB_TOKEN: ${{ github.token }} - 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 PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true" 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: 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_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }} PRODUCTION_CPANEL_USER: ${{ secrets.PRODUCTION_CPANEL_USER }} PRODUCTION_CPANEL_API_TOKEN: ${{ secrets.PRODUCTION_CPANEL_API_TOKEN }} PRODUCTION_CPANEL_API_URL: ${{ vars.PRODUCTION_CPANEL_API_URL }} PRODUCTION_CPANEL_PATH: ${{ vars.PRODUCTION_CPANEL_PATH }} PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} - name: Record Release Manager gate if: steps.branch-head.outputs.current == 'true' 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\":[\"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_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_VERSION: ${{ github.event.workflow_run.head_sha }} - 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 proof = { schemaVersion: 1, 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: "passed", liveCredentialedGate: "passed", releaseManagerGate: "passed", serverVersionUpdated: true, 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 }} - name: Publish verified frontend release proof if: steps.branch-head.outputs.current == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 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: Upload Playwright report if: failure() && steps.branch-head.outputs.current == 'true' continue-on-error: true uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }} path: output/playwright if-no-files-found: ignore retention-days: 14