Skip to content

Commit 6aa108e

Browse files
committed
[release] Add prebuilt deploy mode for functions and sites
Opt-in mode that bypasses Appwrite's server-side build container entirely: runs the build locally, ships node_modules / dist inside the tarball, and tells Appwrite to skip its build step (empty commands sent to createDeployment). Removes the need for a GitHub installation / token on Appwrite when functions or sites pull private GitHub-hosted dependencies. Default behavior is unchanged. Enable per-resource by setting `prebuilt: true` in .fnconfig.yaml / config.yaml, or per single-function CLI deploy with --prebuilt. The deploy-functions GitHub Action gains a `prebuilt` input, and the MCP `deploy_functions` tool gains an optional `prebuilt` flag that forces the whole batch. When prebuilt: - functions: `commands` runs locally in the deploy dir before tarring. - sites: `installCommand` then `buildCommand` run locally. - ignore list shrinks to .git/.vscode/.DS_Store so built artifacts ship (user's explicit `ignore` still wins). - createDeployment receives empty commands so Appwrite does nothing server-side. Schema fields added to AppwriteFunctionSchema and AppwriteSiteSchema; the field is optional and undefined falls back to the existing build-on-Appwrite path.
1 parent d9c9cf9 commit 6aa108e

11 files changed

Lines changed: 218 additions & 22 deletions

File tree

actions/_shared/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ function buildArgv(): string[] {
9292
if (enabled !== undefined) argv.push(`--functionEnabled=${enabled}`);
9393
const logging = env("AWU_FN_LOGGING");
9494
if (logging !== undefined) argv.push(`--functionLogging=${logging}`);
95+
const prebuilt = env("AWU_PREBUILT");
96+
if (prebuilt !== undefined) argv.push(`--prebuilt=${prebuilt}`);
9597
break;
9698
}
9799
case "push": {

actions/deploy-functions/action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ inputs:
8484
description: "Override whether function logging is enabled (true/false)."
8585
required: false
8686
default: ""
87+
prebuilt:
88+
description: "Single-function override: run the function's build commands LOCALLY before tarring, ship node_modules/dist inside the tarball, and tell Appwrite to skip its build step. Bypasses Appwrite's build container entirely so private GitHub deps don't need an installation/GitHub token on Appwrite. (true/false)"
89+
required: false
90+
default: ""
8791

8892
runs:
8993
using: "composite"
@@ -116,3 +120,4 @@ runs:
116120
AWU_FN_EXECUTE: ${{ inputs.execute }}
117121
AWU_FN_ENABLED: ${{ inputs.enabled }}
118122
AWU_FN_LOGGING: ${{ inputs.logging }}
123+
AWU_PREBUILT: ${{ inputs.prebuilt }}

packages/appwrite-utils-cli/src/cli/commands/deployFunctionsFlow.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export interface DeployFunctionFieldOverrides {
4040
ignore?: string;
4141
/** Comma-separated domains for --functionDomains. Reconciled after deploy. */
4242
domains?: string;
43+
/**
44+
* When true (from --prebuilt), run the function's `commands` locally and
45+
* ship the resulting build artifacts inside the tarball; Appwrite is told
46+
* to skip its build step. Single-function only.
47+
*/
48+
prebuilt?: boolean;
4349
}
4450

4551
export interface DeployFunctionsFlowOptions {
@@ -208,6 +214,7 @@ function applyOverrides(
208214
if (overrides.deployDir !== undefined) next.deployDir = overrides.deployDir;
209215
if (overrides.ignore !== undefined) next.ignore = splitCsv(overrides.ignore);
210216
if (overrides.domains !== undefined) next.domains = splitCsv(overrides.domains);
217+
if (overrides.prebuilt !== undefined) next.prebuilt = overrides.prebuilt;
211218
return next;
212219
}
213220

packages/appwrite-utils-cli/src/functions/deployments.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const DEFAULT_IGNORED = [
3030
".venv",
3131
];
3232

33+
// Slimmer ignore list for `prebuilt: true` deploys: built artifacts
34+
// (node_modules, .venv, __pycache__) MUST ship so Appwrite can skip its
35+
// build step entirely. Only strip VCS/editor noise.
36+
const PREBUILT_IGNORED = [".git", ".vscode", ".DS_Store"];
37+
3338
/**
3439
* Upload-only phase of a deployment: tars + uploads the function source and
3540
* creates the deployment record. Does NOT wait for the build to finish and
@@ -225,8 +230,13 @@ export interface PreparedFunctionDeployment {
225230
functionName: string;
226231
deployPath: string;
227232
entrypoint: string;
233+
/**
234+
* Commands string sent to Appwrite's createDeployment. Empty when the
235+
* function is prebuilt — Appwrite skips the build step entirely.
236+
*/
228237
commands: string;
229238
ignored: string[];
239+
prebuilt: boolean;
230240
}
231241

232242
/**
@@ -264,9 +274,10 @@ export const prepareFunctionDeployment = async (
264274
throw new Error(`Function directory is invalid or missing required files: ${resolvedPath}`);
265275
}
266276

277+
const isWindows = platform() === "win32";
278+
267279
if (functionConfig.predeployCommands?.length) {
268280
MessageFormatter.processing("Executing predeploy commands...", { prefix: "Deployment" });
269-
const isWindows = platform() === "win32";
270281

271282
for (const command of functionConfig.predeployCommands) {
272283
try {
@@ -299,13 +310,46 @@ export const prepareFunctionDeployment = async (
299310
? join(resolvedPath, functionConfig.deployDir)
300311
: resolvedPath;
301312

313+
// Prebuilt mode: run the function's build commands locally now (in deployPath
314+
// so the built artifacts land alongside the source we're about to tar), then
315+
// tell Appwrite to skip its build step by passing empty commands.
316+
const prebuilt = functionConfig.prebuilt === true;
317+
if (prebuilt && functionConfig.commands && functionConfig.commands.trim().length > 0) {
318+
MessageFormatter.processing(
319+
`[prebuilt] Running build locally: ${functionConfig.commands}`,
320+
{ prefix: "Deployment" }
321+
);
322+
try {
323+
execSync(functionConfig.commands, {
324+
cwd: deployPath,
325+
stdio: "inherit",
326+
shell: isWindows ? "cmd.exe" : "/bin/sh",
327+
windowsHide: true,
328+
});
329+
} catch (error) {
330+
MessageFormatter.error(
331+
`[prebuilt] Local build failed: ${functionConfig.commands}`,
332+
error instanceof Error ? error : undefined,
333+
{ prefix: "Deployment" }
334+
);
335+
throw error;
336+
}
337+
}
338+
339+
// Pick the ignore list. User-supplied `ignore` always wins. Otherwise:
340+
// - prebuilt: slim list (ship node_modules, .venv, __pycache__)
341+
// - normal: classic DEFAULT_IGNORED (strip those)
342+
const ignored =
343+
functionConfig.ignore ?? (prebuilt ? PREBUILT_IGNORED : DEFAULT_IGNORED);
344+
302345
return {
303346
functionId: functionConfig.$id,
304347
functionName,
305348
deployPath,
306349
entrypoint: functionConfig.entrypoint ?? "main.js",
307-
commands: functionConfig.commands ?? "npm install",
308-
ignored: functionConfig.ignore ?? DEFAULT_IGNORED,
350+
commands: prebuilt ? "" : (functionConfig.commands ?? "npm install"),
351+
ignored,
352+
prebuilt,
309353
};
310354
};
311355

packages/appwrite-utils-cli/src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface CliOptions {
9393
functionDeployDir?: string;
9494
functionIgnore?: string;
9595
functionDomains?: string;
96+
prebuilt?: boolean;
9697
pruneDomains?: boolean;
9798
listRules?: string;
9899
createRule?: boolean;
@@ -740,6 +741,11 @@ const argv = yargs(hideBin(process.argv))
740741
type: "string",
741742
description: "Comma-separated custom domains to attach as Appwrite Proxy Rules during --deployFunctions. For single-function deploys, overrides the config's domains[]. For multi-function deploys, each function's declared domains[] is reconciled.",
742743
})
744+
.option("prebuilt", {
745+
alias: ["function-prebuilt", "functionPrebuilt"],
746+
type: "boolean",
747+
description: "Single-function --deployFunctions override: run the function's `commands` (build step) LOCALLY before tarring, ship the resulting node_modules/dist inside the tarball, and tell Appwrite to skip its build step (empty commands sent to createDeployment). Bypasses Appwrite's build container entirely so private GitHub deps don't need an installation/GitHub token on Appwrite. For multi-function deploys, set `prebuilt: true` per-function in .fnconfig.yaml / config.yaml instead.",
748+
})
743749
.option("pruneDomains", {
744750
alias: ["prune-domains"],
745751
type: "boolean",
@@ -1195,6 +1201,7 @@ async function main() {
11951201
deployDir: argv.functionDeployDir,
11961202
ignore: argv.functionIgnore,
11971203
domains: argv.functionDomains,
1204+
prebuilt: argv.prebuilt,
11981205
},
11991206
pruneDomains: argv.pruneDomains,
12001207
});

packages/appwrite-utils-cli/src/sites/deployments.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ import {
1818
import { MessageFormatter } from "appwrite-utils-helpers";
1919
import { resolveFunctionDirectory, validateFunctionDirectory } from "appwrite-utils-helpers";
2020

21+
const DEFAULT_SITE_IGNORED = [
22+
"node_modules",
23+
".git",
24+
".vscode",
25+
".DS_Store",
26+
"__pycache__",
27+
".venv",
28+
];
29+
30+
// Slim ignore for prebuilt site deploys: ship built artifacts.
31+
const PREBUILT_SITE_IGNORED = [".git", ".vscode", ".DS_Store"];
32+
2133
export const deploySite = async (
2234
client: Client,
2335
siteId: string,
@@ -26,14 +38,7 @@ export const deploySite = async (
2638
installCommand?: string,
2739
buildCommand?: string,
2840
outputDirectory?: string,
29-
ignored: string[] = [
30-
"node_modules",
31-
".git",
32-
".vscode",
33-
".DS_Store",
34-
"__pycache__",
35-
".venv",
36-
]
41+
ignored: string[] = DEFAULT_SITE_IGNORED
3742
) => {
3843
const sites = new Sites(client);
3944
MessageFormatter.processing("Preparing site deployment...", { prefix: "Deployment" });
@@ -196,9 +201,10 @@ export const deployLocalSite = async (
196201
throw new Error(`Site directory is invalid or missing required files: ${resolvedPath}`);
197202
}
198203

204+
const isWindows = platform() === "win32";
205+
199206
if (siteConfig.predeployCommands?.length) {
200207
MessageFormatter.processing("Executing predeploy commands...", { prefix: "Deployment" });
201-
const isWindows = platform() === "win32";
202208

203209
for (const command of siteConfig.predeployCommands) {
204210
try {
@@ -232,14 +238,47 @@ export const deployLocalSite = async (
232238
? join(resolvedPath, siteConfig.deployDir)
233239
: resolvedPath;
234240

241+
// Prebuilt mode: run installCommand then buildCommand locally in deployPath
242+
// so the build output (dist/, .output/, etc.) lands inside the tarball.
243+
// Appwrite is then told to skip its build step (empty install+build commands).
244+
const prebuilt = siteConfig.prebuilt === true;
245+
if (prebuilt) {
246+
const localSteps: Array<{ label: string; command?: string }> = [
247+
{ label: "install", command: siteConfig.installCommand },
248+
{ label: "build", command: siteConfig.buildCommand },
249+
];
250+
for (const step of localSteps) {
251+
if (!step.command || step.command.trim().length === 0) continue;
252+
MessageFormatter.processing(
253+
`[prebuilt] Running ${step.label} locally: ${step.command}`,
254+
{ prefix: "Deployment" }
255+
);
256+
try {
257+
execSync(step.command, {
258+
cwd: deployPath,
259+
stdio: "inherit",
260+
shell: isWindows ? "cmd.exe" : "/bin/sh",
261+
windowsHide: true,
262+
});
263+
} catch (error) {
264+
MessageFormatter.error(
265+
`[prebuilt] Local ${step.label} failed: ${step.command}`,
266+
error instanceof Error ? error : undefined,
267+
{ prefix: "Deployment" }
268+
);
269+
throw error;
270+
}
271+
}
272+
}
273+
235274
return deploySite(
236275
client,
237276
siteConfig.$id,
238277
deployPath,
239278
true,
240-
siteConfig.installCommand,
241-
siteConfig.buildCommand,
279+
prebuilt ? "" : siteConfig.installCommand,
280+
prebuilt ? "" : siteConfig.buildCommand,
242281
siteConfig.outputDirectory,
243-
siteConfig.ignore
282+
siteConfig.ignore ?? (prebuilt ? PREBUILT_SITE_IGNORED : DEFAULT_SITE_IGNORED)
244283
);
245284
};

packages/appwrite-utils-helpers/src/functions/functionManager.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ export interface FunctionDeploymentOptions {
7272
commands?: string;
7373
ignored?: string[];
7474
verbose?: boolean;
75+
/**
76+
* When true (or when `functionConfig.prebuilt === true`), run the function's
77+
* `commands` LOCALLY before tarring, ship the resulting build artifacts
78+
* inside the tarball (slim ignore list), and tell Appwrite to skip its
79+
* build step (empty commands sent to createDeployment). Bypasses Appwrite's
80+
* build container entirely so private GitHub deps don't need an
81+
* installation/GitHub token on Appwrite.
82+
*/
83+
prebuilt?: boolean;
7584
/**
7685
* @deprecated The function config is now always pushed when the function
7786
* exists; this flag is retained for back-compat and is a no-op.
@@ -315,18 +324,31 @@ export class FunctionManager {
315324
functionPath: string,
316325
options: FunctionDeploymentOptions = {}
317326
): Promise<Models.Deployment> {
327+
const prebuilt =
328+
options.prebuilt === true || functionConfig.prebuilt === true;
318329
const {
319330
activate = true,
320331
entrypoint = functionConfig.entrypoint || "main.js",
321-
commands = functionConfig.commands || "npm install",
322-
ignored = ["node_modules", ".git", ".vscode", ".DS_Store", "__pycache__", ".venv"],
323332
verbose = false,
324333
} = options;
334+
// commands/ignored have prebuilt-aware defaults: empty commands so Appwrite
335+
// skips its build, slim ignore so built artifacts ship.
336+
const commands =
337+
options.commands ??
338+
(prebuilt ? "" : functionConfig.commands || "npm install");
339+
const ignored =
340+
options.ignored ??
341+
(prebuilt
342+
? [".git", ".vscode", ".DS_Store"]
343+
: ["node_modules", ".git", ".vscode", ".DS_Store", "__pycache__", ".venv"]);
325344

326345
if (verbose) {
327346
MessageFormatter.processing(`Uploading function: ${functionConfig.name}`, { prefix: "Functions" });
328347
MessageFormatter.debug(`Path: ${functionPath}`, undefined, { prefix: "Functions" });
329348
MessageFormatter.debug(`Entrypoint: ${entrypoint}`, undefined, { prefix: "Functions" });
349+
if (prebuilt) {
350+
MessageFormatter.debug(`Mode: prebuilt (local build; Appwrite skips build step)`, undefined, { prefix: "Functions" });
351+
}
330352
}
331353

332354
if (!await this.isValidFunctionDirectory(functionPath)) {
@@ -353,6 +375,18 @@ export class FunctionManager {
353375
await this.executePredeployCommands(functionConfig.predeployCommands, functionPath, { verbose });
354376
}
355377

378+
// Prebuilt: run the build LOCALLY in functionPath so artifacts land
379+
// alongside source before tarring.
380+
if (prebuilt && functionConfig.commands && functionConfig.commands.trim().length > 0) {
381+
if (verbose) {
382+
MessageFormatter.processing(
383+
`[prebuilt] Running build locally: ${functionConfig.commands}`,
384+
{ prefix: "Functions" }
385+
);
386+
}
387+
await this.executePredeployCommands([functionConfig.commands], functionPath, { verbose });
388+
}
389+
356390
return await this.createDeployment(
357391
functionConfig.$id,
358392
functionPath,

packages/appwrite-utils-helpers/src/sites/siteManager.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface SiteDeploymentOptions {
2424
outputDirectory?: string;
2525
ignored?: string[];
2626
verbose?: boolean;
27+
/**
28+
* When true (or `siteConfig.prebuilt === true`), run installCommand then
29+
* buildCommand LOCALLY in the site directory before tarring, ship the
30+
* build output (dist/, .output/, node_modules) inside the tarball, and
31+
* tell Appwrite to skip its build step (empty install+build commands).
32+
*/
33+
prebuilt?: boolean;
2734
/**
2835
* @deprecated The site config is now always pushed when the site exists;
2936
* this flag is retained for back-compat and is a no-op.
@@ -244,15 +251,26 @@ export class SiteManager {
244251
sitePath: string,
245252
options: SiteDeploymentOptions = {}
246253
): Promise<Models.Deployment> {
254+
const prebuilt =
255+
options.prebuilt === true || siteConfig.prebuilt === true;
247256
const {
248257
activate = true,
249-
installCommand = siteConfig.installCommand,
250-
buildCommand = siteConfig.buildCommand,
251258
outputDirectory = siteConfig.outputDirectory,
252-
ignored = ["node_modules", ".git", ".vscode", ".DS_Store", "__pycache__", ".venv"],
253259
verbose = false,
254260
pollOptions,
255261
} = options;
262+
// installCommand/buildCommand/ignored have prebuilt-aware defaults:
263+
// empty install+build so Appwrite skips its build; slim ignore so built
264+
// artifacts ship.
265+
const installCommand =
266+
options.installCommand ?? (prebuilt ? "" : siteConfig.installCommand);
267+
const buildCommand =
268+
options.buildCommand ?? (prebuilt ? "" : siteConfig.buildCommand);
269+
const ignored =
270+
options.ignored ??
271+
(prebuilt
272+
? [".git", ".vscode", ".DS_Store"]
273+
: ["node_modules", ".git", ".vscode", ".DS_Store", "__pycache__", ".venv"]);
256274

257275
return await siteLimit(async () => {
258276
if (verbose) {
@@ -290,6 +308,25 @@ export class SiteManager {
290308
await this.executePredeployCommands(siteConfig.predeployCommands, sitePath, { verbose });
291309
}
292310

311+
// Prebuilt: run installCommand then buildCommand LOCALLY in sitePath so
312+
// the build output lands inside the tarball.
313+
if (prebuilt) {
314+
const localSteps: Array<{ label: string; command?: string }> = [
315+
{ label: "install", command: siteConfig.installCommand },
316+
{ label: "build", command: siteConfig.buildCommand },
317+
];
318+
for (const step of localSteps) {
319+
if (!step.command || step.command.trim().length === 0) continue;
320+
if (verbose) {
321+
MessageFormatter.processing(
322+
`[prebuilt] Running ${step.label} locally: ${step.command}`,
323+
{ prefix: "Sites" }
324+
);
325+
}
326+
await this.executePredeployCommands([step.command], sitePath, { verbose });
327+
}
328+
}
329+
293330
// Deploy the site
294331
const deployment = await this.createDeployment(siteConfig.$id, sitePath, {
295332
activate,

0 commit comments

Comments
 (0)