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
72 changes: 13 additions & 59 deletions .github/workflows/deploy-cloudflare-bridge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ jobs:

jq --raw-output '
.result as $project |
"Pages project state: name=\($project.name), production_branch=\($project.production_branch), subdomain=\($project.subdomain), custom_domain_count=\($project.domains // [] | map(select(. != $project.subdomain)) | length), source=\(if $project.source == null then "direct-upload" else $project.source.type end), web_analytics=\(if ($project.build_config.web_analytics_tag // "") == "" and ($project.build_config.web_analytics_token // "") == "" then "disabled" else "enabled" end)"
"Pages project state: name=\($project.name), production_branch=\($project.production_branch), subdomain=\($project.subdomain), custom_domains=\($project.domains // [] | map(select(. != $project.subdomain)) | sort | join(",")), source=\(if $project.source == null then "direct-upload" else $project.source.type end), web_analytics=\(if ($project.build_config.web_analytics_tag // "") == "" and ($project.build_config.web_analytics_token // "") == "" then "disabled" else "enabled" end)"
' <<< "$project"

jq --exit-status '
Expand All @@ -109,16 +109,16 @@ jobs:
$project.name == "secure-tools-web-bridge" and
$project.production_branch == "main" and
$project.subdomain == "secure-tools-web-bridge.pages.dev" and
($project.domains // [] | map(select(. != $project.subdomain)) | length) == 0 and
($project.domains // [] | map(select(. != $project.subdomain)) | sort) == ["tools.securetools.app"] and
$project.source == null and
($project.build_config.web_analytics_tag // "") == "" and
($project.build_config.web_analytics_token // "") == ""
' <<< "$project" > /dev/null || {
echo "::error::Cloudflare Pages project is missing or violates the H3.2 isolation contract"
echo "::error::Cloudflare Pages project is missing or violates the H3.3 parallel-domain contract"
exit 1
}

echo "Validated Direct Upload project secure-tools-web-bridge: production branch main, stable pages.dev subdomain only, zero custom domains, no Git integration, no Web Analytics."
echo "Validated Direct Upload project secure-tools-web-bridge: production branch main, stable pages.dev subdomain plus tools.securetools.app, no Git integration, no Web Analytics."

- name: Deploy to Cloudflare Pages
id: deploy
Expand All @@ -131,61 +131,15 @@ jobs:
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
run: node tests/deployment-smoke.mjs "$DEPLOYMENT_URL"

[[ "$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
}
- name: Validate stable bridge alias
run: node tests/deployment-smoke.mjs https://secure-tools-web-bridge.pages.dev

- name: Validate tools custom domain
run: node tests/deployment-smoke.mjs https://tools.securetools.app

- name: Validate existing GitHub Pages production
run: node tests/deployment-smoke.mjs https://securetools.app production
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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 |
| [Cloudflare Pages migration bridge](./cloudflare-pages-bridge.md) | H3.2 Direct Upload provenance plus H3.3 parallel-domain activation, validation, SEO isolation, and rollback contracts |
| [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
92 changes: 65 additions & 27 deletions docs/cloudflare-pages-bridge.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
# 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.
Status: H3.2 is deployed and validated. H3.3 preparation targets a parallel `tools.securetools.app` custom domain, but the preparation pull request must remain unmerged until the activation gate below is satisfied. H3.3 does not authorize an apex 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` |
| Stable validation URL | `https://secure-tools-web-bridge.pages.dev` |
| Deployment mechanism | GitHub Actions Direct Upload through Wrangler |
| Custom domains | None |
| Current custom domains | None before H3.3 activation |
| H3.3 target custom domain | `https://tools.securetools.app` |

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.
The stable Pages hostname is a validated application endpoint and must remain available throughout H3.3 as the rollback and comparison endpoint. `securetools.app` and `www.securetools.app` remain outside the Pages project.

The H3.3 domain contract is:

```text
tools.securetools.app
→ Cloudflare Pages project secure-tools-web-bridge

secure-tools-web-bridge.pages.dev
→ retained validation and rollback endpoint

securetools.app
→ unchanged GitHub Pages production deployment
```

## Provenance and isolation

Expand All @@ -34,53 +48,77 @@ Secure_Tools main repository root + CNAME
→ 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:
The workflow copies only application files to `${{ runner.temp }}/secure-tools-web-bridge`. It 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.
The source artifact therefore retains its current canonical, Open Graph, sitemap, robots, and GitHub Pages behavior. The Pages bridge and H3.3 custom domain remain accessible for QA while their Cloudflare static responses instruct crawlers not to index or follow them.

## Credentials and project contract

## Required one-time setup
The required GitHub Actions secret names are:

Before this workflow can safely merge and run on `main`:
- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_ACCOUNT_ID`

1. Create the Direct Upload Pages project `secure-tools-web-bridge` with production branch `main`, for example with an authenticated Wrangler session:
Their values remain secret and must never enter source, logs, pull-request text, or untrusted workflows. The token remains limited to the intended Cloudflare account and required Pages permissions.

```text
npx wrangler@4 pages project create secure-tools-web-bridge --production-branch main
```
After H3.3 activation, every deployment queries the authenticated Pages project state and requires the expected name, `main` production branch, stable Pages subdomain, exactly the `tools.securetools.app` custom domain, no Git integration, and no Cloudflare Web Analytics configuration.

2. Add these GitHub Actions repository secrets to `SecureToolsProject/Secure_Tools`:
## H3.3 manual browser QA gate

- `CLOUDFLARE_API_TOKEN`
- `CLOUDFLARE_ACCOUNT_ID`
Do not attach the custom domain until a human has completed all of these checks against `https://secure-tools-web-bridge.pages.dev`:

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.
- one representative PDF operation;
- one representative image operation;
- one representative metadata operation; and
- browser Network-panel confirmation that selected user files are not uploaded.

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.
Static and contract tests do not satisfy this manual gate. Record the human result on the H3.3 issue before activation.

## Deployment validation
## Custom-domain activation

After the manual gate has explicit evidence, use this exact order:

1. In Cloudflare, open **Workers & Pages → `secure-tools-web-bridge` → Custom domains → Set up a domain**.
2. Enter `tools.securetools.app`, continue, and activate it through the Pages project.
3. Because `securetools.app` is already a Cloudflare-managed zone, allow Cloudflare Pages to create and manage the associated `tools` DNS record.
4. Wait until the Pages custom domain reports active and its TLS certificate is valid.
5. Validate HTTPS, all 19 routes, representative assets, exact `X-Robots-Tag: noindex, nofollow`, and the existing production SEO inventory with `node tests/deployment-smoke.mjs https://tools.securetools.app`.
6. Revalidate `secure-tools-web-bridge.pages.dev` and `securetools.app`, then merge the prepared pull request. The resulting `main` deployment continuously validates the immutable deployment, stable Pages alias, custom domain, and existing production isolation.

Do not manually create a Pages-target CNAME before associating the hostname with the Pages project. Do not change apex or `www` records. Cloudflare may represent the managed record internally; the invariant is that `tools.securetools.app` resolves through `secure-tools-web-bridge` while apex records remain unchanged.

## Continuous 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;
5. authenticated verification of project identity, production branch, custom-domain contract, 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.
7. the shared deployment smoke contract against the immutable deployment URL, stable Pages alias, H3.3 custom domain, and existing GitHub Pages production;
8. HTTP 200 without redirects for all 19 H3.1 routes on each endpoint;
9. representative CSS, JavaScript, icon, and vendored-library checks;
10. exact `X-Robots-Tag: noindex, nofollow` on Pages routes and assets, plus confirmation that production does not inherit that bridge-only header; and
11. canonical and `og:url` values that intentionally continue to identify `https://securetools.app/...`, while the legacy `/tools/image-to-pdf/` alias retains its existing source-level noindex and stays outside the canonical inventory.

Existing static tests continue to cover representative PDF, image, metadata, privacy, local-processing, dependency-integrity, CSP, and network invariants.

During H3.3, both Pages hostnames remain non-indexable while canonical and `og:url` metadata continue to identify the existing production host. `tools.securetools.app` must not be added to production sitemap files. No Search Console operation belongs to H3.3.

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.
## H3.3 rollback

## Rollback and removal
If `tools.securetools.app` is unhealthy after activation:

The bridge is additive. A failed bridge deployment does not require a production rollback because GitHub Pages remains the production origin.
1. remove the `tools` DNS record associated with Pages if Cloudflare does not remove it as part of detachment;
2. detach `tools.securetools.app` from the Pages project's Custom domains configuration;
3. confirm `secure-tools-web-bridge.pages.dev` remains healthy and no custom-domain record remains; and
4. leave `securetools.app`, its root `CNAME`, GitHub Pages, apex and `www` DNS, Search Console, redirects, sitemap, and production metadata untouched.

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.
The bridge is additive, so rollback never requires a change to the existing production site. H3.3 does not move the apex, create legacy redirects, begin H3.4, or change application behavior.
23 changes: 17 additions & 6 deletions tests/cloudflare-bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,34 @@ assert.match(workflow, /pages deploy .* --project-name=secure-tools-web-bridge -
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, /\$project\.domains \/\/ \[\] \| map\(select\(\. != \$project\.subdomain\)\) \| length/);
assert.match(workflow, /\["tools\.securetools\.app"\]/);
assert.match(workflow, /\$project\.source == null/);
assert.match(workflow, /web_analytics_tag/);
assert.match(workflow, /web_analytics_token/);
assert.match(workflow, /Pages project state:/);
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/);
assert.match(workflow, /node tests\/deployment-smoke\.mjs "\$DEPLOYMENT_URL"/);
assert.match(workflow, /node tests\/deployment-smoke\.mjs https:\/\/secure-tools-web-bridge\.pages\.dev/);
assert.match(workflow, /node tests\/deployment-smoke\.mjs https:\/\/tools\.securetools\.app/);
assert.match(workflow, /node tests\/deployment-smoke\.mjs https:\/\/securetools\.app production/);

const routeLines = workflow.match(/^\s{12}\/(?:$|[^/].*\/$)/gm) || [];
assert.equal(routeLines.length, 19, "the workflow must validate all 19 H3.1 routes");
const deploymentSmoke = fs.readFileSync("tests/deployment-smoke.mjs", "utf8");
const routeLines = deploymentSmoke.match(/^ "\/(?:"|[^"].*\/"),$/gm) || [];
assert.equal(routeLines.length, 19, "deployment smoke must validate all 19 H3.1 routes");
assert.match(deploymentSmoke, /redirect: "manual"/);
assert.match(deploymentSmoke, /\["bridge", "production"\]/);
assert.match(deploymentSmoke, /"x-robots-tag"/);
assert.match(deploymentSmoke, /"noindex, nofollow"/);
assert.match(deploymentSmoke, /new URL\("https:\/\/securetools\.app"\)/);
assert.match(deploymentSmoke, /canonicalExcludedRoutes = new Set\(\["\/tools\/image-to-pdf\/"\]\)/);
assert.match(deploymentSmoke, /"canonical"/);
assert.match(deploymentSmoke, /"og:url"/);

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");
assert.ok(!fs.existsSync("_redirects"), "H3 must not add production redirects");

console.log("Cloudflare bridge workflow contract checks passed.");
Loading
Loading