1
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 630 days ago Viewed 100325 times
0
Submitted by rubenfiszel Bun
Verified 347 days ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "windmill-cli";
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
type PathType =
15
  | "script"
16
  | "flow"
17
  | "app"
18
  | "folder"
19
  | "resource"
20
  | "variable"
21
  | "resourcetype"
22
  | "schedule"
23
  | "user"
24
  | "group"
25
  | "httptrigger"
26
  | "websockettrigger"
27
  | "kafkatrigger"
28
  | "natstrigger"
29
  | "postgrestrigger"
30
  | "mqtttrigger"
31
  | "sqstrigger"
32
  | "gcptrigger";
33

34
let gpgFingerprint: string | undefined = undefined;
35

36
export async function main(
37
  workspace_id: string,
38
  repo_url_resource_path: string,
39
  path_type: PathType,
40
  skip_secret = true,
41
  path: string | undefined,
42
  parent_path: string | undefined,
43
  commit_msg: string,
44
  use_individual_branch = false,
45
  group_by_folder = false
46
) {
47
  let safeDirectoryPath: string | undefined;
48
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
49
  const cwd = process.cwd();
50
  process.env["HOME"] = ".";
51
  console.log(
52
    `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}`
53
  );
54

55
  if (repo_resource.is_github_app) {
56
    const token = await get_gh_app_token();
57
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
58
    repo_resource.url = authRepoUrl;
59
  }
60

61
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath } = await git_clone(cwd, repo_resource, use_individual_branch);
62
  safeDirectoryPath = cloneSafeDirectoryPath;
63
  await move_to_git_branch(
64
    workspace_id,
65
    path_type,
66
    path,
67
    parent_path,
68
    use_individual_branch,
69
    group_by_folder
70
  );
71
  const subfolder = repo_resource.folder ?? "";
72
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
73
  console.log(
74
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
75
  );
76
  await wmill_sync_pull(
77
    path_type,
78
    workspace_id,
79
    path,
80
    parent_path,
81
    skip_secret,
82
    repo_url_resource_path,
83
    use_individual_branch,
84
    repo_resource.branch
85
  );
86
  try {
87
    await git_push(path, parent_path, commit_msg, repo_resource);
88
  } catch (e) {
89
    throw e;
90
  } finally {
91
    await delete_pgp_keys();
92
    // Cleanup: remove safe.directory config
93
    if (safeDirectoryPath) {
94
      try {
95
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
96
      } catch (e) {
97
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
98
      }
99
    }
100
  }
101
  console.log("Finished syncing");
102
  process.chdir(`${cwd}`);
103
}
104
async function git_clone(
105
  cwd: string,
106
  repo_resource: any,
107
  use_individual_branch: boolean
108
): Promise<{ repo_name: string; safeDirectoryPath: string }> {
109
  // TODO: handle private SSH keys as well
110
  let repo_url = repo_resource.url;
111
  const subfolder = repo_resource.folder ?? "";
112
  const branch = repo_resource.branch ?? "";
113
  const repo_name = basename(repo_url, ".git");
114
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
115
  if (azureMatch) {
116
    console.log(
117
      "Requires Azure DevOps service account access token, requesting..."
118
    );
119
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
120
    const response = await fetch(
121
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
122
      {
123
        method: "POST",
124
        body: new URLSearchParams({
125
          client_id: azureResource.azureClientId,
126
          client_secret: azureResource.azureClientSecret,
127
          grant_type: "client_credentials",
128
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
129
        }),
130
      }
131
    );
132
    const { access_token } = await response.json();
133
    repo_url = repo_url.replace(azureMatch[0], access_token);
134
  }
135
  const args = ["clone", "--quiet", "--depth", "1"];
136
  if (use_individual_branch) {
137
    args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
138
  }
139
  if (subfolder !== "") {
140
    args.push("--sparse");
141
  }
142
  if (branch !== "") {
143
    args.push("--branch");
144
    args.push(branch);
145
  }
146
  args.push(repo_url);
147
  args.push(repo_name);
148
  await sh_run(-1, "git", ...args);
149
  try {
150
    process.chdir(`${cwd}/${repo_name}`);
151
    const safeDirectoryPath = process.cwd();
152
    // Add safe.directory to handle dubious ownership in cloned repo
153
    try {
154
      await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
155
    } catch (e) {
156
      console.log(`Warning: Could not add safe.directory config: ${e}`);
157
    }
158

159
    if (subfolder !== "") {
160
      await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
161
      try {
162
        process.chdir(`${cwd}/${repo_name}/${subfolder}`);
163
      } catch (err) {
164
        console.log(
165
          `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
166
        );
167
        throw err;
168
      }
169
    }
170
    return { repo_name, safeDirectoryPath };
171
  } catch (err) {
172
    console.log(
173
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
174
    );
175
    throw err;
176
  }
177
}
178
async function move_to_git_branch(
179
  workspace_id: string,
180
  path_type: PathType,
181
  path: string | undefined,
182
  parent_path: string | undefined,
183
  use_individual_branch: boolean,
184
  group_by_folder: boolean
185
) {
186
  if (!use_individual_branch || path_type === "user" || path_type === "group") {
187
    return;
188
  }
189
  const branchName = group_by_folder
190
    ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
191
        ?.split("/")
192
        .slice(0, 2)
193
        .join("__")}`
194
    : `wm_deploy/${workspace_id}/${path_type}/${(
195
        path ?? parent_path
196
      )?.replaceAll("/", "__")}`;
197
  try {
198
    await sh_run(undefined, "git", "checkout", branchName);
199
  } catch (err) {
200
    console.log(
201
      `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
202
    );
203
    try {
204
      await sh_run(undefined, "git", "checkout", "-b", branchName);
205
      await sh_run(
206
        undefined,
207
        "git",
208
        "config",
209
        "--add",
210
        "--bool",
211
        "push.autoSetupRemote",
212
        "true"
213
      );
214
    } catch (err) {
215
      console.log(
216
        `Error checking out branch '${branchName}'. Error was:\n${err}`
217
      );
218
      throw err;
219
    }
220
  }
221
  console.log(`Successfully switched to branch ${branchName}`);
222
}
223
async function git_push(
224
  path: string | undefined,
225
  parent_path: string | undefined,
226
  commit_msg: string,
227
  repo_resource: any
228
) {
229
  let user_email = process.env["WM_EMAIL"] ?? "";
230
  let user_name = process.env["WM_USERNAME"] ?? "";
231

232
  if (repo_resource.gpg_key) {
233
    await set_gpg_signing_secret(repo_resource.gpg_key);
234
    // Configure git with GPG key email for signing
235
    await sh_run(
236
      undefined,
237
      "git",
238
      "config",
239
      "user.email",
240
      repo_resource.gpg_key.email
241
    );
242
    await sh_run(undefined, "git", "config", "user.name", user_name);
243
  } else {
244
    await sh_run(undefined, "git", "config", "user.email", user_email);
245
    await sh_run(undefined, "git", "config", "user.name", user_name);
246
  }
247

248
  if (path !== undefined && path !== null && path !== "") {
249
    try {
250
      await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
251
    } catch (e) {
252
      console.log(`Unable to stage files matching ${path}**, ${e}`);
253
    }
254
  }
255
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
256
    try {
257
      await sh_run(
258
        undefined,
259
        "git",
260
        "add",
261
        "wmill-lock.yaml",
262
        `${parent_path}**`
263
      );
264
    } catch (e) {
265
      console.log(`Unable to stage files matching ${parent_path}, ${e}`);
266
    }
267
  }
268
  try {
269
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
270
  } catch {
271
    // git diff returns exit-code = 1 when there's at least one staged changes
272
    const commitArgs = ["git", "commit"];
273

274
    // Always use --author to set consistent authorship
275
    commitArgs.push("--author", `"${user_name} <${user_email}>"`);
276
    commitArgs.push(
277
      "-m",
278
      `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"`
279
    );
280

281
    await sh_run(undefined, ...commitArgs);
282
    try {
283
      await sh_run(undefined, "git", "push", "--porcelain");
284
    } catch (e) {
285
      console.log(`Could not push, trying to rebase first: ${e}`);
286
      await sh_run(undefined, "git", "pull", "--rebase");
287
      await sh_run(undefined, "git", "push", "--porcelain");
288
    }
289
    return;
290
  }
291
  console.log("No changes detected, nothing to commit. Returning...");
292
}
293
async function sh_run(
294
  secret_position: number | undefined,
295
  cmd: string,
296
  ...args: string[]
297
) {
298
  const nargs = secret_position != undefined ? args.slice() : args;
299
  if (secret_position && secret_position < 0) {
300
    secret_position = nargs.length - 1 + secret_position;
301
  }
302
  let secret: string | undefined = undefined;
303
  if (secret_position != undefined) {
304
    nargs[secret_position] = "***";
305
    secret = args[secret_position];
306
  }
307

308
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
309
  const command = exec(`${cmd} ${args.join(" ")}`);
310
  // new Deno.Command(cmd, {
311
  //   args: args,
312
  // });
313
  try {
314
    const { stdout, stderr } = await command;
315
    if (stdout.length > 0) {
316
      console.log(stdout);
317
    }
318
    if (stderr.length > 0) {
319
      console.log(stderr);
320
    }
321
    console.log("Command successfully executed");
322
    return stdout;
323
  } catch (error) {
324
    let errorString = error.toString();
325
    if (secret) {
326
      errorString = errorString.replace(secret, "***");
327
    }
328
    const err = `SH command '${cmd} ${nargs.join(
329
      " "
330
    )}' returned with error ${errorString}`;
331
    throw Error(err);
332
  }
333
}
334

335
function regexFromPath(path_type: PathType, path: string) {
336
  if (path_type == "flow") {
337
    return `${path}.flow/*`;
338
  }
339
  if (path_type == "app") {
340
    return `${path}.app/*`;
341
  } else if (path_type == "folder") {
342
    return `${path}/folder.meta.*`;
343
  } else if (path_type == "resourcetype") {
344
    return `${path}.resource-type.*`;
345
  } else if (path_type == "resource") {
346
    return `${path}.resource.*`;
347
  } else if (path_type == "variable") {
348
    return `${path}.variable.*`;
349
  } else if (path_type == "schedule") {
350
    return `${path}.schedule.*`;
351
  } else if (path_type == "user") {
352
    return `${path}.user.*`;
353
  } else if (path_type == "group") {
354
    return `${path}.group.*`;
355
  } else if (path_type == "httptrigger") {
356
    return `${path}.http_trigger.*`;
357
  } else if (path_type == "websockettrigger") {
358
    return `${path}.websocket_trigger.*`;
359
  } else if (path_type == "kafkatrigger") {
360
    return `${path}.kafka_trigger.*`;
361
  } else if (path_type == "natstrigger") {
362
    return `${path}.nats_trigger.*`;
363
  } else if (path_type == "postgrestrigger") {
364
    return `${path}.postgres_trigger.*`;
365
  } else if (path_type == "mqtttrigger") {
366
    return `${path}.mqtt_trigger.*`;
367
  } else if (path_type == "sqstrigger") {
368
    return `${path}.sqs_trigger.*`;
369
  } else if (path_type == "gcptrigger") {
370
    return `${path}.gcp_trigger.*`;
371
  } else {
372
    return `${path}.*`;
373
  }
374
}
375

376
async function wmill_sync_pull(
377
  path_type: PathType,
378
  workspace_id: string,
379
  path: string | undefined,
380
  parent_path: string | undefined,
381
  skip_secret: boolean,
382
  repo_url_resource_path: string,
383
  use_individual_branch: boolean,
384
  original_branch?: string
385
) {
386
  const includes = [];
387
  if (path !== undefined && path !== null && path !== "") {
388
    includes.push(regexFromPath(path_type, path));
389
  }
390
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
391
    includes.push(regexFromPath(path_type, parent_path));
392
  }
393
  await wmill_run(
394
    6,
395
    "workspace",
396
    "add",
397
    workspace_id,
398
    workspace_id,
399
    process.env["BASE_URL"] + "/",
400
    "--token",
401
    process.env["WM_TOKEN"] ?? ""
402
  );
403
  console.log("Pulling workspace into git repo");
404
  const args = [
405
    "sync",
406
    "pull",
407
    "--token",
408
    process.env["WM_TOKEN"] ?? "",
409
    "--workspace",
410
    workspace_id,
411
    "--repository",
412
    repo_url_resource_path,
413
    "--yes",
414
    skip_secret ? "--skip-secrets" : "",
415
    "--include-schedules",
416
    "--include-users",
417
    "--include-groups",
418
    "--include-triggers",
419
  ];
420

421
  // Only include settings when specifically deploying settings
422
  if (path_type === "settings" && !use_individual_branch) {
423
    args.push("--include-settings");
424
  }
425

426
  // Only include key when specifically deploying keys
427
  if (path_type === "key" && !use_individual_branch) {
428
    args.push("--include-key");
429
  }
430

431
  args.push("--extra-includes", includes.join(","));
432

433
  // If using individual branches, apply promotion settings from original branch
434
  if (use_individual_branch && original_branch) {
435
    console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
436
    args.push("--promotion", original_branch);
437
  }
438

439
  await wmill_run(3, ...args);
440
}
441

442
async function wmill_run(secret_position: number, ...cmd: string[]) {
443
  cmd = cmd.filter((elt) => elt !== "");
444
  const cmd2 = cmd.slice();
445
  cmd2[secret_position] = "***";
446
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
447
  await wmill.parse(cmd);
448
  console.log("Command successfully executed");
449
}
450

451
// Function to set up GPG signing
452
async function set_gpg_signing_secret(gpg_key: GpgKey) {
453
  try {
454
    console.log("Setting GPG private key for git commits");
455

456
    const formattedGpgContent = gpg_key.private_key.replace(
457
      /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
458
      (_: string, header: string, body: string, footer: string) =>
459
        header +
460
        "\n" +
461
        "\n" +
462
        body.replace(/ ([^\s])/g, "\n$1").trim() +
463
        "\n" +
464
        footer
465
    );
466

467
    const gpg_path = `/tmp/gpg`;
468
    await sh_run(undefined, "mkdir", "-p", gpg_path);
469
    await sh_run(undefined, "chmod", "700", gpg_path);
470
    process.env.GNUPGHOME = gpg_path;
471
    // process.env.GIT_TRACE = 1;
472

473
    try {
474
      await sh_run(
475
        1,
476
        "bash",
477
        "-c",
478
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
479
      );
480
    } catch (e) {
481
      // Original error would contain sensitive data
482
      throw new Error("Failed to import GPG key!");
483
    }
484

485
    const listKeysOutput = await sh_run(
486
      undefined,
487
      "gpg",
488
      "--list-secret-keys",
489
      "--with-colons",
490
      "--keyid-format=long"
491
    );
492

493
    const keyInfoMatch = listKeysOutput.match(
494
      /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
495
    );
496

497
    if (!keyInfoMatch) {
498
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
499
    }
500

501
    const keyId = keyInfoMatch[1];
502
    gpgFingerprint = keyInfoMatch[2];
503

504
    if (gpg_key.passphrase) {
505
      // This is adummy command to unlock the key
506
      // with passphrase to load it into agent
507
      await sh_run(
508
        1,
509
        "bash",
510
        "-c",
511
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
512
      );
513
    }
514

515
    // Configure Git to use the extracted key
516
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
517
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
518
    console.log(`GPG signing configured with key ID: ${keyId} `);
519
  } catch (e) {
520
    console.error(`Failure while setting GPG key: ${e} `);
521
    await delete_pgp_keys();
522
  }
523
}
524

525
async function delete_pgp_keys() {
526
  console.log("deleting gpg keys");
527
  if (gpgFingerprint) {
528
    await sh_run(
529
      undefined,
530
      "gpg",
531
      "--batch",
532
      "--yes",
533
      "--pinentry-mode",
534
      "loopback",
535
      "--delete-secret-key",
536
      gpgFingerprint
537
    );
538
    await sh_run(
539
      undefined,
540
      "gpg",
541
      "--batch",
542
      "--yes",
543
      "--delete-key",
544
      "--pinentry-mode",
545
      "loopback",
546
      gpgFingerprint
547
    );
548
  }
549
}
550

551
async function get_gh_app_token() {
552
  const workspace = process.env["WM_WORKSPACE"];
553
  const jobToken = process.env["WM_TOKEN"];
554

555
  const baseUrl =
556
    process.env["BASE_INTERNAL_URL"] ??
557
    process.env["BASE_URL"] ??
558
    "http://localhost:8000";
559

560
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
561

562
  const response = await fetch(url, {
563
    method: "POST",
564
    headers: {
565
      "Content-Type": "application/json",
566
      Authorization: `Bearer ${jobToken}`,
567
    },
568
    body: JSON.stringify({
569
      job_token: jobToken,
570
    }),
571
  });
572

573
  if (!response.ok) {
574
    throw new Error(`Error: ${response.statusText}`);
575
  }
576

577
  const data = await response.json();
578

579
  return data.token;
580
}
581

582
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
583
  if (!gitHubUrl || !installationToken) {
584
    throw new Error("Both GitHub URL and Installation Token are required.");
585
  }
586

587
  try {
588
    const url = new URL(gitHubUrl);
589

590
    // GitHub repository URL should be in the format: https://github.com/owner/repo.git
591
    if (url.hostname !== "github.com") {
592
      throw new Error(
593
        "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
594
      );
595
    }
596

597
    // Convert URL to include the installation token
598
    return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
599
  } catch (e) {
600
    const error = e as Error;
601
    throw new Error(`Invalid URL: ${error.message}`);
602
  }
603
}
604

Other submissions