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

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

Created by hugo697 512 days ago Viewed 61115 times
0
Submitted by rubenfiszel Bun
Verified 229 days ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "windmill-cli";
3
import { basename } from "node:path"
4
const util = require('util');
5
const exec = util.promisify(require('child_process').exec);
6
import process from "process";
7

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

14
let gpgFingerprint: string | undefined = undefined;
15

16
export async function main(
17
  workspace_id: string,
18
  repo_url_resource_path: string,
19
  path_type:
20
    | "script"
21
    | "flow"
22
    | "app"
23
    | "folder"
24
    | "resource"
25
    | "variable"
26
    | "resourcetype"
27
    | "schedule"
28
    | "user"
29
    | "group",
30
  skip_secret = true,
31
  path: string | undefined,
32
  parent_path: string | undefined,
33
  commit_msg: string,
34
  use_individual_branch = false,
35
  group_by_folder = false
36
) {
37
  const repo_resource = await wmillclient.getResource(repo_url_resource_path);
38
  const cwd = process.cwd();
39
  process.env["HOME"] = "."
40
  console.log(
41
    `Syncing ${path_type} ${path ?? ""} with parent ${parent_path ?? ""}`
42
  );
43

44
  if (repo_resource.is_github_app) {
45
    const token = await get_gh_app_token()
46
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
47
    repo_resource.url = authRepoUrl;
48
  }
49

50
  const repo_name = await git_clone(cwd, repo_resource, use_individual_branch);
51
  await move_to_git_branch(
52
    workspace_id,
53
    path_type,
54
    path,
55
    parent_path,
56
    use_individual_branch,
57
    group_by_folder
58
  );
59
  const subfolder = repo_resource.folder ?? "";
60
  const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
61
  console.log(
62
    `Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`
63
  );
64
  await wmill_sync_pull(
65
    path_type,
66
    workspace_id,
67
    path,
68
    parent_path,
69
    skip_secret
70
  );
71
  try {
72
    await git_push(path, parent_path, commit_msg, repo_resource);
73
  } catch (e) {
74
    throw (e)
75
  } finally {
76
    await delete_pgp_keys()
77
  }
78
  console.log("Finished syncing");
79
  process.chdir(`${cwd}`);
80
}
81
async function git_clone(
82
  cwd: string,
83
  repo_resource: any,
84
  use_individual_branch: boolean
85
): Promise<string> {
86
  // TODO: handle private SSH keys as well
87
  let repo_url = repo_resource.url;
88
  const subfolder = repo_resource.folder ?? "";
89
  const branch = repo_resource.branch ?? "";
90
  const repo_name = basename(repo_url, ".git");
91
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
92
  if (azureMatch) {
93
    console.log(
94
      "Requires Azure DevOps service account access token, requesting..."
95
    );
96
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
97
    const response = await fetch(
98
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
99
      {
100
        method: "POST",
101
        body: new URLSearchParams({
102
          client_id: azureResource.azureClientId,
103
          client_secret: azureResource.azureClientSecret,
104
          grant_type: "client_credentials",
105
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
106
        }),
107
      }
108
    );
109
    const { access_token } = await response.json();
110
    repo_url = repo_url.replace(azureMatch[0], access_token);
111
  }
112
  const args = ["clone", "--quiet", "--depth", "1"];
113
  if (use_individual_branch) {
114
    args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
115
  }
116
  if (subfolder !== "") {
117
    args.push("--sparse");
118
  }
119
  if (branch !== "") {
120
    args.push("--branch");
121
    args.push(branch);
122
  }
123
  args.push(repo_url);
124
  args.push(repo_name);
125
  await sh_run(-1, "git", ...args);
126
  try {
127
    process.chdir(`${cwd}/${repo_name}`);
128
  } catch (err) {
129
    console.log(
130
      `Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`
131
    );
132
    throw err;
133
  }
134
  process.chdir(`${cwd}/${repo_name}`);
135
  if (subfolder !== "") {
136
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
137
  }
138
  try {
139
    process.chdir(`${cwd}/${repo_name}/${subfolder}`);
140
  } catch (err) {
141
    console.log(
142
      `Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`
143
    );
144
    throw err;
145
  }
146
  return repo_name;
147
}
148
async function move_to_git_branch(
149
  workspace_id: string,
150
  path_type:
151
    | "script"
152
    | "flow"
153
    | "app"
154
    | "folder"
155
    | "resource"
156
    | "variable"
157
    | "resourcetype"
158
    | "schedule"
159
    | "user"
160
    | "group",
161
  path: string | undefined,
162
  parent_path: string | undefined,
163
  use_individual_branch: boolean,
164
  group_by_folder: boolean
165
) {
166
  if (!use_individual_branch || path_type === "user" || path_type === "group") {
167
    return;
168
  }
169
  const branchName = group_by_folder
170
    ? `wm_deploy/${workspace_id}/${(path ?? parent_path)
171
      ?.split("/")
172
      .slice(0, 2)
173
      .join("__")}`
174
    : `wm_deploy/${workspace_id}/${path_type}/${(
175
      path ?? parent_path
176
    )?.replaceAll("/", "__")}`;
177
  try {
178
    await sh_run(undefined, "git", "checkout", branchName);
179
  } catch (err) {
180
    console.log(
181
      `Error checking out branch ${branchName}. It is possible it doesn't exist yet, tentatively creating it... Error was:\n${err}`
182
    );
183
    try {
184
      await sh_run(undefined, "git", "checkout", "-b", branchName);
185
      await sh_run(
186
        undefined,
187
        "git",
188
        "config",
189
        "--add",
190
        "--bool",
191
        "push.autoSetupRemote",
192
        "true"
193
      );
194
    } catch (err) {
195
      console.log(
196
        `Error checking out branch '${branchName}'. Error was:\n${err}`
197
      );
198
      throw err;
199
    }
200
  }
201
  console.log(`Successfully switched to branch ${branchName}`);
202
}
203
async function git_push(
204
  path: string | undefined,
205
  parent_path: string | undefined,
206
  commit_msg: string,
207
  repo_resource: any
208
) {
209
  let user_email = process.env["WM_EMAIL"] ?? ""
210
  let user_name = process.env["WM_USERNAME"] ?? ""
211

212
  if (repo_resource.gpg_key) {
213
    await set_gpg_signing_secret(repo_resource.gpg_key);
214
    user_email = repo_resource.gpg_key.email
215
  }
216

217
  await sh_run(
218
    undefined,
219
    "git",
220
    "config",
221
    "user.email",
222
    user_email
223
  );
224
  await sh_run(
225
    undefined,
226
    "git",
227
    "config",
228
    "user.name",
229
    user_name
230
  );
231
  if (path !== undefined && path !== null && path !== "") {
232
    try {
233
      await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${path}**`);
234
    } catch (e) {
235
      console.log(`Unable to stage files matching ${path}**, ${e}`);
236
    }
237
  }
238
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
239
    try {
240
      await sh_run(undefined, "git", "add", "wmill-lock.yaml", `${parent_path}**`);
241
    } catch (e) {
242
      console.log(`Unable to stage files matching ${parent_path}, ${e}`);
243
    }
244
  }
245
  try {
246
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
247
  } catch {
248
    // git diff returns exit-code = 1 when there's at least one staged changes
249
    await sh_run(undefined, "git", "commit", "-m", `"${commit_msg == undefined || commit_msg == "" ? "no commit msg" : commit_msg}"`);
250
    try {
251
      await sh_run(undefined, "git", "push", "--porcelain");
252
    } catch (e) {
253
      console.log(`Could not push, trying to rebase first: ${e}`);
254
      await sh_run(undefined, "git", "pull", "--rebase");
255
      await sh_run(undefined, "git", "push", "--porcelain");
256
    }
257
    return;
258
  }
259
  console.log("No changes detected, nothing to commit. Returning...");
260
}
261
async function sh_run(
262
  secret_position: number | undefined,
263
  cmd: string,
264
  ...args: string[]
265
) {
266
  const nargs = secret_position != undefined ? args.slice() : args;
267
  if (secret_position && secret_position < 0) {
268
    secret_position = nargs.length - 1 + secret_position;
269
  }
270
  let secret: string | undefined = undefined
271
  if (secret_position != undefined) {
272
    nargs[secret_position] = "***";
273
    secret = args[secret_position]
274
  }
275

276
  console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
277
  const command = exec(`${cmd} ${args.join(" ")}`)
278
  // new Deno.Command(cmd, {
279
  //   args: args,
280
  // });
281
  try {
282
    const { stdout, stderr } = await command
283
    if (stdout.length > 0) {
284
      console.log(stdout);
285
    }
286
    if (stderr.length > 0) {
287
      console.log(stderr);
288
    }
289
    console.log("Command successfully executed");
290
    return stdout;
291

292
  } catch (error) {
293
    let errorString = error.toString();
294
    if (secret) {
295
      errorString = errorString.replace(secret, "***");
296
    }
297
    const err = `SH command '${cmd} ${nargs.join(
298
      " "
299
    )}' returned with error ${errorString}`;
300
    throw Error(err);
301
  }
302
}
303

304
function regexFromPath(
305
  path_type:
306
    | "script"
307
    | "flow"
308
    | "app"
309
    | "folder"
310
    | "resource"
311
    | "variable"
312
    | "resourcetype"
313
    | "schedule"
314
    | "user"
315
    | "group",
316
  path: string
317
) {
318
  if (path_type == "flow") {
319
    return `${path}.flow/*`;
320
  } if (path_type == "app") {
321
    return `${path}.app/*`;
322
  } else if (path_type == "folder") {
323
    return `${path}/folder.meta.*`;
324
  } else if (path_type == "resourcetype") {
325
    return `${path}.resource-type.*`;
326
  } else if (path_type == "resource") {
327
    return `${path}.resource.*`;
328
  } else if (path_type == "variable") {
329
    return `${path}.variable.*`;
330
  } else if (path_type == "schedule") {
331
    return `${path}.schedule.*`;
332
  } else if (path_type == "user") {
333
    return `${path}.user.*`;
334
  } else if (path_type == "group") {
335
    return `${path}.group.*`;
336
  } else {
337
    return `${path}.*`;
338
  }
339
}
340
async function wmill_sync_pull(
341
  path_type:
342
    | "script"
343
    | "flow"
344
    | "app"
345
    | "folder"
346
    | "resource"
347
    | "variable"
348
    | "resourcetype"
349
    | "schedule"
350
    | "user"
351
    | "group",
352
  workspace_id: string,
353
  path: string | undefined,
354
  parent_path: string | undefined,
355
  skip_secret: boolean
356
) {
357
  const includes = [];
358
  if (path !== undefined && path !== null && path !== "") {
359
    includes.push(regexFromPath(path_type, path));
360
  }
361
  if (parent_path !== undefined && parent_path !== null && parent_path !== "") {
362
    includes.push(regexFromPath(path_type, parent_path));
363
  }
364
  await wmill_run(
365
    6,
366
    "workspace",
367
    "add",
368
    workspace_id,
369
    workspace_id,
370
    process.env["BASE_URL"] + "/",
371
    "--token",
372
    process.env["WM_TOKEN"] ?? ""
373
  );
374
  console.log("Pulling workspace into git repo");
375
  await wmill_run(
376
    3,
377
    "sync",
378
    "pull",
379
    "--token",
380
    process.env["WM_TOKEN"] ?? "",
381
    "--workspace",
382
    workspace_id,
383
    "--yes",
384
    skip_secret ? "--skip-secrets" : "",
385
    "--include-schedules",
386
    "--include-users",
387
    "--include-groups",
388
    "--extra-includes",
389
    includes.join(",")
390
  );
391
}
392

393
async function wmill_run(secret_position: number, ...cmd: string[]) {
394
  cmd = cmd.filter((elt) => elt !== "");
395
  const cmd2 = cmd.slice();
396
  cmd2[secret_position] = "***";
397
  console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
398
  await wmill.parse(cmd);
399
  console.log("Command successfully executed");
400
}
401

402
// Function to set up GPG signing
403
async function set_gpg_signing_secret(gpg_key: GpgKey) {
404
  try {
405
    console.log("Setting GPG private key for git commits");
406

407
    const formattedGpgContent = gpg_key.private_key
408
      .replace(
409
        /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
410
        (_: string, header: string, body: string, footer: string) =>
411
          header + "\n" + "\n" + body.replace(/ ([^\s])/g, "\n$1").trim() + "\n" + footer
412
      );
413

414
    const gpg_path = `/tmp/gpg`;
415
    await sh_run(undefined, "mkdir", "-p", gpg_path);
416
    await sh_run(undefined, "chmod", "700", gpg_path)
417
    process.env.GNUPGHOME = gpg_path;
418
    // process.env.GIT_TRACE = 1;
419

420

421
    try {
422
      await sh_run(
423
        1,
424
        "bash",
425
        "-c",
426
        `cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`
427
      );
428
    } catch (e) {
429
      // Original error would contain sensitive data
430
      throw new Error('Failed to import GPG key!')
431
    }
432

433
    const listKeysOutput = await sh_run(
434
      undefined,
435
      "gpg",
436
      "--list-secret-keys",
437
      "--with-colons",
438
      "--keyid-format=long"
439
    );
440

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

443
    if (!keyInfoMatch) {
444
      throw new Error("Failed to extract GPG Key ID and Fingerprint");
445
    }
446

447
    const keyId = keyInfoMatch[1];
448
    gpgFingerprint = keyInfoMatch[2];
449

450
    if (gpg_key.passphrase) {
451
      // This is adummy command to unlock the key
452
      // with passphrase to load it into agent
453
      await sh_run(
454
        1,
455
        "bash",
456
        "-c",
457
        `echo "dummy" | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
458
      );
459
    }
460

461
    // Configure Git to use the extracted key
462
    await sh_run(undefined, "git", "config", "user.signingkey", keyId);
463
    await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
464
    console.log(`GPG signing configured with key ID: ${keyId} `);
465

466
  } catch (e) {
467
    console.error(`Failure while setting GPG key: ${e} `);
468
    await delete_pgp_keys();
469
  }
470
}
471

472
async function delete_pgp_keys() {
473
  console.log("deleting gpg keys")
474
  if (gpgFingerprint) {
475
    await sh_run(undefined, "gpg", "--batch", "--yes", "--pinentry-mode", "loopback", "--delete-secret-key", gpgFingerprint)
476
    await sh_run(undefined, "gpg", "--batch", "--yes", "--delete-key", "--pinentry-mode", "loopback", gpgFingerprint)
477
  }
478
}
479

480
async function get_gh_app_token() {
481
  const workspace = process.env["WM_WORKSPACE"];
482
  const jobToken = process.env["WM_TOKEN"];
483

484
  const baseUrl =
485
    process.env["BASE_INTERNAL_URL"] ??
486
    process.env["BASE_URL"] ??
487
    "http://localhost:8000";
488

489
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
490

491
  const response = await fetch(url, {
492
    method: 'POST',
493
    headers: {
494
      'Content-Type': 'application/json',
495
      'Authorization': `Bearer ${jobToken}`,
496
    },
497
    body: JSON.stringify({
498
      job_token: jobToken,
499
    }),
500
  });
501

502
  if (!response.ok) {
503
    throw new Error(`Error: ${response.statusText}`);
504
  }
505

506
  const data = await response.json();
507

508
  return data.token;
509
}
510

511
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
512
  if (!gitHubUrl || !installationToken) {
513
    throw new Error("Both GitHub URL and Installation Token are required.");
514
  }
515

516
  try {
517
    const url = new URL(gitHubUrl);
518

519
    // GitHub repository URL should be in the format: https://github.com/owner/repo.git
520
    if (url.hostname !== "github.com") {
521
      throw new Error("Invalid GitHub URL. Must be in the format 'https://github.com/owner/repo.git'.");
522
    }
523

524
    // Convert URL to include the installation token
525
    return `https://x-access-token:${installationToken}@github.com${url.pathname}`;
526
  } catch (e) {
527
    const error = e as Error
528
    throw new Error(`Invalid URL: ${error.message}`)
529
  }
530
}
Other submissions