diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 0fb0c156..1f462a85 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -142,13 +142,19 @@ jobs: const fs = require("node:fs"); const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8")); const checks = { - schema: proof.schemaVersion === 1, + schema: proof.schemaVersion === 2, repository: proof.repository === process.env.GITHUB_REPOSITORY, source: proof.sourceSha === process.env.IOS_SOURCE_SHA, + exactSource: proof.sha === process.env.IOS_SOURCE_SHA, + releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0, + archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""), + activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0, + verification: proof.verificationState === "verified", publicGate: proof.livePublicGate === "passed", - credentialedGate: proof.liveCredentialedGate === "passed", + credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate), managerGate: proof.releaseManagerGate === "passed", serverVersion: proof.serverVersionUpdated === true, + serverVersionReadBack: proof.serverVersionReadBack === "passed", }; const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label); if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`); diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml new file mode 100644 index 00000000..8fb9cc1e --- /dev/null +++ b/.github/workflows/release-recovery.yml @@ -0,0 +1,274 @@ +name: Frontend Release Recovery + +on: + workflow_dispatch: + inputs: + action: + description: Verify the active release or roll back before verification + required: true + type: choice + options: + - reverify + - rollback + source_sha: + description: Exact 40-character commit SHA expected after recovery + required: true + type: string + rollback_target: + description: Immutable releases/.../dist target; required for rollback + required: false + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: frontend-production + cancel-in-progress: false + +jobs: + recover: + name: Protected production recovery + runs-on: ubuntu-latest + environment: frontend-production + timeout-minutes: 35 + env: + PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + steps: + - name: Validate exact recovery target + shell: bash + env: + RECOVERY_ACTION: ${{ inputs.action }} + RECOVERY_SHA: ${{ inputs.source_sha }} + RECOVERY_TARGET: ${{ inputs.rollback_target }} + run: | + set -euo pipefail + [[ "$RECOVERY_SHA" =~ ^[a-f0-9]{40}$ ]] + if [[ "$RECOVERY_ACTION" == "rollback" ]]; then + [[ "$RECOVERY_TARGET" =~ ^releases/[A-Za-z0-9._-]+/dist$ ]] + else + [[ -z "$RECOVERY_TARGET" ]] + fi + + - name: Checkout exact recovery source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.source_sha }} + + - name: Authorize source from successful release proof + id: authorize + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_ACTION: ${{ inputs.action }} + RECOVERY_SHA: ${{ inputs.source_sha }} + RECOVERY_TARGET: ${{ inputs.rollback_target }} + run: | + set -euo pipefail + runs="$RUNNER_TEMP/recovery-runs.json" + artifacts="$RUNNER_TEMP/recovery-artifacts.json" + curl --fail --silent --show-error \ + -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?head_sha=$RECOVERY_SHA&status=success&per_page=20" \ + > "$runs" + release_run_id="$(jq -r '[.workflow_runs[] | select(.event == "workflow_run")] | first | .id // empty' "$runs")" + [[ "$release_run_id" =~ ^[0-9]+$ ]] + artifact_name="frontend-release-proof-$RECOVERY_SHA" + curl --fail --silent --show-error \ + -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$release_run_id/artifacts?name=$artifact_name&per_page=20" \ + > "$artifacts" + artifact_id="$(jq -r '[.artifacts[] | select(.expired == false)] | first | .id // empty' "$artifacts")" + [[ "$artifact_id" =~ ^[0-9]+$ ]] + mkdir -p "$RUNNER_TEMP/recovery-proof" + curl --fail --silent --show-error --location \ + -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" \ + -o "$RUNNER_TEMP/recovery-proof.zip" + unzip -q "$RUNNER_TEMP/recovery-proof.zip" -d "$RUNNER_TEMP/recovery-proof" + PROOF_PATH="$RUNNER_TEMP/recovery-proof/frontend-release-proof.json" \ + RELEASE_RUN_ID="$release_run_id" node <<'NODE' + const { appendFileSync, readFileSync } = require("node:fs"); + const proof = JSON.parse(readFileSync(process.env.PROOF_PATH, "utf8")); + const sha = process.env.RECOVERY_SHA; + const target = process.env.RECOVERY_TARGET; + const expectedPrefix = `releases/${sha}-`; + const valid = proof.schemaVersion === 2 + && proof.repository === process.env.GITHUB_REPOSITORY + && proof.sha === sha + && proof.sourceSha === sha + && proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID + && proof.verificationState === "verified" + && proof.livePublicGate === "passed" + && ["passed", "not-configured"].includes(proof.liveCredentialedGate) + && proof.releaseManagerGate === "passed" + && proof.serverVersionUpdated === true + && proof.serverVersionReadBack === "passed" + && typeof proof.activeTarget === "string" + && proof.activeTarget.startsWith(expectedPrefix) + && proof.activeTarget.endsWith("/dist"); + if (!valid) throw new Error("Recovery source does not have valid exact-release proof."); + if (process.env.RECOVERY_ACTION === "rollback" && target !== proof.activeTarget) { + throw new Error("Rollback target does not match the verified release proof."); + } + appendFileSync(process.env.GITHUB_OUTPUT, `verified_target=${proof.activeTarget}\n`); + NODE + + - name: Capture current immutable target + id: current + shell: bash + env: + FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + run: | + node --input-type=module <<'NODE' + import { appendFileSync } from "node:fs"; + const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), { + headers: { "Cache-Control": "no-cache", Pragma: "no-cache" }, + }); + if (!response.ok) throw new Error(`Active manifest returned HTTP ${response.status}.`); + const manifest = await response.json(); + const sha = String(manifest.commit_sha || "").toLowerCase(); + const build = String(manifest.build_id || ""); + if (!/^[a-f0-9]{40}$/.test(sha) || !/^[A-Za-z0-9._-]{1,180}$/.test(build)) { + throw new Error("Active manifest has invalid release identity."); + } + appendFileSync(process.env.GITHUB_OUTPUT, `previous_sha=${sha}\nprevious_target=releases/${sha}-${build}/dist\n`); + NODE + + - 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: Roll back atomically + if: inputs.action == 'rollback' + id: rollback + run: node scripts/release/deploy-cpanel.mjs --rollback + env: + NODE_OPTIONS: --use-system-ca + RELEASE_ROLLBACK_TARGET: ${{ inputs.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: Verify active manifest matches authorized release + shell: bash + env: + EXPECTED_SHA: ${{ inputs.source_sha }} + EXPECTED_TARGET: ${{ steps.authorize.outputs.verified_target }} + FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }} + run: | + node --input-type=module <<'NODE' + const deadline = Date.now() + 300_000; + let actual = ""; + while (Date.now() < deadline) { + const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), { + headers: { "Cache-Control": "no-cache", Pragma: "no-cache" }, + }); + if (response.ok) { + const manifest = await response.json(); + const manifestSha = String(manifest.commit_sha || "").toLowerCase(); + actual = `releases/${manifestSha}-${String(manifest.build_id || "")}/dist`; + if (manifestSha === process.env.EXPECTED_SHA && actual === process.env.EXPECTED_TARGET) process.exit(0); + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`Active release identity did not converge to the authorized target; observed ${actual || "unavailable"}.`); + NODE + + - name: Public live verification + run: npm run test:e2e:live:public + env: + NODE_OPTIONS: --use-system-ca + + - name: Credentialed live verification + 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: Record verified server version + run: npm run release:update-server-version + env: + SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }} + RELEASE_VERSION: ${{ inputs.source_sha }} + RELEASE_VERSION_UPDATE_REQUIRED: "true" + + - name: Publish recovery audit + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-release-recovery-${{ inputs.source_sha }}-${{ github.run_id }} + path: | + test-results + playwright-report + if-no-files-found: ignore + retention-days: 30 + + - name: Restore pre-recovery target after downstream failure + if: >- + failure() && inputs.action == 'rollback' + shell: bash + run: | + node scripts/release/deploy-cpanel.mjs --rollback + node --input-type=module <<'NODE' + const deadline = Date.now() + 300_000; + while (Date.now() < deadline) { + const response = await fetch(new URL(`release-manifest.json?restore=${Date.now()}`, process.env.PRODUCTION_FRONTEND_URL), { + headers: { "Cache-Control": "no-cache", Pragma: "no-cache" }, + }); + if (response.ok) { + const manifest = await response.json(); + const sha = String(manifest.commit_sha || "").toLowerCase(); + const target = `releases/${sha}-${String(manifest.build_id || "")}/dist`; + if (sha === process.env.RELEASE_VERSION && target === process.env.RELEASE_ROLLBACK_TARGET) process.exit(0); + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error("Failed to restore and verify the pre-recovery target."); + NODE + npm run release:update-server-version + env: + NODE_OPTIONS: --use-system-ca + RELEASE_ROLLBACK_TARGET: ${{ steps.current.outputs.previous_target }} + RELEASE_VERSION: ${{ steps.current.outputs.previous_sha }} + RELEASE_VERSION_UPDATE_REQUIRED: "true" + 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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d38aaa8..b3757c34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: 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 @@ -286,8 +287,28 @@ jobs: env: NODE_OPTIONS: --use-system-ca - - name: Credentialed live Playwright gate (when configured) + - 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 @@ -300,26 +321,9 @@ 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' && - (steps.public_live.outcome == 'failure' || steps.credentialed_live.outcome == 'failure') - 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 + id: release_manager if: steps.branch-head.outputs.current == 'true' - continue-on-error: true timeout-minutes: 5 run: | test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) @@ -341,6 +345,7 @@ jobs: 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' @@ -355,24 +360,52 @@ jobs: 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: 1, + 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: "passed", - liveCredentialedGate: "passed", - releaseManagerGate: "passed", + 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' @@ -383,6 +416,30 @@ jobs: 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 diff --git a/scripts/release/update-server-version.mjs b/scripts/release/update-server-version.mjs index b19d5422..ac111a5e 100644 --- a/scripts/release/update-server-version.mjs +++ b/scripts/release/update-server-version.mjs @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; function gitCommit() { try { @@ -8,9 +9,30 @@ function gitCommit() { } } -async function main() { - const token = process.env.SERVER_UPDATE_TOKEN; - const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true"; +async function boundedJson(response, limit = 64 * 1024) { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > limit) throw new Error("Server version response is too large."); + const reader = response.body?.getReader(); + const chunks = []; + let bytes = 0; + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > limit) { + await reader.cancel().catch(() => {}); + throw new Error("Server version response is too large."); + } + chunks.push(Buffer.from(value)); + } + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +export async function updateServerVersion(env = process.env, fetchImpl = globalThis.fetch) { + const token = env.SERVER_UPDATE_TOKEN; + const required = env.RELEASE_VERSION_UPDATE_REQUIRED === "true"; if (!token) { if (required) { throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification."); @@ -19,32 +41,70 @@ async function main() { return; } - const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit(); - if (!version) { - throw new Error("Could not determine release version for server update."); + const version = String(env.RELEASE_VERSION || env.RELEASE_EXPECTED_COMMIT || env.GITHUB_SHA || gitCommit()) + .toLowerCase(); + if (!/^[a-f0-9]{40}$/.test(version)) { + throw new Error("Server release version must be a full lowercase commit SHA."); } - const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version"; + const baseUrl = env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version"; const url = new URL(baseUrl); + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error("SERVER_UPDATE_URL must be an HTTPS URL without embedded credentials."); + } url.searchParams.set("version", version); - const response = await fetch(url, { + const response = await fetchImpl(url, { method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(30_000), headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-cache", }, }); - const body = await response.text(); + const body = await boundedJson(response).catch(() => ({})); if (!response.ok) { - throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`); + throw new Error(`Server version update failed with HTTP ${response.status}.`); } + const readUrl = new URL(env.SERVER_VERSION_READ_URL + || "https://api-v2.truckwash.io/master/api/worker/version"); + if (readUrl.protocol !== "https:" || readUrl.origin !== url.origin + || readUrl.username || readUrl.password || readUrl.search || readUrl.hash) { + throw new Error("SERVER_VERSION_READ_URL must be an exact HTTPS URL on the update origin."); + } + readUrl.searchParams.set("verify", `${Date.now()}`); + const verification = await fetchImpl(readUrl, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(30_000), + headers: { + Authorization: `Bearer ${token}`, + "Cache-Control": "no-cache", + Pragma: "no-cache", + }, + }); + if (!verification.ok) { + throw new Error(`Server version read-back failed with HTTP ${verification.status}.`); + } + const verified = await boundedJson(verification); + const observed = String(verified?.data?.version ?? verified?.version ?? "").toLowerCase(); + if (observed !== version) { + throw new Error(`Server version read-back did not match ${version}.`); + } console.log(`Server version updated to ${version}.`); + return { version, update: body, observed }; } -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : error); - process.exit(1); -}); +async function main() { + await updateServerVersion(); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : error); + process.exit(1); + }); +} diff --git a/tests/unit/cpanel-deploy.spec.js b/tests/unit/cpanel-deploy.spec.js index c0196e60..0bd09ad1 100644 --- a/tests/unit/cpanel-deploy.spec.js +++ b/tests/unit/cpanel-deploy.spec.js @@ -23,6 +23,7 @@ import { targetRelativeToRoot, validateReleaseTarget, } from "../../scripts/release/cpanel-deploy-lib.mjs"; +import { updateServerVersion } from "../../scripts/release/update-server-version.mjs"; const temporaryDirectories = []; const COMMIT_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; @@ -64,6 +65,89 @@ afterEach(async () => { ); }); +describe("Control Plane release evidence", () => { + it("publishes V2 exact-release identity after the live gates", async () => { + const workflow = await fs.readFile(path.resolve(".github/workflows/release.yml"), "utf8"); + expect(workflow).toContain("schemaVersion: 2"); + expect(workflow).toContain('releaseId: required("RELEASE_ID")'); + expect(workflow).toContain('sha: required("RELEASE_COMMIT_SHA").toLowerCase()'); + expect(workflow).toContain('archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase()'); + expect(workflow).toContain('activeTarget: required("RELEASE_ACTIVE_TARGET")'); + expect(workflow).toContain("rollbackTarget:"); + expect(workflow).toContain('verificationState: "verified"'); + expect(workflow).toContain('releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME")'); + expect(workflow).toContain('serverVersionReadBack: "passed"'); + expect(workflow).toContain('if (configured === "false") return "not-configured"'); + expect(workflow).toContain("steps.credentialed_live_config.outputs.configured == 'true'"); + expect(workflow).toContain("Roll back after any post-deployment verification failure"); + expect(workflow).toContain('RELEASE_VERSION_UPDATE_REQUIRED: "true"'); + expect(workflow).toContain("npm run release:update-server-version"); + expect(workflow).not.toContain("continue-on-error: true\n timeout-minutes: 5"); + expect(workflow.indexOf("Roll back after any post-deployment verification failure")).toBeGreaterThan( + workflow.indexOf("Publish verified frontend release proof") + ); + expect(workflow.indexOf("Create verified frontend release proof")).toBeGreaterThan( + workflow.indexOf("Credentialed live Playwright gate") + ); + const testflight = await fs.readFile(path.resolve(".github/workflows/ios-testflight.yml"), "utf8"); + expect(testflight).toContain("proof.schemaVersion === 2"); + expect(testflight).toContain("proof.sha === process.env.IOS_SOURCE_SHA"); + expect(testflight).toContain('proof.verificationState === "verified"'); + expect(testflight).toContain('proof.serverVersionReadBack === "passed"'); + expect(testflight).toContain('["passed", "not-configured"].includes(proof.liveCredentialedGate)'); + }); + + it("keeps rollback and re-verification behind the protected production environment", async () => { + const recovery = await fs.readFile(path.resolve(".github/workflows/release-recovery.yml"), "utf8"); + expect(recovery).toContain("environment: frontend-production"); + expect(recovery).toContain("group: frontend-production"); + expect(recovery).toContain("Authorize source from successful release proof"); + expect(recovery).toContain("proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID"); + expect(recovery).toContain("Rollback target does not match the verified release proof"); + expect(recovery).toContain("Verify active manifest matches authorized release"); + expect(recovery).toContain("Restore pre-recovery target after downstream failure"); + expect(recovery).toContain("node scripts/release/deploy-cpanel.mjs --rollback"); + expect(recovery).toContain('PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"'); + expect(recovery).toContain("RELEASE_VERSION: ${{ inputs.source_sha }}"); + expect(recovery).toContain("PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}"); + }); +}); + +describe("server version release proof", () => { + it("requires a token and independently reads back the exact full SHA", async () => { + const version = "a".repeat(40); + const calls = []; + const fetchImpl = vi.fn(async (url) => { + calls.push(String(url)); + return calls.length === 1 + ? new Response(JSON.stringify({ data: { accepted: true } })) + : new Response(JSON.stringify({ data: { version } })); + }); + const result = await updateServerVersion( + { + SERVER_UPDATE_TOKEN: "test-token", + RELEASE_VERSION_UPDATE_REQUIRED: "true", + RELEASE_VERSION: version, + SERVER_UPDATE_URL: "https://api.example.test/worker/update-version", + SERVER_VERSION_READ_URL: "https://api.example.test/worker/version", + }, + fetchImpl + ); + expect(result.observed).toBe(version); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain("/worker/version?verify="); + await expect( + updateServerVersion( + { + RELEASE_VERSION_UPDATE_REQUIRED: "true", + RELEASE_VERSION: version, + }, + fetchImpl + ) + ).rejects.toThrow("SERVER_UPDATE_TOKEN is required"); + }); +}); + describe("cPanel deployment configuration", () => { it("validates every credential without putting values in errors", () => { const secret = "never-print-this-token";