Add atomic cPanel frontend deployment (#178)

Build and archive the tested frontend, upload it through dedicated FTPS credentials, and atomically activate it through cPanel after CI succeeds.
This commit is contained in:
Jeppe B
2026-07-20 13:53:49 +02:00
committed by GitHub
parent 6b8f3ff806
commit a930bd35a5
17 changed files with 3665 additions and 106 deletions
+99
View File
@@ -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 <recovery> TO <webroot> STATE <state-token> exactly.
required: false
type: string
permissions:
contents: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
audit-or-restore:
runs-on: 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
+229 -79
View File
@@ -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