You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GitHub Pages stays the route for everything public. What it cannot do on the org's Team plan is serve a site to members only: Pages access control needs Enterprise Cloud. The org has three concrete members-only needs that a build in Actions should be able to publish without a person in the loop: the projects dashboard's members view (QuantEcon/status-projects#23, piloted in QuantEcon/status-projects#35), grants and fundraising dashboards (QuantEcon/infrastructure#2), and periodic internal reports such as a monthly Jupyter Book. preview-cloudflare already deploys to the Cloudflare account, but it is PR-only: it refuses to run outside pull_request, always deploys to a pr-N branch alias, and its whole comment machinery assumes a PR. A production deploy to a gated hostname is a different action.
What the research settled
Target Workers static assets, not Pages. QuantEcon/infrastructure#4 already chose Workers for lecture previews (#145), because Cloudflare's investment moved there and Pages is in maintenance. For a private site Workers is also the simpler platform, for one reason: Access is a per-Worker setting. Workers & Pages → the Worker → Access tab, choose "All traffic" or "Previews only", and the application it creates covers the workers.dev hostname, every preview URL, and any custom domain attached to that Worker. The Pages equivalent ("Enable access policy") protects only the hashed preview URLs; gating the production *.pages.dev hostname needs the documented wildcard-removal workaround and a second policy, and a custom domain needs a third. There is also an account-wide "Protect all Workers" switch with all_workers / all_preview_workers destinations, but the same account will host public lecture previews, so per-Worker protection is the one to use.
Identity is GitHub org membership, with caveats to record before the pilot. Cloudflare Access takes GitHub as a login method (an OAuth App whose callback is https://<team>.cloudflareaccess.com/cdn-cgi/access/callback) and a policy rule on organisation or team membership. Session duration goes up to one month. Three things bite:
If the QuantEcon org has OAuth app access restrictions on, an owner must approve the OAuth App.Did not apply (2026-09-03). The restriction targets third-party apps; an OAuth App created under the organisation's own developer settings needs no approval step. Create it there rather than under a personal account.
A user who is denied and then joins the org stays denied until they revoke the app in their GitHub settings and log in again (documented Cloudflare behaviour). Did not reproduce on the path tested (2026-09-03) — access was restored on the next visit with no revocation. That path included an Access logout before the denial, so the behaviour is not disproven. A different edge did appear: on the first load after access is restored, cached Access redirects for style.css, app.js and the data files are served in place of the real assets, leaving a blank page. A plain refresh fixes it, but nothing tells the user that.
New Zero Trust accounts now default to Cloudflare account membership as the login method, not one-time PIN; the pilot removes that default so GitHub is the only method.
Decided 2026-09-02, amended 2026-09-03: GitHub is the only login method in the first phase, with instant authentication and one-month sessions, and a policy scoped to a named GitHub team — not organisation membership. Organisation membership was measured during the pilot at 82 accounts across 23 teams, including translation collaborators, reading-group participants and course teams, which is the wrong audience for a grants or fundraising dashboard. Cloudflare's GitHub Organization policy selector takes an organisation name plus an optional team name, so the narrowing costs nothing and keeps the original virtue of no allow-list to maintain. That is the lowest-friction route for the team (a credential everyone holds, no allow-list to maintain, one consent screen then nothing for a month). A second method for stakeholders outside the GitHub org is a per-application setting, deferred to QuantEcon/infrastructure#17; nothing in this action depends on it.
Limits are comfortable. Workers static assets: 20,000 files and 25 MiB per file per version on the free plan, 100,000 files on paid (wrangler ≥ 4.34.0). Zero Trust free tier: 50 users. The 1,000 most recent preview aliases are retained per Worker. Lecture-sized Jupyter Books are a few thousand files.
Alternatives are all paid. GitHub Pages access control needs Enterprise Cloud. Netlify password protection is Pro at $19 per member per month. Vercel password protection is a $150 per month add-on on Pro. Cloudflare Access plus Workers is free at this scale.
Proposed action
deploy-cloudflare: publish a built directory to a Cloudflare Worker as a production deployment, and prove it is gated.
Input
Purpose
cloudflare-api-token, cloudflare-account-id
as today; token scope Workers Scripts: Edit
worker-name
one Worker per site
account-subdomain
the account's workers.dev subdomain; needed to construct the URL (same reasoning as #131 and #145)
build-dir
directory to publish
alias (optional)
also upload the same build as a named preview alias, e.g. 2026-08, giving a permanent archive URL per month alongside the moving production URL
require-access (default true)
after deploying, request the production URL unauthenticated and fail the job unless it redirects to *.cloudflareaccess.com
Deploy step: write a minimal wrangler.jsonc into RUNNER_TEMP (name, compatibility_date, assets.directory, workers_dev: true, preview_urls: true), run wrangler deploy --config for production and wrangler versions upload --preview-alias when an alias is given. Outputs: deploy-url (https://{worker}.{subdomain}.workers.dev), alias-url when set, plus a job-summary line, since on push, schedule and workflow_dispatch there is no PR to comment on.
The require-access probe is the part worth building. A private report that is silently public is the failure mode this whole route exists to prevent, and the toggle lives in a dashboard nobody looks at after setup. A curl for a 302 to the team domain is a one-line check and turns "we think it is gated" into a green or red job.
Not provisioning Access from the action. The Access application is one-time per Worker and the identity provider is one-time per account. Doing either from CI would put an Access: Apps and Policies: Edit token into every consumer repo for a step that runs once. The README carries the setup checklist instead (the Terraform resources zero_trust_access_application and zero_trust_access_policy exist if the org ever wants the account under code).
What the pilot proved (2026-09-03)
QuantEcon/status-projects#35 ran end to end on a Worker gated to a named GitHub team. Four results bear directly on this action.
The require-access probe works, and its shape is settled. Request the production URL unauthenticated, require a 30x, and require the Location host to be the team's own login domain — not merely any *.cloudflareaccess.com. Asserting the specific team domain catches a Worker attached to the wrong Access organisation, which a wildcard match would pass. A 200 is the alarm case and should fail loudly, since it means the site is public.
Access survives a redeploy. Verified by deploying twice over the same Worker and re-probing. So the check is a regression guard rather than a necessity — still worth keeping, because it is one request and the failure it guards against is a private site silently going public.
The gate covers every path, not just the document root. The un-redacted data/latest.json returned the same redirect as /. Worth stating in the README, because a gate protecting only the entry point would be an easy and invisible mistake to assume.
Deploy ordering matters for a first deployment, and the action should encode it. The pilot deployed a placeholder holding no data under the real Worker name, proved the gate against it, and only then uploaded the real assets. The obvious ordering — deploy the content, then turn Access on — leaves private data on a public hostname for the length of the Access setup. For a first deployment, require-access failing after the content is already uploaded is a detection, not a prevention.
Sequencing
Run the QuantEcon/status-projects#35 pilot, revised 2026-09-02 to deploy a Worker rather than a Pages project, so it exercises the exact Access toggle and the unauthenticated-redirect probe this action will automate.
Build deploy-cloudflare as a sibling action, sharing the deploy script with the Workers preview path. First consumer: status-projects collect.yml, which already needs to redeploy after the data commit.
Open questions
Custom domain (dashboards.quantecon.org per QuantEcon/infrastructure#2) is a per-Worker setting the Access application picks up automatically; decide whether the action takes a custom-domain input or leaves domains to the dashboard.
Whether the status-projects members view and the internal reports share one Zero Trust application policy or need per-site policies.Answered 2026-09-03: per-site, team-scoped. Owner ruling in QuantEcon/status-projects#35 — access is a few named individuals, not the organisation member list. Reusable Access policies make this cheap: define one policy per audience under Access controls → Policies and reference it from each application, rather than restating the rule per Worker. The pilot's policy is QuantEcon Dashboards, QuantEcon + team dashboards, 3 accounts.
Why
GitHub Pages stays the route for everything public. What it cannot do on the org's Team plan is serve a site to members only: Pages access control needs Enterprise Cloud. The org has three concrete members-only needs that a build in Actions should be able to publish without a person in the loop: the projects dashboard's members view (QuantEcon/status-projects#23, piloted in QuantEcon/status-projects#35), grants and fundraising dashboards (QuantEcon/infrastructure#2), and periodic internal reports such as a monthly Jupyter Book.
preview-cloudflarealready deploys to the Cloudflare account, but it is PR-only: it refuses to run outsidepull_request, always deploys to apr-Nbranch alias, and its whole comment machinery assumes a PR. A production deploy to a gated hostname is a different action.What the research settled
Target Workers static assets, not Pages. QuantEcon/infrastructure#4 already chose Workers for lecture previews (#145), because Cloudflare's investment moved there and Pages is in maintenance. For a private site Workers is also the simpler platform, for one reason: Access is a per-Worker setting. Workers & Pages → the Worker → Access tab, choose "All traffic" or "Previews only", and the application it creates covers the
workers.devhostname, every preview URL, and any custom domain attached to that Worker. The Pages equivalent ("Enable access policy") protects only the hashed preview URLs; gating the production*.pages.devhostname needs the documented wildcard-removal workaround and a second policy, and a custom domain needs a third. There is also an account-wide "Protect all Workers" switch withall_workers/all_preview_workersdestinations, but the same account will host public lecture previews, so per-Worker protection is the one to use.Identity is GitHub org membership, with caveats to record before the pilot. Cloudflare Access takes GitHub as a login method (an OAuth App whose callback is
https://<team>.cloudflareaccess.com/cdn-cgi/access/callback) and a policy rule on organisation or team membership. Session duration goes up to one month. Three things bite:If the QuantEcon org has OAuth app access restrictions on, an owner must approve the OAuth App.Did not apply (2026-09-03). The restriction targets third-party apps; an OAuth App created under the organisation's own developer settings needs no approval step. Create it there rather than under a personal account.style.css,app.jsand the data files are served in place of the real assets, leaving a blank page. A plain refresh fixes it, but nothing tells the user that.Decided 2026-09-02, amended 2026-09-03: GitHub is the only login method in the first phase, with instant authentication and one-month sessions, and a policy scoped to a named GitHub team — not organisation membership. Organisation membership was measured during the pilot at 82 accounts across 23 teams, including translation collaborators, reading-group participants and course teams, which is the wrong audience for a grants or fundraising dashboard. Cloudflare's
GitHub Organizationpolicy selector takes an organisation name plus an optional team name, so the narrowing costs nothing and keeps the original virtue of no allow-list to maintain. That is the lowest-friction route for the team (a credential everyone holds, no allow-list to maintain, one consent screen then nothing for a month). A second method for stakeholders outside the GitHub org is a per-application setting, deferred to QuantEcon/infrastructure#17; nothing in this action depends on it.Limits are comfortable. Workers static assets: 20,000 files and 25 MiB per file per version on the free plan, 100,000 files on paid (wrangler ≥ 4.34.0). Zero Trust free tier: 50 users. The 1,000 most recent preview aliases are retained per Worker. Lecture-sized Jupyter Books are a few thousand files.
Alternatives are all paid. GitHub Pages access control needs Enterprise Cloud. Netlify password protection is Pro at $19 per member per month. Vercel password protection is a $150 per month add-on on Pro. Cloudflare Access plus Workers is free at this scale.
Proposed action
deploy-cloudflare: publish a built directory to a Cloudflare Worker as a production deployment, and prove it is gated.cloudflare-api-token,cloudflare-account-idworker-nameaccount-subdomainworkers.devsubdomain; needed to construct the URL (same reasoning as #131 and #145)build-diralias(optional)2026-08, giving a permanent archive URL per month alongside the moving production URLrequire-access(defaulttrue)*.cloudflareaccess.comDeploy step: write a minimal
wrangler.jsoncintoRUNNER_TEMP(name,compatibility_date,assets.directory,workers_dev: true,preview_urls: true), runwrangler deploy --configfor production andwrangler versions upload --preview-aliaswhen an alias is given. Outputs:deploy-url(https://{worker}.{subdomain}.workers.dev),alias-urlwhen set, plus a job-summary line, since onpush,scheduleandworkflow_dispatchthere is no PR to comment on.The
require-accessprobe is the part worth building. A private report that is silently public is the failure mode this whole route exists to prevent, and the toggle lives in a dashboard nobody looks at after setup. A curl for a 302 to the team domain is a one-line check and turns "we think it is gated" into a green or red job.Not provisioning Access from the action. The Access application is one-time per Worker and the identity provider is one-time per account. Doing either from CI would put an
Access: Apps and Policies: Edittoken into every consumer repo for a step that runs once. The README carries the setup checklist instead (the Terraform resourceszero_trust_access_applicationandzero_trust_access_policyexist if the org ever wants the account under code).What the pilot proved (2026-09-03)
QuantEcon/status-projects#35 ran end to end on a Worker gated to a named GitHub team. Four results bear directly on this action.
The
require-accessprobe works, and its shape is settled. Request the production URL unauthenticated, require a30x, and require theLocationhost to be the team's own login domain — not merely any*.cloudflareaccess.com. Asserting the specific team domain catches a Worker attached to the wrong Access organisation, which a wildcard match would pass. A200is the alarm case and should fail loudly, since it means the site is public.Access survives a redeploy. Verified by deploying twice over the same Worker and re-probing. So the check is a regression guard rather than a necessity — still worth keeping, because it is one request and the failure it guards against is a private site silently going public.
The gate covers every path, not just the document root. The un-redacted
data/latest.jsonreturned the same redirect as/. Worth stating in the README, because a gate protecting only the entry point would be an easy and invisible mistake to assume.Deploy ordering matters for a first deployment, and the action should encode it. The pilot deployed a placeholder holding no data under the real Worker name, proved the gate against it, and only then uploaded the real assets. The obvious ordering — deploy the content, then turn Access on — leaves private data on a public hostname for the length of the Access setup. For a first deployment,
require-accessfailing after the content is already uploaded is a detection, not a prevention.Sequencing
deploy-cloudflarereuses whichever answer Add Workers static assets support to the Cloudflare preview action #145 gives.deploy-cloudflareas a sibling action, sharing the deploy script with the Workers preview path. First consumer: status-projectscollect.yml, which already needs to redeploy after the data commit.Open questions
dashboards.quantecon.orgper QuantEcon/infrastructure#2) is a per-Worker setting the Access application picks up automatically; decide whether the action takes acustom-domaininput or leaves domains to the dashboard.Whether the status-projects members view and the internal reports share one Zero Trust application policy or need per-site policies.Answered 2026-09-03: per-site, team-scoped. Owner ruling in QuantEcon/status-projects#35 — access is a few named individuals, not the organisation member list. Reusable Access policies make this cheap: define one policy per audience under Access controls → Policies and reference it from each application, rather than restating the rule per Worker. The pilot's policy isQuantEcon Dashboards,QuantEcon+ teamdashboards, 3 accounts.