make-cloudflare 0.1.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: make-cloudflare
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
5
5
  Project-URL: Homepage, https://make.optersoft.com
6
6
  Author-email: "Optersoft, S.L." <david@optersoft.com>
@@ -29,8 +29,10 @@ from make_cloudflare import cloudflare # importing is what registers the group
29
29
  ```console
30
30
  $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
31
31
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
32
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
32
33
  $ mk cloudflare.projects # what the account has
33
34
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
35
+ $ mk cloudflare.prune --keep 10 # delete all but the newest ten
34
36
  $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
35
37
  ```
36
38
 
@@ -42,6 +44,24 @@ the environment until a task asks for it, redacted in everything printed, and
42
44
  readable from the encrypted store. The project can come from `--project` or from
43
45
  `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
44
46
 
47
+ **Every deploy prunes the project to its newest 10 deployments** (`--keep N`
48
+ to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
49
+ Python). Pages never deletes one by itself, and each stays reachable on its own
50
+ `<hash>.<project>.pages.dev` URL forever. The live production deployment is
51
+ never deleted, however old; a pruned preview that was still its branch's alias
52
+ takes that branch URL with it.
53
+
54
+ **Pages Functions go up with the deployment.** `--functions functions` (or
55
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
56
+ directory, as wrangler expects, and `/api/...` answers from the same project and
57
+ origin. Bundling it is a build step, not an upload, so that one step is
58
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
59
+ writes exactly the `_worker.bundle` the deployment takes, plus
60
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
61
+ site's own `_routes.json` wins. Everything else is still the four calls below.
62
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
63
+ the upload.
64
+
45
65
  ## Why this exists
46
66
 
47
67
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -72,9 +92,9 @@ call returns 200 and the site serves the old bytes, or none:
72
92
 
73
93
  ## What it does not do
74
94
 
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.
95
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
96
+ the built directory is refused by name: it would otherwise be served as a
97
+ static file. Pages Functions go beside it, through `--functions`.
78
98
 
79
99
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
80
100
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -85,5 +105,5 @@ than a wrapper nobody reads.
85
105
  Nothing here is specific to any owner or account. The group name is
86
106
  `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
87
107
  namespaces — so only a same-named task collides; this package claims `deploy`,
88
- `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
108
+ `projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
89
109
  it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -16,8 +16,10 @@ from make_cloudflare import cloudflare # importing is what registers the group
16
16
  ```console
17
17
  $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
18
18
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
19
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
19
20
  $ mk cloudflare.projects # what the account has
20
21
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
22
+ $ mk cloudflare.prune --keep 10 # delete all but the newest ten
21
23
  $ mk -n cloudflare.deploy site/dist # the plan; nothing is sent
22
24
  ```
23
25
 
@@ -29,6 +31,24 @@ the environment until a task asks for it, redacted in everything printed, and
29
31
  readable from the encrypted store. The project can come from `--project` or from
30
32
  `CLOUDFLARE_PAGES_PROJECT` in the repo's env layer.
31
33
 
34
+ **Every deploy prunes the project to its newest 10 deployments** (`--keep N`
35
+ to change it, `--keep 0` to keep them all; `pages.deploy(..., keep=)` from
36
+ Python). Pages never deletes one by itself, and each stays reachable on its own
37
+ `<hash>.<project>.pages.dev` URL forever. The live production deployment is
38
+ never deleted, however old; a pruned preview that was still its branch's alias
39
+ takes that branch URL with it.
40
+
41
+ **Pages Functions go up with the deployment.** `--functions functions` (or
42
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
43
+ directory, as wrangler expects, and `/api/...` answers from the same project and
44
+ origin. Bundling it is a build step, not an upload, so that one step is
45
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
46
+ writes exactly the `_worker.bundle` the deployment takes, plus
47
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
48
+ site's own `_routes.json` wins. Everything else is still the four calls below.
49
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
50
+ the upload.
51
+
32
52
  ## Why this exists
33
53
 
34
54
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -59,9 +79,9 @@ call returns 200 and the site serves the old bytes, or none:
59
79
 
60
80
  ## What it does not do
61
81
 
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.
82
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
83
+ the built directory is refused by name: it would otherwise be served as a
84
+ static file. Pages Functions go beside it, through `--functions`.
65
85
 
66
86
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
67
87
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -72,5 +92,5 @@ than a wrapper nobody reads.
72
92
  Nothing here is specific to any owner or account. The group name is
73
93
  `cloudflare`, and it merges with a repo's own `cloudflare.*` tasks — groups are
74
94
  namespaces — so only a same-named task collides; this package claims `deploy`,
75
- `projects` and `deployments`, nothing else. `blake3` is the one dependency, and
95
+ `projects`, `deployments` and `prune`, nothing else. `blake3` is the one dependency, and
76
96
  it lives here rather than in the runner so `mkrun` itself stays dependency-free.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "make-cloudflare"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2,7 +2,8 @@
2
2
 
3
3
  Today it is Pages direct upload: `cloudflare.deploy` publishes a built
4
4
  directory, `cloudflare.projects` lists what the account has, and
5
- `cloudflare.deployments` shows a project's recent ones. No Node, no wrangler --
5
+ `cloudflare.deployments` shows a project's recent ones, and `cloudflare.prune`
6
+ deletes all but the newest ten (which every deploy also does). No Node, no wrangler --
6
7
  four HTTPS calls and a BLAKE3 hash.
7
8
 
8
9
  Importing is what registers it:
@@ -11,12 +12,12 @@ Importing is what registers it:
11
12
  from make_cloudflare import cloudflare
12
13
 
13
14
  The group merges with a repo's own `cloudflare.*` tasks -- groups are
14
- namespaces, and this package claims only the three names above.
15
+ namespaces, and this package claims only the four names above.
15
16
  """
16
17
 
17
18
  from __future__ import annotations
18
19
 
19
- __version__ = "0.1.0"
20
+ __version__ = "0.3.0"
20
21
 
21
22
  __all__ = ["cloudflare", "pages"]
22
23
 
@@ -23,14 +23,20 @@ def deploy(
23
23
  *,
24
24
  project: str | None = None,
25
25
  branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
26
+ keep: Annotated[int, arg(help="deployments to keep afterwards; 0 keeps every one")] = pages.KEEP,
27
+ functions: Annotated[str | None, arg(help="a Pages Functions tree to bundle and deploy with it")] = None,
26
28
  ) -> None:
27
29
  """Publish a built directory to Cloudflare Pages by direct upload.
28
30
 
29
- No Node and no wrangler: four HTTPS calls. `--branch` is what decides
31
+ No Node and no wrangler: four HTTPS calls. (`--functions` is the exception:
32
+ wrangler bundles that tree, and only bundles it.) `--branch` is what decides
30
33
  production -- the project's production branch goes live, every other name
31
- lands as a preview on its own URL.
34
+ lands as a preview on its own URL. Then the project is pruned to its
35
+ newest `--keep` deployments; the live one always stays.
32
36
  """
33
- url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch)
37
+ url = pages.deploy(
38
+ Path(directory), project=pages.project_name(project), branch=branch, keep=keep, functions=functions
39
+ )
34
40
  note(f"deployed -- {url}")
35
41
 
36
42
 
@@ -50,3 +56,11 @@ def deployments(project: str | None = None, *, limit: int = 10) -> None:
50
56
  stage = (entry.get("latest_stage") or {}).get("status", "?")
51
57
  env_name = entry.get("environment", "?")
52
58
  step(f"{entry.get('created_on', '?')} {env_name:<10} {stage:<10} {entry.get('url', '')}")
59
+
60
+
61
+ @task(group="cloudflare", name="prune", dangerous=True, secrets=["CLOUDFLARE_API_TOKEN"])
62
+ def prune(project: str | None = None, *, keep: int = pages.KEEP) -> None:
63
+ """Delete all but a project's newest `--keep` deployments; the live one always stays."""
64
+ name = pages.project_name(project)
65
+ if not pages.prune(name, keep=keep):
66
+ note(f"{name} has {keep} deployments or fewer; nothing to delete")
@@ -8,6 +8,7 @@ a built directory without installing a JavaScript toolchain to do it.
8
8
 
9
9
  pages.deploy("site/dist", project="mkrun") # production
10
10
  pages.deploy("site/dist", project="mkrun", branch="x") # a preview
11
+ pages.deploy("dist", project="name", functions="functions") # with Pages Functions
11
12
 
12
13
  The flow, and the three things a naive port gets wrong:
13
14
 
@@ -30,6 +31,13 @@ reports success while the site's CSP quietly stops applying.
30
31
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
31
32
  Cloudflare can change them without a changelog. The documented `deployments`
32
33
  endpoint alone cannot upload a file.
34
+
35
+ **Pages Functions are built by wrangler, and only built.** Bundling a
36
+ `functions/` tree into a Worker is esbuild plus Cloudflare's routing glue, and
37
+ `wrangler pages functions build --outfile` writes exactly the `_worker.bundle`
38
+ the deployment takes. That one build step is the only thing wrangler does here;
39
+ the upload is still these calls. Bindings (D1, KV) and secrets are settings on
40
+ the Pages project, never part of the upload.
33
41
  """
34
42
 
35
43
  from __future__ import annotations
@@ -39,17 +47,20 @@ import json
39
47
  import mimetypes
40
48
  import os
41
49
  import secrets as _random
50
+ import shutil
51
+ import tempfile
42
52
  from collections.abc import Iterable, Iterator, Mapping
43
53
  from dataclasses import dataclass
44
54
  from pathlib import Path
45
55
  from typing import Any
46
56
 
47
- from make import env, http, note, step
57
+ from make import env, http, note, sh, step
48
58
  from make.context import mark_sensitive
49
59
  from make.errors import ConfigError, MakeError
50
60
 
51
61
  __all__ = [
52
62
  "Asset",
63
+ "build_functions",
53
64
  "collect",
54
65
  "deploy",
55
66
  "deployments",
@@ -57,6 +68,7 @@ __all__ = [
57
68
  "manifest_of",
58
69
  "project_name",
59
70
  "projects",
71
+ "prune",
60
72
  ]
61
73
 
62
74
  API = "https://api.cloudflare.com/client/v4"
@@ -72,12 +84,21 @@ MAX_ASSET_COUNT = 20_000
72
84
  BATCH_FILES = 100
73
85
  BATCH_BYTES = 10 * 1024 * 1024
74
86
 
87
+ #: How many deployments a project keeps after a deploy: the newest ones, plus
88
+ #: the live production deployment wherever it falls. Pages itself keeps every
89
+ #: one forever, each on its own URL.
90
+ KEEP = 10
91
+
92
+ #: One page of the deployments listing.
93
+ PER_PAGE = 25
94
+
75
95
  #: Handled by Pages itself, as fields on the deployment -- never as assets.
76
96
  SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
77
97
 
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.
98
+ #: Never uploaded. Pages Functions live *beside* the built directory and are
99
+ #: handed to `deploy(functions=...)`; inside it they would be served as static
100
+ #: files -- the source of the API, readable by anyone. An advanced-mode
101
+ #: `_worker.js` is not built here at all.
81
102
  SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
82
103
  SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
83
104
  FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
@@ -137,8 +158,9 @@ def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
137
158
  for name in FUNCTIONS:
138
159
  if (root / name).exists():
139
160
  raise MakeError(
140
- f"{root / name} is a Pages Functions project",
141
- hint="this module uploads static assets only -- deploy it with wrangler",
161
+ f"{root / name} is Pages Functions code inside the built directory",
162
+ hint="keep `functions/` beside the built directory and pass `functions=`; "
163
+ "an advanced-mode `_worker.js` is not supported",
142
164
  )
143
165
 
144
166
  assets: list[Asset] = []
@@ -200,7 +222,18 @@ def _credentials(account: str | None, token: str | None) -> tuple[str, str]:
200
222
  return account, token
201
223
 
202
224
 
203
- def _call(
225
+ def _call(method: str, url: str, **kwargs: Any) -> Any:
226
+ """One API call, returning `result` -- a dict or a list, as the endpoint says.
227
+
228
+ Under `--dry-run` nothing is sent and this returns `None`, which every
229
+ caller reads as "unknown". A dry run therefore prints the whole plan
230
+ instead of stopping at the first call whose answer it needed.
231
+ """
232
+ payload = _envelope(method, url, **kwargs)
233
+ return None if payload is None else payload.get("result")
234
+
235
+
236
+ def _envelope(
204
237
  method: str,
205
238
  url: str,
206
239
  *,
@@ -208,13 +241,8 @@ def _call(
208
241
  body: bytes | None = None,
209
242
  content_type: str | None = None,
210
243
  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
- """
244
+ ) -> dict | None:
245
+ """One API call, returning Cloudflare's whole answer -- `result_info` too."""
218
246
  headers = {"Authorization": f"Bearer {auth}"}
219
247
  if content_type:
220
248
  headers["Content-Type"] = content_type
@@ -236,7 +264,7 @@ def _call(
236
264
  f"{method} {url} -> {answer.status}" + (f": {detail}" if detail else ""),
237
265
  hint=None if detail else (answer.body[:300] or None),
238
266
  )
239
- return payload.get("result")
267
+ return payload
240
268
 
241
269
 
242
270
  def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: Any) -> Any:
@@ -245,7 +273,9 @@ def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: A
245
273
  )
246
274
 
247
275
 
248
- def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None) -> tuple[bytes, str]:
276
+ def _multipart(
277
+ fields: Mapping[str, str], files: Mapping[str, str | bytes] | None = None
278
+ ) -> tuple[bytes, str]:
249
279
  """Encode a deployment's form: plain fields, then the special files as file parts.
250
280
 
251
281
  ⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
@@ -264,7 +294,7 @@ def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None
264
294
  chunks.append(f"--{boundary}\r\n".encode())
265
295
  chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
266
296
  chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
267
- chunks.append(value.encode())
297
+ chunks.append(value if isinstance(value, bytes) else value.encode())
268
298
  chunks.append(b"\r\n")
269
299
  chunks.append(f"--{boundary}--\r\n".encode())
270
300
  return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
@@ -284,6 +314,55 @@ def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
284
314
  yield batch
285
315
 
286
316
 
317
+ # -- Pages Functions -------------------------------------------------------
318
+
319
+
320
+ def _wrangler() -> list[str]:
321
+ found = shutil.which("wrangler")
322
+ return [found] if found else ["npx", "--yes", "wrangler"]
323
+
324
+
325
+ def build_functions(functions: str | Path, directory: str | Path, out: str | Path) -> dict[str, bytes]:
326
+ """Bundle a `functions/` tree; return the deployment's file parts.
327
+
328
+ `_worker.bundle` (the Worker, already in the upload form Pages takes),
329
+ `functions-filepath-routing-config.json`, and `_routes.json` unless the
330
+ built directory brings its own -- the same three `wrangler pages deploy`
331
+ sends. Empty under `--dry-run`, where nothing was built.
332
+ """
333
+ source = Path(functions).resolve()
334
+ root = Path(directory).resolve()
335
+ if not source.is_dir():
336
+ raise MakeError(f"{source} is not a directory", hint="`functions=` names the Pages Functions tree")
337
+ out = Path(out)
338
+ bundle, config, routes = out / "_worker.bundle", out / "routing-config.json", out / "_routes.json"
339
+ sh(
340
+ *_wrangler(),
341
+ "pages",
342
+ "functions",
343
+ "build",
344
+ source,
345
+ "--outfile",
346
+ bundle,
347
+ "--output-config-path",
348
+ config,
349
+ "--output-routes-path",
350
+ routes,
351
+ "--build-output-directory",
352
+ root,
353
+ cwd=source.parent,
354
+ )
355
+ if not bundle.is_file():
356
+ return {}
357
+ parts = {
358
+ "_worker.bundle": bundle.read_bytes(),
359
+ "functions-filepath-routing-config.json": config.read_bytes(),
360
+ }
361
+ if not (root / "_routes.json").is_file() and routes.is_file():
362
+ parts["_routes.json"] = routes.read_bytes()
363
+ return parts
364
+
365
+
287
366
  # -- the tasks' behaviour --------------------------------------------------
288
367
 
289
368
 
@@ -292,6 +371,8 @@ def deploy(
292
371
  *,
293
372
  project: str,
294
373
  branch: str = "main",
374
+ keep: int = KEEP,
375
+ functions: str | Path | None = None,
295
376
  account: str | None = None,
296
377
  token: str | None = None,
297
378
  ) -> str:
@@ -301,9 +382,18 @@ def deploy(
301
382
  branch (`main` for every project here) as the live deployment and every
302
383
  other name as a preview on its own URL. There is no `--production` flag to
303
384
  forget; there is a branch name to get right.
385
+
386
+ `functions` names a Pages Functions tree (`functions/`, beside the built
387
+ directory): it is bundled by `build_functions` and goes up with the
388
+ deployment, so `/api/...` answers from the same project.
389
+
390
+ Afterwards the project is pruned to its newest `keep` deployments (see
391
+ `prune`); `keep=0` leaves every one in place.
304
392
  """
305
393
  account, token = _credentials(account, token)
306
394
  assets, specials = collect(directory)
395
+ with tempfile.TemporaryDirectory(prefix="mk-pages-") as scratch:
396
+ worker = build_functions(functions, directory, scratch) if functions is not None else {}
307
397
  plural = "" if len(assets) == 1 else "s"
308
398
  step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
309
399
 
@@ -358,7 +448,7 @@ def deploy(
358
448
  )
359
449
 
360
450
  fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
361
- body, content_type = _multipart(fields, specials)
451
+ body, content_type = _multipart(fields, {**specials, **worker})
362
452
  created = _call(
363
453
  "POST",
364
454
  f"{API}/accounts/{account}/pages/projects/{project}/deployments",
@@ -366,9 +456,11 @@ def deploy(
366
456
  body=body,
367
457
  content_type=content_type,
368
458
  )
369
- if specials:
370
- note("as deployment files, not assets: " + ", ".join(sorted(specials)))
459
+ if specials or worker:
460
+ note("as deployment files, not assets: " + ", ".join(sorted({**specials, **worker})))
371
461
  url = created.get("url") if isinstance(created, dict) else None
462
+ if keep:
463
+ prune(project, keep=keep, account=account, token=token)
372
464
  return str(url or f"https://{branch}.{project}.pages.dev")
373
465
 
374
466
 
@@ -403,10 +495,55 @@ def create_project(
403
495
 
404
496
 
405
497
  def deployments(project: str, *, account: str | None = None, token: str | None = None) -> list[dict]:
406
- """A project's deployments, newest first."""
498
+ """A project's deployments, newest first -- every page of them.
499
+
500
+ The listing is paginated, and one page is not all of a project that has
501
+ been deployed by hand for a month. `[]` under `--dry-run`.
502
+ """
407
503
  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 []
504
+ found: list[dict] = []
505
+ page = 1
506
+ while True:
507
+ payload = _envelope(
508
+ "GET",
509
+ f"{API}/accounts/{account}/pages/projects/{project}/deployments?page={page}&per_page={PER_PAGE}",
510
+ auth=token,
511
+ )
512
+ result = (payload or {}).get("result")
513
+ if not isinstance(result, list) or not result:
514
+ return found
515
+ found.extend(result)
516
+ pages_total = ((payload or {}).get("result_info") or {}).get("total_pages")
517
+ if isinstance(pages_total, int) and page >= pages_total:
518
+ return found
519
+ page += 1
520
+
521
+
522
+ def prune(
523
+ project: str, *, keep: int = KEEP, account: str | None = None, token: str | None = None
524
+ ) -> list[dict]:
525
+ """Delete all but the newest `keep` deployments of `project`; return the deleted ones.
526
+
527
+ The live production deployment is never deleted, however old -- Pages
528
+ refuses it anyway, and it is the site. A preview that is still its branch's
529
+ alias goes with `force=true`: its branch URL stops serving, which is what
530
+ pruning a preview means.
531
+ """
532
+ if keep < 1:
533
+ raise MakeError(f"keep={keep} would delete every deployment", hint="keep at least 1")
534
+ account, token = _credentials(account, token)
535
+ base = f"{API}/accounts/{account}/pages/projects/{project}"
536
+ found = deployments(project, account=account, token=token)
537
+ if len(found) <= keep:
538
+ return []
539
+ live = (_call("GET", base, auth=token) or {}).get("canonical_deployment") or {}
540
+ doomed = [d for d in found[keep:] if d.get("id") and d.get("id") != live.get("id")]
541
+ for entry in doomed:
542
+ _call("DELETE", f"{base}/deployments/{entry['id']}?force=true", auth=token)
543
+ if doomed:
544
+ plural = "" if len(doomed) == 1 else "s"
545
+ note(f"pruned {len(doomed)} old deployment{plural} of {project}; kept the newest {keep}")
546
+ return doomed
410
547
 
411
548
 
412
549
  def project_name(project: str | None) -> str:
@@ -109,6 +109,13 @@ def test_a_functions_project_is_refused(tmp_path):
109
109
  pages.collect(tmp_path)
110
110
 
111
111
 
112
+ def test_functions_inside_the_built_directory_are_refused(tmp_path):
113
+ """Served from there, the API's source would be a static file anyone can read."""
114
+ build(tmp_path, {"index.html": "x", "functions/api/x.ts": "export const onRequest = 1"})
115
+ with pytest.raises(MakeError, match="inside the built directory"):
116
+ pages.collect(tmp_path)
117
+
118
+
112
119
  def test_an_oversized_asset_is_named(tmp_path):
113
120
  (tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
114
121
  with pytest.raises(MakeError, match=r"big\.bin"):
@@ -133,6 +140,7 @@ def test_deploy_makes_the_four_calls_in_order(monkeypatch, tmp_path):
133
140
  "upload",
134
141
  "upsert-hashes",
135
142
  "deployments",
143
+ "deployments?page=1&per_page=25", # the prune: one deployment, nothing to delete
136
144
  ]
137
145
  assert url == "https://abc123.example.pages.dev"
138
146
 
@@ -282,3 +290,136 @@ def test_a_missing_project_is_created_on_its_production_branch(monkeypatch):
282
290
  method, url, data = calls[-1]
283
291
  assert (method, url.endswith("/accounts/acct/pages/projects")) == ("POST", True)
284
292
  assert json.loads(data) == {"name": "demo", "production_branch": "main"}
293
+
294
+
295
+ # -- pruning ---------------------------------------------------------------
296
+
297
+
298
+ def history_api(count: int, *, live: str, per_page: int = 25):
299
+ """A project with `count` deployments, `d0` newest, served in pages of `per_page`."""
300
+ calls: list[tuple[str, str]] = []
301
+ ids = [f"d{i}" for i in range(count)]
302
+
303
+ def answer(url, *, method="GET", **kwargs):
304
+ calls.append((method, url))
305
+ info = None
306
+ if method == "DELETE":
307
+ result = None
308
+ elif "/deployments?" in url:
309
+ page = int(url.split("page=")[1].split("&")[0])
310
+ chunk = ids[(page - 1) * per_page : page * per_page]
311
+ result = [{"id": i} for i in chunk]
312
+ info = {"page": page, "per_page": per_page, "total_pages": -(-count // per_page)}
313
+ elif url.endswith("/deployments"):
314
+ result = {"url": "https://new.demo.pages.dev"}
315
+ elif url.endswith("/projects/demo"):
316
+ result = {"name": "demo", "canonical_deployment": {"id": live}}
317
+ else:
318
+ result = {"jwt": "jot"} if url.endswith("/upload-token") else []
319
+ body = {"success": True, "errors": [], "result": result}
320
+ if info:
321
+ body["result_info"] = info
322
+ return pages.http.Response(url=url, status=200, body=json.dumps(body))
323
+
324
+ return answer, calls
325
+
326
+
327
+ def deleted(calls) -> list[str]:
328
+ return [url.split("/deployments/")[1].split("?")[0] for method, url in calls if method == "DELETE"]
329
+
330
+
331
+ def test_prune_keeps_the_newest_and_reads_every_page(monkeypatch):
332
+ answer, calls = history_api(60, live="d0")
333
+ monkeypatch.setattr(pages.http, "request", answer)
334
+ gone = pages.prune("demo", keep=10, account="acct", token="tok")
335
+ assert [d["id"] for d in gone] == [f"d{i}" for i in range(10, 60)]
336
+ assert deleted(calls) == [f"d{i}" for i in range(10, 60)]
337
+ assert sum("/deployments?page=" in url for _, url in calls) == 3
338
+ assert all(url.endswith("?force=true") for method, url in calls if method == "DELETE")
339
+
340
+
341
+ def test_prune_never_deletes_the_live_deployment(monkeypatch):
342
+ answer, calls = history_api(15, live="d12") # a run of previews pushed it down
343
+ monkeypatch.setattr(pages.http, "request", answer)
344
+ pages.prune("demo", keep=10, account="acct", token="tok")
345
+ assert deleted(calls) == ["d10", "d11", "d13", "d14"]
346
+
347
+
348
+ def test_prune_with_little_history_deletes_nothing(monkeypatch):
349
+ answer, calls = history_api(10, live="d0")
350
+ monkeypatch.setattr(pages.http, "request", answer)
351
+ assert pages.prune("demo", keep=10, account="acct", token="tok") == []
352
+ assert deleted(calls) == []
353
+
354
+
355
+ def test_prune_refuses_to_keep_nothing():
356
+ with pytest.raises(MakeError, match="every deployment"):
357
+ pages.prune("demo", keep=0, account="acct", token="tok")
358
+
359
+
360
+ def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
361
+ build(tmp_path, {"index.html": "x"})
362
+ answer, calls = history_api(12, live="d0")
363
+ monkeypatch.setattr(pages.http, "request", answer)
364
+ pages.deploy(tmp_path, project="demo", account="acct", token="tok")
365
+ assert deleted(calls) == ["d10", "d11"]
366
+
367
+ calls.clear()
368
+ pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
369
+ assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)
370
+
371
+
372
+ # -- Pages Functions -------------------------------------------------------
373
+
374
+
375
+ def fake_wrangler(monkeypatch):
376
+ """Stands in for `wrangler pages functions build`, writing the three outputs."""
377
+ seen = []
378
+
379
+ def run(*argv, cwd=None, **kwargs):
380
+ argv = [str(a) for a in argv]
381
+ seen.append(argv)
382
+ out = {
383
+ flag: argv[argv.index(flag) + 1]
384
+ for flag in ("--outfile", "--output-config-path", "--output-routes-path")
385
+ }
386
+ pages.Path(out["--outfile"]).write_bytes(b"--bundle--")
387
+ pages.Path(out["--output-config-path"]).write_text('{"routes": []}')
388
+ pages.Path(out["--output-routes-path"]).write_text('{"include": ["/api/*"]}')
389
+
390
+ monkeypatch.setattr(pages, "sh", run)
391
+ return seen
392
+
393
+
394
+ def test_functions_go_up_as_file_parts_of_the_deployment(monkeypatch, tmp_path):
395
+ site = build(tmp_path / "dist", {"index.html": "x"})
396
+ functions = build(tmp_path / "functions", {"api/[[path]].ts": "export const onRequest = 1"})
397
+ seen = fake_wrangler(monkeypatch)
398
+ fake = FakeHTTP()
399
+ deploy(monkeypatch, site, fake, functions=functions)
400
+
401
+ assert seen[0][1:4] == ["pages", "functions", "build"] or seen[0][3:6] == ["pages", "functions", "build"]
402
+ body = fake.body("/deployments").decode()
403
+ assert 'name="_worker.bundle"; filename="_worker.bundle"' in body and "--bundle--" in body
404
+ assert 'name="functions-filepath-routing-config.json"; filename=' in body
405
+ assert 'name="_routes.json"; filename="_routes.json"' in body and "/api/*" in body
406
+ # The functions are never assets.
407
+ assert "[[path]]" not in fake.body("/check-missing").decode()
408
+
409
+
410
+ def test_the_sites_own_routes_beat_the_generated_ones(monkeypatch, tmp_path):
411
+ site = build(tmp_path / "dist", {"index.html": "x", "_routes.json": '{"include": ["/mine/*"]}'})
412
+ functions = build(tmp_path / "functions", {"api/x.ts": "export const onRequest = 1"})
413
+ fake_wrangler(monkeypatch)
414
+ fake = FakeHTTP()
415
+ deploy(monkeypatch, site, fake, functions=functions)
416
+
417
+ body = fake.body("/deployments").decode()
418
+ assert body.count('filename="_routes.json"') == 1
419
+ assert "/mine/*" in body and "/api/*" not in body
420
+
421
+
422
+ def test_a_missing_functions_tree_is_named(monkeypatch, tmp_path):
423
+ site = build(tmp_path / "dist", {"index.html": "x"})
424
+ with pytest.raises(MakeError, match="not a directory"):
425
+ deploy(monkeypatch, site, FakeHTTP(), functions=tmp_path / "nope")