@mediamonks/canopy-cli 0.7.44 → 0.7.46

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 (55) hide show
  1. package/dist/{ai-FBHTSCLZ.mjs → ai-CWPQiUft.mjs} +2 -2
  2. package/dist/{api-Bl6SG8Oq.mjs → api-DmJrIsE7.mjs} +134 -19
  3. package/dist/{browse-B8-0dweq.mjs → browse-BPLWhoFI.mjs} +2 -2
  4. package/dist/{db-BJJGydgL.mjs → db-DpCMc0uz.mjs} +4 -4
  5. package/dist/{deploy-ExrIeX_m.mjs → deploy-BnRQusjJ.mjs} +51 -5
  6. package/dist/{dev-Cq98yblV.mjs → dev-BmKeIdMi.mjs} +7 -7
  7. package/dist/dev-BvVlq9np.mjs +2 -0
  8. package/dist/docs/ci.md +112 -50
  9. package/dist/docs/cli-create.md +2 -0
  10. package/dist/docs/cli-deploy.md +1 -1
  11. package/dist/docs/cli-members.md +18 -11
  12. package/dist/docs/cli-repo.md +34 -2
  13. package/dist/docs/cli-token.md +8 -6
  14. package/dist/docs/cli.md +6 -2
  15. package/dist/docs/deploy.md +3 -2
  16. package/dist/docs/index.json +5 -5
  17. package/dist/docs/platform-deploys-and-rollbacks.md +4 -4
  18. package/dist/docs/platform-members-and-access.md +28 -9
  19. package/dist/docs/roles.md +1 -1
  20. package/dist/{docs-KmN6jD1S.mjs → docs-CtMrMpt1.mjs} +4 -4
  21. package/dist/{egress-C2amvqmE.mjs → egress-BM4EpLBh.mjs} +3 -3
  22. package/dist/{feedback-B5-95neo.mjs → feedback-BXhbxyXI.mjs} +5 -5
  23. package/dist/index.mjs +35 -35
  24. package/dist/{local-CIkL7tXP.mjs → local-C9Bi9f3y.mjs} +2 -2
  25. package/dist/{login-CREcZfLH.mjs → login-2MW9sGJO.mjs} +3 -3
  26. package/dist/{logs-BnrNZvLc.mjs → logs-dZMPTJLu.mjs} +3 -3
  27. package/dist/{mcp-DZdMNTWr.mjs → mcp-CGmWkGN8.mjs} +3 -3
  28. package/dist/{migrations-D8NqjC5I.mjs → migrations-BReMlGWP.mjs} +2 -2
  29. package/dist/{open-Bpz4C2Sr.mjs → open-DcQSdfx5.mjs} +2 -2
  30. package/dist/{package-Bvly8-rB.mjs → package-BdNHq8QV.mjs} +1 -1
  31. package/dist/{project-template-Cdr1uwwE.mjs → project-template-BPW_Dgxj.mjs} +3 -3
  32. package/dist/project-template-Dw11151p.mjs +2 -0
  33. package/dist/{projects-BRG2HnBs.mjs → projects-CnU3194_.mjs} +26 -8
  34. package/dist/{rename-BGKa25Rq.mjs → rename-BWvERJgA.mjs} +3 -3
  35. package/dist/{repo-CwT2RDoW.mjs → repo-6a7gRQaf.mjs} +171 -8
  36. package/dist/repo-C7AXDtFZ.mjs +2 -0
  37. package/dist/{roles-BDZaN3vi.mjs → roles-CrYf_8wk.mjs} +1 -1
  38. package/dist/{roles-UM0Iiws6.mjs → roles-S2D9Id0W.mjs} +71 -13
  39. package/dist/{schedule-BcqBspn3.mjs → schedule-DvHSSzfR.mjs} +3 -3
  40. package/dist/{secrets-BjbIi7Gp.mjs → secrets-BIn2RPoe.mjs} +3 -3
  41. package/dist/{site-0i7wAJqg.mjs → site-BL5Ik-yj.mjs} +50 -1
  42. package/dist/{skill-DRLniv-I.mjs → skill-vzcCD1b4.mjs} +2 -2
  43. package/dist/{staging-_2Js0Fkc.mjs → staging-Dn8kH9cK.mjs} +3 -3
  44. package/dist/templates/spa/.github/workflows/canopy-deploy.yml +18 -12
  45. package/dist/templates/spa/README.md +1 -1
  46. package/dist/templates/worker/.github/workflows/canopy-deploy.yml +18 -12
  47. package/dist/templates/worker/README.md +1 -1
  48. package/dist/{token-7vSNuz-X.mjs → token-DMCj88A-.mjs} +6 -6
  49. package/dist/{ui-CLnICOne.mjs → ui-26ETk1n8.mjs} +1 -1
  50. package/dist/{upgrade-D_yrD7zp.mjs → upgrade-8_pFNTo5.mjs} +2 -2
  51. package/dist/{usage-DGGmTwjJ.mjs → usage-Bpz5B1N1.mjs} +3 -3
  52. package/package.json +1 -1
  53. package/dist/dev-CUJKgaqJ.mjs +0 -2
  54. package/dist/project-template-715H_lnu.mjs +0 -2
  55. package/dist/repo-DmFs6H9m.mjs +0 -2
@@ -1,5 +1,5 @@
1
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
2
- import { r as reportErrors } from "./ui-CLnICOne.mjs";
1
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
2
+ import { r as reportErrors } from "./ui-26ETk1n8.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/ai.ts
5
5
  /**
@@ -1,9 +1,11 @@
1
- import { r as siteConfig, t as projectDir, u as rewriteCanopyJsonSlug } from "./site-0i7wAJqg.mjs";
1
+ import { f as rewriteCanopyJsonSlug, m as tryReadCanopyJson, r as siteConfig, t as projectDir } from "./site-BL5Ik-yj.mjs";
2
2
  //#region src/api.ts
3
3
  /**
4
4
  * Authenticated requests to the Canopy control plane. Credentials, in order:
5
5
  * CANOPY_DEPLOY_TOKEN (CI — a project-scoped deploy token, docs/02-decisions.md
6
- * D11), then the CLI token from `canopy login` (~/.canopy/config.json).
6
+ * D11), then — inside GitHub Actions — a short-lived deploy token exchanged
7
+ * for the run's GitHub identity (OpenSpec platform-oauth-oidc), then the CLI
8
+ * token from `canopy login` (~/.canopy/config.json).
7
9
  */
8
10
  /**
9
11
  * Human copy for the error codes the control plane returns (apps/api): `why`
@@ -192,6 +194,38 @@ const ERROR_COPY = {
192
194
  why: "the secret was saved but redeploying the running worker with it failed",
193
195
  fix: "Retry, or redeploy the project (`canopy deploy`) to apply it."
194
196
  },
197
+ repository_not_linked: {
198
+ why: "the project is not linked to a GitHub repository",
199
+ fix: "Link it with `canopy repo link <owner/name>` (owner only), or set CANOPY_DEPLOY_TOKEN."
200
+ },
201
+ ci_deploys_off: {
202
+ why: "CI deploys are turned off for this project",
203
+ fix: "Turn them back on with `canopy repo deploys on` (owner only)."
204
+ },
205
+ repository_mismatch: {
206
+ why: "this run's repository is not the one linked to the project",
207
+ fix: "Run the workflow from the linked repository, or link this one with `canopy repo link` (`canopy repo show` names the current link)."
208
+ },
209
+ ref_not_allowed: {
210
+ why: "this run's branch is neither the project's live branch nor its staging branch",
211
+ fix: "Deploy from the linked branch, or change it with `canopy repo branches --ref <ref>` (`canopy repo show` names both)."
212
+ },
213
+ invalid_id_token: {
214
+ why: "the platform did not accept this run's GitHub identity — it comes from another GitHub host than the linked repository's, or was minted for another Canopy site",
215
+ fix: "Check `canopy repo show` names this repository's host, and that the workflow deploys to the site canopy.json pins."
216
+ },
217
+ channel_not_allowed: {
218
+ why: "this run is on the project's staging branch, which only deploys to staging",
219
+ fix: "Deploy, promote or roll back live from the live branch, or by hand after `canopy login`."
220
+ },
221
+ invalid_repository: {
222
+ why: "the repository must be written as owner/name",
223
+ fix: "Pass it like `canopy repo link monks/my-tool` (or `monks.ghe.com/monks/my-tool` to name the host)."
224
+ },
225
+ invalid_ref: {
226
+ why: "branches are full git refs, and the staging branch must differ from the live one",
227
+ fix: "Pass e.g. `--ref refs/heads/main --staging-ref refs/heads/develop`."
228
+ },
195
229
  slug_unchanged: {
196
230
  why: "the project already has that slug",
197
231
  fix: "Pass the new slug you want, e.g. `canopy rename my-new-name`."
@@ -369,31 +403,112 @@ async function withContext(request, context) {
369
403
  throw err;
370
404
  }
371
405
  }
372
- /** Resolve the bearer token: CI deploy token first (D11), then the login token. */
373
- function credentials() {
406
+ /**
407
+ * Resolve the bearer token: CI deploy token first (D11), then the GitHub
408
+ * Actions exchange on a runner that may request an identity token, then the
409
+ * login token. The exchange comes before the "Not logged in" check because a
410
+ * runner has no ~/.canopy/config.json.
411
+ */
412
+ async function credentials() {
374
413
  const config = siteConfig();
375
414
  const deployToken = process.env["CANOPY_DEPLOY_TOKEN"];
376
- const token = deployToken || config.token;
377
- if (!token) throw new Error("Not logged in — run `canopy login` first.");
415
+ if (deployToken) return {
416
+ apiUrl: config.apiUrl,
417
+ token: deployToken,
418
+ isDeployToken: true,
419
+ kind: "deploy-token"
420
+ };
421
+ if (onGithubActions()) {
422
+ if (process.env["ACTIONS_ID_TOKEN_REQUEST_URL"] || !config.token) return {
423
+ ...await githubActionsCredentials(),
424
+ isDeployToken: true,
425
+ kind: "github-actions"
426
+ };
427
+ }
428
+ if (!config.token) throw new Error("Not logged in — run `canopy login` first.");
378
429
  return {
379
430
  apiUrl: config.apiUrl,
380
- token,
381
- isDeployToken: Boolean(deployToken)
431
+ token: config.token,
432
+ isDeployToken: false,
433
+ kind: "login"
382
434
  };
383
435
  }
436
+ /** Running inside a GitHub Actions job (the runner sets GITHUB_ACTIONS=true). */
437
+ function onGithubActions(env = process.env) {
438
+ return Boolean(env["GITHUB_ACTIONS"]);
439
+ }
440
+ let exchange;
441
+ /**
442
+ * Trade this GitHub Actions run's OIDC token for a short-lived Canopy deploy
443
+ * token (design D-I). Once per process: every later request reuses it, and a
444
+ * failure stays a failure. The audience is the resolved site's API origin, so
445
+ * the token cannot be replayed on another Canopy site (D-B). `slug` is the
446
+ * project to deploy; it defaults to canopy.json's.
447
+ */
448
+ function githubActionsCredentials(slug) {
449
+ exchange ??= exchangeGithubIdentity(slug);
450
+ return exchange;
451
+ }
452
+ async function exchangeGithubIdentity(slug) {
453
+ const { apiUrl } = siteConfig();
454
+ const project = slug ?? tryReadCanopyJson(projectDir())?.slug;
455
+ if (!project) throw new Error("No project to deploy — run from a directory with a canopy.json naming its slug, or pass --project.");
456
+ const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
457
+ const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
458
+ if (!requestUrl || !requestToken) throw new Error("This GitHub Actions job cannot prove which repository it runs from. Add `permissions: id-token: write` (and `contents: read`) to the workflow or job, or set CANOPY_DEPLOY_TOKEN.");
459
+ const audience = new URL(apiUrl).origin;
460
+ const oidcUrl = new URL(requestUrl);
461
+ oidcUrl.searchParams.set("audience", audience);
462
+ const oidc = await fetch(oidcUrl, { headers: { authorization: `Bearer ${requestToken}` } });
463
+ const idToken = oidc.ok ? (await oidc.json()).value : void 0;
464
+ if (!idToken) throw new Error(`GitHub did not issue an identity token for this run (HTTP ${oidc.status}). Check the workflow has \`permissions: id-token: write\`.`);
465
+ const path = "/auth/github-actions";
466
+ const response = await fetch(`${apiUrl}/control/v1${path}`, {
467
+ method: "POST",
468
+ headers: {
469
+ ...commonHeaders(),
470
+ "content-type": "application/json"
471
+ },
472
+ body: JSON.stringify({
473
+ id_token: idToken,
474
+ project
475
+ })
476
+ });
477
+ const payload = await response.json().catch(() => ({}));
478
+ if (!response.ok || !payload.token) {
479
+ const err = new ApiError(response.status, payload.error ?? `http_${response.status}`, "POST", path, payload.detail, response.headers.get("x-canopy-request-id") ?? void 0);
480
+ err.context = { action: `deploy "${project}" from GitHub Actions` };
481
+ throw err;
482
+ }
483
+ const current = payload.slug ?? project;
484
+ if (current !== project) followRename(response, project);
485
+ return {
486
+ apiUrl,
487
+ token: payload.token,
488
+ slug: current,
489
+ channel: payload.channel === "staging" ? "staging" : "live",
490
+ expiresAt: payload.expires_at ?? ""
491
+ };
492
+ }
493
+ /** The 401 fix line, by what the request carried. */
494
+ const UNAUTHENTICATED_FIX = {
495
+ "deploy-token": "The deploy token was rejected — it may have been revoked. Create a new one in the dashboard (Settings → Deploy tokens) or with `canopy token create`.",
496
+ "github-actions": "This run's short-lived deploy token was refused — CI deploys may have been turned off or the link changed while it ran. Check `canopy repo show`, then re-run the workflow.",
497
+ login: "Your session has expired — run `canopy login` again."
498
+ };
384
499
  /** Turn a non-2xx response into a thrown ApiError (friendlier copy on 401). */
385
- async function fail(response, isDeployToken, method, path) {
500
+ async function fail(response, kind, method, path) {
386
501
  const payload = await response.json().catch(() => ({}));
387
502
  const code = payload.error ?? (response.status === 401 ? "unauthenticated" : `http_${response.status}`);
388
503
  const err = new ApiError(response.status, code, method, path, payload.detail, response.headers.get("x-canopy-request-id") ?? void 0);
389
504
  if (response.status === 401) err.context = {
390
505
  action: "authenticate with Canopy",
391
- fixes: { [code]: isDeployToken ? "The deploy token was rejected — it may have been revoked. Create a new one in the dashboard (Settings → Deploy tokens) or with `canopy token create`." : "Your session has expired — run `canopy login` again." }
506
+ fixes: { [code]: UNAUTHENTICATED_FIX[kind] }
392
507
  };
393
508
  throw err;
394
509
  }
395
510
  async function apiRequest(method, path, body, rawContentType, headers) {
396
- const { apiUrl, token, isDeployToken } = credentials();
511
+ const { apiUrl, token, kind } = await credentials();
397
512
  const isForm = body instanceof FormData;
398
513
  const response = await fetch(`${apiUrl}/control/v1${path}`, {
399
514
  method,
@@ -406,7 +521,7 @@ async function apiRequest(method, path, body, rawContentType, headers) {
406
521
  body: body === void 0 ? void 0 : isForm ? body : rawContentType !== void 0 ? body : JSON.stringify(body)
407
522
  });
408
523
  followRename(response, slugInPath(path));
409
- if (!response.ok) return fail(response, isDeployToken, method, path);
524
+ if (!response.ok) return fail(response, kind, method, path);
410
525
  if (response.status === 204) return void 0;
411
526
  return await response.json();
412
527
  }
@@ -415,7 +530,7 @@ async function apiRequest(method, path, body, rawContentType, headers) {
415
530
  * the bytes itself, so `contentType` is a hint, not a claim it trusts.
416
531
  */
417
532
  async function apiUpload(path, bytes, contentType) {
418
- const { apiUrl, token, isDeployToken } = credentials();
533
+ const { apiUrl, token, kind } = await credentials();
419
534
  const response = await fetch(`${apiUrl}/control/v1${path}`, {
420
535
  method: "POST",
421
536
  headers: {
@@ -426,18 +541,18 @@ async function apiUpload(path, bytes, contentType) {
426
541
  body: bytes
427
542
  });
428
543
  followRename(response, slugInPath(path));
429
- if (!response.ok) return fail(response, isDeployToken, "POST", path);
544
+ if (!response.ok) return fail(response, kind, "POST", path);
430
545
  return await response.json();
431
546
  }
432
547
  /** Authenticated GET returning raw bytes (e.g. a deploy zip). */
433
548
  async function apiDownload(path) {
434
- const { apiUrl, token, isDeployToken } = credentials();
549
+ const { apiUrl, token, kind } = await credentials();
435
550
  const response = await fetch(`${apiUrl}/control/v1${path}`, { headers: {
436
551
  ...commonHeaders(),
437
552
  authorization: `Bearer ${token}`
438
553
  } });
439
554
  followRename(response, slugInPath(path));
440
- if (!response.ok) return fail(response, isDeployToken, "GET", path);
555
+ if (!response.ok) return fail(response, kind, "GET", path);
441
556
  return Buffer.from(await response.arrayBuffer());
442
557
  }
443
558
  /**
@@ -446,7 +561,7 @@ async function apiDownload(path) {
446
561
  * re-vouches it (docs/10-cli-sites.md D17).
447
562
  */
448
563
  async function apiDataRequest(method, path, projectSlug, body, rawContentType, channel) {
449
- const { apiUrl, token, isDeployToken } = credentials();
564
+ const { apiUrl, token, kind } = await credentials();
450
565
  const response = await fetch(`${apiUrl}/data/v1${path}`, {
451
566
  method,
452
567
  headers: {
@@ -459,9 +574,9 @@ async function apiDataRequest(method, path, projectSlug, body, rawContentType, c
459
574
  body: body === void 0 ? void 0 : rawContentType !== void 0 ? body : JSON.stringify(body)
460
575
  });
461
576
  followRename(response, projectSlug);
462
- if (!response.ok) return fail(response, isDeployToken, method, path);
577
+ if (!response.ok) return fail(response, kind, method, path);
463
578
  if (response.status === 204) return void 0;
464
579
  return await response.json();
465
580
  }
466
581
  //#endregion
467
- export { apiRequest as a, credentials as c, noticeRenamesOnly as d, withContext as f, apiDownload as i, currentSlug as l, agentHeaders as n, apiUpload as o, apiDataRequest as r, commonHeaders as s, ApiError as t, followRename as u };
582
+ export { apiRequest as a, credentials as c, githubActionsCredentials as d, noticeRenamesOnly as f, apiDownload as i, currentSlug as l, withContext as m, agentHeaders as n, apiUpload as o, onGithubActions as p, apiDataRequest as r, commonHeaders as s, ApiError as t, followRename as u };
@@ -1,5 +1,5 @@
1
- import { p as tryResolveProject, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, t as ApiError } from "./api-Bl6SG8Oq.mjs";
1
+ import { h as tryResolveProject, r as siteConfig } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, t as ApiError } from "./api-DmJrIsE7.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/browse.ts
5
5
  /**
@@ -1,7 +1,7 @@
1
- import { l as resolveProject, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CLnICOne.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
1
+ import { d as resolveProject, l as projectFlagArgs } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext, r as apiDataRequest } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-26ETk1n8.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-BReMlGWP.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { readFileSync, writeFileSync } from "node:fs";
7
7
  import * as p from "@clack/prompts";
@@ -1,7 +1,7 @@
1
- import { c as projectUrl, d as stagingUrl, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, i as apiDownload, l as currentSlug, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
1
+ import { c as projectDirArgs, d as resolveProject, l as projectFlagArgs, m as tryReadCanopyJson, p as stagingUrl, r as siteConfig, u as projectUrl } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, d as githubActionsCredentials, i as apiDownload, l as currentSlug, m as withContext, p as onGithubActions, r as apiDataRequest, t as ApiError } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-BReMlGWP.mjs";
5
5
  import { builtinModules } from "node:module";
6
6
  import { defineCommand } from "citty";
7
7
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -235,9 +235,18 @@ const deployCommand = defineCommand({
235
235
  },
236
236
  run: reportErrors("deploy", async ({ args }) => {
237
237
  const { dir, slug } = resolveProject(args);
238
- const channel = args.staging ? "staging" : "live";
238
+ let channel = args.staging ? "staging" : "live";
239
239
  const config = tryReadCanopyJson(dir) ?? {};
240
240
  if (config.build && args.build) await runBuild(config.build, dir);
241
+ if (onGithubActions() && !process.env["CANOPY_DEPLOY_TOKEN"]) {
242
+ const exchanged = await exchangeOrSkip(slug);
243
+ if (!exchanged) return;
244
+ p.log.info(`Authenticated as this GitHub Actions run (${exchanged.slug}).`);
245
+ if (exchanged.channel === "staging" && channel === "live") {
246
+ channel = "staging";
247
+ p.log.info("This run is on the project's staging branch — deploying to the staging channel.");
248
+ }
249
+ }
241
250
  const migrations = args.migrate ? readMigrationFiles(dir) : void 0;
242
251
  if (migrations !== void 0 && config.dialect) try {
243
252
  const { dialect } = await apiDataRequest("GET", "/platform", slug);
@@ -396,6 +405,43 @@ function bundleLimitError(files, limits) {
396
405
  }
397
406
  return null;
398
407
  }
408
+ /**
409
+ * Exchange errors that mean "CI deploys are not on here": skip, don't fail.
410
+ * `unauthenticated` is a site whose API predates the exchange — its auth wall
411
+ * answers the unknown route; the exchange itself never says it (a bad token
412
+ * is `invalid_id_token`). A project scaffolded on such a site keeps the
413
+ * yellow skip the old secret-gated workflow gave it until the site catches up.
414
+ */
415
+ const SKIP_CODES = /* @__PURE__ */ new Set([
416
+ "repository_not_linked",
417
+ "ci_deploys_off",
418
+ "unauthenticated"
419
+ ]);
420
+ /**
421
+ * The run's deploy credentials, or null after announcing a skip. A project
422
+ * that is not linked, or whose CI deploys are off, leaves the run yellow
423
+ * rather than red: a workflow annotation saying why and how to change it, and
424
+ * exit 0 without deploying. Every other refusal is a failure.
425
+ */
426
+ async function exchangeOrSkip(slug) {
427
+ try {
428
+ return await githubActionsCredentials(slug);
429
+ } catch (err) {
430
+ if (!(err instanceof ApiError) || !SKIP_CODES.has(err.code)) throw err;
431
+ const message = skipMessage(err.code, slug);
432
+ console.log(`::warning title=Canopy deploy skipped::${message}`);
433
+ p.log.warn(message);
434
+ return null;
435
+ }
436
+ }
437
+ function skipMessage(code, slug) {
438
+ if (code === "ci_deploys_off") return `CI deploys are off for "${slug}", so this run deployed nothing. The project owner can turn them back on with \`canopy repo deploys on\`.`;
439
+ if (code === "unauthenticated") return "This Canopy site doesn't take deploys from GitHub Actions without a token yet, so this run deployed nothing. Set a CANOPY_DEPLOY_TOKEN secret to deploy from CI.";
440
+ const repo = process.env["GITHUB_REPOSITORY"];
441
+ const server = process.env["GITHUB_SERVER_URL"];
442
+ const host = server ? new URL(server).host : void 0;
443
+ return `"${slug}" is not linked to this repository, so this run deployed nothing. The project owner can link it with \`canopy repo link ${repo ? `${host && host !== "github.com" ? `${host}/` : ""}${repo}` : "<owner/name>"}\`, or set a CANOPY_DEPLOY_TOKEN secret.`;
444
+ }
399
445
  /** PUT each file to /data/v1/uploads/<grant> — the grant is the credential. */
400
446
  async function uploadFiles(grant, files) {
401
447
  const { apiUrl } = siteConfig();
@@ -1,9 +1,9 @@
1
- import { a as projectDialect, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, t as ApiError } from "./api-Bl6SG8Oq.mjs";
3
- import { r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
- import { r as readMigrationFiles } from "./migrations-D8NqjC5I.mjs";
1
+ import { c as projectDirArgs, d as resolveProject, m as tryReadCanopyJson, r as siteConfig, s as projectDialect } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext, t as ApiError } from "./api-DmJrIsE7.mjs";
3
+ import { r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
+ import { r as readMigrationFiles } from "./migrations-BReMlGWP.mjs";
5
5
  import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-8vxOBOxW.mjs";
6
- import { a as parseAppRoleFlag } from "./roles-UM0Iiws6.mjs";
6
+ import { a as parseAppRoleFlag } from "./roles-S2D9Id0W.mjs";
7
7
  import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import { defineCommand } from "citty";
@@ -325,7 +325,7 @@ async function devRemote(flags, slug) {
325
325
  async function devLocal(flags, slug) {
326
326
  let dump;
327
327
  if (flags.pull) {
328
- const { fetchProjectDump } = await import("./local-CIkL7tXP.mjs");
328
+ const { fetchProjectDump } = await import("./local-C9Bi9f3y.mjs");
329
329
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
330
330
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
331
331
  }
@@ -342,7 +342,7 @@ async function devLocal(flags, slug) {
342
342
  }
343
343
  const workerAttest = randomBytes(24).toString("base64url");
344
344
  const devRoles = await resolveDevRoles(slug, flags.asRoles);
345
- const { createSandbox } = await import("./local-CIkL7tXP.mjs");
345
+ const { createSandbox } = await import("./local-C9Bi9f3y.mjs");
346
346
  const sandbox = await createSandbox({
347
347
  slug,
348
348
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-BmKeIdMi.mjs";
2
+ export { devCommand };
package/dist/docs/ci.md CHANGED
@@ -1,75 +1,137 @@
1
1
  ---
2
- title: CI and deploy tokens
3
- description: Push-to-main deploys using a project-scoped deploy token, and why we recommend deploying from your own machine instead.
2
+ title: CI deploys
3
+ description: Deploy on every push from GitHub Actions with no secret — link the repository once. Deploy tokens for other CI.
4
4
  ---
5
5
 
6
- ## Our recommendation: deploy manually
6
+ ## Link, push, done
7
+
8
+ A project can deploy itself from GitHub Actions. Link the repository once, and
9
+ every push to its main branch deploys the project. There is no secret to create,
10
+ paste or rotate: the run proves which repository and branch it comes from with
11
+ GitHub's own sign-in, and Canopy only accepts runs from the repository you
12
+ linked.
13
+
14
+ Only the project owner (or a platform admin) can link a repository. In the
15
+ project's folder:
16
+
17
+ ```bash
18
+ canopy repo link
19
+ ```
20
+
21
+ With no argument it links the repository the folder's `origin` remote points
22
+ at, on github.com or the Monks GitHub Enterprise (`monks.ghe.com`). Name it
23
+ yourself if needed:
24
+
25
+ ```bash
26
+ canopy repo link monks/my-tool # on this site's GitHub Enterprise
27
+ canopy repo link github.com/monks/my-tool # on github.com
28
+ ```
29
+
30
+ `canopy create` scaffolds `.github/workflows/canopy-deploy.yml`, which deploys
31
+ on every push to `main`. If you write your own workflow, it needs the
32
+ `id-token: write` permission and nothing else:
33
+
34
+ ```yaml
35
+ on:
36
+ push:
37
+ branches: [main]
38
+
39
+ permissions:
40
+ id-token: write
41
+ contents: read
42
+
43
+ jobs:
44
+ deploy:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v7
48
+ - uses: actions/setup-node@v7
49
+ with:
50
+ node-version: 24
51
+ - run: npm ci
52
+ - run: npm exec --yes @mediamonks/canopy-cli deploy
53
+ ```
7
54
 
8
- Most Canopy users don't have a GitHub Enterprise seat, so they can't see or
9
- interact with GitHub Actions. That is why Canopy repositories are created with
10
- Actions turned **off**: pushing a workflow file works, but nothing runs, and
11
- there is no Actions tab for you to look at. Turning Actions on for a repository
12
- is something only a GitHub administrator can do, and you would still not see
13
- the runs.
55
+ A run from any other branch, a pull request or a fork deploys nothing. Until a
56
+ repository is linked, runs skip the deploy with a warning instead of failing.
14
57
 
15
- Deploy from your machine (or let your coding agent do it): run
16
- `canopy deploy --staging`, check the result, then `canopy promote`. It is
17
- usually quicker than waiting for a pipeline, and your agent can verify the
18
- deploy right afterwards. Run your typecheck, lint and tests locally before you
19
- deploy.
58
+ Deploys from CI show up in the history under the repository and branch, e.g.
59
+ `monks/my-tool @ main`.
20
60
 
21
- The rest of this page is for teams that run their own CI somewhere they can
22
- see it.
61
+ A project with a Canopy repository needs no link: CI deploys from it already.
62
+ GitHub Actions are off on Canopy repositories until a platform administrator
63
+ turns them on for one.
64
+
65
+ ## Our recommendation: deploy manually
23
66
 
24
- Interactive `canopy login` is for humans at a terminal. For automated deploys
25
- (GitHub Actions, etc.) use a **deploy token**: a project-scoped credential that
26
- can deploy that one project and nothing else.
67
+ Most Canopy users don't have a GitHub Enterprise seat, so they can't see the
68
+ runs. Deploying from your machine (or letting your coding agent do it) is
69
+ usually quicker: run `canopy deploy --staging`, check the result, then
70
+ `canopy promote`. Run your typecheck, lint and tests locally before you deploy.
27
71
 
28
- ## Create a token
72
+ ## A staging branch
29
73
 
30
- Only the project owner can create a deploy token (any editor can list
31
- and revoke them). From inside the project directory:
74
+ Pushes to a second branch can deploy to the project's
75
+ [staging channel](https://docs.canopy.website/guides/staging) only. They can never change what is live:
76
+ no live deploy, promote or rollback.
32
77
 
33
78
  ```bash
34
- canopy token create --name "GitHub Actions"
79
+ canopy repo branches --staging-ref develop
35
80
  ```
36
81
 
37
- This prints the token **once** — copy it immediately and store it as a CI secret
38
- (e.g. `CANOPY_DEPLOY_TOKEN`). It is not shown again.
82
+ Then add the branch to the workflow's trigger so its pushes run it:
39
83
 
40
- Manage tokens:
84
+ ```yaml
85
+ on:
86
+ push:
87
+ branches: [main, develop]
88
+ ```
89
+
90
+ The same workflow deploys `main` live and `develop` to staging. Promote from
91
+ staging to live by hand with `canopy promote`.
92
+
93
+ ## Pausing CI deploys
94
+
95
+ Turn CI deploys off without unlinking or editing the workflow:
41
96
 
42
97
  ```bash
43
- canopy token list
44
- canopy token revoke <id>
98
+ canopy repo deploys off
99
+ canopy repo deploys on
45
100
  ```
46
101
 
47
- ## Use it in CI
102
+ While they are off, runs skip the deploy with a warning. The link and branches
103
+ stay as they were. You can also flip the switch on the project's Deploys tab.
48
104
 
49
- The CLI picks up `CANOPY_DEPLOY_TOKEN` from the environment and uses it instead
50
- of an interactive login. A minimal GitHub Actions step:
105
+ `canopy repo show` prints the repository, both branches and whether CI deploys
106
+ are on. `canopy repo unlink` removes the link.
51
107
 
52
- ```yaml
53
- - run: npm install -g @mediamonks/canopy-cli
54
- - run: canopy deploy
55
- env:
56
- CANOPY_DEPLOY_TOKEN: ${{ secrets.CANOPY_DEPLOY_TOKEN }}
108
+ ## Other CI: deploy tokens
109
+
110
+ For CI that isn't GitHub Actions, or a repository you can't link, use a
111
+ **deploy token**: a project-scoped credential that can deploy that one project
112
+ and nothing else.
113
+
114
+ Only the project owner can create one (any editor can list and revoke them).
115
+ From inside the project directory:
116
+
117
+ ```bash
118
+ canopy token create --name "Buildkite"
57
119
  ```
58
120
 
59
- (`canopy deploy` runs the project's `canopy.json` build command by default;
60
- pass `--no-build` if CI already built the output directory.)
121
+ This prints the token **once** — store it as a CI secret right away. It is not
122
+ shown again.
61
123
 
62
- No `canopy login` step is needed — the token is the credential.
124
+ ```bash
125
+ canopy token list
126
+ canopy token revoke <id>
127
+ ```
63
128
 
64
- ## Notes
129
+ Set it as `CANOPY_DEPLOY_TOKEN` in the CI environment and run `canopy deploy`.
130
+ No `canopy login` is needed. A deploy token also works in GitHub Actions, and
131
+ wins over the linked repository when both are there.
65
132
 
66
- - A deploy token can only deploy its own project — it reaches that project's
67
- deploy endpoints and upload grants and nothing else. If it strays to another
68
- project's resources, the platform rejects it.
69
- - The token belongs to the project, not to the person who created it. It keeps
70
- working when they leave the project or the company; deploys it makes show up
71
- in the history under the token's name.
72
- - A token that goes 90 days without a deploy stops working. Active pipelines
73
- never notice; a forgotten one dies on its own. The token list shows when each
74
- was last used.
75
- - Revoke a token the moment it might be exposed; create a fresh one to replace it.
133
+ - A deploy token can only deploy its own project.
134
+ - It belongs to the project, not to the person who created it, and keeps
135
+ working when they leave.
136
+ - A token that goes 90 days without a deploy stops working.
137
+ - Turning CI deploys off does not stop a deploy token. Revoke it for that.
@@ -40,6 +40,8 @@ canopy create
40
40
 
41
41
  Walks you through name, slug, and template, registers the project, and scaffolds a ready-to-run starter with next steps printed.
42
42
 
43
+ The starter includes `.github/workflows/canopy-deploy.yml`, which deploys on every push to `main` once the repository is linked. `create` never links a repository itself; the next steps print the exact [`canopy repo link`](https://docs.canopy.website/cli/repo) command for the folder's GitHub `origin`. See [CI deploys](https://docs.canopy.website/guides/ci).
44
+
43
45
  ```bash
44
46
  canopy create "Team Retro Board" --slug retro-board --template spa
45
47
  ```
@@ -33,7 +33,7 @@ canopy deploy --project my-app ./out
33
33
 
34
34
  Deploys are versioned and immutable: nothing overwrites an old version, so [canopy rollback](https://docs.canopy.website/cli/rollback) can always take you back.
35
35
 
36
- **CI.** Set the `CANOPY_DEPLOY_TOKEN` environment variable to a project-scoped deploy token (from [canopy token create](https://docs.canopy.website/cli/token)) and `canopy deploy` works with no `canopy login` — the token can deploy exactly one project and nothing else. Deploys from GitHub Actions are tagged with their source in the deploy history.
36
+ **CI.** In GitHub Actions, `canopy deploy` needs no secret and no `canopy login`: with `permissions: id-token: write` in the workflow, it proves which repository and branch the run comes from and deploys a project [linked](https://docs.canopy.website/cli/repo) to that repository. A run on the project's staging branch deploys to staging without `--staging`. An unlinked project, or one whose CI deploys are off, skips the deploy with a warning instead of failing. Elsewhere, set `CANOPY_DEPLOY_TOKEN` to a project-scoped deploy token (from [canopy token create](https://docs.canopy.website/cli/token)); it wins over the linked repository when both are there. Deploys from GitHub Actions are tagged with their source in the deploy history. See [CI deploys](https://docs.canopy.website/guides/ci).
37
37
 
38
38
  ## Examples
39
39