@mintlify/cli 4.0.1725 → 4.0.1726

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,202 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { deployFromGithubActions } from '../src/deploy-github-actions.js';
9
+
10
+ const real = vi.hoisted(() => ({ spawn: undefined as unknown as typeof spawn }));
11
+ vi.mock('node:child_process', async (importOriginal) => {
12
+ const original = await importOriginal<typeof import('node:child_process')>();
13
+ real.spawn = original.spawn;
14
+ return { ...original, spawn: vi.fn() };
15
+ });
16
+
17
+ const apiUrl = 'https://leaves.mintlify.com';
18
+ const env = {
19
+ PATH: process.env.PATH,
20
+ HOME: process.env.HOME,
21
+ GITHUB_ACTIONS: 'true',
22
+ GITHUB_REPOSITORY: 'acme/monorepo',
23
+ ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.example.com/request?api-version=2.0',
24
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'request-token',
25
+ };
26
+ const write = {
27
+ remote: 'https://git.example.com/repo.git',
28
+ branch: 'main',
29
+ token: 'write-token',
30
+ tokenExpiresAt: '2026-12-01T00:00:00.000Z',
31
+ };
32
+ let repo: string;
33
+
34
+ const gitIn = (cwd: string, ...args: string[]) =>
35
+ execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
36
+ const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
37
+ const pushCall = () => vi.mocked(spawn).mock.calls.find(([, args]) => args[0] === 'push');
38
+ const stubFetch = (...bodies: Response[]) => {
39
+ const request = vi.fn();
40
+ for (const body of bodies) request.mockResolvedValueOnce(body);
41
+ vi.stubGlobal('fetch', request);
42
+ return request;
43
+ };
44
+
45
+ function pushExitsWith(code: number) {
46
+ vi.mocked(spawn).mockImplementation(((command: string, args: string[], options: object) => {
47
+ if (args[0] !== 'push') return real.spawn(command, args, options);
48
+ const child = new EventEmitter();
49
+ setImmediate(() => child.emit('close', code));
50
+ return child;
51
+ }) as typeof spawn);
52
+ }
53
+
54
+ beforeEach(() => {
55
+ vi.resetAllMocks();
56
+ pushExitsWith(0);
57
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-gha-'));
58
+ fs.mkdirSync(path.join(repo, 'docs'));
59
+ fs.writeFileSync(path.join(repo, 'docs', 'docs.json'), '{}\n');
60
+ fs.writeFileSync(path.join(repo, 'app.ts'), 'export {};\n');
61
+ gitIn(repo, 'init', '-q', '-b', 'main');
62
+ gitIn(
63
+ repo,
64
+ '-c',
65
+ 'user.name=Ada',
66
+ '-c',
67
+ 'user.email=ada@acme.dev',
68
+ 'commit',
69
+ '-q',
70
+ '--allow-empty',
71
+ '-m',
72
+ 'root'
73
+ );
74
+ gitIn(repo, 'add', '-A');
75
+ gitIn(
76
+ repo,
77
+ '-c',
78
+ 'user.name=Ada',
79
+ '-c',
80
+ 'user.email=ada@acme.dev',
81
+ 'commit',
82
+ '-q',
83
+ '-m',
84
+ 'update docs'
85
+ );
86
+ });
87
+ afterEach(() => {
88
+ vi.unstubAllGlobals();
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ });
91
+
92
+ describe('mint deploy in GitHub Actions', () => {
93
+ it('pushes only the docs directory as the site root', async () => {
94
+ const request = stubFetch(response({ value: 'oidc-jwt' }), response(write));
95
+ const directory = path.join(repo, 'docs');
96
+
97
+ await expect(
98
+ deployFromGithubActions({ apiUrl, directory, subdomain: 'my-docs' }, undefined, env)
99
+ ).resolves.toBe('https://my-docs.mintlify.site/');
100
+
101
+ expect(request.mock.calls[0]?.[0]).toBe(
102
+ `${env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${encodeURIComponent(apiUrl)}`
103
+ );
104
+ expect(request.mock.calls[1]?.[0]).toBe(`${apiUrl}/api/agent/deployments/github-actions/token`);
105
+ expect(request.mock.calls[1]?.[1].headers.Authorization).toBe('Bearer oidc-jwt');
106
+
107
+ const [, args, options] = pushCall() ?? [];
108
+ const commit = String(args?.[3]).replace(':refs/heads/main', '');
109
+ expect(args).toEqual(['push', '--force', write.remote, `${commit}:refs/heads/main`]);
110
+ expect(args?.join(' ')).not.toContain('write-token');
111
+ expect(gitIn(repo, 'rev-parse', `${commit}^{tree}`)).toBe(
112
+ gitIn(repo, 'rev-parse', 'HEAD:docs')
113
+ );
114
+ expect(gitIn(repo, 'log', '-1', '--format=%an <%ae>%n%s', commit)).toBe(
115
+ 'Ada <ada@acme.dev>\nupdate docs'
116
+ );
117
+ expect(gitIn(repo, 'log', '-1', '--format=%b', commit)).toBe(
118
+ `Deployed from acme/monorepo@${gitIn(repo, 'rev-parse', 'HEAD')}`
119
+ );
120
+ expect(options?.env).toMatchObject({
121
+ GIT_CONFIG_COUNT: '1',
122
+ GIT_CONFIG_KEY_0: `http.${write.remote}.extraHeader`,
123
+ GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from('x-access-token:write-token').toString('base64')}`,
124
+ });
125
+ });
126
+
127
+ it('pushes the whole tree from the repository root', async () => {
128
+ stubFetch(response({ value: 'oidc-jwt' }), response(write));
129
+ await deployFromGithubActions(
130
+ { apiUrl, directory: repo, subdomain: 'my-docs' },
131
+ undefined,
132
+ env
133
+ );
134
+ const commit = String(pushCall()?.[1]?.[3]).replace(':refs/heads/main', '');
135
+ expect(gitIn(repo, 'rev-parse', `${commit}^{tree}`)).toBe(
136
+ gitIn(repo, 'rev-parse', 'HEAD^{tree}')
137
+ );
138
+ });
139
+
140
+ it('appends to existing git config environment entries', async () => {
141
+ stubFetch(response({ value: 'oidc-jwt' }), response(write));
142
+ await deployFromGithubActions({ apiUrl, directory: repo, subdomain: 'my-docs' }, undefined, {
143
+ ...env,
144
+ GIT_CONFIG_COUNT: '2',
145
+ GIT_CONFIG_KEY_0: 'core.quotepath',
146
+ GIT_CONFIG_VALUE_0: 'false',
147
+ GIT_CONFIG_KEY_1: 'color.ui',
148
+ GIT_CONFIG_VALUE_1: 'false',
149
+ });
150
+ expect(pushCall()?.[2]?.env).toMatchObject({
151
+ GIT_CONFIG_COUNT: '3',
152
+ GIT_CONFIG_KEY_2: `http.${write.remote}.extraHeader`,
153
+ });
154
+ });
155
+
156
+ it('asks for id-token: write when OIDC is unavailable', async () => {
157
+ const request = stubFetch();
158
+ await expect(
159
+ deployFromGithubActions({ apiUrl, directory: repo, subdomain: 'my-docs' }, undefined, {
160
+ ...env,
161
+ ACTIONS_ID_TOKEN_REQUEST_URL: undefined,
162
+ })
163
+ ).rejects.toThrow('permissions: id-token: write');
164
+ expect(request).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('requires --subdomain', async () => {
168
+ await expect(
169
+ deployFromGithubActions({ apiUrl, directory: repo }, undefined, env)
170
+ ).rejects.toThrow('--subdomain');
171
+ });
172
+
173
+ it('fails clearly when the directory is not in the checkout', async () => {
174
+ const request = stubFetch();
175
+ await expect(
176
+ deployFromGithubActions(
177
+ { apiUrl, directory: os.tmpdir(), subdomain: 'my-docs' },
178
+ undefined,
179
+ env
180
+ )
181
+ ).rejects.toThrow('Could not read');
182
+ expect(request).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('explains how to trust the repository on 403', async () => {
186
+ stubFetch(response({ value: 'oidc-jwt' }), response({ error: 'forbidden' }, 403));
187
+ await expect(
188
+ deployFromGithubActions({ apiUrl, directory: repo, subdomain: 'my-docs' }, undefined, env)
189
+ ).rejects.toThrow(
190
+ "This repository/branch isn't trusted to deploy my-docs. Run `mint deploy --github` locally"
191
+ );
192
+ expect(pushCall()).toBeUndefined();
193
+ });
194
+
195
+ it('fails when git push fails', async () => {
196
+ pushExitsWith(1);
197
+ stubFetch(response({ value: 'oidc-jwt' }), response(write));
198
+ await expect(
199
+ deployFromGithubActions({ apiUrl, directory: repo, subdomain: 'my-docs' }, undefined, env)
200
+ ).rejects.toThrow('git push to the site repository failed');
201
+ });
202
+ });
@@ -0,0 +1,239 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import {
8
+ GITHUB_WORKFLOW_PATH,
9
+ githubWorkflow,
10
+ parseGithubRemote,
11
+ setupGithubDeployment,
12
+ writeGithubWorkflow,
13
+ } from '../src/deploy-github-setup.js';
14
+ import { prepareAgentDeployment } from '../src/deploy.js';
15
+
16
+ vi.mock('node:child_process', async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import('node:child_process')>()),
18
+ execFile: vi.fn(),
19
+ }));
20
+ vi.mock('../src/deploy.js', async (importOriginal) => ({
21
+ ...(await importOriginal<typeof import('../src/deploy.js')>()),
22
+ prepareAgentDeployment: vi.fn(),
23
+ }));
24
+
25
+ const apiUrl = 'https://leaves.mintlify.com';
26
+ const site = {
27
+ status: 'ready' as const,
28
+ created: false,
29
+ deploymentId: 'id',
30
+ subdomain: 'my-docs',
31
+ remote: 'https://git.example.com/repo.git',
32
+ branch: 'main' as const,
33
+ expiresAt: '2026-12-01T00:00:00.000Z',
34
+ };
35
+ const credentials = {
36
+ registrationId: 'reg',
37
+ issuer: 'https://auth.example.com',
38
+ assertion: 'assertion',
39
+ claimToken: 'claim',
40
+ accessToken: 'access',
41
+ };
42
+ let root: string;
43
+
44
+ function stubCommands(outputs: Record<string, string | NodeJS.ErrnoException>) {
45
+ vi.mocked(execFile).mockImplementation(((
46
+ command: string,
47
+ args: string[],
48
+ _options: unknown,
49
+ callback: (error: Error | null, stdout: string) => void
50
+ ) => {
51
+ const output = outputs[`${command} ${args[0]}`];
52
+ if (output instanceof Error) callback(output, '');
53
+ else if (output === undefined) callback(new Error('unexpected'), '');
54
+ else callback(null, `${output}\n`);
55
+ }) as unknown as typeof execFile);
56
+ }
57
+
58
+ beforeEach(async () => {
59
+ vi.resetAllMocks();
60
+ root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'mint-github-')));
61
+ await fs.writeFile(path.join(root, 'docs.json'), '{}');
62
+ stubCommands({
63
+ 'git rev-parse': root,
64
+ 'git remote': 'git@github.com:acme/docs.git',
65
+ 'gh api': JSON.stringify({ id: 123, full_name: 'acme/docs', default_branch: 'trunk' }),
66
+ });
67
+ vi.mocked(prepareAgentDeployment).mockResolvedValue({
68
+ status: 'ready',
69
+ site,
70
+ write: { remote: site.remote, branch: 'main', token: 'write', tokenExpiresAt: site.expiresAt },
71
+ created: false,
72
+ credentials,
73
+ });
74
+ });
75
+ afterEach(async () => {
76
+ vi.unstubAllGlobals();
77
+ await fs.rm(root, { recursive: true, force: true });
78
+ });
79
+
80
+ describe('parseGithubRemote', () => {
81
+ it.each([
82
+ 'https://github.com/acme/docs',
83
+ 'https://github.com/acme/docs.git',
84
+ 'https://user@github.com/acme/docs.git',
85
+ 'git@github.com:acme/docs.git',
86
+ 'ssh://git@github.com/acme/docs.git',
87
+ 'ssh://git@github.com:22/acme/docs',
88
+ ])('parses %s', (remote) => {
89
+ expect(parseGithubRemote(remote)).toEqual({ owner: 'acme', repo: 'docs' });
90
+ });
91
+ it('keeps dots in repository names', () => {
92
+ expect(parseGithubRemote('git@github.com:acme/docs.site.git')).toEqual({
93
+ owner: 'acme',
94
+ repo: 'docs.site',
95
+ });
96
+ });
97
+ it.each(['https://gitlab.com/acme/docs.git', 'git@example.com:acme/docs.git', 'docs'])(
98
+ 'rejects %s',
99
+ (remote) => {
100
+ expect(parseGithubRemote(remote)).toBeNull();
101
+ }
102
+ );
103
+ });
104
+
105
+ describe('GitHub workflow', () => {
106
+ it('deploys the subdomain on pushes to the branch with OIDC', () => {
107
+ expect(githubWorkflow('release,stable', 'my-docs')).toBe(`name: Deploy to Mintlify
108
+ on:
109
+ push:
110
+ branches: ["release,stable"]
111
+ permissions:
112
+ id-token: write
113
+ contents: read
114
+ concurrency:
115
+ group: mintlify-deploy
116
+ cancel-in-progress: true
117
+ jobs:
118
+ deploy:
119
+ runs-on: ubuntu-latest
120
+ steps:
121
+ - uses: actions/checkout@v4
122
+ - run: npx mint@latest deploy --subdomain my-docs
123
+ `);
124
+ });
125
+ it('deploys only the docs folder of a monorepo', () => {
126
+ const workflow = githubWorkflow('main', 'my-docs', 'apps/docs');
127
+ expect(workflow).toContain(' paths: ["apps/docs/**", ".github/workflows/mintlify.yml"]\n');
128
+ expect(workflow).toContain(
129
+ ' - run: npx mint@latest deploy --subdomain my-docs --directory apps/docs\n'
130
+ );
131
+ expect(githubWorkflow('main', 'my-docs', 'my docs')).toContain('--directory "my docs"');
132
+ });
133
+ it('creates the workflow and accepts identical contents', async () => {
134
+ await expect(writeGithubWorkflow(root, 'a')).resolves.toBe('created');
135
+ await expect(writeGithubWorkflow(root, 'a')).resolves.toBe('unchanged');
136
+ expect(await fs.readFile(path.join(root, GITHUB_WORKFLOW_PATH), 'utf8')).toBe('a');
137
+ });
138
+ it('does not overwrite a different workflow without --force', async () => {
139
+ await writeGithubWorkflow(root, 'a');
140
+ await expect(writeGithubWorkflow(root, 'b')).rejects.toThrow('--force');
141
+ expect(await fs.readFile(path.join(root, GITHUB_WORKFLOW_PATH), 'utf8')).toBe('a');
142
+ await expect(writeGithubWorkflow(root, 'b', true)).resolves.toBe('updated');
143
+ expect(await fs.readFile(path.join(root, GITHUB_WORKFLOW_PATH), 'utf8')).toBe('b');
144
+ });
145
+ });
146
+
147
+ describe('mint deploy --github', () => {
148
+ const options = { apiUrl, directory: '', subdomain: 'my-docs' };
149
+
150
+ it('trusts the repository and writes the workflow', async () => {
151
+ const request = vi
152
+ .fn()
153
+ .mockResolvedValue(
154
+ new Response(JSON.stringify({ subdomain: 'my-docs', trustedRepos: [] }), { status: 200 })
155
+ );
156
+ vi.stubGlobal('fetch', request);
157
+
158
+ const result = await setupGithubDeployment({ ...options, directory: root }, vi.fn());
159
+
160
+ expect(vi.mocked(execFile).mock.calls.find(([command]) => command === 'gh')?.[1]).toEqual([
161
+ 'api',
162
+ 'repos/acme/docs',
163
+ '--jq',
164
+ '{id: .id, full_name: .full_name, default_branch: .default_branch}',
165
+ ]);
166
+ expect(request.mock.calls[0]?.[0]).toBe(`${apiUrl}/api/agent/deployments/trusted-repos`);
167
+ expect(request.mock.calls[0]?.[1].headers.Authorization).toBe('Bearer access');
168
+ expect(JSON.parse(request.mock.calls[0]?.[1].body)).toEqual({
169
+ repositoryId: '123',
170
+ repository: 'acme/docs',
171
+ branch: 'trunk',
172
+ });
173
+ expect(await fs.readFile(path.join(root, GITHUB_WORKFLOW_PATH), 'utf8')).toBe(
174
+ githubWorkflow('trunk', 'my-docs')
175
+ );
176
+ expect(result).toMatchObject({
177
+ siteUrl: 'https://my-docs.mintlify.site/',
178
+ repository: 'acme/docs',
179
+ branch: 'trunk',
180
+ workflow: 'created',
181
+ });
182
+ });
183
+
184
+ it('writes the workflow at the repository root for a docs subdirectory', async () => {
185
+ vi.stubGlobal(
186
+ 'fetch',
187
+ vi
188
+ .fn()
189
+ .mockResolvedValue(
190
+ new Response(JSON.stringify({ subdomain: 'my-docs', trustedRepos: [] }), { status: 200 })
191
+ )
192
+ );
193
+ const docs = path.join(root, 'docs');
194
+ await fs.mkdir(docs);
195
+ await fs.writeFile(path.join(docs, 'docs.json'), '{}');
196
+ await setupGithubDeployment({ ...options, directory: docs }, vi.fn());
197
+ expect(await fs.readFile(path.join(root, GITHUB_WORKFLOW_PATH), 'utf8')).toBe(
198
+ githubWorkflow('trunk', 'my-docs', 'docs')
199
+ );
200
+ });
201
+
202
+ it('requires docs.json in the docs directory before provisioning', async () => {
203
+ const docs = path.join(root, 'empty');
204
+ await fs.mkdir(docs);
205
+ await expect(setupGithubDeployment({ ...options, directory: docs }, vi.fn())).rejects.toThrow(
206
+ 'No docs.json'
207
+ );
208
+ expect(prepareAgentDeployment).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('rejects non-GitHub origins before provisioning', async () => {
212
+ stubCommands({ 'git rev-parse': root, 'git remote': 'https://gitlab.com/acme/docs.git' });
213
+ await expect(setupGithubDeployment({ ...options, directory: root }, vi.fn())).rejects.toThrow(
214
+ 'not a github.com repository'
215
+ );
216
+ expect(prepareAgentDeployment).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it('explains when gh is missing', async () => {
220
+ stubCommands({
221
+ 'git rev-parse': root,
222
+ 'git remote': 'https://github.com/acme/docs',
223
+ 'gh api': Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }),
224
+ });
225
+ await expect(setupGithubDeployment({ ...options, directory: root }, vi.fn())).rejects.toThrow(
226
+ 'GitHub CLI (gh) is required'
227
+ );
228
+ });
229
+
230
+ it('does not support claimed sites yet', async () => {
231
+ vi.mocked(prepareAgentDeployment).mockResolvedValue({ status: 'claimed' });
232
+ const request = vi.fn();
233
+ vi.stubGlobal('fetch', request);
234
+ await expect(setupGithubDeployment({ ...options, directory: root }, vi.fn())).rejects.toThrow(
235
+ 'not supported yet'
236
+ );
237
+ expect(request).not.toHaveBeenCalled();
238
+ });
239
+ });
@@ -40,6 +40,10 @@ const writeTokenSchema = z.object({
40
40
  token: z.string().min(1),
41
41
  tokenExpiresAt: z.iso.datetime(),
42
42
  });
43
+ const trustedReposSchema = z.object({
44
+ subdomain: z.string().min(1),
45
+ trustedRepos: z.array(z.object({ repositoryId: z.string(), repository: z.string(), branch: z.string() })),
46
+ });
43
47
  export function provisionAgentSite(apiUrl, accessToken, settings) {
44
48
  return __awaiter(this, void 0, void 0, function* () {
45
49
  for (let attempt = 0; attempt < 4; attempt++) {
@@ -71,3 +75,21 @@ export function getAgentWriteToken(apiUrl, accessToken) {
71
75
  }), writeTokenSchema);
72
76
  });
73
77
  }
78
+ export function getGithubActionsWriteToken(apiUrl, oidcToken, subdomain) {
79
+ return __awaiter(this, void 0, void 0, function* () {
80
+ return agentResponse(yield agentRequest(`${apiUrl}/api/agent/deployments/github-actions/token`, {
81
+ method: 'POST',
82
+ headers: { Authorization: `Bearer ${oidcToken}`, 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ subdomain }),
84
+ }), writeTokenSchema);
85
+ });
86
+ }
87
+ export function trustGithubRepo(apiUrl, accessToken, repo) {
88
+ return __awaiter(this, void 0, void 0, function* () {
89
+ return agentResponse(yield agentRequest(`${apiUrl}/api/agent/deployments/trusted-repos`, {
90
+ method: 'POST',
91
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(repo),
93
+ }), trustedReposSchema);
94
+ });
95
+ }
package/bin/cli.js CHANGED
@@ -24,6 +24,8 @@ import { comingSoon } from './comingSoon.js';
24
24
  import { setTelemetryEnabled } from './config.js';
25
25
  import { getConfigValue, setConfigValue, clearConfigValue } from './config.js';
26
26
  import { API_URL } from './constants.js';
27
+ import { deployFromGithubActions, isGithubActions } from './deploy-github-actions.js';
28
+ import { formatGithubSetup, setupGithubDeployment } from './deploy-github-setup.js';
27
29
  import { DEPLOY_HELP, deploymentInstructions } from './deploy-instructions.js';
28
30
  import { formatDeployment, prepareAgentDeployment } from './deploy.js';
29
31
  import { formatHandler } from './format.js';
@@ -141,11 +143,43 @@ export const cli = ({ packageName = 'mint' }) => {
141
143
  type: 'string',
142
144
  description: 'User email for an optional claim link',
143
145
  })
146
+ .option('github', {
147
+ type: 'boolean',
148
+ default: false,
149
+ description: 'Trust this GitHub repository to deploy the site and write a GitHub Actions workflow',
150
+ })
151
+ .option('force', {
152
+ type: 'boolean',
153
+ default: false,
154
+ description: 'With --github, overwrite an existing .github/workflows/mintlify.yml',
155
+ })
156
+ .conflicts('github', 'email')
144
157
  .example('$0 deploy --subdomain my-docs', 'Provision a site without signing in')
145
158
  .example('$0 deploy --email you@example.com', 'Get repository access and a claim link for the current site')
146
159
  .example('$0 deploy --new --subdomain another-site', 'Create another anonymous site')
160
+ .example('$0 deploy --github --subdomain my-docs', 'Deploy the site from GitHub Actions on every push to the default branch')
161
+ .example('$0 deploy --subdomain my-docs', 'In GitHub Actions with id-token: write, push the current commit to a trusted site')
147
162
  .epilog(DEPLOY_HELP), (argv) => __awaiter(void 0, void 0, void 0, function* () {
148
163
  try {
164
+ if (isGithubActions()) {
165
+ const siteUrl = yield deployFromGithubActions({ apiUrl: API_URL, directory: argv.directory, subdomain: argv.subdomain }, (message) => process.stderr.write(`${message}\n`));
166
+ process.stdout.write(`Pushed to ${argv.subdomain}; the build starts now.\nSite: ${siteUrl}\n`);
167
+ yield terminate(0);
168
+ return;
169
+ }
170
+ if (argv.github) {
171
+ const setup = yield setupGithubDeployment({
172
+ apiUrl: API_URL,
173
+ directory: argv.directory,
174
+ subdomain: argv.subdomain,
175
+ orgName: argv.orgName,
176
+ newSite: argv.new,
177
+ force: argv.force,
178
+ }, (message) => process.stderr.write(`Warning: ${message}\n`), (message) => process.stderr.write(`${message}\n`));
179
+ process.stdout.write(`${formatGithubSetup(setup)}\n`);
180
+ yield terminate(0);
181
+ return;
182
+ }
149
183
  const result = yield prepareAgentDeployment({
150
184
  apiUrl: API_URL,
151
185
  directory: argv.directory,
@@ -0,0 +1,102 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { spawn } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import { z } from 'zod';
13
+ import { getGithubActionsWriteToken } from './agent-deployment.js';
14
+ import { AgentApiError } from './agent-http.js';
15
+ import { agentSiteUrl } from './deploy.js';
16
+ const oidcSchema = z.object({ value: z.string().min(1) });
17
+ export function isGithubActions(env = process.env) {
18
+ return env.GITHUB_ACTIONS === 'true';
19
+ }
20
+ function getGithubOidcToken(env, audience) {
21
+ return __awaiter(this, void 0, void 0, function* () {
22
+ const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
23
+ const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
24
+ if (!requestUrl || !requestToken) {
25
+ throw new Error('GitHub Actions did not provide an OIDC token. Add `permissions: id-token: write` to the workflow or job.');
26
+ }
27
+ let response;
28
+ try {
29
+ response = yield fetch(`${requestUrl}&audience=${encodeURIComponent(audience)}`, {
30
+ headers: { Authorization: `Bearer ${requestToken}` },
31
+ signal: AbortSignal.timeout(30000),
32
+ });
33
+ }
34
+ catch (_a) {
35
+ throw new Error('Could not request a GitHub Actions OIDC token. Retry the workflow.');
36
+ }
37
+ const parsed = oidcSchema.safeParse(yield response.json().catch(() => null));
38
+ if (!response.ok || !parsed.success) {
39
+ throw new Error(`Could not get a GitHub Actions OIDC token (HTTP ${response.status}). Check that the workflow has \`permissions: id-token: write\`.`);
40
+ }
41
+ return parsed.data.value;
42
+ });
43
+ }
44
+ function git(args, cwd, env) {
45
+ return new Promise((resolve, reject) => {
46
+ const child = spawn('git', args, { cwd, env, stdio: ['ignore', 'pipe', 'inherit'] });
47
+ let out = '';
48
+ child.stdout.on('data', (chunk) => (out += chunk.toString()));
49
+ child.on('error', reject);
50
+ child.on('close', (code) => code === 0 ? resolve(out.trim()) : reject(new Error(`git ${args[0]} failed`)));
51
+ });
52
+ }
53
+ function commitDirectory(directory, env) {
54
+ return __awaiter(this, void 0, void 0, function* () {
55
+ const prefix = yield git(['rev-parse', '--show-prefix'], directory, env);
56
+ const tree = yield git(['rev-parse', `HEAD:${prefix}`], directory, env);
57
+ const [name = '', email = '', date = '', sha = '', subject = ''] = (yield git(['log', '-1', '--format=%an%n%ae%n%aI%n%H%n%s'], directory, env)).split('\n');
58
+ const repository = env.GITHUB_REPOSITORY ? `${env.GITHUB_REPOSITORY}@` : '';
59
+ return git(['commit-tree', tree, '-m', subject, '-m', `Deployed from ${repository}${sha}`], directory, Object.assign(Object.assign({}, env), { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email }));
60
+ });
61
+ }
62
+ function pushCommit(directory, commit, write, env) {
63
+ const index = Number(env.GIT_CONFIG_COUNT) || 0;
64
+ const credentials = Buffer.from(`x-access-token:${write.token}`).toString('base64');
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn('git', ['push', '--force', write.remote, `${commit}:refs/heads/${write.branch}`], {
67
+ cwd: directory,
68
+ stdio: ['ignore', 'inherit', 'inherit'],
69
+ env: Object.assign(Object.assign({}, env), { GIT_CONFIG_COUNT: String(index + 1), [`GIT_CONFIG_KEY_${index}`]: `http.${write.remote}.extraHeader`, [`GIT_CONFIG_VALUE_${index}`]: `Authorization: Basic ${credentials}` }),
70
+ });
71
+ const failed = () => reject(new Error('git push to the site repository failed.'));
72
+ child.on('error', failed);
73
+ child.on('close', (code) => (code === 0 ? resolve() : failed()));
74
+ });
75
+ }
76
+ export function deployFromGithubActions(options_1) {
77
+ return __awaiter(this, arguments, void 0, function* (options, progress = () => { }, env = process.env) {
78
+ const { apiUrl, subdomain } = options;
79
+ if (!subdomain)
80
+ throw new Error('Pass the site name with --subdomain <name>.');
81
+ const directory = path.resolve(options.directory);
82
+ const commit = yield commitDirectory(directory, env).catch(() => {
83
+ throw new Error(`Could not read ${options.directory} from the checked-out commit. Run mint deploy after actions/checkout, with --directory inside the repository.`);
84
+ });
85
+ progress('Requesting a GitHub Actions identity token...');
86
+ const oidcToken = yield getGithubOidcToken(env, apiUrl);
87
+ progress('Getting repository write access...');
88
+ let write;
89
+ try {
90
+ write = yield getGithubActionsWriteToken(apiUrl, oidcToken, subdomain);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof AgentApiError && error.status === 403) {
94
+ throw new Error(`This repository/branch isn't trusted to deploy ${subdomain}. Run \`mint deploy --github\` locally from this repository to trust it.`);
95
+ }
96
+ throw error;
97
+ }
98
+ progress('Pushing to the site repository...');
99
+ yield pushCommit(directory, commit, write, env);
100
+ return agentSiteUrl(apiUrl, subdomain);
101
+ });
102
+ }