Skip to content

azure-pipelines: port build-git-installers to Azure Pipelines (Linux + macOS + Windows)#910

Draft
dscho wants to merge 16 commits intoazpfrom
azp-build-installers-windows
Draft

azure-pipelines: port build-git-installers to Azure Pipelines (Linux + macOS + Windows)#910
dscho wants to merge 16 commits intoazpfrom
azp-build-installers-windows

Conversation

@dscho
Copy link
Copy Markdown
Member

@dscho dscho commented May 1, 2026

Port the build-git-installers GitHub workflow at .github/workflows/build-git-installers.yml to the Azure Pipeline scaffolded on microsoft/azp at .azure-pipelines/release.yml, replacing the workflow's debsigs / Apple Developer cert / git signtool signing flows with ESRP throughout. All three OS jobs (Linux, macOS, Windows) are covered; each commit is small and reviewable.

The macOS pattern follows what git-credential-manager/.azure-pipelines/release.yml already does (same CP-401337-Apple KeyCode for MacAppDeveloperSign covering both binary codesign and productsign-equivalent .pkg signing, then MacAppNotarize with the bundle ID baked into .github/macos-installer/Makefile, all wrapped in the existing useArchive: true zip-then-sign-then-extract template).

The Windows job bootstraps the Git for Windows SDK manually because there is no AzP equivalent of git-for-windows/setup-git-for-windows-sdk@v1: download tar.exe / zstd.exe / msys-zstd-1.dll from git-for-windows/git-sdk-64, fetch the matching build-installers.tar.zst snapshot from the SDK repo's ci-artifacts rolling release (git-sdk-64 for x64, git-sdk-arm64 for ARM64), extract to C:\sdk, and reuse the existing .azure-pipelines/esrp/windows/esrpsign.sh wrapper for post-build signing.

The Linux job's first run (1ES build IDs 141 and 154) showed that the GitClientPME-1ESHostedPool-{intel,arm64}-pc images silently ignore the container: job-level directive; the first commit therefore drops the workflow's container path entirely and logs lsb_release -a instead, with sudo apt-get for the package install.

Per-commit messages carry the full implementation reasoning (which Make flags, why each ESRP operation, what the GitHub workflow's container/env-var/Node.js workarounds correspond to here).

A few items deliberately deferred as follow-ups:

  • Per-tarball GPG signing of Windows mingw-w64-git source packages (no clear ESRP mapping settled yet).
  • The MINGW-packages.bundle creation (depends on a PKGBUILD.<tag_name> snapshot that microsoft/git does not currently ship).
  • Routing Inno Setup's signtool through ESRP via a git signtool alias (mjcheetham's stated next step in 406d385 (azure-pipelines: add ESRP code signing, 2026-04-30)).
  • Post-sign install/runtime validation on a target Ubuntu before any release cutover (the GitHub workflow ran debsigs --verify --check after signing).
  • Trimming the GitHub release task's linux_*/.tar.gz glob (we do not yet produce a tarball alongside the .deb).

Marked as draft because the pipeline has only been triggered for the Linux jobs so far (and that one trip is what produced the container/sudo discovery above); the macOS and Windows steps have not been exercised against the 1ES pools yet.

Supersedes #909.

dscho added 16 commits May 1, 2026 11:24
Port the build-dependency setup from the GitHub workflow's
create-linux-unsigned-artifacts job
(.github/workflows/build-git-installers.yml). The package list
matches one for one: build-essential for the C toolchain,
tcl tk for git-gui, gettext for i18n, asciidoc and xmlto for
documentation, libcurl4-gnutls-dev / libpcre2-dev / zlib1g-dev /
libexpat-dev for the Git build flags this pipeline will use, and
curl / ca-certificates for any in-job downloads.

The GitHub workflow runs all of this inside an ubuntu:20.04 /
ubuntu:22.04 container, both to pin the resulting .deb's glibc
ABI floor and to give apt-get a root-owned filesystem. The 1ES
pool images we run on
(GitClientPME-1ESHostedPool-{intel,arm64}-pc) silently ignore a
job-level `container:` directive, so the build executes on the
bare Ubuntu host VM as the unprivileged agent user. Run apt-get
via `sudo`, and to at least pin the .deb's glibc floor to
something an audit can read back, log the running Ubuntu version,
kernel, and effective UID at the start of the job.

Two pieces of the workflow's setup are intentionally left out:

The DEBIAN_FRONTEND=noninteractive / TZ=Etc/UTC env vars exist
only to keep `tzdata` from prompting interactively when it gets
pulled in inside the container (see 842cfa4 (fixup!
release: build unsigned Ubuntu .deb package, 2025-02-13)); the
bare 1ES image already has tzdata configured and they would have
no effect.

The Node.js workaround exists to satisfy GitHub Actions' Node-based
shim, which Azure Pipelines does not need.

Re-introducing a real container later (whether via 1ES's container
option, a custom container job, or a build inside docker invoked
from a step) is a separate question.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Replace the Linux dummy build with the real package build, ported
from the create-linux-unsigned-artifacts job in
.github/workflows/build-git-installers.yml.

The pipeline already wires $(git_version) from the prereqs stage,
so the version no longer comes from a tag_version job output.
$(deb_arch) is taken straight from the matrix entry, dropping the
GitHub workflow's runtime dpkg-architecture round-trip; the matrix
already names amd64 and arm64 explicitly. Parallelism switches from
the workflow's hard-coded -j5 (a runner-specific holdover) to
-j$(nproc), which is the common default and adapts to whatever the
1ES pool gives us.

The shell prologue switches from `set -ex` to `set -euo pipefail`
so an unbound variable or a failed step in a pipeline aborts the
job rather than silently producing a broken .deb.

The make recipe and DEBIAN/control body match the workflow byte for
byte: same DESTDIR layout, same Make flags (USE_LIBPCRE,
USE_CURL_FOR_IMAP_SEND, NO_OPENSSL, NO_CROSS_DIRECTORY_HARDLINKS,
ASCIIDOC8, ASCIIDOC_NO_ROFF, ASCIIDOC='TZ=UTC asciidoc'), same
prefix and gitexecdir/libexecdir/htmldir, same install targets,
and the same Depends list and Description text. The only
intentional content change is the package version and architecture
fields, which now come from $(git_version) and $(deb_arch). The
sed 's/-rc/.rc/g' substitution carried over because Git's
GIT-VERSION-GEN expects rc tags spelled with a dot.

Output goes under $(Build.ArtifactStagingDirectory)/app/, where
the existing ESRP signing template will pick it up via its
'**/*.deb' pattern. The collect step below still uses the dummy
'cp -R app/* _final/' shape; tightening that is the next commit.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Replace the Linux dummy collect step with a focused move of just the
signed microsoft-git_<version>_<arch>.deb into
$(Build.ArtifactStagingDirectory)/_final/, which the existing
templateContext.outputs.pipelineArtifact already publishes as the
linux_x64 / linux_arm64 artifact.

The dummy version copied everything under app/* into _final/, which
worked in isolation but would have started silently uploading any
intermediate files (including the pkgroot/ staging tree the build
step now writes alongside the .deb). Naming the file precisely also
turns "ESRP signed something else" into a missing-file error rather
than a silent wrong-artifact upload.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Port the dual-architecture Homebrew install from the GitHub
workflow's create-macos-artifacts job
(.github/workflows/build-git-installers.yml). The native
Homebrew on the macOS-latest pool image is arm64 and lives at
/opt/homebrew. To produce a universal binary we additionally need
the x86_64 build of gettext/libintl, so install a separate x86_64
Homebrew under /usr/local via the upstream installer running under
Rosetta and pull gettext from there as well.

The native arm64 brew installs the rest of the build chain:
automake, asciidoc, xmlto, and docbook (the same set the workflow
installs).

The two arch-specific libintl.a copies are then combined with lipo
into a universal archive at the workspace root, where the upcoming
config.mak's LDFLAGS = -L"$(pwd)" will find it. libintl depends on
iconv, but the system /usr/lib/libiconv.dylib is already universal
and exports the _iconv* symbols Homebrew's gettext was built
against; Homebrew's own libiconv exports _libiconv* and would not
link, which is why the comment from the workflow is preserved here.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Port the config.mak generation from the GitHub workflow's
create-macos-artifacts job. With the universal libintl.a from the
previous step in place, the build needs the Make flags that turn
on the dual-arch compile and that route around several macOS
quirks: HOST_CPU=universal, dual -arch CFLAGS (the actual
universal-binary driver), -DNO_OPENSSL for contrib Makefiles that
do not see the main Makefile's NO_OPENSSL handling, and explicit
USE_HOMEBREW_LIBICONV/ICONVDIR overrides so we link against the
universal /usr/lib/libiconv.dylib (whose unprefixed _iconv*
symbols are what Homebrew's gettext was built against, unlike
Homebrew's own libiconv with its prefixed _libiconv* symbols).

CFLAGS adds the gettext include dirs from both Homebrew prefixes;
LDFLAGS = -L"$(pwd)" so the linker finds the universal libintl.a
in the workspace root. CURL_LDFLAGS / CURL_CONFIG pin against the
OS-supplied libcurl rather than a Homebrew copy. Finally,
SKIP_DASHED_BUILT_INS disables the dashed built-ins; on macOS the
hard-link optimisation does not kick in for the staging tree and
the resulting full copies would bloat the eventual .dmg.

Compared with the GitHub workflow, the differences are mechanical:
the source tree is at $(Build.SourcesDirectory) (no actions/checkout
"path: git" subdir), so config.mak and the version file land at the
worktree root; $(git_version) replaces tag_version; and the script
prologue is set -euo pipefail rather than set -ex.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Port the build sequence from the GitHub workflow's
create-macos-artifacts step. Runs `make GIT-VERSION-FILE dist
dist-doc` in the source tree, recovers the original commit OID
from the resulting source tarball with `git get-tar-commit-id`
(this becomes GIT_BUILT_FROM_COMMIT, which the macos-installer
Makefile bakes into `git version --build-options`), then
extracts the source and manpage tarballs into payload/ and
manpages/, drops a copy of the worktree config.mak inside the
extracted source so the universal-build flags apply during the
real compile, and finally runs `make -C .github/macos-installer
payload` to produce the universal binary tree.

The macos-installer Makefile derives BUILD_DIR from
$(GITHUB_WORKSPACE), which is unset under ADO. Setting
GITHUB_WORKSPACE=$(Build.SourcesDirectory) in the task env block
gives the Makefile the same anchor it had under GitHub Actions,
so payload/git-$VERSION/ ends up where the targets expect it.

XML_CATALOG_FILES is exported to the catalogs from the Homebrew
docbook installed in the dependencies step, so asciidoc/xmlto can
resolve their DTDs during dist-doc.

VERSION is exported because the macos-installer Makefile reads
it directly. The .rc/-rc rewrite stays consistent with the
config-generation step: the on-disk version file has the .rc
form (Git's GIT-VERSION-GEN convention), while the package and
artifact filenames keep the original tag spelling (which is
what the Makefile's ORIGINAL_VERSION tracks).

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Replace the dummy "Hello, Mac!" build placeholder and the dummy
ESRP signing block with a real signing flow against the universal
payload built in the previous step.

The pattern follows
git-credential-manager/.azure-pipelines/release.yml: pre-filter the
payload to just the Mach-O files (using `file --mime` matching
`mach`, the same heuristic the existing
.github/scripts/codesign.sh uses), copy that subset into a staging
directory under $(Build.ArtifactStagingDirectory)/macos-tosign/
preserving relative paths, hand the staging dir off to the existing
.azure-pipelines/esrp/sign.yml template (which zips, signs via
EsrpCodeSigning@6 with KeyCode CP-401337-Apple +
OperationCode MacAppDeveloperSign + Hardening enabled, and
extracts back into the staging dir), then copy the signed binaries
back over the payload tree.

The pre-filter is necessary because the existing template's
CopyFiles@2 step uses minimatch globs and the only reliable way to
pick out Mach-O files is by file content. Signing the entire
payload tree would either fail on non-binary files or sign things
that should not be signed (shell scripts, perl, manpages,
templates, the uninstall.sh).

UseDotNet@2 (8.x) installs the .NET SDK that EsrpCodeSigning@6
depends on; the macOS-latest pool image does not provide it. The
whole block is gated on the `esrp` pipeline parameter, matching
the existing convention in the file.

This commit replaces both the dummy build (which produced
$(Build.ArtifactStagingDirectory)/app/example) and the dummy ESRP
call (which signed that fake artifact) with a real signing flow
against the payload we now build for real. The dummy collect step
remains for now and is replaced when the .pkg/.dmg flow lands.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Run the macos-installer Makefile's `pkg` target to produce
.github/macos-installer/disk-image/git-<version>-universal.pkg
from the signed payload tree built and ESRP-signed in the
preceding steps.

Crucially we leave APPLE_INSTALLER_IDENTITY undefined. The
Makefile's pkg_cmd has an `ifdef APPLE_INSTALLER_IDENTITY` branch
that adds `--sign "<identity>"` to the pkgbuild invocation; with
the variable unset, pkgbuild produces an unsigned .pkg, which
ESRP then signs in the next commit. This is the whole point of
the migration: replace the `productsign`-via-pkgbuild path with
ESRP signing of the resulting .pkg.

GITHUB_WORKSPACE and VERSION are exported for the same reason as
in the payload step: the Makefile reads them directly and there
is no GitHub Actions runtime under ADO to set them automatically.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Sign the unsigned .pkg produced in the previous step. The same
ESRP path used for the Mach-O binaries works here too: KeyCode
CP-401337-Apple covers both Developer ID Application and Developer
ID Installer certs in this account, so MacAppDeveloperSign on a
.pkg is the productsign equivalent.

git-credential-manager/.azure-pipelines/release.yml uses exactly
this pattern (same KeyCode and OperationCode, just pointed at the
.pkg instead of the binary tree), so the sign-then-extract cycle
through the existing esrp/sign.yml template applies unchanged.
folderPath is the macos-installer's disk-image/ directory, where
the previous step deposited the unsigned .pkg; the template's
ExtractFiles@1 writes the signed .pkg back over it.

This replaces the productsign --sign branch in the macos-installer
Makefile's pkg_cmd, which we deliberately did not exercise (we
left APPLE_INSTALLER_IDENTITY undefined in the previous commit so
pkgbuild produced an unsigned .pkg).

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Submit the signed .pkg through ESRP for Apple notarization. Same
KeyCode (CP-401337-Apple) and same template as the previous two
ESRP calls, but a different OperationCode (MacAppNotarize) and a
required BundleId parameter.

The bundle identifier comes straight from the macos-installer
Makefile's pkg_cmd, which invokes
`pkgbuild --identifier com.git.pkg`. Reusing it here keeps the
notarization request consistent with the actual identifier baked
into the package.

This replaces the `xcrun notarytool submit ... --wait` plus
`xcrun stapler staple` flow that .github/scripts/notarize.sh
performs in the GitHub workflow's `make notarize` target. The
ESRP MacAppNotarize operation handles both the submission and the
ticket stapling, returning the notarized .pkg back into
disk-image/ via the same zip-extract template path the previous
sign step used.

git-credential-manager/.azure-pipelines/release.yml uses the same
operation with its own BundleId; that pipeline is the working
reference for this combination of KeyCode, OperationCode, and
useArchive: true.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Run the macos-installer Makefile's `image` target to build
.github/macos-installer/git-<version>-universal.dmg from the
contents of disk-image/, which by this point contains the signed
and notarized .pkg from the previous ESRP step. hdiutil's
behaviour is unchanged from the GitHub workflow path; only the
trigger is.

The final stage step replaces the placeholder dummy collect step
that pointed at the long-dead $(Build.ArtifactStagingDirectory)/app
directory. The .dmg lands at the macos-installer root and the
signed-and-notarized .pkg lives in disk-image/; we move both
(named precisely, not globbed) into
$(Build.ArtifactStagingDirectory)/_final/, which the job's
templateContext.outputs already publishes as the macos_universal
pipeline artifact.

Naming the files instead of globbing turns "the build skipped one"
into a missing-file error rather than a silent half-empty upload,
matching the same defensive choice the Linux stage step makes.

GITHUB_WORKSPACE and VERSION are exported for the same reason as
the earlier macos-installer Makefile invocations: the Makefile
reads them directly and ADO does not set them automatically.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
GitHub Actions has the git-for-windows/setup-git-for-windows-sdk@v1
action that drops a full SDK onto the runner; ADO has no equivalent
task, so the Windows job has to bootstrap the SDK by hand before it
can run any of the bash-driven build steps the GitHub workflow
relies on.

The SDK is published as a build-installers.tar.zst snapshot in the
`ci-artifacts` rolling release of git-for-windows/git-sdk-64 (and
git-sdk-arm64 for ARM64). Add a `sdk_repo` field to each
windows_matrix entry so the new download step can pick the right
one, and surface it as a job-level variable.

Decompressing zstd archives is the awkward bit: Windows 2022's
built-in tar.exe (BSD tar) does not understand zstd; Git for
Windows' GNU tar can but only if zstd.exe is on the PATH; and we
cannot assume Git for Windows is even installed on the agent. The
robust path, as suggested by Johannes Schindelin in the planning
discussion for this PR, is to download tar.exe, zstd.exe, and
msys-zstd-1.dll directly from
https://github.com/git-for-windows/git-sdk-64/raw/HEAD/usr/bin
into a tools dir, prepend that dir to PATH (in this task via
$env:PATH so the extraction can use them, and via
##vso[task.prependpath] so subsequent tasks see them too), then
extract the SDK archive with `tar --use-compress-program='zstd -d'`
into C:\sdk.

The downloaded MSYS2 binaries depend on msys-2.0.dll, which is
satisfied either by the agent's bundled MinGit (which the prior
setup-git-bash.cmd step puts on the PATH) or, after extraction, by
the SDK's own usr/bin. Both paths are kept on PATH after the
bootstrap so subsequent bash tasks find a working bash, and the
matching MinGW toolchain (mingw64 for x64, clangarm64 for ARM64)
is also surfaced.

If 1ES network egress later turns out to forbid those raw and
release downloads, the same bytes can be vendored via Secure Files
or fetched via a partial clone of git-sdk-64; that switch is a
follow-up.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Git for Windows' build tooling (please.sh, signtool.sh, the
installer .iss templates, and the MINGW-packages helpers) lives in
git-for-windows/build-extra rather than in the SDK snapshot. The
GitHub workflow's Windows job clones it into /usr/src/build-extra
of the SDK before invoking please.sh; mirror that here.

A partial clone (--filter=blob:none) plus --single-branch -b main
is enough for everything please.sh needs and avoids pulling the
full blob history; it matches the workflow's invocation byte for
byte.

The bash task picks up the SDK's bash from the PATH set up by the
preceding bootstrap step, so /usr/src resolves into the SDK at
C:\sdk\usr\src as expected.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Drive please.sh build-mingw-w64-git from a Bash@3 task using the
SDK's bash that the bootstrap step put on the PATH. Outputs land
in $(Build.SourcesDirectory)/artifacts/ so the subsequent
installer-build step can pass them to please.sh
make_installers_from_mingw_w64_git via --pkg= flags.

Three small adaptations from the GitHub workflow's source step:

The /usr/bin/git trampoline that delegates to the matching
MinGW-built git.exe is the same one the workflow writes by hand;
makepkg-mingw shells out to plain `git`, and the SDK bash's git
candidates would otherwise come from MinGit, not the toolchain
we are building against.

The user.name / user.email / PACKAGER values used to be the
GitHub actor; on ADO there is no equivalent identity, so the
initial port hardcodes a build-bot identity. If this needs to
attribute to a specific human or service principal later, that is
a one-line change here.

please.sh's --only-<arch> flag takes the bare CPU name (x86_64 or
aarch64), not the toolchain triple. windows_x64 already happens
to match (toolchain=x86_64), but windows_arm64's toolchain is
clang-aarch64, so a small case statement maps it to aarch64.
Adding another arch later would extend the case rather than touch
the matrix shape.

Two pieces of the workflow's pkg-build step are intentionally not
ported in this commit and noted in a comment for follow-up: the
per-tarball GPG signing (replaceable with an ESRP PGP operation
analogous to the Linux LinuxSign flow if needed downstream), and
the MINGW-packages bundle creation, which depends on a
PKGBUILD.<tag_name> snapshot that microsoft/git does not currently
ship.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Drive please.sh make_installers_from_mingw_w64_git for both
installer and portable variants from a single bash task. The
GitHub workflow runs these as separate matrix jobs (one per
type/arch combination); the AzP version keeps both builds in the
same job so the .pkg.tar.* artifacts produced in the previous
step are available without an inter-job artifact passing trip.

The PDB archive copy into build-extra/cached-source-packages is
the same prerequisite that --include-pdbs needs in the GitHub
workflow. Build-extra reads from there to embed PDBs into the
installer; without the copy --include-pdbs is silently
ineffective.

The --pkg= filter that strips signatures and the optional
archimport / cvs / p4 / gitweb / doc-man pieces matches the
workflow's sed exactly so the resulting .exe sizes are
comparable.

The two .exe artifacts here are still unsigned: the GitHub
workflow relies on Inno Setup invoking signtool.sh via a
git signtool alias to sign the installer at build time, and a
post-build git signtool to sign the portable. Neither path is
wired here; the next commit applies ESRP signing to both .exe
artifacts after the build, which is the migration target this PR
series is moving towards. The SHA-256 sidecar the workflow emits
follows in that same next commit, since its content depends on
the signed bytes.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Replace the dummy ESRP-signing block (which copied calc.exe into
example*.exe and signed those) and the dummy collect block (which
xcopy'd the same fake artifacts into _final/) with the real
post-build flow.

The signing call hands artifacts/Git-*.exe and
artifacts/PortableGit-*.exe to the existing
.azure-pipelines/esrp/windows/esrpsign.sh wrapper that
mjcheetham landed earlier. That script reads ESRP_TOOL and
ESRP_AUTH from the env block (set by the setup.yml template above)
and SYSTEM_ACCESSTOKEN from $(System.AccessToken). The wrapper
script is the same one that a future `git signtool` alias is
intended to delegate to per the comment on commit 406d385,
so signing the installer at Inno Setup time becomes a follow-up
that does not require touching this step.

The SHA-256 sidecar moves here from the build commit so the hashes
reflect the bytes that ship: ESRP signing rewrites the .exe
contents, so a SHA-256 computed before signing would mismatch the
released artifact. When ESRP is disabled the openssl call still
runs against the unsigned binaries, which is the right answer in
that case.

The final cp into $(Build.ArtifactStagingDirectory)/_final/ ships
exactly Git-*.exe, PortableGit-*.exe, and sha-256.txt; the
templateContext.outputs.pipelineArtifact already publishes that
directory as the windows_x64 / windows_arm64 artifact.

Assisted-by: Claude Opus 4.7
Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant