1

Sync script to Git repo

by
Published Dec 1, 2023

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.

Script windmill Verified

The script

Submitted by pyranota275 Bun
Verified 5 days ago
1
import * as wmillclient from "windmill-client";
2

3
import wmill from "[email protected]";
4
import { basename } from "node:path";
5
import { existsSync } from "node:fs";
6
const util = require("util");
7
const exec = util.promisify(require("child_process").exec);
8
import process from "process";
9

10
type GpgKey = {
11
  email: string;
12
  private_key: string;
13
  passphrase: string;
14
};
15

16
const FORKED_WORKSPACE_PREFIX = "wm-fork-";
17

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

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

48
let gpgFingerprint: string | undefined = undefined;
49

50
export async function main(
51
  items: SyncObject[],
52
  // Compat, do not use in code, rely on `items` instead
53
  path_type: PathType | undefined,
54
  path: string | undefined,
55
  parent_path: string | undefined,
56
  commit_msg: string | undefined,
57
  //
58
  workspace_id: string,
59
  repo_url_resource_path: string,
60
  skip_secret: boolean = true,
61
  use_individual_branch: boolean = false,
62
  group_by_folder: boolean = false,
63
  only_create_branch: boolean = false,
64
  parent_workspace_id?: string,
65
  dev_workspace_label?: string,
66
  parent_dev_workspace_label?: string,
67
) {
68
  if (path_type !== undefined && commit_msg !== undefined) {
69
    items = [
70
      {
71
        path_type,
72
        path,
73
        parent_path,
74
        commit_msg,
75
      },
76
    ];
77
  }
78
  // The push outcome is the job result: the completion hook reads `pushed` to
79
  // decide on PR creation and `sha`/`branch` to record the pushed head.
80
  return await inner(
81
    items,
82
    workspace_id,
83
    repo_url_resource_path,
84
    skip_secret,
85
    use_individual_branch,
86
    group_by_folder,
87
    only_create_branch,
88
    parent_workspace_id,
89
    dev_workspace_label,
90
    parent_dev_workspace_label,
91
  );
92
}
93

94
async function inner(
95
  items: SyncObject[],
96
  workspace_id: string,
97
  repo_url_resource_path: string,
98
  skip_secret: boolean = true,
99
  use_individual_branch: boolean = false,
100
  group_by_folder: boolean = false,
101
  only_create_branch: boolean = false,
102
  parent_workspace_id?: string,
103
  dev_workspace_label?: string,
104
  parent_dev_workspace_label?: string,
105
) {
106
  let safeDirectoryPath: string | undefined;
107
  // Whether a commit was actually pushed, and which commit on which branch:
108
  // surfaced in the result so the completion hook can skip PR creation for
109
  // no-op pushes and record the pushed head as the workspace's synced state.
110
  let push: PushOutcome = { pushed: false };
111
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
112
  const cwd = process.cwd();
113
  process.env["HOME"] = ".";
114
  if (!only_create_branch) {
115
    for (const item of items) {
116
      console.log(
117
        `Syncing ${item.path_type} ${item.path ?? ""} with parent ${item.parent_path ?? ""}`,
118
      );
119
    }
120
  }
121

122
  if (repo_resource.is_github_app) {
123
    const token = await get_gh_app_token();
124
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
125
    repo_resource.url = authRepoUrl;
126
  } else if (!urlCarriesCredential(repo_resource.url)) {
127
    // A repository whose credential Windmill holds gets it from the same
128
    // endpoint the app path uses; one that carries a token in its URL, or needs
129
    // none at all, is left exactly as it is.
130
    const token = await get_stored_git_token();
131
    if (token) {
132
      console.log("Using the git credential stored for this repository");
133
      repo_resource.url = prependTokenToGitUrl(repo_resource.url, token);
134
    }
135
  }
136

137
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath } =
138
    await git_clone(
139
      cwd,
140
      repo_resource,
141
      // Dev workspaces are forks with a prefix-less id, so also detect them via
142
      // parent_workspace_id (mirrors the CLI's `parent || wm-fork- prefix`).
143
      use_individual_branch ||
144
        workspace_id.startsWith(FORKED_WORKSPACE_PREFIX) ||
145
        !!parent_workspace_id,
146
    );
147
  safeDirectoryPath = cloneSafeDirectoryPath;
148

149
  // GPG signing must be set up BEFORE git_push (which is in-script and runs
150
  // back-to-back with the agent pre-warm — that's the whole point of doing
151
  // commit/push in the script instead of the CLI).
152
  if (repo_resource.gpg_key) {
153
    await set_gpg_signing_secret(repo_resource.gpg_key);
154
  }
155

156
  const subfolder = repo_resource.folder ?? "";
157
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
158
  console.log(
159
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`,
160
  );
161

162
  try {
163
    // CLI owns: branch checkout (wm_deploy/<workspace>/... or wm-fork/...),
164
    // include-set derivation from items, promotion-overrides resolution,
165
    // workspace zip pull. With --skip-commit, the CLI returns without
166
    // committing — the script does that next.
167
    const args = [
168
      "sync",
169
      "git-deploy",
170
      "--token",
171
      process.env["WM_TOKEN"] ?? "",
172
      "--workspace",
173
      workspace_id,
174
      "--base-url",
175
      process.env["BASE_URL"] + "/",
176
      "--repository",
177
      repo_url_resource_path,
178
      "--git-deploy-items",
179
      JSON.stringify(items),
180
    ];
181
    if (use_individual_branch) args.push("--use-individual-branch");
182
    if (group_by_folder) args.push("--group-by-folder");
183
    if (only_create_branch) args.push("--only-create-branch");
184
    if (parent_workspace_id) {
185
      args.push("--parent-workspace-id", parent_workspace_id);
186
    }
187
    if (dev_workspace_label) {
188
      // Dev workspaces deploy to their environment-label branch (dev/staging).
189
      args.push("--dev-workspace-label", dev_workspace_label);
190
    }
191
    if (parent_dev_workspace_label) {
192
      // A fork of a dev workspace roots its branch on the dev's label branch.
193
      args.push("--parent-dev-workspace-label", parent_dev_workspace_label);
194
    }
195
    if (skip_secret) args.push("--skip-secrets");
196
    await wmill_run(3, ...args);
197

198
    // In-script commit + push. Stays in the SAME process as the dummy gpg
199
    // sign in set_gpg_signing_secret — agent cache is warm, signing works.
200
    if (!only_create_branch) {
201
      push = await git_push(items, repo_resource, only_create_branch);
202
    }
203
  } catch (e) {
204
    throw e;
205
  } finally {
206
    await delete_pgp_keys();
207
    if (safeDirectoryPath) {
208
      try {
209
        await sh_run(
210
          undefined,
211
          "git",
212
          "config",
213
          "--global",
214
          "--unset",
215
          "safe.directory",
216
          safeDirectoryPath,
217
        );
218
      } catch (e) {
219
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
220
      }
221
    }
222
  }
223
  console.log("Finished syncing");
224
  process.chdir(`${cwd}`);
225
  return push;
226
}
227

228
type PushOutcome = {
229
  pushed: boolean;
230
  /** Head commit and branch after a successful push. */
231
  sha?: string;
232
  branch?: string;
233
  /** The push was rejected and retried after rebasing onto the remote, so the
234
   * pushed commit sits on top of commits this workspace has not pulled. */
235
  rebased?: boolean;
236
};
237

238
async function git_clone(
239
  cwd: string,
240
  repo_resource: any,
241
  no_single_branch: boolean,
242
): Promise<{
243
  repo_name: string;
244
  safeDirectoryPath: string;
245
  clonedBranchName: string;
246
}> {
247
  let repo_url = repo_resource.url;
248
  const subfolder = repo_resource.folder ?? "";
249
  const branch = repo_resource.branch ?? "";
250
  const repo_name = basename(repo_url, ".git");
251
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
252
  if (azureMatch) {
253
    console.log(
254
      "Requires Azure DevOps service account access token, requesting...",
255
    );
256
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
257
    const response = await fetch(
258
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
259
      {
260
        method: "POST",
261
        body: new URLSearchParams({
262
          client_id: azureResource.azureClientId,
263
          client_secret: azureResource.azureClientSecret,
264
          grant_type: "client_credentials",
265
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
266
        }),
267
      },
268
    );
269
    const { access_token } = await response.json();
270
    repo_url = repo_url.replace(azureMatch[0], access_token);
271
  }
272
  const args = ["clone", "--quiet", "--depth", "1"];
273
  if (no_single_branch) {
274
    args.push("--no-single-branch");
275
  }
276
  if (subfolder !== "") {
277
    args.push("--sparse");
278
  }
279
  if (branch !== "") {
280
    args.push("--branch");
281
    args.push(branch);
282
  }
283
  args.push(repo_url);
284
  args.push(repo_name);
285
  await sh_run(-1, "git", ...args);
286
  try {
287
    process.chdir(`${cwd}/${repo_name}`);
288
    const safeDirectoryPath = process.cwd();
289
    try {
290
      await sh_run(
291
        undefined,
292
        "git",
293
        "config",
294
        "--global",
295
        "--add",
296
        "safe.directory",
297
        process.cwd(),
298
      );
299
    } catch (e) {
300
      console.log(`Warning: Could not add safe.directory config: ${e}`);
301
    }
302

303
    if (subfolder !== "") {
304
      await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
305
      try {
306
        process.chdir(`${cwd}/${repo_name}/${subfolder}`);
307
      } catch (err) {
308
        console.log(
309
          `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`,
310
        );
311
        throw err;
312
      }
313
    }
314
    const clonedBranchName = (
315
      await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")
316
    ).trim();
317
    return { repo_name, safeDirectoryPath, clonedBranchName };
318
  } catch (err) {
319
    console.log(
320
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`,
321
    );
322
    throw err;
323
  }
324
}
325

326
function composeCommitHeader(items: SyncObject[]): string {
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
  const sortedTypes = Array.from(typeCounts.entries()).sort(
333
    (a, b) => b[1] - a[1],
334
  );
335

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

339
  for (let i = 0; i < sortedTypes.length; i++) {
340
    const [pathType, count] = sortedTypes[i];
341
    if (i < 3) {
342
      const label = count > 1 ? `${pathType}s` : pathType;
343
      if (i == 2 && sortedTypes.length == 3) {
344
        parts.push(`and ${count} ${label}`);
345
      } else {
346
        parts.push(`${count} ${label}`);
347
      }
348
    } else {
349
      othersCount += count;
350
    }
351
  }
352

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

360
// Stage / commit / push the deploy. Mirrors hub/28217's git_push EXACTLY
361
// (same staging globs, same commit-message construction quirks, same
362
// rebase-retry fallback). Keeping this in-script — instead of letting the
363
// CLI's gitSyncDeployPush handle it — is what restores GPG correctness:
364
// set_gpg_signing_secret already pre-warmed the agent in THIS process
365
// milliseconds ago, so signing inherits a warm cache via plain exec.
366
async function git_push(
367
  items: SyncObject[],
368
  repo_resource: any,
369
  only_create_branch: boolean,
370
): Promise<PushOutcome> {
371
  const user_email = process.env["WM_EMAIL"] ?? "";
372
  const user_name = process.env["WM_USERNAME"] ?? "";
373

374
  if (repo_resource.gpg_key) {
375
    // GPG path: committer identity = the GPG key's email (else git rejects
376
    // the signed commit's committer/key mismatch).
377
    await sh_run(
378
      undefined,
379
      "git",
380
      "config",
381
      "user.email",
382
      repo_resource.gpg_key.email,
383
    );
384
    await sh_run(undefined, "git", "config", "user.name", user_name);
385
  } else {
386
    await sh_run(undefined, "git", "config", "user.email", user_email);
387
    await sh_run(undefined, "git", "config", "user.name", user_name);
388
  }
389
  if (only_create_branch) {
390
    await sh_run(undefined, "git", "push", "--porcelain");
391
  }
392

393
  const commit_description: string[] = [];
394
  for (const { path, parent_path, commit_msg } of items) {
395
    if (path !== undefined && path !== null && path !== "") {
396
      try {
397
        await sh_run(undefined, "git", "add", "wmill-lock.yaml", `'${path}**'`);
398
      } catch (e) {
399
        console.log(`Unable to stage files matching ${path}**, ${e}`);
400
      }
401
    }
402
    if (
403
      parent_path !== undefined &&
404
      parent_path !== null &&
405
      parent_path !== ""
406
    ) {
407
      try {
408
        await sh_run(
409
          undefined,
410
          "git",
411
          "add",
412
          "wmill-lock.yaml",
413
          `'${parent_path}**'`,
414
        );
415
      } catch (e) {
416
        console.log(`Unable to stage files matching ${parent_path}, ${e}`);
417
      }
418
    }
419
    commit_description.push(commit_msg);
420
  }
421
  // A shared lockfile (`dedupeLockfiles`) lives under `locks/`, outside every
422
  // item's path glob, and the pull rewrites it when a deployed script's lock
423
  // changed. `-A` also stages the deletion of a swept one. Only when the
424
  // directory exists or is tracked: `git add` fails on a pathspec matching
425
  // nothing, and a repository without shared lockfiles must stay silent.
426
  const trackedSharedLocks = await sh_run(
427
    undefined,
428
    "git",
429
    "ls-files",
430
    "--",
431
    "locks",
432
  );
433
  if (existsSync("locks") || trackedSharedLocks.trim() !== "") {
434
    await sh_run(undefined, "git", "add", "-A", "--", "locks");
435
  }
436

437
  try {
438
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
439
  } catch {
440
    // git diff --cached --quiet exits 1 iff there is something staged.
441
    const commitArgs = ["git", "commit"];
442
    commitArgs.push("--author", `"${user_name} <${user_email}>"`);
443

444
    const [header, description] =
445
      commit_description.length == 1
446
        ? [commit_description[0], ""]
447
        : [composeCommitHeader(items), commit_description.join("\n")];
448

449
    commitArgs.push(
450
      "-m",
451
      `"${header == undefined || header == "" ? "no commit msg" : header}"`,
452
      "-m",
453
      `"${description}"`,
454
    );
455

456
    await sh_run(undefined, ...commitArgs);
457
    let rebased = false;
458
    try {
459
      await sh_run(undefined, "git", "push", "--porcelain");
460
    } catch (e) {
461
      console.log(`Could not push, trying to rebase first: ${e}`);
462
      await sh_run(undefined, "git", "pull", "--rebase");
463
      await sh_run(undefined, "git", "push", "--porcelain");
464
      rebased = true;
465
    }
466
    // Read after the push: a rebase rewrites the commit.
467
    const sha = (await sh_run(undefined, "git", "rev-parse", "HEAD")).trim();
468
    const branch = (
469
      await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")
470
    ).trim();
471
    return { pushed: true, sha, branch, rebased };
472
  }
473

474
  console.log("No changes detected, nothing to commit. Returning...");
475
  return { pushed: false };
476
}
477

478
async function sh_run(
479
  secret_position: number | undefined,
480
  cmd: string,
481
  ...args: string[]
482
) {
483
  const nargs = secret_position != undefined ? args.slice() : args;
484
  if (secret_position && secret_position < 0) {
485
    secret_position = nargs.length - 1 + secret_position;
486
  }
487
  let secret: string | undefined = undefined;
488
  if (secret_position != undefined) {
489
    nargs[secret_position] = "***";
490
    secret = args[secret_position];
491
  }
492

493
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
494
  const command = exec(`${cmd} ${args.join(" ")}`);
495
  try {
496
    const { stdout, stderr } = await command;
497
    if (stdout.length > 0) {
498
      console.log(stdout);
499
    }
500
    if (stderr.length > 0) {
501
      console.log(stderr);
502
    }
503
    console.log("Command successfully executed");
504
    return stdout;
505
  } catch (error) {
506
    let errorString = error.toString();
507
    if (secret) {
508
      errorString = errorString.replace(secret, "***");
509
    }
510
    const err = `SH command '${cmd} ${nargs.join(
511
      " ",
512
    )}' returned with error ${errorString}`;
513
    throw Error(err);
514
  }
515
}
516

517
async function wmill_run(secret_position: number, ...cmd: string[]) {
518
  cmd = cmd.filter((elt) => elt !== "");
519
  const cmd2 = cmd.slice();
520
  cmd2[secret_position] = "***";
521
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
522
  await wmill.parse(cmd);
523
  console.log("Command successfully executed");
524
}
525

526
// Identical to hub/28217's set_gpg_signing_secret. Sets up GNUPGHOME,
527
// imports the key, pre-warms the agent's passphrase cache with a dummy
528
// `gpg -bsau`, and configures git's local user.signingkey + commit.gpgsign.
529
// Because git_push runs immediately after in the SAME process (no CLI
530
// boundary), the agent cache is still warm at sign time.
531
async function set_gpg_signing_secret(gpg_key: GpgKey) {
532
  try {
533
    console.log("Setting GPG private key for git commits");
534

535
    const formattedGpgContent = gpg_key.private_key.replace(
536
      /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
537
      (_: string, header: string, body: string, footer: string) =>
538
        header +
539
        "\n" +
540
        "\n" +
541
        body.replace(/ ([^\s])/g, "\n$1").trim() +
542
        "\n" +
543
        footer,
544
    );
545

546
    const gpg_path = `/tmp/gpg`;
547
    await sh_run(undefined, "mkdir", "-p", gpg_path);
548
    await sh_run(undefined, "chmod", "700", gpg_path);
549
    process.env.GNUPGHOME = gpg_path;
550

551
    try {
552
      await sh_run(
553
        1,
554
        "bash",
555
        "-c",
556
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`,
557
      );
558
    } catch (e) {
559
      throw new Error("Failed to import GPG key!");
560
    }
561

562
    const listKeysOutput = await sh_run(
563
      undefined,
564
      "gpg",
565
      "--list-secret-keys",
566
      "--with-colons",
567
      "--keyid-format=long",
568
    );
569

570
    const keyInfoMatch = listKeysOutput.match(
571
      /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/,
572
    );
573

574
    if (!keyInfoMatch) {
575
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
576
    }
577

578
    const keyId = keyInfoMatch[1];
579
    gpgFingerprint = keyInfoMatch[2];
580

581
    if (gpg_key.passphrase) {
582
      // Pre-warm the agent: this dummy sign loads the passphrase into
583
      // gpg-agent's cache so the actual `git commit` later in this same
584
      // process doesn't need to prompt.
585
      await sh_run(
586
        1,
587
        "bash",
588
        "-c",
589
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`,
590
      );
591
    }
592

593
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
594
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
595
    console.log(`GPG signing configured with key ID: ${keyId} `);
596
  } catch (e) {
597
    console.error(`Failure while setting GPG key: ${e} `);
598
    await delete_pgp_keys();
599
  }
600
}
601

602
async function delete_pgp_keys() {
603
  console.log("deleting gpg keys");
604
  if (gpgFingerprint) {
605
    await sh_run(
606
      undefined,
607
      "gpg",
608
      "--batch",
609
      "--yes",
610
      "--pinentry-mode",
611
      "loopback",
612
      "--delete-secret-key",
613
      gpgFingerprint,
614
    );
615
    await sh_run(
616
      undefined,
617
      "gpg",
618
      "--batch",
619
      "--yes",
620
      "--delete-key",
621
      "--pinentry-mode",
622
      "loopback",
623
      gpgFingerprint,
624
    );
625
  }
626
}
627

628
// True when the remote already authenticates itself, i.e. it has a `user@` or
629
// `user:password@` userinfo component.
630
function urlCarriesCredential(url: string | undefined): boolean {
631
  return /:\/\/[^/@]+@/.test(url ?? "");
632
}
633

634
// The credential Windmill stores for this repository, or undefined when it holds
635
// none. Absence is normal (a public remote), so it must not fail the sync; the
636
// reason is logged so a real outage is still diagnosable from the job log.
637
async function get_stored_git_token() {
638
  try {
639
    return await get_gh_app_token();
640
  } catch (error) {
641
    console.log(
642
      "No stored git credential for this repository:",
643
      error?.message ?? String(error)
644
    );
645
    return undefined;
646
  }
647
}
648

649
async function get_gh_app_token() {
650
  const workspace = process.env["WM_WORKSPACE"];
651
  const jobToken = process.env["WM_TOKEN"];
652

653
  const baseUrl =
654
    process.env["BASE_INTERNAL_URL"] ??
655
    process.env["BASE_URL"] ??
656
    "http://localhost:8000";
657

658
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
659

660
  const response = await fetch(url, {
661
    method: "POST",
662
    headers: {
663
      "Content-Type": "application/json",
664
      Authorization: `Bearer ${jobToken}`,
665
    },
666
    body: JSON.stringify({
667
      job_token: jobToken,
668
    }),
669
  });
670

671
  if (!response.ok) {
672
    const errorBody = await response.text().catch(() => "");
673
    throw new Error(
674
      `GitHub App token error (${response.status}): ${errorBody || response.statusText}`,
675
    );
676
  }
677

678
  const data = await response.json();
679

680
  return data.token;
681
}
682

683
// Rewrite the git remote formats `new URL` can't parse into an https URL:
684
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
685
// A host:8080/... port form is kept as authority rather than read as scp.
686
function normalizeGitHubUrl(raw: string): string {
687
  const cleaned = raw.trim();
688
  if (cleaned.includes("://")) {
689
    return cleaned;
690
  }
691
  const colonIdx = cleaned.indexOf(":");
692
  if (colonIdx !== -1) {
693
    const before = cleaned.slice(0, colonIdx);
694
    const after = cleaned.slice(colonIdx + 1);
695
    const firstSeg = after.split("/")[0];
696
    // `user@host:...` is always scp form, so digits there are an owner
697
    // (GitHub allows all-numeric ones), not a port.
698
    const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
699
    // A `@` right after the `:` means `user:token@host/...` — an authority
700
    // (userinfo) form, not scp.
701
    const isUserinfo = firstSeg.includes("@");
702
    if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
703
      const host = before.split("@").pop();
704
      return `https://${host}/${after.replace(/^\/+/, "")}`;
705
    }
706
  }
707
  return `https://${cleaned}`;
708
}
709

710
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
711
  if (!gitHubUrl || !installationToken) {
712
    throw new Error("Both GitHub URL and Installation Token are required.");
713
  }
714

715
  // `host` (not `hostname`) so a custom port on a self-hosted git server survives.
716
  const url = new URL(normalizeGitHubUrl(gitHubUrl));
717
  return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
718
}
719

720
// Authenticate a remote with a stored token. Unlike the app-token form above,
721
// this keeps the scheme the resource chose, so a self-managed git server reached
722
// over http is not dialled as https; `oauth2` is the username GitLab documents
723
// for token auth, and the setters percent-encode the token.
724
function prependTokenToGitUrl(gitUrl: string, token: string) {
725
  const url = new URL(normalizeGitHubUrl(gitUrl));
726
  url.username = "oauth2";
727
  url.password = token;
728
  return url.toString();
729
}
730

Other submissions
  • Submitted by hugo697 Deno
    Created 793 days ago
    1
    import * as wmillclient from "npm:[email protected]";
    2
    import wmill from "https://deno.land/x/[email protected]/main.ts";
    3
    import { basename } from "https://deno.land/[email protected]/path/mod.ts";
    4
    
    
    5
    export async function main(
    6
      workspace_id: string,
    7
      repo_url_resource_path: string,
    8
      path_type:
    9
        | "script"
    10
        | "flow"
    11
        | "app"
    12
        | "folder"
    13
        | "resource"
    14
        | "variable"
    15
        | "resourcetype"
    16
        | "schedule"
    17
        | "user"
    18
        | "group",
    19
      skip_secret = true,
    20
      path: string | undefined,
    21
      parent_path: string | undefined,
    22
      commit_msg: string,
    23
      use_individual_branch = false,
    24
      group_by_folder = false
    25
    ) {
    26
      const repo_resource = await wmillclient.getResource(repo_url_resource_path);
    27
    
    
    28
      const cwd = Deno.cwd();
    29
      Deno.env.set("HOME", ".");
    30
      console.log(
    31
        `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}`
    32
      );
    33
    
    
    34
      const repo_name = await git_clone(cwd, repo_resource, use_individual_branch);
    35
      await move_to_git_branch(
    36
        workspace_id,
    37
        path_type,
    38
        path,
    39
        parent_path,
    40
        use_individual_branch,
    41
        group_by_folder
    42
      );
    43
    
    
    44
      const subfolder = repo_resource.folder ?? "";
    45
      const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
    46
      console.log(
    47
        `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
    48
      );
    49
    
    
    50
      await wmill_sync_pull(
    51
        path_type,
    52
        workspace_id,
    53
        path,
    54
        parent_path,
    55
        skip_secret
    56
      );
    57
    
    
    58
      await git_push(path, parent_path, commit_msg);
    59
    
    
    60
      console.log("Finished syncing");
    61
      Deno.chdir(`${cwd}`);
    62
    }
    63
    
    
    64
    async function git_clone(
    65
      cwd: string,
    66
      repo_resource: any,
    67
      use_individual_branch: boolean
    68
    ): Promise<string> {
    69
      // TODO: handle private SSH keys as well
    70
      let repo_url = repo_resource.url;
    71
      const subfolder = repo_resource.folder ?? "";
    72
      const branch = repo_resource.branch ?? "";
    73
      const repo_name = basename(repo_url, ".git");
    74
    
    
    75
      const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
    76
    
    
    77
      if (azureMatch) {
    78
        console.log(
    79
          "Requires Azure DevOps service account access token, requesting..."
    80
        );
    81
        const azureResource = await wmillclient.getResource(azureMatch.groups.url);
    82
    
    
    83
        const response = await fetch(
    84
          `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
    85
          {
    86
            method: "POST",
    87
            body: new URLSearchParams({
    88
              client_id: azureResource.azureClientId,
    89
              client_secret: azureResource.azureClientSecret,
    90
              grant_type: "client_credentials",
    91
              resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
    92
            }),
    93
          }
    94
        );
    95
    
    
    96
        const { access_token } = await response.json();
    97
    
    
    98
        repo_url = repo_url.replace(azureMatch[0], access_token);
    99
      }
    100
    
    
    101
      const args = ["clone", "--quiet", "--depth", "1"];
    102
      if (use_individual_branch) {
    103
        args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
    104
      }
    105
      if (subfolder !== "") {
    106
        args.push("--sparse");
    107
      }
    108
      if (branch !== "") {
    109
        args.push("--branch");
    110
        args.push(branch);
    111
      }
    112
      args.push(repo_url);
    113
      args.push(repo_name);
    114
      await sh_run(-1, "git", ...args);
    115
    
    
    116
      try {
    117
        Deno.chdir(`${cwd}/${repo_name}`);
    118
      } catch (err) {
    119
        console.log(
    120
          `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
    121
        );
    122
        throw err;
    123
      }
    124
      Deno.chdir(`${cwd}/${repo_name}`);
    125
      if (subfolder !== "") {
    126
        await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
    127
      }
    128
      try {
    129
        Deno.chdir(`${cwd}/${repo_name}/${subfolder}`);
    130
      } catch (err) {
    131
        console.log(
    132
          `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
    133
        );
    134
        throw err;
    135
      }
    136
    
    
    137
      return repo_name;
    138
    }
    139
    
    
    140
    async function move_to_git_branch(
    141
      workspace_id: string,
    142
      path_type:
    143
        | "script"
    144
        | "flow"
    145
        | "app"
    146
        | "folder"
    147
        | "resource"
    148
        | "variable"
    149
        | "resourcetype"
    150
        | "schedule"
    151
        | "user"
    152
        | "group",
    153
      path: string | undefined,
    154
      parent_path: string | undefined,
    155
      use_individual_branch: boolean,
    156
      group_by_folder: boolean
    157
    ) {
    158
      if (!use_individual_branch || path_type === "user" || path_type === "group") {
    159
        return;
    160
      }
    161
    
    
    162
      const branchName = group_by_folder
    163
        ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
    164
            ?.split("/")
    165
            .slice(0, 2)
    166
            .join("__")}`
    167
        : `wm_deploy/${workspace_id}/${path_type}/${(
    168
            path ?? parent_path
    169
          )?.replaceAll("/", "__")}`;
    170
    
    
    171
      try {
    172
        await sh_run(undefined, "git", "checkout", branchName);
    173
      } catch (err) {
    174
        console.log(
    175
          `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
    176
        );
    177
        try {
    178
          await sh_run(undefined, "git", "checkout", "-b", branchName);
    179
          await sh_run(
    180
            undefined,
    181
            "git",
    182
            "config",
    183
            "--add",
    184
            "--bool",
    185
            "push.autoSetupRemote",
    186
            "true"
    187
          );
    188
        } catch (err) {
    189
          console.log(
    190
            `Error checking out branch '${branchName}'. Error was:\n${err}`
    191
          );
    192
          throw err;
    193
        }
    194
      }
    195
      console.log(`Successfully switched to branch ${branchName}`);
    196
    }
    197
    
    
    198
    async function git_push(
    199
      path: string | undefined,
    200
      parent_path: string | undefined,
    201
      commit_msg: string
    202
    ) {
    203
      await sh_run(
    204
        undefined,
    205
        "git",
    206
        "config",
    207
        "user.email",
    208
        Deno.env.get("WM_EMAIL") ?? ""
    209
      );
    210
      await sh_run(
    211
        undefined,
    212
        "git",
    213
        "config",
    214
        "user.name",
    215
        Deno.env.get("WM_USERNAME") ?? ""
    216
      );
    217
      if (path !== undefined && path !== null && path !== "") {
    218
        try {
    219
          await sh_run(undefined, "git", "add", `${path}**`);
    220
        } catch (e) {
    221
          console.log(`Unable to stage files matching ${path}**, ${e}`);
    222
        }
    223
      }
    224
      if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    225
        try {
    226
          await sh_run(undefined, "git", "add", `${parent_path}**`);
    227
        } catch (e) {
    228
          console.log(`Unable to stage files matching ${parent_path}, ${e}`);
    229
        }
    230
      }
    231
      try {
    232
        await sh_run(undefined, "git", "diff", "--cached", "--quiet");
    233
      } catch {
    234
        // git diff returns exit-code = 1 when there's at least one staged changes
    235
        await sh_run(undefined, "git", "commit", "-m", commit_msg);
    236
        try {
    237
            await sh_run(undefined, "git", "push", "--porcelain");
    238
        } catch {
    239
            console.log("Could not push, trying to rebase first");
    240
            await sh_run(undefined, "git", "pull", "--rebase");
    241
            await sh_run(undefined, "git", "push", "--porcelain");
    242
        }
    243
        return;
    244
      }
    245
      console.log("No changes detected, nothing to commit. Returning...");
    246
    }
    247
    
    
    248
    async function sh_run(
    249
      secret_position: number | undefined,
    250
      cmd: string,
    251
      ...args: string[]
    252
    ) {
    253
      const nargs = secret_position != undefined ? args.slice() : args;
    254
      if (secret_position < 0) {
    255
        secret_position = nargs.length - 1 + secret_position;
    256
      }
    257
      if (secret_position != undefined) {
    258
        nargs[secret_position] = "***";
    259
      }
    260
      console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
    261
      const command = new Deno.Command(cmd, {
    262
        args: args,
    263
      });
    264
    
    
    265
      const { code, stdout, stderr } = await command.output();
    266
      if (stdout.length > 0) {
    267
        console.log(new TextDecoder().decode(stdout));
    268
      }
    269
      if (stderr.length > 0) {
    270
        console.log(new TextDecoder().decode(stderr));
    271
      }
    272
      if (code !== 0) {
    273
        const err = `SH command '${cmd} ${nargs.join(
    274
          " "
    275
        )}' returned with a non-zero status ${code}.`;
    276
        throw Error(err);
    277
      }
    278
      console.log("Command successfully executed");
    279
    }
    280
    
    
    281
    function regexFromPath(
    282
      path_type:
    283
        | "script"
    284
        | "flow"
    285
        | "app"
    286
        | "folder"
    287
        | "resource"
    288
        | "variable"
    289
        | "resourcetype"
    290
        | "schedule"
    291
        | "user"
    292
        | "group",
    293
      path: string
    294
    ) {
    295
      if (path_type == "flow") {
    296
        return `${path}.flow/*`;
    297
      } if (path_type == "app") {
    298
        return `${path}.app/*`;
    299
      } else if (path_type == "folder") {
    300
        return `${path}/folder.meta.*`;
    301
      } else if (path_type == "resourcetype") {
    302
        return `${path}.resource-type.*`;
    303
      } else if (path_type == "resource") {
    304
        return `${path}.resource.*`;
    305
      } else if (path_type == "variable") {
    306
        return `${path}.variable.*`;
    307
      } else if (path_type == "schedule") {
    308
        return `${path}.schedule.*`;
    309
      } else if (path_type == "user") {
    310
        return `${path}.user.*`;
    311
      } else if (path_type == "group") {
    312
        return `${path}.group.*`;
    313
      } else {
    314
        return `${path}.*`;
    315
      }
    316
    }
    317
    async function wmill_sync_pull(
    318
      path_type:
    319
        | "script"
    320
        | "flow"
    321
        | "app"
    322
        | "folder"
    323
        | "resource"
    324
        | "variable"
    325
        | "resourcetype"
    326
        | "schedule"
    327
        | "user"
    328
        | "group",
    329
      workspace_id: string,
    330
      path: string | undefined,
    331
      parent_path: string | undefined,
    332
      skip_secret: boolean
    333
    ) {
    334
      const includes = [];
    335
      if (path !== undefined && path !== null && path !== "") {
    336
        includes.push(regexFromPath(path_type, path));
    337
      }
    338
      if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    339
        includes.push(regexFromPath(path_type, parent_path));
    340
      }
    341
    
    
    342
      await wmill_run(
    343
        6,
    344
        "workspace",
    345
        "add",
    346
        workspace_id,
    347
        workspace_id,
    348
        Deno.env.get("BASE_INTERNAL_URL") + "/",
    349
        "--token",
    350
        Deno.env.get("WM_TOKEN") ?? ""
    351
      );
    352
      console.log("Pulling workspace into git repo");
    353
      await wmill_run(
    354
        3,
    355
        "sync",
    356
        "pull",
    357
        "--token",
    358
        Deno.env.get("WM_TOKEN") ?? "",
    359
        "--workspace",
    360
        workspace_id,
    361
        "--yes",
    362
        "--raw",
    363
        skip_secret ? "--skip-secrets" : "",
    364
        "--include-schedules",
    365
        "--include-users",
    366
        "--include-groups",
    367
        "--includes",
    368
        includes.join(",")
    369
      );
    370
    }
    371
    
    
    372
    async function wmill_run(secret_position: number, ...cmd: string[]) {
    373
      cmd = cmd.filter((elt) => elt !== "");
    374
      const cmd2 = cmd.slice();
    375
      cmd2[secret_position] = "***";
    376
      console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
    377
      await wmill.parse(cmd);
    378
      console.log("Command successfully executed");
    379
    }
    380
    
    
  • Submitted by rubenfiszel Bun
    Created 287 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
    
    
    463
      console.log("Pulling workspace into git repo");
    464
    
    
    465
      const args = [
    466
        "sync",
    467
        "pull",
    468
        "--token",
    469
        process.env["WM_TOKEN"] ?? "",
    470
        "--workspace",
    471
        workspace_id,
    472
        "--base-url",
    473
        process.env["BASE_URL"] + "/",
    474
        "--repository",
    475
        repo_url_resource_path,
    476
        "--yes",
    477
        skip_secret ? "--skip-secrets" : "",
    478
      ];
    479
    
    
    480
      if (path_type === "schedule" && !use_individual_branch) {
    481
        args.push("--include-schedules");
    482
      }
    483
    
    
    484
      if (path_type === "group" && !use_individual_branch) {
    485
        args.push("--include-groups");
    486
      }
    487
    
    
    488
      if (path_type === "user" && !use_individual_branch) {
    489
        args.push("--include-users");
    490
      }
    491
    
    
    492
      if (path_type.includes("trigger") && !use_individual_branch) {
    493
        args.push("--include-triggers");
    494
      }
    495
      // Only include settings when specifically deploying settings
    496
      if (path_type === "settings" && !use_individual_branch) {
    497
        args.push("--include-settings");
    498
      }
    499
    
    
    500
      // Only include key when specifically deploying keys
    501
      if (path_type === "key" && !use_individual_branch) {
    502
        args.push("--include-key");
    503
      }
    504
    
    
    505
      args.push("--extra-includes", includes.join(","));
    506
    
    
    507
      // If using individual branches, apply promotion settings from original branch
    508
      if (use_individual_branch && original_branch) {
    509
        console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
    510
        args.push("--promotion", original_branch);
    511
      }
    512
    
    
    513
      await wmill_run(3, ...args);
    514
    }
    515
    
    
    516
    async function wmill_run(secret_position: number, ...cmd: string[]) {
    517
      cmd = cmd.filter((elt) => elt !== "");
    518
      const cmd2 = cmd.slice();
    519
      cmd2[secret_position] = "***";
    520
      console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
    521
      await wmill.parse(cmd);
    522
      console.log("Command successfully executed");
    523
    }
    524
    
    
    525
    // Function to set up GPG signing
    526
    async function set_gpg_signing_secret(gpg_key: GpgKey) {
    527
      try {
    528
        console.log("Setting GPG private key for git commits");
    529
    
    
    530
        const formattedGpgContent = gpg_key.private_key.replace(
    531
          /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
    532
          (_: string, header: string, body: string, footer: string) =>
    533
            header +
    534
            "\n" +
    535
            "\n" +
    536
            body.replace(/ ([^\s])/g, "\n$1").trim() +
    537
            "\n" +
    538
            footer
    539
        );
    540
    
    
    541
        const gpg_path = `/tmp/gpg`;
    542
        await sh_run(undefined, "mkdir", "-p", gpg_path);
    543
        await sh_run(undefined, "chmod", "700", gpg_path);
    544
        process.env.GNUPGHOME = gpg_path;
    545
        // process.env.GIT_TRACE = 1;
    546
    
    
    547
        try {
    548
          await sh_run(
    549
            1,
    550
            "bash",
    551
            "-c",
    552
            `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
    553
          );
    554
        } catch (e) {
    555
          // Original error would contain sensitive data
    556
          throw new Error("Failed to import GPG key!");
    557
        }
    558
    
    
    559
        const listKeysOutput = await sh_run(
    560
          undefined,
    561
          "gpg",
    562
          "--list-secret-keys",
    563
          "--with-colons",
    564
          "--keyid-format=long"
    565
        );
    566
    
    
    567
        const keyInfoMatch = listKeysOutput.match(
    568
          /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
    569
        );
    570
    
    
    571
        if (!keyInfoMatch) {
    572
          throw new Error("Failed to extract GPG Key ID and Fingerprint");
    573
        }
    574
    
    
    575
        const keyId = keyInfoMatch[1];
    576
        gpgFingerprint = keyInfoMatch[2];
    577
    
    
    578
        if (gpg_key.passphrase) {
    579
          // This is adummy command to unlock the key
    580
          // with passphrase to load it into agent
    581
          await sh_run(
    582
            1,
    583
            "bash",
    584
            "-c",
    585
            `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
    586
          );
    587
        }
    588
    
    
    589
        // Configure Git to use the extracted key
    590
        await sh_run(undefined, "git", "config", "user.signingkey", keyId);
    591
        await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
    592
        console.log(`GPG signing configured with key ID: ${keyId} `);
    593
      } catch (e) {
    594
        console.error(`Failure while setting GPG key: ${e} `);
    595
        await delete_pgp_keys();
    596
      }
    597
    }
    598
    
    
    599
    async function delete_pgp_keys() {
    600
      console.log("deleting gpg keys");
    601
      if (gpgFingerprint) {
    602
        await sh_run(
    603
          undefined,
    604
          "gpg",
    605
          "--batch",
    606
          "--yes",
    607
          "--pinentry-mode",
    608
          "loopback",
    609
          "--delete-secret-key",
    610
          gpgFingerprint
    611
        );
    612
        await sh_run(
    613
          undefined,
    614
          "gpg",
    615
          "--batch",
    616
          "--yes",
    617
          "--delete-key",
    618
          "--pinentry-mode",
    619
          "loopback",
    620
          gpgFingerprint
    621
        );
    622
      }
    623
    }
    624
    
    
    625
    async function get_gh_app_token() {
    626
      const workspace = process.env["WM_WORKSPACE"];
    627
      const jobToken = process.env["WM_TOKEN"];
    628
    
    
    629
      const baseUrl =
    630
        process.env["BASE_INTERNAL_URL"] ??
    631
        process.env["BASE_URL"] ??
    632
        "http://localhost:8000";
    633
    
    
    634
      const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
    635
    
    
    636
      const response = await fetch(url, {
    637
        method: "POST",
    638
        headers: {
    639
          "Content-Type": "application/json",
    640
          Authorization: `Bearer ${jobToken}`,
    641
        },
    642
        body: JSON.stringify({
    643
          job_token: jobToken,
    644
        }),
    645
      });
    646
    
    
    647
      if (!response.ok) {
    648
        throw new Error(`Error: ${response.statusText}`);
    649
      }
    650
    
    
    651
      const data = await response.json();
    652
    
    
    653
      return data.token;
    654
    }
    655
    
    
    656
    function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
    657
      if (!gitHubUrl || !installationToken) {
    658
        throw new Error("Both GitHub URL and Installation Token are required.");
    659
      }
    660
    
    
    661
      try {
    662
        const url = new URL(gitHubUrl);
    663
    
    
    664
        // GitHub repository URL should be in the format: https://github.com/owner/repo.git
    665
        if (url.hostname !== "github.com") {
    666
          throw new Error(
    667
            "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
    668
          );
    669
        }
    670
    
    
    671
        // Convert URL to include the installation token
    672
        return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
    673
      } catch (e) {
    674
        const error = e as Error;
    675
        throw new Error(`Invalid URL: ${error.message}`);
    676
      }
    677
    }
    678
    
    
  • Submitted by pyranota275 Bun
    Created 260 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
    type SyncObject = {
    39
      path_type: PathType;
    40
      path: string | undefined;
    41
      parent_path: string | undefined;
    42
      commit_msg: string;
    43
    };
    44
    
    
    45
    let gpgFingerprint: string | undefined = undefined;
    46
    
    
    47
    export async function main(
    48
      items: SyncObject[],
    49
      // Compat, do not use in code, rely on `items` instead
    50
      path_type: PathType | undefined,
    51
      path: string | undefined,
    52
      parent_path: string | undefined,
    53
      commit_msg: string | undefined,
    54
      //
    55
      workspace_id: string,
    56
      repo_url_resource_path: string,
    57
      skip_secret: boolean = true,
    58
      use_individual_branch: boolean = false,
    59
      group_by_folder: boolean = false,
    60
      only_create_branch: boolean = false,
    61
      parent_workspace_id?: string,
    62
    ) {
    63
    
    
    64
      if (path_type !== undefined && commit_msg !== undefined) {
    65
        items = [{
    66
          path_type,
    67
          path,
    68
          parent_path,
    69
          commit_msg,
    70
        }];
    71
      }
    72
      await inner(items, workspace_id, repo_url_resource_path, skip_secret, use_individual_branch, group_by_folder, only_create_branch, parent_workspace_id);
    73
    }
    74
    
    
    75
    async function inner(
    76
      items: SyncObject[],
    77
      workspace_id: string,
    78
      repo_url_resource_path: string,
    79
      skip_secret: boolean = true,
    80
      use_individual_branch: boolean = false,
    81
      group_by_folder: boolean = false,
    82
      only_create_branch: boolean = false,
    83
      parent_workspace_id?: string,
    84
    ) {
    85
    
    
    86
      let safeDirectoryPath: string | undefined;
    87
      const repo_resource = await wmillclient.getResource(repo_url_resource_path);
    88
      const cwd = process.cwd();
    89
      process.env["HOME"] = ".";
    90
      if (!only_create_branch) {
    91
        for (const item of items) {
    92
          console.log(
    93
            `Syncing ${item.path_type} ${item.path ?? ""} with parent ${item.parent_path ?? ""}`
    94
          );
    95
        }
    96
      }
    97
    
    
    98
      if (repo_resource.is_github_app) {
    99
        const token = await get_gh_app_token();
    100
        const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
    101
        repo_resource.url = authRepoUrl;
    102
      }
    103
    
    
    104
      const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = await git_clone(cwd, repo_resource, use_individual_branch || workspace_id.startsWith(FORKED_WORKSPACE_PREFIX));
    105
      safeDirectoryPath = cloneSafeDirectoryPath;
    106
    
    
    107
    
    
    108
      // Since we don't modify the resource on the forked workspaces, we have to cosnider the case of
    109
      // a fork of a fork workspace. In that case, the original branch is not stored in the resource
    110
      // settings, but we need to infer it from the workspace id
    111
    
    
    112
      if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    113
        if (use_individual_branch) {
    114
          console.log("Cannot have `use_individual_branch` in a forked workspace, disabling option`");
    115
          use_individual_branch = false;
    116
        }
    117
        if (group_by_folder) {
    118
          console.log("Cannot have `group_by_folder` in a forked workspace, disabling option`");
    119
          group_by_folder = false;
    120
        }
    121
      }
    122
    
    
    123
      if (parent_workspace_id && parent_workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    124
        const parentBranch = get_fork_branch_name(parent_workspace_id, clonedBranchName);
    125
        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`);
    126
        await git_checkout_branch(
    127
          items,
    128
          parent_workspace_id,
    129
          use_individual_branch,
    130
          group_by_folder,
    131
          clonedBranchName
    132
        );
    133
      }
    134
    
    
    135
      await git_checkout_branch(
    136
        items,
    137
        workspace_id,
    138
        use_individual_branch,
    139
        group_by_folder,
    140
        clonedBranchName
    141
      );
    142
    
    
    143
    
    
    144
      const subfolder = repo_resource.folder ?? "";
    145
      const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
    146
      console.log(
    147
        `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
    148
      );
    149
    
    
    150
      // If we want to just create the branch, we can skip pulling the changes.
    151
      if (!only_create_branch) {
    152
        await wmill_sync_pull(
    153
          items,
    154
          workspace_id,
    155
          skip_secret,
    156
          repo_url_resource_path,
    157
          use_individual_branch,
    158
          repo_resource.branch
    159
        );
    160
      }
    161
      try {
    162
        await git_push(items, repo_resource, only_create_branch);
    163
      } catch (e) {
    164
        throw e;
    165
      } finally {
    166
        await delete_pgp_keys();
    167
        // Cleanup: remove safe.directory config
    168
        if (safeDirectoryPath) {
    169
          try {
    170
            await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
    171
          } catch (e) {
    172
            console.log(`Warning: Could not unset safe.directory config: ${e}`);
    173
          }
    174
        }
    175
      }
    176
      console.log("Finished syncing");
    177
      process.chdir(`${cwd}`);
    178
    }
    179
    
    
    180
    
    
    181
    function get_fork_branch_name(w_id: string, originalBranch: string): string {
    182
      if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    183
        return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
    184
      }
    185
      return w_id;
    186
    }
    187
    
    
    188
    async function git_clone(
    189
      cwd: string,
    190
      repo_resource: any,
    191
      no_single_branch: boolean,
    192
    ): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
    193
      // TODO: handle private SSH keys as well
    194
      let repo_url = repo_resource.url;
    195
      const subfolder = repo_resource.folder ?? "";
    196
      const branch = repo_resource.branch ?? "";
    197
      const repo_name = basename(repo_url, ".git");
    198
      const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
    199
      if (azureMatch) {
    200
        console.log(
    201
          "Requires Azure DevOps service account access token, requesting..."
    202
        );
    203
        const azureResource = await wmillclient.getResource(azureMatch.groups.url);
    204
        const response = await fetch(
    205
          `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
    206
          {
    207
            method: "POST",
    208
            body: new URLSearchParams({
    209
              client_id: azureResource.azureClientId,
    210
              client_secret: azureResource.azureClientSecret,
    211
              grant_type: "client_credentials",
    212
              resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
    213
            }),
    214
          }
    215
        );
    216
        const { access_token } = await response.json();
    217
        repo_url = repo_url.replace(azureMatch[0], access_token);
    218
      }
    219
      const args = ["clone", "--quiet", "--depth", "1"];
    220
      if (no_single_branch) {
    221
        args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
    222
      }
    223
      if (subfolder !== "") {
    224
        args.push("--sparse");
    225
      }
    226
      if (branch !== "") {
    227
        args.push("--branch");
    228
        args.push(branch);
    229
      }
    230
      args.push(repo_url);
    231
      args.push(repo_name);
    232
      await sh_run(-1, "git", ...args);
    233
      try {
    234
        process.chdir(`${cwd}/${repo_name}`);
    235
        const safeDirectoryPath = process.cwd();
    236
        // Add safe.directory to handle dubious ownership in cloned repo
    237
        try {
    238
          await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
    239
        } catch (e) {
    240
          console.log(`Warning: Could not add safe.directory config: ${e}`);
    241
        }
    242
    
    
    243
        if (subfolder !== "") {
    244
          await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
    245
          try {
    246
            process.chdir(`${cwd}/${repo_name}/${subfolder}`);
    247
          } catch (err) {
    248
            console.log(
    249
              `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
    250
            );
    251
            throw err;
    252
          }
    253
        }
    254
        const clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
    255
        return { repo_name, safeDirectoryPath, clonedBranchName };
    256
    
    
    257
      } catch (err) {
    258
        console.log(
    259
          `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
    260
        );
    261
        throw err;
    262
      }
    263
    }
    264
    async function git_checkout_branch(
    265
      items: SyncObject[],
    266
      workspace_id: string,
    267
      use_individual_branch: boolean,
    268
      group_by_folder: boolean,
    269
      originalBranchName: string
    270
    ) {
    271
      let branchName;
    272
      if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    273
        branchName = get_fork_branch_name(workspace_id, originalBranchName);
    274
      } else {
    275
    
    
    276
        if (!use_individual_branch
    277
          // If individual branch is true, we can assume items is of length 1, as debouncing is disabled for jobs with this flag
    278
          || items[0].path_type === "user" || items[0].path_type === "group") {
    279
          return;
    280
        }
    281
    
    
    282
        // as mentioned above, it is safe to assume that items.len is 1
    283
        const [path, parent_path] = [items[0].path, items[0].parent_path];
    284
        branchName = group_by_folder
    285
          ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
    286
            ?.split("/")
    287
            .slice(0, 2)
    288
            .join("__")}`
    289
          : `wm_deploy/${workspace_id}/${items[0].path_type}/${(
    290
            path ?? parent_path
    291
          )?.replaceAll("/", "__")}`;
    292
      }
    293
    
    
    294
      try {
    295
        await sh_run(undefined, "git", "checkout", branchName);
    296
      } catch (err) {
    297
        console.log(
    298
          `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
    299
        );
    300
        try {
    301
          await sh_run(undefined, "git", "checkout", "-b", branchName);
    302
          await sh_run(
    303
            undefined,
    304
            "git",
    305
            "config",
    306
            "--add",
    307
            "--bool",
    308
            "push.autoSetupRemote",
    309
            "true"
    310
          );
    311
        } catch (err) {
    312
          console.log(
    313
            `Error checking out branch '${branchName}'. Error was:\n${err}`
    314
          );
    315
          throw err;
    316
        }
    317
      }
    318
      console.log(`Successfully switched to branch ${branchName}`);
    319
    }
    320
    
    
    321
    function composeCommitHeader(items: SyncObject[]): string {
    322
      // Count occurrences of each path_type
    323
      const typeCounts = new Map<PathType, number>();
    324
      for (const item of items) {
    325
        typeCounts.set(item.path_type, (typeCounts.get(item.path_type) ?? 0) + 1);
    326
      }
    327
    
    
    328
      // Sort by count descending to get the top 2
    329
      const sortedTypes = Array.from(typeCounts.entries()).sort((a, b) => b[1] - a[1]);
    330
    
    
    331
      const parts: string[] = [];
    332
      let othersCount = 0;
    333
    
    
    334
      for (let i = 0; i < sortedTypes.length; i++) {
    335
        const [pathType, count] = sortedTypes[i];
    336
        if (i < 3) {
    337
          // Pluralize the path type if count > 1
    338
          const label = count > 1 ? `${pathType}s` : pathType;
    339
    
    
    340
          if (i == 2 && sortedTypes.length == 3) {
    341
            parts.push(`and ${count} ${label}`);
    342
          } else {
    343
            parts.push(`${count} ${label}`);
    344
          }
    345
        } else {
    346
          othersCount += count;
    347
        }
    348
      }
    349
    
    
    350
      let header = `[WM] Deployed ${parts.join(", ")}`;
    351
      if (othersCount > 0) {
    352
        header += ` and ${othersCount} other object${othersCount > 1 ? "s" : ""}`;
    353
      }
    354
    
    
    355
      return header;
    356
    }
    357
    
    
    358
    async function git_push(
    359
      items: SyncObject[],
    360
      repo_resource: any,
    361
      only_create_branch: boolean,
    362
    ) {
    363
      let user_email = process.env["WM_EMAIL"] ?? "";
    364
      let user_name = process.env["WM_USERNAME"] ?? "";
    365
    
    
    366
      if (repo_resource.gpg_key) {
    367
        await set_gpg_signing_secret(repo_resource.gpg_key);
    368
        // Configure git with GPG key email for signing
    369
        await sh_run(
    370
          undefined,
    371
          "git",
    372
          "config",
    373
          "user.email",
    374
          repo_resource.gpg_key.email
    375
        );
    376
        await sh_run(undefined, "git", "config", "user.name", user_name);
    377
      } else {
    378
        await sh_run(undefined, "git", "config", "user.email", user_email);
    379
        await sh_run(undefined, "git", "config", "user.name", user_name);
    380
      }
    381
      if (only_create_branch) {
    382
        await sh_run(undefined, "git", "push", "--porcelain");
    383
      }
    384
    
    
    385
      let commit_description: string[] = [];
    386
      for (const { path, parent_path, commit_msg } of items) {
    387
        if (path !== undefined && path !== null && path !== "") {
    388
          try {
    389
            await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
    390
          } catch (e) {
    391
            console.log(`Unable to stage files matching ${path}**, ${e}`);
    392
          }
    393
        }
    394
        if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    395
          try {
    396
            await sh_run(
    397
              undefined,
    398
              "git",
    399
              "add",
    400
              "wmill-lock.yaml",
    401
              `${parent_path}**`
    402
            );
    403
          } catch (e) {
    404
            console.log(`Unable to stage files matching ${parent_path}, ${e}`);
    405
          }
    406
        }
    407
    
    
    408
        commit_description.push(commit_msg);
    409
      }
    410
    
    
    411
      try {
    412
        await sh_run(undefined, "git", "diff", "--cached", "--quiet");
    413
      } catch {
    414
        // git diff returns exit-code = 1 when there's at least one staged changes
    415
        const commitArgs = ["git", "commit"];
    416
    
    
    417
        // Always use --author to set consistent authorship
    418
        commitArgs.push("--author", `"${user_name} <${user_email}>"`);
    419
    
    
    420
        const [header, description] = (commit_description.length == 1)
    421
          ? [commit_description[0], ""]
    422
          : [composeCommitHeader(items), commit_description.join("\n")];
    423
    
    
    424
        commitArgs.push(
    425
          "-m",
    426
          `"${header == undefined || header == "" ? "no commit msg" : header}"`,
    427
          "-m",
    428
          `"${description}"`
    429
        );
    430
    
    
    431
        await sh_run(undefined, ...commitArgs);
    432
        try {
    433
          await sh_run(undefined, "git", "push", "--porcelain");
    434
        } catch (e) {
    435
          console.log(`Could not push, trying to rebase first: ${e}`);
    436
          await sh_run(undefined, "git", "pull", "--rebase");
    437
          await sh_run(undefined, "git", "push", "--porcelain");
    438
        }
    439
        return;
    440
      }
    441
    
    
    442
      console.log("No changes detected, nothing to commit. Returning...");
    443
    }
    444
    async function sh_run(
    445
      secret_position: number | undefined,
    446
      cmd: string,
    447
      ...args: string[]
    448
    ) {
    449
      const nargs = secret_position != undefined ? args.slice() : args;
    450
      if (secret_position && secret_position < 0) {
    451
        secret_position = nargs.length - 1 + secret_position;
    452
      }
    453
      let secret: string | undefined = undefined;
    454
      if (secret_position != undefined) {
    455
        nargs[secret_position] = "***";
    456
        secret = args[secret_position];
    457
      }
    458
    
    
    459
      console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
    460
      const command = exec(`${cmd} ${args.join(" ")}`);
    461
      // new Deno.Command(cmd, {
    462
      //   args: args,
    463
      // });
    464
      try {
    465
        const { stdout, stderr } = await command;
    466
        if (stdout.length > 0) {
    467
          console.log(stdout);
    468
        }
    469
        if (stderr.length > 0) {
    470
          console.log(stderr);
    471
        }
    472
        console.log("Command successfully executed");
    473
        return stdout;
    474
      } catch (error) {
    475
        let errorString = error.toString();
    476
        if (secret) {
    477
          errorString = errorString.replace(secret, "***");
    478
        }
    479
        const err = `SH command '${cmd} ${nargs.join(
    480
          " "
    481
        )}' returned with error ${errorString}`;
    482
        throw Error(err);
    483
      }
    484
    }
    485
    
    
    486
    function regexFromPath(path_type: PathType, path: string) {
    487
      if (path_type == "flow") {
    488
        return `${path}.flow/*`;
    489
      }
    490
      if (path_type == "app") {
    491
        return `${path}.app/*`;
    492
      } else if (path_type == "folder") {
    493
        return `${path}/folder.meta.*`;
    494
      } else if (path_type == "resourcetype") {
    495
        return `${path}.resource-type.*`;
    496
      } else if (path_type == "resource") {
    497
        return `${path}.resource.*`;
    498
      } else if (path_type == "variable") {
    499
        return `${path}.variable.*`;
    500
      } else if (path_type == "schedule") {
    501
        return `${path}.schedule.*`;
    502
      } else if (path_type == "user") {
    503
        return `${path}.user.*`;
    504
      } else if (path_type == "group") {
    505
        return `${path}.group.*`;
    506
      } else if (path_type == "httptrigger") {
    507
        return `${path}.http_trigger.*`;
    508
      } else if (path_type == "websockettrigger") {
    509
        return `${path}.websocket_trigger.*`;
    510
      } else if (path_type == "kafkatrigger") {
    511
        return `${path}.kafka_trigger.*`;
    512
      } else if (path_type == "natstrigger") {
    513
        return `${path}.nats_trigger.*`;
    514
      } else if (path_type == "postgrestrigger") {
    515
        return `${path}.postgres_trigger.*`;
    516
      } else if (path_type == "mqtttrigger") {
    517
        return `${path}.mqtt_trigger.*`;
    518
      } else if (path_type == "sqstrigger") {
    519
        return `${path}.sqs_trigger.*`;
    520
      } else if (path_type == "gcptrigger") {
    521
        return `${path}.gcp_trigger.*`;
    522
      } else if (path_type == "emailtrigger") {
    523
        return `${path}.email_trigger.*`;
    524
      } else {
    525
        return `${path}.*`;
    526
      }
    527
    }
    528
    
    
    529
    async function wmill_sync_pull(
    530
      items: SyncObject[],
    531
      workspace_id: string,
    532
      skip_secret: boolean,
    533
      repo_url_resource_path: string,
    534
      use_individual_branch: boolean,
    535
      original_branch?: string
    536
    ) {
    537
      const includes = [];
    538
      for (const item of items) {
    539
        const { path_type, path, parent_path } = item;
    540
        if (path !== undefined && path !== null && path !== "") {
    541
          includes.push(regexFromPath(path_type, path));
    542
        }
    543
        if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    544
          includes.push(regexFromPath(path_type, parent_path));
    545
        }
    546
      }
    547
    
    
    548
      console.log("Pulling workspace into git repo");
    549
    
    
    550
      const args = [
    551
        "sync",
    552
        "pull",
    553
        "--token",
    554
        process.env["WM_TOKEN"] ?? "",
    555
        "--workspace",
    556
        workspace_id,
    557
        "--base-url",
    558
        process.env["BASE_URL"] + "/",
    559
        "--repository",
    560
        repo_url_resource_path,
    561
        "--yes",
    562
        skip_secret ? "--skip-secrets" : "",
    563
      ];
    564
    
    
    565
      if (items.some(item => item.path_type === "schedule") && !use_individual_branch) {
    566
        args.push("--include-schedules");
    567
      }
    568
    
    
    569
      if (items.some(item => item.path_type === "group") && !use_individual_branch) {
    570
        args.push("--include-groups");
    571
      }
    572
    
    
    573
      if (items.some(item => item.path_type === "user") && !use_individual_branch) {
    574
        args.push("--include-users");
    575
      }
    576
    
    
    577
      if (items.some(item => item.path_type.includes("trigger")) && !use_individual_branch) {
    578
        args.push("--include-triggers");
    579
      }
    580
      // Only include settings when specifically deploying settings
    581
      if (items.some(item => item.path_type === "settings") && !use_individual_branch) {
    582
        args.push("--include-settings");
    583
      }
    584
    
    
    585
      // Only include key when specifically deploying keys
    586
      if (items.some(item => item.path_type === "key") && !use_individual_branch) {
    587
        args.push("--include-key");
    588
      }
    589
    
    
    590
      args.push("--extra-includes", includes.join(","));
    591
    
    
    592
      // If using individual branches, apply promotion settings from original branch
    593
      if (use_individual_branch && original_branch) {
    594
        console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
    595
        args.push("--promotion", original_branch);
    596
      }
    597
    
    
    598
      await wmill_run(3, ...args);
    599
    }
    600
    
    
    601
    async function wmill_run(secret_position: number, ...cmd: string[]) {
    602
      cmd = cmd.filter((elt) => elt !== "");
    603
      const cmd2 = cmd.slice();
    604
      cmd2[secret_position] = "***";
    605
      console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
    606
      await wmill.parse(cmd);
    607
      console.log("Command successfully executed");
    608
    }
    609
    
    
    610
    // Function to set up GPG signing
    611
    async function set_gpg_signing_secret(gpg_key: GpgKey) {
    612
      try {
    613
        console.log("Setting GPG private key for git commits");
    614
    
    
    615
        const formattedGpgContent = gpg_key.private_key.replace(
    616
          /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
    617
          (_: string, header: string, body: string, footer: string) =>
    618
            header +
    619
            "\n" +
    620
            "\n" +
    621
            body.replace(/ ([^\s])/g, "\n$1").trim() +
    622
            "\n" +
    623
            footer
    624
        );
    625
    
    
    626
        const gpg_path = `/tmp/gpg`;
    627
        await sh_run(undefined, "mkdir", "-p", gpg_path);
    628
        await sh_run(undefined, "chmod", "700", gpg_path);
    629
        process.env.GNUPGHOME = gpg_path;
    630
        // process.env.GIT_TRACE = 1;
    631
    
    
    632
        try {
    633
          await sh_run(
    634
            1,
    635
            "bash",
    636
            "-c",
    637
            `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
    638
          );
    639
        } catch (e) {
    640
          // Original error would contain sensitive data
    641
          throw new Error("Failed to import GPG key!");
    642
        }
    643
    
    
    644
        const listKeysOutput = await sh_run(
    645
          undefined,
    646
          "gpg",
    647
          "--list-secret-keys",
    648
          "--with-colons",
    649
          "--keyid-format=long"
    650
        );
    651
    
    
    652
        const keyInfoMatch = listKeysOutput.match(
    653
          /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
    654
        );
    655
    
    
    656
        if (!keyInfoMatch) {
    657
          throw new Error("Failed to extract GPG Key ID and Fingerprint");
    658
        }
    659
    
    
    660
        const keyId = keyInfoMatch[1];
    661
        gpgFingerprint = keyInfoMatch[2];
    662
    
    
    663
        if (gpg_key.passphrase) {
    664
          // This is adummy command to unlock the key
    665
          // with passphrase to load it into agent
    666
          await sh_run(
    667
            1,
    668
            "bash",
    669
            "-c",
    670
            `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
    671
          );
    672
        }
    673
    
    
    674
        // Configure Git to use the extracted key
    675
        await sh_run(undefined, "git", "config", "user.signingkey", keyId);
    676
        await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
    677
        console.log(`GPG signing configured with key ID: ${keyId} `);
    678
      } catch (e) {
    679
        console.error(`Failure while setting GPG key: ${e} `);
    680
        await delete_pgp_keys();
    681
      }
    682
    }
    683
    
    
    684
    async function delete_pgp_keys() {
    685
      console.log("deleting gpg keys");
    686
      if (gpgFingerprint) {
    687
        await sh_run(
    688
          undefined,
    689
          "gpg",
    690
          "--batch",
    691
          "--yes",
    692
          "--pinentry-mode",
    693
          "loopback",
    694
          "--delete-secret-key",
    695
          gpgFingerprint
    696
        );
    697
        await sh_run(
    698
          undefined,
    699
          "gpg",
    700
          "--batch",
    701
          "--yes",
    702
          "--delete-key",
    703
          "--pinentry-mode",
    704
          "loopback",
    705
          gpgFingerprint
    706
        );
    707
      }
    708
    }
    709
    
    
    710
    async function get_gh_app_token() {
    711
      const workspace = process.env["WM_WORKSPACE"];
    712
      const jobToken = process.env["WM_TOKEN"];
    713
    
    
    714
      const baseUrl =
    715
        process.env["BASE_INTERNAL_URL"] ??
    716
        process.env["BASE_URL"] ??
    717
        "http://localhost:8000";
    718
    
    
    719
      const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
    720
    
    
    721
      const response = await fetch(url, {
    722
        method: "POST",
    723
        headers: {
    724
          "Content-Type": "application/json",
    725
          Authorization: `Bearer ${jobToken}`,
    726
        },
    727
        body: JSON.stringify({
    728
          job_token: jobToken,
    729
        }),
    730
      });
    731
    
    
    732
      if (!response.ok) {
    733
        throw new Error(`Error: ${response.statusText}`);
    734
      }
    735
    
    
    736
      const data = await response.json();
    737
    
    
    738
      return data.token;
    739
    }
    740
    
    
    741
    function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
    742
      if (!gitHubUrl || !installationToken) {
    743
        throw new Error("Both GitHub URL and Installation Token are required.");
    744
      }
    745
    
    
    746
      try {
    747
        const url = new URL(gitHubUrl);
    748
    
    
    749
        // GitHub repository URL should be in the format: https://github.com/owner/repo.git
    750
        if (url.hostname !== "github.com") {
    751
          throw new Error(
    752
            "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
    753
          );
    754
        }
    755
    
    
    756
        // Convert URL to include the installation token
    757
        return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
    758
      } catch (e) {
    759
        const error = e as Error;
    760
        throw new Error(`Invalid URL: ${error.message}`);
    761
      }
    762
    }
  • Submitted by pyranota275 Bun
    Created 261 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
    type SyncObject = {
    39
      path_type: PathType;
    40
      path: string | undefined;
    41
      parent_path: string | undefined;
    42
      commit_msg: string;
    43
    };
    44
    
    
    45
    let gpgFingerprint: string | undefined = undefined;
    46
    
    
    47
    export async function main(
    48
      items: SyncObject[],
    49
      // Compat, do not use in code, rely on `items` instead
    50
      path_type: PathType | undefined,
    51
      path: string | undefined,
    52
      parent_path: string | undefined,
    53
      commit_msg: string | undefined,
    54
      //
    55
      workspace_id: string,
    56
      repo_url_resource_path: string,
    57
      skip_secret: boolean = true,
    58
      use_individual_branch: boolean = false,
    59
      group_by_folder: boolean = false,
    60
      only_create_branch: boolean = false,
    61
      parent_workspace_id?: string,
    62
    ) {
    63
    
    
    64
      if (path_type !== undefined && commit_msg !== undefined) {
    65
        items = [{
    66
          path_type,
    67
          path,
    68
          parent_path,
    69
          commit_msg,
    70
        }];
    71
      }
    72
      await inner(items, workspace_id, repo_url_resource_path, skip_secret, use_individual_branch, group_by_folder, only_create_branch, parent_workspace_id);
    73
    }
    74
    
    
    75
    async function inner(
    76
      items: SyncObject[],
    77
      workspace_id: string,
    78
      repo_url_resource_path: string,
    79
      skip_secret: boolean = true,
    80
      use_individual_branch: boolean = false,
    81
      group_by_folder: boolean = false,
    82
      only_create_branch: boolean = false,
    83
      parent_workspace_id?: string,
    84
    ) {
    85
    
    
    86
      let safeDirectoryPath: string | undefined;
    87
      const repo_resource = await wmillclient.getResource(repo_url_resource_path);
    88
      const cwd = process.cwd();
    89
      process.env["HOME"] = ".";
    90
      if (!only_create_branch) {
    91
        for (const item of items) {
    92
          console.log(
    93
            `Syncing ${item.path_type} ${item.path ?? ""} with parent ${item.parent_path ?? ""}`
    94
          );
    95
        }
    96
      }
    97
    
    
    98
      if (repo_resource.is_github_app) {
    99
        const token = await get_gh_app_token();
    100
        const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
    101
        repo_resource.url = authRepoUrl;
    102
      }
    103
    
    
    104
      const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = await git_clone(cwd, repo_resource, use_individual_branch || workspace_id.startsWith(FORKED_WORKSPACE_PREFIX));
    105
      safeDirectoryPath = cloneSafeDirectoryPath;
    106
    
    
    107
    
    
    108
      // Since we don't modify the resource on the forked workspaces, we have to cosnider the case of
    109
      // a fork of a fork workspace. In that case, the original branch is not stored in the resource
    110
      // settings, but we need to infer it from the workspace id
    111
    
    
    112
      if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    113
        if (use_individual_branch) {
    114
          console.log("Cannot have `use_individual_branch` in a forked workspace, disabling option`");
    115
          use_individual_branch = false;
    116
        }
    117
        if (group_by_folder) {
    118
          console.log("Cannot have `group_by_folder` in a forked workspace, disabling option`");
    119
          group_by_folder = false;
    120
        }
    121
      }
    122
    
    
    123
      if (parent_workspace_id && parent_workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    124
        const parentBranch = get_fork_branch_name(parent_workspace_id, clonedBranchName);
    125
        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`);
    126
        await git_checkout_branch(
    127
          items,
    128
          parent_workspace_id,
    129
          use_individual_branch,
    130
          group_by_folder,
    131
          clonedBranchName
    132
        );
    133
      }
    134
    
    
    135
      await git_checkout_branch(
    136
        items,
    137
        workspace_id,
    138
        use_individual_branch,
    139
        group_by_folder,
    140
        clonedBranchName
    141
      );
    142
    
    
    143
    
    
    144
      const subfolder = repo_resource.folder ?? "";
    145
      const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
    146
      console.log(
    147
        `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
    148
      );
    149
    
    
    150
      // If we want to just create the branch, we can skip pulling the changes.
    151
      if (!only_create_branch) {
    152
        await wmill_sync_pull(
    153
          items,
    154
          workspace_id,
    155
          skip_secret,
    156
          repo_url_resource_path,
    157
          use_individual_branch,
    158
          repo_resource.branch
    159
        );
    160
      }
    161
      try {
    162
        await git_push(items, repo_resource, only_create_branch);
    163
      } catch (e) {
    164
        throw e;
    165
      } finally {
    166
        await delete_pgp_keys();
    167
        // Cleanup: remove safe.directory config
    168
        if (safeDirectoryPath) {
    169
          try {
    170
            await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
    171
          } catch (e) {
    172
            console.log(`Warning: Could not unset safe.directory config: ${e}`);
    173
          }
    174
        }
    175
      }
    176
      console.log("Finished syncing");
    177
      process.chdir(`${cwd}`);
    178
    }
    179
    
    
    180
    
    
    181
    function get_fork_branch_name(w_id: string, originalBranch: string): string {
    182
      if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    183
        return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
    184
      }
    185
      return w_id;
    186
    }
    187
    
    
    188
    async function git_clone(
    189
      cwd: string,
    190
      repo_resource: any,
    191
      no_single_branch: boolean,
    192
    ): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
    193
      // TODO: handle private SSH keys as well
    194
      let repo_url = repo_resource.url;
    195
      const subfolder = repo_resource.folder ?? "";
    196
      const branch = repo_resource.branch ?? "";
    197
      const repo_name = basename(repo_url, ".git");
    198
      const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
    199
      if (azureMatch) {
    200
        console.log(
    201
          "Requires Azure DevOps service account access token, requesting..."
    202
        );
    203
        const azureResource = await wmillclient.getResource(azureMatch.groups.url);
    204
        const response = await fetch(
    205
          `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
    206
          {
    207
            method: "POST",
    208
            body: new URLSearchParams({
    209
              client_id: azureResource.azureClientId,
    210
              client_secret: azureResource.azureClientSecret,
    211
              grant_type: "client_credentials",
    212
              resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
    213
            }),
    214
          }
    215
        );
    216
        const { access_token } = await response.json();
    217
        repo_url = repo_url.replace(azureMatch[0], access_token);
    218
      }
    219
      const args = ["clone", "--quiet", "--depth", "1"];
    220
      if (no_single_branch) {
    221
        args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
    222
      }
    223
      if (subfolder !== "") {
    224
        args.push("--sparse");
    225
      }
    226
      if (branch !== "") {
    227
        args.push("--branch");
    228
        args.push(branch);
    229
      }
    230
      args.push(repo_url);
    231
      args.push(repo_name);
    232
      await sh_run(-1, "git", ...args);
    233
      try {
    234
        process.chdir(`${cwd}/${repo_name}`);
    235
        const safeDirectoryPath = process.cwd();
    236
        // Add safe.directory to handle dubious ownership in cloned repo
    237
        try {
    238
          await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
    239
        } catch (e) {
    240
          console.log(`Warning: Could not add safe.directory config: ${e}`);
    241
        }
    242
    
    
    243
        if (subfolder !== "") {
    244
          await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
    245
          try {
    246
            process.chdir(`${cwd}/${repo_name}/${subfolder}`);
    247
          } catch (err) {
    248
            console.log(
    249
              `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
    250
            );
    251
            throw err;
    252
          }
    253
        }
    254
        const clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
    255
        return { repo_name, safeDirectoryPath, clonedBranchName };
    256
    
    
    257
      } catch (err) {
    258
        console.log(
    259
          `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
    260
        );
    261
        throw err;
    262
      }
    263
    }
    264
    async function git_checkout_branch(
    265
      items: SyncObject[],
    266
      workspace_id: string,
    267
      use_individual_branch: boolean,
    268
      group_by_folder: boolean,
    269
      originalBranchName: string
    270
    ) {
    271
      let branchName;
    272
      if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
    273
        branchName = get_fork_branch_name(workspace_id, originalBranchName);
    274
      } else {
    275
    
    
    276
        if (!use_individual_branch
    277
          // If individual branch is true, we can assume items is of length 1, as debouncing is disabled for jobs with this flag
    278
          || items[0].path_type === "user" || items[0].path_type === "group") {
    279
          return;
    280
        }
    281
    
    
    282
        // as mentioned above, it is safe to assume that items.len is 1
    283
        const [path, parent_path] = [items[0].path, items[0].parent_path];
    284
        branchName = group_by_folder
    285
          ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
    286
            ?.split("/")
    287
            .slice(0, 2)
    288
            .join("__")}`
    289
          : `wm_deploy/${workspace_id}/${items[0].path_type}/${(
    290
            path ?? parent_path
    291
          )?.replaceAll("/", "__")}`;
    292
      }
    293
    
    
    294
      try {
    295
        await sh_run(undefined, "git", "checkout", branchName);
    296
      } catch (err) {
    297
        console.log(
    298
          `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
    299
        );
    300
        try {
    301
          await sh_run(undefined, "git", "checkout", "-b", branchName);
    302
          await sh_run(
    303
            undefined,
    304
            "git",
    305
            "config",
    306
            "--add",
    307
            "--bool",
    308
            "push.autoSetupRemote",
    309
            "true"
    310
          );
    311
        } catch (err) {
    312
          console.log(
    313
            `Error checking out branch '${branchName}'. Error was:\n${err}`
    314
          );
    315
          throw err;
    316
        }
    317
      }
    318
      console.log(`Successfully switched to branch ${branchName}`);
    319
    }
    320
    
    
    321
    async function git_push(
    322
      items: SyncObject[],
    323
      repo_resource: any,
    324
      only_create_branch: boolean,
    325
    ) {
    326
      let user_email = process.env["WM_EMAIL"] ?? "";
    327
      let user_name = process.env["WM_USERNAME"] ?? "";
    328
    
    
    329
      if (repo_resource.gpg_key) {
    330
        await set_gpg_signing_secret(repo_resource.gpg_key);
    331
        // Configure git with GPG key email for signing
    332
        await sh_run(
    333
          undefined,
    334
          "git",
    335
          "config",
    336
          "user.email",
    337
          repo_resource.gpg_key.email
    338
        );
    339
        await sh_run(undefined, "git", "config", "user.name", user_name);
    340
      } else {
    341
        await sh_run(undefined, "git", "config", "user.email", user_email);
    342
        await sh_run(undefined, "git", "config", "user.name", user_name);
    343
      }
    344
      if (only_create_branch) {
    345
        await sh_run(undefined, "git", "push", "--porcelain");
    346
      }
    347
    
    
    348
      let commit_description: string[] = [];
    349
      for (const { path, parent_path, commit_msg } of items) {
    350
        if (path !== undefined && path !== null && path !== "") {
    351
          try {
    352
            await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
    353
          } catch (e) {
    354
            console.log(`Unable to stage files matching ${path}**, ${e}`);
    355
          }
    356
        }
    357
        if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    358
          try {
    359
            await sh_run(
    360
              undefined,
    361
              "git",
    362
              "add",
    363
              "wmill-lock.yaml",
    364
              `${parent_path}**`
    365
            );
    366
          } catch (e) {
    367
            console.log(`Unable to stage files matching ${parent_path}, ${e}`);
    368
          }
    369
        }
    370
    
    
    371
        commit_description.push(commit_msg);
    372
      }
    373
    
    
    374
      try {
    375
        await sh_run(undefined, "git", "diff", "--cached", "--quiet");
    376
      } catch {
    377
        // git diff returns exit-code = 1 when there's at least one staged changes
    378
        const commitArgs = ["git", "commit"];
    379
    
    
    380
        // Always use --author to set consistent authorship
    381
        commitArgs.push("--author", `"${user_name} <${user_email}>"`);
    382
    
    
    383
        const [header, description] = (commit_description.length == 1) ? [commit_description[0], ""] : [`[WM]: Deployed ${commit_description.length} objects`, commit_description.join("\n")];
    384
    
    
    385
        commitArgs.push(
    386
          "-m",
    387
          `"${header == undefined || header == "" ? "no commit msg" : header}"`,
    388
          "-m",
    389
          `"${description}"`
    390
        );
    391
    
    
    392
        await sh_run(undefined, ...commitArgs);
    393
        try {
    394
          await sh_run(undefined, "git", "push", "--porcelain");
    395
        } catch (e) {
    396
          console.log(`Could not push, trying to rebase first: ${e}`);
    397
          await sh_run(undefined, "git", "pull", "--rebase");
    398
          await sh_run(undefined, "git", "push", "--porcelain");
    399
        }
    400
        return;
    401
      }
    402
    
    
    403
      console.log("No changes detected, nothing to commit. Returning...");
    404
    }
    405
    async function sh_run(
    406
      secret_position: number | undefined,
    407
      cmd: string,
    408
      ...args: string[]
    409
    ) {
    410
      const nargs = secret_position != undefined ? args.slice() : args;
    411
      if (secret_position && secret_position < 0) {
    412
        secret_position = nargs.length - 1 + secret_position;
    413
      }
    414
      let secret: string | undefined = undefined;
    415
      if (secret_position != undefined) {
    416
        nargs[secret_position] = "***";
    417
        secret = args[secret_position];
    418
      }
    419
    
    
    420
      console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
    421
      const command = exec(`${cmd} ${args.join(" ")}`);
    422
      // new Deno.Command(cmd, {
    423
      //   args: args,
    424
      // });
    425
      try {
    426
        const { stdout, stderr } = await command;
    427
        if (stdout.length > 0) {
    428
          console.log(stdout);
    429
        }
    430
        if (stderr.length > 0) {
    431
          console.log(stderr);
    432
        }
    433
        console.log("Command successfully executed");
    434
        return stdout;
    435
      } catch (error) {
    436
        let errorString = error.toString();
    437
        if (secret) {
    438
          errorString = errorString.replace(secret, "***");
    439
        }
    440
        const err = `SH command '${cmd} ${nargs.join(
    441
          " "
    442
        )}' returned with error ${errorString}`;
    443
        throw Error(err);
    444
      }
    445
    }
    446
    
    
    447
    function regexFromPath(path_type: PathType, path: string) {
    448
      if (path_type == "flow") {
    449
        return `${path}.flow/*`;
    450
      }
    451
      if (path_type == "app") {
    452
        return `${path}.app/*`;
    453
      } else if (path_type == "folder") {
    454
        return `${path}/folder.meta.*`;
    455
      } else if (path_type == "resourcetype") {
    456
        return `${path}.resource-type.*`;
    457
      } else if (path_type == "resource") {
    458
        return `${path}.resource.*`;
    459
      } else if (path_type == "variable") {
    460
        return `${path}.variable.*`;
    461
      } else if (path_type == "schedule") {
    462
        return `${path}.schedule.*`;
    463
      } else if (path_type == "user") {
    464
        return `${path}.user.*`;
    465
      } else if (path_type == "group") {
    466
        return `${path}.group.*`;
    467
      } else if (path_type == "httptrigger") {
    468
        return `${path}.http_trigger.*`;
    469
      } else if (path_type == "websockettrigger") {
    470
        return `${path}.websocket_trigger.*`;
    471
      } else if (path_type == "kafkatrigger") {
    472
        return `${path}.kafka_trigger.*`;
    473
      } else if (path_type == "natstrigger") {
    474
        return `${path}.nats_trigger.*`;
    475
      } else if (path_type == "postgrestrigger") {
    476
        return `${path}.postgres_trigger.*`;
    477
      } else if (path_type == "mqtttrigger") {
    478
        return `${path}.mqtt_trigger.*`;
    479
      } else if (path_type == "sqstrigger") {
    480
        return `${path}.sqs_trigger.*`;
    481
      } else if (path_type == "gcptrigger") {
    482
        return `${path}.gcp_trigger.*`;
    483
      } else if (path_type == "emailtrigger") {
    484
        return `${path}.email_trigger.*`;
    485
      } else {
    486
        return `${path}.*`;
    487
      }
    488
    }
    489
    
    
    490
    async function wmill_sync_pull(
    491
      items: SyncObject[],
    492
      workspace_id: string,
    493
      skip_secret: boolean,
    494
      repo_url_resource_path: string,
    495
      use_individual_branch: boolean,
    496
      original_branch?: string
    497
    ) {
    498
      const includes = [];
    499
      for (const item of items) {
    500
        const { path_type, path, parent_path } = item;
    501
        if (path !== undefined && path !== null && path !== "") {
    502
          includes.push(regexFromPath(path_type, path));
    503
        }
    504
        if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
    505
          includes.push(regexFromPath(path_type, parent_path));
    506
        }
    507
      }
    508
    
    
    509
      console.log("Pulling workspace into git repo");
    510
    
    
    511
      const args = [
    512
        "sync",
    513
        "pull",
    514
        "--token",
    515
        process.env["WM_TOKEN"] ?? "",
    516
        "--workspace",
    517
        workspace_id,
    518
        "--base-url",
    519
        process.env["BASE_URL"] + "/",
    520
        "--repository",
    521
        repo_url_resource_path,
    522
        "--yes",
    523
        skip_secret ? "--skip-secrets" : "",
    524
      ];
    525
    
    
    526
      if (items.some(item => item.path_type === "schedule") && !use_individual_branch) {
    527
        args.push("--include-schedules");
    528
      }
    529
    
    
    530
      if (items.some(item => item.path_type === "group") && !use_individual_branch) {
    531
        args.push("--include-groups");
    532
      }
    533
    
    
    534
      if (items.some(item => item.path_type === "user") && !use_individual_branch) {
    535
        args.push("--include-users");
    536
      }
    537
    
    
    538
      if (items.some(item => item.path_type.includes("trigger")) && !use_individual_branch) {
    539
        args.push("--include-triggers");
    540
      }
    541
      // Only include settings when specifically deploying settings
    542
      if (items.some(item => item.path_type === "settings") && !use_individual_branch) {
    543
        args.push("--include-settings");
    544
      }
    545
    
    
    546
      // Only include key when specifically deploying keys
    547
      if (items.some(item => item.path_type === "key") && !use_individual_branch) {
    548
        args.push("--include-key");
    549
      }
    550
    
    
    551
      args.push("--extra-includes", includes.join(","));
    552
    
    
    553
      // If using individual branches, apply promotion settings from original branch
    554
      if (use_individual_branch && original_branch) {
    555
        console.log(`Individual branch deployment detected - using promotion settings from '${original_branch}'`);
    556
        args.push("--promotion", original_branch);
    557
      }
    558
    
    
    559
      await wmill_run(3, ...args);
    560
    }
    561
    
    
    562
    async function wmill_run(secret_position: number, ...cmd: string[]) {
    563
      cmd = cmd.filter((elt) => elt !== "");
    564
      const cmd2 = cmd.slice();
    565
      cmd2[secret_position] = "***";
    566
      console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
    567
      await wmill.parse(cmd);
    568
      console.log("Command successfully executed");
    569
    }
    570
    
    
    571
    // Function to set up GPG signing
    572
    async function set_gpg_signing_secret(gpg_key: GpgKey) {
    573
      try {
    574
        console.log("Setting GPG private key for git commits");
    575
    
    
    576
        const formattedGpgContent = gpg_key.private_key.replace(
    577
          /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
    578
          (_: string, header: string, body: string, footer: string) =>
    579
            header +
    580
            "\n" +
    581
            "\n" +
    582
            body.replace(/ ([^\s])/g, "\n$1").trim() +
    583
            "\n" +
    584
            footer
    585
        );
    586
    
    
    587
        const gpg_path = `/tmp/gpg`;
    588
        await sh_run(undefined, "mkdir", "-p", gpg_path);
    589
        await sh_run(undefined, "chmod", "700", gpg_path);
    590
        process.env.GNUPGHOME = gpg_path;
    591
        // process.env.GIT_TRACE = 1;
    592
    
    
    593
        try {
    594
          await sh_run(
    595
            1,
    596
            "bash",
    597
            "-c",
    598
            `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
    599
          );
    600
        } catch (e) {
    601
          // Original error would contain sensitive data
    602
          throw new Error("Failed to import GPG key!");
    603
        }
    604
    
    
    605
        const listKeysOutput = await sh_run(
    606
          undefined,
    607
          "gpg",
    608
          "--list-secret-keys",
    609
          "--with-colons",
    610
          "--keyid-format=long"
    611
        );
    612
    
    
    613
        const keyInfoMatch = listKeysOutput.match(
    614
          /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
    615
        );
    616
    
    
    617
        if (!keyInfoMatch) {
    618
          throw new Error("Failed to extract GPG Key ID and Fingerprint");
    619
        }
    620
    
    
    621
        const keyId = keyInfoMatch[1];
    622
        gpgFingerprint = keyInfoMatch[2];
    623
    
    
    624
        if (gpg_key.passphrase) {
    625
          // This is adummy command to unlock the key
    626
          // with passphrase to load it into agent
    627
          await sh_run(
    628
            1,
    629
            "bash",
    630
            "-c",
    631
            `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
    632
          );
    633
        }
    634
    
    
    635
        // Configure Git to use the extracted key
    636
        await sh_run(undefined, "git", "config", "user.signingkey", keyId);
    637
        await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
    638
        console.log(`GPG signing configured with key ID: ${keyId} `);
    639
      } catch (e) {
    640
        console.error(`Failure while setting GPG key: ${e} `);
    641
        await delete_pgp_keys();
    642
      }
    643
    }
    644
    
    
    645
    async function delete_pgp_keys() {
    646
      console.log("deleting gpg keys");
    647
      if (gpgFingerprint) {
    648
        await sh_run(
    649
          undefined,
    650
          "gpg",
    651
          "--batch",
    652
          "--yes",
    653
          "--pinentry-mode",
    654
          "loopback",
    655
          "--delete-secret-key",
    656
          gpgFingerprint
    657
        );
    658
        await sh_run(
    659
          undefined,
    660
          "gpg",
    661
          "--batch",
    662
          "--yes",
    663
          "--delete-key",
    664
          "--pinentry-mode",
    665
          "loopback",
    666
          gpgFingerprint
    667
        );
    668
      }
    669
    }
    670
    
    
    671
    async function get_gh_app_token() {
    672
      const workspace = process.env["WM_WORKSPACE"];
    673
      const jobToken = process.env["WM_TOKEN"];
    674
    
    
    675
      const baseUrl =
    676
        process.env["BASE_INTERNAL_URL"] ??
    677
        process.env["BASE_URL"] ??
    678
        "http://localhost:8000";
    679
    
    
    680
      const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
    681
    
    
    682
      const response = await fetch(url, {
    683
        method: "POST",
    684
        headers: {
    685
          "Content-Type": "application/json",
    686
          Authorization: `Bearer ${jobToken}`,
    687
        },
    688
        body: JSON.stringify({
    689
          job_token: jobToken,
    690
        }),
    691
      });
    692
    
    
    693
      if (!response.ok) {
    694
        throw new Error(`Error: ${response.statusText}`);
    695
      }
    696
    
    
    697
      const data = await response.json();
    698
    
    
    699
      return data.token;
    700
    }
    701
    
    
    702
    function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
    703
      if (!gitHubUrl || !installationToken) {
    704
        throw new Error("Both GitHub URL and Installation Token are required.");
    705
      }
    706
    
    
    707
      try {
    708
        const url = new URL(gitHubUrl);
    709
    
    
    710
        // GitHub repository URL should be in the format: https://github.com/owner/repo.git
    711
        if (url.hostname !== "github.com") {
    712
          throw new Error(
    713
            "Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'."
    714
          );
    715
        }
    716
    
    
    717
        // Convert URL to include the installation token
    718
        return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
    719
      } catch (e) {
    720
        const error = e as Error;
    721
        throw new Error(`Invalid URL: ${error.message}`);
    722
      }
    723
    }
    724