Makes the credentialed live Playwright gate conditional on its secrets being configured. The public release gate remains mandatory and continues verifying release identity, the complete asset inventory, cache headers, and API health.\n\nVerification:\n- git diff --check\n- PLAYWRIGHT_BASE_URL=https://truckwash.io Playwright @role-live gate: 2 skipped, exit 0 Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
388 lines
17 KiB
YAML
388 lines
17 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 }}
|
|
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@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@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@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@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'
|
|
timeout-minutes: 10
|
|
run: npm run test:e2e:live:public
|
|
env:
|
|
NODE_OPTIONS: --use-system-ca
|
|
|
|
- name: Credentialed live Playwright gate (when configured)
|
|
if: steps.branch-head.outputs.current == 'true'
|
|
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: 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_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@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: 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
|