// Copyright 2022 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT //go:build ignore package main import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "slices" "sort" "strings" ) // regexp is based on go-license, excluding README and NOTICE // https://github.com/google/go-licenses/blob/master/licenses/find.go // also defined in vite.config.ts var licenseRe = regexp.MustCompile(`^(?i)((UN)?LICEN(S|C)E|COPYING).*$`) // primaryLicenseRe matches exact primary license filenames without suffixes. // When a directory has both primary and variant files (e.g. LICENSE and // LICENSE.docs), only the primary files are kept. var primaryLicenseRe = regexp.MustCompile(`^(?i)(LICEN[SC]E|COPYING)$`) // ignoredNames are LicenseEntry.Name values to exclude from the output. var ignoredNames = map[string]bool{ "gitea.dev": true, "gitea.dev/options/license": true, } var excludedExt = map[string]bool{ ".gitignore": true, ".go": true, ".mod": true, ".sum": true, ".toml": true, ".yaml": true, ".yml": true, } type ModuleInfo struct { Path string Dir string PkgDirs []string // directories of packages imported from this module } type LicenseEntry struct { Name string `json:"name"` Path string `json:"path"` LicenseText string `json:"licenseText"` } // getModules returns all dependency modules with their local directory paths // and the package directories used from each module. func getModules(goCmd string) []ModuleInfo { cmd := exec.Command(goCmd, "list", "-deps", "-f", "{{if .Module}}{{.Module.Path}}\t{{.Module.Dir}}\t{{.Dir}}{{end}}", "./...") cmd.Stderr = os.Stderr // Use GOOS=linux with CGO to ensure we capture all platform-specific // dependencies, matching the CI environment. cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1") output, err := cmd.Output() if err != nil { fmt.Fprintf(os.Stderr, "failed to run 'go list -deps': %v\n", err) os.Exit(1) } var modules []ModuleInfo seen := make(map[string]int) // module path -> index in modules for _, line := range strings.Split(string(output), "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, "\t") if len(parts) != 3 { continue } modPath, modDir, pkgDir := parts[0], parts[1], parts[2] if idx, ok := seen[modPath]; ok { modules[idx].PkgDirs = append(modules[idx].PkgDirs, pkgDir) } else { seen[modPath] = len(modules) modules = append(modules, ModuleInfo{ Path: modPath, Dir: modDir, PkgDirs: []string{pkgDir}, }) } } return modules } // findLicenseFiles scans a module's root directory and its used package // directories for license files. It also walks up from each package directory // to the module root, scanning intermediate directories. Subdirectory licenses // are only included if their text differs from the root license(s). func findLicenseFiles(mod ModuleInfo) []LicenseEntry { var entries []LicenseEntry seenTexts := make(map[string]bool) // First, collect root-level license files. entries = append(entries, scanDirForLicenses(mod.Dir, mod.Path, "")...) for _, e := range entries { seenTexts[e.LicenseText] = true } // Then check each package directory and all intermediate parent directories // up to the module root for license files with unique text. seenDirs := map[string]bool{mod.Dir: true} for _, pkgDir := range mod.PkgDirs { for dir := pkgDir; dir != mod.Dir && strings.HasPrefix(dir, mod.Dir); dir = filepath.Dir(dir) { if seenDirs[dir] { continue } seenDirs[dir] = true for _, e := range scanDirForLicenses(dir, mod.Path, mod.Dir) { if !seenTexts[e.LicenseText] { seenTexts[e.LicenseText] = true entries = append(entries, e) } } } } return entries } // scanDirForLicenses reads a single directory for license files and returns entries. // If moduleRoot is non-empty, paths are made relative to it. func scanDirForLicenses(dir, modulePath, moduleRoot string) []LicenseEntry { dirEntries, err := os.ReadDir(dir) if err != nil { return nil } var entries []LicenseEntry for _, entry := range dirEntries { if entry.IsDir() { continue } name := entry.Name() if !licenseRe.MatchString(name) { continue } if excludedExt[strings.ToLower(filepath.Ext(name))] { continue } content, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { continue } entryName := modulePath entryPath := modulePath + "/" + name if moduleRoot != "" { rel, _ := filepath.Rel(moduleRoot, dir) if rel != "." { relSlash := filepath.ToSlash(rel) entryName = modulePath + "/" + relSlash entryPath = modulePath + "/" + relSlash + "/" + name } } entries = append(entries, LicenseEntry{ Name: entryName, Path: entryPath, LicenseText: string(content), }) } // When multiple license files exist, prefer primary files (e.g. LICENSE) // over variants with suffixes (e.g. LICENSE.docs, LICENSE-2.0.txt). // If no primary file exists, keep only the first variant. if len(entries) > 1 { var primary []LicenseEntry for _, e := range entries { fileName := e.Path[strings.LastIndex(e.Path, "/")+1:] if primaryLicenseRe.MatchString(fileName) { primary = append(primary, e) } } if len(primary) > 0 { return primary } return entries[:1] } return entries } func main() { if len(os.Args) != 2 { fmt.Println("usage: go run generate-go-licenses.go ") os.Exit(1) } out := os.Args[1] goCmd := "go" if env := os.Getenv("GO"); env != "" { goCmd = env } modules := getModules(goCmd) var entries []LicenseEntry for _, mod := range modules { entries = append(entries, findLicenseFiles(mod)...) } entries = slices.DeleteFunc(entries, func(e LicenseEntry) bool { return ignoredNames[e.Name] }) sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) jsonBytes, err := json.MarshalIndent(entries, "", " ") if err != nil { panic(err) } // Ensure file has a final newline if jsonBytes[len(jsonBytes)-1] != '\n' { jsonBytes = append(jsonBytes, '\n') } err = os.WriteFile(out, jsonBytes, 0o644) if err != nil { panic(err) } }