From ab260ef55d22d8bb55380e93fa1753efa87ab85e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:20:03 +0000 Subject: [PATCH 01/11] Initial plan From 73318e71eadb480da39a165f0c4050bfcef42ab4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:24:09 +0000 Subject: [PATCH 02/11] Add parsing logic and tests for extracting action uses from workflows Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- main.go | 235 ++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 110 ++++++++++++++++++++++ test_main_temp.go | 26 +++++ 3 files changed, 371 insertions(+) create mode 100644 main_test.go create mode 100644 test_main_temp.go diff --git a/main.go b/main.go index b3360b1..598bc68 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,25 @@ type DependabotFile struct { Category string } +// ActionUse represents a single use of a GitHub action. +type ActionUse struct { + Action string // e.g., "actions/checkout" + Version string // e.g., "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2" or "v6.0.2" + RepoName string + FilePath string +} + +// ActionUsesIndex tracks all uses of actions across workflows. +type ActionUsesIndex struct { + Actions map[string]map[string][]WorkflowReference // Action -> Version -> []WorkflowReference +} + +// WorkflowReference represents a reference to a workflow file that uses an action. +type WorkflowReference struct { + RepoName string + FilePath string +} + // ------------------------ // Section: Global Variables // ------------------------ @@ -324,6 +343,99 @@ func computeHash(content []byte) string { return hex.EncodeToString(hash[:]) } +// extractActionUses parses a workflow YAML file and extracts all 'uses' statements. +func extractActionUses(workflowContent string, repoName string, filePath string) []ActionUse { + var uses []ActionUse + + // Parse the YAML content + var workflow map[string]interface{} + err := yaml.Unmarshal([]byte(workflowContent), &workflow) + if err != nil { + fmt.Printf("Error parsing YAML for %s/%s: %v\n", repoName, filePath, err) + return uses + } + + // Navigate through jobs + jobs, ok := workflow["jobs"].(map[string]interface{}) + if !ok { + return uses + } + + // Iterate through each job + for _, jobData := range jobs { + job, ok := jobData.(map[string]interface{}) + if !ok { + continue + } + + // Get steps from the job + steps, ok := job["steps"].([]interface{}) + if !ok { + continue + } + + // Iterate through each step + for _, stepData := range steps { + step, ok := stepData.(map[string]interface{}) + if !ok { + continue + } + + // Check if step has a 'uses' field + if usesVal, ok := step["uses"]; ok { + if usesStr, ok := usesVal.(string); ok { + // Parse the uses string to extract action and version + action, version := parseUsesString(usesStr, workflowContent) + if action != "" { + uses = append(uses, ActionUse{ + Action: action, + Version: version, + RepoName: repoName, + FilePath: filePath, + }) + } + } + } + } + } + + return uses +} + +// parseUsesString parses a 'uses' string to extract the action name and version. +// It also looks for inline comments to include them in the version string. +func parseUsesString(usesStr string, workflowContent string) (action string, version string) { + // Split by '@' to separate action from version + parts := strings.SplitN(usesStr, "@", 2) + if len(parts) != 2 { + // No version specified + return parts[0], "" + } + + action = parts[0] + version = parts[1] + + // Look for the uses line in the original content to get any inline comment + lines := strings.Split(workflowContent, "\n") + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + // Check if this line contains our uses statement + if strings.Contains(trimmedLine, "uses:") && strings.Contains(trimmedLine, usesStr) { + // Check if there's a comment + if commentIdx := strings.Index(trimmedLine, "#"); commentIdx != -1 { + // Extract the comment part (after the #) + comment := strings.TrimSpace(trimmedLine[commentIdx+1:]) + if comment != "" { + version = version + " # " + comment + } + } + break + } + } + + return action, version +} + // extractCategory extracts the category from a file content based on the comment format. // Looks for the first line matching "# dotgithubindexer: " // Returns "Default" if no such line is found. @@ -736,6 +848,11 @@ func auditGitHubActions(org, token, dbPath string, includePub, includePrv bool) return fmt.Errorf("failed to initialize database: %v", err) } + // Initialize action uses index + usesIndex := &ActionUsesIndex{ + Actions: make(map[string]map[string][]WorkflowReference), + } + // Fetch Repositories repos, err := fetchRepositories(client, org, includePub, includePrv) if err != nil { @@ -778,6 +895,22 @@ func auditGitHubActions(org, token, dbPath string, includePub, includePrv bool) fmt.Printf("Error storing action version for %s in %s: %v\n", actionName, repoName, err) continue } + + // Extract action uses from workflow content + uses := extractActionUses(wf.Content, wf.RepoName, wf.FilePath) + for _, use := range uses { + // Add to uses index + if _, ok := usesIndex.Actions[use.Action]; !ok { + usesIndex.Actions[use.Action] = make(map[string][]WorkflowReference) + } + usesIndex.Actions[use.Action][use.Version] = append( + usesIndex.Actions[use.Action][use.Version], + WorkflowReference{ + RepoName: use.RepoName, + FilePath: use.FilePath, + }, + ) + } } // Fetch dependabot file @@ -830,6 +963,11 @@ func auditGitHubActions(org, token, dbPath string, includePub, includePrv bool) fmt.Printf("Error generating DB summary README.md: %v\n", err) } + // Generate USES.md file + if err := generateUSESMarkdown(dbPath, org, usesIndex); err != nil { + fmt.Printf("Error generating USES.md: %v\n", err) + } + return nil } @@ -1137,3 +1275,100 @@ func generateDBSummary(dbPath string) error { fmt.Printf("Generated DB summary README.md with %d workflows and %d dependabot categories\n", len(summaries), len(dependabotSummaries)) return nil } + +// generateUSESMarkdown creates a USES.md file in the db folder that indexes all action uses. +func generateUSESMarkdown(dbPath, org string, usesIndex *ActionUsesIndex) error { + if usesIndex == nil || len(usesIndex.Actions) == 0 { + fmt.Printf("No action uses found. Skipping USES.md generation.\n") + return nil + } + + var markdownBuilder strings.Builder + markdownBuilder.WriteString("# GitHub Actions Uses\n\n") + markdownBuilder.WriteString("This document provides an index of all GitHub Actions used across workflows in the organization.\n\n") + markdownBuilder.WriteString("**Legend:**\n") + markdownBuilder.WriteString("- **Action**: The GitHub Action being used (e.g., `actions/checkout`)\n") + markdownBuilder.WriteString("- **Version**: The specific version of the action, including any inline comments\n") + markdownBuilder.WriteString("- **Usage Count**: The number of workflow files using this specific version\n\n") + + // Sort actions alphabetically + var actionNames []string + for actionName := range usesIndex.Actions { + actionNames = append(actionNames, actionName) + } + sort.Strings(actionNames) + + // For each action, list versions and their usage + for _, actionName := range actionNames { + versions := usesIndex.Actions[actionName] + + // Sort versions alphabetically + var versionKeys []string + for version := range versions { + versionKeys = append(versionKeys, version) + } + sort.Strings(versionKeys) + + // Calculate total usage count for this action + totalUsage := 0 + for _, refs := range versions { + totalUsage += len(refs) + } + + markdownBuilder.WriteString(fmt.Sprintf("## %s\n\n", actionName)) + markdownBuilder.WriteString(fmt.Sprintf("**Total Usage**: %d workflow file(s) across %d version(s)\n\n", totalUsage, len(versions))) + + // For each version, create a collapsible section + for _, version := range versionKeys { + refs := versions[version] + + // Sort references by repo name and file path + sort.Slice(refs, func(i, j int) bool { + if refs[i].RepoName == refs[j].RepoName { + return refs[i].FilePath < refs[j].FilePath + } + return refs[i].RepoName < refs[j].RepoName + }) + + usageCount := len(refs) + + // Display version with usage count + versionDisplay := version + if versionDisplay == "" { + versionDisplay = "(no version specified)" + } + + markdownBuilder.WriteString(fmt.Sprintf("### Version: `%s`\n\n", versionDisplay)) + markdownBuilder.WriteString(fmt.Sprintf("**Usage Count**: %d\n\n", usageCount)) + + // Create collapsible section for workflow files + // To minimize noise, we'll show up to 10 files directly, and collapse the rest + const maxDirectShow = 10 + + markdownBuilder.WriteString("
\n") + markdownBuilder.WriteString(fmt.Sprintf("Show %d workflow file(s) using this version\n\n", usageCount)) + + // Show all refs in the collapsible section + for _, ref := range refs { + url := fmt.Sprintf("https://github.com/%s/%s/blob/main/%s", org, ref.RepoName, ref.FilePath) + markdownBuilder.WriteString(fmt.Sprintf("- [%s: %s](%s)\n", ref.RepoName, ref.FilePath, url)) + } + + markdownBuilder.WriteString("\n
\n\n") + } + + markdownBuilder.WriteString("\n") + } + + markdownBuilder.WriteString("\n*This file is automatically generated after each data collection run.*\n") + + // Write to USES.md in db folder + usesPath := filepath.Join(dbPath, "USES.md") + err := os.WriteFile(usesPath, []byte(markdownBuilder.String()), 0644) + if err != nil { + return fmt.Errorf("error writing USES.md: %v", err) + } + + fmt.Printf("Generated USES.md with %d actions\n", len(actionNames)) + return nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..6d9fba5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "os" + "testing" +) + +func TestExtractActionUses(t *testing.T) { + // Read a sample workflow file + content, err := os.ReadFile(".github/workflows/build-go.yml") + if err != nil { + t.Fatalf("Error reading workflow file: %v", err) + } + + uses := extractActionUses(string(content), "dotgithubindexer", ".github/workflows/build-go.yml") + + if len(uses) == 0 { + t.Error("Expected to find action uses, but found none") + } + + // Check that we found the expected actions + foundCheckout := false + foundSetupGo := false + foundCache := false + foundCodecov := false + + for _, use := range uses { + t.Logf("Found action: %s, version: %s", use.Action, use.Version) + + switch use.Action { + case "actions/checkout": + foundCheckout = true + // Check that version includes the comment + if use.Version == "" { + t.Error("Expected version for actions/checkout, but got empty string") + } + case "actions/setup-go": + foundSetupGo = true + case "actions/cache": + foundCache = true + case "codecov/codecov-action": + foundCodecov = true + } + } + + if !foundCheckout { + t.Error("Expected to find actions/checkout") + } + if !foundSetupGo { + t.Error("Expected to find actions/setup-go") + } + if !foundCache { + t.Error("Expected to find actions/cache") + } + if !foundCodecov { + t.Error("Expected to find codecov/codecov-action") + } +} + +func TestParseUsesString(t *testing.T) { + testCases := []struct { + name string + usesStr string + workflowContent string + expectedAction string + expectedVersion string + }{ + { + name: "Simple version", + usesStr: "actions/checkout@v4", + workflowContent: ` + - uses: actions/checkout@v4 +`, + expectedAction: "actions/checkout", + expectedVersion: "v4", + }, + { + name: "Version with comment", + usesStr: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", + workflowContent: ` + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +`, + expectedAction: "actions/checkout", + expectedVersion: "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", + }, + { + name: "No version", + usesStr: "actions/checkout", + workflowContent: ` + - uses: actions/checkout +`, + expectedAction: "actions/checkout", + expectedVersion: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + action, version := parseUsesString(tc.usesStr, tc.workflowContent) + + if action != tc.expectedAction { + t.Errorf("Expected action %q, got %q", tc.expectedAction, action) + } + + if version != tc.expectedVersion { + t.Errorf("Expected version %q, got %q", tc.expectedVersion, version) + } + }) + } +} diff --git a/test_main_temp.go b/test_main_temp.go new file mode 100644 index 0000000..6bdbad2 --- /dev/null +++ b/test_main_temp.go @@ -0,0 +1,26 @@ +package main + +import ( +"fmt" +"os" +) + +func testParsing() { +// Read the workflow file +content, err := os.ReadFile(".github/workflows/build-go.yml") +if err != nil { +fmt.Printf("Error reading file: %v\n", err) +os.Exit(1) +} + +// Parse it +uses := extractActionUses(string(content), "dotgithubindexer", ".github/workflows/build-go.yml") + +fmt.Printf("Found %d uses:\n", len(uses)) +for _, use := range uses { +fmt.Printf(" - Action: %s\n", use.Action) +fmt.Printf(" Version: %s\n", use.Version) +fmt.Printf(" File: %s/%s\n", use.RepoName, use.FilePath) +fmt.Println() +} +} From 6ffbcf10a15859b7529c2c56d7952fc4efbe10ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:24:17 +0000 Subject: [PATCH 03/11] Remove temporary test file Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- test_main_temp.go | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 test_main_temp.go diff --git a/test_main_temp.go b/test_main_temp.go deleted file mode 100644 index 6bdbad2..0000000 --- a/test_main_temp.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( -"fmt" -"os" -) - -func testParsing() { -// Read the workflow file -content, err := os.ReadFile(".github/workflows/build-go.yml") -if err != nil { -fmt.Printf("Error reading file: %v\n", err) -os.Exit(1) -} - -// Parse it -uses := extractActionUses(string(content), "dotgithubindexer", ".github/workflows/build-go.yml") - -fmt.Printf("Found %d uses:\n", len(uses)) -for _, use := range uses { -fmt.Printf(" - Action: %s\n", use.Action) -fmt.Printf(" Version: %s\n", use.Version) -fmt.Printf(" File: %s/%s\n", use.RepoName, use.FilePath) -fmt.Println() -} -} From cc1b5dfe4d698bec2d52e2cd6e6e709ac10ff1cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:25:42 +0000 Subject: [PATCH 04/11] Add comprehensive tests for USES.md generation Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- integration_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++ uses_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 integration_test.go create mode 100644 uses_test.go diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..790ab3d --- /dev/null +++ b/integration_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "os" + "testing" +) + +func TestIntegrationWorkflowParsing(t *testing.T) { + // Create a sample workflow content + workflowContent := ` +name: Test Workflow +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@v5 + + - name: Cache + uses: actions/cache@v4.1.0 # latest cache + + - name: Custom action + uses: my-org/my-action@main + + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/upload-artifact@v4 +` + + // Test extraction + uses := extractActionUses(workflowContent, "test-repo", ".github/workflows/test.yml") + + // Verify we found all the uses + expectedCount := 6 + if len(uses) != expectedCount { + t.Errorf("Expected to find %d uses, but found %d", expectedCount, len(uses)) + } + + // Build a map for easier verification + usesMap := make(map[string]string) + for _, use := range uses { + usesMap[use.Action] = use.Version + } + + // Verify each action + testCases := []struct { + action string + shouldContainInVersion string + }{ + {"actions/checkout", "de0fac2e4500dabe0009e67214ff5f5447ce83dd"}, + {"actions/checkout", "v6.0.2"}, // Should have the comment + {"actions/setup-go", "v5"}, + {"actions/cache", "v4.1.0"}, + {"my-org/my-action", "main"}, + {"actions/upload-artifact", "v4"}, + } + + for _, tc := range testCases { + found := false + for _, use := range uses { + if use.Action == tc.action { + found = true + t.Logf("Found %s @ %s", use.Action, use.Version) + break + } + } + if !found { + t.Errorf("Expected to find action %s", tc.action) + } + } + + // Verify that the comment is captured for actions/checkout + checkoutFound := false + for _, use := range uses { + if use.Action == "actions/checkout" && use.Version == "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2" { + checkoutFound = true + break + } + } + if !checkoutFound { + t.Error("Expected to find actions/checkout with version comment") + } +} + +func TestRealWorkflowFiles(t *testing.T) { + // Test with actual workflow files in the repository + workflowFiles := []string{ + ".github/workflows/build-go.yml", + ".github/workflows/codeql-go.yml", + ".github/workflows/release-go-github.yml", + } + + totalUses := 0 + for _, file := range workflowFiles { + content, err := os.ReadFile(file) + if err != nil { + t.Logf("Skipping %s (not found): %v", file, err) + continue + } + + uses := extractActionUses(string(content), "dotgithubindexer", file) + t.Logf("File %s: found %d uses", file, len(uses)) + for _, use := range uses { + t.Logf(" - %s @ %s", use.Action, use.Version) + } + totalUses += len(uses) + } + + if totalUses == 0 { + t.Error("Expected to find at least some action uses across all workflow files") + } +} diff --git a/uses_test.go b/uses_test.go new file mode 100644 index 0000000..6f4b636 --- /dev/null +++ b/uses_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateUSESMarkdown(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create sample action uses data + usesIndex := &ActionUsesIndex{ + Actions: make(map[string]map[string][]WorkflowReference), + } + + // Add some test data + usesIndex.Actions["actions/checkout"] = map[string][]WorkflowReference{ + "v4": { + {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, + {RepoName: "repo2", FilePath: ".github/workflows/build.yml"}, + }, + "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2": { + {RepoName: "repo3", FilePath: ".github/workflows/deploy.yml"}, + }, + } + + usesIndex.Actions["actions/setup-go"] = map[string][]WorkflowReference{ + "v5.0.0": { + {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, + }, + } + + // Generate USES.md + err := generateUSESMarkdown(tempDir, "test-org", usesIndex) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Read the generated file + usesPath := filepath.Join(tempDir, "USES.md") + content, err := os.ReadFile(usesPath) + if err != nil { + t.Fatalf("Error reading USES.md: %v", err) + } + + contentStr := string(content) + t.Logf("Generated USES.md:\n%s", contentStr) + + // Verify the content + expectedStrings := []string{ + "# GitHub Actions Uses", + "## actions/checkout", + "## actions/setup-go", + "### Version: `v4`", + "### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`", + "### Version: `v5.0.0`", + "**Total Usage**: 3 workflow file(s) across 2 version(s)", + "**Total Usage**: 1 workflow file(s) across 1 version(s)", + "
", + "
", + "repo1", + "repo2", + "repo3", + } + + for _, expected := range expectedStrings { + if !strings.Contains(contentStr, expected) { + t.Errorf("Expected to find %q in USES.md, but it was not found", expected) + } + } + + // Verify that the markdown is properly formatted + if !strings.HasPrefix(contentStr, "# GitHub Actions Uses") { + t.Error("USES.md should start with the title") + } + + if !strings.HasSuffix(strings.TrimSpace(contentStr), "*This file is automatically generated after each data collection run.*") { + t.Error("USES.md should end with the auto-generated notice") + } +} + +func TestGenerateUSESMarkdown_Empty(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create empty uses index + usesIndex := &ActionUsesIndex{ + Actions: make(map[string]map[string][]WorkflowReference), + } + + // Generate USES.md with empty data + err := generateUSESMarkdown(tempDir, "test-org", usesIndex) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Verify that no file was created for empty index + usesPath := filepath.Join(tempDir, "USES.md") + if _, err := os.Stat(usesPath); err == nil { + t.Error("USES.md should not be created for empty index") + } +} + +func TestGenerateUSESMarkdown_Nil(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Generate USES.md with nil index + err := generateUSESMarkdown(tempDir, "test-org", nil) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Verify that no file was created for nil index + usesPath := filepath.Join(tempDir, "USES.md") + if _, err := os.Stat(usesPath); err == nil { + t.Error("USES.md should not be created for nil index") + } +} From a471c1879150736261508057552fef22bcc8466b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:27:01 +0000 Subject: [PATCH 05/11] Add example USES.md file for documentation Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- USES_EXAMPLE.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 USES_EXAMPLE.md diff --git a/USES_EXAMPLE.md b/USES_EXAMPLE.md new file mode 100644 index 0000000..2a75cc2 --- /dev/null +++ b/USES_EXAMPLE.md @@ -0,0 +1,65 @@ +# GitHub Actions Uses + +This document provides an index of all GitHub Actions used across workflows in the organization. + +**Legend:** +- **Action**: The GitHub Action being used (e.g., `actions/checkout`) +- **Version**: The specific version of the action, including any inline comments +- **Usage Count**: The number of workflow files using this specific version + +## actions/checkout + +**Total Usage**: 4 workflow file(s) across 3 version(s) + +### Version: `8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1` + +**Usage Count**: 1 + +
+Show 1 workflow file(s) using this version + +- [dotgithubindexer: .github/workflows/zizmor.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/zizmor.yml) + +
+ +### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2` + +**Usage Count**: 1 + +
+Show 1 workflow file(s) using this version + +- [dotgithubindexer: .github/workflows/build-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/build-go.yml) + +
+ +### Version: `v6` + +**Usage Count**: 2 + +
+Show 2 workflow file(s) using this version + +- [dotgithubindexer: .github/workflows/codeql-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/codeql-go.yml) +- [dotgithubindexer: .github/workflows/release-go-github.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/release-go-github.yml) + +
+ + +## actions/setup-go + +**Total Usage**: 1 workflow file(s) across 1 version(s) + +### Version: `7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0` + +**Usage Count**: 1 + +
+Show 1 workflow file(s) using this version + +- [dotgithubindexer: .github/workflows/build-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/build-go.yml) + +
+ + +*This file is automatically generated after each data collection run.* From a3528eb9d003b6950869047c3fa77c207eebaf2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:28:37 +0000 Subject: [PATCH 06/11] Address code review feedback - fix comment parsing and simplify test Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- integration_test.go | 44 ++++++++------------------------------------ main.go | 17 +++++++++++------ 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/integration_test.go b/integration_test.go index 790ab3d..ab132c0 100644 --- a/integration_test.go +++ b/integration_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "strings" "testing" ) @@ -43,49 +44,20 @@ jobs: t.Errorf("Expected to find %d uses, but found %d", expectedCount, len(uses)) } - // Build a map for easier verification - usesMap := make(map[string]string) for _, use := range uses { - usesMap[use.Action] = use.Version + t.Logf("Found %s @ %s", use.Action, use.Version) } - // Verify each action - testCases := []struct { - action string - shouldContainInVersion string - }{ - {"actions/checkout", "de0fac2e4500dabe0009e67214ff5f5447ce83dd"}, - {"actions/checkout", "v6.0.2"}, // Should have the comment - {"actions/setup-go", "v5"}, - {"actions/cache", "v4.1.0"}, - {"my-org/my-action", "main"}, - {"actions/upload-artifact", "v4"}, - } - - for _, tc := range testCases { - found := false - for _, use := range uses { - if use.Action == tc.action { - found = true - t.Logf("Found %s @ %s", use.Action, use.Version) - break - } - } - if !found { - t.Errorf("Expected to find action %s", tc.action) - } - } - - // Verify that the comment is captured for actions/checkout - checkoutFound := false + // Verify that the comment is captured for actions/checkout with hash + checkoutWithCommentFound := false for _, use := range uses { - if use.Action == "actions/checkout" && use.Version == "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2" { - checkoutFound = true + if use.Action == "actions/checkout" && strings.Contains(use.Version, "de0fac2e4500dabe0009e67214ff5f5447ce83dd") && strings.Contains(use.Version, "v6.0.2") { + checkoutWithCommentFound = true break } } - if !checkoutFound { - t.Error("Expected to find actions/checkout with version comment") + if !checkoutWithCommentFound { + t.Error("Expected to find actions/checkout with version comment (hash # v6.0.2)") } } diff --git a/main.go b/main.go index 598bc68..58fc3cc 100644 --- a/main.go +++ b/main.go @@ -421,12 +421,17 @@ func parseUsesString(usesStr string, workflowContent string) (action string, ver trimmedLine := strings.TrimSpace(line) // Check if this line contains our uses statement if strings.Contains(trimmedLine, "uses:") && strings.Contains(trimmedLine, usesStr) { - // Check if there's a comment - if commentIdx := strings.Index(trimmedLine, "#"); commentIdx != -1 { - // Extract the comment part (after the #) - comment := strings.TrimSpace(trimmedLine[commentIdx+1:]) - if comment != "" { - version = version + " # " + comment + // Check if there's a comment after the uses statement + // Find the position after the version part to look for a comment + usesEndIdx := strings.Index(trimmedLine, usesStr) + len(usesStr) + if usesEndIdx < len(trimmedLine) { + remainingLine := trimmedLine[usesEndIdx:] + if commentIdx := strings.Index(remainingLine, "#"); commentIdx != -1 { + // Extract the comment part (after the #) + comment := strings.TrimSpace(remainingLine[commentIdx+1:]) + if comment != "" { + version = version + " # " + comment + } } } break From 307e535585211119fd0a81d1d9799d46d086e71e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:31:10 +0000 Subject: [PATCH 07/11] Add feature documentation for USES.md Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- USES_FEATURE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 USES_FEATURE.md diff --git a/USES_FEATURE.md b/USES_FEATURE.md new file mode 100644 index 0000000..8eb9409 --- /dev/null +++ b/USES_FEATURE.md @@ -0,0 +1,51 @@ +# USES.md Feature Implementation + +## Overview + +This implementation adds automatic generation of a `USES.md` file that indexes all GitHub Actions `uses` statements across workflow files in an organization. + +## How It Works + +1. **Parsing**: The application parses each workflow YAML file and extracts all `uses` statements from steps +2. **Version Extraction**: For each use, it captures: + - The action name (e.g., `actions/checkout`) + - The version or reference (e.g., `v6`, `de0fac2e4500dabe0009e67214ff5f5447ce83dd`) + - Any inline comments (e.g., `# v6.0.2`) +3. **Aggregation**: Data is aggregated by: + - Action name (alphabetically sorted) + - Version within each action (alphabetically sorted) + - List of workflow files using each version +4. **Generation**: A markdown file is generated with: + - Action sections with total usage counts + - Version subsections with usage counts + - Collapsible details sections listing all workflow files + - Direct links to workflow files on GitHub + +## Output Location + +The `USES.md` file is generated in the `db` folder alongside: +- `README.md` (workflow summary) +- `repositories.yaml` (repository manifest) +- `workflows/` (workflow file storage) +- `dependabot/` (dependabot configuration storage) + +## Example + +See `USES_EXAMPLE.md` in the repository root for a sample of the generated output. + +## Key Features + +- **Comprehensive**: Captures all action uses across all workflows +- **Version Aware**: Includes both references and inline comments +- **Organized**: Alphabetically sorted for easy navigation +- **Minimal Noise**: Collapsible sections prevent overwhelming detail +- **Actionable**: Direct links to source files for investigation +- **Automated**: Generated automatically during each audit run + +## Use Cases + +1. **Standardization**: Identify which versions of actions are in use across the organization +2. **Updates**: Find all workflows using specific action versions for coordinated updates +3. **Security**: Quickly locate workflows using deprecated or vulnerable action versions +4. **Compliance**: Track action usage for compliance and governance purposes +5. **Optimization**: Identify opportunities to consolidate on fewer action versions From 571e2f7a8a87910f10d5aca2166a47c7cf205e5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:58:47 +0000 Subject: [PATCH 08/11] Consolidate all tests into main_test.go and skip archived repositories Co-authored-by: JaredHatfield <208119+JaredHatfield@users.noreply.github.com> --- integration_test.go | 91 ------------------- main.go | 5 ++ main_test.go | 212 ++++++++++++++++++++++++++++++++++++++++++++ uses_test.go | 122 ------------------------- 4 files changed, 217 insertions(+), 213 deletions(-) delete mode 100644 integration_test.go delete mode 100644 uses_test.go diff --git a/integration_test.go b/integration_test.go deleted file mode 100644 index ab132c0..0000000 --- a/integration_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "os" - "strings" - "testing" -) - -func TestIntegrationWorkflowParsing(t *testing.T) { - // Create a sample workflow content - workflowContent := ` -name: Test Workflow -on: [push] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Go - uses: actions/setup-go@v5 - - - name: Cache - uses: actions/cache@v4.1.0 # latest cache - - - name: Custom action - uses: my-org/my-action@main - - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/upload-artifact@v4 -` - - // Test extraction - uses := extractActionUses(workflowContent, "test-repo", ".github/workflows/test.yml") - - // Verify we found all the uses - expectedCount := 6 - if len(uses) != expectedCount { - t.Errorf("Expected to find %d uses, but found %d", expectedCount, len(uses)) - } - - for _, use := range uses { - t.Logf("Found %s @ %s", use.Action, use.Version) - } - - // Verify that the comment is captured for actions/checkout with hash - checkoutWithCommentFound := false - for _, use := range uses { - if use.Action == "actions/checkout" && strings.Contains(use.Version, "de0fac2e4500dabe0009e67214ff5f5447ce83dd") && strings.Contains(use.Version, "v6.0.2") { - checkoutWithCommentFound = true - break - } - } - if !checkoutWithCommentFound { - t.Error("Expected to find actions/checkout with version comment (hash # v6.0.2)") - } -} - -func TestRealWorkflowFiles(t *testing.T) { - // Test with actual workflow files in the repository - workflowFiles := []string{ - ".github/workflows/build-go.yml", - ".github/workflows/codeql-go.yml", - ".github/workflows/release-go-github.yml", - } - - totalUses := 0 - for _, file := range workflowFiles { - content, err := os.ReadFile(file) - if err != nil { - t.Logf("Skipping %s (not found): %v", file, err) - continue - } - - uses := extractActionUses(string(content), "dotgithubindexer", file) - t.Logf("File %s: found %d uses", file, len(uses)) - for _, use := range uses { - t.Logf(" - %s @ %s", use.Action, use.Version) - } - totalUses += len(uses) - } - - if totalUses == 0 { - t.Error("Expected to find at least some action uses across all workflow files") - } -} diff --git a/main.go b/main.go index 58fc3cc..c7a251d 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,11 @@ func fetchRepositories(client *github.Client, org string, includePub, includePrv } for _, repo := range repos { + // Skip archived repositories + if repo.GetArchived() { + continue + } + visibility := repo.GetVisibility() if includePub && visibility == "public" { diff --git a/main_test.go b/main_test.go index 6d9fba5..dcd8ae9 100644 --- a/main_test.go +++ b/main_test.go @@ -2,9 +2,15 @@ package main import ( "os" + "path/filepath" + "strings" "testing" ) +// ------------------------ +// Section: Action Uses Parsing Tests +// ------------------------ + func TestExtractActionUses(t *testing.T) { // Read a sample workflow file content, err := os.ReadFile(".github/workflows/build-go.yml") @@ -108,3 +114,209 @@ func TestParseUsesString(t *testing.T) { }) } } + +// ------------------------ +// Section: USES.md Generation Tests +// ------------------------ + +func TestGenerateUSESMarkdown(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create sample action uses data + usesIndex := &ActionUsesIndex{ + Actions: make(map[string]map[string][]WorkflowReference), + } + + // Add some test data + usesIndex.Actions["actions/checkout"] = map[string][]WorkflowReference{ + "v4": { + {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, + {RepoName: "repo2", FilePath: ".github/workflows/build.yml"}, + }, + "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2": { + {RepoName: "repo3", FilePath: ".github/workflows/deploy.yml"}, + }, + } + + usesIndex.Actions["actions/setup-go"] = map[string][]WorkflowReference{ + "v5.0.0": { + {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, + }, + } + + // Generate USES.md + err := generateUSESMarkdown(tempDir, "test-org", usesIndex) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Read the generated file + usesPath := filepath.Join(tempDir, "USES.md") + content, err := os.ReadFile(usesPath) + if err != nil { + t.Fatalf("Error reading USES.md: %v", err) + } + + contentStr := string(content) + t.Logf("Generated USES.md:\n%s", contentStr) + + // Verify the content + expectedStrings := []string{ + "# GitHub Actions Uses", + "## actions/checkout", + "## actions/setup-go", + "### Version: `v4`", + "### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`", + "### Version: `v5.0.0`", + "**Total Usage**: 3 workflow file(s) across 2 version(s)", + "**Total Usage**: 1 workflow file(s) across 1 version(s)", + "
", + "
", + "repo1", + "repo2", + "repo3", + } + + for _, expected := range expectedStrings { + if !strings.Contains(contentStr, expected) { + t.Errorf("Expected to find %q in USES.md, but it was not found", expected) + } + } + + // Verify that the markdown is properly formatted + if !strings.HasPrefix(contentStr, "# GitHub Actions Uses") { + t.Error("USES.md should start with the title") + } + + if !strings.HasSuffix(strings.TrimSpace(contentStr), "*This file is automatically generated after each data collection run.*") { + t.Error("USES.md should end with the auto-generated notice") + } +} + +func TestGenerateUSESMarkdown_Empty(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create empty uses index + usesIndex := &ActionUsesIndex{ + Actions: make(map[string]map[string][]WorkflowReference), + } + + // Generate USES.md with empty data + err := generateUSESMarkdown(tempDir, "test-org", usesIndex) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Verify that no file was created for empty index + usesPath := filepath.Join(tempDir, "USES.md") + if _, err := os.Stat(usesPath); err == nil { + t.Error("USES.md should not be created for empty index") + } +} + +func TestGenerateUSESMarkdown_Nil(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Generate USES.md with nil index + err := generateUSESMarkdown(tempDir, "test-org", nil) + if err != nil { + t.Fatalf("Error generating USES.md: %v", err) + } + + // Verify that no file was created for nil index + usesPath := filepath.Join(tempDir, "USES.md") + if _, err := os.Stat(usesPath); err == nil { + t.Error("USES.md should not be created for nil index") + } +} + +// ------------------------ +// Section: Integration Tests +// ------------------------ + +func TestIntegrationWorkflowParsing(t *testing.T) { + // Create a sample workflow content + workflowContent := ` +name: Test Workflow +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@v5 + + - name: Cache + uses: actions/cache@v4.1.0 # latest cache + + - name: Custom action + uses: my-org/my-action@main + + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/upload-artifact@v4 +` + + // Test extraction + uses := extractActionUses(workflowContent, "test-repo", ".github/workflows/test.yml") + + // Verify we found all the uses + expectedCount := 6 + if len(uses) != expectedCount { + t.Errorf("Expected to find %d uses, but found %d", expectedCount, len(uses)) + } + + for _, use := range uses { + t.Logf("Found %s @ %s", use.Action, use.Version) + } + + // Verify that the comment is captured for actions/checkout with hash + checkoutWithCommentFound := false + for _, use := range uses { + if use.Action == "actions/checkout" && strings.Contains(use.Version, "de0fac2e4500dabe0009e67214ff5f5447ce83dd") && strings.Contains(use.Version, "v6.0.2") { + checkoutWithCommentFound = true + break + } + } + if !checkoutWithCommentFound { + t.Error("Expected to find actions/checkout with version comment (hash # v6.0.2)") + } +} + +func TestRealWorkflowFiles(t *testing.T) { + // Test with actual workflow files in the repository + workflowFiles := []string{ + ".github/workflows/build-go.yml", + ".github/workflows/codeql-go.yml", + ".github/workflows/release-go-github.yml", + } + + totalUses := 0 + for _, file := range workflowFiles { + content, err := os.ReadFile(file) + if err != nil { + t.Logf("Skipping %s (not found): %v", file, err) + continue + } + + uses := extractActionUses(string(content), "dotgithubindexer", file) + t.Logf("File %s: found %d uses", file, len(uses)) + for _, use := range uses { + t.Logf(" - %s @ %s", use.Action, use.Version) + } + totalUses += len(uses) + } + + if totalUses == 0 { + t.Error("Expected to find at least some action uses across all workflow files") + } +} diff --git a/uses_test.go b/uses_test.go deleted file mode 100644 index 6f4b636..0000000 --- a/uses_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestGenerateUSESMarkdown(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Create sample action uses data - usesIndex := &ActionUsesIndex{ - Actions: make(map[string]map[string][]WorkflowReference), - } - - // Add some test data - usesIndex.Actions["actions/checkout"] = map[string][]WorkflowReference{ - "v4": { - {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, - {RepoName: "repo2", FilePath: ".github/workflows/build.yml"}, - }, - "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2": { - {RepoName: "repo3", FilePath: ".github/workflows/deploy.yml"}, - }, - } - - usesIndex.Actions["actions/setup-go"] = map[string][]WorkflowReference{ - "v5.0.0": { - {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, - }, - } - - // Generate USES.md - err := generateUSESMarkdown(tempDir, "test-org", usesIndex) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Read the generated file - usesPath := filepath.Join(tempDir, "USES.md") - content, err := os.ReadFile(usesPath) - if err != nil { - t.Fatalf("Error reading USES.md: %v", err) - } - - contentStr := string(content) - t.Logf("Generated USES.md:\n%s", contentStr) - - // Verify the content - expectedStrings := []string{ - "# GitHub Actions Uses", - "## actions/checkout", - "## actions/setup-go", - "### Version: `v4`", - "### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`", - "### Version: `v5.0.0`", - "**Total Usage**: 3 workflow file(s) across 2 version(s)", - "**Total Usage**: 1 workflow file(s) across 1 version(s)", - "
", - "
", - "repo1", - "repo2", - "repo3", - } - - for _, expected := range expectedStrings { - if !strings.Contains(contentStr, expected) { - t.Errorf("Expected to find %q in USES.md, but it was not found", expected) - } - } - - // Verify that the markdown is properly formatted - if !strings.HasPrefix(contentStr, "# GitHub Actions Uses") { - t.Error("USES.md should start with the title") - } - - if !strings.HasSuffix(strings.TrimSpace(contentStr), "*This file is automatically generated after each data collection run.*") { - t.Error("USES.md should end with the auto-generated notice") - } -} - -func TestGenerateUSESMarkdown_Empty(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Create empty uses index - usesIndex := &ActionUsesIndex{ - Actions: make(map[string]map[string][]WorkflowReference), - } - - // Generate USES.md with empty data - err := generateUSESMarkdown(tempDir, "test-org", usesIndex) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Verify that no file was created for empty index - usesPath := filepath.Join(tempDir, "USES.md") - if _, err := os.Stat(usesPath); err == nil { - t.Error("USES.md should not be created for empty index") - } -} - -func TestGenerateUSESMarkdown_Nil(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Generate USES.md with nil index - err := generateUSESMarkdown(tempDir, "test-org", nil) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Verify that no file was created for nil index - usesPath := filepath.Join(tempDir, "USES.md") - if _, err := os.Stat(usesPath); err == nil { - t.Error("USES.md should not be created for nil index") - } -} From 1483e07eaa5b94c944e41c7aab751ac6b8c409c6 Mon Sep 17 00:00:00 2001 From: Jared Hatfield Date: Tue, 10 Feb 2026 22:01:22 -0500 Subject: [PATCH 09/11] Delete USES_EXAMPLE.md --- USES_EXAMPLE.md | 65 ------------------------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 USES_EXAMPLE.md diff --git a/USES_EXAMPLE.md b/USES_EXAMPLE.md deleted file mode 100644 index 2a75cc2..0000000 --- a/USES_EXAMPLE.md +++ /dev/null @@ -1,65 +0,0 @@ -# GitHub Actions Uses - -This document provides an index of all GitHub Actions used across workflows in the organization. - -**Legend:** -- **Action**: The GitHub Action being used (e.g., `actions/checkout`) -- **Version**: The specific version of the action, including any inline comments -- **Usage Count**: The number of workflow files using this specific version - -## actions/checkout - -**Total Usage**: 4 workflow file(s) across 3 version(s) - -### Version: `8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1` - -**Usage Count**: 1 - -
-Show 1 workflow file(s) using this version - -- [dotgithubindexer: .github/workflows/zizmor.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/zizmor.yml) - -
- -### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2` - -**Usage Count**: 1 - -
-Show 1 workflow file(s) using this version - -- [dotgithubindexer: .github/workflows/build-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/build-go.yml) - -
- -### Version: `v6` - -**Usage Count**: 2 - -
-Show 2 workflow file(s) using this version - -- [dotgithubindexer: .github/workflows/codeql-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/codeql-go.yml) -- [dotgithubindexer: .github/workflows/release-go-github.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/release-go-github.yml) - -
- - -## actions/setup-go - -**Total Usage**: 1 workflow file(s) across 1 version(s) - -### Version: `7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0` - -**Usage Count**: 1 - -
-Show 1 workflow file(s) using this version - -- [dotgithubindexer: .github/workflows/build-go.yml](https://github.com/UnitVectorY-Labs/dotgithubindexer/blob/main/.github/workflows/build-go.yml) - -
- - -*This file is automatically generated after each data collection run.* From 91bae25addf09435169ba74f23bd36efd202e0a6 Mon Sep 17 00:00:00 2001 From: Jared Hatfield Date: Tue, 10 Feb 2026 22:01:37 -0500 Subject: [PATCH 10/11] Delete USES_FEATURE.md --- USES_FEATURE.md | 51 ------------------------------------------------- 1 file changed, 51 deletions(-) delete mode 100644 USES_FEATURE.md diff --git a/USES_FEATURE.md b/USES_FEATURE.md deleted file mode 100644 index 8eb9409..0000000 --- a/USES_FEATURE.md +++ /dev/null @@ -1,51 +0,0 @@ -# USES.md Feature Implementation - -## Overview - -This implementation adds automatic generation of a `USES.md` file that indexes all GitHub Actions `uses` statements across workflow files in an organization. - -## How It Works - -1. **Parsing**: The application parses each workflow YAML file and extracts all `uses` statements from steps -2. **Version Extraction**: For each use, it captures: - - The action name (e.g., `actions/checkout`) - - The version or reference (e.g., `v6`, `de0fac2e4500dabe0009e67214ff5f5447ce83dd`) - - Any inline comments (e.g., `# v6.0.2`) -3. **Aggregation**: Data is aggregated by: - - Action name (alphabetically sorted) - - Version within each action (alphabetically sorted) - - List of workflow files using each version -4. **Generation**: A markdown file is generated with: - - Action sections with total usage counts - - Version subsections with usage counts - - Collapsible details sections listing all workflow files - - Direct links to workflow files on GitHub - -## Output Location - -The `USES.md` file is generated in the `db` folder alongside: -- `README.md` (workflow summary) -- `repositories.yaml` (repository manifest) -- `workflows/` (workflow file storage) -- `dependabot/` (dependabot configuration storage) - -## Example - -See `USES_EXAMPLE.md` in the repository root for a sample of the generated output. - -## Key Features - -- **Comprehensive**: Captures all action uses across all workflows -- **Version Aware**: Includes both references and inline comments -- **Organized**: Alphabetically sorted for easy navigation -- **Minimal Noise**: Collapsible sections prevent overwhelming detail -- **Actionable**: Direct links to source files for investigation -- **Automated**: Generated automatically during each audit run - -## Use Cases - -1. **Standardization**: Identify which versions of actions are in use across the organization -2. **Updates**: Find all workflows using specific action versions for coordinated updates -3. **Security**: Quickly locate workflows using deprecated or vulnerable action versions -4. **Compliance**: Track action usage for compliance and governance purposes -5. **Optimization**: Identify opportunities to consolidate on fewer action versions From 60571c053b23cf1ea386dd89545ab9d2cf158b6f Mon Sep 17 00:00:00 2001 From: Jared Hatfield Date: Tue, 10 Feb 2026 22:06:21 -0500 Subject: [PATCH 11/11] Delete main_test.go --- main_test.go | 322 --------------------------------------------------- 1 file changed, 322 deletions(-) delete mode 100644 main_test.go diff --git a/main_test.go b/main_test.go deleted file mode 100644 index dcd8ae9..0000000 --- a/main_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// ------------------------ -// Section: Action Uses Parsing Tests -// ------------------------ - -func TestExtractActionUses(t *testing.T) { - // Read a sample workflow file - content, err := os.ReadFile(".github/workflows/build-go.yml") - if err != nil { - t.Fatalf("Error reading workflow file: %v", err) - } - - uses := extractActionUses(string(content), "dotgithubindexer", ".github/workflows/build-go.yml") - - if len(uses) == 0 { - t.Error("Expected to find action uses, but found none") - } - - // Check that we found the expected actions - foundCheckout := false - foundSetupGo := false - foundCache := false - foundCodecov := false - - for _, use := range uses { - t.Logf("Found action: %s, version: %s", use.Action, use.Version) - - switch use.Action { - case "actions/checkout": - foundCheckout = true - // Check that version includes the comment - if use.Version == "" { - t.Error("Expected version for actions/checkout, but got empty string") - } - case "actions/setup-go": - foundSetupGo = true - case "actions/cache": - foundCache = true - case "codecov/codecov-action": - foundCodecov = true - } - } - - if !foundCheckout { - t.Error("Expected to find actions/checkout") - } - if !foundSetupGo { - t.Error("Expected to find actions/setup-go") - } - if !foundCache { - t.Error("Expected to find actions/cache") - } - if !foundCodecov { - t.Error("Expected to find codecov/codecov-action") - } -} - -func TestParseUsesString(t *testing.T) { - testCases := []struct { - name string - usesStr string - workflowContent string - expectedAction string - expectedVersion string - }{ - { - name: "Simple version", - usesStr: "actions/checkout@v4", - workflowContent: ` - - uses: actions/checkout@v4 -`, - expectedAction: "actions/checkout", - expectedVersion: "v4", - }, - { - name: "Version with comment", - usesStr: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", - workflowContent: ` - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 -`, - expectedAction: "actions/checkout", - expectedVersion: "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2", - }, - { - name: "No version", - usesStr: "actions/checkout", - workflowContent: ` - - uses: actions/checkout -`, - expectedAction: "actions/checkout", - expectedVersion: "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - action, version := parseUsesString(tc.usesStr, tc.workflowContent) - - if action != tc.expectedAction { - t.Errorf("Expected action %q, got %q", tc.expectedAction, action) - } - - if version != tc.expectedVersion { - t.Errorf("Expected version %q, got %q", tc.expectedVersion, version) - } - }) - } -} - -// ------------------------ -// Section: USES.md Generation Tests -// ------------------------ - -func TestGenerateUSESMarkdown(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Create sample action uses data - usesIndex := &ActionUsesIndex{ - Actions: make(map[string]map[string][]WorkflowReference), - } - - // Add some test data - usesIndex.Actions["actions/checkout"] = map[string][]WorkflowReference{ - "v4": { - {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, - {RepoName: "repo2", FilePath: ".github/workflows/build.yml"}, - }, - "de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2": { - {RepoName: "repo3", FilePath: ".github/workflows/deploy.yml"}, - }, - } - - usesIndex.Actions["actions/setup-go"] = map[string][]WorkflowReference{ - "v5.0.0": { - {RepoName: "repo1", FilePath: ".github/workflows/test.yml"}, - }, - } - - // Generate USES.md - err := generateUSESMarkdown(tempDir, "test-org", usesIndex) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Read the generated file - usesPath := filepath.Join(tempDir, "USES.md") - content, err := os.ReadFile(usesPath) - if err != nil { - t.Fatalf("Error reading USES.md: %v", err) - } - - contentStr := string(content) - t.Logf("Generated USES.md:\n%s", contentStr) - - // Verify the content - expectedStrings := []string{ - "# GitHub Actions Uses", - "## actions/checkout", - "## actions/setup-go", - "### Version: `v4`", - "### Version: `de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`", - "### Version: `v5.0.0`", - "**Total Usage**: 3 workflow file(s) across 2 version(s)", - "**Total Usage**: 1 workflow file(s) across 1 version(s)", - "
", - "
", - "repo1", - "repo2", - "repo3", - } - - for _, expected := range expectedStrings { - if !strings.Contains(contentStr, expected) { - t.Errorf("Expected to find %q in USES.md, but it was not found", expected) - } - } - - // Verify that the markdown is properly formatted - if !strings.HasPrefix(contentStr, "# GitHub Actions Uses") { - t.Error("USES.md should start with the title") - } - - if !strings.HasSuffix(strings.TrimSpace(contentStr), "*This file is automatically generated after each data collection run.*") { - t.Error("USES.md should end with the auto-generated notice") - } -} - -func TestGenerateUSESMarkdown_Empty(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Create empty uses index - usesIndex := &ActionUsesIndex{ - Actions: make(map[string]map[string][]WorkflowReference), - } - - // Generate USES.md with empty data - err := generateUSESMarkdown(tempDir, "test-org", usesIndex) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Verify that no file was created for empty index - usesPath := filepath.Join(tempDir, "USES.md") - if _, err := os.Stat(usesPath); err == nil { - t.Error("USES.md should not be created for empty index") - } -} - -func TestGenerateUSESMarkdown_Nil(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Generate USES.md with nil index - err := generateUSESMarkdown(tempDir, "test-org", nil) - if err != nil { - t.Fatalf("Error generating USES.md: %v", err) - } - - // Verify that no file was created for nil index - usesPath := filepath.Join(tempDir, "USES.md") - if _, err := os.Stat(usesPath); err == nil { - t.Error("USES.md should not be created for nil index") - } -} - -// ------------------------ -// Section: Integration Tests -// ------------------------ - -func TestIntegrationWorkflowParsing(t *testing.T) { - // Create a sample workflow content - workflowContent := ` -name: Test Workflow -on: [push] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup Go - uses: actions/setup-go@v5 - - - name: Cache - uses: actions/cache@v4.1.0 # latest cache - - - name: Custom action - uses: my-org/my-action@main - - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/upload-artifact@v4 -` - - // Test extraction - uses := extractActionUses(workflowContent, "test-repo", ".github/workflows/test.yml") - - // Verify we found all the uses - expectedCount := 6 - if len(uses) != expectedCount { - t.Errorf("Expected to find %d uses, but found %d", expectedCount, len(uses)) - } - - for _, use := range uses { - t.Logf("Found %s @ %s", use.Action, use.Version) - } - - // Verify that the comment is captured for actions/checkout with hash - checkoutWithCommentFound := false - for _, use := range uses { - if use.Action == "actions/checkout" && strings.Contains(use.Version, "de0fac2e4500dabe0009e67214ff5f5447ce83dd") && strings.Contains(use.Version, "v6.0.2") { - checkoutWithCommentFound = true - break - } - } - if !checkoutWithCommentFound { - t.Error("Expected to find actions/checkout with version comment (hash # v6.0.2)") - } -} - -func TestRealWorkflowFiles(t *testing.T) { - // Test with actual workflow files in the repository - workflowFiles := []string{ - ".github/workflows/build-go.yml", - ".github/workflows/codeql-go.yml", - ".github/workflows/release-go-github.yml", - } - - totalUses := 0 - for _, file := range workflowFiles { - content, err := os.ReadFile(file) - if err != nil { - t.Logf("Skipping %s (not found): %v", file, err) - continue - } - - uses := extractActionUses(string(content), "dotgithubindexer", file) - t.Logf("File %s: found %d uses", file, len(uses)) - for _, use := range uses { - t.Logf(" - %s @ %s", use.Action, use.Version) - } - totalUses += len(uses) - } - - if totalUses == 0 { - t.Error("Expected to find at least some action uses across all workflow files") - } -}