diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4fa82600b..3c23c78a4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1100,7 +1100,7 @@ jobs:
# Only a stable release changes what /releases/latest resolves to, so a
# pre-release would rebuild the site to byte-identical output.
#
- # Dispatched against main on purpose: the github-pages environment only
+ # Dispatched against main on purpose: the website environment only
# permits `main` to deploy, so docs.yml's old `on: release` trigger ran
# with a tag ref and failed its deploy every time. See docs.yml.
if: ${{ steps.release.outputs.is_prerelease == 'false' }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 8565f14e9..3575c0249 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -18,8 +18,8 @@ on:
#
# That rebuild is a `workflow_dispatch` fired by build.yml once the release is
# published, NOT an `on: release` trigger. A release event runs with
- # github.ref = refs/tags/vX.Y.Z, and the github-pages environment only allows
- # `main` to deploy, so the deploy job failed on every stable release (it never
+ # github.ref = refs/tags/vX.Y.Z, and the deploy environment only allows `main`
+ # to deploy, so the deploy job failed on every stable release (it never
# surfaced earlier because pre-releases skipped the build entirely). Dispatching
# against main both satisfies that policy and publishes main's docs rather than
# the release branch's older snapshot.
@@ -119,33 +119,41 @@ jobs:
exit 1
fi
- name: Upload artifact
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+ name: website-build
path: website/build
- # v4 stopped bundling dotfiles by default. Nothing in website/build
- # is hidden today — the deployed artifact from 7c4827b9 was checked
- # and contains no dot-entries at all, so this changes nothing now
- # (Docusaurus only writes a `.nojekyll` on its own `deploy` command,
- # not in a plain `build`). It is set so that stays true by accident
- # rather than by omission: a `.well-known/` for domain verification
- # or security.txt is the realistic case, and under the v4+ default it
- # would vanish from the deploy silently. `.git` and `.github` are
- # excluded regardless of this flag.
+ retention-days: 7
+ # Off by default since v4. Nothing in website/build is hidden today,
+ # but a `.well-known/` for domain verification or security.txt is the
+ # realistic case, and it would vanish from the deploy silently.
include-hidden-files: true
+ # The site is a Cloudflare Worker with static assets only (website/wrangler.jsonc).
+ # The token lives in the `website` environment, which only `main` may deploy
+ # from, so neither a PR nor a dispatch on another branch can read it.
deploy:
- name: Deploy to GitHub Pages
+ name: Deploy to Cloudflare
runs-on: ubuntu-latest
needs: build
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|| (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- permissions:
- pages: write
- id-token: write
+ name: website
+ url: https://getopenscreen.com
steps:
- - id: deployment
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ sparse-checkout: website/wrangler.jsonc
+ sparse-checkout-cone-mode: false
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: website-build
+ path: website/build
+ - name: Deploy
+ working-directory: website
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ run: npx --yes wrangler@4.145.0 deploy
diff --git a/technical-documentation/engineering/ci-workflows.md b/technical-documentation/engineering/ci-workflows.md
index 2f1b61c7a..cc3ecea65 100644
--- a/technical-documentation/engineering/ci-workflows.md
+++ b/technical-documentation/engineering/ci-workflows.md
@@ -13,7 +13,7 @@ flowchart TD
CI --> BuildSmoke[Vite build]
CI --> Semantic[semantic PR title]
Docs["docs.yml website changes or dispatch"] --> DocsBuild[website build artifact]
- DocsBuild --> DocsDeploy[GitHub Pages deploy on main]
+ DocsBuild --> DocsDeploy[Cloudflare deploy on main]
end
subgraph T2["Tier 2 — native and release builds"]
@@ -138,4 +138,4 @@ Each workflow needs variables or credentials, and where it checks for them decid
Release platform artifacts have 30-day Actions retention. `publish-release` copies the NSIS installer, two architecture-specific DMGs when present, and Linux packages into the GitHub release. It intentionally does not download the independently uploaded Store artifact. A stable published release then fans out to Homebrew, WinGet, and AUR.
-Diagnostic artifacts remain Actions-only for 14 days. Whisper archives remain Actions-only for 30 days and are used as a binary-refresh output rather than being downloaded by the release workflow. The website build artifact flows only into GitHub Pages deployment.
+Diagnostic artifacts remain Actions-only for 14 days. Whisper archives remain Actions-only for 30 days and are used as a binary-refresh output rather than being downloaded by the release workflow. The website build artifact flows only into the Cloudflare deployment, which reads its token from the `website` environment, restricted to `main`.
diff --git a/website/.gitignore b/website/.gitignore
index 01c29630f..98f0dd340 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -5,6 +5,8 @@ node_modules/
build/
.docusaurus/
.cache-loader/
+# wrangler dev's local state
+.wrangler/
# Generated files
.bundle-stats
diff --git a/website/README.md b/website/README.md
index 3d6f41ac8..b9a5baf9f 100644
--- a/website/README.md
+++ b/website/README.md
@@ -1,13 +1,13 @@
# OpenScreen docs site
-Docusaurus 3 site. Lives in `website/` inside the monorepo. Deployed to GitHub Pages via `.github/workflows/docs.yml`.
+Docusaurus 3 site. Lives in `website/` inside the monorepo. Deployed to Cloudflare (a Worker serving static assets, `wrangler.jsonc`) via `.github/workflows/docs.yml`. `www-redirect/` is a separate Worker that sends www.getopenscreen.com to the apex; it is deployed by hand.
## Develop
```sh
cd website
npm install
-npm run dev # http://localhost:3000/openscreen/
+npm run dev # http://localhost:3000/
```
## Build
@@ -25,6 +25,4 @@ npm run typecheck
## Notes
-- Site URL:
-- The base URL is `/openscreen/` because the org-level Pages site hosts multiple projects.
-- Docs migration from `docs/` (root) → `website/docs/` happens in a follow-up PR. For now only the intro page is published.
\ No newline at end of file
+- Site URL: , served from the domain root (`baseUrl: "/"`).
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index 3ba10bfca..ee792e6d8 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -333,13 +333,13 @@ export default async function createConfig(): Promise {
tagline: "A free, open-source screen recorder and editor.",
favicon: "img/logo-icon.png",
- // Pages serves this from the custom domain's root, not from
- // getopenscreen.github.io/openscreen/, so baseUrl has to be "/" — a project
- // baseUrl would prefix every asset URL with a path the server has nothing at.
+ // Served from the custom domain's root, so baseUrl has to be "/": a
+ // project baseUrl would prefix every asset URL with a path the server has
+ // nothing at.
url: SITE_URL,
baseUrl: "/",
- // Every page is emitted as /index.html, and GitHub Pages 301s the
+ // Every page is emitted as /index.html, and Cloudflare redirects the
// extensionless form to the trailing-slash one. Leaving this unset makes
// Docusaurus advertise the pre-redirect URL in both
// and sitemap.xml, so every indexed URL costs a crawler an extra hop to a
@@ -439,11 +439,13 @@ export default async function createConfig(): Promise {
// for a renamed tag: a translated build has no blog, so that copy
// would be the only thing under /fr/blog/, a page nothing links to,
// redirecting a French URL to an English one. /fr/discord/ would be
- // a second, unlinked copy of the invite redirect, and crawlers read
- // llms.txt and robots.txt at the root only. The rest (img, video)
- // stays: pages resolve images through the locale's baseUrl.
+ // a second, unlinked copy of the invite redirect, crawlers read
+ // llms.txt and robots.txt at the root only, and Cloudflare reads
+ // _headers there only, serving any other copy as a public file. The
+ // rest (img, video) stays: pages resolve images through the locale's
+ // baseUrl.
if (LOCALE !== "en") {
- for (const entry of [BLOG_PATH, "discord", "llms.txt", "robots.txt"]) {
+ for (const entry of [BLOG_PATH, "discord", "llms.txt", "robots.txt", "_headers"]) {
await rm(path.join(outDir, entry), { recursive: true, force: true });
}
}
diff --git a/website/i18n/TRANSLATING.md b/website/i18n/TRANSLATING.md
index 009904e3d..1920df10d 100644
--- a/website/i18n/TRANSLATING.md
+++ b/website/i18n/TRANSLATING.md
@@ -6,7 +6,7 @@ The site is translated into `fr`, `es`, `pt-BR`, `ja`, `zh-CN`, `zh-TW` and `de`
## What is translated, and what is not
-- **Translated:** everything but the blog: the landing page, `/download/`, the docs, the comparison, platform and feature pages (`src/pages/**/*.mdx`), the navbar, the docs sidebar, the footer and the theme (404 page, pagination, "last updated"…). The translated 404 page only shows after a click inside the site: GitHub Pages answers every missing URL, `/fr/…` included, with the English one.
+- **Translated:** everything but the blog: the landing page, `/download/`, the docs, the comparison, platform and feature pages (`src/pages/**/*.mdx`), the navbar, the docs sidebar, the footer and the theme (404 page, pagination, "last updated"…). A missing URL under `//` gets that locale's 404 page.
- **English only:** the blog, a dated development journal. A translated build does not contain it, and links to it from translated pages go to the English blog (see below).
- **The build enforces it:** a locale missing any doc or `.mdx` page fails `npm run build`, because Docusaurus would otherwise publish the English file under `//` marked as your language.
diff --git a/website/static/_headers b/website/static/_headers
new file mode 100644
index 000000000..a796a253b
--- /dev/null
+++ b/website/static/_headers
@@ -0,0 +1,7 @@
+# Read by Cloudflare at the root of the deployed build (website/wrangler.jsonc).
+# Docusaurus puts a content hash in the name of every file under assets/, so a
+# cached copy can never go stale.
+/assets/*
+ Cache-Control: public, max-age=31536000, immutable
+/:locale/assets/*
+ Cache-Control: public, max-age=31536000, immutable
diff --git a/website/wrangler.jsonc b/website/wrangler.jsonc
new file mode 100644
index 000000000..3522ac9dd
--- /dev/null
+++ b/website/wrangler.jsonc
@@ -0,0 +1,21 @@
+// Cloudflare Workers config for getopenscreen.com, deployed by .github/workflows/docs.yml.
+// Assets only, no Worker script: Cloudflare serves static assets without counting
+// them against the Workers request quota.
+{
+ "name": "getopenscreen-website",
+ // An identifier, not a credential: the API token in CI is the secret.
+ "account_id": "49f0ef8f15d646deb80a7e7d675a4910",
+ "compatibility_date": "2026-10-01",
+ "assets": {
+ "directory": "./build",
+ // The nearest 404.html, so /fr/missing/ gets the French page, not the English one.
+ "not_found_handling": "404-page"
+ },
+ // A route on the proxied apex, not a custom domain: Cloudflare will not attach a
+ // custom domain over existing DNS records (error 100117). With a route, the move off
+ // GitHub Pages was only switching those records to proxied, with no gap.
+ "routes": [{ "pattern": "getopenscreen.com/*", "zone_name": "getopenscreen.com" }],
+ // The site has one address; a workers.dev copy would be a duplicate of it.
+ "workers_dev": false,
+ "preview_urls": false
+}
diff --git a/website/www-redirect/index.js b/website/www-redirect/index.js
new file mode 100644
index 000000000..a6d936df9
--- /dev/null
+++ b/website/www-redirect/index.js
@@ -0,0 +1,11 @@
+// www.getopenscreen.com only: every request goes to the same path on the apex, as
+// GitHub Pages did. Deployed by hand (`npx wrangler deploy` in this folder); it has no
+// reason to change with the site.
+export default {
+ fetch(request) {
+ const url = new URL(request.url);
+ url.protocol = "https:";
+ url.hostname = "getopenscreen.com";
+ return Response.redirect(url.toString(), 301);
+ },
+};
diff --git a/website/www-redirect/wrangler.jsonc b/website/www-redirect/wrangler.jsonc
new file mode 100644
index 000000000..3c86cb9ba
--- /dev/null
+++ b/website/www-redirect/wrangler.jsonc
@@ -0,0 +1,11 @@
+// Redirects www to the apex (index.js). The site itself is ../wrangler.jsonc.
+{
+ "name": "getopenscreen-www-redirect",
+ "account_id": "49f0ef8f15d646deb80a7e7d675a4910",
+ "main": "index.js",
+ "compatibility_date": "2026-10-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ // A route, like the site's: the www record only has to be proxied.
+ "routes": [{ "pattern": "www.getopenscreen.com/*", "zone_name": "getopenscreen.com" }]
+}