make-cloudflare 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
10
+
11
+ # wrangler caches its Pages upload state here (site/ is deployed by direct upload).
12
+ .wrangler/
13
+
14
+ # The VS Code extension in editors/vscode: `dist/` is above, and these two are what
15
+ # building a .vsix leaves behind. `npm install` is never run there -- the extension has
16
+ # no runtime dependency and both CLIs come from `npx --yes` -- so node_modules/ is
17
+ # listed to catch the day somebody tries.
18
+ node_modules/
19
+ *.vsix
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: make-cloudflare
3
+ Version: 0.1.0
4
+ Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
5
+ Project-URL: Homepage, https://make.optersoft.com
6
+ Author-email: "Optersoft, S.L." <david@optersoft.com>
7
+ License-Expression: MIT OR Apache-2.0
8
+ Keywords: cloudflare,deploy,direct upload,mk,mkrun,pages
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: blake3>=1.0
11
+ Requires-Dist: mkrun>=0.4.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # make-cloudflare
15
+
16
+ Cloudflare Pages **direct upload** for [`mkrun`](https://pypi.org/project/mkrun/), in
17
+ Python: publish a built directory with no Node on the machine and no `wrangler`
18
+ in the pipeline.
19
+
20
+ ```python
21
+ # Makefile.py in a consuming repo
22
+ # /// script
23
+ # requires-python = ">=3.11"
24
+ # dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
25
+ # ///
26
+ from make_cloudflare import cloudflare # importing is what registers the group
27
+ ```
28
+
29
+ ```console
30
+ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
31
+ $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
32
+ $ mk cloudflare.projects # what the account has
33
+ $ mk cloudflare.deployments --limit 5 # one project's recent ones
34
+ $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
35
+ ```
36
+
37
+ Credentials are the two variables wrangler already reads, so a repository that
38
+ deploys from CI needs no new secret: `CLOUDFLARE_ACCOUNT_ID` and
39
+ `CLOUDFLARE_API_TOKEN` (a token with **Cloudflare Pages: Edit**). The token's
40
+ name ends in `TOKEN`, so mkrun treats it as a credential — it is withheld from
41
+ the environment until a task asks for it, redacted in everything printed, and
42
+ readable from the encrypted store. The project can come from `--project` or from
43
+ `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
44
+
45
+ ## Why this exists
46
+
47
+ `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
48
+ `npx` costs a Node toolchain in repositories that otherwise have none — a Rust
49
+ one, a [frontage](https://github.com/optersoft/frontage) one — and a
50
+ `node_modules` in the deploy image of every one of them.
51
+
52
+ ```
53
+ POST /accounts/<acct>/pages/projects/<p>/upload-token -> a short-lived JWT
54
+ POST /pages/assets/check-missing -> what is not stored yet
55
+ POST /pages/assets/upload -> the files, batched
56
+ POST /pages/assets/upsert-hashes
57
+ POST /accounts/<acct>/pages/projects/<p>/deployments -> the manifest
58
+ ```
59
+
60
+ Three things make a naive port wrong, and all three fail **silently** — every
61
+ call returns 200 and the site serves the old bytes, or none:
62
+
63
+ - **The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
64
+ with the extension (no dot) appended, first 32 hex characters. Not of the
65
+ bytes, and not SHA-256. `tests/test_pages.py` locks it with a golden value.
66
+ - **`_headers`, `_redirects` and `_routes.json` are not assets.** They are
67
+ fields on the deployment. Walk them in as ordinary files and the deploy
68
+ reports success while the site's CSP stops applying.
69
+ - **`check-missing`, `upload` and `upsert-hashes` take the JWT, not the account
70
+ token, and carry no `/accounts/<id>` prefix** — the JWT already names the
71
+ account.
72
+
73
+ ## What it does not do
74
+
75
+ **Pages Functions.** A directory holding `_worker.js` or `functions/` is
76
+ refused by name rather than half-deployed: bundling a Worker is a build step,
77
+ not an upload, and wrangler should keep doing it.
78
+
79
+ **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
80
+ Cloudflare can change them without a changelog; the documented `deployments`
81
+ endpoint alone cannot upload a file. That is the real cost of dropping
82
+ wrangler, and it is the reason this is a small module with a golden test rather
83
+ than a wrapper nobody reads.
84
+
85
+ Nothing here is specific to any owner or account. The group name is
86
+ `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
87
+ namespaces — so only a same-named task collides; this package claims `deploy`,
88
+ `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
89
+ it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -0,0 +1,76 @@
1
+ # make-cloudflare
2
+
3
+ Cloudflare Pages **direct upload** for [`mkrun`](https://pypi.org/project/mkrun/), in
4
+ Python: publish a built directory with no Node on the machine and no `wrangler`
5
+ in the pipeline.
6
+
7
+ ```python
8
+ # Makefile.py in a consuming repo
9
+ # /// script
10
+ # requires-python = ">=3.11"
11
+ # dependencies = ["mkrun>=0.4", "make-cloudflare>=0.1"]
12
+ # ///
13
+ from make_cloudflare import cloudflare # importing is what registers the group
14
+ ```
15
+
16
+ ```console
17
+ $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
18
+ $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
19
+ $ mk cloudflare.projects # what the account has
20
+ $ mk cloudflare.deployments --limit 5 # one project's recent ones
21
+ $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
22
+ ```
23
+
24
+ Credentials are the two variables wrangler already reads, so a repository that
25
+ deploys from CI needs no new secret: `CLOUDFLARE_ACCOUNT_ID` and
26
+ `CLOUDFLARE_API_TOKEN` (a token with **Cloudflare Pages: Edit**). The token's
27
+ name ends in `TOKEN`, so mkrun treats it as a credential — it is withheld from
28
+ the environment until a task asks for it, redacted in everything printed, and
29
+ readable from the encrypted store. The project can come from `--project` or from
30
+ `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
31
+
32
+ ## Why this exists
33
+
34
+ `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
35
+ `npx` costs a Node toolchain in repositories that otherwise have none — a Rust
36
+ one, a [frontage](https://github.com/optersoft/frontage) one — and a
37
+ `node_modules` in the deploy image of every one of them.
38
+
39
+ ```
40
+ POST /accounts/<acct>/pages/projects/<p>/upload-token -> a short-lived JWT
41
+ POST /pages/assets/check-missing -> what is not stored yet
42
+ POST /pages/assets/upload -> the files, batched
43
+ POST /pages/assets/upsert-hashes
44
+ POST /accounts/<acct>/pages/projects/<p>/deployments -> the manifest
45
+ ```
46
+
47
+ Three things make a naive port wrong, and all three fail **silently** — every
48
+ call returns 200 and the site serves the old bytes, or none:
49
+
50
+ - **The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
51
+ with the extension (no dot) appended, first 32 hex characters. Not of the
52
+ bytes, and not SHA-256. `tests/test_pages.py` locks it with a golden value.
53
+ - **`_headers`, `_redirects` and `_routes.json` are not assets.** They are
54
+ fields on the deployment. Walk them in as ordinary files and the deploy
55
+ reports success while the site's CSP stops applying.
56
+ - **`check-missing`, `upload` and `upsert-hashes` take the JWT, not the account
57
+ token, and carry no `/accounts/<id>` prefix** — the JWT already names the
58
+ account.
59
+
60
+ ## What it does not do
61
+
62
+ **Pages Functions.** A directory holding `_worker.js` or `functions/` is
63
+ refused by name rather than half-deployed: bundling a Worker is a build step,
64
+ not an upload, and wrangler should keep doing it.
65
+
66
+ **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
67
+ Cloudflare can change them without a changelog; the documented `deployments`
68
+ endpoint alone cannot upload a file. That is the real cost of dropping
69
+ wrangler, and it is the reason this is a small module with a golden test rather
70
+ than a wrapper nobody reads.
71
+
72
+ Nothing here is specific to any owner or account. The group name is
73
+ `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
74
+ namespaces — so only a same-named task collides; this package claims `deploy`,
75
+ `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
76
+ it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "make-cloudflare"
3
+ version = "0.1.0"
4
+ description = "Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ # Same dual licence as mkrun, for the same reason: this member is generic and
8
+ # publishable. The licence texts live at the repository root; a built wheel
9
+ # carries the SPDX expression, which is what PEP 639 asks for.
10
+ license = "MIT OR Apache-2.0"
11
+ authors = [{ name = "Optersoft, S.L.", email = "david@optersoft.com" }]
12
+ keywords = ["cloudflare", "pages", "deploy", "direct upload", "mk", "mkrun"]
13
+ # blake3 is not optional and not replaceable: Cloudflare's asset store is keyed
14
+ # on it, so a deploy that hashes with anything else uploads files nothing will
15
+ # ever ask for. It lives here rather than in the runner precisely so `mkrun`
16
+ # itself stays dependency-free -- see this repo's CLAUDE.md, rule 4.
17
+ dependencies = ["mkrun>=0.4.1", "blake3>=1.0"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/make_cloudflare"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://make.optersoft.com"
28
+
29
+ # The runner is the other half of this repository, so resolve it from the
30
+ # working tree instead of PyPI, exactly as rust/ does: a change to `make.http`
31
+ # is testable against a real consumer in the same commit that makes it.
32
+ [tool.uv.sources]
33
+ mkrun = { workspace = true }
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8", "ruff>=0.6"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ addopts = "-q"
41
+
42
+ # No [tool.ruff] here on purpose: every member shares the root config, so they
43
+ # cannot drift apart by an isort option.
@@ -0,0 +1,35 @@
1
+ """Cloudflare tasks for mkrun -- the `cloudflare` group.
2
+
3
+ Today it is Pages direct upload: `cloudflare.deploy` publishes a built
4
+ directory, `cloudflare.projects` lists what the account has, and
5
+ `cloudflare.deployments` shows a project's recent ones. No Node, no wrangler --
6
+ four HTTPS calls and a BLAKE3 hash.
7
+
8
+ Importing is what registers it:
9
+
10
+ # Makefile.py
11
+ from make_cloudflare import cloudflare
12
+
13
+ The group merges with a repo's own `cloudflare.*` tasks -- groups are
14
+ namespaces, and this package claims only the three names above.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = ["cloudflare", "pages"]
22
+
23
+
24
+ def __getattr__(name: str):
25
+ """Import on first access, keeping module scope import-free.
26
+
27
+ The same shape as the other task packages: startup latency is a feature of
28
+ the tool this plugs into, so nothing heavy -- `blake3`, here -- may load
29
+ before it is asked for.
30
+ """
31
+ if name in __all__:
32
+ import importlib
33
+
34
+ return importlib.import_module(f".{name}", __name__)
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,52 @@
1
+ """The `cloudflare` group: deploy to Pages, and see what is there.
2
+
3
+ Nothing here knows whose account it runs in. The project name comes from the
4
+ task line or from `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer, and the
5
+ credentials come from `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` -- the
6
+ same two names wrangler reads, so a repository that already deploys through CI
7
+ needs no new secret.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ from make import arg, note, step, task
16
+
17
+ from . import pages
18
+
19
+
20
+ @task(group="cloudflare", name="deploy", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
21
+ def deploy(
22
+ directory: str = "dist",
23
+ *,
24
+ project: str | None = None,
25
+ branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
26
+ ) -> None:
27
+ """Publish a built directory to Cloudflare Pages by direct upload.
28
+
29
+ No Node and no wrangler: four HTTPS calls. `--branch` is what decides
30
+ production -- the project's production branch goes live, every other name
31
+ lands as a preview on its own URL.
32
+ """
33
+ url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch)
34
+ note(f"deployed -- {url}")
35
+
36
+
37
+ @task(group="cloudflare", name="projects", secrets=["CLOUDFLARE_API_TOKEN"])
38
+ def projects() -> None:
39
+ """List the account's Pages projects, and where each one deploys from."""
40
+ for project in pages.projects():
41
+ source = (project.get("source") or {}).get("type") or "direct upload"
42
+ domains = ", ".join(project.get("domains") or []) or "--"
43
+ step(f"{project.get('name', '?')} [{source}] {domains}")
44
+
45
+
46
+ @task(group="cloudflare", name="deployments", secrets=["CLOUDFLARE_API_TOKEN"])
47
+ def deployments(project: str | None = None, *, limit: int = 10) -> None:
48
+ """The most recent deployments of one project, newest first."""
49
+ for entry in pages.deployments(pages.project_name(project))[:limit]:
50
+ stage = (entry.get("latest_stage") or {}).get("status", "?")
51
+ env_name = entry.get("environment", "?")
52
+ step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
@@ -0,0 +1,422 @@
1
+ """Cloudflare Pages direct upload, in Python.
2
+
3
+ `wrangler pages deploy` is four HTTPS calls and a hash. This is those four
4
+ calls, so a repository with no Node -- a Rust one, a frontage one -- can publish
5
+ a built directory without installing a JavaScript toolchain to do it.
6
+
7
+ from make_cloudflare import pages
8
+
9
+ pages.deploy("site/dist", project="mkrun") # production
10
+ pages.deploy("site/dist", project="mkrun", branch="x") # a preview
11
+
12
+ The flow, and the three things a naive port gets wrong:
13
+
14
+ 1. `POST /accounts/<acct>/pages/projects/<p>/upload-token` -> a short-lived JWT.
15
+ 2. `POST /pages/assets/check-missing` (JWT) -> the hashes not already stored.
16
+ Note the missing `/accounts/<acct>` prefix on this call and the next: the
17
+ JWT carries the account, and adding the prefix 404s.
18
+ 3. `POST /pages/assets/upload` (JWT), then `upsert-hashes` -- in batches.
19
+ 4. `POST /accounts/<acct>/pages/projects/<p>/deployments` -> the manifest.
20
+
21
+ **The hash is BLAKE3 of a strange input**: the base64 *text* of the contents
22
+ with the extension (no dot) appended, first 32 hex characters. Not of the bytes,
23
+ not SHA-256. Get it wrong and every call still succeeds while the deployment
24
+ serves nothing.
25
+
26
+ **`_headers`, `_redirects` and `_routes.json` are not assets.** They are form
27
+ fields on the deployment, and a port that walks them in as ordinary files
28
+ reports success while the site's CSP quietly stops applying.
29
+
30
+ **Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
31
+ Cloudflare can change them without a changelog. The documented `deployments`
32
+ endpoint alone cannot upload a file.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import base64
38
+ import json
39
+ import mimetypes
40
+ import os
41
+ import secrets as _random
42
+ from collections.abc import Iterable, Iterator, Mapping
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from make import env, http, note, step
48
+ from make.context import mark_sensitive
49
+ from make.errors import ConfigError, MakeError
50
+
51
+ __all__ = [
52
+ "Asset",
53
+ "collect",
54
+ "deploy",
55
+ "deployments",
56
+ "file_hash",
57
+ "manifest_of",
58
+ "project_name",
59
+ "projects",
60
+ ]
61
+
62
+ API = "https://api.cloudflare.com/client/v4"
63
+
64
+ #: Pages' own limits. Both are refused by the API anyway; refusing here names
65
+ #: the offending file instead of failing the batch that happened to carry it.
66
+ MAX_ASSET_SIZE = 25 * 1024 * 1024
67
+ MAX_ASSET_COUNT = 20_000
68
+
69
+ #: One upload request. Cloudflare accepts more; these keep a single request
70
+ #: small enough to retry cheaply on a bad connection, which matters more than
71
+ #: shaving round trips off a deploy that happens by hand.
72
+ BATCH_FILES = 100
73
+ BATCH_BYTES = 10 * 1024 * 1024
74
+
75
+ #: Handled by Pages itself, as fields on the deployment -- never as assets.
76
+ SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
77
+
78
+ #: Never uploaded. `_worker.js` and `functions/` are Pages Functions, which this
79
+ #: module does not build: a directory that has them is a Functions project and
80
+ #: belongs on wrangler until someone needs it here.
81
+ SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
82
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
83
+ FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Asset:
88
+ """One file, as the manifest and the upload endpoint each want it."""
89
+
90
+ key: str
91
+ """Manifest path, with the leading slash Cloudflare requires."""
92
+ file: Path
93
+ hash: str
94
+ size: int
95
+ content_type: str
96
+
97
+
98
+ # -- hashing ---------------------------------------------------------------
99
+
100
+
101
+ def file_hash(data: bytes, extension: str = "") -> str:
102
+ """Cloudflare's asset key: `blake3(base64(data) + ext)` , first 32 hex chars.
103
+
104
+ `extension` is the suffix without its dot, and the empty string for a file
105
+ that has none. The base64 text is hashed, not the bytes -- that is not a
106
+ mistake in the reading of wrangler, it is what wrangler does, and the store
107
+ is keyed on the result.
108
+ """
109
+ from blake3 import blake3
110
+
111
+ payload = base64.b64encode(data) + extension.encode()
112
+ return blake3(payload).hexdigest()[:32]
113
+
114
+
115
+ # -- walking a built directory --------------------------------------------
116
+
117
+
118
+ def _walk(directory: Path) -> Iterator[Path]:
119
+ for parent, dirs, names in os.walk(directory):
120
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
121
+ for name in sorted(names):
122
+ if name not in SKIP_NAMES:
123
+ yield Path(parent) / name
124
+
125
+
126
+ def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
127
+ """Every uploadable file under `directory`, plus the special files' contents.
128
+
129
+ Returns `(assets, specials)`, where `specials` maps `_headers` /
130
+ `_redirects` / `_routes.json` to their text. They are deliberately not
131
+ assets: Pages takes them as fields on the deployment.
132
+ """
133
+ root = Path(directory).resolve()
134
+ if not root.is_dir():
135
+ raise MakeError(f"{root} is not a directory", hint="build the site first")
136
+
137
+ for name in FUNCTIONS:
138
+ if (root / name).exists():
139
+ raise MakeError(
140
+ f"{root / name} is a Pages Functions project",
141
+ hint="this module uploads static assets only -- deploy it with wrangler",
142
+ )
143
+
144
+ assets: list[Asset] = []
145
+ for file in _walk(root):
146
+ data = file.read_bytes()
147
+ if len(data) > MAX_ASSET_SIZE:
148
+ raise MakeError(
149
+ f"{file.relative_to(root)} is {len(data) / 1_048_576:.1f} MiB",
150
+ hint=f"Cloudflare Pages refuses any asset over {MAX_ASSET_SIZE // 1_048_576} MiB",
151
+ )
152
+ guessed, _ = mimetypes.guess_type(file.name)
153
+ assets.append(
154
+ Asset(
155
+ key="/" + file.relative_to(root).as_posix(),
156
+ file=file,
157
+ hash=file_hash(data, file.suffix.lstrip(".")),
158
+ size=len(data),
159
+ content_type=guessed or "application/octet-stream",
160
+ )
161
+ )
162
+
163
+ if not assets:
164
+ raise MakeError(f"{root} holds no files to upload")
165
+ if len(assets) > MAX_ASSET_COUNT:
166
+ raise MakeError(
167
+ f"{len(assets)} files under {root}",
168
+ hint=f"Cloudflare Pages takes at most {MAX_ASSET_COUNT} per deployment",
169
+ )
170
+
171
+ specials = {name: (root / name).read_text() for name in SPECIAL_FILES if (root / name).is_file()}
172
+ return assets, specials
173
+
174
+
175
+ def manifest_of(assets: Iterable[Asset]) -> dict[str, str]:
176
+ """The deployment manifest: `/path` -> hash."""
177
+ return {asset.key: asset.hash for asset in assets}
178
+
179
+
180
+ # -- the API ---------------------------------------------------------------
181
+
182
+
183
+ def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
184
+ # The layers first, as every task package that needs a credential does: without
185
+ # this, `require` sees only the process environment, and a machine whose
186
+ # `~/.make/secrets.env` already holds these two is told to set what it has set.
187
+ # The secret itself is still withheld from child processes until asked for --
188
+ # `layered()` holds sensitive values back by design.
189
+ if account is None or token is None:
190
+ env.layered()
191
+ account = account or env.require(
192
+ "CLOUDFLARE_ACCOUNT_ID", hint="the account id from any zone's overview page"
193
+ )
194
+ token = token or env.require(
195
+ "CLOUDFLARE_API_TOKEN",
196
+ hint="an API token with the `Cloudflare Pages: Edit` permission, "
197
+ "from https://dash.cloudflare.com/profile/api-tokens",
198
+ )
199
+ mark_sensitive(token)
200
+ return account, token
201
+
202
+
203
+ def _call(
204
+ method: str,
205
+ url: str,
206
+ *,
207
+ auth: str,
208
+ body: bytes | None = None,
209
+ content_type: str | None = None,
210
+ timeout: float = 120.0,
211
+ ) -> Any:
212
+ """One API call, returning `result` -- a dict or a list, as the endpoint says.
213
+
214
+ Under `--dry-run` nothing is sent and this returns `None`, which every
215
+ caller reads as "unknown". A dry run therefore prints the whole plan
216
+ instead of stopping at the first call whose answer it needed.
217
+ """
218
+ headers = {"Authorization": f"Bearer {auth}"}
219
+ if content_type:
220
+ headers["Content-Type"] = content_type
221
+ answer = http.request(url, method=method, headers=headers, data=body, timeout=timeout)
222
+ if answer.skipped:
223
+ return None
224
+ if answer.status == 0:
225
+ raise MakeError(f"{method} {url}: no answer from Cloudflare")
226
+ try:
227
+ payload = json.loads(answer.body)
228
+ except ValueError:
229
+ payload = {}
230
+ if not answer.ok or not payload.get("success", False):
231
+ detail = "; ".join(f"{e.get('code', '?')}: {e.get('message', '')}" for e in payload.get("errors", []))
232
+ # Cloudflare's own text goes in the message, not the hint: a 403 saying
233
+ # which permission the token lacks is the thing to read, and a hint is
234
+ # printed as advice rather than as the failure.
235
+ raise MakeError(
236
+ f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
237
+ hint=None if detail else (answer.body[:300] or None),
238
+ )
239
+ return payload.get("result")
240
+
241
+
242
+ def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
243
+ return _call(
244
+ method, url, auth=auth, body=json.dumps(payload).encode(), content_type="application/json", **kwargs
245
+ )
246
+
247
+
248
+ def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None) -> tuple[bytes, str]:
249
+ """Encode a deployment's form: plain fields, then the special files as file parts.
250
+
251
+ ⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
252
+ the way wrangler appends them: `formData.append("_headers", new File(...))`.
253
+ Sent as plain text fields, the deployment succeeds and Pages silently ignores
254
+ them -- every header rule, the CSP included, simply stops applying.
255
+ """
256
+ boundary = "----mk" + _random.token_hex(16)
257
+ chunks: list[bytes] = []
258
+ for name, value in fields.items():
259
+ chunks.append(f"--{boundary}\r\n".encode())
260
+ chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
261
+ chunks.append(value.encode())
262
+ chunks.append(b"\r\n")
263
+ for name, value in (files or {}).items():
264
+ chunks.append(f"--{boundary}\r\n".encode())
265
+ chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
266
+ chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
267
+ chunks.append(value.encode())
268
+ chunks.append(b"\r\n")
269
+ chunks.append(f"--{boundary}--\r\n".encode())
270
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
271
+
272
+
273
+ def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
274
+ batch: list[Asset] = []
275
+ total = 0
276
+ for asset in assets:
277
+ encoded = asset.size * 4 // 3
278
+ if batch and (len(batch) >= BATCH_FILES or total + encoded > BATCH_BYTES):
279
+ yield batch
280
+ batch, total = [], 0
281
+ batch.append(asset)
282
+ total += encoded
283
+ if batch:
284
+ yield batch
285
+
286
+
287
+ # -- the tasks' behaviour --------------------------------------------------
288
+
289
+
290
+ def deploy(
291
+ directory: str | Path,
292
+ *,
293
+ project: str,
294
+ branch: str = "main",
295
+ account: str | None = None,
296
+ token: str | None = None,
297
+ ) -> str:
298
+ """Publish a built directory and return the deployment's URL.
299
+
300
+ `branch` is what decides production: Pages treats the project's production
301
+ branch (`main` for every project here) as the live deployment and every
302
+ other name as a preview on its own URL. There is no `--production` flag to
303
+ forget; there is a branch name to get right.
304
+ """
305
+ account, token = _credentials(account, token)
306
+ assets, specials = collect(directory)
307
+ plural = "" if len(assets) == 1 else "s"
308
+ step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
309
+
310
+ granted = _call("GET", f"{API}/accounts/{account}/pages/projects/{project}/upload-token", auth=token)
311
+ jwt = str(granted.get("jwt", "")) if isinstance(granted, dict) else ""
312
+ if jwt:
313
+ mark_sensitive(jwt)
314
+ # The JWT is what the two asset endpoints want; the account token is not
315
+ # accepted there. Falling back to it keeps a dry run legible rather than
316
+ # correct -- there is nothing to authenticate when nothing is sent.
317
+ upload_auth = jwt or token
318
+
319
+ # `check-missing` answers with the hashes Cloudflare does NOT already hold:
320
+ # a repeat deploy of an unchanged site uploads nothing. `None` is a dry run,
321
+ # where the honest plan is "all of them".
322
+ missing = _json_call(
323
+ "POST",
324
+ f"{API}/pages/assets/check-missing",
325
+ auth=upload_auth,
326
+ payload={"hashes": [a.hash for a in assets]},
327
+ )
328
+ pending = assets if missing is None else [a for a in assets if a.hash in set(missing)]
329
+
330
+ if pending:
331
+ for batch in _batches(pending):
332
+ _json_call(
333
+ "POST",
334
+ f"{API}/pages/assets/upload",
335
+ auth=upload_auth,
336
+ payload=[
337
+ {
338
+ "key": asset.hash,
339
+ "value": base64.b64encode(asset.file.read_bytes()).decode(),
340
+ "metadata": {"contentType": asset.content_type},
341
+ "base64": True,
342
+ }
343
+ for asset in batch
344
+ ],
345
+ )
346
+ if len(pending) == len(assets):
347
+ note(f"uploading all {len(assets)} file{plural}")
348
+ else:
349
+ note(f"uploading {len(pending)} of {len(assets)}; Cloudflare already holds the rest")
350
+ else:
351
+ note("every file was already stored; only the manifest changes")
352
+
353
+ _json_call(
354
+ "POST",
355
+ f"{API}/pages/assets/upsert-hashes",
356
+ auth=upload_auth,
357
+ payload={"hashes": [a.hash for a in assets]},
358
+ )
359
+
360
+ fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
361
+ body, content_type = _multipart(fields, specials)
362
+ created = _call(
363
+ "POST",
364
+ f"{API}/accounts/{account}/pages/projects/{project}/deployments",
365
+ auth=token,
366
+ body=body,
367
+ content_type=content_type,
368
+ )
369
+ if specials:
370
+ note("as deployment files, not assets: " + ", ".join(sorted(specials)))
371
+ url = created.get("url") if isinstance(created, dict) else None
372
+ return str(url or f"https://{branch}.{project}.pages.dev")
373
+
374
+
375
+ def projects(*, account: str | None = None, token: str | None = None) -> list[dict]:
376
+ """Every Pages project on the account."""
377
+ account, token = _credentials(account, token)
378
+ result = _call("GET", f"{API}/accounts/{account}/pages/projects", auth=token)
379
+ return list(result) if isinstance(result, list) else []
380
+
381
+
382
+ def create_project(
383
+ project: str, *, production_branch: str = "main", account: str | None = None, token: str | None = None
384
+ ) -> dict:
385
+ """The project named `project`, created for direct upload if the account has none.
386
+
387
+ Idempotent: an existing project is returned as it is, whatever its branch or source.
388
+ Its `subdomain` is where it serves -- `<project>.pages.dev` only if no other account
389
+ holds that name; Cloudflare appends a suffix otherwise, so read it, never assume it.
390
+ """
391
+ account, token = _credentials(account, token)
392
+ for found in _call("GET", f"{API}/accounts/{account}/pages/projects", auth=token) or []:
393
+ if found.get("name") == project:
394
+ return found
395
+ step(f"creating the Pages project {project} (production branch {production_branch})")
396
+ created = _json_call(
397
+ "POST",
398
+ f"{API}/accounts/{account}/pages/projects",
399
+ auth=token,
400
+ payload={"name": project, "production_branch": production_branch},
401
+ )
402
+ return created if isinstance(created, dict) else {"name": project}
403
+
404
+
405
+ def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
406
+ """A project's deployments, newest first."""
407
+ account, token = _credentials(account, token)
408
+ result = _call("GET", f"{API}/accounts/{account}/pages/projects/{project}/deployments", auth=token)
409
+ return list(result) if isinstance(result, list) else []
410
+
411
+
412
+ def project_name(project: str | None) -> str:
413
+ """The project to act on: the one given, else the repo's env layer."""
414
+ if project:
415
+ return project
416
+ found = env.get("CLOUDFLARE_PAGES_PROJECT")
417
+ if found:
418
+ return found
419
+ raise ConfigError(
420
+ "no Pages project given",
421
+ hint="pass --project, or set CLOUDFLARE_PAGES_PROJECT in this repo's env layer",
422
+ )
@@ -0,0 +1,19 @@
1
+ """Test setup for make-cloudflare.
2
+
3
+ Importing the group is what registers it, and a module is imported once per
4
+ process -- so the registry is populated here, once, rather than relying on
5
+ whichever test file happened to import first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from make.tasks import registry
13
+
14
+
15
+ @pytest.fixture(scope="session", autouse=True)
16
+ def register_groups():
17
+ from make_cloudflare import cloudflare # noqa: F401
18
+
19
+ registry.finalize()
@@ -0,0 +1,284 @@
1
+ """make-cloudflare: the hash is the contract, and the special files are not assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from make.errors import MakeError
11
+ from make.testing import context
12
+ from make_cloudflare import pages
13
+
14
+
15
+ def build(root, files: dict[str, str]):
16
+ for name, text in files.items():
17
+ target = root / name
18
+ target.parent.mkdir(parents=True, exist_ok=True)
19
+ target.write_text(text)
20
+ return root
21
+
22
+
23
+ class FakeHTTP:
24
+ """Stands in for `make.http.request`, answering each endpoint in turn."""
25
+
26
+ def __init__(self, *, missing: list[str] | None = None):
27
+ self.calls: list[tuple[str, str, dict]] = []
28
+ self.missing = missing
29
+
30
+ def __call__(self, url, *, method="GET", headers=None, data=None, **kwargs):
31
+ self.calls.append((method, url, {"headers": headers or {}, "data": data}))
32
+ if url.endswith("/upload-token"):
33
+ result = {"jwt": "jot"}
34
+ elif url.endswith("/check-missing"):
35
+ sent = json.loads(data)["hashes"]
36
+ result = sent if self.missing is None else self.missing
37
+ elif url.endswith("/deployments"):
38
+ result = {"url": "https://abc123.example.pages.dev"}
39
+ else:
40
+ result = {}
41
+ return pages.http.Response(
42
+ url=url, status=200, body=json.dumps({"success": True, "errors": [], "result": result})
43
+ )
44
+
45
+ def urls(self, fragment: str) -> list[str]:
46
+ return [url for _, url, _ in self.calls if fragment in url]
47
+
48
+ def body(self, fragment: str):
49
+ return next(call["data"] for _, url, call in self.calls if fragment in url)
50
+
51
+ def auth(self, fragment: str) -> str:
52
+ call = next(c for _, url, c in self.calls if fragment in url)
53
+ return call["headers"]["Authorization"]
54
+
55
+
56
+ def deploy(monkeypatch, root, fake, **kwargs):
57
+ monkeypatch.setattr(pages.http, "request", fake)
58
+ return pages.deploy(root, project="demo", account="acct", token="tok", **kwargs)
59
+
60
+
61
+ # -- the hash --------------------------------------------------------------
62
+
63
+
64
+ def test_the_hash_is_blake3_of_the_base64_text_plus_the_extension():
65
+ # Locked deliberately. Cloudflare's asset store is keyed on this value, so
66
+ # a "simplification" to hashing the raw bytes -- the obvious reading -- would
67
+ # upload files that nothing ever asks for, with every call still returning
68
+ # 200. If this test fails, the deploy is broken, not the test.
69
+ assert pages.file_hash(b"hello", "html") == "a2b82584e50075886b08927390f2f573"
70
+ assert len(pages.file_hash(b"hello", "html")) == 32
71
+
72
+
73
+ def test_the_extension_is_part_of_the_hash():
74
+ assert pages.file_hash(b"same", "html") != pages.file_hash(b"same", "css")
75
+ assert pages.file_hash(b"same", "") != pages.file_hash(b"same", "html")
76
+
77
+
78
+ # -- walking the directory -------------------------------------------------
79
+
80
+
81
+ def test_collect_keys_with_a_leading_slash_and_skips_the_noise(tmp_path):
82
+ build(
83
+ tmp_path,
84
+ {
85
+ "index.html": "<h1>hi</h1>",
86
+ "assets/app.css": "body{}",
87
+ ".DS_Store": "junk",
88
+ "node_modules/dep/index.js": "junk",
89
+ },
90
+ )
91
+ assets, specials = pages.collect(tmp_path)
92
+ assert pages.manifest_of(assets).keys() == {"/index.html", "/assets/app.css"}
93
+ assert specials == {}
94
+
95
+
96
+ def test_the_special_files_are_fields_not_assets(tmp_path):
97
+ build(
98
+ tmp_path,
99
+ {"index.html": "x", "_headers": "/*\n X-Frame-Options: DENY\n", "_redirects": "/a /b 301\n"},
100
+ )
101
+ assets, specials = pages.collect(tmp_path)
102
+ assert pages.manifest_of(assets).keys() == {"/index.html"}
103
+ assert set(specials) == {"_headers", "_redirects"}
104
+
105
+
106
+ def test_a_functions_project_is_refused(tmp_path):
107
+ build(tmp_path, {"index.html": "x", "_worker.js": "export default {}"})
108
+ with pytest.raises(MakeError, match="Functions"):
109
+ pages.collect(tmp_path)
110
+
111
+
112
+ def test_an_oversized_asset_is_named(tmp_path):
113
+ (tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
114
+ with pytest.raises(MakeError, match=r"big\.bin"):
115
+ pages.collect(tmp_path)
116
+
117
+
118
+ def test_an_empty_directory_is_refused(tmp_path):
119
+ with pytest.raises(MakeError, match="no files"):
120
+ pages.collect(tmp_path)
121
+
122
+
123
+ # -- the four calls --------------------------------------------------------
124
+
125
+
126
+ def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
127
+ build(tmp_path, {"index.html": "x"})
128
+ fake = FakeHTTP()
129
+ url = deploy(monkeypatch, tmp_path, fake)
130
+ assert [u.rsplit("/", 1)[-1] for _, u, _ in fake.calls] == [
131
+ "upload-token",
132
+ "check-missing",
133
+ "upload",
134
+ "upsert-hashes",
135
+ "deployments",
136
+ ]
137
+ assert url == "https://abc123.example.pages.dev"
138
+
139
+
140
+ def test_the_asset_endpoints_use_the_jwt_and_omit_the_account(monkeypatch, tmp_path):
141
+ build(tmp_path, {"index.html": "x"})
142
+ fake = FakeHTTP()
143
+ deploy(monkeypatch, tmp_path, fake)
144
+ for endpoint in ("/assets/check-missing", "/assets/upload", "/assets/upsert-hashes"):
145
+ assert fake.auth(endpoint) == "Bearer jot"
146
+ assert "/accounts/" not in fake.urls(endpoint)[0]
147
+ assert fake.auth("/deployments") == "Bearer tok"
148
+ assert "/accounts/acct/" in fake.urls("/deployments")[0]
149
+
150
+
151
+ def test_only_the_missing_hashes_are_uploaded(monkeypatch, tmp_path):
152
+ build(tmp_path, {"index.html": "x", "second.html": "y"})
153
+ wanted = pages.file_hash(b"y", "html")
154
+ fake = FakeHTTP(missing=[wanted])
155
+ deploy(monkeypatch, tmp_path, fake)
156
+ uploaded = json.loads(fake.body("/assets/upload"))
157
+ assert [entry["key"] for entry in uploaded] == [wanted]
158
+ assert base64.b64decode(uploaded[0]["value"]) == b"y"
159
+ assert uploaded[0]["metadata"]["contentType"].startswith("text/html")
160
+ # ... and the manifest still names both, or the unchanged one would 404.
161
+ assert (
162
+ len(
163
+ json.loads(
164
+ fake.body("/deployments")
165
+ .decode()
166
+ .split('name="manifest"')[1]
167
+ .split("\r\n\r\n")[1]
168
+ .split("\r\n")[0]
169
+ )
170
+ )
171
+ == 2
172
+ )
173
+
174
+
175
+ def test_nothing_uploads_when_cloudflare_already_has_it_all(monkeypatch, tmp_path):
176
+ build(tmp_path, {"index.html": "x"})
177
+ fake = FakeHTTP(missing=[])
178
+ deploy(monkeypatch, tmp_path, fake)
179
+ assert fake.urls("/assets/upload") == []
180
+ assert fake.urls("/deployments") # the deployment is still created
181
+
182
+
183
+ def test_the_deployment_carries_the_manifest_the_branch_and_the_headers(monkeypatch, tmp_path):
184
+ build(tmp_path, {"index.html": "x", "_headers": "/*\n X-Frame-Options: DENY\n"})
185
+ fake = FakeHTTP()
186
+ deploy(monkeypatch, tmp_path, fake, branch="preview")
187
+ body = fake.body("/deployments").decode()
188
+ assert 'name="manifest"' in body and "/index.html" in body
189
+ assert 'name="branch"\r\n\r\npreview' in body
190
+ assert 'name="_headers"' in body and "X-Frame-Options" in body
191
+
192
+
193
+ def test_a_cloudflare_error_names_its_own_message(monkeypatch, tmp_path):
194
+ build(tmp_path, {"index.html": "x"})
195
+
196
+ def angry(url, **kwargs):
197
+ return pages.http.Response(
198
+ url=url,
199
+ status=403,
200
+ body=json.dumps({"success": False, "errors": [{"code": 10000, "message": "no permission"}]}),
201
+ )
202
+
203
+ monkeypatch.setattr(pages.http, "request", angry)
204
+ with pytest.raises(MakeError, match="no permission"):
205
+ pages.deploy(tmp_path, project="demo", account="acct", token="tok")
206
+
207
+
208
+ def test_dry_run_sends_nothing(tmp_path):
209
+ build(tmp_path, {"index.html": "x"})
210
+ with context(tmp_path, dry_run=True):
211
+ # The real http module, which refuses to make a request under --dry-run:
212
+ # a plan is printed and the fallback URL comes back.
213
+ url = pages.deploy(tmp_path, project="demo", account="acct", token="tok")
214
+ assert url == "https://main.demo.pages.dev"
215
+
216
+
217
+ # -- batching --------------------------------------------------------------
218
+
219
+
220
+ def test_uploads_are_batched_by_count_and_by_size(tmp_path):
221
+ assets = [
222
+ pages.Asset(
223
+ key=f"/{i}.txt", file=tmp_path / f"{i}.txt", hash=f"h{i}", size=1, content_type="text/plain"
224
+ )
225
+ for i in range(pages.BATCH_FILES * 2 + 3)
226
+ ]
227
+ assert [len(b) for b in pages._batches(assets)] == [pages.BATCH_FILES, pages.BATCH_FILES, 3]
228
+
229
+ fat = [
230
+ pages.Asset(key="/a", file=tmp_path / "a", hash="a", size=pages.BATCH_BYTES, content_type="x"),
231
+ pages.Asset(key="/b", file=tmp_path / "b", hash="b", size=pages.BATCH_BYTES, content_type="x"),
232
+ ]
233
+ assert [len(b) for b in pages._batches(fat)] == [1, 1]
234
+
235
+
236
+ def test_the_special_files_are_file_parts_of_the_deployment():
237
+ """Pages reads `_headers` only as a FILE part, as wrangler sends it.
238
+
239
+ As a plain field the deployment still succeeds and the site serves with no
240
+ header rules at all -- isard.pages.dev lost its CSP that way on 2026-10-03.
241
+ """
242
+ body, content_type = pages._multipart({"branch": "main"}, {"_headers": "/*\n X-Test: 1\n"})
243
+ text = body.decode()
244
+ assert 'name="_headers"; filename="_headers"' in text
245
+ assert 'name="branch"\r\n\r\nmain' in text # an ordinary field stays one
246
+ assert "X-Test: 1" in text
247
+ assert content_type.startswith("multipart/form-data; boundary=")
248
+
249
+
250
+ # -- creating a project ----------------------------------------------------
251
+
252
+
253
+ def projects_api(existing):
254
+ calls = []
255
+
256
+ def answer(url, *, method="GET", headers=None, data=None, **kwargs):
257
+ calls.append((method, url, data))
258
+ if method == "GET":
259
+ result = [{"name": name, "subdomain": f"{name}.pages.dev"} for name in existing]
260
+ else:
261
+ sent = json.loads(data)
262
+ result = {"name": sent["name"], "subdomain": f"{sent['name']}-x1y.pages.dev"}
263
+ return pages.http.Response(
264
+ url=url, status=200, body=json.dumps({"success": True, "errors": [], "result": result})
265
+ )
266
+
267
+ return answer, calls
268
+
269
+
270
+ def test_an_existing_project_is_returned_and_nothing_is_created(monkeypatch):
271
+ answer, calls = projects_api(["demo"])
272
+ monkeypatch.setattr(pages.http, "request", answer)
273
+ assert pages.create_project("demo", account="acct", token="tok")["subdomain"] == "demo.pages.dev"
274
+ assert [method for method, _, _ in calls] == ["GET"]
275
+
276
+
277
+ def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
278
+ answer, calls = projects_api(["other"])
279
+ monkeypatch.setattr(pages.http, "request", answer)
280
+ made = pages.create_project("demo", account="acct", token="tok")
281
+ assert made["subdomain"] == "demo-x1y.pages.dev"
282
+ method, url, data = calls[-1]
283
+ assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
284
+ assert json.loads(data) == {"name": "demo", "production_branch": "main"}