Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions .github/workflows/deploy-cloudflare-bridge.yml
Original file line number Diff line number Diff line change
@@ -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

[[ "$(<CNAME)" == "securetools.app" ]] || {
echo "::error file=CNAME::Existing GitHub Pages custom domain changed"
exit 1
}

- name: Prepare isolated bridge artifact
shell: bash
env:
BRIDGE_DIRECTORY: ${{ runner.temp }}/secure-tools-web-bridge
run: |
set -euo pipefail

mkdir -p "$BRIDGE_DIRECTORY"
cp -R \
404.html \
index.html \
about \
assets \
css \
js \
privacy \
robots.txt \
sitemap.xml \
tools \
"$BRIDGE_DIRECTORY/"

printf '/*\n X-Robots-Tag: noindex, nofollow\n' > "$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
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
86 changes: 86 additions & 0 deletions docs/cloudflare-pages-bridge.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions tests/cloudflare-bridge.test.mjs
Original file line number Diff line number Diff line change
@@ -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.");
1 change: 1 addition & 0 deletions tests/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading