Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.0] - 2026-02-14

### Changed
- **Breaking:** `.goodchangesrc.json` now uses a `targets` array instead of a single top-level target definition. Each entry in `targets` is a target object with `type`, `app`, `targetName`, and `changeDirs`. The `ignores` field remains at the top level (shared across all targets).

## [0.11.2] - 2026-02-14

### Fixed
Expand Down Expand Up @@ -151,6 +156,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Multi-stage Docker build
- Automated vendor upgrade workflow

[0.12.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.2...v0.12.0
[0.11.2]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.1...v0.11.2
[0.11.1]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.11.0...v0.11.1
[0.11.0]: https://github.com/gooddata/gooddata-goodchanges/compare/v0.10.0...v0.11.0
Expand Down
53 changes: 31 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,34 @@ Everything else is an **app** (bundled). Apps are not analyzed for granular expo

## Configuration

Each project can optionally have a `.goodchangesrc.json` file in its root directory.

### Target

Marks a project as an e2e test package. The package name is included in the output when any of the 4 trigger conditions are met.
Each project can optionally have a `.goodchangesrc.json` file in its root directory. A single config file can define multiple targets via the `targets` array.

```json
{
"type": "target",
"app": "@gooddata/gdc-dashboards",
"targets": [
{
"type": "target",
"app": "@gooddata/gdc-dashboards"
},
{
"type": "virtual-target",
"targetName": "neobackstop",
"changeDirs": [
{ "glob": "src/**/*" },
{ "glob": "scenarios/**/*" },
{ "glob": "stories/**/*.stories.tsx", "type": "fine-grained" },
{ "glob": "neobackstop/**/*" }
]
}
],
"ignores": ["scenarios/**/*.md"]
}
```

### Target

Marks a project as an e2e test package. The package name is included in the output when any of the 4 trigger conditions are met.

**Trigger conditions:**

1. **Direct file changes** -- files changed in the project folder (excluding ignored paths)
Expand All @@ -105,19 +119,6 @@ Marks a project as an e2e test package. The package name is included in the outp

An aggregated target that uses glob patterns to match files across a project. Does not correspond to a real package name in the output -- uses `targetName` instead.

```json
{
"type": "virtual-target",
"targetName": "neobackstop",
"changeDirs": [
{ "glob": "src/**/*" },
{ "glob": "scenarios/**/*" },
{ "glob": "stories/**/*.stories.tsx", "type": "fine-grained" },
{ "glob": "neobackstop/**/*" }
]
}
```

Each `changeDirs` entry is an object with:

- `glob` -- glob pattern to match files (relative to project root). Uses doublestar syntax: `*` matches files in current directory only, `**/*` matches all nested files, `**/*.stories.tsx` matches specific patterns recursively.
Expand All @@ -139,13 +140,21 @@ Each `changeDirs` entry is an object with:

### Fields reference

**Top-level fields:**

| Field | Type | Description |
|-----------|---------------|------------------------------------------------------------|
| `targets` | `TargetDef[]` | Array of target definitions (see below) |
| `ignores` | `string[]` | Glob patterns for files to exclude from change detection |

**TargetDef fields (each entry in `targets`):**

| Field | Type | Used by | Description |
|--------------|----------------------------------|----------------|--------------------------------------------------------------------------------------------------------|
| `type` | `"target"` \| `"virtual-target"` | Both | Declares what kind of target this project is |
| `type` | `"target"` \| `"virtual-target"` | Both | Declares what kind of target this is |
| `app` | `string` | Target | Package name of the corresponding app this e2e package tests |
| `targetName` | `string` | Virtual target | Output name emitted when the virtual target is triggered |
| `changeDirs` | `ChangeDir[]` | Virtual target | Glob patterns to match files. Each entry: `{"glob": "...", "filter?": "...", "type?": "fine-grained"}` |
| `ignores` | `string[]` | Both | Glob patterns for files to exclude from change detection |

The `.goodchangesrc.json` file itself is always ignored.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.11.2
0.12.0
30 changes: 18 additions & 12 deletions internal/rush/rush.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,26 @@ func (cd ChangeDir) IsFineGrained() bool {
return cd.Type != nil && *cd.Type == "fine-grained"
}

type ProjectConfig struct {
Type *string `json:"type,omitempty"` // "target", "virtual-target"
type TargetDef struct {
Type string `json:"type"` // "target", "virtual-target"
App *string `json:"app,omitempty"` // rush project name of corresponding app
TargetName *string `json:"targetName,omitempty"` // output name for virtual targets
ChangeDirs []ChangeDir `json:"changeDirs,omitempty"` // dirs to watch for virtual targets
Ignores []string `json:"ignores,omitempty"`
ChangeDirs []ChangeDir `json:"changeDirs,omitempty"` // globs to watch for virtual targets
}

// IsTarget returns true if this target definition is a regular target.
func (td TargetDef) IsTarget() bool {
return td.Type == "target"
}

// IsVirtualTarget returns true if this target definition is a virtual target.
func (td TargetDef) IsVirtualTarget() bool {
return td.Type == "virtual-target"
}

type ProjectConfig struct {
Targets []TargetDef `json:"targets,omitempty"`
Ignores []string `json:"ignores,omitempty"`
}

// LoadProjectConfig reads .goodchangesrc.json from the project folder.
Expand Down Expand Up @@ -167,15 +181,7 @@ func (pc *ProjectConfig) IsIgnored(relPath string) bool {
return false
}

// IsTarget returns true if this project is configured as a target (e2e test package).
func (pc *ProjectConfig) IsTarget() bool {
return pc != nil && pc.Type != nil && *pc.Type == "target"
}

// IsVirtualTarget returns true if this project is configured as a virtual target.
func (pc *ProjectConfig) IsVirtualTarget() bool {
return pc != nil && pc.Type != nil && *pc.Type == "virtual-target"
}

// FindChangedProjects determines which projects have files in the changed file list.
// Files matching ignore globs in .goodchangesrc.json are excluded.
Expand Down
210 changes: 110 additions & 100 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,15 @@ func main() {
var targetSeeds []string
for _, rp := range rushConfig.Projects {
cfg := configMap[rp.ProjectFolder]
if cfg.IsTarget() && matchesTargetFilter(rp.PackageName, targetPatterns) {
targetSeeds = append(targetSeeds, rp.PackageName)
} else if cfg.IsVirtualTarget() && cfg.TargetName != nil && matchesTargetFilter(*cfg.TargetName, targetPatterns) {
targetSeeds = append(targetSeeds, rp.PackageName)
if cfg == nil {
continue
}
for _, td := range cfg.Targets {
if td.IsTarget() && matchesTargetFilter(rp.PackageName, targetPatterns) {
targetSeeds = append(targetSeeds, rp.PackageName)
} else if td.IsVirtualTarget() && td.TargetName != nil && matchesTargetFilter(*td.TargetName, targetPatterns) {
targetSeeds = append(targetSeeds, rp.PackageName)
}
}
}
relevantPackages = rush.FindTransitiveDependencies(projectMap, targetSeeds)
Expand Down Expand Up @@ -303,119 +308,124 @@ func main() {

for _, rp := range rushConfig.Projects {
cfg := configMap[rp.ProjectFolder]
if cfg == nil {
continue
}

if cfg.IsTarget() {
if len(targetPatterns) > 0 && !matchesTargetFilter(rp.PackageName, targetPatterns) {
continue
}
// Target detection with 4 conditions:
// 1. Direct file changes (outside ignores)
// 2. External dep changes in lockfile
// 3. Tainted workspace imports
// 4. Corresponding app is tainted
info := projectMap[rp.PackageName]
if info == nil {
continue
}
for _, td := range cfg.Targets {
if td.IsTarget() {
if len(targetPatterns) > 0 && !matchesTargetFilter(rp.PackageName, targetPatterns) {
continue
}
// Target detection with 4 conditions:
// 1. Direct file changes (outside ignores)
// 2. External dep changes in lockfile
// 3. Tainted workspace imports
// 4. Corresponding app is tainted
info := projectMap[rp.PackageName]
if info == nil {
continue
}

// Condition 1: Direct file changes
triggered := false
for _, f := range changedFiles {
if strings.HasPrefix(f, rp.ProjectFolder+"/") {
relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/")
if !cfg.IsIgnored(relPath) {
triggered = true
break
// Condition 1: Direct file changes
triggered := false
for _, f := range changedFiles {
if strings.HasPrefix(f, rp.ProjectFolder+"/") {
relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/")
if !cfg.IsIgnored(relPath) {
triggered = true
break
}
}
}
}
if triggered {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}

// Condition 2: External dep changes in lockfile
if len(depChangedDeps[rp.ProjectFolder]) > 0 {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
if triggered {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}

// Condition 3: Tainted workspace imports
if analyzer.HasTaintedImports(rp.ProjectFolder, allUpstreamTaint, cfg) {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
// Condition 2: External dep changes in lockfile
if len(depChangedDeps[rp.ProjectFolder]) > 0 {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}

// Condition 4: Corresponding app is tainted
if cfg.App != nil {
appInfo := projectMap[*cfg.App]
if appInfo != nil {
if changedProjects[*cfg.App] != nil {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
if len(depChangedDeps[appInfo.ProjectFolder]) > 0 {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
if analyzer.HasTaintedImports(appInfo.ProjectFolder, allUpstreamTaint, nil) {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
// Condition 3: Tainted workspace imports
if analyzer.HasTaintedImports(rp.ProjectFolder, allUpstreamTaint, cfg) {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
}
} else if cfg.IsVirtualTarget() && cfg.TargetName != nil {
if len(targetPatterns) > 0 && !matchesTargetFilter(*cfg.TargetName, targetPatterns) {
continue
}
// Virtual target: check changeDirs globs for file changes or tainted imports.
// Normal globs trigger a full run; fine-grained globs collect specific affected files.
normalTriggered := false
var fineGrainedDetections []string

for _, cd := range cfg.ChangeDirs {
if cd.IsFineGrained() {
filterPattern := ""
if cd.Filter != nil {
filterPattern = *cd.Filter
}
detected := analyzer.FindAffectedFiles(cd.Glob, filterPattern, allUpstreamTaint, changedFiles, rp.ProjectFolder, cfg, depChangedDeps[rp.ProjectFolder], mergeBase, flagIncludeTypes)
if len(detected) > 0 {
fineGrainedDetections = append(fineGrainedDetections, detected...)
}
} else {
// Normal: check for any changed file matching the glob
for _, f := range changedFiles {
if !strings.HasPrefix(f, rp.ProjectFolder+"/") {

// Condition 4: Corresponding app is tainted
if td.App != nil {
appInfo := projectMap[*td.App]
if appInfo != nil {
if changedProjects[*td.App] != nil {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/")
if cfg.IsIgnored(relPath) {
if len(depChangedDeps[appInfo.ProjectFolder]) > 0 {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
if matched, _ := doublestar.Match(cd.Glob, relPath); matched {
normalTriggered = true
break
if analyzer.HasTaintedImports(appInfo.ProjectFolder, allUpstreamTaint, nil) {
changedE2E[rp.PackageName] = &TargetResult{Name: rp.PackageName}
continue
}
}
if !normalTriggered {
if analyzer.HasTaintedImportsForGlob(rp.ProjectFolder, cd.Glob, allUpstreamTaint, cfg) {
normalTriggered = true
}
} else if td.IsVirtualTarget() && td.TargetName != nil {
if len(targetPatterns) > 0 && !matchesTargetFilter(*td.TargetName, targetPatterns) {
continue
}
// Virtual target: check changeDirs globs for file changes or tainted imports.
// Normal globs trigger a full run; fine-grained globs collect specific affected files.
normalTriggered := false
var fineGrainedDetections []string

for _, cd := range td.ChangeDirs {
if cd.IsFineGrained() {
filterPattern := ""
if cd.Filter != nil {
filterPattern = *cd.Filter
}
detected := analyzer.FindAffectedFiles(cd.Glob, filterPattern, allUpstreamTaint, changedFiles, rp.ProjectFolder, cfg, depChangedDeps[rp.ProjectFolder], mergeBase, flagIncludeTypes)
if len(detected) > 0 {
fineGrainedDetections = append(fineGrainedDetections, detected...)
}
} else {
// Normal: check for any changed file matching the glob
for _, f := range changedFiles {
if !strings.HasPrefix(f, rp.ProjectFolder+"/") {
continue
}
relPath := strings.TrimPrefix(f, rp.ProjectFolder+"/")
if cfg.IsIgnored(relPath) {
continue
}
if matched, _ := doublestar.Match(cd.Glob, relPath); matched {
normalTriggered = true
break
}
}
if !normalTriggered {
if analyzer.HasTaintedImportsForGlob(rp.ProjectFolder, cd.Glob, allUpstreamTaint, cfg) {
normalTriggered = true
}
}
}
if normalTriggered {
break
}
}
if normalTriggered {
break
}
}

if normalTriggered {
changedE2E[*cfg.TargetName] = &TargetResult{Name: *cfg.TargetName}
} else if len(fineGrainedDetections) > 0 {
sort.Strings(fineGrainedDetections)
changedE2E[*cfg.TargetName] = &TargetResult{
Name: *cfg.TargetName,
Detections: fineGrainedDetections,
if normalTriggered {
changedE2E[*td.TargetName] = &TargetResult{Name: *td.TargetName}
} else if len(fineGrainedDetections) > 0 {
sort.Strings(fineGrainedDetections)
changedE2E[*td.TargetName] = &TargetResult{
Name: *td.TargetName,
Detections: fineGrainedDetections,
}
}
}
}
Expand Down