Yeet those builds out!

feat: support SOURCE_DATE_EPOCH (#26)

I have found out something horrifying about how tools deal with time:
some tools will interpret a zero time value as "has no time". This is
horrifying. As such, I have implemented support for SOURCE_DATE_EPOCH,
which will be auto-populated by the commit date in the current working
directory, or be overrided with the environment variable value.

This means that yeet package contents won't be "50 years old", which can
cause some issues for downstream consumers.

https://reproducible-builds.org/docs/source-date-epoch/

Signed-off-by: Xe Iaso <me@xeiaso.net>

authored by Xe Iaso and committed by GitHub c01c3db5 d3e5a176

Changed files
+172 -96
internal
+36 -5
internal/git.go
··· 3 3 import ( 4 4 "context" 5 5 "flag" 6 + "log/slog" 6 7 "os" 8 + "os/exec" 7 9 "path/filepath" 10 + "strconv" 11 + "strings" 12 + "time" 8 13 9 14 "github.com/Songmu/gitconfig" 10 15 "github.com/TecharoHQ/yeet/internal/yeet" 11 16 ) 12 17 13 18 var ( 14 - GPGKeyFile = flag.String("gpg-key-file", gpgKeyFileLocation(), "GPG key file to sign the package") 15 - GPGKeyID = flag.String("gpg-key-id", "", "GPG key ID to sign the package") 16 - GPGKeyPassword = flag.String("gpg-key-password", "", "GPG key password to sign the package") 17 - UserName = flag.String("git-user-name", GitUserName(), "user name in Git") 18 - UserEmail = flag.String("git-user-email", GitUserEmail(), "user email in Git") 19 + GPGKeyFile = flag.String("gpg-key-file", gpgKeyFileLocation(), "GPG key file to sign the package") 20 + GPGKeyID = flag.String("gpg-key-id", "", "GPG key ID to sign the package") 21 + GPGKeyPassword = flag.String("gpg-key-password", "", "GPG key password to sign the package") 22 + UserName = flag.String("git-user-name", GitUserName(), "user name in Git") 23 + UserEmail = flag.String("git-user-email", GitUserEmail(), "user email in Git") 24 + SourceDateEpoch = flag.Int64("source-date-epoch", GetSourceDateEpoch(), "Timestamp to use for all files in packages") 19 25 ) 20 26 21 27 const ( ··· 58 64 59 65 return vers 60 66 } 67 + 68 + func GetSourceDateEpoch() int64 { 69 + // fallback needs to be 1 because some software thinks unix time 0 means "no time" 70 + const fallback = 1 71 + 72 + gitPath, err := exec.LookPath("git") 73 + if err != nil { 74 + slog.Warn("git not found in $PATH", "err", err) 75 + return fallback 76 + } 77 + 78 + epochFromGitStr, err := yeet.Output(context.Background(), gitPath, "log", "-1", "--format=%ct") 79 + if err == nil { 80 + num, _ := strconv.ParseInt(strings.TrimSpace(epochFromGitStr), 10, 64) 81 + if num != 0 { 82 + return num 83 + } 84 + } 85 + 86 + return fallback 87 + } 88 + 89 + func SourceEpoch() time.Time { 90 + return time.Unix(*SourceDateEpoch, 0) 91 + }
+8 -9
internal/mkdeb/mkdeb.go
··· 6 6 "os" 7 7 "path/filepath" 8 8 "runtime" 9 - "time" 10 9 11 10 "github.com/Masterminds/semver/v3" 12 11 "github.com/TecharoHQ/yeet/internal" ··· 76 75 Type: files.TypeDir, 77 76 Destination: d, 78 77 FileInfo: &files.ContentFileInfo{ 79 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 78 + MTime: internal.SourceEpoch(), 80 79 Mode: os.FileMode(0600), 81 80 }, 82 81 }) ··· 89 88 Destination: osPath, 90 89 FileInfo: &files.ContentFileInfo{ 91 90 Mode: os.FileMode(0600), 92 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 91 + MTime: internal.SourceEpoch(), 93 92 }, 94 93 }) 95 94 } ··· 100 99 Source: repoPath, 101 100 Destination: filepath.Join("/usr/share/doc", p.Name, rpmPath), 102 101 FileInfo: &files.ContentFileInfo{ 103 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 102 + MTime: internal.SourceEpoch(), 104 103 }, 105 104 }) 106 105 } ··· 111 110 Source: repoPath, 112 111 Destination: rpmPath, 113 112 FileInfo: &files.ContentFileInfo{ 114 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 113 + MTime: internal.SourceEpoch(), 115 114 }, 116 115 }) 117 116 } ··· 130 129 Source: path, 131 130 Destination: path[len(dir)+1:], 132 131 FileInfo: &files.ContentFileInfo{ 133 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 132 + MTime: internal.SourceEpoch(), 134 133 }, 135 134 }) 136 135 ··· 139 138 return "", fmt.Errorf("mkdeb: can't walk output directory: %w", err) 140 139 } 141 140 142 - contents, err = files.PrepareForPackager(contents, 0o002, "deb", true, time.Unix(0, 0)) 141 + contents, err = files.PrepareForPackager(contents, 0o002, "deb", true, internal.SourceEpoch()) 143 142 if err != nil { 144 143 return "", fmt.Errorf("mkdeb: can't prepare for packager: %w", err) 145 144 } 146 145 147 146 for _, content := range contents { 148 - content.FileInfo.MTime = time.Unix(0, 0) 147 + content.FileInfo.MTime = internal.SourceEpoch() 149 148 } 150 149 151 150 info := nfpm.WithDefaults(&nfpm.Info{ ··· 157 156 Maintainer: fmt.Sprintf("%s <%s>", *internal.UserName, *internal.UserEmail), 158 157 Homepage: p.Homepage, 159 158 License: p.License, 160 - MTime: time.Unix(0, 0), 159 + MTime: internal.SourceEpoch(), 161 160 Overridables: nfpm.Overridables{ 162 161 Contents: contents, 163 162 Depends: p.Depends,
+7 -7
internal/mkrpm/mkrpm.go
··· 80 80 Type: files.TypeDir, 81 81 Destination: d, 82 82 FileInfo: &files.ContentFileInfo{ 83 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 83 + MTime: internal.SourceEpoch(), 84 84 }, 85 85 }) 86 86 } ··· 92 92 Destination: rpmPath, 93 93 FileInfo: &files.ContentFileInfo{ 94 94 Mode: os.FileMode(0600), 95 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 95 + MTime: internal.SourceEpoch(), 96 96 }, 97 97 }) 98 98 } ··· 103 103 Source: repoPath, 104 104 Destination: filepath.Join("/usr/share/doc", p.Name, rpmPath), 105 105 FileInfo: &files.ContentFileInfo{ 106 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 106 + MTime: internal.SourceEpoch(), 107 107 }, 108 108 }) 109 109 } ··· 114 114 Source: repoPath, 115 115 Destination: rpmPath, 116 116 FileInfo: &files.ContentFileInfo{ 117 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 117 + MTime: internal.SourceEpoch(), 118 118 }, 119 119 }) 120 120 } ··· 133 133 Source: path, 134 134 Destination: path[len(dir)+1:], 135 135 FileInfo: &files.ContentFileInfo{ 136 - MTime: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), 136 + MTime: internal.SourceEpoch(), 137 137 }, 138 138 }) 139 139 ··· 148 148 } 149 149 150 150 for _, content := range contents { 151 - content.FileInfo.MTime = time.Unix(0, 0) 151 + content.FileInfo.MTime = internal.SourceEpoch() 152 152 } 153 153 154 154 info := nfpm.WithDefaults(&nfpm.Info{ ··· 160 160 Maintainer: fmt.Sprintf("%s <%s>", *internal.UserName, *internal.UserEmail), 161 161 Homepage: p.Homepage, 162 162 License: p.License, 163 - MTime: time.Unix(0, 0), 163 + MTime: internal.SourceEpoch(), 164 164 Overridables: nfpm.Overridables{ 165 165 Contents: contents, 166 166 Depends: p.Depends,
+1 -1
internal/mktarball/mktarball.go
··· 123 123 return "", fmt.Errorf("can't open root FS %s: %w", dir, err) 124 124 } 125 125 126 - if err := tw.AddFS(vfs.MtimeZeroFS{FS: root.FS()}); err != nil { 126 + if err := tw.AddFS(vfs.ModTimeFS{FS: root.FS(), Time: internal.SourceEpoch()}); err != nil { 127 127 return "", fmt.Errorf("can't copy built files to tarball: %w", err) 128 128 } 129 129
+42
internal/mktarball/mktarball_test.go
··· 1 1 package mktarball 2 2 3 3 import ( 4 + "archive/tar" 5 + "compress/gzip" 6 + "io" 7 + "os" 4 8 "testing" 5 9 10 + "github.com/TecharoHQ/yeet/internal" 6 11 "github.com/TecharoHQ/yeet/internal/yeettest" 7 12 ) 8 13 ··· 13 18 func TestBuildError(t *testing.T) { 14 19 yeettest.BuildHello(t, Build, ".0.0", false) 15 20 } 21 + 22 + func TestTimestampsNotZero(t *testing.T) { 23 + pkg := yeettest.BuildHello(t, Build, "1.0.0", true) 24 + 25 + fin, err := os.Open(pkg) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + defer fin.Close() 30 + 31 + gzr, err := gzip.NewReader(fin) 32 + if err != nil { 33 + t.Fatal(err) 34 + } 35 + defer gzr.Close() 36 + 37 + tr := tar.NewReader(gzr) 38 + 39 + for { 40 + header, err := tr.Next() 41 + switch { 42 + case err == io.EOF: 43 + return 44 + case err != nil: 45 + t.Fatal(err) 46 + } 47 + 48 + expect := internal.SourceEpoch() 49 + 50 + t.Run(header.Name, func(t *testing.T) { 51 + header := header 52 + if !header.ModTime.Equal(expect) { 53 + t.Errorf("file has wrong timestamp %s, wanted: %s", header.ModTime, expect) 54 + } 55 + }) 56 + } 57 + }
+78
internal/vfs/modtimefs.go
··· 1 + package vfs 2 + 3 + import ( 4 + "io/fs" 5 + "time" 6 + ) 7 + 8 + // ModTimeFS wraps an fs.FS and overrides all file mtimes with a fixed time. 9 + type ModTimeFS struct { 10 + fs.FS 11 + Time time.Time 12 + } 13 + 14 + // Open overrides the FS.Open method to wrap returned files. 15 + func (m ModTimeFS) Open(name string) (fs.File, error) { 16 + f, err := m.FS.Open(name) 17 + if err != nil { 18 + return nil, err 19 + } 20 + return &modTimeFile{File: f, Time: m.Time}, nil 21 + } 22 + 23 + // ReadDir implements fs.ReadDirFS if the underlying FS supports it. 24 + func (m ModTimeFS) ReadDir(name string) ([]fs.DirEntry, error) { 25 + readDirFS, ok := m.FS.(fs.ReadDirFS) 26 + if !ok { 27 + return nil, &fs.PathError{Op: "ReadDir", Path: name, Err: fs.ErrInvalid} 28 + } 29 + 30 + entries, err := readDirFS.ReadDir(name) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + wrapped := make([]fs.DirEntry, len(entries)) 36 + for i, entry := range entries { 37 + wrapped[i] = modTimeDirEntry{DirEntry: entry, Time: m.Time} 38 + } 39 + return wrapped, nil 40 + } 41 + 42 + // modTimeFile wraps fs.File to override Stat().ModTime(). 43 + type modTimeFile struct { 44 + fs.File 45 + Time time.Time 46 + } 47 + 48 + func (f *modTimeFile) Stat() (fs.FileInfo, error) { 49 + info, err := f.File.Stat() 50 + if err != nil { 51 + return nil, err 52 + } 53 + return modTimeFileInfo{FileInfo: info, Time: f.Time}, nil 54 + } 55 + 56 + // modTimeFileInfo overrides ModTime to return a fixed time. 57 + type modTimeFileInfo struct { 58 + fs.FileInfo 59 + Time time.Time 60 + } 61 + 62 + func (fi modTimeFileInfo) ModTime() time.Time { 63 + return fi.Time 64 + } 65 + 66 + // modTimeDirEntry wraps fs.DirEntry to override Info().ModTime(). 67 + type modTimeDirEntry struct { 68 + fs.DirEntry 69 + Time time.Time 70 + } 71 + 72 + func (d modTimeDirEntry) Info() (fs.FileInfo, error) { 73 + info, err := d.DirEntry.Info() 74 + if err != nil { 75 + return nil, err 76 + } 77 + return modTimeFileInfo{FileInfo: info, Time: d.Time}, nil 78 + }
-74
internal/vfs/mtimezero.go
··· 1 - package vfs 2 - 3 - import ( 4 - "io/fs" 5 - "time" 6 - ) 7 - 8 - // MtimeZeroFS wraps an fs.FS and overrides all file mtimes to time.Unix(0, 0). 9 - type MtimeZeroFS struct { 10 - fs.FS 11 - } 12 - 13 - // Open overrides the FS.Open method to wrap returned files. 14 - func (m MtimeZeroFS) Open(name string) (fs.File, error) { 15 - f, err := m.FS.Open(name) 16 - if err != nil { 17 - return nil, err 18 - } 19 - return &mtimeZeroFile{File: f}, nil 20 - } 21 - 22 - // ReadDir implements fs.ReadDirFS if the underlying FS supports it. 23 - func (m MtimeZeroFS) ReadDir(name string) ([]fs.DirEntry, error) { 24 - readDirFS, ok := m.FS.(fs.ReadDirFS) 25 - if !ok { 26 - return nil, &fs.PathError{Op: "ReadDir", Path: name, Err: fs.ErrInvalid} 27 - } 28 - 29 - entries, err := readDirFS.ReadDir(name) 30 - if err != nil { 31 - return nil, err 32 - } 33 - 34 - wrapped := make([]fs.DirEntry, len(entries)) 35 - for i, entry := range entries { 36 - wrapped[i] = mtimeZeroDirEntry{DirEntry: entry} 37 - } 38 - return wrapped, nil 39 - } 40 - 41 - // mtimeZeroFile wraps fs.File to override Stat().ModTime(). 42 - type mtimeZeroFile struct { 43 - fs.File 44 - } 45 - 46 - func (f *mtimeZeroFile) Stat() (fs.FileInfo, error) { 47 - info, err := f.File.Stat() 48 - if err != nil { 49 - return nil, err 50 - } 51 - return mtimeZeroFileInfo{FileInfo: info}, nil 52 - } 53 - 54 - // mtimeZeroFileInfo overrides ModTime to return time.Unix(0, 0). 55 - type mtimeZeroFileInfo struct { 56 - fs.FileInfo 57 - } 58 - 59 - func (fi mtimeZeroFileInfo) ModTime() time.Time { 60 - return time.Unix(0, 0) 61 - } 62 - 63 - // mtimeZeroDirEntry wraps fs.DirEntry to override Info().ModTime(). 64 - type mtimeZeroDirEntry struct { 65 - fs.DirEntry 66 - } 67 - 68 - func (d mtimeZeroDirEntry) Info() (fs.FileInfo, error) { 69 - info, err := d.DirEntry.Info() 70 - if err != nil { 71 - return nil, err 72 - } 73 - return mtimeZeroFileInfo{FileInfo: info}, nil 74 - }