rastack 0.0.33 → 0.0.35

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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * `rastack deploy` config — the single source of truth for a repo's serverless
3
+ * deployment, written to `.rastack/deploy.json`.
4
+ *
5
+ * The deploy is split by *what changes* (see docs/serverless-deploy.md): the
6
+ * CloudFormation stack owns all durable infrastructure and is created once by a
7
+ * human, while CI does exactly one thing on every push —
8
+ * `aws lambda update-function-code`. This module models the small set of
9
+ * *authoring inputs* a human decides up front (app slug, region, the GitHub repo
10
+ * whose CI ships the code, the Lambda build shape) plus the *stack outputs* that
11
+ * only exist after the stack is created (function name, API URL, JWKS URL, …).
12
+ * CI resolves any missing output live from the stack, so the config stays a
13
+ * short, human-owned file.
14
+ *
15
+ * Everything here is pure and side-effect-free — the same discipline as the
16
+ * resource compiler and the token compiler. `rastack-{init,deploy,ci}.ts` are the
17
+ * thin IO wrappers that read/write the file and shell out.
18
+ */
19
+
20
+ /** Values CI resolves from the CloudFormation stack Outputs (see app.yaml). */
21
+ export interface DeployOutputs {
22
+ /** Lambda function name — CI targets this with `update-function-code`. */
23
+ functionName?: string;
24
+ /** Base URL of the deployed HTTP API. */
25
+ apiUrl?: string;
26
+ /** OIDC role CI assumes — wire as the `AWS_DEPLOY_ROLE_ARN` repo Variable. */
27
+ deployRoleArn?: string;
28
+ /** Cognito User Pool id (identity / JWTs). */
29
+ userPoolId?: string;
30
+ /** App client id — the token audience `rastack-server` pins. */
31
+ userPoolClientId?: string;
32
+ /** JWKS URL baked into the Lambda bundle at build time. */
33
+ jwksUrl?: string;
34
+ /** S3 Iceberg warehouse bucket — the system of record. */
35
+ warehouseBucket?: string;
36
+ }
37
+
38
+ export interface LambdaBuild {
39
+ /** Cargo bin built for the custom runtime (`provided.al2023`). */
40
+ bin: string;
41
+ /** Target architecture — arm64 (Graviton, the default) or x86_64. */
42
+ arch: "arm64" | "x86_64";
43
+ /** Cargo features the Lambda build enables. */
44
+ features: string;
45
+ }
46
+
47
+ export interface DeployConfig {
48
+ /** App slug — namespaces buckets/roles/SSM. Cognito-safe: `^[a-z0-9]{1,20}$`. */
49
+ app: string;
50
+ /** Resource-name prefix shared across a deployment. `^[a-z0-9]{1,20}$`. */
51
+ namePrefix: string;
52
+ /** AWS region the stack (and CI) target. */
53
+ region: string;
54
+ /** CloudFormation stack name — defaults to `${namePrefix}-${app}`. */
55
+ stackName: string;
56
+ /** The GitHub repo whose CI is trusted to ship the Lambda code. */
57
+ github: { owner: string; repo: string; branch: string };
58
+ /** Iceberg namespace the warehouse writes under. */
59
+ namespace: string;
60
+ /** How the Rust Lambda bundle is built. */
61
+ lambda: LambdaBuild;
62
+ /** Where the TypeScript resource definitions live. */
63
+ resourcesDir: string;
64
+ /** Where `rastack compile` writes the manifest + OpenAPI. */
65
+ outDir: string;
66
+ /** Bootstrap templates bucket that hosts `app.yaml` (for the launch command). */
67
+ templatesBucket?: string;
68
+ /** Bootstrap artifacts bucket holding the Lambda placeholder bundle. */
69
+ artifactsBucket?: string;
70
+ /** Whether the function verifies the `access` or `id` token. */
71
+ tokenUse: "access" | "id";
72
+ /** Values resolved from the stack after it is created (optional). */
73
+ outputs?: DeployOutputs;
74
+ }
75
+
76
+ export const DEFAULT_LAMBDA: LambdaBuild = {
77
+ bin: "bootstrap",
78
+ arch: "arm64",
79
+ features: "lambda",
80
+ };
81
+
82
+ /** The out-of-the-box config — everything a fresh repo can default sensibly. */
83
+ export const DEFAULTS: Omit<DeployConfig, "app" | "github"> = {
84
+ namePrefix: "rastack",
85
+ region: "af-south-1",
86
+ stackName: "",
87
+ namespace: "production",
88
+ lambda: DEFAULT_LAMBDA,
89
+ resourcesDir: "resources",
90
+ outDir: ".rastack",
91
+ tokenUse: "access",
92
+ };
93
+
94
+ /** A recursively-optional view of the config, as read from disk or CLI flags. */
95
+ export type PartialDeployConfig = {
96
+ [K in keyof DeployConfig]?: DeployConfig[K] extends object
97
+ ? Partial<DeployConfig[K]>
98
+ : DeployConfig[K];
99
+ };
100
+
101
+ /**
102
+ * Fill a partial config with defaults into a complete `DeployConfig`. Never
103
+ * throws — `validateConfig` reports what is still missing or malformed.
104
+ */
105
+ export function normalizeConfig(
106
+ partial: PartialDeployConfig = {},
107
+ ): DeployConfig {
108
+ const app = (partial.app ?? "").trim();
109
+ const namePrefix = (partial.namePrefix ?? DEFAULTS.namePrefix).trim();
110
+ const github = {
111
+ owner: (partial.github?.owner ?? "").trim(),
112
+ repo: (partial.github?.repo ?? "").trim(),
113
+ branch: (partial.github?.branch ?? "master").trim(),
114
+ };
115
+ const stackName =
116
+ (partial.stackName ?? "").trim() || (app ? `${namePrefix}-${app}` : "");
117
+ return {
118
+ app,
119
+ namePrefix,
120
+ region: (partial.region ?? DEFAULTS.region).trim(),
121
+ stackName,
122
+ github,
123
+ namespace: (partial.namespace ?? DEFAULTS.namespace).trim(),
124
+ lambda: {
125
+ bin: (partial.lambda?.bin ?? DEFAULT_LAMBDA.bin).trim(),
126
+ arch: partial.lambda?.arch ?? DEFAULT_LAMBDA.arch,
127
+ features: (partial.lambda?.features ?? DEFAULT_LAMBDA.features).trim(),
128
+ },
129
+ resourcesDir: (partial.resourcesDir ?? DEFAULTS.resourcesDir).trim(),
130
+ outDir: (partial.outDir ?? DEFAULTS.outDir).trim(),
131
+ templatesBucket: partial.templatesBucket?.trim() || undefined,
132
+ artifactsBucket: partial.artifactsBucket?.trim() || undefined,
133
+ tokenUse: partial.tokenUse ?? DEFAULTS.tokenUse,
134
+ outputs: partial.outputs ? { ...partial.outputs } : undefined,
135
+ };
136
+ }
137
+
138
+ const SLUG = /^[a-z0-9]{1,20}$/;
139
+ const REGION = /^[a-z]{2}-[a-z]+-\d+$/;
140
+ const GH_NAME = /^[A-Za-z0-9_.-]+$/;
141
+ const NAMESPACE = /^[a-z0-9_]+$/;
142
+
143
+ /**
144
+ * Return a list of human-readable problems, empty when the config is deployable.
145
+ * The slug/prefix patterns mirror app.yaml's `AllowedPattern`s exactly, so a
146
+ * config that passes here also passes CloudFormation's own validation.
147
+ */
148
+ export function validateConfig(cfg: DeployConfig): string[] {
149
+ const errors: string[] = [];
150
+ if (!cfg.app) errors.push("app is required (a short slug, e.g. `airline`).");
151
+ else if (!SLUG.test(cfg.app))
152
+ errors.push(
153
+ `app "${cfg.app}" must match ${SLUG} (lowercase letters/digits, 1–20).`,
154
+ );
155
+
156
+ if (!SLUG.test(cfg.namePrefix))
157
+ errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
158
+
159
+ if (!cfg.region) errors.push("region is required (e.g. `af-south-1`).");
160
+ else if (!REGION.test(cfg.region))
161
+ errors.push(
162
+ `region "${cfg.region}" does not look like an AWS region (e.g. af-south-1).`,
163
+ );
164
+
165
+ if (!cfg.github.owner) errors.push("github.owner is required.");
166
+ else if (!GH_NAME.test(cfg.github.owner))
167
+ errors.push(
168
+ `github.owner "${cfg.github.owner}" is not a valid GitHub name.`,
169
+ );
170
+ if (!cfg.github.repo) errors.push("github.repo is required.");
171
+ else if (!GH_NAME.test(cfg.github.repo))
172
+ errors.push(`github.repo "${cfg.github.repo}" is not a valid GitHub name.`);
173
+ if (!cfg.github.branch) errors.push("github.branch is required.");
174
+
175
+ if (!NAMESPACE.test(cfg.namespace))
176
+ errors.push(`namespace "${cfg.namespace}" must match ${NAMESPACE}.`);
177
+
178
+ if (cfg.lambda.arch !== "arm64" && cfg.lambda.arch !== "x86_64")
179
+ errors.push(
180
+ `lambda.arch must be "arm64" or "x86_64" (got "${cfg.lambda.arch}").`,
181
+ );
182
+ if (!cfg.lambda.bin) errors.push("lambda.bin is required.");
183
+
184
+ return errors;
185
+ }
186
+
187
+ /** Deep-merge a patch over a base config (used by `deploy`/`init` reconfigure). */
188
+ export function mergeConfig(
189
+ base: DeployConfig,
190
+ patch: PartialDeployConfig,
191
+ ): DeployConfig {
192
+ return normalizeConfig({
193
+ ...base,
194
+ ...patch,
195
+ github: { ...base.github, ...patch.github },
196
+ lambda: { ...base.lambda, ...patch.lambda },
197
+ outputs: { ...base.outputs, ...patch.outputs },
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Recover `{ owner, repo }` from a GitHub remote URL — both the SSH
203
+ * (`git@github.com:owner/repo.git`) and HTTPS
204
+ * (`https://github.com/owner/repo(.git)`) forms — so `init` can default the
205
+ * GitHub coordinates from `git remote get-url origin`. Returns null for any
206
+ * non-GitHub or unparseable URL.
207
+ */
208
+ export function parseGitHubRemote(
209
+ url: string,
210
+ ): { owner: string; repo: string } | null {
211
+ const trimmed = url.trim();
212
+ const m =
213
+ /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed) ||
214
+ /^(?:https?:\/\/|ssh:\/\/git@)github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(
215
+ trimmed,
216
+ );
217
+ if (!m) return null;
218
+ const owner = m[1];
219
+ const repo = m[2];
220
+ if (!GH_NAME.test(owner) || !GH_NAME.test(repo)) return null;
221
+ return { owner, repo };
222
+ }
223
+
224
+ /** A `Key=Value` CloudFormation parameter override. */
225
+ export interface StackParameter {
226
+ key: string;
227
+ value: string;
228
+ }
229
+
230
+ /**
231
+ * The `--parameter-overrides` for `aws cloudformation deploy` of app.yaml,
232
+ * derived from the config. Order and keys match the template's `Parameters`.
233
+ * `ArtifactsBucket` (which has no template default) is included only when set.
234
+ */
235
+ export function stackParameters(cfg: DeployConfig): StackParameter[] {
236
+ const params: StackParameter[] = [
237
+ { key: "AppName", value: cfg.app },
238
+ { key: "NamePrefix", value: cfg.namePrefix },
239
+ { key: "GitHubOwner", value: cfg.github.owner },
240
+ { key: "GitHubRepo", value: cfg.github.repo },
241
+ { key: "GitHubBranch", value: cfg.github.branch },
242
+ { key: "IcebergNamespace", value: cfg.namespace },
243
+ ];
244
+ if (cfg.artifactsBucket)
245
+ params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
246
+ return params;
247
+ }
248
+
249
+ /** The S3 URL app.yaml is launched from, when the templates bucket is known. */
250
+ export function templateUrl(cfg: DeployConfig): string | null {
251
+ if (!cfg.templatesBucket) return null;
252
+ return `https://${cfg.templatesBucket}.s3.${cfg.region}.amazonaws.com/app.yaml`;
253
+ }
254
+
255
+ /**
256
+ * The one-time `aws cloudformation deploy` command that creates the app's
257
+ * infrastructure. Launched from the hosted template URL when the templates
258
+ * bucket is known, else from the in-repo `deploy/cloudformation/app.yaml`.
259
+ */
260
+ export function deployStackCommand(cfg: DeployConfig): string {
261
+ const src = templateUrl(cfg);
262
+ const templateArg = src
263
+ ? `--template-url ${src}`
264
+ : "--template-file deploy/cloudformation/app.yaml";
265
+ const overrides = stackParameters(cfg)
266
+ .map((p) => `${p.key}=${p.value}`)
267
+ .join(" ");
268
+ return [
269
+ "aws cloudformation deploy",
270
+ templateArg,
271
+ `--stack-name ${cfg.stackName}`,
272
+ "--capabilities CAPABILITY_NAMED_IAM",
273
+ `--region ${cfg.region}`,
274
+ `--parameter-overrides ${overrides}`,
275
+ ].join(" \\\n ");
276
+ }
277
+
278
+ /** GitHub repo Variable to wire (name + resolved value, if known from outputs). */
279
+ export interface RepoVariable {
280
+ name: string;
281
+ value: string;
282
+ from: string;
283
+ }
284
+
285
+ /**
286
+ * The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
287
+ * `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
288
+ * Outputs when already resolved, else an empty string to fill in from the
289
+ * Console.
290
+ */
291
+ export function repoVariables(cfg: DeployConfig): RepoVariable[] {
292
+ return [
293
+ {
294
+ name: "AWS_DEPLOY_ROLE_ARN",
295
+ value: cfg.outputs?.deployRoleArn ?? "",
296
+ from: "DeployRoleArn stack output",
297
+ },
298
+ { name: "AWS_REGION", value: cfg.region, from: "Region stack output" },
299
+ ];
300
+ }
301
+
302
+ /**
303
+ * The `RASTACK_*` environment the Lambda reads (docs/serverless-deploy.md §1).
304
+ * The IaC sets these on the function; surfaced here for `rastack deploy` to
305
+ * print so the mapping between config and runtime env is inspectable.
306
+ */
307
+ export function lambdaEnv(cfg: DeployConfig): StackParameter[] {
308
+ const warehouse = cfg.outputs?.warehouseBucket
309
+ ? `s3://${cfg.outputs.warehouseBucket}/warehouse`
310
+ : "s3://{warehouse-bucket}/warehouse";
311
+ const manifest = `/var/task/schema.rastack.json`;
312
+ return [
313
+ { key: "RASTACK_WAREHOUSE", value: warehouse },
314
+ { key: "RASTACK_MANIFEST", value: manifest },
315
+ { key: "RASTACK_OPENAPI", value: "/var/task/openapi.json" },
316
+ { key: "RASTACK_JWKS", value: "/var/task/jwks.json" },
317
+ {
318
+ key: "RASTACK_ISSUER",
319
+ value: cfg.outputs?.userPoolId
320
+ ? `https://cognito-idp.${cfg.region}.amazonaws.com/${cfg.outputs.userPoolId}`
321
+ : `https://cognito-idp.${cfg.region}.amazonaws.com/{userPoolId}`,
322
+ },
323
+ {
324
+ key: "RASTACK_AUDIENCE",
325
+ value: cfg.outputs?.userPoolClientId ?? "{appClientId}",
326
+ },
327
+ { key: "RASTACK_TOKEN_USE", value: cfg.tokenUse },
328
+ ];
329
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The `rastack deploy | ci | init` core — the DevOps surface of React API Stack.
3
+ *
4
+ * Pure, testable building blocks (config, CI plan, workflow templates); the
5
+ * `rastack-{deploy,ci,init}.ts` entrypoints are the thin IO wrappers over them.
6
+ */
7
+
8
+ export * from "./config";
9
+ export * from "./ci";
10
+ export * from "./workflows";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Filesystem + prompt helpers shared by the `rastack deploy | ci | init`
3
+ * entrypoints. Everything pure lives in the sibling modules; this is the small
4
+ * IO boundary they wrap.
5
+ */
6
+
7
+ import { execSync } from "child_process";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as readline from "readline";
11
+ import { CONFIG_PATH } from "./workflows";
12
+ import { DeployConfig, PartialDeployConfig, normalizeConfig } from "./config";
13
+
14
+ /** Absolute path to the deploy config for a project root. */
15
+ export function configPath(cwd = process.cwd()): string {
16
+ return path.join(cwd, CONFIG_PATH);
17
+ }
18
+
19
+ /** Read + normalize the deploy config, or null when it does not exist yet. */
20
+ export function loadConfig(cwd = process.cwd()): DeployConfig | null {
21
+ const file = configPath(cwd);
22
+ if (!fs.existsSync(file)) return null;
23
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as PartialDeployConfig;
24
+ return normalizeConfig(raw);
25
+ }
26
+
27
+ /** Write the deploy config, creating `.rastack/` if needed. */
28
+ export function saveConfig(cfg: DeployConfig, cwd = process.cwd()): string {
29
+ const file = configPath(cwd);
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
32
+ return file;
33
+ }
34
+
35
+ /**
36
+ * Best-effort GitHub `origin` remote URL, for defaulting the repo coordinates.
37
+ * Returns null when not a git repo or no origin.
38
+ */
39
+ export function gitRemoteUrl(cwd = process.cwd()): string | null {
40
+ try {
41
+ // Reads the developer's own git config with a fixed command; no untrusted
42
+ // input, so there is no command-injection surface.
43
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
44
+ const out = execSync("git remote get-url origin", {
45
+ cwd,
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ });
48
+ const url = out.toString().trim();
49
+ return url || null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** True when we can prompt the user (a TTY, and not explicitly opted out). */
56
+ export function canPrompt(nonInteractive: boolean): boolean {
57
+ return !nonInteractive && Boolean(process.stdin.isTTY);
58
+ }
59
+
60
+ /**
61
+ * Ask a single question with a default, echoed as `label [default]: `. Returns
62
+ * the trimmed answer, or the default on an empty line.
63
+ */
64
+ export function ask(
65
+ rl: readline.Interface,
66
+ label: string,
67
+ def: string,
68
+ ): Promise<string> {
69
+ const suffix = def ? ` [${def}]` : "";
70
+ return new Promise((resolve) => {
71
+ rl.question(` ${label}${suffix}: `, (answer) => {
72
+ resolve(answer.trim() || def);
73
+ });
74
+ });
75
+ }
76
+
77
+ /** Run a callback with a readline interface, always closing it. */
78
+ export async function withPrompt<T>(
79
+ fn: (rl: readline.Interface) => Promise<T>,
80
+ ): Promise<T> {
81
+ const rl = readline.createInterface({
82
+ input: process.stdin,
83
+ output: process.stdout,
84
+ });
85
+ try {
86
+ return await fn(rl);
87
+ } finally {
88
+ rl.close();
89
+ }
90
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The files `rastack init` templates into a repo — the base GitHub Actions and
3
+ * the deploy config — so starting (or reconfiguring) a project is one command.
4
+ *
5
+ * The generated workflows lean on `rastack ci`: rather than inlining the build
6
+ * and deploy shell, each job runs one `rastack ci …` line, and the CLI owns the
7
+ * steps (tools/src/deploy/ci.ts). That keeps the YAML short and keeps the
8
+ * pipeline logic versioned with the CLI, testable, and inspectable with
9
+ * `rastack ci --plan`.
10
+ *
11
+ * Pure string builders — `rastack-init.ts` is the thin wrapper that writes them.
12
+ */
13
+
14
+ import { DeployConfig } from "./config";
15
+
16
+ /** A templated file: repo-relative path + its contents. */
17
+ export interface ScaffoldFile {
18
+ path: string;
19
+ content: string;
20
+ }
21
+
22
+ /** Node version the templated workflows pin (LTS — safe for a fresh repo). */
23
+ const NODE_VERSION = "22";
24
+
25
+ /**
26
+ * `.github/workflows/ci.yml` — validate resources and compile the manifest on
27
+ * every push/PR. The clean replacement for hand-written compile/check steps.
28
+ */
29
+ export function renderCiWorkflow(cfg: DeployConfig): string {
30
+ return `name: CI
31
+
32
+ # Validate the resource graph and compile the manifest on every change.
33
+ # The heavy lifting lives in \`rastack ci check\` (see tools/src/deploy/ci.ts),
34
+ # so this workflow stays a single command.
35
+
36
+ on:
37
+ push:
38
+ branches: [${cfg.github.branch}]
39
+ pull_request:
40
+
41
+ permissions:
42
+ contents: read
43
+
44
+ jobs:
45
+ validate:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - uses: actions/setup-node@v4
50
+ with:
51
+ node-version: '${NODE_VERSION}'
52
+ - name: Install rastack
53
+ run: npm install -g rastack
54
+ - name: Validate resources & compile manifest
55
+ run: rastack ci check
56
+ `;
57
+ }
58
+
59
+ /**
60
+ * `.github/workflows/deploy-backend.yml` — ship the Rust Lambda on every push to
61
+ * the deploy branch. Authenticates with GitHub OIDC (no stored keys) and then
62
+ * runs one line, `rastack ci deploy`, which cross-builds the bundle, bakes the
63
+ * JWKS, and calls `update-function-code`. That single privileged call is the
64
+ * whole of CI's blast radius (docs/serverless-deploy.md §2).
65
+ */
66
+ export function renderDeployWorkflow(cfg: DeployConfig): string {
67
+ const arch = cfg.lambda.arch === "arm64" ? "arm64" : "x86_64";
68
+ return `name: Deploy backend
69
+
70
+ # Ship the Rust API (rastack-server) to AWS Lambda. Infrastructure is created
71
+ # once by a human via CloudFormation (docs/serverless-deploy.md); this workflow
72
+ # only ever runs \`aws lambda update-function-code\`, wrapped by \`rastack ci\`.
73
+ #
74
+ # Wire these from the CloudFormation stack Outputs as GitHub repo Variables:
75
+ # AWS_DEPLOY_ROLE_ARN DeployRoleArn (the OIDC role this job assumes)
76
+ # AWS_REGION Region (${cfg.region})
77
+
78
+ on:
79
+ push:
80
+ branches: [${cfg.github.branch}]
81
+
82
+ # Least privilege: read the repo + mint an OIDC token to assume the deploy role.
83
+ permissions:
84
+ contents: read
85
+ id-token: write
86
+
87
+ concurrency:
88
+ group: deploy-backend
89
+ cancel-in-progress: true
90
+
91
+ jobs:
92
+ deploy:
93
+ name: Build & deploy the Lambda (${arch})
94
+ runs-on: ubuntu-latest
95
+ # Skip on forks / repos that never wired the deploy role, so the job does
96
+ # not fail red for contributors who cannot (and should not) deploy.
97
+ if: vars.AWS_DEPLOY_ROLE_ARN != ''
98
+ steps:
99
+ - uses: actions/checkout@v4
100
+
101
+ - uses: actions/setup-node@v4
102
+ with:
103
+ node-version: '${NODE_VERSION}'
104
+ - name: Install rastack
105
+ run: npm install -g rastack
106
+
107
+ - uses: dtolnay/rust-toolchain@stable
108
+ - name: Cache cargo
109
+ uses: Swatinem/rust-cache@v2
110
+ with:
111
+ workspaces: rust
112
+ # cargo-lambda cross-builds the custom-runtime binary; it auto-installs
113
+ # zig (via pip) for the cross-compile when needed.
114
+ - name: Install cargo-lambda
115
+ run: pip3 install cargo-lambda
116
+
117
+ - name: Configure AWS credentials (OIDC — no stored keys)
118
+ uses: aws-actions/configure-aws-credentials@v4
119
+ with:
120
+ role-to-assume: \${{ vars.AWS_DEPLOY_ROLE_ARN }}
121
+ aws-region: \${{ vars.AWS_REGION }}
122
+
123
+ # One line: compile the manifest, cross-build the bundle, bake the JWKS,
124
+ # and update-function-code. See \`rastack ci --plan deploy\`.
125
+ - name: Build & deploy
126
+ run: rastack ci deploy
127
+ `;
128
+ }
129
+
130
+ /** The deploy config written to `.rastack/deploy.json`, pretty-printed. */
131
+ export function renderConfigFile(cfg: DeployConfig): string {
132
+ return JSON.stringify(cfg, null, 2) + "\n";
133
+ }
134
+
135
+ export const CONFIG_PATH = ".rastack/deploy.json";
136
+ export const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
137
+ export const DEPLOY_WORKFLOW_PATH = ".github/workflows/deploy-backend.yml";
138
+
139
+ export interface ScaffoldOptions {
140
+ /** Omit the GitHub Actions workflows (config only). */
141
+ noWorkflows?: boolean;
142
+ }
143
+
144
+ /**
145
+ * The full set of files `rastack init` templates: the deploy config plus the two
146
+ * base workflows. Returned as data so the caller decides what to write, skip, or
147
+ * overwrite.
148
+ */
149
+ export function scaffoldFiles(
150
+ cfg: DeployConfig,
151
+ opts: ScaffoldOptions = {},
152
+ ): ScaffoldFile[] {
153
+ const files: ScaffoldFile[] = [
154
+ { path: CONFIG_PATH, content: renderConfigFile(cfg) },
155
+ ];
156
+ if (!opts.noWorkflows) {
157
+ files.push(
158
+ { path: CI_WORKFLOW_PATH, content: renderCiWorkflow(cfg) },
159
+ { path: DEPLOY_WORKFLOW_PATH, content: renderDeployWorkflow(cfg) },
160
+ );
161
+ }
162
+ return files;
163
+ }