feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix)

hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache
pre-warm (which became stale by the time the CLI's `git commit` ran) with
a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback`
(and `--passphrase-file` when a passphrase exists) on every gpg invocation.
Bundled CLI is windmill-cli@1.705.0.

Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately
killing gpg-agent between GPG setup and `git commit` reproduces the
customer's `gpg failed to sign the data` error verbatim under the old
flow, and the wrapper signs through it. Holds for passphrase-protected
keys, split-subkey [C]+[S] layouts, and unprotected keys.

Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical
now that 28234 is published.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-22 06:59:48 +00:00
co-authored by Claude Opus 4.7
parent 6f9a0e3765
commit e4596ceedb
2 changed files with 10 additions and 536 deletions
+10 -10
View File
@@ -157,16 +157,16 @@ pub enum ObjectType {
WorkspaceDependencies,
}
// hub/28231 ("thin" script that delegates commit/push to the CLI's `sync
// git-deploy`) breaks GPG-signed deploys: by the time the CLI runs `git
// commit`, gpg-agent's passphrase cache from the script's pre-warm dummy sign
// is no longer valid for the spawned git process, so `git commit` fails with
// `gpg failed to sign the data` (WIN-1974). hub/28230 keeps the GPG setup AND
// the commit in the same in-script flow (originally 28217's logic, rebuilt
// with windmill-cli@1.703.3) and is the last known-good default. A forward
// fix (CLI-side gpg.program wrapper that doesn't depend on agent state, then a
// new thin script that opts into it) will re-bump this past 28231.
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28230/sync-script-to-git-repo-windmill";
// hub/28231 broke GPG-signed deploys (WIN-1974): the agent-cache pre-warm in
// `set_gpg_signing_secret` was no longer valid for the spawned `git` process
// by the time the CLI's `git commit` ran (after API resolve, zip pull, file
// extract, lockfile autofill), so signing failed with `gpg failed to sign
// the data`. hub/28234 replaces the agent-cache dependency with a stateless
// `gpg.program` wrapper that always uses `--pinentry-mode loopback` (and
// `--passphrase-file` when a passphrase exists), so every git-invoked gpg
// call provides the passphrase itself instead of trusting the agent. Bundled
// CLI is windmill-cli@1.705.0.
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28234/sync-script-to-git-repo-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
@@ -1,526 +0,0 @@
// Source-of-truth copy of the git-sync deployment-callback hub script.
// Published to hub.windmill.dev as `sync-script-to-git-repo-windmill`; the
// hub id this corresponds to is referenced in
// `backend/windmill-common/src/workspaces.rs::LATEST_GIT_SYNC_SCRIPT_PATH`.
//
// Diff vs. hub/28231 (WIN-1974 fix): the agent-cache pre-warm (`gpg -bsau`
// with --passphrase) is replaced by a stateless `gpg.program` wrapper script
// + chmod-600 passphrase file. Git invokes the wrapper on every sign, the
// wrapper always uses `--pinentry-mode loopback` (+ `--passphrase-file` when
// a passphrase exists), so signing does NOT depend on gpg-agent having a
// cached passphrase by the time the CLI's `git commit` runs minutes later.
import * as wmillclient from "windmill-client";
import wmill from "windmill-cli@1.705.0";
import { basename } from "node:path";
const util = require("util");
const exec = util.promisify(require("child_process").exec);
import process from "process";
type GpgKey = {
email: string;
private_key: string;
passphrase: string;
};
const FORKED_WORKSPACE_PREFIX = "wm-fork-";
const GPG_HOME = "/tmp/gpg";
const GPG_PASSPHRASE_FILE = `${GPG_HOME}/passphrase`;
const GPG_WRAPPER = `${GPG_HOME}/gpg-wrapper.sh`;
type PathType =
| "script"
| "flow"
| "app"
| "raw_app"
| "folder"
| "resource"
| "variable"
| "resourcetype"
| "schedule"
| "user"
| "group"
| "httptrigger"
| "websockettrigger"
| "kafkatrigger"
| "natstrigger"
| "postgrestrigger"
| "mqtttrigger"
| "sqstrigger"
| "gcptrigger"
| "azuretrigger"
| "emailtrigger";
type SyncObject = {
path_type: PathType;
path: string | undefined;
parent_path: string | undefined;
commit_msg: string;
};
let gpgFingerprint: string | undefined = undefined;
let gpgWrapperWritten = false;
export async function main(
items: SyncObject[],
// Compat, do not use in code, rely on `items` instead
path_type: PathType | undefined,
path: string | undefined,
parent_path: string | undefined,
commit_msg: string | undefined,
//
workspace_id: string,
repo_url_resource_path: string,
skip_secret: boolean = true,
use_individual_branch: boolean = false,
group_by_folder: boolean = false,
only_create_branch: boolean = false,
parent_workspace_id?: string,
) {
if (path_type !== undefined && commit_msg !== undefined) {
items = [
{
path_type,
path,
parent_path,
commit_msg,
},
];
}
await inner(
items,
workspace_id,
repo_url_resource_path,
skip_secret,
use_individual_branch,
group_by_folder,
only_create_branch,
parent_workspace_id,
);
}
// Thin git-sync deployment-callback script.
//
// All git orchestration (wm_deploy/fork branch selection, include/promotion
// derivation, commit, push, fork-disable, parent-of-fork rooting) lives in
// the bundled CLI's hidden `wmill sync git-deploy`. This script only does the
// parts that need Windmill resource/secret access and must wrap the clone:
// resolve the repo resource, GitHub-App / Azure auth, `git clone`, GPG key
// import + wrapper config, then delegate, then clean up.
async function inner(
items: SyncObject[],
workspace_id: string,
repo_url_resource_path: string,
skip_secret: boolean = true,
use_individual_branch: boolean = false,
group_by_folder: boolean = false,
only_create_branch: boolean = false,
parent_workspace_id?: string,
) {
let safeDirectoryPath: string | undefined;
const repo_resource = await wmillclient.getResource(repo_url_resource_path);
const cwd = process.cwd();
process.env["HOME"] = ".";
if (!only_create_branch) {
for (const item of items) {
console.log(
`Syncing ${item.path_type} ${item.path ?? ""} with parent ${item.parent_path ?? ""}`,
);
}
}
if (repo_resource.is_github_app) {
const token = await get_gh_app_token();
const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
repo_resource.url = authRepoUrl;
}
const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath } =
await git_clone(
cwd,
repo_resource,
use_individual_branch || workspace_id.startsWith(FORKED_WORKSPACE_PREFIX),
);
safeDirectoryPath = cloneSafeDirectoryPath;
// GPG signing must be configured (local commit.gpgsign / user.signingkey /
// gpg.program wrapper) BEFORE the CLI commits. The committer email/name
// are passed through to the CLI so authorship stays identical to the old
// in-script git_push (committer email = gpg key email when signing).
if (repo_resource.gpg_key) {
await set_gpg_signing_secret(repo_resource.gpg_key);
}
const subfolder = repo_resource.folder ?? "";
const branch_or_default = repo_resource.branch ?? "<DEFAULT>";
console.log(
`Pushing to repository ${repo_name} in subfolder ${subfolder} on branch ${branch_or_default}`,
);
try {
// Raw flags are passed through verbatim; the CLI replicates the
// fork-disable, parent-of-fork rooting and include/promotion derivation.
const args = [
"sync",
"git-deploy",
"--token",
process.env["WM_TOKEN"] ?? "",
"--workspace",
workspace_id,
"--base-url",
process.env["BASE_URL"] + "/",
"--repository",
repo_url_resource_path,
"--git-deploy-items",
JSON.stringify(items),
];
if (use_individual_branch) args.push("--use-individual-branch");
if (group_by_folder) args.push("--group-by-folder");
if (only_create_branch) args.push("--only-create-branch");
if (parent_workspace_id) {
args.push("--parent-workspace-id", parent_workspace_id);
}
if (skip_secret) args.push("--skip-secrets");
if (repo_resource.gpg_key) {
args.push("--git-committer-email", repo_resource.gpg_key.email);
args.push("--git-committer-name", process.env["WM_USERNAME"] ?? "");
}
await wmill_run(3, ...args);
} catch (e) {
throw e;
} finally {
await delete_pgp_keys();
// Cleanup: remove safe.directory config
if (safeDirectoryPath) {
try {
await sh_run(
undefined,
"git",
"config",
"--global",
"--unset",
"safe.directory",
safeDirectoryPath,
);
} catch (e) {
console.log(`Warning: Could not unset safe.directory config: ${e}`);
}
}
}
console.log("Finished syncing");
process.chdir(`${cwd}`);
}
async function git_clone(
cwd: string,
repo_resource: any,
no_single_branch: boolean,
): Promise<{
repo_name: string;
safeDirectoryPath: string;
clonedBranchName: string;
}> {
// TODO: handle private SSH keys as well
let repo_url = repo_resource.url;
const subfolder = repo_resource.folder ?? "";
const branch = repo_resource.branch ?? "";
const repo_name = basename(repo_url, ".git");
const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
if (azureMatch) {
console.log(
"Requires Azure DevOps service account access token, requesting...",
);
const azureResource = await wmillclient.getResource(azureMatch.groups.url);
const response = await fetch(
`https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
{
method: "POST",
body: new URLSearchParams({
client_id: azureResource.azureClientId,
client_secret: azureResource.azureClientSecret,
grant_type: "client_credentials",
resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
}),
},
);
const { access_token } = await response.json();
repo_url = repo_url.replace(azureMatch[0], access_token);
}
const args = ["clone", "--quiet", "--depth", "1"];
if (no_single_branch) {
args.push("--no-single-branch"); // needed in case the asset branch already exists in the repo
}
if (subfolder !== "") {
args.push("--sparse");
}
if (branch !== "") {
args.push("--branch");
args.push(branch);
}
args.push(repo_url);
args.push(repo_name);
await sh_run(-1, "git", ...args);
try {
process.chdir(`${cwd}/${repo_name}`);
const safeDirectoryPath = process.cwd();
// Add safe.directory to handle dubious ownership in cloned repo
try {
await sh_run(
undefined,
"git",
"config",
"--global",
"--add",
"safe.directory",
process.cwd(),
);
} catch (e) {
console.log(`Warning: Could not add safe.directory config: ${e}`);
}
if (subfolder !== "") {
await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
try {
process.chdir(`${cwd}/${repo_name}/${subfolder}`);
} catch (err) {
console.log(
`Error changing directory to '${cwd}/${repo_name}/${subfolder}'. Error was:\n${err}`,
);
throw err;
}
}
const clonedBranchName = (
await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")
).trim();
return { repo_name, safeDirectoryPath, clonedBranchName };
} catch (err) {
console.log(
`Error changing directory to '${cwd}/${repo_name}'. Error was:\n${err}`,
);
throw err;
}
}
async function sh_run(
secret_position: number | undefined,
cmd: string,
...args: string[]
) {
const nargs = secret_position != undefined ? args.slice() : args;
if (secret_position && secret_position < 0) {
secret_position = nargs.length - 1 + secret_position;
}
let secret: string | undefined = undefined;
if (secret_position != undefined) {
nargs[secret_position] = "***";
secret = args[secret_position];
}
console.log(`Running '${cmd} ${nargs.join(" ")} ...'`);
const command = exec(`${cmd} ${args.join(" ")}`);
try {
const { stdout, stderr } = await command;
if (stdout.length > 0) {
console.log(stdout);
}
if (stderr.length > 0) {
console.log(stderr);
}
console.log("Command successfully executed");
return stdout;
} catch (error) {
let errorString = error.toString();
if (secret) {
errorString = errorString.replace(secret, "***");
}
const err = `SH command '${cmd} ${nargs.join(
" ",
)}' returned with error ${errorString}`;
throw Error(err);
}
}
async function wmill_run(secret_position: number, ...cmd: string[]) {
cmd = cmd.filter((elt) => elt !== "");
const cmd2 = cmd.slice();
cmd2[secret_position] = "***";
console.log(`Running 'wmill ${cmd2.join(" ")} ...'`);
await wmill.parse(cmd);
console.log("Command successfully executed");
}
// Import the key, write a stateless gpg wrapper, and point git at it.
//
// WIN-1974 fix: the previous version pre-warmed gpg-agent's passphrase cache
// with a dummy `gpg -bsau` immediately before handing control to the CLI.
// That cache was no longer reliably valid by the time the CLI's `git commit`
// ran (workspace API resolution, zip pull, file extract, lockfile autofill
// all run in between), so signing failed with `gpg failed to sign the data`.
// Replacing the agent-cache dependency with a `gpg.program` wrapper that
// always uses `--pinentry-mode loopback` (and a `--passphrase-file` when a
// passphrase exists) makes signing stateless: every git-invoked gpg call
// provides the passphrase itself instead of trusting the agent.
async function set_gpg_signing_secret(gpg_key: GpgKey) {
try {
console.log("Setting GPG private key for git commits");
const formattedGpgContent = gpg_key.private_key.replace(
/(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
(_: string, header: string, body: string, footer: string) =>
header +
"\n" +
"\n" +
body.replace(/ ([^\s])/g, "\n$1").trim() +
"\n" +
footer,
);
await sh_run(undefined, "mkdir", "-p", GPG_HOME);
await sh_run(undefined, "chmod", "700", GPG_HOME);
process.env.GNUPGHOME = GPG_HOME;
try {
await sh_run(
1,
"bash",
"-c",
`cat <<EOF | gpg --batch --import \n${formattedGpgContent}\nEOF`,
);
} catch (e) {
// Original error would contain sensitive data
throw new Error("Failed to import GPG key!");
}
const listKeysOutput = await sh_run(
undefined,
"gpg",
"--list-secret-keys",
"--with-colons",
"--keyid-format=long",
);
const keyInfoMatch = listKeysOutput.match(
/sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/,
);
if (!keyInfoMatch) {
throw new Error("Failed to extract GPG Key ID and Fingerprint");
}
const keyId = keyInfoMatch[1];
gpgFingerprint = keyInfoMatch[2];
// Write a stateless gpg wrapper: every sign goes through
// `--pinentry-mode loopback` (+ --passphrase-file when set), so signing
// never depends on gpg-agent having a cached passphrase. Passphrase file
// is mode-600 via umask 077; both files are removed in delete_pgp_keys.
if (gpg_key.passphrase) {
// Escape single quotes for shell-safe single-quoting: ' -> '\''
const escapedPassphrase = gpg_key.passphrase.replace(/'/g, "'\\''");
await sh_run(
2, // mask the passphrase arg in logs
"bash",
"-c",
`umask 077 && printf %s '${escapedPassphrase}' > ${GPG_PASSPHRASE_FILE}`,
);
}
const wrapperBody = gpg_key.passphrase
? `#!/bin/sh\nexec gpg --pinentry-mode loopback --passphrase-file "${GPG_PASSPHRASE_FILE}" --batch "$@"\n`
: `#!/bin/sh\nexec gpg --pinentry-mode loopback --batch "$@"\n`;
await sh_run(
undefined,
"bash",
"-c",
`cat > ${GPG_WRAPPER} <<'WRAPPER_EOF' && chmod 700 ${GPG_WRAPPER}\n${wrapperBody}WRAPPER_EOF`,
);
gpgWrapperWritten = true;
// Configure Git: use the extracted key, sign every commit, and route
// every gpg invocation through the wrapper. All three are LOCAL repo
// config, not --global — so two concurrent deploys against different
// repos don't trample each other.
await sh_run(undefined, "git", "config", "user.signingkey", keyId);
await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
await sh_run(undefined, "git", "config", "gpg.program", GPG_WRAPPER);
console.log(`GPG signing configured with key ID: ${keyId}`);
} catch (e) {
console.error(`Failure while setting GPG key: ${e} `);
await delete_pgp_keys();
}
}
async function delete_pgp_keys() {
console.log("deleting gpg keys");
if (gpgFingerprint) {
await sh_run(
undefined,
"gpg",
"--batch",
"--yes",
"--pinentry-mode",
"loopback",
"--delete-secret-key",
gpgFingerprint,
);
await sh_run(
undefined,
"gpg",
"--batch",
"--yes",
"--delete-key",
"--pinentry-mode",
"loopback",
gpgFingerprint,
);
}
if (gpgWrapperWritten) {
try {
await sh_run(undefined, "rm", "-f", GPG_PASSPHRASE_FILE, GPG_WRAPPER);
} catch (e) {
console.log(`Warning: Could not remove gpg wrapper artifacts: ${e}`);
}
}
}
async function get_gh_app_token() {
const workspace = process.env["WM_WORKSPACE"];
const jobToken = process.env["WM_TOKEN"];
const baseUrl =
process.env["BASE_INTERNAL_URL"] ??
process.env["BASE_URL"] ??
"http://localhost:8000";
const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jobToken}`,
},
body: JSON.stringify({
job_token: jobToken,
}),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(
`GitHub App token error (${response.status}): ${errorBody || response.statusText}`,
);
}
const data = await response.json();
return data.token;
}
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
if (!gitHubUrl || !installationToken) {
throw new Error("Both GitHub URL and Installation Token are required.");
}
const url = new URL(gitHubUrl);
return `https://x-access-token:${installationToken}@${url.hostname}${url.pathname}`;
}