mozbridge-cli 0.8.0__tar.gz → 0.9.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.
Files changed (60) hide show
  1. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/PKG-INFO +1 -1
  2. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/docs/commands.md +139 -10
  3. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/pyproject.toml +1 -1
  4. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/api.py +100 -1
  5. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_deploy.py +40 -2
  6. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_publish.py +160 -21
  7. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_shared.py +14 -0
  8. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_deploy.py +26 -0
  9. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_deploy_preflight.py +22 -0
  10. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_diff.py +32 -0
  11. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish.py +21 -0
  12. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish_local.py +22 -0
  13. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish_multi_component.py +20 -0
  14. mozbridge_cli-0.9.0/tests/test_publish_site.py +320 -0
  15. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/.gitignore +0 -0
  16. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/README.md +0 -0
  17. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/scripts/release.sh +0 -0
  18. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/__init__.py +0 -0
  19. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/__main__.py +0 -0
  20. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/auth.py +0 -0
  21. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/build.py +0 -0
  22. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_adopt.py +0 -0
  23. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_auth.py +0 -0
  24. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_config.py +0 -0
  25. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
  26. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_env.py +0 -0
  27. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_projects.py +0 -0
  28. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_registry.py +0 -0
  29. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
  30. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_token.py +0 -0
  31. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/compose.py +0 -0
  32. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/config.py +0 -0
  33. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/link.py +0 -0
  34. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/local_build.py +0 -0
  35. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/main.py +0 -0
  36. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  37. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/session.py +0 -0
  38. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/conftest.py +0 -0
  39. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_adopt.py +0 -0
  40. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_build.py +0 -0
  41. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_ci_token_auth.py +0 -0
  42. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_compose.py +0 -0
  43. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_config.py +0 -0
  44. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_doctor.py +0 -0
  45. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_env.py +0 -0
  46. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_link.py +0 -0
  47. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_local_build.py +0 -0
  48. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_login.py +0 -0
  49. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_login_cli.py +0 -0
  50. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_logout.py +0 -0
  51. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_logs.py +0 -0
  52. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_refresh.py +0 -0
  53. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_registry.py +0 -0
  54. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_rollback.py +0 -0
  55. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_runtime_secrets.py +0 -0
  56. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_secrets.py +0 -0
  57. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_session_permissions.py +0 -0
  58. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_status.py +0 -0
  59. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_token.py +0 -0
  60. {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mozbridge-cli
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
5
5
  Project-URL: Homepage, https://mozbridge.com
6
6
  Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
@@ -382,6 +382,96 @@ modes):
382
382
 
383
383
  ---
384
384
 
385
+ ## `mozbridge publish` — Launch sites (site-shaped projects)
386
+
387
+ Everything under `## \`mozbridge publish\`` above describes the **app-build**
388
+ path. A project can instead be **site-shaped** (a Launch site — static
389
+ hosting only, `config.shape == "site"` on the project, set by `mozbridge
390
+ create-site`/the dashboard's "Launch site" project type) — `publish`
391
+ resolves this automatically and takes a structurally different path for it.
392
+ This section is the site-shaped counterpart to everything above.
393
+
394
+ **Flags**: `--local` — see below, it's rejected outright for a site.
395
+
396
+ **Requires**: same as `publish` (link + login). No compose file / component
397
+ discovery ever runs for a site — a Launch site has no Docker image.
398
+
399
+ **What it does**:
400
+ 1. One extra call at the very start of `publish` (for *every* project, app or
401
+ site): `GET /api/v1/projects/{project_id}` to resolve
402
+ `config.shape` — the same one discriminator the backend itself uses
403
+ everywhere (`is_site_project`). This is the only new cost `publish` pays
404
+ for an app-shaped project — one cheap GET before it does exactly what it
405
+ always did.
406
+ 2. If `shape == "site"`: zips the current directory — the *exact same*
407
+ `build.build_zip` primitive `mozbridge build` and the app-build path use,
408
+ no separate site-zipping logic — then:
409
+ - `POST /api/v1/projects/{project_id}/publish-uploads` — creates a
410
+ short-lived upload session, returns `{upload_id, put_url, expires_at,
411
+ max_bytes, instructions}` (the site counterpart of `source-uploads`,
412
+ same response shape, different endpoint).
413
+ - `PUT {put_url}` — the raw zip bytes, `Authorization` + `X-Organization-Id`
414
+ + `Content-Type: application/zip`, same wire contract as the app-build
415
+ upload.
416
+ - `POST /api/v1/projects/{project_id}/publish-from-upload`, body
417
+ `{upload_id, immediate: true}` — consumes the upload and publishes it
418
+ right away. `immediate=true` is what this CLI always sends, for parity
419
+ with how `publish` already behaves for an app-shaped project (no
420
+ separate stage-then-confirm step).
421
+ 3. Prints the resulting task id/revision, then — since the
422
+ `publish-from-upload` response itself never carries the site's live URL —
423
+ the project's own `domain` (already known from step 1's `get_project`
424
+ call) as a clearly labeled `Site: https://...` line. This is the actual
425
+ deliverable of a site publish.
426
+
427
+ **Example**:
428
+ ```
429
+ $ mozbridge publish
430
+ Built /tmp/mozbridge-build-a1b2.zip (12.4 KB, 4 files)
431
+ Site publish triggered (task_id=abc123, revision=deadbeefcafe).
432
+ Site: https://launchpage.mozbridge.com
433
+ Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.
434
+ ```
435
+
436
+ **`--local` against a site fails immediately**: `--local` means "build and
437
+ push a Docker image on this machine" — meaningless for a site, which has no
438
+ Docker image at all. `_publish_local` checks the project's shape right after
439
+ resolving the login session (still before `MOZBRIDGE_BUILD_TOKEN`'s
440
+ runtime-secrets fetch, `docker login`, or any build/push) and, if the
441
+ project is a site, fails immediately:
442
+ ```
443
+ $ mozbridge publish --local
444
+ --local is for app builds; sites don't use Docker — run `mozbridge publish`
445
+ without --local.
446
+ ```
447
+ No docker command and no HTTP call beyond the shape check itself is ever
448
+ attempted.
449
+
450
+ **Failure modes** (in addition to `publish`'s own not-linked/not-logged-in
451
+ modes):
452
+ - The shape-check `get_project` call itself fails: the raw `ApiError`
453
+ message (same pattern as every other command), exit 1 — nothing else is
454
+ attempted.
455
+ - `create_publish_upload` fails: `Could not create a publish upload
456
+ session: <backend detail>`, exit 1.
457
+ - The `PUT` upload fails: `Could not upload the site content: <backend
458
+ detail>`, exit 1 — `publish-from-upload` is never called if the upload
459
+ failed.
460
+ - `publish-from-upload` itself fails: `Could not publish the site: <backend
461
+ detail>`, exit 1.
462
+ - `--local` against a site: the clear message above, exit 1, no docker or
463
+ HTTP call beyond the shape check.
464
+
465
+ **Known gap**: `mozbridge status`'s "recent deployments" list stays empty
466
+ for a site even after real publishes — see `## \`mozbridge status\`` below.
467
+ `mozbridge diff` describes the site-publish path in plain language instead
468
+ of the compose/component analysis (see `## \`mozbridge diff\`` below).
469
+ `mozbridge deploy` refuses outright for a site (see `## \`mozbridge
470
+ deploy\`` below) — `mozbridge rollback` is unaffected, it already works
471
+ identically for both shapes via one unified backend endpoint.
472
+
473
+ ---
474
+
385
475
  ## `mozbridge diff`
386
476
 
387
477
  A genuine dry-run/preview: shows what `mozbridge publish` would do right
@@ -393,15 +483,23 @@ now, without doing anything. Makes **no** mutating API call — GET-only
393
483
  **Requires**: link + login, same as `publish` and `status`.
394
484
 
395
485
  **What it does**:
396
- 1. `GET /api/v1/projects/{project_id}/deployments` — same call `status`
397
- makes — and prints the single most recent deployment record for
398
- context (id, timestamp, `component=version`, status, `[stable]`).
399
- 2. Discovers a compose file exactly as `publish` does (reusing
400
- `compose.py`). If none is found, or one is found but none of its
401
- services have a `build:` key, it says plainly that `publish` would fall
402
- back to the single-component path (zip the whole directory) — this is
403
- reported, not treated as an error.
404
- 3. If components are found, prints each one's resolved
486
+ 1. `GET /api/v1/projects/{project_id}` to resolve the project (and its
487
+ shape — see the site-shaped behavior below), and `GET
488
+ /api/v1/projects/{project_id}/deployments` — same call `status` makes —
489
+ and prints the single most recent deployment record for context (id,
490
+ timestamp, `component=version`, status, `[stable]`).
491
+ 2. **Site-shaped project**: stops here and prints, in plain language, what
492
+ `mozbridge publish` would actually do for a site (zip the directory,
493
+ create a publish-upload session, PUT the bytes, publish-from-upload with
494
+ `immediate=true`) — none of the compose/component analysis below applies,
495
+ a Launch site has no Docker image to build. See `## \`mozbridge publish\`
496
+ — Launch sites` above for the real flow this describes.
497
+ 3. **App-shaped project** (unchanged): discovers a compose file exactly as
498
+ `publish` does (reusing `compose.py`). If none is found, or one is found
499
+ but none of its services have a `build:` key, it says plainly that
500
+ `publish` would fall back to the single-component path (zip the whole
501
+ directory) — this is reported, not treated as an error.
502
+ 4. If components are found, prints each one's resolved
405
503
  `image_name`/`context_path`/`dockerfile_path` — computed by the exact
406
504
  same `compose.resolve_components` function `publish` calls to build its
407
505
  `trigger_build` request bodies, so `diff`'s output can never silently
@@ -487,6 +585,14 @@ only when `is_stable` is true on that deployment. If there are no
487
585
  deployments at all, the project line still prints, followed by `No
488
586
  deployments yet.`
489
587
 
588
+ **KNOWN GAP — Launch sites**: `/deployments` only ever returns rows written
589
+ by the app-build deploy task; a site's own publish path never writes one.
590
+ For a site-shaped project this command currently prints `No deployments
591
+ yet.` even after several real publishes, even though the project's own
592
+ config tracks its actual revision history. Not yet fixed — see the CLI
593
+ site-project-support notes rather than assuming this is a bug specific to
594
+ your project.
595
+
490
596
  **Failure modes**:
491
597
  - Not linked / not logged in: identical messages to `publish` above,
492
598
  exit 1.
@@ -500,6 +606,11 @@ deployments yet.`
500
606
 
501
607
  Rolls back the linked project's deployment. A real, consequential
502
608
  production action — prompts for confirmation unless `--yes` is given.
609
+ Unlike `publish`/`deploy`, this command needs **no shape check** and no
610
+ change for this feature: the backend already unifies app- and site-shaped
611
+ rollback behind these same two endpoints
612
+ (`rollback_project_or_site`/`rollback_to_specific`), so it already works
613
+ identically for both.
503
614
 
504
615
  **Flags**:
505
616
  | Flag | Meaning |
@@ -567,7 +678,21 @@ confirmation unless `--yes` is given.
567
678
  one of `--frontend`/`--backend` must be given — deploying neither makes no
568
679
  sense.
569
680
 
570
- **What it does**: prints what it's about to do (project, which service(s),
681
+ **Not applicable to a Launch site**: a site has no image tag to deploy at
682
+ all. Right after resolving the link/session — before the preflight check,
683
+ before the confirmation prompt, before any deploy request — `deploy` checks
684
+ the linked project's shape (`GET /api/v1/projects/{project_id}`) and, if
685
+ it's a site, fails immediately:
686
+ ```
687
+ $ mozbridge deploy --backend v1.9.319 --yes
688
+ This project is a Launch site — sites deploy via `mozbridge publish`, not
689
+ `mozbridge deploy` (there is no image tag to deploy for a site). Run
690
+ `mozbridge publish` instead.
691
+ ```
692
+ No preflight call and no deploy request is ever sent for a site — this
693
+ never becomes a confusing raw 400 from the backend.
694
+
695
+ **What it does** (app-shaped project): prints what it's about to do (project, which service(s),
571
696
  which tag(s), which environment), then either aborts on a declined prompt or
572
697
  calls `POST /api/v1/projects/{project_id}/deploy` with
573
698
  `{frontend_version, backend_version, services, environment, force}`.
@@ -620,6 +745,10 @@ Re-run with --force to override — this is not done automatically.
620
745
  - Neither `--frontend` nor `--backend` given: clear error, exit 1, no API
621
746
  call is made.
622
747
  - Not linked / not logged in: identical messages to `publish` above, exit 1.
748
+ - The shape-check `get_project` call itself fails: the raw `ApiError`
749
+ message, exit 1 — nothing else is attempted.
750
+ - Linked project is a Launch site: the clear message above, exit 1, no
751
+ preflight/deploy call ever sent.
623
752
  - Confirmation prompt declined (`--yes` not passed, answer is not `y`):
624
753
  Typer's own `Abort` handling — the command exits non-zero without calling
625
754
  the API.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mozbridge-cli"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -134,7 +134,16 @@ def put_source_upload_bytes(
134
134
 
135
135
 
136
136
  def get_project(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
137
- """GET /api/v1/projects/{project_id} — schemas.Project (name/slug/status/config/...)."""
137
+ """GET /api/v1/projects/{project_id} — schemas.Project (name/slug/status/config/...).
138
+
139
+ `config` (a plain dict, see schemas.Project) is what carries the project's
140
+ "shape" — `(project.get("config") or {}).get("shape") == "site"` for a
141
+ Launch site, absent/anything else for an app build. This is the SAME
142
+ discriminator the backend itself uses everywhere
143
+ (`app/services/site_service.py:is_site_project`), so callers needing to
144
+ branch on shape (see cmd_shared.is_site_project) should call this rather
145
+ than adding a second, parallel "get the shape" endpoint.
146
+ """
138
147
  url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
139
148
  try:
140
149
  resp = client.get(url, headers=_headers(access_token, org_id))
@@ -144,6 +153,96 @@ def get_project(client: httpx.Client, access_token: str, org_id: int, project_id
144
153
  return resp.json()
145
154
 
146
155
 
156
+ def create_publish_upload(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
157
+ """POST /api/v1/projects/{project_id}/publish-uploads — Launch-site counterpart to
158
+ `create_source_upload` above.
159
+
160
+ Sites don't get a Docker build — they get a static-file publish
161
+ (`backend/app/features/projects/router.py`'s `create_publish_upload`,
162
+ ~line 464), 400s if the project isn't `shape=site` (`is_site_project`).
163
+
164
+ Returns the SAME shape as `create_source_upload`
165
+ ({upload_id, put_url, expires_at, max_bytes, instructions}) — the backend
166
+ reuses `schemas.SitePublishUploadSession` for both, so this is a distinct
167
+ function (not an alias) only because it hits a distinct, site-only route.
168
+ """
169
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/publish-uploads"
170
+ try:
171
+ resp = client.post(url, headers=_headers(access_token, org_id))
172
+ except httpx.HTTPError as exc:
173
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
174
+ _raise_for_status(resp, "create a publish upload session")
175
+ return resp.json()
176
+
177
+
178
+ def put_publish_upload_bytes(
179
+ client: httpx.Client,
180
+ access_token: str,
181
+ org_id: int,
182
+ put_url: str,
183
+ data: bytes,
184
+ ) -> None:
185
+ """PUT the zip bytes to the put_url returned by create_publish_upload.
186
+
187
+ Same wire contract as `put_source_upload_bytes` above (Authorization +
188
+ X-Organization-Id + `application/zip` body) — the backend's
189
+ `put_publish_upload` route (router.py ~line 504) accepts the raw body the
190
+ same way `put_source_upload` does. Kept as a separate function (rather
191
+ than reusing `put_source_upload_bytes` directly) purely so a failure here
192
+ reads as "upload the site content", not "upload the build source" — the
193
+ two flows are conceptually distinct even though the bytes-on-the-wire
194
+ shape is identical.
195
+ """
196
+ try:
197
+ resp = client.put(
198
+ put_url,
199
+ content=data,
200
+ headers={**_headers(access_token, org_id), "Content-Type": "application/zip"},
201
+ )
202
+ except httpx.HTTPError as exc:
203
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
204
+ _raise_for_status(resp, "upload the site content")
205
+
206
+
207
+ def publish_from_upload(
208
+ client: httpx.Client,
209
+ access_token: str,
210
+ org_id: int,
211
+ project_id: int,
212
+ upload_id: str,
213
+ *,
214
+ immediate: bool = True,
215
+ ) -> dict:
216
+ """POST /api/v1/projects/{project_id}/publish-from-upload, body schemas.SitePublishFromUpload.
217
+
218
+ Consumes a ready `create_publish_upload` session and ingests it exactly
219
+ like the multipart publish endpoint (router.py's `publish_site_from_upload`,
220
+ ~line 612) — 400s if the project isn't `shape=site`.
221
+
222
+ `immediate` defaults True: this CLI's `publish` command always calls with
223
+ immediate=True, matching how `publish` already behaves for app-shaped
224
+ projects (no separate stage-then-confirm step) — see cmd_publish.py's
225
+ `_publish_site`. With immediate=True the revision is deployed right away
226
+ and the response's `status` comes back "queued" with `preview_url` unset;
227
+ with immediate=False (not currently used by this CLI, kept as a real
228
+ parameter rather than hardcoding True so a future `--stage` flag doesn't
229
+ need a new function) the revision is only staged, `status` comes back
230
+ "staged" and `preview_url` is populated instead.
231
+
232
+ Returns schemas.SitePublishResponse: {task_id, revision, status,
233
+ preview_url}. Note this response never carries the site's live URL —
234
+ that's `project.domain` (from `get_project`), not anything in this body.
235
+ """
236
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/publish-from-upload"
237
+ payload = {"upload_id": upload_id, "immediate": immediate}
238
+ try:
239
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
240
+ except httpx.HTTPError as exc:
241
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
242
+ _raise_for_status(resp, "publish the site")
243
+ return resp.json()
244
+
245
+
147
246
  def list_project_deployments(
148
247
  client: httpx.Client, access_token: str, org_id: int, project_id: int
149
248
  ) -> list[dict]:
@@ -9,7 +9,7 @@ import httpx
9
9
  import typer
10
10
 
11
11
  from . import api
12
- from .cmd_shared import require_link_and_session
12
+ from .cmd_shared import is_site_project, require_link_and_session
13
13
 
14
14
  app = typer.Typer(add_completion=False)
15
15
 
@@ -43,7 +43,20 @@ def _print_preflight_notice(preflight: dict) -> None:
43
43
 
44
44
  @app.command("status")
45
45
  def status_cmd() -> None:
46
- """Show the linked project's status and its 5 most recent deployments."""
46
+ """Show the linked project's status and its 5 most recent deployments.
47
+
48
+ KNOWN GAP for a site-shaped (Launch site) project: `list_project_deployments`
49
+ only ever returns rows written by the app-build deploy task
50
+ (`models.ProjectDeployment`, created in `app/tasks.py`'s `deploy_project`
51
+ task) — a site's own publish path (`SiteService.ingest_zip` /
52
+ `_enqueue_deploy`, driven by `publish_static_site`) never writes one. So
53
+ for a site this command will print "No deployments yet." even after
54
+ several real publishes, even though the project's `config` (last_revision/
55
+ staged_revision/stable_revision) shows otherwise. Left unfixed
56
+ deliberately in this slice — see the CLI site-project-support docs for the
57
+ full reasoning — rather than guessed at without a confirmed design for
58
+ what a site's own "recent activity" list should look like.
59
+ """
47
60
  cwd = Path.cwd()
48
61
 
49
62
  with httpx.Client(timeout=30.0) as client:
@@ -171,6 +184,14 @@ def deploy_cmd(
171
184
  still proceeds to the normal confirmation flow — matching the
172
185
  backend's own fail-open design for this gate rather than being
173
186
  stricter than the platform itself is.
187
+
188
+ Not applicable to a site-shaped (Launch site) project: a site has no
189
+ image tag to deploy at all — `--frontend`/`--backend` are meaningless for
190
+ it. If the linked project turns out to be a site, this command fails
191
+ immediately (right after resolving the link/session, before the preflight
192
+ check or any deploy request) with a clear error pointing at `mozbridge
193
+ publish` instead — it never sends the nonsensical deploy request to the
194
+ backend and lets a raw API error stand in for that explanation.
174
195
  """
175
196
  if frontend is None and backend is None:
176
197
  typer.echo(
@@ -190,6 +211,23 @@ def deploy_cmd(
190
211
  with httpx.Client(timeout=30.0) as client:
191
212
  current_link, active_session = require_link_and_session(client, cwd)
192
213
 
214
+ try:
215
+ project = api.get_project(
216
+ client, active_session.access_token, current_link.org_id, current_link.project_id
217
+ )
218
+ except api.ApiError as exc:
219
+ typer.echo(str(exc), err=True)
220
+ raise typer.Exit(code=1)
221
+
222
+ if is_site_project(project):
223
+ typer.echo(
224
+ "This project is a Launch site — sites deploy via `mozbridge publish`, "
225
+ "not `mozbridge deploy` (there is no image tag to deploy for a site). "
226
+ "Run `mozbridge publish` instead.",
227
+ err=True,
228
+ )
229
+ raise typer.Exit(code=1)
230
+
193
231
  try:
194
232
  preflight = api.get_project_preflight(
195
233
  client, active_session.access_token, current_link.org_id, current_link.project_id
@@ -15,7 +15,7 @@ from . import link as linkmod
15
15
  from . import local_build as local_buildmod
16
16
  from . import runtime_secrets as runtime_secretsmod
17
17
  from .cmd_auth import load_and_refresh_session
18
- from .cmd_shared import require_link_and_session
18
+ from .cmd_shared import is_site_project, require_link_and_session
19
19
 
20
20
  app = typer.Typer(add_completion=False)
21
21
 
@@ -104,6 +104,80 @@ def _publish_single_component(
104
104
  typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
105
105
 
106
106
 
107
+ def _publish_site(
108
+ client: httpx.Client,
109
+ active_session: session.Session,
110
+ current_link: linkmod.Link,
111
+ cwd: Path,
112
+ project: dict,
113
+ ) -> None:
114
+ """`mozbridge publish` for a Launch site (shape=site): the site-shaped
115
+ counterpart of `_publish_single_component` above — same shape (zip -> create
116
+ an upload session -> PUT the bytes -> tell the backend to consume it), but
117
+ against the site-only endpoints, and it always publishes immediately (no
118
+ build to enqueue, no task to poll for a docker image; the platform serves
119
+ the zip's contents directly).
120
+
121
+ Uses the EXACT SAME zip (`buildmod.build_zip`) as `mozbridge build` and the
122
+ app-build path above — no separate zipping logic for sites.
123
+ """
124
+ result = buildmod.build_zip(cwd)
125
+ typer.echo(f"Built {result.zip_path} ({result.size_bytes / 1024:.1f} KB, {result.file_count} files)")
126
+ try:
127
+ try:
128
+ upload_session = api.create_publish_upload(
129
+ client,
130
+ active_session.access_token,
131
+ current_link.org_id,
132
+ current_link.project_id,
133
+ )
134
+ except api.ApiError as exc:
135
+ typer.echo(str(exc), err=True)
136
+ raise typer.Exit(code=1)
137
+
138
+ zip_bytes = result.zip_path.read_bytes()
139
+ try:
140
+ api.put_publish_upload_bytes(
141
+ client,
142
+ active_session.access_token,
143
+ current_link.org_id,
144
+ upload_session["put_url"],
145
+ zip_bytes,
146
+ )
147
+ except api.ApiError as exc:
148
+ typer.echo(str(exc), err=True)
149
+ raise typer.Exit(code=1)
150
+ finally:
151
+ result.zip_path.unlink(missing_ok=True)
152
+
153
+ try:
154
+ publish_response = api.publish_from_upload(
155
+ client,
156
+ active_session.access_token,
157
+ current_link.org_id,
158
+ current_link.project_id,
159
+ upload_session["upload_id"],
160
+ immediate=True,
161
+ )
162
+ except api.ApiError as exc:
163
+ typer.echo(str(exc), err=True)
164
+ raise typer.Exit(code=1)
165
+
166
+ task_id = publish_response.get("task_id")
167
+ revision = publish_response.get("revision") or ""
168
+ typer.echo(f"Site publish triggered (task_id={task_id}, revision={revision[:12]}).")
169
+
170
+ # The publish response itself never carries the site's live URL (see
171
+ # api.publish_from_upload's docstring) — it's the project's own domain,
172
+ # which we already have from the get_project call publish_cmd made to
173
+ # resolve the project's shape in the first place. This is the actual
174
+ # deliverable of a site publish, so it gets its own clearly-labeled line.
175
+ domain = project.get("domain")
176
+ if domain:
177
+ typer.echo(f"Site: https://{domain}")
178
+ typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
179
+
180
+
107
181
  def _publish_multi_component(
108
182
  client: httpx.Client,
109
183
  active_session: session.Session,
@@ -199,21 +273,32 @@ def publish_cmd(
199
273
  Requires `mozbridge link` to have been run here, and an active login
200
274
  session.
201
275
 
202
- If a compose file is present (docker-compose.yml, compose.yaml, or
203
- docker-compose.yaml — Docker's own discovery order) and at least one of
204
- its services has a `build:` key, each such service is published as its
205
- own component/image: one create_source_upload -> PUT -> trigger_build
206
- per component, with contexts shared across services de-duplicated to a
207
- single upload. Otherwise (no compose file, or none of its services
208
- build) this falls back unchanged to the original single-component flow:
209
- zip the whole directory, one upload, one trigger_build call.
276
+ First resolves the linked project's shape (one cheap `get_project` call)
277
+ and branches:
278
+
279
+ - **Site-shaped** (Launch site, `config.shape == "site"`): zips the whole
280
+ directory and publishes it as a static site — see `_publish_site`
281
+ below. None of the compose/component logic below applies; a site has
282
+ no Docker image to build.
283
+ - **App-shaped** (everything else, the original behavior, unchanged): if
284
+ a compose file is present (docker-compose.yml, compose.yaml, or
285
+ docker-compose.yaml — Docker's own discovery order) and at least one of
286
+ its services has a `build:` key, each such service is published as its
287
+ own component/image: one create_source_upload -> PUT -> trigger_build
288
+ per component, with contexts shared across services de-duplicated to a
289
+ single upload. Otherwise (no compose file, or none of its services
290
+ build) this falls back unchanged to the original single-component flow:
291
+ zip the whole directory, one upload, one trigger_build call.
210
292
 
211
293
  `--local` replaces the upload+remote-build step entirely: components are
212
294
  resolved the same way, but each is built and pushed with the developer's
213
295
  own local `docker`, using registry credentials fetched with a SEPARATE
214
296
  build-scoped token (MOZBRIDGE_BUILD_TOKEN), then registered via the same
215
297
  trigger_build endpoint using the regular login session. See
216
- `_publish_local` below.
298
+ `_publish_local` below. `--local` has no meaning for a site-shaped
299
+ project (sites don't build a Docker image at all) — `_publish_local`
300
+ checks this itself and fails immediately, before any docker or upload
301
+ call, if the linked project turns out to be a site.
217
302
  """
218
303
  cwd = Path.cwd()
219
304
  current_link = linkmod.load_link(cwd)
@@ -227,14 +312,26 @@ def publish_cmd(
227
312
  _publish_local(cwd, current_link)
228
313
  return
229
314
 
230
- components: list[composemod.Component] = []
231
- compose_path = composemod.find_compose_file(cwd)
232
- if compose_path is not None:
233
- components = composemod.parse_components(compose_path)
234
-
235
315
  with httpx.Client(timeout=120.0) as client:
236
316
  active_session = load_and_refresh_session(client)
237
317
 
318
+ try:
319
+ project = api.get_project(
320
+ client, active_session.access_token, current_link.org_id, current_link.project_id
321
+ )
322
+ except api.ApiError as exc:
323
+ typer.echo(str(exc), err=True)
324
+ raise typer.Exit(code=1)
325
+
326
+ if is_site_project(project):
327
+ _publish_site(client, active_session, current_link, cwd, project)
328
+ return
329
+
330
+ components: list[composemod.Component] = []
331
+ compose_path = composemod.find_compose_file(cwd)
332
+ if compose_path is not None:
333
+ components = composemod.parse_components(compose_path)
334
+
238
335
  if components:
239
336
  typer.echo(f"Using {compose_path.name} for multi-component publish.")
240
337
  groups = composemod.resolve_components(
@@ -256,7 +353,11 @@ def _publish_local(cwd: Path, current_link: linkmod.Link) -> None:
256
353
  Order of checks is deliberate: cheap, local, no-network checks first
257
354
  (MOZBRIDGE_BUILD_TOKEN present, docker installed/daemon reachable) so a
258
355
  misconfigured environment fails fast with zero docker or HTTP calls,
259
- before login-session refresh or any network call is attempted.
356
+ before login-session refresh or any network call is attempted. The one
357
+ network-shaped check that can't be done locally — is this project even a
358
+ site, which makes `--local` meaningless — runs right after session
359
+ refresh, still before runtime-secrets/docker-login/build/push, so a site
360
+ project fails clearly without ever touching docker or a registry.
260
361
  """
261
362
  build_token = os.environ.get(runtime_secretsmod.BUILD_TOKEN_ENV_VAR)
262
363
  if not build_token:
@@ -288,6 +389,22 @@ def _publish_local(cwd: Path, current_link: linkmod.Link) -> None:
288
389
  with httpx.Client(timeout=180.0) as client:
289
390
  active_session = load_and_refresh_session(client)
290
391
 
392
+ try:
393
+ project = api.get_project(
394
+ client, active_session.access_token, current_link.org_id, current_link.project_id
395
+ )
396
+ except api.ApiError as exc:
397
+ typer.echo(str(exc), err=True)
398
+ raise typer.Exit(code=1)
399
+
400
+ if is_site_project(project):
401
+ typer.echo(
402
+ "--local is for app builds; sites don't use Docker — run `mozbridge publish` "
403
+ "without --local.",
404
+ err=True,
405
+ )
406
+ raise typer.Exit(code=1)
407
+
291
408
  try:
292
409
  secrets = runtime_secretsmod.fetch_runtime_secrets(client, build_token)
293
410
  except runtime_secretsmod.RuntimeSecretsError as exc:
@@ -410,21 +527,34 @@ def diff_cmd() -> None:
410
527
  """Preview what `mozbridge publish` would do right now. Does not publish anything.
411
528
 
412
529
  Requires `mozbridge link` and an active login session, same as `publish`.
413
- Makes no mutating API calls: it only GETs the linked project's most
414
- recent deployment (the same call `mozbridge status` makes) for context,
415
- plus local filesystem reads to work out what `publish` would build. No
416
- upload, no trigger_build.
530
+ Makes no mutating API calls: it only GETs the linked project itself (to
531
+ resolve its shape) and its most recent deployment (the same call
532
+ `mozbridge status` makes) for context, plus local filesystem reads to
533
+ work out what `publish` would build. No upload, no trigger_build.
417
534
 
418
535
  This compares local components against the most recent deployment
419
536
  RECORD only — the platform does not expose the actually-deployed
420
537
  compose content anywhere, so this is not a full semantic compose diff,
421
- just "here's what's live" next to "here's what publish would build".
538
+ just "here's what's live" next to "here's what publish would build". For
539
+ a site-shaped project, the deployment-record list is normally empty even
540
+ after real publishes — see `mozbridge status`'s own known gap, the same
541
+ limitation applies here — and the compose/component analysis below is
542
+ skipped entirely in favor of a plain statement of what `publish` would
543
+ actually do for a site.
422
544
  """
423
545
  cwd = Path.cwd()
424
546
 
425
547
  with httpx.Client(timeout=30.0) as client:
426
548
  current_link, active_session = require_link_and_session(client, cwd)
427
549
 
550
+ try:
551
+ project = api.get_project(
552
+ client, active_session.access_token, current_link.org_id, current_link.project_id
553
+ )
554
+ except api.ApiError as exc:
555
+ typer.echo(str(exc), err=True)
556
+ raise typer.Exit(code=1)
557
+
428
558
  try:
429
559
  deployments = api.list_project_deployments(
430
560
  client, active_session.access_token, current_link.org_id, current_link.project_id
@@ -452,6 +582,15 @@ def diff_cmd() -> None:
452
582
  typer.echo(" No deployments yet.")
453
583
  typer.echo("")
454
584
 
585
+ if is_site_project(project):
586
+ typer.echo(
587
+ "This project is a Launch site — `mozbridge publish` would zip the current "
588
+ "directory and publish it as a static site (create a publish-upload session, "
589
+ "PUT the zip, then publish-from-upload with immediate=true), not build a Docker "
590
+ "image. The compose/component analysis below does not apply to Launch sites."
591
+ )
592
+ return
593
+
455
594
  components: list[composemod.Component] = []
456
595
  compose_path = composemod.find_compose_file(cwd)
457
596
  if compose_path is not None:
@@ -29,3 +29,17 @@ def require_link_and_session(client: httpx.Client, cwd: Path) -> tuple[linkmod.L
29
29
  raise typer.Exit(code=1)
30
30
  active_session = load_and_refresh_session(client)
31
31
  return current_link, active_session
32
+
33
+
34
+ def is_site_project(project: dict) -> bool:
35
+ """True when `project` (an api.get_project response) is a Launch site
36
+ (`config.shape == "site"`), not an app build.
37
+
38
+ Mirrors `backend/app/services/site_service.py`'s own `is_site_project` —
39
+ the ONE discriminator the backend uses everywhere to route between the
40
+ app-build and static-site code paths. Shape-aware commands
41
+ (`publish`, `publish --local`, `deploy`, `diff`) all call this against
42
+ whatever `api.get_project` already returned, rather than adding a second,
43
+ parallel "get the shape" endpoint.
44
+ """
45
+ return (project.get("config") or {}).get("shape") == "site"