Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: detect changes in sources #460

Merged
merged 10 commits into from
Dec 11, 2024
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"heimdalr",
"hhmmss",
"htmlcov",
"jedib",
"ldflags",
"mypy",
"opengrabeso",
Expand Down
126 changes: 125 additions & 1 deletion action/filesystem/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"

"github.com/plus3it/gorecurcopy"
)
Expand All @@ -20,6 +22,8 @@ var (
ErrPathIsDirectory = fmt.Errorf("provided path is directory: %w", os.ErrExist)
// ErrFileNotRegular is returned when path exists, but is not a regular file
ErrFileNotRegular = errors.New("file is not regular file")
// ErrFileModified is returned when a file in given path was modified
ErrFileModified = errors.New("file has been modified since")
)

// CheckFileExists checks if file exists at PATH
Expand Down Expand Up @@ -126,7 +130,11 @@ func MoveFile(pathSource, pathDestination string) error {
func DirTree(root string) ([]string, error) {
var files []string

err := filepath.Walk(root, func(path string, info os.FileInfo, _ error) error {
// WalkDir is faster than Walk
// https://pkg.go.dev/path/filepath#Walk
// > Walk is less efficient than WalkDir, introduced in Go 1.16, which avoids
// > calling os.Lstat on every visited file or directory.
err := filepath.WalkDir(root, func(path string, info os.DirEntry, _ error) error {
foundItem := path
if info.IsDir() {
foundItem = fmt.Sprintf("%s/", path)
Expand All @@ -141,3 +149,119 @@ func DirTree(root string) ([]string, error) {

return files, err
}

// LoadLastRunTime loads time of the last execution from file
func LoadLastRunTime(pathLastRun string) (time.Time, error) {
// Return zero time if file doesn't exist
err := CheckFileExists(pathLastRun)
if errors.Is(err, os.ErrNotExist) {
return time.Time{}, nil
}

content, err := os.ReadFile(pathLastRun)
// Return zero and error on reading error
if err != nil {
slog.Warn(
fmt.Sprintf("Error when reading file '%s'", pathLastRun),
slog.Any("error", err),
)
return time.Time{}, err
}

// File should contain time in RFC3339Nano format
lastRun, err := time.Parse(time.RFC3339Nano, string(content))
// Return zero and error on parsing error
if err != nil {
slog.Warn(
fmt.Sprintf("Error when parsing time-stamp from '%s'", pathLastRun),
slog.Any("error", err),
)
return time.Time{}, err
}
return lastRun, nil
}

// SaveCurrentRunTime writes the current time into file
func SaveCurrentRunTime(pathLastRun string) error {
// Create temporaryFilesDir

// Create directory if needed
dir := filepath.Dir(pathLastRun)
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return err
}

// Write the current time into file
return os.WriteFile(pathLastRun, []byte(time.Now().Format(time.RFC3339Nano)), 0o666)
}

// GetFileModTime returns modification time of a file
func GetFileModTime(filePath string) (time.Time, error) {
info, err := os.Stat(filePath)
if err != nil {
return time.Time{}, err
}
return info.ModTime(), nil
}

// AnyFileNewerThan checks recursively if any file in given path (can be directory or file) has
// modification time newer than the given time.
// Returns:
// - true if a newer file is found
// - false if no newer file is found or givenTime is zero
// Function is lazy, and returns on first positive occurrence.
func AnyFileNewerThan(path string, givenTime time.Time) (bool, error) {
// If path does not exist
err := CheckFileExists(path)
if errors.Is(err, os.ErrNotExist) {
return false, err
}

// If given time is zero, assume up-to-date
// This is handy especially for CI, where we can't assume that people will cache firmware-action
// timestamp directory, but they will likely cache the produced files
if givenTime.Equal(time.Time{}) {
return false, nil
}

// If path is directory
if errors.Is(err, ErrPathIsDirectory) {
errMod := filepath.WalkDir(path, func(path string, info os.DirEntry, _ error) error {
// skip .git
if info.Name() == ".git" && info.IsDir() {
return filepath.SkipDir
}
if !info.IsDir() {
fileInfo, err := info.Info()
if err != nil {
return err
}
if fileInfo.ModTime().After(givenTime) {
return fmt.Errorf("file '%s' has been modified: %w", path, ErrFileModified)
}
}
return nil
})
if errors.Is(errMod, ErrFileModified) {
return true, nil
}
return false, nil
}

// If path is file
if errors.Is(err, os.ErrExist) {
modTime, errMod := GetFileModTime(path)
if errMod != nil {
slog.Warn(
fmt.Sprintf("Encountered error when getting modification time of file '%s'", path),
slog.Any("error", errMod),
)
return false, errMod
}
return modTime.After(givenTime), nil
}

// If path is neither file nor directory
return false, err
}
90 changes: 90 additions & 0 deletions action/filesystem/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
)
Expand Down Expand Up @@ -99,3 +100,92 @@ func TestDirTree(t *testing.T) {
assert.NoError(t, err)
assert.True(t, len(files) > 0, "found no files or directories")
}

func TestLastSaveRunTime(t *testing.T) {
currentTime := time.Now()

tmpDir := t.TempDir()
pathTimeFile := filepath.Join(tmpDir, "last_run_time.txt")

// Load - should fallback because no file exists, but no error
loadTime, err := LoadLastRunTime(pathTimeFile)
assert.NoError(t, err)
assert.Equal(t, loadTime, time.Time{})
assert.ErrorIs(t, CheckFileExists(pathTimeFile), os.ErrNotExist)

// Save
err = SaveCurrentRunTime(pathTimeFile)
assert.NoError(t, err)
// file should now exist
assert.ErrorIs(t, CheckFileExists(pathTimeFile), os.ErrExist)

// Load again - should now work since file exists
loadTime, err = LoadLastRunTime(pathTimeFile)
assert.NoError(t, err)
assert.True(t, loadTime.After(currentTime))
assert.True(t, time.Now().After(loadTime))
}

func TestGetFileModTime(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "test.txt")

// Missing file - should fail
modTime, err := GetFileModTime(pathFile)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, modTime, time.Time{})
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrNotExist)

// Make file
err = os.WriteFile(pathFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrExist)

// Should work
_, err = GetFileModTime(pathFile)
assert.NoError(t, err)
}

func TestAnyFileNewerThan(t *testing.T) {
tmpDir := t.TempDir()
pathFile := filepath.Join(tmpDir, "test.txt")

// Call on missing file - should fail
mod, err := AnyFileNewerThan(pathFile, time.Now())
assert.ErrorIs(t, err, os.ErrNotExist)
assert.False(t, mod)

// Call on existing file
// - Make file
err = os.WriteFile(pathFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(pathFile), os.ErrExist)
// - Should work - is file newer than last year? (true)
mod, err = AnyFileNewerThan(pathFile, time.Now().AddDate(-1, 0, 0))
assert.NoError(t, err)
assert.True(t, mod)
// - Should work - is file newer than next year? (false)
mod, err = AnyFileNewerThan(pathFile, time.Now().AddDate(1, 0, 0))
assert.NoError(t, err)
assert.False(t, mod)

// Call on nested directory
// - Make directory tree
subDirRoot := filepath.Join(tmpDir, "test")
subSubDir := filepath.Join(subDirRoot, "deep_test/even_deeper")
err = os.MkdirAll(subSubDir, os.ModePerm)
assert.NoError(t, err)
// - Make file
deepFile := filepath.Join(subSubDir, "test.txt")
err = os.WriteFile(deepFile, []byte{}, 0o666)
assert.NoError(t, err)
assert.ErrorIs(t, CheckFileExists(deepFile), os.ErrExist)
// - Should work - older
mod, err = AnyFileNewerThan(subDirRoot, time.Now().AddDate(-1, 0, 0))
assert.NoError(t, err)
assert.True(t, mod)
// - Should work - newer
mod, err = AnyFileNewerThan(subDirRoot, time.Now().AddDate(1, 0, 0))
assert.NoError(t, err)
assert.False(t, mod)
}
3 changes: 3 additions & 0 deletions action/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jedib0t/go-pretty/v6 v6.6.3 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/sosodev/duration v1.3.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions action/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.6.3 h1:nGqgS0tgIO1Hto47HSaaK4ac/I/Bu7usmdD3qvs0WvM=
github.com/jedib0t/go-pretty/v6 v6.6.3/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand All @@ -97,6 +99,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
Expand All @@ -109,6 +113,8 @@ github.com/plus3it/gorecurcopy v0.0.1 h1:H7AgvM0N/uIo7o1PQRlewEGQ92BNr7DqbPy5lnR
github.com/plus3it/gorecurcopy v0.0.1/go.mod h1:NvVTm4RX68A1vQbHmHunDO4OtBLVroT6CrsiqAzNyJA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
Expand Down
16 changes: 10 additions & 6 deletions action/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/9elements/firmware-action/action/recipes"
"github.com/alecthomas/kong"
"github.com/go-git/go-git/v5"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/sethvargo/go-githubactions"
)

Expand Down Expand Up @@ -133,20 +134,23 @@ submodule_out:
recipes.Execute,
)

// Print overview
summary := "Build summary:"
// Pretty table
summaryTable := table.NewWriter()
summaryTable.AppendHeader(table.Row{"Module", "Status"})

// Create overview table
for _, item := range results {
result := ""
if item.BuildResult == nil {
result = "Success"
} else if errors.Is(item.BuildResult, recipes.ErrBuildSkipped) {
result = "Skipped"
} else if errors.Is(item.BuildResult, recipes.ErrBuildUpToDate) {
result = "Up-to-date"
} else {
result = "Fail"
}
summary = fmt.Sprintf("%s\n%s: %s", summary, item.Name, result)
summaryTable.AppendRow([]interface{}{item.Name, result})
}
slog.Info(summary)
slog.Info(fmt.Sprintf("Build summary:\n%s", summaryTable.Render()))

if err == nil {
slog.Info("Build finished successfully")
Expand Down
17 changes: 16 additions & 1 deletion action/recipes/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (

var (
// ErrVerboseJSON is raised when JSONVerboseError can't find location of problem in JSON configuration file
ErrVerboseJSON = errors.New("unable to pinpoint the problem in JSON file")
ErrVerboseJSON = errors.New("unable to pinpoint the problem in JSON file")
// ErrEnvVarUndefined is raised when undefined environment variable is found in JSON configuration file
ErrEnvVarUndefined = errors.New("environment variable used in JSON file is not present in the environment")
)
Expand Down Expand Up @@ -155,6 +155,20 @@ func (opts CommonOpts) GetOutputDir() string {
return opts.OutputDir
}

// GetSources returns slice of paths to all sources which are used for build
func (opts CommonOpts) GetSources() []string {
sources := []string{}

// Repository path
sources = append(sources, opts.RepoPath)

// Input files and directories
sources = append(sources, opts.InputDirs[:]...)
sources = append(sources, opts.InputFiles[:]...)

return sources
}

// Config is for storing parsed configuration file
type Config struct {
// defined in coreboot.go
Expand Down Expand Up @@ -201,6 +215,7 @@ type FirmwareModule interface {
GetContainerOutputDirs() []string
GetContainerOutputFiles() []string
GetOutputDir() string
GetSources() []string
buildFirmware(ctx context.Context, client *dagger.Client, dockerfileDirectoryPath string) (*dagger.Container, error)
}

Expand Down
Loading
Loading