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

Improve Dependency Resolution for Workflow Optimization #2355

Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c11c90d
feat: add package-based dependency resolution
oliverqx Mar 23, 2024
b86fd2b
fix: remove redundant step in recursion
oliverqx Mar 24, 2024
3b0b02c
feat: add recursion cache
oliverqx Mar 24, 2024
53300cd
refactor: separate dependency extraction into 2 steps
oliverqx Mar 25, 2024
1c8201f
refactor: change dependencies type
oliverqx Mar 25, 2024
94dd773
fix: optimize loop
oliverqx Mar 25, 2024
dd43542
fix: change naming of packages in extractGoFiles
oliverqx Mar 25, 2024
6f4ceb2
fix: package naming
oliverqx Mar 25, 2024
fad4ff6
refactor: remove dependency filtering from graph creation
oliverqx Mar 25, 2024
5acc601
fix: package naming in dependencies map
oliverqx Mar 26, 2024
a843b5e
fix: add '.' to relative package name construction
oliverqx Mar 26, 2024
cdb1b50
fix: move recursive cache check to step one
oliverqx Mar 26, 2024
dba2a8c
add: filter self-referential dependencies
oliverqx Mar 26, 2024
521a023
add: graceful error handling
oliverqx Mar 26, 2024
6f10f09
fix: swtich from contains to hasSuffix
oliverqx Mar 26, 2024
86813b0
fix: if condition
oliverqx Mar 26, 2024
77f5308
fix: remove identifyNestedDependencyChange
oliverqx Mar 27, 2024
b85315f
fix: remove caching
oliverqx Mar 27, 2024
c7fc018
fix: clean loop
oliverqx Mar 27, 2024
f43e9ce
add: comments
oliverqx Mar 27, 2024
bbd3966
fix: add formatting directive
oliverqx Mar 27, 2024
89c7127
fix tests
oliverqx Mar 27, 2024
7e9af01
fix: clean implementation
oliverqx Mar 27, 2024
a8cb540
fix: improve readability
oliverqx Mar 27, 2024
d15cfe7
improve readability
oliverqx Mar 28, 2024
73f4eeb
fix: clean recursion
oliverqx Mar 28, 2024
3793f2e
refactor: split detector package into three
oliverqx Mar 28, 2024
1ae1307
fix: typo
oliverqx Mar 28, 2024
5d3060d
fix: typo in function name
oliverqx Mar 28, 2024
8d2fd3c
fix: lint issue
oliverqx Mar 28, 2024
07d04a7
fix: lint issues
oliverqx Mar 28, 2024
586ab76
Update eventtype_string.go
oliverqx Mar 30, 2024
faf1fda
chore: Update READ ME
oliverqx Apr 1, 2024
a33e94c
Update README.md
oliverqx Apr 1, 2024
12dc6f5
Update README
oliverqx Apr 3, 2024
4a316fd
feat: enable workflow input to define dependency level resolution
oliverqx Apr 3, 2024
1e99cd5
Update README.md
oliverqx Apr 3, 2024
39b33bf
fix: lint issues
oliverqx Apr 3, 2024
c3ac016
fix: update variable names
oliverqx Apr 3, 2024
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
93 changes: 52 additions & 41 deletions contrib/git-changes-action/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Git Changes Action

This GitHub Action exports a variable that contains the list of Go modules changed in the current pull request, along with any dependent modules. This can be useful for automating build, test, or deployment workflows that involve Go projects.
This GitHub Action exports a variable that contains the list of Go modules changed in the current pull request, and if desired, along with any dependent modules. This can be useful for automating build, test, or deployment workflows that involve Go projects.

[![Go Reference](https://pkg.go.dev/badge/github.com/synapsecns/sanguine/contrib/git-changes-action.svg)](https://pkg.go.dev/github.com/synapsecns/sanguine/contrib/git-changes-action)
[![Go Report Card](https://goreportcard.com/badge/github.com/synapsecns/sanguine/contrib/git-changes-action)](https://goreportcard.com/report/github.com/synapsecns/sanguine/contrib/git-changes-action)
Expand All @@ -9,16 +9,15 @@ This GitHub Action exports a variable that contains the list of Go modules chang

1. To use this action, add the following steps to your workflow:

Check out the current pull request using the actions/checkout action. It's recommended to set fetch-depth to 0 and submodules to recursive to ensure that all necessary dependencies are fetched.


Check out the current pull request using the actions/checkout action. It's recommended to set fetch-depth to 0 and submodules to 'recursive' to ensure that all necessary dependencies are fetched.
oliverqx marked this conversation as resolved.
Show resolved Hide resolved

```yaml
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: 'recursive'
dependencyLevelResolution: "modules"
oliverqx marked this conversation as resolved.
Show resolved Hide resolved
```

1. Use the synapsecns/sanguine/git-changes-action Docker image to run the git-changes script, which exports a variable that contains the list of changed Go modules.
Expand All @@ -29,55 +28,26 @@ This GitHub Action exports a variable that contains the list of Go modules chang
with:
github_token: ${{ secrets.github_token }}
timeout: "1m" # optional, defaults to 1m
dependencyLevelResolution: "modules"
# For package level resolution, use "packages" instead of "modules"
oliverqx marked this conversation as resolved.
Show resolved Hide resolved
```

You can customize the behavior of the git-changes script by using the following inputs:

- `github_token`: The token to use for authentication with the GitHub API. This is required to fetch information about the current pull request.
- `timeout`: The maximum time to wait for the GitHub API to respond. Defaults to 1 minute.
- `dependencyLevelResolution`: "modules" or "packages".
oliverqx marked this conversation as resolved.
Show resolved Hide resolved

The output of the git-changes script is a comma-separated list of Go module paths. You can access this list using the `filter_go` output variable, like so:

```yaml
- run: echo "Changed modules: ${{ steps.filter_go.outputs.changed_modules }}"
- run: echo "Unchanged modules: ${{ steps.filter_go.outputs.unchanged_modules }}"
- run: echo "Changed modules (including dependencies): ${{ steps.filter_go.outputs.changed_modules_deps }}"
- run: echo "Unchanged modules (including dependencies): ${{ steps.filter_go.outputs.unchanged_modules_deps }}"
```

## Example

Here's an example workflow that uses the `git-changes` action to run tests for changed Go modules:


```yaml
name: Test Go Modules

on:
pull_request:
types: [opened, synchronize]

jobs:
test_go_modules:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'recursive'
- run: echo 'Changed modules:' ${{ steps.filter_go.outputs.changed_modules }}
- run: echo 'Unchanged modules:' ${{ steps.filter_go.outputs.unchanged_modules }}
- run: echo 'Changed modules (including dependencies):' ${{ steps.filter_go.outputs.changed_modules_deps }}
- run: echo 'Unchanged modules (including dependencies):' ${{ steps.filter_go.outputs.unchanged_modules_deps }}

- uses: docker://ghcr.io/synapsecns/sanguine/git-changes-action:latest
id: filter_go
with:
github_token: ${{ secrets.github_token }}
timeout: "1m"

- name: Run tests
run: go test -v ${{ steps.filter_go.outputs.changed_modules }}
```

This workflow will run tests for all changed Go modules and their dependencies whenever a pull request is opened or synchronized.

## How It Works

First a change tree is calculated between the ref and the base (as passed in by the user or inferred by the event type) based on the flow described below and [here](https://github.com/dorny/paths-filter/blob/4067d885736b84de7c414f582ac45897079b0a78/README.md#supported-workflows). This is a file tree that contains all the files that have changed between two refs.
Expand All @@ -94,7 +64,9 @@ graph TB
F --> G
```

Each module in the `go.work` is visited. If any changes were detected by the previous step, the module is added to the list of changed modules. If `include_dependencies` is on and the module has a dependency that is also in the `go.work`, the dependency is added to the list of changed modules as well. This process is repeated until all modules in the `go.work` have been visited.
## Module Level Resolution

Each module in the `go.work` is visited. If any changes are detected, the module itself is added to the list of changed modules. By setting `include_dependencies` to `modules`, changes to their direct or indirect module dependencies will flag the module as changed. This process is repeated until all modules in the `go.work` have been visited.

```mermaid
sequenceDiagram
Expand Down Expand Up @@ -126,3 +98,42 @@ sequenceDiagram
end
GW->>GW: Continue Until All Modules Visited
```

## Package Level Resolution

Each module in the `go.work` is visited. If any changes are detected, the module itself is added to the list of changed modules. By setting `include_dependencies` to `packages`, changes to its direct or indirect package dependencies will flag the module as changed. This process is repeated until all package dependencies for the module been visited.

```mermaid
sequenceDiagram
participant GW as go.work
participant M as Module
participant P as Package
participant CML as Changed_Module_List
participant UML as Unchanged_Module_List
participant D as Dependency

GW->>M: Visit Module
M->>P:Extract all packages for each Module
P->>M: Returns [module]: [package1,package2]
M->>M: Changes detected?
M->>P: Loop over packages
P-->>GW: Changes Detected?
alt Changes Detected
GW->>CML: Add Module to Changed_Module_List
M->>D: Has Package Dependency?
alt Has Dependency
GW->>CML: Add Dependency to Changed_Module_List
else No Dependency
M-->>GW: No Dependency to Add
end
else No Changes Detected
GW->>UML: Add Module to Unchanged_Module_List
M->>D: Has Dependency in go.work?
alt Has Dependency
GW->>UML: Add Dependency to Unchanged_Module_List
else No Dependency
M-->>GW: No Dependency to Add
end
end
GW->>GW: Continue Until All Modules Visited
```
2 changes: 0 additions & 2 deletions contrib/git-changes-action/detector/doc.go

This file was deleted.

14 changes: 0 additions & 14 deletions contrib/git-changes-action/detector/export_test.go

This file was deleted.

2 changes: 2 additions & 0 deletions contrib/git-changes-action/detector/git/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package git provides utilities to interact with git.
package git

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,72 +1,21 @@
package detector
package git

import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/ethereum/go-ethereum/common"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/google/go-github/v41/github"
"github.com/synapsecns/sanguine/contrib/git-changes-action/detector/actionscore"
"github.com/synapsecns/sanguine/contrib/git-changes-action/detector/tree"
"github.com/synapsecns/sanguine/core"
"golang.org/x/mod/modfile"
"os"
"path"
"strings"
)

// DetectChangedModules is the change detector client.
// nolint: cyclop
func DetectChangedModules(repoPath string, ct tree.Tree, includeDeps bool) (modules map[string]bool, err error) {
modules = make(map[string]bool)

goWorkPath := path.Join(repoPath, "go.work")

if !common.FileExist(goWorkPath) {
return nil, fmt.Errorf("go.work file not found in %s", repoPath)
}

//nolint: gosec
workFile, err := os.ReadFile(goWorkPath)
if err != nil {
return nil, fmt.Errorf("failed to read go.work file: %w", err)
}

parsedWorkFile, err := modfile.ParseWork(goWorkPath, workFile, nil)
if err != nil {
return nil, fmt.Errorf("failed to parse go.work file: %w", err)
}

depGraph, err := getDependencyGraph(repoPath)
if err != nil {
return nil, fmt.Errorf("could not get dep graph: %w", err)
}

for _, module := range parsedWorkFile.Use {
changed := false
if ct.HasPath(module.Path) {
changed = true
}

if includeDeps {
deps := depGraph[module.Path]
for _, dep := range deps {
if ct.HasPath(dep) {
changed = true
}
}
}

modules[module.Path] = changed
}

return modules, nil
}

// getChangeTreeFromGit returns a tree of all the files that have changed between the current commit and the commit with the given hash.
// nolint: cyclop, gocognit
func getChangeTreeFromGit(repoPath string, ghContext *actionscore.Context, head, base string) (tree.Tree, error) {
Expand All @@ -76,7 +25,7 @@ func getChangeTreeFromGit(repoPath string, ghContext *actionscore.Context, head,
return nil, fmt.Errorf("could not open repository %s: %w", repoPath, err)
}

head, err = getHead(repository, ghContext, head)
head, err = GetHead(repository, ghContext, head)
if err != nil {
return nil, fmt.Errorf("could not get head: %w", err)
}
Expand Down Expand Up @@ -272,9 +221,9 @@ func getHeadBase(repo *git.Repository) (head string, base string, err error) {
return co.Hash().String(), lastCommit.Hash.String(), nil
}

// getHead gets the head of the current branch.
// GetHead gets the head of the current branch.
// it attempts to mirror the logic of https://github.com/dorny/paths-filter/blob/0ef5f0d812dc7b631d69e07d2491d70fcebc25c8/src/main.ts#L104
func getHead(repo *git.Repository, ghContext *actionscore.Context, head string) (string, error) {
func GetHead(repo *git.Repository, ghContext *actionscore.Context, head string) (string, error) {
if head != "" {
return head, nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package detector
package git

import (
"context"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package detector
package moduledetector

import (
"fmt"
Expand Down Expand Up @@ -36,25 +36,25 @@ func getDependencyGraph(repoPath string) (moduleDeps map[string][]string, err er
}

// map of module->dependencies + replaces
var dependencies map[string][]string
// bidirectional map of module->module name
var moduleNames *bimap.BiMap[string, string]
var dependencies map[string]map[string]struct{}
// bidirectional map of module->module
// moduleRelativeName <-> modulePublicName
var dependencyNames *bimap.BiMap[string, string]

// iterate through each module in the go.work file
// create a list of dependencies for each module
// and module names
dependencies, moduleNames, err = makeDepMaps(repoPath, parsedWorkFile.Use)
dependencies, dependencyNames, err = makeDepMaps(repoPath, parsedWorkFile.Use)
if err != nil {
return nil, fmt.Errorf("failed to create dependency maps: %w", err)
}

depGraph := depgraph.New()
// build the dependency graph
for _, module := range parsedWorkFile.Use {
moduleDeps := dependencies[module.Path]
for _, dep := range moduleDeps {
for dep := range dependencies[module.Path] {
// check if the full package name (e.g. github.com/myorg/myrepo/mymodule) is in the list of modules. If it is, add it as a dependency after renaming
renamedDep, hasDep := moduleNames.GetInverse(dep)
renamedDep, hasDep := dependencyNames.GetInverse(dep)
if hasDep {
err = depGraph.DependOn(module.Path, renamedDep)
if err != nil {
Expand All @@ -81,41 +81,45 @@ func getDependencyGraph(repoPath string) (moduleDeps map[string][]string, err er
return moduleDeps, nil
}

// makeDepMaps makes a dependency map and a bidirectional map of dep<->module.
func makeDepMaps(repoPath string, uses []*modfile.Use) (dependencies map[string][]string, moduleNames *bimap.BiMap[string, string], err error) {
// map of module->dependencies + replaces
dependencies = make(map[string][]string)
// makeDepMaps builds a
// 1. module->dependency map
// 2. bidirectional map of module<->module (moduleRelativeName to modulePublicName).
func makeDepMaps(repoPath string, uses []*modfile.Use) (dependencies map[string]map[string]struct{}, dependencyNames *bimap.BiMap[string, string], err error) {
// map of module->depndencies
dependencies = make(map[string]map[string]struct{})

// bidirectional map of module->module name
moduleNames = bimap.NewBiMap[string, string]()
// Maps relative to public names, used to filer out all external libraries/packages.
dependencyNames = bimap.NewBiMap[string, string]()

// iterate through each module in the go.work file
// create a list of dependencies for each module
// and module names
// 1. Create a list of dependencies for each module
// 2. Map public names to private names for each module
//nolint: gosec
for _, module := range uses {
//nolint: gosec
modContents, err := os.ReadFile(filepath.Join(repoPath, module.Path, "go.mod"))
if err != nil {
return dependencies, moduleNames, fmt.Errorf("failed to read module file %s: %w", module.Path, err)
return dependencies, dependencyNames, fmt.Errorf("failed to read module file %s: %w", module.Path, err)
}

parsedModFile, err := modfile.Parse(module.Path, modContents, nil)
if err != nil {
return dependencies, moduleNames, fmt.Errorf("failed to parse module file %s: %w", module.Path, err)
return dependencies, dependencyNames, fmt.Errorf("failed to parse module file %s: %w", module.Path, err)
}

moduleNames.Insert(module.Path, parsedModFile.Module.Mod.Path)
dependencyNames.Insert(module.Path, parsedModFile.Module.Mod.Path)
dependencies[module.Path] = make(map[string]struct{})

dependencies[module.Path] = make([]string, 0)
// include all requires and replaces, as they are dependencies
for _, require := range parsedModFile.Require {
dependencies[module.Path] = append(dependencies[module.Path], convertRelPath(repoPath, module.Path, require.Mod.Path))
dependencies[module.Path][convertRelPath(repoPath, module.Path, require.Mod.Path)] = struct{}{}
}
for _, require := range parsedModFile.Replace {
dependencies[module.Path] = append(dependencies[module.Path], convertRelPath(repoPath, module.Path, require.New.Path))
dependencies[module.Path][convertRelPath(repoPath, module.Path, require.New.Path)] = struct{}{}
}
}

return dependencies, moduleNames, nil
return dependencies, dependencyNames, nil
}

// isRelativeDep returns true if the dependency is relative to the module (starts with ./ or ../).
Expand Down
Loading
Loading