Files
pleno-vue/.github/workflows/release.yml
T
Jeppe B 468d436d3e Harden atomic cPanel release recovery (#238)
Adds exact-SHA cPanel release proof v2, atomic rollback restoration, and protected recovery gating.
2026-07-29 20:30:34 +02:00

452 lines
20 KiB
YAML

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_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})-[A-Za-z0-9._-]+/dist$ ]]
export RELEASE_VERSION="${BASH_REMATCH[1]}"
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_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