Sync script to Git repo
One script reply has been approved by the moderators Verified

This script will pull the script from the current workspace in a temporary folder, then commit it to the remote Git repository and push it. Only the script-related file will be pushed, nothing else. It takes as input the git_repository resource containing the repository URL, the script path, and the commit message to use for the commit. All params are mandatory.

Created by hugo697 734 days ago Picked 23 times
Submitted by rubenfiszel Bun
Verified 4 days ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "[email protected]";
3
import { basename } from "node:path";
4
const util = require("util");
5
const exec = util.promisify(require("child_process").exec);
6
import process from "process";
7

8
type GpgKey = {
9
  email: string;
10
  private_key: string;
11
  passphrase: string;
12
};
13

14
const FORKED_WORKSPACE_PREFIX = "wm-fork-";
15
const FORKED_BRANCH_PREFIX = "wm-fork";
16

17
type PathType =
18
  | "script"
19
  | "flow"
20
  | "app"
21
  | "folder"
22
  | "resource"
23
  | "variable"
24
  | "resourcetype"
25
  | "schedule"
26
  | "user"
27
  | "group"
28
  | "httptrigger"
29
  | "websockettrigger"
30
  | "kafkatrigger"
31
  | "natstrigger"
32
  | "postgrestrigger"
33
  | "mqtttrigger"
34
  | "sqstrigger"
35
  | "gcptrigger"
36
  | "emailtrigger";
37

38
let gpgFingerprint: string | undefined = undefined;
39

40
export async function main(
41
  workspace_id: string,
42
  repo_url_resource_path: string,
43
  path_type: PathType,
44
  skip_secret = true,
45
  path: string | undefined,
46
  parent_path: string | undefined,
47
  commit_msg: string,
48
  parent_workspace_id?: string,
49
  use_individual_branch = false,
50
  group_by_folder = false,
51
  only_create_branch: boolean = false
52
) {
53
  let safeDirectoryPath: string | undefined;
54
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
55
  const cwd = process.cwd();
56
  process.env["HOME"] = ".";
57
  if (!only_create_branch) {
58
    console.log(
59
      `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}`
60
    );
61
  }
62

63
  if (repo_resource.is_github_app) {
64
    const token = await get_gh_app_token();
65
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
66
    repo_resource.url = authRepoUrl;
67
  }
68

69
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = await git_clone(cwd, repo_resource, use_individual_branch || workspace_id.startsWith(FORKED_WORKSPACE_PREFIX));
70
  safeDirectoryPath = cloneSafeDirectoryPath;
71

72

73
  // Since we don't modify the resource on the forked workspaces, we have to cosnider the case of
74
  // a fork of a fork workspace. In that case, the original branch is not stored in the resource
75
  // settings, but we need to infer it from the workspace id
76

77
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
78
    if (use_individual_branch) {
79
      console.log("Cannot have `use_individual_branch` in a forked workspace, disabling option`");
80
      use_individual_branch = false;
81
    }
82
    if (group_by_folder) {
83
      console.log("Cannot have `group_by_folder` in a forked workspace, disabling option`");
84
      group_by_folder = false;
85
    }
86
  }
87

88
  if (parent_workspace_id && parent_workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
89
    const parentBranch = get_fork_branch_name(parent_workspace_id, clonedBranchName);
90
    console.log(`This workspace's parent is also a fork, moving to branch ${parentBranch} in case a new branch needs to be created with the appropriate root`);
91
    await move_to_git_branch(
92
      parent_workspace_id,
93
      path_type,
94
      path,
95
      parent_path,
96
      use_individual_branch,
97
      group_by_folder,
98
      clonedBranchName
99
    );
100
  }
101

102
  await move_to_git_branch(
103
    workspace_id,
104
    path_type,
105
    path,
106
    parent_path,
107
    use_individual_branch,
108
    group_by_folder,
109
    clonedBranchName
110
  );
111

112

113
  const subfolder = repo_resource.folder ?? "";
114
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
115
  console.log(
116
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
117
  );
118

119
  // If we want to just create the branch, we can skip pulling the changes.
120
  if (!only_create_branch) {
121
    await wmill_sync_pull(
122
      path_type,
123
      workspace_id,
124
      path,
125
      parent_path,
126
      skip_secret,
127
      repo_url_resource_path,
128
      use_individual_branch,
129
      repo_resource.branch
130
    );
131
  }
132
  try {
133
    await git_push(path, parent_path, commit_msg, repo_resource, only_create_branch);
134
  } catch (e) {
135
    throw e;
136
  } finally {
137
    await delete_pgp_keys();
138
    // Cleanup: remove safe.directory config
139
    if (safeDirectoryPath) {
140
      try {
141
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
142
      } catch (e) {
143
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
144
      }
145
    }
146
  }
147
  console.log("Finished syncing");
148
  process.chdir(`${cwd}`);
149
}
150

151
function get_fork_branch_name(w_id: string, originalBranch: string): string {
152
  if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
153
    return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
154
  }
155
  return w_id;
156
}
157

158
async function git_clone(
159
  cwd: string,
160
  repo_resource: any,
161
  no_single_branch: boolean,
162
): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
163
  // TODO: handle private SSH keys as well
164
  let repo_url = repo_resource.url;
165
  const subfolder = repo_resource.folder ?? "";
166
  const branch = repo_resource.branch ?? "";
167
  const repo_name = basename(repo_url, ".git");
168
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
169
  if (azureMatch) {
170
    console.log(
171
      "Requires Azure DevOps service account access token, requesting..."
172
    );
173
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
174
    const response = await fetch(
175
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
176
      {
177
        method: "POST",
178
        body: new URLSearchParams({
179
          client_id: azureResource.azureClientId,
180
          client_secret: azureResource.azureClientSecret,
181
          grant_type: "client_credentials",
182
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
183
        }),
184
      }
185
    );
186
    const { access_token } = await response.json();
187
    repo_url = repo_url.replace(azureMatch[0], access_token);
188
  }
189
  const args = ["clone", "--quiet", "--depth", "1"];
190
  if (no_single_branch) {
191
    args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
192
  }
193
  if (subfolder !== "") {
194
    args.push("--sparse");
195
  }
196
  if (branch !== "") {
197
    args.push("--branch");
198
    args.push(branch);
199
  }
200
  args.push(repo_url);
201
  args.push(repo_name);
202
  await sh_run(-1, "git", ...args);
203
  try {
204
    process.chdir(`${cwd}/${repo_name}`);
205
    const safeDirectoryPath = process.cwd();
206
    // Add safe.directory to handle dubious ownership in cloned repo
207
    try {
208
      await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
209
    } catch (e) {
210
      console.log(`Warning: Could not add safe.directory config: ${e}`);
211
    }
212

213
    if (subfolder !== "") {
214
      await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
215
      try {
216
        process.chdir(`${cwd}/${repo_name}/${subfolder}`);
217
      } catch (err) {
218
        console.log(
219
          `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
220
        );
221
        throw err;
222
      }
223
    }
224
    const clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
225
    return { repo_name, safeDirectoryPath, clonedBranchName };
226

227
  } catch (err) {
228
    console.log(
229
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
230
    );
231
    throw err;
232
  }
233
}
234
async function move_to_git_branch(
235
  workspace_id: string,
236
  path_type: PathType,
237
  path: string | undefined,
238
  parent_path: string | undefined,
239
  use_individual_branch: boolean,
240
  group_by_folder: boolean,
241
  originalBranchName: string
242
) {
243
  let branchName;
244
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
245
    branchName = get_fork_branch_name(workspace_id, originalBranchName);
246
  } else {
247
    if (!use_individual_branch || path_type === "user" || path_type === "group") {
248
      return;
249
    }
250
    branchName = group_by_folder
251
      ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
252
        ?.split("/")
253
        .slice(0, 2)
254
        .join("__")}`
255
      : `wm_deploy/${workspace_id}/${path_type}/${(
256
        path ?? parent_path
257
      )?.replaceAll("/", "__")}`;
258
  }
259

260
  try {
261
    await sh_run(undefined, "git", "checkout", branchName);
262
  } catch (err) {
263
    console.log(
264
      `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
265
    );
266
    try {
267
      await sh_run(undefined, "git", "checkout", "-b", branchName);
268
      await sh_run(
269
        undefined,
270
        "git",
271
        "config",
272
        "--add",
273
        "--bool",
274
        "push.autoSetupRemote",
275
        "true"
276
      );
277
    } catch (err) {
278
      console.log(
279
        `Error checking out branch '${branchName}'. Error was:\n${err}`
280
      );
281
      throw err;
282
    }
283
  }
284
  console.log(`Successfully switched to branch ${branchName}`);
285
}
286
async function git_push(
287
  path: string | undefined,
288
  parent_path: string | undefined,
289
  commit_msg: string,
290
  repo_resource: any,
291
  only_create_branch: boolean,
292
) {
293
  let user_email = process.env["WM_EMAIL"] ?? "";
294
  let user_name = process.env["WM_USERNAME"] ?? "";
295

296
  if (repo_resource.gpg_key) {
297
    await set_gpg_signing_secret(repo_resource.gpg_key);
298
    // Configure git with GPG key email for signing
299
    await sh_run(
300
      undefined,
301
      "git",
302
      "config",
303
      "user.email",
304
      repo_resource.gpg_key.email
305
    );
306
    await sh_run(undefined, "git", "config", "user.name", user_name);
307
  } else {
308
    await sh_run(undefined, "git", "config", "user.email", user_email);
309
    await sh_run(undefined, "git", "config", "user.name", user_name);
310
  }
311
  if (only_create_branch) {
312
    await sh_run(undefined, "git", "push", "--porcelain");
313
  }
314

315
  if (path !== undefined && path !== null && path !== "") {
316
    try {
317
      await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
318
    } catch (e) {
319
      console.log(`Unable to stage files matching ${path}**, ${e}`);
320
    }
321
  }
322
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
323
    try {
324
      await sh_run(
325
        undefined,
326
        "git",
327
        "add",
328
        "wmill-lock.yaml",
329
        `${parent_path}**`
330
      );
331
    } catch (e) {
332
      console.log(`Unable to stage files matching ${parent_path}, ${e}`);
333
    }
334
  }
335
  try {
336
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
337
  } catch {
338
    // git diff returns exit-code = 1 when there's at least one staged changes
339
    const commitArgs = ["git", "commit"];
340

341
    // Always use --author to set consistent authorship
342
    commitArgs.push("--author", `"${user_name} <${user_email}>"`);
343
    commitArgs.push(
344
      "-m",
345
      `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"`
346
    );
347

348
    await sh_run(undefined, ...commitArgs);
349
    try {
350
      await sh_run(undefined, "git", "push", "--porcelain");
351
    } catch (e) {
352
      console.log(`Could not push, trying to rebase first: ${e}`);
353
      await sh_run(undefined, "git", "pull", "--rebase");
354
      await sh_run(undefined, "git", "push", "--porcelain");
355
    }
356
    return;
357
  }
358
  console.log("No changes detected, nothing to commit. Returning...");
359
}
360
async function sh_run(
361
  secret_position: number | undefined,
362
  cmd: string,
363
  ...args: string[]
364
) {
365
  const nargs = secret_position != undefined ? args.slice() : args;
366
  if (secret_position && secret_position < 0) {
367
    secret_position = nargs.length - 1 + secret_position;
368
  }
369
  let secret: string | undefined = undefined;
370
  if (secret_position != undefined) {
371
    nargs[secret_position] = "***";
372
    secret = args[secret_position];
373
  }
374

375
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
376
  const command = exec(`${cmd} ${args.join(" ")}`);
377
  // new Deno.Command(cmd, {
378
  //   args: args,
379
  // });
380
  try {
381
    const { stdout, stderr } = await command;
382
    if (stdout.length > 0) {
383
      console.log(stdout);
384
    }
385
    if (stderr.length > 0) {
386
      console.log(stderr);
387
    }
388
    console.log("Command successfully executed");
389
    return stdout;
390
  } catch (error) {
391
    let errorString = error.toString();
392
    if (secret) {
393
      errorString = errorString.replace(secret, "***");
394
    }
395
    const err = `SH command '${cmd} ${nargs.join(
396
      " "
397
    )}' returned with error ${errorString}`;
398
    throw Error(err);
399
  }
400
}
401

402
function regexFromPath(path_type: PathType, path: string) {
403
  if (path_type == "flow") {
404
    return `${path}.flow/*`;
405
  }
406
  if (path_type == "app") {
407
    return `${path}.app/*`;
408
  } else if (path_type == "folder") {
409
    return `${path}/folder.meta.*`;
410
  } else if (path_type == "resourcetype") {
411
    return `${path}.resource-type.*`;
412
  } else if (path_type == "resource") {
413
    return `${path}.resource.*`;
414
  } else if (path_type == "variable") {
415
    return `${path}.variable.*`;
416
  } else if (path_type == "schedule") {
417
    return `${path}.schedule.*`;
418
  } else if (path_type == "user") {
419
    return `${path}.user.*`;
420
  } else if (path_type == "group") {
421
    return `${path}.group.*`;
422
  } else if (path_type == "httptrigger") {
423
    return `${path}.http_trigger.*`;
424
  } else if (path_type == "websockettrigger") {
425
    return `${path}.websocket_trigger.*`;
426
  } else if (path_type == "kafkatrigger") {
427
    return `${path}.kafka_trigger.*`;
428
  } else if (path_type == "natstrigger") {
429
    return `${path}.nats_trigger.*`;
430
  } else if (path_type == "postgrestrigger") {
431
    return `${path}.postgres_trigger.*`;
432
  } else if (path_type == "mqtttrigger") {
433
    return `${path}.mqtt_trigger.*`;
434
  } else if (path_type == "sqstrigger") {
435
    return `${path}.sqs_trigger.*`;
436
  } else if (path_type == "gcptrigger") {
437
    return `${path}.gcp_trigger.*`;
438
  } else if (path_type == "emailtrigger") {
439
    return `${path}.email_trigger.*`;
440
  } else {
441
    return `${path}.*`;
442
  }
443
}
444

445
async function wmill_sync_pull(
446
  path_type: PathType,
447
  workspace_id: string,
448
  path: string | undefined,
449
  parent_path: string | undefined,
450
  skip_secret: boolean,
451
  repo_url_resource_path: string,
452
  use_individual_branch: boolean,
453
  original_branch?: string
454
) {
455
  const includes = [];
456
  if (path !== undefined && path !== null && path !== "") {
457
    includes.push(regexFromPath(path_type, path));
458
  }
459
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
460
    includes.push(regexFromPath(path_type, parent_path));
461
  }
462
  await wmill_run(
463
    6,
464
    "workspace",
465
    "add",
466
    workspace_id,
467
    workspace_id,
468
    process.env["BASE_URL"] + "/",
469
    "--token",
470
    process.env["WM_TOKEN"] ?? ""
471
  );
472
  console.log("Pulling workspace into git repo");
473
  const args = [
474
    "sync",
475
    "pull",
476
    "--token",
477
    process.env["WM_TOKEN"] ?? "",
478
    "--workspace",
479
    workspace_id,
480
    "--repository",
481
    repo_url_resource_path,
482
    "--yes",
483
    skip_secret ? "--skip-secrets" : "",
484
    "--include-schedules",
485
    "--include-users",
486
    "--include-groups",
487
    "--include-triggers",
488
  ];
489

490
  // Only include settings when specifically deploying settings
491
  if (path_type === "settings" && !use_individual_branch) {
492
    args.push("--include-settings");
493
  }
494

495
  // Only include key when specifically deploying keys
496
  if (path_type === "key" && !use_individual_branch) {
497
    args.push("--include-key");
498
  }
499

500
  args.push("--extra-includes", includes.join(","));
501

502
  // If using individual branches, apply promotion settings from original branch
503
  if (use_individual_branch && original_branch) {
504
    console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
505
    args.push("--promotion", original_branch);
506
  }
507

508
  await wmill_run(3, ...args);
509
}
510

511
async function wmill_run(secret_position: number, ...cmd: string[]) {
512
  cmd = cmd.filter((elt) => elt !== "");
513
  const cmd2 = cmd.slice();
514
  cmd2[secret_position] = "***";
515
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
516
  await wmill.parse(cmd);
517
  console.log("Command successfully executed");
518
}
519

520
// Function to set up GPG signing
521
async function set_gpg_signing_secret(gpg_key: GpgKey) {
522
  try {
523
    console.log("Setting GPG private key for git commits");
524

525
    const formattedGpgContent = gpg_key.private_key.replace(
526
      /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
527
      (_: string, header: string, body: string, footer: string) =>
528
        header +
529
        "\n" +
530
        "\n" +
531
        body.replace(/ ([^\s])/g, "\n$1").trim() +
532
        "\n" +
533
        footer
534
    );
535

536
    const gpg_path = `/tmp/gpg`;
537
    await sh_run(undefined, "mkdir", "-p", gpg_path);
538
    await sh_run(undefined, "chmod", "700", gpg_path);
539
    process.env.GNUPGHOME = gpg_path;
540
    // process.env.GIT_TRACE = 1;
541

542
    try {
543
      await sh_run(
544
        1,
545
        "bash",
546
        "-c",
547
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
548
      );
549
    } catch (e) {
550
      // Original error would contain sensitive data
551
      throw new Error("Failed to import GPG key!");
552
    }
553

554
    const listKeysOutput = await sh_run(
555
      undefined,
556
      "gpg",
557
      "--list-secret-keys",
558
      "--with-colons",
559
      "--keyid-format=long"
560
    );
561

562
    const keyInfoMatch = listKeysOutput.match(
563
      /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
564
    );
565

566
    if (!keyInfoMatch) {
567
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
568
    }
569

570
    const keyId = keyInfoMatch[1];
571
    gpgFingerprint = keyInfoMatch[2];
572

573
    if (gpg_key.passphrase) {
574
      // This is adummy command to unlock the key
575
      // with passphrase to load it into agent
576
      await sh_run(
577
        1,
578
        "bash",
579
        "-c",
580
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
581
      );
582
    }
583

584
    // Configure Git to use the extracted key
585
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
586
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
587
    console.log(`GPG signing configured with key ID: ${keyId} `);
588
  } catch (e) {
589
    console.error(`Failure while setting GPG key: ${e} `);
590
    await delete_pgp_keys();
591
  }
592
}
593

594
async function delete_pgp_keys() {
595
  console.log("deleting gpg keys");
596
  if (gpgFingerprint) {
597
    await sh_run(
598
      undefined,
599
      "gpg",
600
      "--batch",
601
      "--yes",
602
      "--pinentry-mode",
603
      "loopback",
604
      "--delete-secret-key",
605
      gpgFingerprint
606
    );
607
    await sh_run(
608
      undefined,
609
      "gpg",
610
      "--batch",
611
      "--yes",
612
      "--delete-key",
613
      "--pinentry-mode",
614
      "loopback",
615
      gpgFingerprint
616
    );
617
  }
618
}
619

620
async function get_gh_app_token() {
621
  const workspace = process.env["WM_WORKSPACE"];
622
  const jobToken = process.env["WM_TOKEN"];
623

624
  const baseUrl =
625
    process.env["BASE_INTERNAL_URL"] ??
626
    process.env["BASE_URL"] ??
627
    "http://localhost:8000";
628

629
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
630

631
  const response = await fetch(url, {
632
    method: "POST",
633
    headers: {
634
      "Content-Type": "application/json",
635
      Authorization: `Bearer ${jobToken}`,
636
    },
637
    body: JSON.stringify({
638
      job_token: jobToken,
639
    }),
640
  });
641

642
  if (!response.ok) {
643
    throw new Error(`Error: ${response.statusText}`);
644
  }
645

646
  const data = await response.json();
647

648
  return data.token;
649
}
650

651
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
652
  if (!gitHubUrl || !installationToken) {
653
    throw new Error("Both GitHub URL and Installation Token are required.");
654
  }
655

656
  try {
657
    const url = new URL(gitHubUrl);
658

659
    // GitHub repository URL should be in the format: https://github.com/owner/repo.git
660
    if (url.hostname !== "github.com") {
661
      throw new Error(
662
        "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
663
      );
664
    }
665

666
    // Convert URL to include the installation token
667
    return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
668
  } catch (e) {
669
    const error = e as Error;
670
    throw new Error(`Invalid URL: ${error.message}`);
671
  }
672
}
673

Other submissions