diff --git a/.github/workflows/deploy-cloudflare-bridge.yml b/.github/workflows/deploy-cloudflare-bridge.yml new file mode 100644 index 0000000..43aca28 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-bridge.yml @@ -0,0 +1,185 @@ +name: Deploy Cloudflare bridge + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + deployments: write + +concurrency: + group: web-utilities-cloudflare-bridge + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Web Utilities bridge + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + # actions/checkout v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run static tool validation + run: node tests/run-all.mjs + + - name: Validate bridge prerequisites + shell: bash + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + missing=() + [[ -n "$CLOUDFLARE_API_TOKEN" ]] || missing+=(CLOUDFLARE_API_TOKEN) + [[ -n "$CLOUDFLARE_ACCOUNT_ID" ]] || missing+=(CLOUDFLARE_ACCOUNT_ID) + + if (( ${#missing[@]} > 0 )); then + printf '::error::Missing required GitHub Actions secret: %s\n' "${missing[@]}" + exit 1 + fi + + [[ "$( "$BRIDGE_DIRECTORY/_headers" + + [[ ! -e "$BRIDGE_DIRECTORY/CNAME" ]] + [[ ! -e "$BRIDGE_DIRECTORY/_redirects" ]] + [[ ! -e "$BRIDGE_DIRECTORY/_worker.js" ]] + [[ ! -d "$BRIDGE_DIRECTORY/functions" ]] + [[ "$(find "$BRIDGE_DIRECTORY" -name index.html -type f | wc -l)" -eq 19 ]] + + - name: Validate Cloudflare Pages project isolation + shell: bash + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + project="$(curl --fail --silent --show-error \ + "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/secure-tools-web-bridge" \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}")" + + jq --exit-status ' + .success == true and + .result.name == "secure-tools-web-bridge" and + .result.production_branch == "main" and + .result.subdomain == "secure-tools-web-bridge.pages.dev" and + (.result.domains | length) == 0 and + .result.source == null and + (.result.build_config.web_analytics_tag // "") == "" and + (.result.build_config.web_analytics_token // "") == "" + ' <<< "$project" > /dev/null || { + echo "::error::Cloudflare Pages project is missing or violates the H3.2 isolation contract" + exit 1 + } + + echo "Validated Direct Upload project secure-tools-web-bridge: production branch main, zero custom domains, no Git integration, no Web Analytics." + + - name: Deploy to Cloudflare Pages + id: deploy + # cloudflare/wrangler-action v4.0.0 + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + command: pages deploy "${{ runner.temp }}/secure-tools-web-bridge" --project-name=secure-tools-web-bridge --branch=main --commit-hash=${{ github.sha }} + + - name: Validate deployed bridge + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + run: | + set -euo pipefail + + [[ "$DEPLOYMENT_URL" == https://*.pages.dev ]] || { + echo "::error::Wrangler did not return a Pages deployment URL" + exit 1 + } + DEPLOYMENT_URL="${DEPLOYMENT_URL%/}" + + routes=( + / + /privacy/ + /about/ + /tools/pdf/ + /tools/pdf/images-to-pdf/ + /tools/pdf/merge/ + /tools/pdf/split/ + /tools/pdf/organize/ + /tools/pdf/to-images/ + /tools/pdf/metadata/ + /tools/image/ + /tools/image/converter/ + /tools/image/resize/ + /tools/image/compress/ + /tools/image/metadata/ + /tools/privacy/ + /tools/scan/ + /tools/media/ + /tools/image-to-pdf/ + ) + + for route in "${routes[@]}"; do + status="$(curl --silent --show-error --output /dev/null --max-redirs 0 --write-out '%{http_code}' "${DEPLOYMENT_URL}${route}")" + [[ "$status" == 200 ]] || { + echo "::error::Bridge route ${route} returned HTTP ${status}" + exit 1 + } + done + + for asset in \ + /css/base.css \ + /css/components.css \ + /css/pages.css \ + /js/theme-bootstrap.js \ + /js/main.js \ + /assets/icons/favicon.ico \ + /assets/vendor/pdf-lib/pdf-lib.min.js; do + curl --fail --silent --show-error --output /dev/null "${DEPLOYMENT_URL}${asset}" + done + + headers="$(curl --fail --silent --show-error --head "${DEPLOYMENT_URL}/" | tr -d '\r')" + grep -Eiq '^x-robots-tag: *noindex, *nofollow$' <<< "$headers" || { + echo "::error::Bridge root is missing X-Robots-Tag: noindex, nofollow" + exit 1 + } diff --git a/docs/README.md b/docs/README.md index 80bf9c3..8d3da6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det | [Dependencies](./dependencies.md) | Production runtime inventory, versions, vendoring, licenses, and integrity ownership | | [Tool status](./tool-status.md) | Production and planned surfaces, supported formats, behavior, and resource boundaries | | [Search discovery and metadata](./seo.md) | Canonical routes, crawler files, metadata policy, maintenance, and submission steps | +| [Cloudflare Pages migration bridge](./cloudflare-pages-bridge.md) | H3.2 Direct Upload provenance, indexing isolation, activation prerequisites, validation, and removal | | [Image Metadata privacy](./image-metadata-privacy.md) | Format-specific inspection, cleaning, preservation, and verification semantics | | [UX consistency audit](./ux-consistency-audit.md) | Shared interaction, accessibility, responsive, theme, and historical browser-QA findings | | [i18n copy review](./i18n-copy-review.md) | Localization coverage and editorial review record | diff --git a/docs/cloudflare-pages-bridge.md b/docs/cloudflare-pages-bridge.md new file mode 100644 index 0000000..fd7114c --- /dev/null +++ b/docs/cloudflare-pages-bridge.md @@ -0,0 +1,86 @@ +# Cloudflare Pages migration bridge + +Status: activation prerequisites confirmed on 2026-09-01; the first `main` deployment is pending merge of the reviewed workflow. This document does not authorize H3.3 or a custom-domain migration. + +## Deployment identity + +| Item | Value | +| --- | --- | +| Cloudflare Pages project | `secure-tools-web-bridge` | +| Production branch | `main` | +| Stable validation URL after activation | `https://secure-tools-web-bridge.pages.dev` | +| Deployment mechanism | GitHub Actions Direct Upload through Wrangler | +| Custom domains | None | + +The project hostname resolves, but it is not a validated application endpoint until the first deployment succeeds. No `securetools.app`, `www.securetools.app`, or `tools.securetools.app` custom domain may be attached during H3.2. + +## Provenance and isolation + +The bridge workflow is `.github/workflows/deploy-cloudflare-bridge.yml`: + +```text +Secure_Tools main +→ GitHub Actions validation +→ temporary bridge artifact +→ GitHub Deployment +→ Cloudflare Pages Direct Upload +``` + +The existing GitHub Pages production path remains independent: + +```text +Secure_Tools main repository root + CNAME +→ GitHub Pages +→ https://securetools.app +``` + +The workflow copies only application files to `${{ runner.temp }}/secure-tools-web-bridge`. It deliberately excludes the repository `CNAME` and injects this bridge-only file into that temporary directory: + +```text +/* + X-Robots-Tag: noindex, nofollow +``` + +The source artifact therefore retains its current canonical, Open Graph, sitemap, robots, and GitHub Pages behavior. The deployed bridge remains accessible for QA while its Cloudflare static responses instruct crawlers not to index or follow it. + +## Required one-time setup + +Before this workflow can safely merge and run on `main`: + +1. Create the Direct Upload Pages project `secure-tools-web-bridge` with production branch `main`, for example with an authenticated Wrangler session: + + ```text + npx wrangler@4 pages project create secure-tools-web-bridge --production-branch main + ``` + +2. Add these GitHub Actions repository secrets to `SecureToolsProject/Secure_Tools`: + + - `CLOUDFLARE_API_TOKEN` + - `CLOUDFLARE_ACCOUNT_ID` + +3. Limit the token to the intended Cloudflare account with only **Account → Cloudflare Pages → Edit**. No zone or DNS permission is required for this bridge. Do not reuse or expose a token value through source, logs, pull-request text, or untrusted workflows. +4. Confirm the project has no custom domains before the first deployment. + +Both required secret names and the Direct Upload project were provisioned on 2026-09-01. Before every deployment, the workflow queries the authenticated Pages project state and requires the expected name, `main` production branch, stable Pages subdomain, zero custom domains, no Git integration, and no Cloudflare Web Analytics configuration. + +## Deployment validation + +Every `main` push and optional manual dispatch performs: + +1. the complete repository test suite; +2. an explicit secret-name prerequisite check; +3. creation of a temporary static artifact without `CNAME`, `_redirects`, Workers, or Pages Functions; +4. injection of the bridge-only `_headers` rule; +5. authenticated verification of project identity, production branch, custom-domain isolation, Direct Upload mode, and analytics isolation; +6. Direct Upload with source SHA and branch provenance; +7. HTTP 200 checks for all 19 H3.1 routes; +8. representative CSS, JavaScript, icon, and vendored-library checks; +9. verification of `X-Robots-Tag: noindex, nofollow` on the deployed root response. + +Existing static tests continue to cover the representative PDF, image, metadata, privacy, local-processing, dependency-integrity, CSP, and network invariants. Interactive browser QA is still required after the endpoint exists; static tests are not a substitute for rendered or Network-panel evidence. + +## Rollback and removal + +The bridge is additive. A failed bridge deployment does not require a production rollback because GitHub Pages remains the production origin. + +To stop bridge automation, disable the Cloudflare bridge workflow without changing the existing CI or GitHub Pages settings. After H3 migration no longer needs the bridge, remove its Pages project only after preserving any required deployment evidence. Removing the bridge must not delete or modify the root `CNAME`, GitHub Pages configuration, DNS, Search Console, redirects, or production metadata. diff --git a/tests/cloudflare-bridge.test.mjs b/tests/cloudflare-bridge.test.mjs new file mode 100644 index 0000000..6473323 --- /dev/null +++ b/tests/cloudflare-bridge.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const workflow = fs.readFileSync(".github/workflows/deploy-cloudflare-bridge.yml", "utf8"); + +assert.match(workflow, /^name: Deploy Cloudflare bridge$/m); +assert.match(workflow, /^\s{2}push:\s*$[\s\S]*?^\s{6}- main$/m); +assert.match(workflow, /^\s{2}workflow_dispatch:$/m); +assert.match(workflow, /permissions:\s*\n\s+contents: read\s*\n\s+deployments: write/); +assert.match(workflow, /run: node tests\/run-all\.mjs/); +assert.match(workflow, /secrets\.CLOUDFLARE_API_TOKEN/); +assert.match(workflow, /secrets\.CLOUDFLARE_ACCOUNT_ID/); +assert.match(workflow, /secure-tools-web-bridge/); +assert.match(workflow, /pages deploy .* --project-name=secure-tools-web-bridge --branch=main --commit-hash=\$\{\{ github\.sha \}\}/); +assert.match(workflow, /gitHubToken: \$\{\{ secrets\.GITHUB_TOKEN \}\}/); +assert.match(workflow, /X-Robots-Tag: noindex, nofollow/); +assert.match(workflow, /steps\.deploy\.outputs\.deployment-url/); +assert.match(workflow, /--max-redirs 0/); +assert.match(workflow, /api\.cloudflare\.com\/client\/v4\/accounts\/\$\{CLOUDFLARE_ACCOUNT_ID\}\/pages\/projects\/secure-tools-web-bridge/); +assert.match(workflow, /\(\.result\.domains \| length\) == 0/); +assert.match(workflow, /\.result\.source == null/); +assert.match(workflow, /web_analytics_tag/); +assert.match(workflow, /web_analytics_token/); +assert.match(workflow, /\[\[ ! -e "\$BRIDGE_DIRECTORY\/CNAME" \]\]/); +assert.match(workflow, /\[\[ ! -e "\$BRIDGE_DIRECTORY\/_redirects" \]\]/); +assert.doesNotMatch(workflow, /tools\.securetools\.app/); +assert.doesNotMatch(workflow, /securetools\.app\/tools/); + +const routeLines = workflow.match(/^\s{12}\/(?:$|[^/].*\/$)/gm) || []; +assert.equal(routeLines.length, 19, "the workflow must validate all 19 H3.1 routes"); + +assert.equal(fs.readFileSync("CNAME", "utf8").trim(), "securetools.app"); +assert.ok(!fs.existsSync("_headers"), "bridge headers must not enter the GitHub Pages artifact"); +assert.ok(!fs.existsSync("_redirects"), "H3.2 must not add production redirects"); + +console.log("Cloudflare bridge workflow contract checks passed."); diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 0b587ee..1b5fceb 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -44,6 +44,7 @@ for (const test of [ "tests/i18n-quality.test.mjs", "tests/ux-consistency.test.mjs", "tests/ci-foundation.test.mjs", + "tests/cloudflare-bridge.test.mjs", ]) { runNode([test], test); }