@devdogsuga/backstage 0.1.6 → 0.1.7
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.
- package/README.md +3 -1
- package/dist/{catalog-Dke7bQVz.js → catalog-BiXI0wyi.js} +10 -0
- package/dist/{cli-BhZ4je2D.js → cli-Cz8faASC.js} +124 -5
- package/dist/launch.js +2 -2
- package/env.ts +16 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ pnpm backstage export stars --from 2026-08-17 # audited under your gh login
|
|
|
49
49
|
| `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
|
|
50
50
|
| `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
|
|
51
51
|
| `deploy reconcile --tier <t>` | The platform's config reconcile, after the deploy (`CRON_SECRET`). |
|
|
52
|
+
| `deploy prune-monitors --tier <t> [--app]` | Deletes the app's Sentry Crons monitors it no longer declares (production only). |
|
|
52
53
|
| `env pull\|push\|audit --target <t>` | One env file per target, synced to Bitwarden and GitHub. `audit` lists orphans. |
|
|
53
54
|
| `planner status\|create\|reset-password\|drop` | The `migration_planner` role the preflight tier holds. |
|
|
54
55
|
| `graphics [graphic…]` | Club images from `@devdogsuga/brand`: `brand/*`, `app/*`, `event/*`. |
|
|
@@ -234,7 +235,8 @@ stderr, and every confirmation needs `--yes`. Against staging or production a
|
|
|
234
235
|
command that is not read-only asks once first (`--yes` answers it).
|
|
235
236
|
|
|
236
237
|
Each command checks for the secrets it uses up front: `deploy <app>` for
|
|
237
|
-
`CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `
|
|
238
|
+
`CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `deploy
|
|
239
|
+
prune-monitors` for `SENTRY_MONITORS_TOKEN` (skipped without it), `env` for the
|
|
238
240
|
Secrets Manager token (flag, environment, then the Bitwarden vault, which `env`
|
|
239
241
|
signs in to and unlocks itself, then asking).
|
|
240
242
|
|
|
@@ -1143,6 +1143,16 @@ const deployCommand = {
|
|
|
1143
1143
|
summary: "Run the platform's config reconcile after a deploy.",
|
|
1144
1144
|
hint: "needs CRON_SECRET",
|
|
1145
1145
|
options: [DEPLOY_TIER, SMOKE_APP]
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
name: "prune-monitors",
|
|
1149
|
+
summary: "Delete the Sentry Crons monitors an app no longer declares.",
|
|
1150
|
+
hint: "needs SENTRY_MONITORS_TOKEN; production only",
|
|
1151
|
+
options: [
|
|
1152
|
+
DEPLOY_TIER,
|
|
1153
|
+
SMOKE_APP,
|
|
1154
|
+
DRY_RUN
|
|
1155
|
+
]
|
|
1146
1156
|
}
|
|
1147
1157
|
]
|
|
1148
1158
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { C as discoverRepoRoot, T as cliName, b as ownPackageDir, c as reportDevtoolsError, f as isNoEnv, l as reportDevtoolsFailure, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, w as findRepoRoot, x as ownVersion } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
-
import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-
|
|
2
|
+
import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-BiXI0wyi.js";
|
|
3
3
|
import { a as repoPeerUrl, n as loadEnv, r as loadEnvLoad, t as getEnvSync } from "./peers-B0KDkI6a.js";
|
|
4
4
|
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
5
5
|
import { a as wouldRunLine, n as isDryRun, t as dryRunKind } from "./dry-run-3IxPmCtW.js";
|
|
@@ -553,6 +553,116 @@ async function runDeployMigrate(env = process.env, push = (url) => dbPush(url, {
|
|
|
553
553
|
say(["deploy migrate: migrations applied."]);
|
|
554
554
|
}
|
|
555
555
|
//#endregion
|
|
556
|
+
//#region src/deploy/monitors.ts
|
|
557
|
+
/**
|
|
558
|
+
* `deploy prune-monitors`: deletes the Sentry Crons monitors an app no longer
|
|
559
|
+
* declares.
|
|
560
|
+
*
|
|
561
|
+
* Sentry creates a monitor the first time a job checks in, and never removes
|
|
562
|
+
* one. Rename or drop a `monitorSlug` and the old monitor keeps expecting
|
|
563
|
+
* check-ins that will never come, opening a "missed check-in" issue every
|
|
564
|
+
* period until someone deletes it by hand (PLATFORM-2: the Discord role sync
|
|
565
|
+
* folded into the fifteen-minute cron, and its ten-minute monitor alerted
|
|
566
|
+
* every ten minutes after the deploy).
|
|
567
|
+
*
|
|
568
|
+
* The declared set is every `monitorSlug` on the entries of the app's
|
|
569
|
+
* `cloudflare/scheduled.ts` exports `CRON_ROUTES` and `WORKFLOW_CRONS`. Every
|
|
570
|
+
* monitor in the app's Sentry project that is not in it, and whose slug
|
|
571
|
+
* starts with `<app>-`, is deleted. The prefix keeps a monitor someone made
|
|
572
|
+
* by hand in the Sentry UI out of reach.
|
|
573
|
+
*
|
|
574
|
+
* Delete, not disable. Sentry drops every check-in sent to a disabled monitor
|
|
575
|
+
* and never re-enables one from a check-in, so a slug that came back would
|
|
576
|
+
* report nowhere. A deleted one is recreated by the next check-in that
|
|
577
|
+
* carries its config (each `withMonitor`/`captureCheckIn` here does), and the
|
|
578
|
+
* delete renames the old slug first, so the name is free at once.
|
|
579
|
+
*
|
|
580
|
+
* Production only. Monitors belong to a project, not a tier, and staging
|
|
581
|
+
* deploys `main`, which can be ahead of production: a monitor `main` dropped
|
|
582
|
+
* still has a production job checking in to it.
|
|
583
|
+
*
|
|
584
|
+
* `SENTRY_MONITORS_TOKEN`, not `SENTRY_AUTH_TOKEN`: the deploy token is an
|
|
585
|
+
* organization token (`org:ci`), which Sentry lets neither list nor delete
|
|
586
|
+
* monitors. This one needs `alerts:write` (`alerts:read` to list), and is
|
|
587
|
+
* held in this step's `env:` alone. Absent, the step is skipped with a
|
|
588
|
+
* notice, the same as the Sentry release check.
|
|
589
|
+
*/
|
|
590
|
+
const SENTRY_API$1 = "https://sentry.io/api/0";
|
|
591
|
+
/** Only the field read here; the exports' own shapes are devtools' to check. */
|
|
592
|
+
const Declarations = z.record(z.string(), z.object({ monitorSlug: z.string().min(1).optional() }));
|
|
593
|
+
const MonitorList = z.array(z.object({ slug: z.string() }));
|
|
594
|
+
/**
|
|
595
|
+
* Every `monitorSlug` the app's `cloudflare/scheduled.ts` declares, or
|
|
596
|
+
* `undefined` when the app has no such file (no crons, nothing to prune).
|
|
597
|
+
*/
|
|
598
|
+
async function declaredMonitorSlugs(app, root = findRepoRoot()) {
|
|
599
|
+
const path = join(root, "apps", app, "cloudflare", "scheduled.ts");
|
|
600
|
+
if (!existsSync(path)) return void 0;
|
|
601
|
+
const mod = await import(pathToFileURL(path).href);
|
|
602
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
603
|
+
for (const name of ["CRON_ROUTES", "WORKFLOW_CRONS"]) {
|
|
604
|
+
const parsed = Declarations.safeParse(mod[name] ?? {});
|
|
605
|
+
if (!parsed.success) throw new DeployError(`${path}: ${name} is not a map of entries.`);
|
|
606
|
+
for (const entry of Object.values(parsed.data)) if (entry.monitorSlug) slugs.add(entry.monitorSlug);
|
|
607
|
+
}
|
|
608
|
+
return slugs;
|
|
609
|
+
}
|
|
610
|
+
/** The `rel="next"` URL of a Sentry `Link` header, when it has results. */
|
|
611
|
+
function nextPage(link) {
|
|
612
|
+
for (const part of link?.split(",") ?? []) if (part.includes("rel=\"next\"") && part.includes("results=\"true\"")) return /<([^>]+)>/.exec(part)?.[1];
|
|
613
|
+
}
|
|
614
|
+
async function listMonitorSlugs(org, project, headers, fetchImpl) {
|
|
615
|
+
const slugs = [];
|
|
616
|
+
let url = `${SENTRY_API$1}/organizations/${org}/monitors/?project=${encodeURIComponent(project)}`;
|
|
617
|
+
while (url) {
|
|
618
|
+
const response = await fetchImpl(url, { headers });
|
|
619
|
+
if (!response.ok) throw new DeployError(`Listing Sentry monitors: HTTP ${response.status}.`, ["SENTRY_MONITORS_TOKEN needs alerts:read and alerts:write."]);
|
|
620
|
+
slugs.push(...MonitorList.parse(await response.json()).map((m) => m.slug));
|
|
621
|
+
url = nextPage(response.headers.get("link"));
|
|
622
|
+
}
|
|
623
|
+
return slugs;
|
|
624
|
+
}
|
|
625
|
+
/** Returns the slugs it deleted (or would have, on a dry run). */
|
|
626
|
+
async function runPruneMonitors(options) {
|
|
627
|
+
const env = options.env ?? process.env;
|
|
628
|
+
const { app } = options;
|
|
629
|
+
if (options.tier !== "production") {
|
|
630
|
+
say([`prune-monitors: skipped on ${options.tier}; monitors are pruned against production only.`]);
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
const token = env.SENTRY_MONITORS_TOKEN;
|
|
634
|
+
if (!token) {
|
|
635
|
+
say(["::notice::Sentry monitor pruning skipped -- SENTRY_MONITORS_TOKEN is not configured."]);
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
if (!env.SENTRY_ORG) throw new DeployError("SENTRY_ORG is not set.");
|
|
639
|
+
const declared = options.declared ?? await declaredMonitorSlugs(app);
|
|
640
|
+
if (!declared) {
|
|
641
|
+
say([`prune-monitors: ${app} has no cloudflare/scheduled.ts; nothing to prune.`]);
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
645
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
646
|
+
const orphans = (await listMonitorSlugs(env.SENTRY_ORG, app, headers, fetchImpl)).filter((slug) => slug.startsWith(`${app}-`) && !declared.has(slug));
|
|
647
|
+
if (orphans.length === 0) {
|
|
648
|
+
say([`prune-monitors: every ${app} monitor is declared.`]);
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
for (const slug of orphans) {
|
|
652
|
+
if (options.dryRun) {
|
|
653
|
+
say([`Would delete Sentry monitor ${slug}`]);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const response = await fetchImpl(`${SENTRY_API$1}/projects/${env.SENTRY_ORG}/${app}/monitors/${encodeURIComponent(slug)}/`, {
|
|
657
|
+
method: "DELETE",
|
|
658
|
+
headers
|
|
659
|
+
});
|
|
660
|
+
if (!response.ok && response.status !== 404) throw new DeployError(`Deleting Sentry monitor ${slug}: HTTP ${response.status}.`);
|
|
661
|
+
say([`Deleted Sentry monitor ${slug}`]);
|
|
662
|
+
}
|
|
663
|
+
return orphans;
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
556
666
|
//#region src/deploy/preflight.ts
|
|
557
667
|
/**
|
|
558
668
|
* `pnpm devtools deploy preflight`
|
|
@@ -1554,10 +1664,11 @@ function renderWriteEnvReport(result) {
|
|
|
1554
1664
|
* Sandbox (plain Worker) always used this shape, so all three apps share one
|
|
1555
1665
|
* deploy step.
|
|
1556
1666
|
*
|
|
1557
|
-
* Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile
|
|
1558
|
-
* individually addressable for jobs that run only one.
|
|
1559
|
-
* single credential in the job's own `env:` block and
|
|
1560
|
-
* (`preflight`, `plan`, `migrate`, `smoke`, `reconcile
|
|
1667
|
+
* Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
|
|
1668
|
+
* `prune-monitors`) are individually addressable for jobs that run only one.
|
|
1669
|
+
* The ones that hold a single credential in the job's own `env:` block and
|
|
1670
|
+
* compose no env file (`preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
|
|
1671
|
+
* `prune-monitors`), and `write-env`,
|
|
1561
1672
|
* which CREATES the file tier resolution would otherwise insist on reading,
|
|
1562
1673
|
* run with `--no-env`.
|
|
1563
1674
|
*/
|
|
@@ -1707,6 +1818,14 @@ async function runStep(sub, rest) {
|
|
|
1707
1818
|
});
|
|
1708
1819
|
return true;
|
|
1709
1820
|
}
|
|
1821
|
+
if (sub === "prune-monitors") {
|
|
1822
|
+
await runPruneMonitors({
|
|
1823
|
+
app: flagValue(rest, "--app") ?? "platform",
|
|
1824
|
+
tier: requireTier(rest),
|
|
1825
|
+
dryRun: rest.includes("--dry-run")
|
|
1826
|
+
});
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1710
1829
|
if (sub === "write-env") {
|
|
1711
1830
|
await loadRegistry();
|
|
1712
1831
|
const index = rest.indexOf("--source");
|
package/dist/launch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { C as discoverRepoRoot, E as setCliName, S as RepoNotFoundError, T as cliName, _ as installFailureLog, d as hasYes, g as stripTierFlag, h as stripNoEnvFlag, m as setNoEnv, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, s as lastSentryEventId } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
-
import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-
|
|
2
|
+
import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-BiXI0wyi.js";
|
|
3
3
|
import { i as loadEnvSession, r as loadEnvLoad } from "./peers-B0KDkI6a.js";
|
|
4
4
|
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
5
5
|
import { i as setDryRun, n as isDryRun, r as resolveDryRun } from "./dry-run-3IxPmCtW.js";
|
|
@@ -322,7 +322,7 @@ async function launchWith(argv, options) {
|
|
|
322
322
|
*/
|
|
323
323
|
async function launch(argv) {
|
|
324
324
|
await launchWith(argv, { dispatch: async (args) => {
|
|
325
|
-
const { main } = await import("./cli-
|
|
325
|
+
const { main } = await import("./cli-Cz8faASC.js");
|
|
326
326
|
await main(args);
|
|
327
327
|
} });
|
|
328
328
|
}
|
package/env.ts
CHANGED
|
@@ -96,7 +96,22 @@ declare({
|
|
|
96
96
|
doc:
|
|
97
97
|
"A Sentry organization auth token. Deploys use it to create each " +
|
|
98
98
|
"release and upload its source maps, and the smoke test uses it to " +
|
|
99
|
-
"
|
|
99
|
+
"confirm the release landed. One token serves every environment.",
|
|
100
|
+
scope: "environment",
|
|
101
|
+
secrecy: "secret",
|
|
102
|
+
commented: true,
|
|
103
|
+
}),
|
|
104
|
+
// Read only by `deploy prune-monitors`, in its own step's `env:`: an
|
|
105
|
+
// organization token (above) can neither list nor delete Crons monitors,
|
|
106
|
+
// and one that can delete monitors and alert rules org-wide should not
|
|
107
|
+
// sit in every step of the deploy job. Without it, pruning is skipped
|
|
108
|
+
// with a notice.
|
|
109
|
+
SENTRY_MONITORS_TOKEN: define(z.string().min(1).optional(), {
|
|
110
|
+
doc:
|
|
111
|
+
"A Sentry internal integration token with Alerts: Read & Write and " +
|
|
112
|
+
"nothing else. The production deploy uses it to delete the Crons " +
|
|
113
|
+
"monitors an app no longer declares, which would otherwise alert " +
|
|
114
|
+
"on missed check-ins forever.",
|
|
100
115
|
scope: "environment",
|
|
101
116
|
secrecy: "secret",
|
|
102
117
|
commented: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/backstage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Officer and production CLI for DevDogsUGA: deploys, env sync with Bitwarden and GitHub, the migration planner role, club graphics and QR codes, GitHub rulesets and settings, and the newsletter. Ships built JS; run it anywhere with `pnpm dlx @devdogsuga/backstage` (no checkout needed to start; commands that read a checkout say so).",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
42
|
"zod": "4.4.3",
|
|
43
43
|
"@devdogsuga/brand": "0.1.5",
|
|
44
|
+
"@devdogsuga/events": "0.1.5",
|
|
44
45
|
"@devdogsuga/newsletter": "0.1.10",
|
|
45
|
-
"@devdogsuga/telemetry": "0.1.3"
|
|
46
|
-
"@devdogsuga/events": "0.1.5"
|
|
46
|
+
"@devdogsuga/telemetry": "0.1.3"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@devdogsuga/db": "*",
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"eslint": "^9.39.5",
|
|
65
65
|
"tsdown": "^0.23.0",
|
|
66
66
|
"vitest": "^4.1.11",
|
|
67
|
-
"@devdogsuga/
|
|
67
|
+
"@devdogsuga/cli-core": "0.0.0",
|
|
68
68
|
"@devdogsuga/db": "0.1.3",
|
|
69
|
-
"@devdogsuga/
|
|
70
|
-
"@devdogsuga/
|
|
69
|
+
"@devdogsuga/config": "0.1.2",
|
|
70
|
+
"@devdogsuga/env": "0.1.5"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
73
|
"build": "tsdown && node scripts/write-build-info.mjs && node ../../scripts/check-bundle-imports.mjs",
|