create-svc 0.1.53 → 0.1.55

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 (45) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/src/git-bootstrap.test.ts +49 -1
  4. package/src/git-bootstrap.ts +13 -1
  5. package/src/scaffold.test.ts +38 -8
  6. package/src/scaffold.ts +28 -1
  7. package/src/service-runtime/cloudrun/cli.ts +10 -0
  8. package/src/service-runtime/cloudrun/config.ts +3 -0
  9. package/src/service-runtime/cloudrun/lib.ts +0 -3
  10. package/src/service-runtime/cloudrun/observability.ts +18 -0
  11. package/templates/shared/.env.example +40 -0
  12. package/templates/shared/.github/workflows/deploy.yml +2 -1
  13. package/templates/shared/.github/workflows/preview.yml +5 -3
  14. package/templates/shared/README.md +37 -0
  15. package/templates/shared/_gitignore +13 -0
  16. package/templates/shared/service.jsonc +8 -0
  17. package/templates/targets/workers/.github/workflows/deploy.yml +1 -0
  18. package/templates/variants/bun-connectrpc/Makefile +4 -1
  19. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
  20. package/templates/variants/bun-connectrpc/package.json +1 -0
  21. package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
  22. package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
  23. package/templates/variants/bun-connectrpc/src/index.ts +47 -4
  24. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
  25. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
  26. package/templates/variants/bun-hono/Makefile +4 -1
  27. package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
  28. package/templates/variants/bun-hono/package.json +1 -0
  29. package/templates/variants/bun-hono/src/db/repository.ts +52 -3
  30. package/templates/variants/bun-hono/src/db/schema.ts +13 -0
  31. package/templates/variants/bun-hono/src/index.ts +34 -8
  32. package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
  33. package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
  34. package/templates/variants/bun-hono/test/app.test.ts +13 -0
  35. package/templates/variants/go-chi/Dockerfile +0 -1
  36. package/templates/variants/go-chi/Makefile +4 -1
  37. package/templates/variants/go-chi/internal/app/service.go +96 -0
  38. package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
  39. package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
  40. package/templates/variants/go-chi/migrations/atlas.sum +2 -2
  41. package/templates/variants/go-connectrpc/Makefile +4 -1
  42. package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
  43. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
  44. package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
  45. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -2
package/README.md CHANGED
@@ -115,6 +115,7 @@ bun run lint
115
115
  bun run test
116
116
  service create
117
117
  service deploy
118
+ service observability-bootstrap
118
119
  service dev down
119
120
  service destroy
120
121
  ```
@@ -129,6 +130,7 @@ make lint
129
130
  make test
130
131
  service create
131
132
  service deploy
133
+ service observability-bootstrap
132
134
  service dev down
133
135
  service destroy
134
136
  ```
@@ -136,6 +138,10 @@ service destroy
136
138
  Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
137
139
  `service destroy --force` also stops local dev and runs Docker Compose cleanup for generated Cloud Run services.
138
140
 
141
+ `service observability-bootstrap` enables the Google Cloud Logging, Monitoring,
142
+ and Trace APIs for the generated GCP project. It does not create dashboards,
143
+ alerts, log-based metrics, or SLOs; those stay explicit follow-up work.
144
+
139
145
  After `service create` has provisioned auth, the generated repo can mint a
140
146
  client-credentials bearer token for smoke checks:
141
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -2,7 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { buildGitBootstrapConfig, findExistingGitWorktree, manualGitHubDeleteCommand, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
5
+ import {
6
+ buildGitBootstrapConfig,
7
+ commitAndPushGeneratedArtifacts,
8
+ findExistingGitWorktree,
9
+ manualGitHubDeleteCommand,
10
+ markGitHubRepositoryDeleteOnDestroy,
11
+ } from "./git-bootstrap";
6
12
 
7
13
  test("buildGitBootstrapConfig defaults to anmho private repo creation", () => {
8
14
  expect(buildGitBootstrapConfig("launch-api", undefined)).toEqual({
@@ -41,6 +47,36 @@ test("markGitHubRepositoryDeleteOnDestroy records generated repo ownership", asy
41
47
  expect(await Bun.file(join(root, "service.jsonc")).text()).toContain('"delete_on_destroy": true');
42
48
  });
43
49
 
50
+ test("commitAndPushGeneratedArtifacts excludes local dependencies and dev runtime files", async () => {
51
+ const root = await mkdtemp(join(tmpdir(), "create-svc-git-"));
52
+ const remote = await mkdtemp(join(tmpdir(), "create-svc-remote-"));
53
+ run(["git", "init", "--bare"], remote);
54
+ run(["git", "init", "-b", "main"], root);
55
+ run(["git", "config", "user.name", "create-svc test"], root);
56
+ run(["git", "config", "user.email", "create-svc@example.invalid"], root);
57
+ run(["git", "remote", "add", "origin", remote], root);
58
+ await writeFile(join(root, "README.md"), "# generated\n");
59
+ run(["git", "add", "."], root);
60
+ run(["git", "commit", "-m", "Initial commit"], root);
61
+ run(["git", "push", "-u", "origin", "main"], root);
62
+
63
+ await mkdir(join(root, "node_modules", "large-package"), { recursive: true });
64
+ await mkdir(join(root, ".service"), { recursive: true });
65
+ await mkdir(join(root, ".wrangler", "tmp"), { recursive: true });
66
+ await writeFile(join(root, "node_modules", "large-package", "artifact.bin"), "large");
67
+ await writeFile(join(root, ".service", "local-dev.log"), "log");
68
+ await writeFile(join(root, ".service", "local-dev.pid"), "123");
69
+ await writeFile(join(root, ".wrangler", "tmp", "bundle.js"), "bundle");
70
+ await writeFile(join(root, "service.jsonc"), '{ "deployed": true }\n');
71
+
72
+ expect(commitAndPushGeneratedArtifacts(root, "Record generated deployment artifacts")).toEqual({ committed: true });
73
+
74
+ expect(git(["ls-files"], root)).toContain("service.jsonc");
75
+ expect(git(["ls-files"], root)).not.toContain("node_modules");
76
+ expect(git(["ls-files"], root)).not.toContain(".service/local-dev.log");
77
+ expect(git(["ls-files"], root)).not.toContain(".wrangler");
78
+ });
79
+
44
80
  function run(command: string[], cwd: string) {
45
81
  const result = Bun.spawnSync(command, {
46
82
  cwd,
@@ -51,3 +87,15 @@ function run(command: string[], cwd: string) {
51
87
  throw new Error(result.stderr.toString());
52
88
  }
53
89
  }
90
+
91
+ function git(command: string[], cwd: string) {
92
+ const result = Bun.spawnSync(["git", ...command], {
93
+ cwd,
94
+ stdout: "pipe",
95
+ stderr: "pipe",
96
+ });
97
+ if (result.exitCode !== 0) {
98
+ throw new Error(result.stderr.toString());
99
+ }
100
+ return result.stdout.toString();
101
+ }
@@ -58,7 +58,19 @@ export async function bootstrapGitHubRepository(targetDir: string, config: GitBo
58
58
  }
59
59
 
60
60
  export function commitAndPushGeneratedArtifacts(targetDir: string, message: string) {
61
- run(["git", "add", "."], targetDir);
61
+ run(
62
+ [
63
+ "git",
64
+ "add",
65
+ "--all",
66
+ ".",
67
+ ":!node_modules",
68
+ ":!.service/*.log",
69
+ ":!.service/*.pid",
70
+ ":!.wrangler",
71
+ ],
72
+ targetDir
73
+ );
62
74
  if (!hasStagedChanges(targetDir)) {
63
75
  return { committed: false };
64
76
  }
@@ -105,6 +105,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
105
105
 
106
106
  const gitignore = await Bun.file(join(generatedRoot, ".gitignore")).text();
107
107
  expect(gitignore).toContain("node_modules");
108
+ expect(gitignore).toContain(".service/*.log");
109
+ expect(gitignore).toContain(".wrangler");
108
110
  expect(await Bun.file(join(generatedRoot, "website", "package.json")).exists()).toBeFalse();
109
111
 
110
112
  const dockerCompose = await Bun.file(join(generatedRoot, "docker-compose.yml")).text();
@@ -133,12 +135,25 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
133
135
  expect(localEnv).toContain("VAULT_CLOUDFLARE_API_TOKEN_PATH=prod/providers/cloudflare");
134
136
  expect(localEnv).not.toContain("ATTACHMENT_PUBLIC_BASE_URL=");
135
137
 
136
- const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
137
- expect(ciWorkflow).toContain("bun run dashboards");
138
- expect(ciWorkflow).toContain("GCX_ENABLED");
139
138
  expect(await Bun.file(join(generatedRoot, "grafana", "waitlist-dashboard.json")).exists()).toBeTrue();
140
139
  expect(await Bun.file(join(generatedRoot, "grafana", "alerts.yaml")).exists()).toBeTrue();
141
140
 
141
+ const previewWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "preview.yml")).text();
142
+ expect(previewWorkflow).toContain("push:");
143
+ expect(previewWorkflow).toContain("branches-ignore:");
144
+ expect(previewWorkflow).toContain("- main");
145
+ expect(previewWorkflow).toContain("github.ref_name");
146
+ expect(previewWorkflow).toContain("service deploy --ci --environment preview --name");
147
+ expect(previewWorkflow).toContain("NEON_API_KEY");
148
+
149
+ const deployWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).text();
150
+ expect(deployWorkflow).toContain("branches:");
151
+ expect(deployWorkflow).toContain("- main");
152
+ expect(deployWorkflow).toContain("bun install -g create-svc@latest");
153
+ expect(deployWorkflow).toContain("service deploy --ci");
154
+ expect(deployWorkflow).toContain("bun run dashboards");
155
+ expect(deployWorkflow).toContain("GCX_ENABLED");
156
+
142
157
  if (variant.runtime === "go") {
143
158
  const goMod = await Bun.file(join(generatedRoot, "go.mod")).text();
144
159
  const goSumExists = await Bun.file(join(generatedRoot, "go.sum")).exists();
@@ -148,6 +163,11 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
148
163
  expect(goSumExists).toBeTrue();
149
164
  const dockerfile = await Bun.file(join(generatedRoot, "Dockerfile")).text();
150
165
  expect(dockerfile).toContain("COPY go.mod go.sum ./");
166
+ if (variant.framework === "chi") {
167
+ expect(dockerfile).not.toContain("COPY gen ./gen");
168
+ } else {
169
+ expect(dockerfile).toContain("COPY gen ./gen");
170
+ }
151
171
  expect(packageJson).toContain('"dev": "make dev"');
152
172
  expect(packageJson).toContain('"service": "service"');
153
173
  expect(packageJson).toContain('"migrate": "service migrate"');
@@ -198,6 +218,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
198
218
  expect(packageJson).toContain('"create": "service create"');
199
219
  expect(packageJson).toContain('"deploy": "service deploy"');
200
220
  expect(packageJson).toContain('"dashboards": "service dashboards"');
221
+ expect(packageJson).toContain('"observability-bootstrap": "service observability-bootstrap"');
201
222
  expect(packageJson).toContain('"auth": "service auth"');
202
223
  expect(packageJson).toContain('"destroy": "service destroy"');
203
224
  expect(await Bun.file(join(generatedRoot, "scripts", "cloudrun", "cli.ts")).exists()).toBeFalse();
@@ -206,12 +227,15 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
206
227
  expect(serviceConfig).toContain('"service_id": "dns-api"');
207
228
  expect(serviceConfig).toContain('"project_id": "anmho-dns-api"');
208
229
  expect(serviceConfig).toContain('"database_name": "dns_api"');
230
+ expect(serviceConfig).toContain('"observability"');
231
+ expect(serviceConfig).toContain("logging.googleapis.com");
209
232
  const authScript = await Bun.file(join(generatedRoot, "src", "auth.ts")).text();
210
233
  expect(authScript).toContain('"Ed25519"');
211
234
 
212
235
  const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
213
236
  expect(makefile).toContain("SERVICE := service");
214
237
  expect(makefile).toContain("dashboards:");
238
+ expect(makefile).toContain("observability-bootstrap:");
215
239
  expect(makefile).toContain("auth:");
216
240
  expect(makefile).toContain("bun run dev");
217
241
  const devScript = await Bun.file(join(generatedRoot, "scripts", "dev.ts")).text();
@@ -249,8 +273,6 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
249
273
  expect(readme).toContain("service auth resource-server");
250
274
  }
251
275
 
252
- const deployWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).text();
253
- expect(deployWorkflow).toContain("bun run dashboards");
254
276
  }
255
277
  }, 30000);
256
278
 
@@ -275,14 +297,20 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
275
297
  expect(readme).toContain("known-good CLIs");
276
298
  expect(readme).toContain("service create");
277
299
  expect(readme).toContain("service deploy");
300
+ expect(readme).toContain("Google observability bootstrap");
301
+ expect(readme).toContain("Google Cloud Logging, Monitoring, and Trace APIs");
278
302
  expect(readme).toContain("one-command production create");
279
303
  expect(readme).toContain("waitlist/launch service");
280
304
  expect(readme).toContain("Terraform is optional");
281
305
  expect(readme).toContain("waitlist/launch service");
282
306
  expect(readme).not.toContain("Neon main, preview, and personal branch provisioning");
283
- const ciWorkflow = await Bun.file(join(generatedRoot, ".github", "workflows", "ci.yml")).text();
284
- expect(ciWorkflow).toContain("bun run dashboards");
285
- expect(readme).not.toContain("repository");
307
+ expect(readme).toContain("GitHub Actions deployment");
308
+ expect(readme).toContain(".github/workflows/preview.yml");
309
+ expect(readme).toContain(".github/workflows/deploy.yml");
310
+ expect(readme).toContain("pushes to non-main branches");
311
+ expect(readme).toContain("GCP_WIF_PROVIDER");
312
+ expect(readme).toContain("GCP_DEPLOYER_SERVICE_ACCOUNT");
313
+ expect(readme).toContain("NEON_API_KEY");
286
314
 
287
315
  const packageJson = await Bun.file(join(generatedRoot, "package.json")).text();
288
316
  expect(packageJson).toContain('"hono"');
@@ -290,6 +318,8 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
290
318
  const entrypoint = await Bun.file(join(generatedRoot, "src", "index.ts")).text();
291
319
  expect(entrypoint).toContain("/v1/waitlist");
292
320
  expect(entrypoint).toContain("/v1/admin/waitlist");
321
+ expect(await Bun.file(join(generatedRoot, ".github", "workflows", "preview.yml")).exists()).toBeTrue();
322
+ expect(await Bun.file(join(generatedRoot, ".github", "workflows", "deploy.yml")).exists()).toBeTrue();
293
323
  }, 15000);
294
324
 
295
325
  test("scaffolds the workers target with wrangler lifecycle commands", async () => {
package/src/scaffold.ts CHANGED
@@ -12,6 +12,11 @@ import {
12
12
  import { exampleForProfile, type Profile } from "./profiles";
13
13
  import type { GitBootstrapConfig } from "./git-bootstrap";
14
14
 
15
+ const GENERATED_GITHUB_ACTION_WORKFLOWS = new Set([
16
+ ".github/workflows/preview.yml",
17
+ ".github/workflows/deploy.yml",
18
+ ]);
19
+
15
20
  export type ScaffoldConfig = {
16
21
  directory: string;
17
22
  serviceName: string;
@@ -64,7 +69,7 @@ export async function scaffoldProject(config: ScaffoldConfig) {
64
69
  continue;
65
70
  }
66
71
  const sourcePath = join(template.root, relativePath);
67
- const destinationPath = join(targetDir, relativePath);
72
+ const destinationPath = join(targetDir, templateDestinationPath(relativePath));
68
73
  const raw = await Bun.file(sourcePath).text();
69
74
  const rendered = renderTemplate(raw, replacements);
70
75
 
@@ -76,6 +81,10 @@ export async function scaffoldProject(config: ScaffoldConfig) {
76
81
  await writeLocalEnvFile(targetDir, replacements);
77
82
  }
78
83
 
84
+ function templateDestinationPath(relativePath: string) {
85
+ return relativePath === "_gitignore" ? ".gitignore" : relativePath;
86
+ }
87
+
79
88
  function shouldSkipForTarget(target: DeployTarget, templateKind: "shared" | "variant" | "target", relativePath: string) {
80
89
  if (
81
90
  relativePath === "scripts/authctl.ts" ||
@@ -153,12 +162,23 @@ async function collectTemplateFiles(root: string, relative = ""): Promise<string
153
162
  files.push(...(await collectTemplateFiles(root, nextRelative)));
154
163
  continue;
155
164
  }
165
+ if (shouldSkipTemplateFile(nextRelative)) {
166
+ continue;
167
+ }
156
168
  files.push(nextRelative);
157
169
  }
158
170
 
159
171
  return files.sort();
160
172
  }
161
173
 
174
+ function shouldSkipTemplateFile(relativePath: string) {
175
+ if (!relativePath.startsWith(".github/")) {
176
+ return false;
177
+ }
178
+
179
+ return !GENERATED_GITHUB_ACTION_WORKFLOWS.has(relativePath);
180
+ }
181
+
162
182
  function buildReplacements(config: ScaffoldConfig) {
163
183
  const example = exampleForProfile(config.profile);
164
184
  const serviceAccountBase = compactIdentifier(config.serviceName, 21);
@@ -219,6 +239,13 @@ function buildReplacements(config: ScaffoldConfig) {
219
239
  COMMAND_DEV_DOWN: "service dev down",
220
240
  COMMAND_BOOTSTRAP: "service create",
221
241
  COMMAND_DEPLOY: "service deploy",
242
+ COMMAND_OBSERVABILITY_BOOTSTRAP:
243
+ config.runtime === "bun" ? "bun run observability-bootstrap" : "make observability-bootstrap",
244
+ WORKFLOW_DEPLOY_MAIN_COMMAND: "service deploy --ci",
245
+ WORKFLOW_DEPLOY_PREVIEW_COMMAND:
246
+ "service deploy --ci --environment preview --name ${{ github.ref_name }}",
247
+ WORKFLOW_DEPLOY_MAIN_DOC_COMMAND: "service deploy --ci",
248
+ WORKFLOW_DEPLOY_PREVIEW_DOC_COMMAND: "service deploy --ci --environment preview --name <branch-name>",
222
249
  COMMAND_AUTH_RESOURCE: "service auth resource-server",
223
250
  COMMAND_AUTH_CLIENT: "service auth client create",
224
251
  COMMAND_AUTH_TOKEN: "service auth token",
@@ -6,6 +6,7 @@ import { stopLocalDev } from "../local-dev";
6
6
  import { bootstrap, prepareGcpProject } from "./bootstrap";
7
7
  import { cleanup } from "./cleanup";
8
8
  import { deploy } from "./deploy";
9
+ import { observabilityBootstrap } from "./observability";
9
10
  import { config } from "./config";
10
11
  import {
11
12
  accessSecretVersion,
@@ -69,6 +70,14 @@ export async function main(argv = Bun.argv.slice(2)) {
69
70
  return;
70
71
  }
71
72
 
73
+ if (command === "observability-bootstrap") {
74
+ await runMain("Google observability bootstrap", async () => {
75
+ await observabilityBootstrap();
76
+ return `Google observability bootstrap finished for ${config.serviceName}`;
77
+ });
78
+ return;
79
+ }
80
+
72
81
  if (command === "dev") {
73
82
  if (rest[0] !== "down") {
74
83
  throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
@@ -125,6 +134,7 @@ function formatHelp() {
125
134
  " sdk Build or publish generated SDK artifacts",
126
135
  " dns Repair or inspect DNS mappings",
127
136
  " dev down Stop local dev and Docker Compose containers",
137
+ " observability-bootstrap Enable Google observability APIs",
128
138
  " dashboards Publish Grafana resources",
129
139
  " destroy Remove service-managed cloud resources",
130
140
  ].join("\n");
@@ -55,6 +55,9 @@ export const config = {
55
55
  repository: serviceConfig.git?.repository || serviceConfig.service_id,
56
56
  deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
57
57
  },
58
+ observability: {
59
+ requiredApis: serviceConfig.observability?.required_apis ?? ["logging.googleapis.com", "monitoring.googleapis.com", "cloudtrace.googleapis.com"],
60
+ },
58
61
  requiredApis: cloudrun.required_apis,
59
62
  } as const;
60
63
 
@@ -605,7 +605,6 @@ export function ensureProductionDomainMapping(serviceName: string) {
605
605
  }
606
606
 
607
607
  const result = gcloud([
608
- "beta",
609
608
  "run",
610
609
  "domain-mappings",
611
610
  "create",
@@ -627,7 +626,6 @@ export function describeProductionDomainMapping():
627
626
  | undefined {
628
627
  const result = gcloud(
629
628
  [
630
- "beta",
631
629
  "run",
632
630
  "domain-mappings",
633
631
  "describe",
@@ -680,7 +678,6 @@ export function deleteProductionDomainMapping() {
680
678
  deleteCloudflareDnsRecord();
681
679
  gcloud(
682
680
  [
683
- "beta",
684
681
  "run",
685
682
  "domain-mappings",
686
683
  "delete",
@@ -0,0 +1,18 @@
1
+ import { config } from "./config";
2
+ import { gcloud, requireCommand, requireGcloudAuth, runMain, runStep } from "./lib";
3
+
4
+ export async function observabilityBootstrap() {
5
+ requireCommand("gcloud");
6
+ requireGcloudAuth();
7
+
8
+ await runStep("Enabling Google observability APIs", () =>
9
+ gcloud(["services", "enable", ...config.observability.requiredApis, "--project", config.project.id])
10
+ );
11
+ }
12
+
13
+ if (import.meta.main) {
14
+ await runMain("Google observability bootstrap", async () => {
15
+ await observabilityBootstrap();
16
+ return `Google observability bootstrap finished for ${config.serviceName}`;
17
+ });
18
+ }
@@ -0,0 +1,40 @@
1
+ # Local development defaults.
2
+ # The scaffold also writes a ready-to-use .env.local for Docker Compose Postgres.
3
+
4
+ DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable
5
+
6
+ TEMPORAL_ENABLED=false
7
+ TEMPORAL_ADDRESS=localhost:7233
8
+ TEMPORAL_NAMESPACE=default
9
+ TEMPORAL_TASK_QUEUE={{SERVICE_ID}}
10
+ # Optional for Temporal Cloud. `service create` writes this to Secret Manager.
11
+ TEMPORAL_API_KEY=
12
+
13
+ AUTH_ENABLED=false
14
+ AUTH_ISSUER=https://auth.anmho.com
15
+ AUTH_AUDIENCE=api://{{SERVICE_ID}}
16
+ AUTH_JWKS_URL=https://auth.anmho.com/api/auth/jwks
17
+
18
+ # Production authctl operations such as `service create` and
19
+ # `service auth client create` load Cloudflare Access values from Vault by
20
+ # default. Direct env values still override Vault when set.
21
+ AUTH_INTERNAL_BASE_URL=https://auth.anmho.com/internal
22
+ CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID=
23
+ CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET=
24
+
25
+ # Remote bootstrap and deploy can use Neon admin credentials directly or through Vault.
26
+ # Do not commit VAULT_TOKEN. Prefer `vault login`; the CLI will also use
27
+ # VAULT_TOKEN_FILE or ~/.vault-token when available.
28
+
29
+ VAULT_ADDR=https://vault.example.com
30
+ VAULT_SECRET_MOUNT=secret
31
+ VAULT_AUTHCTL_ACCESS_PATH=prod/apps/auth/authctl/cloudflare-access
32
+ VAULT_AUTHCTL_ACCESS_BASE_URL_FIELD=AUTH_INTERNAL_BASE_URL
33
+ VAULT_AUTHCTL_ACCESS_CLIENT_ID_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID
34
+ VAULT_AUTHCTL_ACCESS_CLIENT_SECRET_FIELD=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET
35
+ VAULT_NEON_API_KEY_PATH=prod/providers/neon
36
+ VAULT_NEON_API_KEY_FIELD=api_key
37
+
38
+ # Optional provider credentials can be read from Vault or environment by
39
+ # generated adapters you add later. The base waitlist service does not require
40
+ # provider secrets.
@@ -25,7 +25,8 @@ jobs:
25
25
  service_account: ${{ vars.GCP_DEPLOYER_SERVICE_ACCOUNT }}
26
26
  - uses: google-github-actions/setup-gcloud@v2
27
27
  - run: bun install
28
- - run: bun run deploy -- --ci
28
+ - run: bun install -g create-svc@latest
29
+ - run: {{WORKFLOW_DEPLOY_MAIN_COMMAND}}
29
30
  env:
30
31
  NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
31
32
  - if: ${{ vars.GCX_ENABLED == 'true' }}
@@ -1,8 +1,9 @@
1
1
  name: Preview
2
2
 
3
3
  on:
4
- pull_request:
5
- types: [opened, synchronize, reopened]
4
+ push:
5
+ branches-ignore:
6
+ - main
6
7
 
7
8
  permissions:
8
9
  contents: read
@@ -24,6 +25,7 @@ jobs:
24
25
  service_account: ${{ vars.GCP_DEPLOYER_SERVICE_ACCOUNT }}
25
26
  - uses: google-github-actions/setup-gcloud@v2
26
27
  - run: bun install
27
- - run: bun run deploy -- --ci --environment preview --name ${{ github.event.pull_request.number }}
28
+ - run: bun install -g create-svc@latest
29
+ - run: {{WORKFLOW_DEPLOY_PREVIEW_COMMAND}}
28
30
  env:
29
31
  NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
@@ -9,6 +9,7 @@ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run wi
9
9
  - local Docker Compose Postgres for first-run development
10
10
  - the `service` CLI for create, deploy, doctor, dashboards, and destroy
11
11
  - shared GCP project deployment with quota-project-aware `gcloud` calls
12
+ - Google Cloud Observability API bootstrap through `service observability-bootstrap`
12
13
  - Neon-backed remote database provisioning during create and deploy
13
14
  - Better Auth client-credentials resource-server registration through `authctl`
14
15
  - stage-aware waitlist data and trigger ingestion
@@ -77,6 +78,20 @@ Build to build the image remotely.
77
78
  Local Docker builds target `linux/amd64` so images built on Apple Silicon run on
78
79
  Cloud Run.
79
80
 
81
+ ## Google observability bootstrap
82
+
83
+ Run this after the first production create when you want the project ready for
84
+ Google Cloud Observability follow-up work:
85
+
86
+ ```bash
87
+ {{COMMAND_OBSERVABILITY_BOOTSTRAP}}
88
+ ```
89
+
90
+ The command authenticates through `gcloud`, targets the generated GCP project,
91
+ and enables the Google Cloud Logging, Monitoring, and Trace APIs. It does not
92
+ create dashboards, alerts, log-based metrics, or SLOs; those remain explicit
93
+ follow-up work.
94
+
80
95
  Authenticate `gcloud` on the machine before running provisioning commands:
81
96
 
82
97
  ```bash
@@ -211,6 +226,28 @@ secrets only when you add a provider adapter. A generic adapter can honor:
211
226
 
212
227
  - `WEBHOOK_<PROVIDER>_SECRET`
213
228
 
229
+ ## GitHub Actions deployment
230
+
231
+ The scaffold emits a minimal deployment workflow slice for Cloud Run:
232
+
233
+ - [.github/workflows/preview.yml](.github/workflows/preview.yml) deploys a preview environment on pushes to non-main branches. It runs `{{WORKFLOW_DEPLOY_PREVIEW_DOC_COMMAND}}`.
234
+ - [.github/workflows/deploy.yml](.github/workflows/deploy.yml) deploys the main production environment on pushes to `main`. It runs `{{WORKFLOW_DEPLOY_MAIN_DOC_COMMAND}}`.
235
+
236
+ These workflows intentionally assume only GitHub OIDC, Google Cloud Workload Identity Federation, the generated `service` CLI, and Neon.
237
+ They do not use a long-lived Google service account key.
238
+
239
+ Before enabling the workflows, set these GitHub repository variables:
240
+
241
+ - `GCP_WIF_PROVIDER`: full Workload Identity Provider resource name that trusts this repository
242
+ - `GCP_DEPLOYER_SERVICE_ACCOUNT`: deployer service account email
243
+
244
+ Set this GitHub repository secret:
245
+
246
+ - `NEON_API_KEY`: Neon admin API key used to create preview branches and resolve the main database
247
+
248
+ The deployer service account needs enough access in the generated GCP project to run `service deploy`, including Cloud Run, Artifact Registry, Secret Manager, IAM service account usage, and storage operations for this service.
249
+ Run `{{COMMAND_BOOTSTRAP}}` once before relying on the production workflow for a fresh project.
250
+
214
251
  ## One-command production create
215
252
 
216
253
  The one-command production create path is designed for a fresh standalone service.
@@ -0,0 +1,13 @@
1
+ node_modules
2
+ .env
3
+ .env.local
4
+ .env.*
5
+ coverage
6
+ dist
7
+ out
8
+ .cloudrun.rendered.yaml
9
+ .service/*.log
10
+ .service/*.pid
11
+ .wrangler
12
+ .DS_Store
13
+ {{GITIGNORE_EXTRA}}
@@ -85,6 +85,14 @@
85
85
  "module": "buf.build/anmho/{{SERVICE_ID}}"
86
86
  },
87
87
 
88
+ "observability": {
89
+ "required_apis": [
90
+ "logging.googleapis.com",
91
+ "monitoring.googleapis.com",
92
+ "cloudtrace.googleapis.com"
93
+ ]
94
+ },
95
+
88
96
  "cloudrun": {
89
97
  "project_id": "{{PROJECT_ID}}",
90
98
  "project_name": "{{PROJECT_NAME}}",
@@ -12,6 +12,7 @@ jobs:
12
12
  - uses: actions/checkout@v4
13
13
  - uses: oven-sh/setup-bun@v2
14
14
  - run: bun install
15
+ - run: bun install -g create-svc@latest
15
16
  - run: bun run deploy
16
17
  env:
17
18
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -1,4 +1,4 @@
1
- .PHONY: dev migrate gen lint test create deploy dashboards auth destroy
1
+ .PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
2
2
 
3
3
  SERVICE := service
4
4
 
@@ -26,6 +26,9 @@ deploy:
26
26
  dashboards:
27
27
  $(SERVICE) dashboards
28
28
 
29
+ observability-bootstrap:
30
+ $(SERVICE) observability-bootstrap
31
+
29
32
  auth:
30
33
  $(SERVICE) auth $(ARGS)
31
34
 
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
18
18
  created_at timestamptz not null default now(),
19
19
  processed_at timestamptz
20
20
  );
21
+
22
+ create table if not exists webhook_events (
23
+ id text primary key,
24
+ provider text not null,
25
+ external_event_id text not null,
26
+ payload_json text not null,
27
+ headers_json text not null,
28
+ received_at timestamptz not null default now(),
29
+ unique (provider, external_event_id)
30
+ );
@@ -12,6 +12,7 @@
12
12
  "create": "service create",
13
13
  "deploy": "service deploy",
14
14
  "dashboards": "service dashboards",
15
+ "observability-bootstrap": "service observability-bootstrap",
15
16
  "auth": "service auth",
16
17
  "destroy": "service destroy"
17
18
  },