Skip to content

Commit 2ca92f8

Browse files
authored
ci(tools): Port install Git step to Go (#588)
Port the step to build and install a specific version of Git from source to Go from bash.
1 parent ca1ea0b commit 2ca92f8

File tree

2 files changed

+313
-19
lines changed

2 files changed

+313
-19
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,25 +62,11 @@ jobs:
6262
- name: Install Git
6363
shell: bash
6464
if: matrix.git-version != 'system'
65-
run: |
66-
if [[ ! -x "$GIT_CACHE_DIR/bin/git" ]]; then
67-
URL=https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.gz
68-
echo "Downloading Git $GIT_VERSION from $URL"
69-
sudo apt-get install \
70-
dh-autoreconf libcurl4-gnutls-dev libexpat1-dev gettext \
71-
libz-dev libssl-dev
72-
GIT_SRC_DIR=$(mktemp -d)
73-
( mkdir -p "$GIT_SRC_DIR" &&
74-
cd "$GIT_SRC_DIR" &&
75-
(curl -sSL "$URL" | tar -xz --strip-components=1) &&
76-
make prefix="$GIT_CACHE_DIR" &&
77-
make prefix="$GIT_CACHE_DIR" install )
78-
fi
79-
if [[ ! -x "$GIT_CACHE_DIR/bin/git" ]]; then
80-
echo "Failed to build Git $GIT_VERSION"
81-
exit 1
82-
fi
83-
echo "$GIT_CACHE_DIR/bin" >> "$GITHUB_PATH"
65+
run: >-
66+
go run ./tools/ci/install-git
67+
-debian
68+
-prefix "$GIT_CACHE_DIR"
69+
-version "$GIT_VERSION"
8470
- name: Report Git version
8571
shell: bash
8672
run:

tools/ci/install-git/main.go

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// install-git downloads and installs a specific version of Git
2+
// to the given prefix.
3+
//
4+
// Dependencies are assumed to already be installed.
5+
package main
6+
7+
import (
8+
"archive/tar"
9+
"compress/gzip"
10+
"errors"
11+
"flag"
12+
"fmt"
13+
"io"
14+
"log"
15+
"net/http"
16+
"os"
17+
"os/exec"
18+
"path/filepath"
19+
"strings"
20+
)
21+
22+
func main() {
23+
log.SetFlags(0)
24+
log.SetPrefix("install-git: ")
25+
26+
var req installRequest
27+
flag.StringVar(&req.Prefix, "prefix", "", "Destination to install to")
28+
flag.StringVar(&req.Version, "version", "", "Version to install")
29+
flag.StringVar(&req.GithubPath, "github-path", os.Getenv("GITHUB_PATH"), "Path to the GitHub Actions PATH file")
30+
flag.BoolVar(&req.Debian, "debian", false, "Whether we're on a Debian-based system")
31+
flag.BoolVar(&req.NoCache, "no-cache", false, "Whether to ignore the cached version")
32+
flag.Parse()
33+
34+
if flag.NArg() > 0 {
35+
log.Fatalf("unexpected arguments: %v", flag.Args())
36+
}
37+
38+
if err := run(log.Default(), req); err != nil {
39+
log.Fatal(err)
40+
}
41+
}
42+
43+
type installRequest struct {
44+
Prefix string
45+
Version string
46+
47+
// Whether to ignore the cached version.
48+
NoCache bool
49+
50+
// Whether we're on a Debian-based system.
51+
// Determines how to install build dependencies.
52+
Debian bool
53+
54+
// GithubPath is the path to the GitHub Actions PATH file.
55+
// Any paths written to this file will be added to the PATH
56+
// for the action.
57+
GithubPath string
58+
}
59+
60+
func (r *installRequest) Validate() (err error) {
61+
r.Version = strings.TrimPrefix(r.Version, "v")
62+
if r.Version == "" {
63+
err = errors.Join(err, errors.New("-version is required"))
64+
}
65+
if r.Prefix == "" {
66+
err = errors.Join(err, errors.New("-prefix is required"))
67+
}
68+
return err
69+
}
70+
71+
var _gitBuildDependencies = []string{
72+
"dh-autoreconf",
73+
"libcurl4-gnutls-dev",
74+
"libexpat1-dev",
75+
"gettext",
76+
"libz-dev",
77+
"libssl-dev",
78+
}
79+
80+
func run(log *log.Logger, req installRequest) error {
81+
if err := req.Validate(); err != nil {
82+
return err
83+
}
84+
85+
// If prefix is specified and $prefix/bin/git already exists,
86+
// do nothing.
87+
binDir := filepath.Join(req.Prefix, "bin")
88+
gitExe := filepath.Join(binDir, "git")
89+
if _, err := os.Stat(gitExe); err != nil || req.NoCache {
90+
// If we're on a Debian-based system, we need to install
91+
// build dependencies with apt-get.
92+
if req.Debian {
93+
installArgs := append([]string{"apt-get", "install"}, _gitBuildDependencies...)
94+
if err := exec.Command("sudo", installArgs...).Run(); err != nil {
95+
return fmt.Errorf("apt-get: %w", wrapExecError(err))
96+
}
97+
}
98+
99+
srcDir, cleanup, err := downloadGit(log, req.Version)
100+
if err != nil {
101+
return fmt.Errorf("download git: %w", err)
102+
}
103+
defer cleanup()
104+
log.Printf("Extracted to: %v", srcDir)
105+
106+
if err := installGit(req.Prefix, srcDir); err != nil {
107+
return fmt.Errorf("install git: %w", err)
108+
}
109+
110+
if info, err := os.Stat(gitExe); err != nil {
111+
return fmt.Errorf("git not installed: %w", err)
112+
} else if info.Mode()&0o111 == 0 {
113+
return fmt.Errorf("git not executable: %v", gitExe)
114+
}
115+
} else {
116+
log.Printf("git %v already built at: %v", req.Version, gitExe)
117+
}
118+
119+
github := &githubAction{
120+
Log: logWithPrefix(log, "github: "),
121+
PathFile: req.GithubPath,
122+
}
123+
124+
if err := github.AddPath(binDir); err != nil {
125+
return fmt.Errorf("add path to GitHub Actions: %w", err)
126+
}
127+
128+
return nil
129+
}
130+
131+
func downloadGit(log *log.Logger, version string) (dir string, cleanup func(), err error) {
132+
dstPath, err := os.MkdirTemp("", "git-"+version+"-*")
133+
if err != nil {
134+
return "", nil, fmt.Errorf("create temp dir: %w", err)
135+
}
136+
defer func() {
137+
// If the operation fails for any reason beyond this point,
138+
// delete the temporary directory.
139+
if err != nil {
140+
err = errors.Join(err, os.RemoveAll(dstPath))
141+
}
142+
}()
143+
144+
dstDir, err := os.OpenRoot(dstPath)
145+
if err != nil {
146+
return "", nil, fmt.Errorf("open temp dir: %w", err)
147+
}
148+
defer func() { err = errors.Join(err, dstDir.Close()) }()
149+
150+
gitURL := fmt.Sprintf("https://mirrors.edge.kernel.org/pub/software/scm/git/git-%s.tar.gz", version)
151+
log.Printf("Downloading Git %v from: %v", version, gitURL)
152+
res, err := http.Get(gitURL)
153+
if err != nil {
154+
return "", nil, fmt.Errorf("http get: %w", err)
155+
}
156+
defer func() { _ = res.Body.Close() }()
157+
158+
var resBody io.Reader = res.Body
159+
if res.ContentLength > 0 {
160+
progress := &progressWriter{
161+
N: res.ContentLength,
162+
W: log.Writer(),
163+
}
164+
defer progress.Finish()
165+
resBody = io.TeeReader(resBody, progress)
166+
}
167+
168+
gzipReader, err := gzip.NewReader(resBody)
169+
if err != nil {
170+
return "", nil, fmt.Errorf("gunzip: %w", err)
171+
}
172+
173+
tarReader := tar.NewReader(gzipReader)
174+
for {
175+
hdr, err := tarReader.Next()
176+
if err != nil {
177+
if errors.Is(err, io.EOF) {
178+
// End of archive.
179+
break
180+
}
181+
182+
return "", nil, fmt.Errorf("read tar header: %w", err)
183+
}
184+
185+
// Inside the Git archive, the root directory is git-<version>.
186+
// Strip it from the path.
187+
_, name, ok := strings.Cut(hdr.Name, string(filepath.Separator))
188+
if !ok {
189+
log.Printf("WARN: Skipping unexpected root-level name: %v", hdr.Name)
190+
continue
191+
}
192+
if name == "" {
193+
// Root git-<version>/ directory. Ignore.
194+
continue
195+
}
196+
197+
if hdr.FileInfo().IsDir() {
198+
if err := dstDir.Mkdir(name, 0o755); err != nil {
199+
return "", nil, err
200+
}
201+
continue
202+
}
203+
204+
err = func() (err error) {
205+
dst, err := dstDir.Create(name)
206+
if err != nil {
207+
return err
208+
}
209+
defer func() { err = errors.Join(err, dst.Close()) }()
210+
211+
if _, err := io.Copy(dst, tarReader); err != nil {
212+
return fmt.Errorf("copy: %w", err)
213+
}
214+
215+
return nil
216+
}()
217+
if err != nil {
218+
return "", nil, fmt.Errorf("unpack %v: %w", name, err)
219+
}
220+
}
221+
222+
return dstPath, func() { _ = os.RemoveAll(dstPath) }, nil
223+
}
224+
225+
func installGit(prefix, srcDir string) error {
226+
buildCmd := exec.Command("make", "prefix="+prefix)
227+
buildCmd.Dir = srcDir
228+
buildCmd.Stdout = os.Stdout
229+
buildCmd.Stderr = os.Stderr
230+
if err := buildCmd.Run(); err != nil {
231+
return fmt.Errorf("make: %w", err)
232+
}
233+
234+
installCmd := exec.Command("make", "prefix="+prefix, "install")
235+
installCmd.Dir = srcDir
236+
installCmd.Stdout = os.Stdout
237+
installCmd.Stderr = os.Stderr
238+
if err := installCmd.Run(); err != nil {
239+
return fmt.Errorf("make install: %w", err)
240+
}
241+
242+
binDir := filepath.Join(prefix, "bin")
243+
gitExe := filepath.Join(binDir, "git")
244+
if info, err := os.Stat(gitExe); err != nil {
245+
return fmt.Errorf("stat %v: %w", gitExe, err)
246+
} else if info.Mode()&0o111 == 0 {
247+
return fmt.Errorf("git not executable: %v", gitExe)
248+
}
249+
250+
return nil
251+
}
252+
253+
type progressWriter struct {
254+
N int64
255+
W io.Writer
256+
257+
written int
258+
lastUpdate int
259+
}
260+
261+
func (w *progressWriter) Write(bs []byte) (int, error) {
262+
w.written += len(bs)
263+
// We want to post updates at 1% increments.
264+
// If it's been at least w.N/100 bytes since last update, post one.
265+
if w.written-w.lastUpdate >= int(w.N)/100 {
266+
fmt.Fprintf(w.W, "\r%v / %v downloaded", w.written, w.N)
267+
w.lastUpdate = w.written
268+
}
269+
return len(bs), nil
270+
}
271+
272+
func (w *progressWriter) Finish() {
273+
fmt.Fprintln(w.W)
274+
}
275+
276+
type githubAction struct {
277+
Log *log.Logger // required
278+
PathFile string
279+
}
280+
281+
func (a *githubAction) AddPath(path string) error {
282+
if a.PathFile == "" {
283+
return nil
284+
}
285+
286+
f, err := os.OpenFile(a.PathFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
287+
if err != nil {
288+
return err
289+
}
290+
defer func() { _ = f.Close() }()
291+
292+
a.Log.Printf("add path %q", path)
293+
_, err = fmt.Fprintf(f, "%s\n", path)
294+
return err
295+
}
296+
297+
func logWithPrefix(logger *log.Logger, prefix string) *log.Logger {
298+
return log.New(logger.Writer(), prefix, logger.Flags())
299+
}
300+
301+
func wrapExecError(err error) error {
302+
var exitErr *exec.ExitError
303+
if !errors.As(err, &exitErr) {
304+
return err
305+
}
306+
307+
return errors.Join(err, fmt.Errorf("stderr: %s", exitErr.Stderr))
308+
}

0 commit comments

Comments
 (0)