Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# git-dist

Builds a portable Git distribution for Windows and macOS, bundled with
[Anchorpoint's `git-lfs` fork](https://github.com/Anchorpoint-Software/git-lfs),
Builds a portable Git distribution for Windows, macOS, and Linux, bundled with
[Anchorpoint's `git-lfs` fork](https://github.com/Anchorpoint-Software/git-lfs)
and [Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager),
and publishes the per-OS archives as GitHub release assets.

Anchorpoint ships its own Git rather than relying on the user's system Git. The
Expand All @@ -11,23 +12,34 @@ in the repo. Stock `git-lfs` lacks `--stdin`, so the fork ships with Anchorpoint

## Release assets

Each release tag attaches three signed archives, each with a `.sha256`:
Each release tag attaches these archives (signed on Windows/macOS), each with a
`.sha256`:

| Asset | Platform |
|-------|----------|
| `git-windows-x64.zip` | Windows x64 (MinGit) |
| `git-macos-arm64.zip` | macOS Apple Silicon |
| `git-macos-x64.zip` | macOS Intel |
| `git-linux-x64.tar.gz` | Linux x64 |

Each is a self-contained Git tree with the fork's `git-lfs` in
`libexec/git-core/`, so `git lfs version` reports `Anchorpoint`.

Credential handling matches across platforms: MinGit ships GCM on Windows; on
macOS/Linux the pinned official GCM release (`GCM_VERSION`/`GCM_SHA256` in
`build.py`) is bundled under `gcm/` and wired through a baked `etc/gitconfig`
(`credential.helper = manager`, plus the lfs filter block that MinGit's
gitconfig already carries). On Linux the store defaults to the freedesktop
Secret Service (`credential.credentialStore = secretservice`, i.e. the
GNOME/KDE keyrings); override with `GCM_CREDENTIAL_STORE` on headless setups.

## Layout

```
build.py # git + fork git-lfs -> dist/<asset>/ -> zip + .sha256
build.py # git + fork git-lfs + GCM -> dist/<asset>/ -> zip + .sha256
release.ps1 # Windows one-command release (checks SimplySign, runs build.py)
sign-windows.ps1 # signs the dist's .exe/.dll via signtool (SimplySign cert)
gcm-entitlements.plist # hardened-runtime JIT entitlements for re-signing GCM (macOS)
config.example.ini # copy to config.ini; host-specific paths + signing
third_party/git-lfs/ # submodule -> the git-lfs fork
.github/workflows/ci.yml # compile-checks the fork (no sign/publish)
Expand Down Expand Up @@ -59,6 +71,9 @@ One-time per machine (not per release):
build-extra sources are fetched automatically on the first build.
- **macOS** — install Go and Xcode command-line tools, and check out `git/git`
at the target tag; set `[gitsource].path`.
- **Linux** — install Go plus `gcc`/`make`, `libcurl4-openssl-dev`,
`zlib1g-dev`, `libexpat1-dev`, and `gettext`; check out `git/git` at the
target tag and set `[gitsource].path`.

Signing is config/env-driven and a no-op when unset: macOS uses
`MACOS_SIGN_IDENTITY`; Windows uses `sign-windows.ps1` with the
Expand All @@ -82,6 +97,9 @@ python build.py --package --arch arm64 --publish
xcrun notarytool submit dist/git-macos-arm64.zip --keychain-profile <profile> --wait
python build.py --package --arch x86_64 --publish
xcrun notarytool submit dist/git-macos-x64.zip --keychain-profile <profile> --wait

# Linux — unsigned; builds and uploads into the same tag:
python build.py --package --publish
```

`--publish` auto-derives the tag `v<gitversion>.anchorpoint.<n>` (GfW-style, like
Expand Down
194 changes: 155 additions & 39 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
"""Build a portable Git distribution bundled with Anchorpoint's git-lfs fork.

Windows -> MinGit (built from the Git for Windows SDK) + the fork's git-lfs.exe.
macOS -> git built from source (arm64 or x86_64) + the fork's git-lfs.
Linux -> git built from source (x64 or arm64) + the fork's git-lfs.
macOS -> git built from source (arm64 or x86_64) + the fork's git-lfs + GCM.
Linux -> git built from source (x64 or arm64) + the fork's git-lfs + GCM.

The fork's git-lfs binary is dropped into the distribution's libexec/git-core/
so that `git lfs ...` resolves to it via GIT_EXEC_PATH. The result is staged
so that `git lfs ...` resolves to it via GIT_EXEC_PATH. On macOS/Linux the
pinned Git Credential Manager release is bundled under gcm/ and wired via a
baked etc/gitconfig (`credential.helper = manager`) — MinGit already ships GCM
on Windows. The result is staged
under dist/<platform>/ and, with --package, archived to
dist/git-<os>-<arch>.zip (Windows/macOS) or .tar.gz (Linux — GNU tar can't
read zips, and consumers extract with the platform tar) plus a .sha256
Expand Down Expand Up @@ -35,12 +38,15 @@
import platform
import shutil
import stat
import tarfile
import urllib.request
import zipfile

ROOT = os.path.dirname(os.path.realpath(__file__))
MACOSX_DEPLOYMENT_TARGET = "12.0"

# Server-side and unsupported programs we never ship.
# Server-side and unsupported programs we never ship. Entries are tried under
# <dest> and <dest>/mingw64, bare and with .exe (see prune_programs).
PRUNE_PROGRAMS = [
"bin/git-cvsserver",
"bin/git-receive-pack",
Expand All @@ -49,8 +55,23 @@
"bin/git-shell",
"libexec/git-core/git-svn",
"libexec/git-core/git-p4",
# Legacy Windows credential helper superseded by GCM; MinGit drops a copy
# in both bin/ and libexec/git-core/.
"bin/git-credential-wincred",
"libexec/git-core/git-credential-wincred",
]

# Git Credential Manager, bundled on macOS/Linux (MinGit already ships GCM on
# Windows). Official self-contained release tarballs from
# git-ecosystem/git-credential-manager, pinned by version + per-asset sha256.
GCM_VERSION = "2.8.0"
GCM_SHA256 = {
"osx-arm64": "a61399385e861b8e6e896d04e528eebe1214e002edbb98373e5a458dee194d56",
"osx-x64": "bba2b1a20198b2a1a69496a7cd81f4cb23f569fcc7d17751a8a93c071bb6a2b9",
"linux-arm64": "6e1782b351eec3399db43699ff4fe2a1bf576654310ee4580aa4867705c0bc08",
"linux-x64": "6040c849b428a4e5b78594fd4fd2819fc3ea4ca472c13580b65a486bef9df21d",
}


# --------------------------------------------------------------------------- #
# Config
Expand Down Expand Up @@ -106,6 +127,63 @@ def build_gitlfs(gitlfs: str, dest: str, goos: str, goarch: str) -> None:
print(f"git-lfs installed at {target}")


# --------------------------------------------------------------------------- #
# Git Credential Manager (macOS/Linux; Windows gets GCM via MinGit)
# --------------------------------------------------------------------------- #
def bundle_gcm(dest: str, arch: str) -> None:
"""Bundle the official GCM release so `credential.helper = manager` works.

Downloads the pinned self-contained tarball (sha256-verified, cached in
build-temp/), extracts it to <dest>/gcm/, and installs a small sh shim at
libexec/git-core/git-credential-manager. git prepends GIT_EXEC_PATH to a
helper's PATH, so the shim resolves exactly like the bundled git-lfs.
A shim rather than a symlink or moving the files into git-core: the macOS
tarball is a ~200-file .NET layout that must stay next to its apphost, and
the apphost misresolves its .dll when the executable path is a symlink.
"""
plat = "osx" if platform.system() == "Darwin" else "linux"
key = f"{plat}-{'arm64' if arch == 'arm64' else 'x64'}"
asset = f"gcm-{key}-{GCM_VERSION}.tar.gz"
want = GCM_SHA256[key]

cache = os.path.join(ROOT, "build-temp", asset)
if not (os.path.exists(cache) and _sha256(cache) == want):
url = (
"https://github.com/git-ecosystem/git-credential-manager/"
f"releases/download/v{GCM_VERSION}/{asset}"
)
print(f"Downloading {url}")
os.makedirs(os.path.dirname(cache), exist_ok=True)
urllib.request.urlretrieve(url, cache)
got = _sha256(cache)
if got != want:
raise SystemExit(
f"[git-dist] sha256 mismatch for {asset}\n"
f" expected {want}\n got {got}"
)

gcm_dir = os.path.join(dest, "gcm")
shutil.rmtree(gcm_dir, ignore_errors=True)
os.makedirs(gcm_dir)
with tarfile.open(cache, "r:gz") as tf:
try:
tf.extractall(gcm_dir, filter="data")
except TypeError: # Python < 3.12: no extraction filters
tf.extractall(gcm_dir)
# GCM's own uninstaller doesn't apply to a bundled tree.
uninstall = os.path.join(gcm_dir, "uninstall.sh")
if os.path.exists(uninstall):
os.remove(uninstall)
os.chmod(os.path.join(gcm_dir, "git-credential-manager"), 0o755)

shim = os.path.join(git_core_dir(dest), "git-credential-manager")
os.makedirs(os.path.dirname(shim), exist_ok=True)
with open(shim, "w", newline="\n") as f:
f.write('#!/bin/sh\nexec "$(dirname "$0")/../../gcm/git-credential-manager" "$@"\n')
os.chmod(shim, 0o755)
print(f"GCM {GCM_VERSION} installed at {gcm_dir}")


# --------------------------------------------------------------------------- #
# Git (Windows: MinGit via Git SDK)
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -285,42 +363,62 @@ def prune_programs(dest: str) -> None:
def patch_git_config(dest: str) -> None:
"""Bake the system-level config the app relies on into the bundled gitconfig.

Mirrors dugite-native: portable-friendly, schannel TLS on Windows.
Windows patches the gitconfig MinGit already ships (portable-friendly,
schannel TLS — mirrors dugite-native). The macOS/Linux dists are built from
plain git source and ship no gitconfig at all, so create etc/gitconfig —
the RUNTIME_PREFIX build reads it relative to the extracted tree — and bake
the wiring for the bundled GCM plus the lfs filter (MinGit's gitconfig
already carries both on Windows).
"""
candidates = [
os.path.join(dest, "etc/gitconfig"),
os.path.join(dest, "mingw64/etc/gitconfig"),
]
system_config = next((c for c in candidates if os.path.exists(c)), None)
if not system_config:
print("[git-dist] WARNING: no bundled gitconfig found to patch")
return
if platform.system() == "Windows":
candidates = [
os.path.join(dest, "etc/gitconfig"),
os.path.join(dest, "mingw64/etc/gitconfig"),
]
system_config = next((c for c in candidates if os.path.exists(c)), None)
if not system_config:
print("[git-dist] WARNING: no bundled gitconfig found to patch")
return
pairs = [
("core.symlinks", "false"),
("core.autocrlf", "true"),
("core.fscache", "true"),
("http.sslBackend", "schannel"),
("http.schannelUseSSLCAInfo", "false"),
("credential.https://dev.azure.com.useHttpPath", "true"),
]
else:
# Created fresh: only keys that make sense off-Windows (no autocrlf/
# symlinks/fscache, no schannel — the OpenSSL/SecureTransport builds
# find the CA bundle via their own defaults).
system_config = os.path.join(dest, "etc", "gitconfig")
os.makedirs(os.path.dirname(system_config), exist_ok=True)
pairs = [
("credential.helper", "manager"),
("credential.https://dev.azure.com.useHttpPath", "true"),
# What `git lfs install --system` would write; MinGit bakes the
# same block on Windows.
("filter.lfs.clean", "git-lfs clean -- %f"),
("filter.lfs.smudge", "git-lfs smudge -- %f"),
("filter.lfs.process", "git-lfs filter-process"),
("filter.lfs.required", "true"),
]
if platform.system() == "Linux":
# GCM has no default credential store on Linux. Secret Service is
# what the GNOME/KDE keyrings implement; unusual setups can
# override via GCM_CREDENTIAL_STORE.
pairs.append(("credential.credentialStore", "secretservice"))

pairs = [
("core.symlinks", "false"),
("core.autocrlf", "true"),
("core.fscache", "true"),
("http.sslBackend", "schannel"),
("http.schannelUseSSLCAInfo", "false"),
("credential.https://dev.azure.com.useHttpPath", "true"),
]
if platform.system() == "Linux":
# OpenSSL-linked build: the schannel keys are Windows-only and an
# unsupported sslBackend makes every https operation fail outright.
# The system CA bundle is found via the usual OpenSSL defaults.
pairs = [(k, v) for (k, v) in pairs if not k.startswith("http.")]
for key, value in pairs:
_run(["git", "config", "--file", system_config, key, value])
_run(["git", "config", "--file", system_config, "--unset", "http.sslCAInfo"])
_run(["git", "config", "--file", system_config, "--remove-section", "include"])

for rel in ("etc/gitattributes", "mingw64/etc/gitattributes"):
p = os.path.join(dest, rel)
if os.path.exists(p):
os.remove(p)
legacy = os.path.join(dest, "mingw64/bin/git-credential-wincred.exe")
if os.path.exists(legacy):
os.remove(legacy)
if platform.system() == "Windows":
_run(["git", "config", "--file", system_config, "--unset", "http.sslCAInfo"])
_run(["git", "config", "--file", system_config, "--remove-section", "include"])
for rel in ("etc/gitattributes", "mingw64/etc/gitattributes"):
p = os.path.join(dest, rel)
if os.path.exists(p):
os.remove(p)


# --------------------------------------------------------------------------- #
Expand All @@ -332,11 +430,20 @@ def sign(dest: str) -> None:
if not identity:
print("[git-dist] WARNING: MACOS_SIGN_IDENTITY unset — skipping signing")
return
entitlements = os.path.join(ROOT, "gcm-entitlements.plist")
for binary in _executables(dest):
_run([
cmd = [
"codesign", "--deep", "--force", "--verify", "--verbose",
"--timestamp", "--options", "runtime", "--sign", identity, binary,
])
"--timestamp", "--options", "runtime",
]
# GCM's CoreCLR needs JIT entitlements under the hardened runtime
# — a re-signed binary without them crashes at startup. Same set
# GCM's own release signatures carry (dylibs don't take
# entitlements, only the main executables).
in_gcm = os.path.basename(os.path.dirname(binary)) == "gcm"
if in_gcm and not binary.endswith(".dylib"):
cmd += ["--entitlements", entitlements]
_run(cmd + ["--sign", identity, binary])
print("macOS signing complete (notarization happens in CI after packaging)")
elif platform.system() == "Windows":
script = os.environ.get("WINDOWS_SIGN_SCRIPT") or os.path.join(ROOT, "sign-windows.ps1")
Expand Down Expand Up @@ -367,7 +474,6 @@ def package_targz(dest: str, asset_name: str) -> None:
"""Linux archive. tar preserves symlinks and exec bits natively, and the
consumer side (GNU tar) can't read zips — mirrors 3d-tools' convention of
zip on Windows, tar.gz elsewhere."""
import tarfile
os.makedirs(os.path.join(ROOT, "dist"), exist_ok=True)
tar_path = os.path.join(ROOT, "dist", f"{asset_name}.tar.gz")
print(f"Packaging {dest} -> {tar_path}")
Expand Down Expand Up @@ -528,6 +634,14 @@ def _executables(dest: str):
p = os.path.join(d, name)
if os.path.isfile(p) and (os.access(p, os.X_OK) or name.endswith(".dylib")):
yield p
# The GCM tree needs an explicit allowlist: its ~200 .NET assemblies
# (.dll) carry the executable bit but are PE files codesign rejects.
# Mach-O files there are the two apphosts and the native .dylibs.
gcm = os.path.join(dest, "gcm")
if os.path.isdir(gcm):
for name in os.listdir(gcm):
if name.endswith(".dylib") or name in ("git-credential-manager", "createdump"):
yield os.path.join(gcm, name)


def _sha256(path: str) -> str:
Expand Down Expand Up @@ -644,10 +758,12 @@ def main() -> None:
git_source = resolve_path(config, "gitsource", "GIT_SOURCE_PATH", required=True)
build_git_macos(git_source, dest, arch)
build_gitlfs(gitlfs, dest, goos="darwin", goarch="amd64" if arch == "x86_64" else "arm64")
bundle_gcm(dest, arch)
elif platform.system() == "Linux":
git_source = resolve_path(config, "gitsource", "GIT_SOURCE_PATH", required=True)
build_git_linux(git_source, dest)
build_gitlfs(gitlfs, dest, goos="linux", goarch="amd64" if arch == "x86_64" else "arm64")
bundle_gcm(dest, arch)
else:
raise SystemExit("[git-dist] unsupported platform (Windows, macOS, and Linux only)")

Expand Down
16 changes: 16 additions & 0 deletions gcm-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- Hardened-runtime entitlements for the bundled Git Credential Manager.
The .NET CoreCLR JIT-compiles into writable+executable pages, which the
hardened runtime forbids without these. Mirrors GCM's own release
signatures (src/osx/Installer.Mac/entitlements.xml upstream). -->
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
Loading