-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
deploy.task.ts
248 lines (226 loc) · 9.26 KB
/
deploy.task.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
import {spawn} from '@ryanatkn/belt/process.js';
import {print_error} from '@ryanatkn/belt/print.js';
import {styleText as st} from 'node:util';
import {z} from 'zod';
import {cp, mkdir, rm} from 'node:fs/promises';
import {join, resolve} from 'node:path';
import {existsSync, readdirSync} from 'node:fs';
import {Task_Error, type Task} from './task.js';
import {print_path} from './paths.js';
import {GRO_DIRNAME, GIT_DIRNAME, SVELTEKIT_BUILD_DIRNAME} from './constants.js';
import {empty_dir} from './fs.js';
import {
git_check_clean_workspace,
git_checkout,
git_local_branch_exists,
git_remote_branch_exists,
Git_Origin,
Git_Branch,
git_delete_local_branch,
git_push_to_create,
git_reset_branch_to_first_commit,
git_pull,
git_fetch,
git_check_setting_pull_rebase,
git_clone_locally,
git_current_branch_name,
} from './git.js';
// docs at ./docs/deploy.md
// terminal command for testing:
// npm run bootstrap && rm -rf .gro && clear && gro deploy --source no-git-workspace --no-build --dry
// TODO customize
const dir = process.cwd();
const INITIAL_FILE_PATH = '.gitkeep';
const DEPLOY_DIR = GRO_DIRNAME + '/deploy';
const SOURCE_BRANCH = 'main';
const TARGET_BRANCH = 'deploy';
const DANGEROUS_BRANCHES = [SOURCE_BRANCH, 'master'];
export const Args = z
.object({
source: Git_Branch.describe('git source branch to build and deploy from').default(
SOURCE_BRANCH,
),
target: Git_Branch.describe('git target branch to deploy to').default(TARGET_BRANCH),
origin: Git_Origin.describe('git origin to deploy to').default('origin'),
deploy_dir: z.string({description: 'the deploy output directory'}).default(DEPLOY_DIR),
build_dir: z
.string({description: 'the SvelteKit build directory'})
.default(SVELTEKIT_BUILD_DIRNAME),
dry: z
.boolean({
description: 'build and prepare to deploy without actually deploying',
})
.default(false),
force: z
.boolean({description: 'caution!! destroys the target branch both locally and remotely'})
.default(false),
dangerous: z
.boolean({description: 'caution!! enables destruction of branches like main and master'})
.default(false),
reset: z
.boolean({
description: 'if true, resets the target branch back to the first commit before deploying',
})
.default(false),
build: z.boolean({description: 'dual of no-build'}).default(true),
'no-build': z.boolean({description: 'opt out of building'}).default(false),
})
.strict();
export type Args = z.infer<typeof Args>;
export const task: Task<Args> = {
summary: 'deploy to a branch',
Args,
run: async ({args, log, invoke_task}): Promise<void> => {
const {source, target, origin, build_dir, deploy_dir, dry, force, dangerous, reset, build} =
args;
// Checks
if (!force && target !== TARGET_BRANCH) {
throw new Task_Error(
`Warning! You are deploying to a custom target branch '${target}',` +
` instead of the default '${TARGET_BRANCH}' branch.` +
` This is destructive to your '${target}' branch!` +
` If you understand and are OK with deleting your branch '${target}',` +
` both locally and remotely, pass --force to suppress this error.`,
);
}
if (!dangerous && DANGEROUS_BRANCHES.includes(target)) {
throw new Task_Error(
`Warning! You are deploying to a custom target branch '${target}'` +
` and that appears very dangerous: it is destructive to your '${target}' branch!` +
` If you understand and are OK with deleting your branch '${target}',` +
` both locally and remotely, pass --dangerous to suppress this error.`,
);
}
const clean_error_message = await git_check_clean_workspace();
if (clean_error_message) {
throw new Task_Error(
'Deploy failed because the git workspace has uncommitted changes: ' + clean_error_message,
);
}
if (!(await git_check_setting_pull_rebase())) {
throw new Task_Error(
'Deploying currently requires `git config --global pull.rebase true`,' +
' but this restriction could be lifted with more work',
);
}
// Fetch the source branch in the cwd if it's not there
if (!(await git_local_branch_exists(source))) {
await git_fetch(origin, source);
}
// Prepare the source branch in the cwd
await git_checkout(source);
await git_pull(origin, source);
if (await git_check_clean_workspace()) {
throw new Task_Error(
'Deploy failed because the local source branch is out of sync with the remote one,' +
' finish rebasing manually or reset with `git rebase --abort`',
);
}
// Prepare the target branch remotely and locally
const resolved_deploy_dir = resolve(deploy_dir);
const target_spawn_options = {cwd: resolved_deploy_dir};
const remote_target_exists = await git_remote_branch_exists(origin, target);
if (remote_target_exists) {
// Remote target branch already exists, so sync up efficiently
// First, check if the deploy dir exists, and if so, attempt to sync it.
// If anything goes wrong, delete the directory and we'll initialize it
// using the same code path as if it didn't exist in the first place.
if (existsSync(resolved_deploy_dir)) {
if (target !== (await git_current_branch_name(target_spawn_options))) {
// We're in a bad state because the target branch has changed,
// so delete the directory and continue as if it wasn't there.
await rm(resolved_deploy_dir, {recursive: true});
} else {
await spawn('git', ['reset', '--hard'], target_spawn_options); // in case it's dirty
await git_pull(origin, target, target_spawn_options);
if (await git_check_clean_workspace(target_spawn_options)) {
// We're in a bad state because the local branch lost continuity with the remote,
// so delete the directory and continue as if it wasn't there.
await rm(resolved_deploy_dir, {recursive: true});
}
}
}
// Second, initialize the deploy dir if needed.
// It may not exist, or it may have been deleted after failing to sync above.
if (!existsSync(resolved_deploy_dir)) {
const local_deploy_branch_exists = await git_local_branch_exists(target);
await git_fetch(origin, ('+' + target + ':' + target) as Git_Branch); // fetch+merge and allow non-fastforward updates with the +
await git_clone_locally(origin, target, dir, resolved_deploy_dir);
// Clean up if we created the target branch in the cwd
if (!local_deploy_branch_exists) {
await git_delete_local_branch(target);
}
}
// Local target branch is now synced with remote, but do we need to reset?
if (reset) {
await git_reset_branch_to_first_commit(origin, target, target_spawn_options);
}
} else {
// Remote target branch does not exist, so start from scratch
// Delete the deploy dir and recreate it
if (existsSync(resolved_deploy_dir)) {
await rm(resolved_deploy_dir, {recursive: true});
await mkdir(resolved_deploy_dir, {recursive: true});
}
// Delete the target branch locally in the cwd if it exists
if (await git_local_branch_exists(target)) {
await git_delete_local_branch(target);
}
// Create the target branch locally and remotely.
// This is more complex to avoid churning the cwd.
await git_clone_locally(origin, source, dir, resolved_deploy_dir);
await spawn('git', ['checkout', '--orphan', target], target_spawn_options);
// TODO there's definitely a better way to do this
await spawn('git', ['rm', '-rf', '.'], target_spawn_options);
await spawn('touch', [INITIAL_FILE_PATH], target_spawn_options);
await spawn('git', ['add', INITIAL_FILE_PATH], target_spawn_options);
await spawn('git', ['commit', '-m', 'init'], target_spawn_options);
await git_push_to_create(origin, target, target_spawn_options);
await git_delete_local_branch(source, target_spawn_options);
}
// Remove everything except .git from the deploy directory to avoid stale files
await empty_dir(resolved_deploy_dir, (path) => path !== GIT_DIRNAME);
// Build
try {
if (build) {
await invoke_task('build');
}
if (!existsSync(build_dir)) {
log.error(st('red', 'directory to deploy does not exist after building:'), build_dir);
return;
}
} catch (err) {
log.error(
st('red', 'build failed'),
'but',
st('green', 'no changes were made to git'),
print_error(err),
);
if (dry) {
log.info(st('red', 'dry deploy failed'));
}
throw new Task_Error(`Deploy safely canceled due to build failure. See the error above.`);
}
// Copy the build
await Promise.all(
readdirSync(build_dir).map((path) =>
cp(join(build_dir, path), join(resolved_deploy_dir, path), {recursive: true}),
),
);
// At this point, `dist/` is ready to be committed and deployed!
if (dry) {
log.info(st('green', 'dry deploy complete:'), 'files at', print_path(resolved_deploy_dir));
return;
}
// Commit and push
try {
await spawn('git', ['add', '.', '-f'], target_spawn_options);
await spawn('git', ['commit', '-m', 'deployment'], target_spawn_options);
await spawn('git', ['push', origin, target, '-f'], target_spawn_options); // force push because we may be resetting the branch, see the checks above to make this safer
} catch (err) {
log.error(st('red', 'updating git failed:'), print_error(err));
throw new Task_Error(`Deploy failed in a bad state: built but not pushed, see error above.`);
}
log.info(st('green', 'deployed')); // TODO log a different message if "Everything up-to-date"
},
};