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 825 days ago Picked 90 times
Submitted by pyranota275 Bun
Verified 8 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
  | "raw_app"
22
  | "folder"
23
  | "resource"
24
  | "variable"
25
  | "resourcetype"
26
  | "schedule"
27
  | "user"
28
  | "group"
29
  | "httptrigger"
30
  | "websockettrigger"
31
  | "kafkatrigger"
32
  | "natstrigger"
33
  | "postgrestrigger"
34
  | "mqtttrigger"
35
  | "sqstrigger"
36
  | "gcptrigger"
37
  | "emailtrigger";
38

39
type SyncObject = {
40
  path_type: PathType;
41
  path: string | undefined;
42
  parent_path: string | undefined;
43
  commit_msg: string;
44
};
45

46
let gpgFingerprint: string | undefined = undefined;
47

48
export async function main(
49
  items: SyncObject[],
50
  // Compat, do not use in code, rely on `items` instead
51
  path_type: PathType | undefined,
52
  path: string | undefined,
53
  parent_path: string | undefined,
54
  commit_msg: string | undefined,
55
  //
56
  workspace_id: string,
57
  repo_url_resource_path: string,
58
  skip_secret: boolean = true,
59
  use_individual_branch: boolean = false,
60
  group_by_folder: boolean = false,
61
  only_create_branch: boolean = false,
62
  parent_workspace_id?: string,
63
  force_branch?: string,
64
) {
65

66
  if (path_type !== undefined && commit_msg !== undefined) {
67
    items = [{
68
      path_type,
69
      path,
70
      parent_path,
71
      commit_msg,
72
    }];
73
  }
74
  await inner(items, workspace_id, repo_url_resource_path, skip_secret, use_individual_branch, group_by_folder, only_create_branch, parent_workspace_id, force_branch);
75
}
76

77
async function inner(
78
  items: SyncObject[],
79
  workspace_id: string,
80
  repo_url_resource_path: string,
81
  skip_secret: boolean = true,
82
  use_individual_branch: boolean = false,
83
  group_by_folder: boolean = false,
84
  only_create_branch: boolean = false,
85
  parent_workspace_id?: string,
86
  force_branch?: string,
87
) {
88

89
  let safeDirectoryPath: string | undefined;
90
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
91
  const cwd = process.cwd();
92
  process.env["HOME"] = ".";
93
  if (!only_create_branch) {
94
    for (const item of items) {
95
      console.log(
96
        `Syncing ${item.path_type} ${item.path ?? ""} with parent ${item.parent_path ?? ""}`
97
      );
98
    }
99
  }
100

101
  if (repo_resource.is_github_app) {
102
    const token = await get_gh_app_token();
103
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
104
    repo_resource.url = authRepoUrl;
105
  }
106

107
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = await git_clone(cwd, repo_resource, use_individual_branch || workspace_id.startsWith(FORKED_WORKSPACE_PREFIX));
108
  safeDirectoryPath = cloneSafeDirectoryPath;
109

110

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

115
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
116
    if (use_individual_branch) {
117
      console.log("Cannot have `use_individual_branch` in a forked workspace, disabling option`");
118
      use_individual_branch = false;
119
    }
120
    if (group_by_folder) {
121
      console.log("Cannot have `group_by_folder` in a forked workspace, disabling option`");
122
      group_by_folder = false;
123
    }
124
  }
125

126
  if (parent_workspace_id && parent_workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
127
    const parentBranch = get_fork_branch_name(parent_workspace_id, clonedBranchName);
128
    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`);
129
    await git_checkout_branch(
130
      items,
131
      parent_workspace_id,
132
      use_individual_branch,
133
      group_by_folder,
134
      clonedBranchName
135
    );
136
  }
137

138
  await git_checkout_branch(
139
    items,
140
    workspace_id,
141
    use_individual_branch,
142
    group_by_folder,
143
    clonedBranchName
144
  );
145

146

147
  const subfolder = repo_resource.folder ?? "";
148
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
149
  console.log(
150
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
151
  );
152

153
  // If we want to just create the branch, we can skip pulling the changes.
154
  if (!only_create_branch) {
155
    await wmill_sync_pull(
156
      items,
157
      workspace_id,
158
      skip_secret,
159
      repo_url_resource_path,
160
      use_individual_branch,
161
      repo_resource.branch,
162
      force_branch
163
    );
164
  }
165
  try {
166
    await git_push(items, repo_resource, only_create_branch);
167
  } catch (e) {
168
    throw e;
169
  } finally {
170
    await delete_pgp_keys();
171
    // Cleanup: remove safe.directory config
172
    if (safeDirectoryPath) {
173
      try {
174
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
175
      } catch (e) {
176
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
177
      }
178
    }
179
  }
180
  console.log("Finished syncing");
181
  process.chdir(`${cwd}`);
182
}
183

184

185
function get_fork_branch_name(w_id: string, originalBranch: string): string {
186
  if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
187
    return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
188
  }
189
  return w_id;
190
}
191

192
async function git_clone(
193
  cwd: string,
194
  repo_resource: any,
195
  no_single_branch: boolean,
196
): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
197
  // TODO: handle private SSH keys as well
198
  let repo_url = repo_resource.url;
199
  const subfolder = repo_resource.folder ?? "";
200
  const branch = repo_resource.branch ?? "";
201
  const repo_name = basename(repo_url, ".git");
202
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
203
  if (azureMatch) {
204
    console.log(
205
      "Requires Azure DevOps service account access token, requesting..."
206
    );
207
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
208
    const response = await fetch(
209
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
210
      {
211
        method: "POST",
212
        body: new URLSearchParams({
213
          client_id: azureResource.azureClientId,
214
          client_secret: azureResource.azureClientSecret,
215
          grant_type: "client_credentials",
216
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
217
        }),
218
      }
219
    );
220
    const { access_token } = await response.json();
221
    repo_url = repo_url.replace(azureMatch[0], access_token);
222
  }
223
  const args = ["clone", "--quiet", "--depth", "1"];
224
  if (no_single_branch) {
225
    args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
226
  }
227
  if (subfolder !== "") {
228
    args.push("--sparse");
229
  }
230
  if (branch !== "") {
231
    args.push("--branch");
232
    args.push(branch);
233
  }
234
  args.push(repo_url);
235
  args.push(repo_name);
236
  await sh_run(-1, "git", ...args);
237
  try {
238
    process.chdir(`${cwd}/${repo_name}`);
239
    const safeDirectoryPath = process.cwd();
240
    // Add safe.directory to handle dubious ownership in cloned repo
241
    try {
242
      await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
243
    } catch (e) {
244
      console.log(`Warning: Could not add safe.directory config: ${e}`);
245
    }
246

247
    if (subfolder !== "") {
248
      await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
249
      try {
250
        process.chdir(`${cwd}/${repo_name}/${subfolder}`);
251
      } catch (err) {
252
        console.log(
253
          `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
254
        );
255
        throw err;
256
      }
257
    }
258
    const clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
259
    return { repo_name, safeDirectoryPath, clonedBranchName };
260

261
  } catch (err) {
262
    console.log(
263
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
264
    );
265
    throw err;
266
  }
267
}
268
async function git_checkout_branch(
269
  items: SyncObject[],
270
  workspace_id: string,
271
  use_individual_branch: boolean,
272
  group_by_folder: boolean,
273
  originalBranchName: string
274
) {
275
  let branchName;
276
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
277
    branchName = get_fork_branch_name(workspace_id, originalBranchName);
278
  } else {
279

280
    if (!use_individual_branch
281
      // If individual branch is true, we can assume items is of length 1, as debouncing is disabled for jobs with this flag
282
      || items[0].path_type === "user" || items[0].path_type === "group") {
283
      return;
284
    }
285

286
    // as mentioned above, it is safe to assume that items.len is 1
287
    const [path, parent_path] = [items[0].path, items[0].parent_path];
288
    branchName = group_by_folder
289
      ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
290
        ?.split("/")
291
        .slice(0, 2)
292
        .join("__")}`
293
      : `wm_deploy/${workspace_id}/${items[0].path_type}/${(
294
        path ?? parent_path
295
      )?.replaceAll("/", "__")}`;
296
  }
297

298
  try {
299
    await sh_run(undefined, "git", "checkout", branchName);
300
  } catch (err) {
301
    console.log(
302
      `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
303
    );
304
    try {
305
      await sh_run(undefined, "git", "checkout", "-b", branchName);
306
      await sh_run(
307
        undefined,
308
        "git",
309
        "config",
310
        "--add",
311
        "--bool",
312
        "push.autoSetupRemote",
313
        "true"
314
      );
315
    } catch (err) {
316
      console.log(
317
        `Error checking out branch '${branchName}'. Error was:\n${err}`
318
      );
319
      throw err;
320
    }
321
  }
322
  console.log(`Successfully switched to branch ${branchName}`);
323
}
324

325
function composeCommitHeader(items: SyncObject[]): string {
326
  // Count occurrences of each path_type
327
  const typeCounts = new Map<PathType, number>();
328
  for (const item of items) {
329
    typeCounts.set(item.path_type, (typeCounts.get(item.path_type) ?? 0) + 1);
330
  }
331

332
  // Sort by count descending to get the top 2
333
  const sortedTypes = Array.from(typeCounts.entries()).sort((a, b) => b[1] - a[1]);
334

335
  const parts: string[] = [];
336
  let othersCount = 0;
337

338
  for (let i = 0; i < sortedTypes.length; i++) {
339
    const [pathType, count] = sortedTypes[i];
340
    if (i < 3) {
341
      // Pluralize the path type if count > 1
342
      const label = count > 1 ? `${pathType}s` : pathType;
343

344
      if (i == 2 && sortedTypes.length == 3) {
345
        parts.push(`and ${count} ${label}`);
346
      } else {
347
        parts.push(`${count} ${label}`);
348
      }
349
    } else {
350
      othersCount += count;
351
    }
352
  }
353

354
  let header = `[WM]: Deployed ${parts.join(", ")}`;
355
  if (othersCount > 0) {
356
    header += ` and ${othersCount} other object${othersCount > 1 ? "s" : ""}`;
357
  }
358

359
  return header;
360
}
361

362
async function git_push(
363
  items: SyncObject[],
364
  repo_resource: any,
365
  only_create_branch: boolean,
366
) {
367
  let user_email = process.env["WM_EMAIL"] ?? "";
368
  let user_name = process.env["WM_USERNAME"] ?? "";
369

370
  if (repo_resource.gpg_key) {
371
    await set_gpg_signing_secret(repo_resource.gpg_key);
372
    // Configure git with GPG key email for signing
373
    await sh_run(
374
      undefined,
375
      "git",
376
      "config",
377
      "user.email",
378
      repo_resource.gpg_key.email
379
    );
380
    await sh_run(undefined, "git", "config", "user.name", user_name);
381
  } else {
382
    await sh_run(undefined, "git", "config", "user.email", user_email);
383
    await sh_run(undefined, "git", "config", "user.name", user_name);
384
  }
385
  if (only_create_branch) {
386
    await sh_run(undefined, "git", "push", "--porcelain");
387
  }
388

389
  let commit_description: string[] = [];
390
  for (const { path, parent_path, commit_msg } of items) {
391
    if (path !== undefined && path !== null && path !== "") {
392
      try {
393
        await sh_run(undefined, "git", "add", "wmill-lock.yaml", `'${path}**'`);
394
      } catch (e) {
395
        console.log(`Unable to stage files matching ${path}**, ${e}`);
396
      }
397
    }
398
    if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
399
      try {
400
        await sh_run(
401
          undefined,
402
          "git",
403
          "add",
404
          "wmill-lock.yaml",
405
          `'${parent_path}**'`
406
        );
407
      } catch (e) {
408
        console.log(`Unable to stage files matching ${parent_path}, ${e}`);
409
      }
410
    }
411

412
    commit_description.push(commit_msg);
413
  }
414

415
  try {
416
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
417
  } catch {
418
    // git diff returns exit-code = 1 when there's at least one staged changes
419
    const commitArgs = ["git", "commit"];
420

421
    // Always use --author to set consistent authorship
422
    commitArgs.push("--author", `"${user_name} <${user_email}>"`);
423

424
    const [header, description] = (commit_description.length == 1)
425
      ? [commit_description[0], ""]
426
      : [composeCommitHeader(items), commit_description.join("\n")];
427

428
    commitArgs.push(
429
      "-m",
430
      `"${header == undefined || header == "" ? "no commit msg" : header}"`,
431
      "-m",
432
      `"${description}"`
433
    );
434

435
    await sh_run(undefined, ...commitArgs);
436
    try {
437
      await sh_run(undefined, "git", "push", "--porcelain");
438
    } catch (e) {
439
      console.log(`Could not push, trying to rebase first: ${e}`);
440
      await sh_run(undefined, "git", "pull", "--rebase");
441
      await sh_run(undefined, "git", "push", "--porcelain");
442
    }
443
    return;
444
  }
445

446
  console.log("No changes detected, nothing to commit. Returning...");
447
}
448
async function sh_run(
449
  secret_position: number | undefined,
450
  cmd: string,
451
  ...args: string[]
452
) {
453
  const nargs = secret_position != undefined ? args.slice() : args;
454
  if (secret_position && secret_position < 0) {
455
    secret_position = nargs.length - 1 + secret_position;
456
  }
457
  let secret: string | undefined = undefined;
458
  if (secret_position != undefined) {
459
    nargs[secret_position] = "***";
460
    secret = args[secret_position];
461
  }
462

463
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
464
  const command = exec(`${cmd} ${args.join(" ")}`);
465
  // new Deno.Command(cmd, {
466
  //   args: args,
467
  // });
468
  try {
469
    const { stdout, stderr } = await command;
470
    if (stdout.length > 0) {
471
      console.log(stdout);
472
    }
473
    if (stderr.length > 0) {
474
      console.log(stderr);
475
    }
476
    console.log("Command successfully executed");
477
    return stdout;
478
  } catch (error) {
479
    let errorString = error.toString();
480
    if (secret) {
481
      errorString = errorString.replace(secret, "***");
482
    }
483
    const err = `SH command '${cmd} ${nargs.join(
484
      " "
485
    )}' returned with error ${errorString}`;
486
    throw Error(err);
487
  }
488
}
489

490
function regexFromPath(path_type: PathType, path: string) {
491
  if (path_type == "flow") {
492
    return `${path}.flow/*,${path}__flow/*`;
493
  } else if (path_type == "app") {
494
    return `${path}.app/*,${path}__app/*`;
495
  } else if (path_type == "raw_app") {
496
    return `${path}.raw_app/**,${path}__raw_app/**`;
497
  } else if (path_type == "folder") {
498
    return `${path}/folder.meta.*`;
499
  } else if (path_type == "resourcetype") {
500
    return `${path}.resource-type.*`;
501
  } else if (path_type == "resource") {
502
    return `${path}.resource.*`;
503
  } else if (path_type == "variable") {
504
    return `${path}.variable.*`;
505
  } else if (path_type == "schedule") {
506
    return `${path}.schedule.*`;
507
  } else if (path_type == "user") {
508
    return `${path}.user.*`;
509
  } else if (path_type == "group") {
510
    return `${path}.group.*`;
511
  } else if (path_type == "httptrigger") {
512
    return `${path}.http_trigger.*`;
513
  } else if (path_type == "websockettrigger") {
514
    return `${path}.websocket_trigger.*`;
515
  } else if (path_type == "kafkatrigger") {
516
    return `${path}.kafka_trigger.*`;
517
  } else if (path_type == "natstrigger") {
518
    return `${path}.nats_trigger.*`;
519
  } else if (path_type == "postgrestrigger") {
520
    return `${path}.postgres_trigger.*`;
521
  } else if (path_type == "mqtttrigger") {
522
    return `${path}.mqtt_trigger.*`;
523
  } else if (path_type == "sqstrigger") {
524
    return `${path}.sqs_trigger.*`;
525
  } else if (path_type == "gcptrigger") {
526
    return `${path}.gcp_trigger.*`;
527
  } else if (path_type == "emailtrigger") {
528
    return `${path}.email_trigger.*`;
529
  } else {
530
    return `${path}.*`;
531
  }
532
}
533

534
async function wmill_sync_pull(
535
  items: SyncObject[],
536
  workspace_id: string,
537
  skip_secret: boolean,
538
  repo_url_resource_path: string,
539
  use_individual_branch: boolean,
540
  original_branch?: string,
541
  force_branch?: string,
542
) {
543
  const includes = [];
544
  for (const item of items) {
545
    const { path_type, path, parent_path } = item;
546
    if (path !== undefined && path !== null && path !== "") {
547
      includes.push(regexFromPath(path_type, path));
548
    }
549
    if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
550
      includes.push(regexFromPath(path_type, parent_path));
551
    }
552
  }
553

554
  console.log("Pulling workspace into git repo");
555

556
  const args = [
557
    "sync",
558
    "pull",
559
    "--token",
560
    process.env["WM_TOKEN"] ?? "",
561
    "--workspace",
562
    workspace_id,
563
    "--base-url",
564
    process.env["BASE_URL"] + "/",
565
    "--repository",
566
    repo_url_resource_path,
567
    "--yes",
568
    skip_secret ? "--skip-secrets" : "",
569
  ];
570

571
  if (items.some(item => item.path_type === "schedule") && !use_individual_branch) {
572
    args.push("--include-schedules");
573
  }
574

575
  if (items.some(item => item.path_type === "group") && !use_individual_branch) {
576
    args.push("--include-groups");
577
  }
578

579
  if (items.some(item => item.path_type === "user") && !use_individual_branch) {
580
    args.push("--include-users");
581
  }
582

583
  if (items.some(item => item.path_type.includes("trigger")) && !use_individual_branch) {
584
    args.push("--include-triggers");
585
  }
586
  // Only include settings when specifically deploying settings
587
  if (items.some(item => item.path_type === "settings") && !use_individual_branch) {
588
    args.push("--include-settings");
589
  }
590

591
  // Only include key when specifically deploying keys
592
  if (items.some(item => item.path_type === "key") && !use_individual_branch) {
593
    args.push("--include-key");
594
  }
595

596
  if (force_branch) {
597
    args.push("--branch", force_branch);
598
  }
599

600
  args.push("--extra-includes", includes.join(","));
601

602
  // If using individual branches, apply promotion settings from original branch
603
  if (use_individual_branch && original_branch) {
604
    console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
605
    args.push("--promotion", original_branch);
606
  }
607

608
  await wmill_run(3, ...args);
609
}
610

611
async function wmill_run(secret_position: number, ...cmd: string[]) {
612
  cmd = cmd.filter((elt) => elt !== "");
613
  const cmd2 = cmd.slice();
614
  cmd2[secret_position] = "***";
615
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
616
  await wmill.parse(cmd);
617
  console.log("Command successfully executed");
618
}
619

620
// Function to set up GPG signing
621
async function set_gpg_signing_secret(gpg_key: GpgKey) {
622
  try {
623
    console.log("Setting GPG private key for git commits");
624

625
    const formattedGpgContent = gpg_key.private_key.replace(
626
      /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
627
      (_: string, header: string, body: string, footer: string) =>
628
        header +
629
        "\n" +
630
        "\n" +
631
        body.replace(/ ([^\s])/g, "\n$1").trim() +
632
        "\n" +
633
        footer
634
    );
635

636
    const gpg_path = `/tmp/gpg`;
637
    await sh_run(undefined, "mkdir", "-p", gpg_path);
638
    await sh_run(undefined, "chmod", "700", gpg_path);
639
    process.env.GNUPGHOME = gpg_path;
640
    // process.env.GIT_TRACE = 1;
641

642
    try {
643
      await sh_run(
644
        1,
645
        "bash",
646
        "-c",
647
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
648
      );
649
    } catch (e) {
650
      // Original error would contain sensitive data
651
      throw new Error("Failed to import GPG key!");
652
    }
653

654
    const listKeysOutput = await sh_run(
655
      undefined,
656
      "gpg",
657
      "--list-secret-keys",
658
      "--with-colons",
659
      "--keyid-format=long"
660
    );
661

662
    const keyInfoMatch = listKeysOutput.match(
663
      /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
664
    );
665

666
    if (!keyInfoMatch) {
667
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
668
    }
669

670
    const keyId = keyInfoMatch[1];
671
    gpgFingerprint = keyInfoMatch[2];
672

673
    if (gpg_key.passphrase) {
674
      // This is adummy command to unlock the key
675
      // with passphrase to load it into agent
676
      await sh_run(
677
        1,
678
        "bash",
679
        "-c",
680
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
681
      );
682
    }
683

684
    // Configure Git to use the extracted key
685
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
686
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
687
    console.log(`GPG signing configured with key ID: ${keyId} `);
688
  } catch (e) {
689
    console.error(`Failure while setting GPG key: ${e} `);
690
    await delete_pgp_keys();
691
  }
692
}
693

694
async function delete_pgp_keys() {
695
  console.log("deleting gpg keys");
696
  if (gpgFingerprint) {
697
    await sh_run(
698
      undefined,
699
      "gpg",
700
      "--batch",
701
      "--yes",
702
      "--pinentry-mode",
703
      "loopback",
704
      "--delete-secret-key",
705
      gpgFingerprint
706
    );
707
    await sh_run(
708
      undefined,
709
      "gpg",
710
      "--batch",
711
      "--yes",
712
      "--delete-key",
713
      "--pinentry-mode",
714
      "loopback",
715
      gpgFingerprint
716
    );
717
  }
718
}
719

720
async function get_gh_app_token() {
721
  const workspace = process.env["WM_WORKSPACE"];
722
  const jobToken = process.env["WM_TOKEN"];
723

724
  const baseUrl =
725
    process.env["BASE_INTERNAL_URL"] ??
726
    process.env["BASE_URL"] ??
727
    "http://localhost:8000";
728

729
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
730

731
  const response = await fetch(url, {
732
    method: "POST",
733
    headers: {
734
      "Content-Type": "application/json",
735
      Authorization: `Bearer ${jobToken}`,
736
    },
737
    body: JSON.stringify({
738
      job_token: jobToken,
739
    }),
740
  });
741

742
  if (!response.ok) {
743
    throw new Error(`Error: ${response.statusText}`);
744
  }
745

746
  const data = await response.json();
747

748
  return data.token;
749
}
750

751
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
752
  if (!gitHubUrl || !installationToken) {
753
    throw new Error("Both GitHub URL and Installation Token are required.");
754
  }
755

756
  try {
757
    const url = new URL(gitHubUrl);
758

759
    // GitHub repository URL should be in the format: https://github.com/owner/repo.git
760
    if (url.hostname !== "github.com") {
761
      throw new Error(
762
        "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
763
      );
764
    }
765

766
    // Convert URL to include the installation token
767
    return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
768
  } catch (e) {
769
    const error = e as Error;
770
    throw new Error(`Invalid URL: ${error.message}`);
771
  }
772
}
Other submissions