@fold-run/github-action 1.0.0

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/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@fold-run/github-action",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Action for deploying functions to Fold",
5
+ "main": "dist/index.js",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "scripts": {
10
+ "build": "esbuild src/main.ts --bundle --platform=node --target=node20 --outfile=dist/index.js",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@actions/core": "^1.11.1",
17
+ "@actions/exec": "^1.1.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "esbuild": "^0.25.0",
22
+ "typescript": "^5.8.3",
23
+ "vitest": "^3.2.4"
24
+ }
25
+ }
@@ -0,0 +1,186 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // ── Hoisted mocks (vi.mock factories run before module imports) ────────────
4
+
5
+ const mockCore = vi.hoisted(() => ({
6
+ getInput: vi.fn(),
7
+ setOutput: vi.fn(),
8
+ setFailed: vi.fn(),
9
+ info: vi.fn(),
10
+ summary: {
11
+ addHeading: vi.fn().mockReturnThis(),
12
+ addTable: vi.fn().mockReturnThis(),
13
+ write: vi.fn().mockResolvedValue(undefined),
14
+ },
15
+ }));
16
+
17
+ const mockExec = vi.hoisted(() => ({
18
+ exec: vi.fn().mockResolvedValue(0),
19
+ }));
20
+
21
+ const mockFs = vi.hoisted(() => ({
22
+ readFileSync: vi.fn(),
23
+ unlinkSync: vi.fn(),
24
+ }));
25
+
26
+ vi.mock('@actions/core', () => mockCore);
27
+ vi.mock('@actions/exec', () => mockExec);
28
+ vi.mock('node:fs', () => mockFs);
29
+
30
+ // ── Import after mocks ─────────────────────────────────────────────────────
31
+
32
+ import { bundleFile, run } from './main';
33
+
34
+ // ── Helpers ────────────────────────────────────────────────────────────────
35
+
36
+ function setupInputs(overrides: Record<string, string> = {}) {
37
+ const defaults: Record<string, string> = {
38
+ 'fold-token': 'fld_test',
39
+ 'tenant-id': 'my-tenant',
40
+ 'function-name': 'my-fn',
41
+ file: 'worker.js',
42
+ 'working-directory': '.',
43
+ 'api-url': 'https://api.fold.run',
44
+ };
45
+ mockCore.getInput.mockImplementation((name: string) => overrides[name] ?? defaults[name] ?? '');
46
+ }
47
+
48
+ function makeDeployResponse(overrides: Partial<{ id: string; name: string; version: number; status: string }> = {}) {
49
+ return { id: 'fn-abc', name: 'my-fn', version: 1, status: 'active', ...overrides };
50
+ }
51
+
52
+ // ── bundleFile ─────────────────────────────────────────────────────────────
53
+
54
+ describe('bundleFile', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ });
58
+
59
+ it('reads JS files directly without bundling', async () => {
60
+ mockFs.readFileSync.mockReturnValue('export default {}');
61
+ const result = await bundleFile('worker.js', '/tmp/work');
62
+ expect(mockFs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('worker.js'), 'utf-8');
63
+ expect(mockExec.exec).not.toHaveBeenCalled();
64
+ expect(result).toBe('export default {}');
65
+ });
66
+
67
+ it('bundles TypeScript files using esbuild via exec', async () => {
68
+ mockExec.exec.mockResolvedValue(0);
69
+ mockFs.readFileSync.mockReturnValue('bundled code');
70
+ await bundleFile('worker.ts', '/tmp/work');
71
+ expect(mockExec.exec).toHaveBeenCalledWith(
72
+ 'npx',
73
+ expect.arrayContaining(['--yes', 'esbuild@0.25', expect.stringContaining('worker.ts')]),
74
+ expect.objectContaining({ cwd: '/tmp/work' }),
75
+ );
76
+ });
77
+
78
+ it('bundles .tsx files the same as .ts', async () => {
79
+ mockExec.exec.mockResolvedValue(0);
80
+ mockFs.readFileSync.mockReturnValue('bundled');
81
+ await bundleFile('component.tsx', '/tmp');
82
+ expect(mockExec.exec).toHaveBeenCalled();
83
+ });
84
+
85
+ it('throws when esbuild exits non-zero', async () => {
86
+ mockExec.exec.mockResolvedValue(1);
87
+ await expect(bundleFile('worker.ts', '/tmp')).rejects.toThrow('esbuild exited with code 1');
88
+ });
89
+
90
+ it('throws when exec itself throws (esbuild not available)', async () => {
91
+ mockExec.exec.mockRejectedValue(new Error('command not found'));
92
+ await expect(bundleFile('worker.ts', '/tmp')).rejects.toThrow('esbuild not available');
93
+ });
94
+ });
95
+
96
+ // ── run ────────────────────────────────────────────────────────────────────
97
+
98
+ describe('run', () => {
99
+ beforeEach(() => {
100
+ vi.clearAllMocks();
101
+ vi.stubGlobal('fetch', vi.fn());
102
+ setupInputs();
103
+ mockFs.readFileSync.mockReturnValue('export default {}');
104
+ });
105
+
106
+ afterEach(() => {
107
+ vi.unstubAllGlobals();
108
+ });
109
+
110
+ it('deploys successfully and sets outputs', async () => {
111
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(makeDeployResponse()), { status: 200 }));
112
+
113
+ await run();
114
+
115
+ expect(fetch).toHaveBeenCalledWith(
116
+ 'https://api.fold.run/deploy/my-tenant',
117
+ expect.objectContaining({
118
+ method: 'POST',
119
+ headers: expect.objectContaining({ Authorization: 'Bearer fld_test' }),
120
+ }),
121
+ );
122
+ expect(mockCore.setOutput).toHaveBeenCalledWith('function-id', 'fn-abc');
123
+ expect(mockCore.setOutput).toHaveBeenCalledWith('version', '1');
124
+ expect(mockCore.setOutput).toHaveBeenCalledWith('url', 'https://my-tenant.fold.run/my-fn');
125
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('constructs the correct tenant subdomain URL', async () => {
129
+ setupInputs({ 'tenant-id': 'acme', 'function-name': 'weather' });
130
+ vi.mocked(fetch).mockResolvedValue(
131
+ new Response(JSON.stringify(makeDeployResponse({ name: 'weather' })), { status: 200 }),
132
+ );
133
+
134
+ await run();
135
+
136
+ expect(mockCore.setOutput).toHaveBeenCalledWith('url', 'https://acme.fold.run/weather');
137
+ });
138
+
139
+ it('sends correct deploy body without runtime field', async () => {
140
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(makeDeployResponse()), { status: 200 }));
141
+
142
+ await run();
143
+
144
+ const [, init] = vi.mocked(fetch).mock.calls[0];
145
+ const body = JSON.parse((init as RequestInit).body as string);
146
+ expect(body).toEqual({ name: 'my-fn', code: 'export default {}' });
147
+ expect(body.runtime).toBeUndefined();
148
+ });
149
+
150
+ it('calls setFailed when API returns non-ok', async () => {
151
+ vi.mocked(fetch).mockResolvedValue(
152
+ new Response(JSON.stringify({ error: 'Function limit reached' }), { status: 422 }),
153
+ );
154
+
155
+ await run();
156
+
157
+ expect(mockCore.setFailed).toHaveBeenCalledWith('Function limit reached');
158
+ expect(mockCore.setOutput).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('calls setFailed on network error', async () => {
162
+ vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
163
+
164
+ await run();
165
+
166
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('Network error'));
167
+ });
168
+
169
+ it('calls setFailed when bundling fails', async () => {
170
+ setupInputs({ file: 'worker.ts' });
171
+ mockExec.exec.mockResolvedValue(1);
172
+
173
+ await run();
174
+
175
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('Failed to bundle'));
176
+ });
177
+
178
+ it('uses custom api-url', async () => {
179
+ setupInputs({ 'api-url': 'http://localhost:8787' });
180
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(makeDeployResponse()), { status: 200 }));
181
+
182
+ await run();
183
+
184
+ expect(fetch).toHaveBeenCalledWith('http://localhost:8787/deploy/my-tenant', expect.anything());
185
+ });
186
+ });
package/src/main.ts ADDED
@@ -0,0 +1,125 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import * as core from '@actions/core';
5
+ import * as exec from '@actions/exec';
6
+
7
+ interface DeployResponse {
8
+ id: string;
9
+ name: string;
10
+ version: number;
11
+ status: string;
12
+ }
13
+
14
+ export async function bundleFile(filePath: string, workingDir: string): Promise<string> {
15
+ const absPath = path.resolve(workingDir, filePath);
16
+ const ext = path.extname(absPath);
17
+ const isTypeScript = ext === '.ts' || ext === '.tsx';
18
+
19
+ if (!isTypeScript) {
20
+ // Plain JS — read directly
21
+ return fs.readFileSync(absPath, 'utf-8');
22
+ }
23
+
24
+ // Bundle TypeScript with esbuild
25
+ const outFile = path.join(os.tmpdir(), `fold-action-bundle-${Date.now()}.js`);
26
+ const args = [absPath, '--bundle', '--platform=browser', '--format=esm', '--target=es2022', `--outfile=${outFile}`];
27
+
28
+ core.info(`Bundling ${filePath} with esbuild...`);
29
+ let exitCode = 0;
30
+ try {
31
+ exitCode = await exec.exec('npx', ['--yes', 'esbuild@0.25', ...args], {
32
+ cwd: workingDir,
33
+ ignoreReturnCode: true,
34
+ });
35
+ } catch (err) {
36
+ throw new Error(`esbuild not available: ${err}`);
37
+ }
38
+
39
+ if (exitCode !== 0) {
40
+ throw new Error(`esbuild exited with code ${exitCode}`);
41
+ }
42
+
43
+ const bundle = fs.readFileSync(outFile, 'utf-8');
44
+ fs.unlinkSync(outFile);
45
+ return bundle;
46
+ }
47
+
48
+ export async function run(): Promise<void> {
49
+ const token = core.getInput('fold-token', { required: true });
50
+ const tenantId = core.getInput('tenant-id', { required: true });
51
+ const functionName = core.getInput('function-name', { required: true });
52
+ const file = core.getInput('file', { required: true });
53
+ const workingDir = core.getInput('working-directory') || '.';
54
+ const apiUrl = core.getInput('api-url') || 'https://api.fold.run';
55
+
56
+ const absWorkingDir = path.resolve(process.cwd(), workingDir);
57
+
58
+ // Bundle / read the function source
59
+ let code: string;
60
+ try {
61
+ code = await bundleFile(file, absWorkingDir);
62
+ } catch (err) {
63
+ core.setFailed(`Failed to bundle function: ${err}`);
64
+ return;
65
+ }
66
+
67
+ // Deploy via Fold API
68
+ core.info(`Deploying function "${functionName}" to ${apiUrl}...`);
69
+
70
+ let res: Response;
71
+ try {
72
+ res = await fetch(`${apiUrl}/deploy/${tenantId}`, {
73
+ method: 'POST',
74
+ headers: {
75
+ Authorization: `Bearer ${token}`,
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ body: JSON.stringify({ name: functionName, code }),
79
+ });
80
+ } catch (err) {
81
+ core.setFailed(`Network error: ${err}`);
82
+ return;
83
+ }
84
+
85
+ if (!res.ok) {
86
+ let message = `Deploy failed with HTTP ${res.status}`;
87
+ try {
88
+ const body = (await res.json()) as { error?: string; message?: string };
89
+ message = body.error ?? body.message ?? message;
90
+ } catch {
91
+ // ignore parse error
92
+ }
93
+ core.setFailed(message);
94
+ return;
95
+ }
96
+
97
+ const fn = (await res.json()) as DeployResponse;
98
+
99
+ const functionUrl = `https://${tenantId}.fold.run/${functionName}`;
100
+ core.setOutput('function-id', fn.id);
101
+ core.setOutput('version', String(fn.version ?? '1'));
102
+ core.setOutput('url', functionUrl);
103
+
104
+ // Job summary
105
+ await core.summary
106
+ .addHeading('Fold Deploy')
107
+ .addTable([
108
+ [
109
+ { data: 'Field', header: true },
110
+ { data: 'Value', header: true },
111
+ ],
112
+ ['Function', fn.name],
113
+ ['ID', fn.id],
114
+ ['Version', String(fn.version ?? '1')],
115
+ ['URL', functionUrl],
116
+ ['Status', fn.status ?? 'active'],
117
+ ])
118
+ .write();
119
+
120
+ core.info(`Deployed: ${functionUrl}`);
121
+ }
122
+
123
+ run().catch((err) => {
124
+ core.setFailed(String(err));
125
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "node16",
5
+ "moduleResolution": "node16",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "rootDir": "src",
10
+ "outDir": "dist"
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ },
7
+ });