From 887542d4e373e0079bb467cc1e82ab42b9482ead Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Wed, 21 Oct 2026 11:33:07 -0400 Subject: [PATCH 01/11] auto-generate license ranges as part of the `extract` process run by cron --- .github/workflows/fetch-licenses.yaml | 2 +- cmd/doc.go | 7 +- cmd/license.go | 12 +- cmd/license_ranges.go | 180 +++++++++++++++++++ cmd/license_ranges_test.go | 38 ++++ cmd/main.go | 19 +- spdxexp/satisfies_test.go | 239 +++++++++++++------------ spdxexp/spdxlicenses/license_ranges.go | 28 ++- 8 files changed, 370 insertions(+), 155 deletions(-) create mode 100644 cmd/license_ranges.go create mode 100644 cmd/license_ranges_test.go diff --git a/.github/workflows/fetch-licenses.yaml b/.github/workflows/fetch-licenses.yaml index 226ae9a..deb6474 100644 --- a/.github/workflows/fetch-licenses.yaml +++ b/.github/workflows/fetch-licenses.yaml @@ -93,5 +93,5 @@ jobs: branch: auto-update-licenses base: main title: "Update SPDX license files (${{ env.DT_STAMP }})" - body: "The files in this PR are auto-generated by the [fetch-licenses](./.git/workflows/fetch-license.yaml) workflow when it runs the `extract` command defined in [cmd/main.go](./cmd/main.go). It updates SPDX licenses based on the latest released set in the [spdx/license-list-data](https://github.com/spdx/license-list-data) repository maintained by [SPDX](https://spdx.org/licenses/). \n\nTODO: [spdxexp/spdxlicenses/license_ranges.go](./spdxexp/spdxlicenses/license_range.go) has to be updated manually." + body: "The files in this PR are auto-generated by the [fetch-licenses](./.github/workflows/fetch-licenses.yaml) workflow when it runs the `extract` command defined in [cmd/main.go](./cmd/main.go). It updates SPDX licenses and license ranges based on the latest released set in the [spdx/license-list-data](https://github.com/spdx/license-list-data) repository maintained by [SPDX](https://spdx.org/licenses/)." labels: 'auto-update,licenses' diff --git a/cmd/doc.go b/cmd/doc.go index 33da144..c53df81 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -4,9 +4,8 @@ The source data needs to be manually updated by copying the licenses.json file f https://github.com/spdx/license-list-data/blob/main/json/licenses.json and exceptions.json file from https://github.com/spdx/license-list-data/blob/main/json/exceptions.json. -After running the extract command, the license_ids.json, deprecated_ids.json, and exception_ids.json -files will be overwritten with the extracted ids. These license ids can then be used to update the -spdxexp/license.go file. +After running the extract command, the generated license, deprecated license, exception, and license +range Go files in spdxexp/spdxlicenses will be overwritten with data derived from the SPDX license list. Command to run all extractions (run command from the /cmd directory): @@ -16,7 +15,7 @@ Command to run all extractions (run command from the /cmd directory): Usage options: -h: prints this help message - -l: Extract license ids + -l: Extract licenses and ranges -e: Extract exception ids */ package main diff --git a/cmd/license.go b/cmd/license.go index 22f1366..97d4b67 100644 --- a/cmd/license.go +++ b/cmd/license.go @@ -24,11 +24,9 @@ type License struct { IsOsiApproved bool `json:"isOsiApproved"` } -// extractLicenseIDs reads the official licenses.json file copied from spdx/license-list-data -// and writes two files, license_ids.json and deprecated_license_ids.json, containing just -// the license IDs and deprecated license IDs, respectively. It returns an error if it -// encounters one. -func extractLicenseIDs() error { +// extractLicenses reads the official licenses.json file copied from spdx/license-list-data +// and generates the active license, deprecated license, and license range Go files. +func extractLicenses() error { // open file file, err := os.Open("licenses.json") if err != nil { @@ -175,5 +173,9 @@ func GetDeprecated() []string { } fmt.Println("Writing `../spdxexp/spdxlicenses/get_deprecated.go`... COMPLETE") + if err := generateLicenseRanges(licenseData.Licenses); err != nil { + return fmt.Errorf("generate license ranges: %w", err) + } + return nil } diff --git a/cmd/license_ranges.go b/cmd/license_ranges.go new file mode 100644 index 0000000..d618311 --- /dev/null +++ b/cmd/license_ranges.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "go/format" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +type licenseRangeFamily struct { + base string + suffix string + inheritBaseVersions bool +} + +// SPDX identifies version ranges syntactically but does not publish family relationships as data. +// Keep family membership explicit and discover each family's available versions from licenses.json. +var licenseRangeFamilies = []licenseRangeFamily{ + {base: "AFL"}, + {base: "AGPL"}, + {base: "Apache"}, + {base: "APSL"}, + {base: "Artistic"}, + {base: "ASWF-Digital-Assets"}, + {base: "BitTorrent"}, + {base: "Brian-Gladman", suffix: "-Clause"}, + {base: "CC-BY"}, + {base: "CC-BY-NC"}, + {base: "CC-BY-NC-ND"}, + {base: "CC-BY-NC-SA"}, + {base: "CC-BY-ND"}, + {base: "CC-BY-SA"}, + {base: "CDDL"}, + {base: "CECILL"}, + {base: "DRL"}, + {base: "ECL"}, + {base: "EFL"}, + {base: "EPL"}, + {base: "EUPL"}, + {base: "GFDL"}, + {base: "GPL"}, + {base: "HP"}, + {base: "LGPL"}, + {base: "LPL"}, + {base: "LPPL"}, + {base: "MPL"}, + {base: "MPL", suffix: "-no-copyleft-exception", inheritBaseVersions: true}, + {base: "NPL"}, + {base: "OFL"}, + {base: "OLDAP"}, + {base: "OSL"}, + {base: "PHP"}, + {base: "RPL"}, + {base: "SGI-B"}, + {base: "YPL"}, + {base: "ZPL"}, + {base: "Zimbra"}, + {base: "bzip2"}, +} + +const licenseVersionPattern = `\d+(?:\.\d+)*(?:[a-z])?` + +func generateLicenseRanges(licenses []License) error { + ranges := buildLicenseRanges(licenses) + contents := []byte(`package spdxlicenses + +// Code generated by go-spdx cmd/license_ranges.go. DO NOT EDIT. +// Source: https://github.com/spdx/license-list-data specifies official SPDX license list. + +// LicenseRanges returns license version groups in ascending version order. +// IDs within the same version group are equivalent (for example, GPL-2.0 and GPL-2.0-only). +func LicenseRanges() [][][]string { + return [][][]string{ +`) + for _, family := range ranges { + contents = append(contents, "\t\t{\n"...) + for _, version := range family { + contents = append(contents, "\t\t\t{\n"...) + for _, id := range version { + contents = append(contents, fmt.Sprintf("\t\t\t\t%q,\n", id)...) + } + contents = append(contents, "\t\t\t},\n"...) + } + contents = append(contents, "\t\t},\n"...) + } + contents = append(contents, "\t}\n}\n"...) + + contents, err := format.Source(contents) + if err != nil { + return fmt.Errorf("format generated license_ranges.go: %w", err) + } + if err := os.WriteFile("../spdxexp/spdxlicenses/license_ranges.go", contents, 0600); err != nil { + return err + } + fmt.Println("Writing `../spdxexp/spdxlicenses/license_ranges.go`... COMPLETE") + return nil +} + +func buildLicenseRanges(licenses []License) [][][]string { + licenseIDs := make(map[string]struct{}, len(licenses)) + for _, license := range licenses { + licenseIDs[license.LicenseID] = struct{}{} + } + + ranges := make([][][]string, 0, len(licenseRangeFamilies)) + for _, family := range licenseRangeFamilies { + pattern := regexp.MustCompile("^" + regexp.QuoteMeta(family.base) + "-(" + licenseVersionPattern + ")" + regexp.QuoteMeta(family.suffix) + "$") + versions := make(map[string][]string) + for id := range licenseIDs { + matches := pattern.FindStringSubmatch(id) + if matches == nil { + continue + } + versions[matches[1]] = append(versions[matches[1]], id) + if family.suffix == "" { + onlyID := id + "-only" + if _, ok := licenseIDs[onlyID]; ok { + versions[matches[1]] = append(versions[matches[1]], onlyID) + } + } + } + if family.inheritBaseVersions { + basePattern := regexp.MustCompile("^" + regexp.QuoteMeta(family.base) + "-(" + licenseVersionPattern + ")$") + for id := range licenseIDs { + matches := basePattern.FindStringSubmatch(id) + if matches != nil { + if _, overridden := versions[matches[1]]; !overridden { + versions[matches[1]] = []string{id} + } + } + } + } + + versionNames := make([]string, 0, len(versions)) + for version := range versions { + versionNames = append(versionNames, version) + } + sort.Slice(versionNames, func(i, j int) bool { + return compareLicenseVersions(versionNames[i], versionNames[j]) < 0 + }) + + licenseGroup := make([][]string, 0, len(versionNames)) + for _, version := range versionNames { + sort.Strings(versions[version]) + licenseGroup = append(licenseGroup, versions[version]) + } + if len(licenseGroup) > 1 { + ranges = append(ranges, licenseGroup) + } + } + return ranges +} + +func compareLicenseVersions(first, second string) int { + firstParts := strings.FieldsFunc(first, func(r rune) bool { return r == '.' }) + secondParts := strings.FieldsFunc(second, func(r rune) bool { return r == '.' }) + for i := 0; i < len(firstParts) && i < len(secondParts); i++ { + firstNumber, firstSuffix := splitVersionPart(firstParts[i]) + secondNumber, secondSuffix := splitVersionPart(secondParts[i]) + if firstNumber != secondNumber { + return firstNumber - secondNumber + } + if firstSuffix != secondSuffix { + return strings.Compare(firstSuffix, secondSuffix) + } + } + return len(firstParts) - len(secondParts) +} + +func splitVersionPart(part string) (int, string) { + index := 0 + for index < len(part) && part[index] >= '0' && part[index] <= '9' { + index++ + } + number, _ := strconv.Atoi(part[:index]) + return number, part[index:] +} diff --git a/cmd/license_ranges_test.go b/cmd/license_ranges_test.go new file mode 100644 index 0000000..7ec5c36 --- /dev/null +++ b/cmd/license_ranges_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildLicenseRanges(t *testing.T) { + previousFamilies := licenseRangeFamilies + licenseRangeFamilies = []licenseRangeFamily{ + {base: "Example"}, + {base: "Example", suffix: "-special", inheritBaseVersions: true}, + } + t.Cleanup(func() { licenseRangeFamilies = previousFamilies }) + + licenses := []License{ + {LicenseID: "Example-10.0"}, + {LicenseID: "Example-2.0-only"}, + {LicenseID: "Example-1.0-or-later"}, + {LicenseID: "Example-1.0"}, + {LicenseID: "Example-2.0"}, + {LicenseID: "Example-3.0-special"}, + {LicenseID: "Example-1.0-special"}, + {LicenseID: "Unrelated-2.0"}, + } + + assert.Equal(t, [][][]string{ + {{"Example-1.0"}, {"Example-2.0", "Example-2.0-only"}, {"Example-10.0"}}, + {{"Example-1.0-special"}, {"Example-2.0"}, {"Example-3.0-special"}, {"Example-10.0"}}, + }, buildLicenseRanges(licenses)) +} + +func TestCompareLicenseVersions(t *testing.T) { + assert.Negative(t, compareLicenseVersions("1.3a", "1.3c")) + assert.Negative(t, compareLicenseVersions("2.0", "10.0")) + assert.Zero(t, compareLicenseVersions("2.0", "2.0")) +} diff --git a/cmd/main.go b/cmd/main.go index 31731ca..4cbde8d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,7 @@ func main() { } flagSet := flag.NewFlagSet("run", flag.ExitOnError) - extractLicenses := flagSet.Bool("l", false, "Should license ids be extracted?") + shouldExtractLicenses := flagSet.Bool("l", false, "Should licenses and ranges be extracted?") extractExceptions := flagSet.Bool("e", false, "Should exception ids be extracted?") help := flagSet.Bool("h", false, "Show help") @@ -30,16 +30,16 @@ func main() { switch cmd { case "extract": - if *help || (!*extractLicenses && !*extractExceptions) { + if *help || (!*shouldExtractLicenses && !*extractExceptions) { writeHelpMessage() os.Exit(0) } - if *extractLicenses { + if *shouldExtractLicenses { fmt.Println("-------------------------") - fmt.Println("Extracting license ids...") - err := extractLicenseIDs() + fmt.Println("Extracting licenses and ranges...") + err := extractLicenses() if err != nil { - fmt.Printf("error extracting license ids: %v\n", err) + fmt.Printf("error extracting licenses and ranges: %v\n", err) os.Exit(1) } fmt.Println("Done!") @@ -68,16 +68,15 @@ func writeHelpMessage() { fmt.Println("https://github.com/spdx/license-list-data/blob/main/json/licenses.json and exceptions.json") fmt.Println("file from https://github.com/spdx/license-list-data/blob/main/json/exceptions.json.") fmt.Println("") - fmt.Println("After running the extract command, the license_ids.json, deprecated_ids.json, and exception_ids.json") - fmt.Println("files will be overwritten with the extracted ids. These license ids can then be used to update the") - fmt.Println("spdxexp/license.go file.") + fmt.Println("After running the extract command, the generated license, deprecated license, exception, and license") + fmt.Println("range Go files in spdxexp/spdxlicenses will be overwritten with data derived from the SPDX license list.") fmt.Println("") fmt.Println("Command to run all extractions (run command from the /cmd directory):") fmt.Println(" `go run . extract -l -e`") fmt.Println("") fmt.Println("Usage options:") fmt.Println(" -h: prints this help message") - fmt.Println(" -l: Extract license ids") + fmt.Println(" -l: Extract licenses and ranges") fmt.Println(" -e: Extract exception ids") fmt.Println("") os.Exit(0) diff --git a/spdxexp/satisfies_test.go b/spdxexp/satisfies_test.go index 6955d95..7b08ec7 100644 --- a/spdxexp/satisfies_test.go +++ b/spdxexp/satisfies_test.go @@ -559,7 +559,7 @@ func TestSatisfies(t *testing.T) { {"MIT AND Apache-2.0 satisfies [MIT, Apache-1.0, Apache-2.0]", "MIT AND Apache-2.0", []string{"MIT", "Apache-1.0", "Apache-2.0"}, true, nil}, {"Apache-1.0+ satisfies [Apache-2.0]", "Apache-1.0+", []string{"Apache-2.0"}, true, nil}, - {"Apache-1.0+ satisfies [Apache-2.0+]", "Apache-1.0+", []string{"Apache-2.0+"}, true, nil}, // TODO: Fails here but passes js + {"Apache-1.0+ satisfies [Apache-2.0+]", "Apache-1.0+", []string{"Apache-2.0+"}, true, nil}, {"! Apache-1.0 satisfies [Apache-2.0+]", "Apache-1.0", []string{"Apache-2.0+"}, false, nil}, {"Apache-2.0 satisfies [Apache-2.0+]", "Apache-2.0", []string{"Apache-2.0+"}, true, nil}, {"! Apache-3.0 satisfies [Apache-2.0+]", "Apache-3.0", []string{"Apache-2.0+"}, false, errors.New("unknown license 'Apache-3.0' at offset 0")}, @@ -578,8 +578,8 @@ func TestSatisfies(t *testing.T) { {"MIT satisfies [ISC, MIT]", "MIT", []string{"ISC", "MIT"}, true, nil}, {"Zlib satisfies [ISC, MIT, Zlib]", "Zlib", []string{"ISC", "MIT", "Zlib"}, true, nil}, {"! GPL-3.0 satisfies [ISC, MIT]", "GPL-3.0", []string{"ISC", "MIT"}, false, nil}, - {"GPL-2.0 satisfies [GPL-2.0+]", "GPL-2.0", []string{"GPL-2.0+"}, true, nil}, // TODO: Fails here but passes js - {"GPL-2.0 satisfies [GPL-2.0-or-later]", "GPL-2.0", []string{"GPL-2.0-or-later"}, true, nil}, // TODO: Fails here and js + {"GPL-2.0 satisfies [GPL-2.0+]", "GPL-2.0", []string{"GPL-2.0+"}, true, nil}, + {"GPL-2.0 satisfies [GPL-2.0-or-later]", "GPL-2.0", []string{"GPL-2.0-or-later"}, true, nil}, {"GPL-3.0 satisfies [GPL-2.0+]", "GPL-3.0", []string{"GPL-2.0+"}, true, nil}, {"GPL-1.0-or-later satisfies [GPL-2.0-or-later]", "GPL-1.0-or-later", []string{"GPL-2.0-or-later"}, true, nil}, {"GPL-1.0+ satisfies [GPL-2.0+]", "GPL-1.0+", []string{"GPL-2.0+"}, true, nil}, @@ -613,7 +613,7 @@ func TestSatisfies(t *testing.T) { {"MIT satisfies [GPL-2.0, MIT, MIT, ISC]", "MIT", []string{"GPL-2.0", "MIT", "MIT", "ISC"}, true, nil}, {"MIT AND ICU satisfies [MIT, GPL-2.0, ISC, Apache-2.0, ICU]", - "MIT AND ICU", []string{"MIT", "GPL-2.0", "ISC", "Apache-2.0", "ICU"}, true, nil}, // TODO: This says true and the js version returns true, but it shouldn't. + "MIT AND ICU", []string{"MIT", "GPL-2.0", "ISC", "Apache-2.0", "ICU"}, true, nil}, {"! (MIT AND GPL-2.0) satisfies [ISC, GPL-2.0]", "(MIT AND GPL-2.0)", []string{"ISC", "GPL-2.0"}, false, nil}, {"! MIT AND (GPL-2.0 OR ISC) satisfies [MIT]", @@ -737,121 +737,122 @@ func TestExpandOr(t *testing.T) { {andOrAndExpression().name, andOrAndExpression().node, andOrAndExpression().expanded}, {oPAndCPOrOPAndCPExpression().name, oPAndCPOrOPAndCPExpression().node, oPAndCPOrOPAndCPExpression().expanded}, - // TODO: Uncomment kitchen sink test when license plus, exception, license ref, and document ref are supported. - // {"kitchen sink", - // // " (MIT AND Apache-1.0+) OR DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2 OR (GPL-2.0 WITH Bison-exception-2.2)", - // &node{ - // role: expressionNode, - // exp: &expressionNodePartial{ - // left: &node{ - // role: expressionNode, - // exp: &expressionNodePartial{ - // left: &node{ - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "MIT", - // hasPlus: false, - // hasException: false, - // exception: "", - // }, - // ref: nil, - // }, - // conjunction: "and", - // right: &node{ - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "Apache-1.0", - // hasPlus: true, - // hasException: false, - // exception: "", - // }, - // ref: nil, - // }, - // }, - // lic: nil, - // ref: nil, - // }, - // conjunction: "or", - // right: &node{ - // role: expressionNode, - // exp: &expressionNodePartial{ - // left: &node{ - // role: licenseRefNode, - // exp: nil, - // lic: nil, - // ref: &referenceNodePartial{ - // hasDocumentRef: true, - // documentRef: "spdx-tool-1.2", - // licenseRef: "MIT-Style-2", - // }, - // }, - // conjunction: "or", - // right: &node{ - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "GPL-2.0", - // hasPlus: false, - // hasException: true, - // exception: "Bison-exception-2.2", - // }, - // ref: nil, - // }, - // }, - // lic: nil, - // ref: nil, - // }, - // }, - // lic: nil, - // ref: nil, - // }, - // // [][]string{{"MIT", "Apache-1.0+"}, {"DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2"}, {"GPL-2.0 with Bison-exception-2.2"}}}, - // [][]*node{ - // { - // { - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "MIT", hasPlus: false, - // hasException: false, exception: ""}, - // ref: nil, - // }, - // { - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "Apache-1.0", hasPlus: true, - // hasException: false, exception: ""}, - // ref: nil, - // }, - // }, - // { - // { - // role: licenseRefNode, - // exp: nil, - // lic: nil, - // ref: &referenceNodePartial{ - // hasDocumentRef: true, - // documentRef: "spdx-tool-1.2", - // licenseRef: "MIT-Style-2", - // }, - // }, - // { - // role: licenseNode, - // exp: nil, - // lic: &licenseNodePartial{ - // license: "GPL-2.0", - // hasPlus: false, - // hasException: true, - // exception: "Bison-exception-2.2", - // }, - // ref: nil, - // }, - // }, - // }, - // }, + {"kitchen sink", + // " (MIT AND Apache-1.0+) OR DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2 OR (GPL-2.0 WITH Bison-exception-2.2)", + &node{ + role: expressionNode, + exp: &expressionNodePartial{ + left: &node{ + role: expressionNode, + exp: &expressionNodePartial{ + left: &node{ + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "MIT", + hasPlus: false, + hasException: false, + exception: "", + }, + ref: nil, + }, + conjunction: "and", + right: &node{ + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "Apache-1.0", + hasPlus: true, + hasException: false, + exception: "", + }, + ref: nil, + }, + }, + lic: nil, + ref: nil, + }, + conjunction: "or", + right: &node{ + role: expressionNode, + exp: &expressionNodePartial{ + left: &node{ + role: licenseRefNode, + exp: nil, + lic: nil, + ref: &referenceNodePartial{ + hasDocumentRef: true, + documentRef: "spdx-tool-1.2", + licenseRef: "MIT-Style-2", + }, + }, + conjunction: "or", + right: &node{ + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "GPL-2.0", + hasPlus: false, + hasException: true, + exception: "Bison-exception-2.2", + }, + ref: nil, + }, + }, + lic: nil, + ref: nil, + }, + }, + lic: nil, + ref: nil, + }, + // [][]string{{"MIT", "Apache-1.0+"}, {"DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2"}, {"GPL-2.0 with Bison-exception-2.2"}}}, + [][]*node{ + { + { + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "MIT", hasPlus: false, + hasException: false, exception: ""}, + ref: nil, + }, + { + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "Apache-1.0", hasPlus: true, + hasException: false, exception: ""}, + ref: nil, + }, + }, + { + { + role: licenseRefNode, + exp: nil, + lic: nil, + ref: &referenceNodePartial{ + hasDocumentRef: true, + documentRef: "spdx-tool-1.2", + licenseRef: "MIT-Style-2", + }, + }, + }, + { + { + role: licenseNode, + exp: nil, + lic: &licenseNodePartial{ + license: "GPL-2.0", + hasPlus: false, + hasException: true, + exception: "Bison-exception-2.2", + }, + ref: nil, + }, + }, + }, + }, } for _, test := range tests { diff --git a/spdxexp/spdxlicenses/license_ranges.go b/spdxexp/spdxlicenses/license_ranges.go index e5b01be..6db94f1 100644 --- a/spdxexp/spdxlicenses/license_ranges.go +++ b/spdxexp/spdxlicenses/license_ranges.go @@ -1,12 +1,10 @@ package spdxlicenses -// LicenseRanges returns a list of license ranges. -// -// Ranges are organized into groups (referred to as license groups) of the same base license (e.g. GPL). -// Groups have sub-groups of license versions (referred to as the range) where each member is considered -// to be the same version (e.g. {GPL-2.0, GPL-2.0-only}). The sub-groups are in ascending order within -// the license group, such that the first sub-group is considered to be less than the second sub-group, -// and so on. (e.g. {{GPL-1.0}, {GPL-2.0, GPL-2.0-only}} implies {GPL-1.0} < {GPL-2.0, GPL-2.0-only}). +// Code generated by go-spdx cmd/license_ranges.go. DO NOT EDIT. +// Source: https://github.com/spdx/license-list-data specifies official SPDX license list. + +// LicenseRanges returns license version groups in ascending version order. +// IDs within the same version group are equivalent (for example, GPL-2.0 and GPL-2.0-only). func LicenseRanges() [][][]string { return [][][]string{ { @@ -29,6 +27,7 @@ func LicenseRanges() [][][]string { { { "AGPL-1.0", + "AGPL-1.0-only", }, { "AGPL-3.0", @@ -212,6 +211,9 @@ func LicenseRanges() [][][]string { { "CECILL-2.0", }, + { + "CECILL-2.1", + }, }, { { @@ -252,6 +254,9 @@ func LicenseRanges() [][][]string { { "EUPL-1.1", }, + { + "EUPL-1.2", + }, }, { { @@ -263,11 +268,8 @@ func LicenseRanges() [][][]string { "GFDL-1.2-only", }, { - "GFDL-1.1-or-later", - "GFDL-1.2-or-later", "GFDL-1.3", "GFDL-1.3-only", - "GFDL-1.3-or-later", }, }, { @@ -280,11 +282,8 @@ func LicenseRanges() [][][]string { "GPL-2.0-only", }, { - "GPL-1.0-or-later", - "GPL-2.0-or-later", "GPL-3.0", "GPL-3.0-only", - "GPL-3.0-or-later", }, }, { @@ -305,11 +304,8 @@ func LicenseRanges() [][][]string { "LGPL-2.1-only", }, { - "LGPL-2.0-or-later", - "LGPL-2.1-or-later", "LGPL-3.0", "LGPL-3.0-only", - "LGPL-3.0-or-later", }, }, { From 02f937674c987ef46ccf47c21686b73426fcaa50 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Wed, 21 Oct 2026 12:05:55 -0400 Subject: [PATCH 02/11] fix fmt --- spdxexp/satisfies_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spdxexp/satisfies_test.go b/spdxexp/satisfies_test.go index 7b08ec7..a7da479 100644 --- a/spdxexp/satisfies_test.go +++ b/spdxexp/satisfies_test.go @@ -807,7 +807,7 @@ func TestExpandOr(t *testing.T) { ref: nil, }, // [][]string{{"MIT", "Apache-1.0+"}, {"DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2"}, {"GPL-2.0 with Bison-exception-2.2"}}}, - [][]*node{ + [][]*node{ { { role: licenseNode, From 1b7adc8731fe2ba0b3ffe067b6cb4baf70a55b5a Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Wed, 21 Oct 2026 13:07:06 -0400 Subject: [PATCH 03/11] generate range families automatically - uses an include list to identify manually any family that does not follow the pattern of ending with a version number (eg. "Brian-Gladman-2-Clause") - uses an exclusion list to identify manually any license that should not be treated as a range family even though it appears to follow the pattern of ending with a version number (_none identified at this time_) --- cmd/license_ranges.go | 99 ++++++++++++---------- cmd/license_ranges_test.go | 36 +++++++- spdxexp/license_test.go | 24 ++++-- spdxexp/spdxlicenses/license_ranges.go | 113 +++++++++++++++++++++++-- 4 files changed, 210 insertions(+), 62 deletions(-) diff --git a/cmd/license_ranges.go b/cmd/license_ranges.go index d618311..79d5c1d 100644 --- a/cmd/license_ranges.go +++ b/cmd/license_ranges.go @@ -16,53 +16,21 @@ type licenseRangeFamily struct { inheritBaseVersions bool } -// SPDX identifies version ranges syntactically but does not publish family relationships as data. -// Keep family membership explicit and discover each family's available versions from licenses.json. -var licenseRangeFamilies = []licenseRangeFamily{ - {base: "AFL"}, - {base: "AGPL"}, - {base: "Apache"}, - {base: "APSL"}, - {base: "Artistic"}, - {base: "ASWF-Digital-Assets"}, - {base: "BitTorrent"}, +var includedLicenseRangeFamilies = []licenseRangeFamily{ {base: "Brian-Gladman", suffix: "-Clause"}, - {base: "CC-BY"}, - {base: "CC-BY-NC"}, - {base: "CC-BY-NC-ND"}, - {base: "CC-BY-NC-SA"}, - {base: "CC-BY-ND"}, - {base: "CC-BY-SA"}, - {base: "CDDL"}, - {base: "CECILL"}, - {base: "DRL"}, - {base: "ECL"}, - {base: "EFL"}, - {base: "EPL"}, - {base: "EUPL"}, - {base: "GFDL"}, - {base: "GPL"}, - {base: "HP"}, - {base: "LGPL"}, - {base: "LPL"}, - {base: "LPPL"}, - {base: "MPL"}, {base: "MPL", suffix: "-no-copyleft-exception", inheritBaseVersions: true}, - {base: "NPL"}, - {base: "OFL"}, - {base: "OLDAP"}, - {base: "OSL"}, - {base: "PHP"}, - {base: "RPL"}, - {base: "SGI-B"}, - {base: "YPL"}, - {base: "ZPL"}, - {base: "Zimbra"}, - {base: "bzip2"}, +} + +var excludedLicenseRangeFamilyBases = map[string]struct{}{ + "Spencer": {}, + "Unicode-DFS": {}, + "W3C": {}, } const licenseVersionPattern = `\d+(?:\.\d+)*(?:[a-z])?` +var standardLicenseVersionPattern = regexp.MustCompile("^(.+)-(" + licenseVersionPattern + ")$") + func generateLicenseRanges(licenses []License) error { ranges := buildLicenseRanges(licenses) contents := []byte(`package spdxlicenses @@ -105,8 +73,9 @@ func buildLicenseRanges(licenses []License) [][][]string { licenseIDs[license.LicenseID] = struct{}{} } - ranges := make([][][]string, 0, len(licenseRangeFamilies)) - for _, family := range licenseRangeFamilies { + families := buildLicenseRangeFamilies(licenses, includedLicenseRangeFamilies, excludedLicenseRangeFamilyBases) + ranges := make([][][]string, 0, len(families)) + for _, family := range families { pattern := regexp.MustCompile("^" + regexp.QuoteMeta(family.base) + "-(" + licenseVersionPattern + ")" + regexp.QuoteMeta(family.suffix) + "$") versions := make(map[string][]string) for id := range licenseIDs { @@ -154,6 +123,50 @@ func buildLicenseRanges(licenses []License) [][][]string { return ranges } +func buildLicenseRangeFamilies(licenses []License, included []licenseRangeFamily, excluded map[string]struct{}) []licenseRangeFamily { + versionsByBase := make(map[string]map[string]struct{}) + for _, license := range licenses { + matches := standardLicenseVersionPattern.FindStringSubmatch(license.LicenseID) + if matches == nil { + continue + } + if _, excluded := excluded[matches[1]]; excluded { + continue + } + if versionsByBase[matches[1]] == nil { + versionsByBase[matches[1]] = make(map[string]struct{}) + } + versionsByBase[matches[1]][matches[2]] = struct{}{} + } + + familiesByKey := make(map[string]licenseRangeFamily, len(versionsByBase)+len(included)) + for base, versions := range versionsByBase { + if len(versions) > 1 { + familiesByKey[base+"\x00"] = licenseRangeFamily{base: base} + } + } + for _, family := range included { + familiesByKey[family.base+"\x00"+family.suffix] = family + } + + families := make([]licenseRangeFamily, 0, len(familiesByKey)) + for _, family := range familiesByKey { + families = append(families, family) + } + sort.Slice(families, func(i, j int) bool { + firstBase := strings.ToLower(families[i].base) + secondBase := strings.ToLower(families[j].base) + if firstBase != secondBase { + return firstBase < secondBase + } + if families[i].base != families[j].base { + return families[i].base < families[j].base + } + return families[i].suffix < families[j].suffix + }) + return families +} + func compareLicenseVersions(first, second string) int { firstParts := strings.FieldsFunc(first, func(r rune) bool { return r == '.' }) secondParts := strings.FieldsFunc(second, func(r rune) bool { return r == '.' }) diff --git a/cmd/license_ranges_test.go b/cmd/license_ranges_test.go index 7ec5c36..9a7f54d 100644 --- a/cmd/license_ranges_test.go +++ b/cmd/license_ranges_test.go @@ -7,12 +7,17 @@ import ( ) func TestBuildLicenseRanges(t *testing.T) { - previousFamilies := licenseRangeFamilies - licenseRangeFamilies = []licenseRangeFamily{ + previousIncluded := includedLicenseRangeFamilies + previousExcluded := excludedLicenseRangeFamilyBases + includedLicenseRangeFamilies = []licenseRangeFamily{ {base: "Example"}, {base: "Example", suffix: "-special", inheritBaseVersions: true}, } - t.Cleanup(func() { licenseRangeFamilies = previousFamilies }) + excludedLicenseRangeFamilyBases = nil + t.Cleanup(func() { + includedLicenseRangeFamilies = previousIncluded + excludedLicenseRangeFamilyBases = previousExcluded + }) licenses := []License{ {LicenseID: "Example-10.0"}, @@ -31,6 +36,31 @@ func TestBuildLicenseRanges(t *testing.T) { }, buildLicenseRanges(licenses)) } +func TestBuildLicenseRangeFamilies(t *testing.T) { + licenses := []License{ + {LicenseID: "Apache-1.0"}, + {LicenseID: "Apache-2.0"}, + {LicenseID: "ASWF-1.0"}, + {LicenseID: "ASWF-2.0"}, + {LicenseID: "Example-1.0"}, + {LicenseID: "Example-2.0"}, + {LicenseID: "Excluded-1.0"}, + {LicenseID: "Excluded-2.0"}, + {LicenseID: "Singleton-1.0"}, + {LicenseID: "Special-2-Clause"}, + {LicenseID: "Special-3-Clause"}, + } + included := []licenseRangeFamily{{base: "Special", suffix: "-Clause"}} + excluded := map[string]struct{}{"Excluded": {}} + + assert.Equal(t, []licenseRangeFamily{ + {base: "Apache"}, + {base: "ASWF"}, + {base: "Example"}, + {base: "Special", suffix: "-Clause"}, + }, buildLicenseRangeFamilies(licenses, included, excluded)) +} + func TestCompareLicenseVersions(t *testing.T) { assert.Negative(t, compareLicenseVersions("1.3a", "1.3c")) assert.Negative(t, compareLicenseVersions("2.0", "10.0")) diff --git a/spdxexp/license_test.go b/spdxexp/license_test.go index 5ec8d8e..f8213e1 100644 --- a/spdxexp/license_test.go +++ b/spdxexp/license_test.go @@ -121,22 +121,28 @@ func TestGetLicenseRange(t *testing.T) { tests := []struct { name string id string - licenseRange *licenseRange + licenses []string + versionGroup int + licenseIndex int }{ - {"no multi-element ranges", "Apache-2.0", &licenseRange{ - licenses: []string{"Apache-2.0"}, - location: map[uint8]int{licenseGroup: 2, versionGroup: 2, licenseIndex: 0}}}, - {"multi-element ranges", "GFDL-1.2-only", &licenseRange{ - licenses: []string{"GFDL-1.2", "GFDL-1.2-only"}, - location: map[uint8]int{licenseGroup: 21, versionGroup: 1, licenseIndex: 1}}}, - {"no range", "Bison-exception-2.2", nil}, + {"no multi-element ranges", "Apache-2.0", []string{"Apache-2.0"}, 2, 0}, + {"multi-element ranges", "GFDL-1.2-only", []string{"GFDL-1.2", "GFDL-1.2-only"}, 1, 1}, + {"no range", "Bison-exception-2.2", nil, 0, 0}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { licenseRange := getLicenseRange(test.id) - assert.Equal(t, test.licenseRange, licenseRange) + if test.licenses == nil { + assert.Nil(t, licenseRange) + return + } + if assert.NotNil(t, licenseRange) { + assert.Equal(t, test.licenses, licenseRange.licenses) + assert.Equal(t, test.versionGroup, licenseRange.location[versionGroup]) + assert.Equal(t, test.licenseIndex, licenseRange.location[licenseIndex]) + } }) } } diff --git a/spdxexp/spdxlicenses/license_ranges.go b/spdxexp/spdxlicenses/license_ranges.go index 6db94f1..9aec14f 100644 --- a/spdxexp/spdxlicenses/license_ranges.go +++ b/spdxexp/spdxlicenses/license_ranges.go @@ -91,6 +91,14 @@ func LicenseRanges() [][][]string { "Brian-Gladman-3-Clause", }, }, + { + { + "bzip2-1.0.5", + }, + { + "bzip2-1.0.6", + }, + }, { { "CC-BY-1.0", @@ -201,6 +209,14 @@ func LicenseRanges() [][][]string { "CDDL-1.1", }, }, + { + { + "CDLA-Permissive-1.0", + }, + { + "CDLA-Permissive-2.0", + }, + }, { { "CECILL-1.0", @@ -215,6 +231,22 @@ func LicenseRanges() [][][]string { "CECILL-2.1", }, }, + { + { + "CERN-OHL-1.1", + }, + { + "CERN-OHL-1.2", + }, + }, + { + { + "copyleft-next-0.3.0", + }, + { + "copyleft-next-0.3.1", + }, + }, { { "DRL-1.0", @@ -294,6 +326,14 @@ func LicenseRanges() [][][]string { "HP-1989", }, }, + { + { + "LAL-1.2", + }, + { + "LAL-1.3", + }, + }, { { "LGPL-2.0", @@ -308,6 +348,14 @@ func LicenseRanges() [][][]string { "LGPL-3.0-only", }, }, + { + { + "libpng-1.6.35", + }, + { + "libpng-2.0", + }, + }, { { "LPL-1.0", @@ -355,6 +403,22 @@ func LicenseRanges() [][][]string { "MPL-2.0-no-copyleft-exception", }, }, + { + { + "MulanPSL-1.0", + }, + { + "MulanPSL-2.0", + }, + }, + { + { + "NLOD-1.0", + }, + { + "NLOD-2.0", + }, + }, { { "NPL-1.0", @@ -371,6 +435,17 @@ func LicenseRanges() [][][]string { "OFL-1.1", }, }, + { + { + "OGL-UK-1.0", + }, + { + "OGL-UK-2.0", + }, + { + "OGL-UK-3.0", + }, + }, { { "OLDAP-1.1", @@ -438,6 +513,14 @@ func LicenseRanges() [][][]string { "OSL-3.0", }, }, + { + { + "Parity-6.0.0", + }, + { + "Parity-7.0.0", + }, + }, { { "PHP-3.0", @@ -446,6 +529,14 @@ func LicenseRanges() [][][]string { "PHP-3.01", }, }, + { + { + "Python-2.0", + }, + { + "Python-2.0.1", + }, + }, { { "RPL-1.1", @@ -467,21 +558,26 @@ func LicenseRanges() [][][]string { }, { { - "YPL-1.0", + "SHL-0.5", }, { - "YPL-1.1", + "SHL-0.51", }, }, { { - "ZPL-1.1", + "TU-Berlin-1.0", }, { - "ZPL-2.0", + "TU-Berlin-2.0", }, + }, + { { - "ZPL-2.1", + "YPL-1.0", + }, + { + "YPL-1.1", }, }, { @@ -494,10 +590,13 @@ func LicenseRanges() [][][]string { }, { { - "bzip2-1.0.5", + "ZPL-1.1", }, { - "bzip2-1.0.6", + "ZPL-2.0", + }, + { + "ZPL-2.1", }, }, } From 3febfe732ea0bdaf080a4f0f28e01526b78b8f1f Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Wed, 21 Oct 2026 14:25:34 -0400 Subject: [PATCH 04/11] Provide details in cron generated PR with list of changes includes instructions to copilot to aid in its review --- .../license-range-review.instructions.md | 12 ++ .github/workflows/fetch-licenses.yaml | 10 +- cmd/main.go | 10 + cmd/pr_body.go | 204 ++++++++++++++++++ cmd/pr_body_test.go | 46 ++++ 5 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/license-range-review.instructions.md create mode 100644 cmd/pr_body.go create mode 100644 cmd/pr_body_test.go diff --git a/.github/instructions/license-range-review.instructions.md b/.github/instructions/license-range-review.instructions.md new file mode 100644 index 0000000..f57e98b --- /dev/null +++ b/.github/instructions/license-range-review.instructions.md @@ -0,0 +1,12 @@ +--- +description: "Use when reviewing generated SPDX license range changes, especially additions or removals of complete license range families." +applyTo: "spdxexp/spdxlicenses/license_ranges.go" +--- + +# License Range Family Review + +- Treat the addition or removal of a complete license range family as requiring human review. +- Always leave an inline review comment on the added or removed family block, or on the nearest changed line when the family was removed. Start the comment with **Human review required: license range family changed** and identify the family. +- Ask the human reviewer to verify that the family change is intended and that its version grouping and ordering are correct. +- Compare the diff with the **License range families** summary in the pull request description and report any discrepancy. +- Do not leave this comment for a version added to or removed from an existing family unless the grouping or ordering is incorrect. \ No newline at end of file diff --git a/.github/workflows/fetch-licenses.yaml b/.github/workflows/fetch-licenses.yaml index deb6474..9ce5700 100644 --- a/.github/workflows/fetch-licenses.yaml +++ b/.github/workflows/fetch-licenses.yaml @@ -13,6 +13,10 @@ on: jobs: fetch-licenses: runs-on: ubuntu-latest + env: + DT_STAMP: '' + GENFILES_CHANGED: 'false' + SRC_CHANGED: 'false' permissions: contents: write pull-requests: write @@ -84,6 +88,10 @@ jobs: fi echo "GENFILES_CHANGED=$genfiles_changed" >> $GITHUB_ENV + - name: Generate Pull Request description + if: ${{ env.GENFILES_CHANGED == 'true' || github.event.inputs.force_run == 'true' }} + run: go run ./cmd pr-body -o "${RUNNER_TEMP}/license-pr-body.md" + - name: Create Pull Request if: ${{ env.GENFILES_CHANGED == 'true' || github.event.inputs.force_run == 'true' }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 @@ -93,5 +101,5 @@ jobs: branch: auto-update-licenses base: main title: "Update SPDX license files (${{ env.DT_STAMP }})" - body: "The files in this PR are auto-generated by the [fetch-licenses](./.github/workflows/fetch-licenses.yaml) workflow when it runs the `extract` command defined in [cmd/main.go](./cmd/main.go). It updates SPDX licenses and license ranges based on the latest released set in the [spdx/license-list-data](https://github.com/spdx/license-list-data) repository maintained by [SPDX](https://spdx.org/licenses/)." + body-path: ${{ runner.temp }}/license-pr-body.md labels: 'auto-update,licenses' diff --git a/cmd/main.go b/cmd/main.go index 4cbde8d..a8c587b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,6 +20,7 @@ func main() { flagSet := flag.NewFlagSet("run", flag.ExitOnError) shouldExtractLicenses := flagSet.Bool("l", false, "Should licenses and ranges be extracted?") extractExceptions := flagSet.Bool("e", false, "Should exception ids be extracted?") + outputPath := flagSet.String("o", "", "Output path") help := flagSet.Bool("h", false, "Show help") err := flagSet.Parse(argsRemainder) @@ -55,6 +56,15 @@ func main() { fmt.Println("Done!") fmt.Println("---------------------------") } + case "pr-body": + if *outputPath == "" { + fmt.Println("error generating PR body: -o is required") + os.Exit(1) + } + if err := generatePRBody(*outputPath); err != nil { + fmt.Printf("error generating PR body: %v\n", err) + os.Exit(1) + } default: writeHelpMessage() os.Exit(0) diff --git a/cmd/pr_body.go b/cmd/pr_body.go new file mode 100644 index 0000000..71ce9f0 --- /dev/null +++ b/cmd/pr_body.go @@ -0,0 +1,204 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func generatePRBody(outputPath string) error { + repositoryRootOutput, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return fmt.Errorf("find repository root: %w", err) + } + repositoryRoot := strings.TrimSpace(string(repositoryRootOutput)) + + var previousLicenses, currentLicenses LicenseData + if err := decodePreviousJSON(repositoryRoot, "cmd/licenses.json", &previousLicenses); err != nil { + return err + } + if err := decodeJSONFile(filepath.Join(repositoryRoot, "cmd/licenses.json"), ¤tLicenses); err != nil { + return err + } + + var previousExceptions, currentExceptions ExceptionData + if err := decodePreviousJSON(repositoryRoot, "cmd/exceptions.json", &previousExceptions); err != nil { + return err + } + if err := decodeJSONFile(filepath.Join(repositoryRoot, "cmd/exceptions.json"), ¤tExceptions); err != nil { + return err + } + + body := buildPRBody(previousLicenses, currentLicenses, previousExceptions, currentExceptions) + if err := os.WriteFile(outputPath, []byte(body), 0600); err != nil { + return fmt.Errorf("write PR body: %w", err) + } + return nil +} + +func decodePreviousJSON(repositoryRoot, path string, destination any) error { + contents, err := exec.Command("git", "-C", repositoryRoot, "show", "HEAD:"+path).Output() + if err != nil { + return fmt.Errorf("read previous %s: %w", path, err) + } + if err := json.Unmarshal(contents, destination); err != nil { + return fmt.Errorf("decode previous %s: %w", path, err) + } + return nil +} + +func decodeJSONFile(path string, destination any) error { + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(contents, destination); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + return nil +} + +func buildPRBody(previousLicenses, currentLicenses LicenseData, previousExceptions, currentExceptions ExceptionData) string { + var body bytes.Buffer + body.WriteString("> [!WARNING]\n") + body.WriteString("> Do not push commits directly to this PR's `auto-update-licenses` branch or manually edit this description. The nightly workflow updates both and may overwrite manual changes. For changes that depend on this update, open a stacked PR with `auto-update-licenses` as its base. For generator or workflow fixes that must persist, open a separate PR against `main`, merge it, and rerun this workflow.\n\n") + body.WriteString("This PR was generated by the [fetch-licenses](./.github/workflows/fetch-licenses.yaml) workflow from [spdx/license-list-data](https://github.com/spdx/license-list-data).\n\n") + fmt.Fprintf(&body, "- **Previous SPDX License List Version:** `%s`\n", previousLicenses.Version) + fmt.Fprintf(&body, "- **New SPDX License List Version:** `%s`\n\n", currentLicenses.Version) + + body.WriteString("## SPDX changes\n\n") + + previousLicenseStatuses := licenseStatuses(previousLicenses.Licenses) + currentLicenseStatuses := licenseStatuses(currentLicenses.Licenses) + writeChanges(&body, "Licenses", addedIDs(previousLicenseStatuses, currentLicenseStatuses), removedIDs(previousLicenseStatuses, currentLicenseStatuses)) + + previousExceptionStatuses := exceptionStatuses(previousExceptions.Exceptions) + currentExceptionStatuses := exceptionStatuses(currentExceptions.Exceptions) + writeChanges(&body, "Exceptions", addedIDs(previousExceptionStatuses, currentExceptionStatuses), removedIDs(previousExceptionStatuses, currentExceptionStatuses)) + + body.WriteString("### Deprecations\n\n") + writeIDList(&body, "Newly deprecated licenses", changedStatusIDs(previousLicenseStatuses, currentLicenseStatuses, true)) + writeIDList(&body, "Reactivated licenses", changedStatusIDs(previousLicenseStatuses, currentLicenseStatuses, false)) + writeIDList(&body, "Newly deprecated exceptions", changedStatusIDs(previousExceptionStatuses, currentExceptionStatuses, true)) + writeIDList(&body, "Reactivated exceptions", changedStatusIDs(previousExceptionStatuses, currentExceptionStatuses, false)) + + body.WriteString("\n### License range families\n\n") + writeIDList(&body, "Added", changedLicenseRangeFamilies(previousLicenses.Licenses, currentLicenses.Licenses)) + writeIDList(&body, "Removed", removedLicenseRangeFamilies(previousLicenses.Licenses, currentLicenses.Licenses)) + + body.WriteString("\n## Review instructions (human and Copilot)\n\n") + body.WriteString("Use the summary above as the expected change set and report any discrepancy between it, the source JSON, and the generated files. Focus review on these invariants:\n\n") + body.WriteString("- Active and deprecated license IDs appear in the matching generated collection. Non-deprecated exception IDs appear in the generated exception collection; deprecated exceptions are intentionally excluded.\n") + body.WriteString("- Any added license range family contains at least two versions, keeps equivalent `-only` IDs in the same version group, and is sorted in semantic version order.\n") + body.WriteString("- Copilot: If **Added** or **Removed** under **License range families** is not `None`, leave an inline review comment on the relevant generated family block beginning with **Human review required: license range family changed**.\n") + body.WriteString("- Generated files contain no unrelated changes. Changes should be made in the generator or source data, not directly in generated files.\n") + body.WriteString("- Pay particular attention to IDs that moved between active and deprecated collections; these are status changes, not additions or removals.\n") + return body.String() +} + +func licenseStatuses(licenses []License) map[string]bool { + statuses := make(map[string]bool, len(licenses)) + for _, license := range licenses { + statuses[license.LicenseID] = license.IsDeprecated + } + return statuses +} + +func exceptionStatuses(exceptions []Exception) map[string]bool { + statuses := make(map[string]bool, len(exceptions)) + for _, exception := range exceptions { + statuses[exception.LicenseID] = exception.IsDeprecated + } + return statuses +} + +func addedIDs(previous, current map[string]bool) []string { + return mapDifference(current, previous) +} + +func removedIDs(previous, current map[string]bool) []string { + return mapDifference(previous, current) +} + +func mapDifference(first, second map[string]bool) []string { + ids := make([]string, 0) + for id := range first { + if _, exists := second[id]; !exists { + ids = append(ids, id) + } + } + sort.Strings(ids) + return ids +} + +func changedStatusIDs(previous, current map[string]bool, deprecated bool) []string { + ids := make([]string, 0) + for id, currentStatus := range current { + previousStatus, exists := previous[id] + if exists && previousStatus != currentStatus && currentStatus == deprecated { + ids = append(ids, id) + } + } + sort.Strings(ids) + return ids +} + +func changedLicenseRangeFamilies(previous, current []License) []string { + previousFamilies := buildLicenseRangeFamilies(previous, includedLicenseRangeFamilies, excludedLicenseRangeFamilyBases) + currentFamilies := buildLicenseRangeFamilies(current, includedLicenseRangeFamilies, excludedLicenseRangeFamilyBases) + previousKeys := make(map[string]struct{}, len(previousFamilies)) + for _, family := range previousFamilies { + previousKeys[family.base+"\x00"+family.suffix] = struct{}{} + } + + added := make([]string, 0) + for _, family := range currentFamilies { + if _, exists := previousKeys[family.base+"\x00"+family.suffix]; !exists { + added = append(added, family.base+"-"+family.suffix) + } + } + return added +} + +func removedLicenseRangeFamilies(previous, current []License) []string { + previousFamilies := buildLicenseRangeFamilies(previous, includedLicenseRangeFamilies, excludedLicenseRangeFamilyBases) + currentFamilies := buildLicenseRangeFamilies(current, includedLicenseRangeFamilies, excludedLicenseRangeFamilyBases) + currentKeys := make(map[string]struct{}, len(currentFamilies)) + for _, family := range currentFamilies { + currentKeys[family.base+"\x00"+family.suffix] = struct{}{} + } + + removed := make([]string, 0) + for _, family := range previousFamilies { + if _, exists := currentKeys[family.base+"\x00"+family.suffix]; !exists { + removed = append(removed, family.base+"-"+family.suffix) + } + } + return removed +} + +func writeChanges(body *bytes.Buffer, heading string, added, removed []string) { + fmt.Fprintf(body, "### %s\n\n", heading) + writeIDList(body, "Added", added) + writeIDList(body, "Removed", removed) + body.WriteString("\n") +} + +func writeIDList(body *bytes.Buffer, label string, ids []string) { + fmt.Fprintf(body, "- **%s:** ", label) + if len(ids) == 0 { + body.WriteString("None\n") + return + } + formatted := make([]string, len(ids)) + for index, id := range ids { + formatted[index] = "`" + id + "`" + } + body.WriteString(strings.Join(formatted, ", ")) + body.WriteByte('\n') +} diff --git a/cmd/pr_body_test.go b/cmd/pr_body_test.go new file mode 100644 index 0000000..9fcdcf3 --- /dev/null +++ b/cmd/pr_body_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildPRBody(t *testing.T) { + previousLicenses := LicenseData{ + Version: "3.28.0", + Licenses: []License{ + {LicenseID: "Example-1.0"}, + {LicenseID: "Removed-1.0"}, + {LicenseID: "Now-Deprecated"}, + {LicenseID: "Reactivated", IsDeprecated: true}, + }, + } + currentLicenses := LicenseData{ + Version: "3.29.0", + Licenses: []License{ + {LicenseID: "Example-1.0"}, + {LicenseID: "Example-2.0"}, + {LicenseID: "Now-Deprecated", IsDeprecated: true}, + {LicenseID: "Reactivated"}, + }, + } + previousExceptions := ExceptionData{Exceptions: []Exception{{LicenseID: "Old-exception"}}} + currentExceptions := ExceptionData{Exceptions: []Exception{{LicenseID: "New-exception"}}} + + body := buildPRBody(previousLicenses, currentLicenses, previousExceptions, currentExceptions) + + assert.True(t, strings.HasPrefix(body, "> [!WARNING]\n")) + assert.Contains(t, body, "open a stacked PR with `auto-update-licenses` as its base") + assert.Contains(t, body, "- **Previous SPDX License List Version:** `3.28.0`") + assert.Contains(t, body, "- **New SPDX License List Version:** `3.29.0`") + assert.Contains(t, body, "- **Added:** `Example-2.0`") + assert.Contains(t, body, "- **Removed:** `Removed-1.0`") + assert.Contains(t, body, "- **Added:** `New-exception`") + assert.Contains(t, body, "- **Removed:** `Old-exception`") + assert.Contains(t, body, "- **Newly deprecated licenses:** `Now-Deprecated`") + assert.Contains(t, body, "- **Reactivated licenses:** `Reactivated`") + assert.Contains(t, body, "- **Added:** `Example-`") + assert.Contains(t, body, "Human review required: license range family changed") +} From 9e8cbd30656964919c58f5c9a4bee8bde51a4c66 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Wed, 21 Oct 2026 14:31:01 -0400 Subject: [PATCH 05/11] ignore nosec lint errors --- cmd/pr_body.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/pr_body.go b/cmd/pr_body.go index 71ce9f0..e95e74e 100644 --- a/cmd/pr_body.go +++ b/cmd/pr_body.go @@ -42,6 +42,7 @@ func generatePRBody(outputPath string) error { } func decodePreviousJSON(repositoryRoot, path string, destination any) error { + // #nosec G204 -- repositoryRoot is discovered by Git and path is an internal constant. contents, err := exec.Command("git", "-C", repositoryRoot, "show", "HEAD:"+path).Output() if err != nil { return fmt.Errorf("read previous %s: %w", path, err) @@ -53,6 +54,7 @@ func decodePreviousJSON(repositoryRoot, path string, destination any) error { } func decodeJSONFile(path string, destination any) error { + // #nosec G304 -- path is built from the Git repository root and an internal constant. contents, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", path, err) From df183f44f247edf09175521119d06dd7ed633759 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 06:21:57 -0400 Subject: [PATCH 06/11] remove licenses with versions from the range exclusion list --- cmd/license_ranges.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/license_ranges.go b/cmd/license_ranges.go index 79d5c1d..803affc 100644 --- a/cmd/license_ranges.go +++ b/cmd/license_ranges.go @@ -23,8 +23,6 @@ var includedLicenseRangeFamilies = []licenseRangeFamily{ var excludedLicenseRangeFamilyBases = map[string]struct{}{ "Spencer": {}, - "Unicode-DFS": {}, - "W3C": {}, } const licenseVersionPattern = `\d+(?:\.\d+)*(?:[a-z])?` From c3ede393fadf32bca194a483f8725427f725b0ab Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 07:57:18 -0400 Subject: [PATCH 07/11] fmt fix --- cmd/license_ranges.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/license_ranges.go b/cmd/license_ranges.go index 803affc..55f33a3 100644 --- a/cmd/license_ranges.go +++ b/cmd/license_ranges.go @@ -22,7 +22,7 @@ var includedLicenseRangeFamilies = []licenseRangeFamily{ } var excludedLicenseRangeFamilyBases = map[string]struct{}{ - "Spencer": {}, + "Spencer": {}, } const licenseVersionPattern = `\d+(?:\.\d+)*(?:[a-z])?` From b4ba926e14647c891229eb6f8e6da5e3f4f4ac9e Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 13:23:26 -0400 Subject: [PATCH 08/11] add a golden file test to ensure the end-to-end generation process is correct --- cmd/license.go | 2 +- cmd/license_ranges.go | 4 +-- cmd/license_ranges_test.go | 53 +++++++++++++++++++++++------- cmd/testdata/license_ranges.golden | 37 +++++++++++++++++++++ 4 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 cmd/testdata/license_ranges.golden diff --git a/cmd/license.go b/cmd/license.go index 97d4b67..5ab6ca8 100644 --- a/cmd/license.go +++ b/cmd/license.go @@ -173,7 +173,7 @@ func GetDeprecated() []string { } fmt.Println("Writing `../spdxexp/spdxlicenses/get_deprecated.go`... COMPLETE") - if err := generateLicenseRanges(licenseData.Licenses); err != nil { + if err := generateLicenseRanges(licenseData.Licenses, "../spdxexp/spdxlicenses/license_ranges.go"); err != nil { return fmt.Errorf("generate license ranges: %w", err) } diff --git a/cmd/license_ranges.go b/cmd/license_ranges.go index 55f33a3..d25c2f2 100644 --- a/cmd/license_ranges.go +++ b/cmd/license_ranges.go @@ -29,7 +29,7 @@ const licenseVersionPattern = `\d+(?:\.\d+)*(?:[a-z])?` var standardLicenseVersionPattern = regexp.MustCompile("^(.+)-(" + licenseVersionPattern + ")$") -func generateLicenseRanges(licenses []License) error { +func generateLicenseRanges(licenses []License, outputPath string) error { ranges := buildLicenseRanges(licenses) contents := []byte(`package spdxlicenses @@ -58,7 +58,7 @@ func LicenseRanges() [][][]string { if err != nil { return fmt.Errorf("format generated license_ranges.go: %w", err) } - if err := os.WriteFile("../spdxexp/spdxlicenses/license_ranges.go", contents, 0600); err != nil { + if err := os.WriteFile(outputPath, contents, 0600); err != nil { return err } fmt.Println("Writing `../spdxexp/spdxlicenses/license_ranges.go`... COMPLETE") diff --git a/cmd/license_ranges_test.go b/cmd/license_ranges_test.go index 9a7f54d..454b452 100644 --- a/cmd/license_ranges_test.go +++ b/cmd/license_ranges_test.go @@ -1,23 +1,40 @@ package main import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestBuildLicenseRanges(t *testing.T) { - previousIncluded := includedLicenseRangeFamilies - previousExcluded := excludedLicenseRangeFamilyBases - includedLicenseRangeFamilies = []licenseRangeFamily{ - {base: "Example"}, - {base: "Example", suffix: "-special", inheritBaseVersions: true}, +func TestGenerateLicenseRanges(t *testing.T) { + configureExampleLicenseRangeFamilies(t) + + outputPath := filepath.Join(t.TempDir(), "license_ranges.go") + licenses := []License{ + {LicenseID: "Example-10.0"}, + {LicenseID: "Example-2.0-only"}, + {LicenseID: "Example-1.0-or-later"}, + {LicenseID: "Example-1.0"}, + {LicenseID: "Example-2.0"}, + {LicenseID: "Example-3.0-special"}, + {LicenseID: "Example-1.0-special"}, + {LicenseID: "Unrelated-2.0"}, } - excludedLicenseRangeFamilyBases = nil - t.Cleanup(func() { - includedLicenseRangeFamilies = previousIncluded - excludedLicenseRangeFamilyBases = previousExcluded - }) + + require.NoError(t, generateLicenseRanges(licenses, outputPath)) + generated, err := os.ReadFile(outputPath) + require.NoError(t, err) + golden, err := os.ReadFile("testdata/license_ranges.golden") + require.NoError(t, err) + + assert.Equal(t, string(golden), string(generated)) +} + +func TestBuildLicenseRanges(t *testing.T) { + configureExampleLicenseRangeFamilies(t) licenses := []License{ {LicenseID: "Example-10.0"}, @@ -36,6 +53,20 @@ func TestBuildLicenseRanges(t *testing.T) { }, buildLicenseRanges(licenses)) } +func configureExampleLicenseRangeFamilies(t *testing.T) { + previousIncluded := includedLicenseRangeFamilies + previousExcluded := excludedLicenseRangeFamilyBases + includedLicenseRangeFamilies = []licenseRangeFamily{ + {base: "Example"}, + {base: "Example", suffix: "-special", inheritBaseVersions: true}, + } + excludedLicenseRangeFamilyBases = nil + t.Cleanup(func() { + includedLicenseRangeFamilies = previousIncluded + excludedLicenseRangeFamilyBases = previousExcluded + }) +} + func TestBuildLicenseRangeFamilies(t *testing.T) { licenses := []License{ {LicenseID: "Apache-1.0"}, diff --git a/cmd/testdata/license_ranges.golden b/cmd/testdata/license_ranges.golden new file mode 100644 index 0000000..b2f3c25 --- /dev/null +++ b/cmd/testdata/license_ranges.golden @@ -0,0 +1,37 @@ +package spdxlicenses + +// Code generated by go-spdx cmd/license_ranges.go. DO NOT EDIT. +// Source: https://github.com/spdx/license-list-data specifies official SPDX license list. + +// LicenseRanges returns license version groups in ascending version order. +// IDs within the same version group are equivalent (for example, GPL-2.0 and GPL-2.0-only). +func LicenseRanges() [][][]string { + return [][][]string{ + { + { + "Example-1.0", + }, + { + "Example-2.0", + "Example-2.0-only", + }, + { + "Example-10.0", + }, + }, + { + { + "Example-1.0-special", + }, + { + "Example-2.0", + }, + { + "Example-3.0-special", + }, + { + "Example-10.0", + }, + }, + } +} From 9d74d1649ec376b6798febb3ea88f750a89e5c47 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 14:24:08 -0400 Subject: [PATCH 09/11] improve readability of TestBuildLicenseRangeFamilies making rules explicit --- cmd/license_ranges_test.go | 42 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/cmd/license_ranges_test.go b/cmd/license_ranges_test.go index 454b452..fd5a7c7 100644 --- a/cmd/license_ranges_test.go +++ b/cmd/license_ranges_test.go @@ -21,7 +21,7 @@ func TestGenerateLicenseRanges(t *testing.T) { {LicenseID: "Example-2.0"}, {LicenseID: "Example-3.0-special"}, {LicenseID: "Example-1.0-special"}, - {LicenseID: "Unrelated-2.0"}, + {LicenseID: "SingleVersion-2.0"}, } require.NoError(t, generateLicenseRanges(licenses, outputPath)) @@ -44,13 +44,17 @@ func TestBuildLicenseRanges(t *testing.T) { {LicenseID: "Example-2.0"}, {LicenseID: "Example-3.0-special"}, {LicenseID: "Example-1.0-special"}, - {LicenseID: "Unrelated-2.0"}, + {LicenseID: "SingleVersion-2.0"}, } + licenseRanges := buildLicenseRanges(licenses) + assert.Equal(t, [][][]string{ {{"Example-1.0"}, {"Example-2.0", "Example-2.0-only"}, {"Example-10.0"}}, {{"Example-1.0-special"}, {"Example-2.0"}, {"Example-3.0-special"}, {"Example-10.0"}}, - }, buildLicenseRanges(licenses)) + }, licenseRanges) + + assert.NotContains(t, licenseRanges, [][]string{{"SingleVersion-2.0"}}) } func configureExampleLicenseRangeFamilies(t *testing.T) { @@ -84,12 +88,32 @@ func TestBuildLicenseRangeFamilies(t *testing.T) { included := []licenseRangeFamily{{base: "Special", suffix: "-Clause"}} excluded := map[string]struct{}{"Excluded": {}} - assert.Equal(t, []licenseRangeFamily{ - {base: "Apache"}, - {base: "ASWF"}, - {base: "Example"}, - {base: "Special", suffix: "-Clause"}, - }, buildLicenseRangeFamilies(licenses, included, excluded)) + tests := []struct { + name string + licenses []License + included []licenseRangeFamily + excluded map[string]struct{} + licenseRangeFamilies []licenseRangeFamily + }{ + {"no version", []License{{LicenseID: "Example"}}, nil, nil, []licenseRangeFamily{}}, + {"only one version", []License{{LicenseID: "Singleton-1.0"}}, nil, nil, []licenseRangeFamily{}}, + {"multiple versions", []License{{LicenseID: "Example-1.0"}, {LicenseID: "Example-2.0"}}, nil, nil, []licenseRangeFamily{{base: "Example"}}}, + {"included with suffix", []License{{LicenseID: "Special-2-Clause"}, {LicenseID: "Special-3-Clause"}}, included, nil, []licenseRangeFamily{{base: "Special", suffix: "-Clause"}}}, + {"excluded base", []License{{LicenseID: "Excluded-1.0"}, {LicenseID: "Excluded-2.0"}}, nil, excluded, []licenseRangeFamily{}}, + {"multiple families", licenses, included, excluded, []licenseRangeFamily{ + {base: "Apache"}, + {base: "ASWF"}, + {base: "Example"}, + {base: "Special", suffix: "-Clause"}, + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + licenseRangeFamilies := buildLicenseRangeFamilies(test.licenses, test.included, test.excluded) + assert.Equal(t, test.licenseRangeFamilies, licenseRangeFamilies) + }) + } } func TestCompareLicenseVersions(t *testing.T) { From 0b0df83402366ae7147e0b8019109053c314b95c Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 14:34:17 -0400 Subject: [PATCH 10/11] avoid lint error --- cmd/license_ranges_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/license_ranges_test.go b/cmd/license_ranges_test.go index fd5a7c7..03b3c24 100644 --- a/cmd/license_ranges_test.go +++ b/cmd/license_ranges_test.go @@ -25,6 +25,7 @@ func TestGenerateLicenseRanges(t *testing.T) { } require.NoError(t, generateLicenseRanges(licenses, outputPath)) + // #nosec G304 -- path is created within t.TempDir for this test. generated, err := os.ReadFile(outputPath) require.NoError(t, err) golden, err := os.ReadFile("testdata/license_ranges.golden") From 1b424ba687e0d0cdd8993ff3a257dd399c255205 Mon Sep 17 00:00:00 2001 From: "E. Lynette Rayle" Date: Thu, 22 Oct 2026 14:56:23 -0400 Subject: [PATCH 11/11] add function to return SPDX License List Version Allows consumers of go-spdx licenses to get the version of the current set of SPDX IDs being used --- cmd/license.go | 10 ++++++++-- spdxexp/spdxlicenses/get_licenses.go | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/license.go b/cmd/license.go index 5ab6ca8..3fb334f 100644 --- a/cmd/license.go +++ b/cmd/license.go @@ -54,13 +54,19 @@ func extractLicenses() error { } // generate the GetLicenses() function in get_licenses.go - getLicensesContents := []byte(`package spdxlicenses + getLicensesContents := fmt.Appendf(nil, `package spdxlicenses // Code generated by go-spdx cmd/license.go. DO NOT EDIT. // Source: https://github.com/spdx/license-list-data specifies official SPDX license list. import "strings" +// LicenseListVersion returns the SPDX License List version from which the +// generated license IDs were sourced. +func LicenseListVersion() string { + return %q +} + // IsActiveLicense does a case-insensitive lookup for the license id in the active licenses map. // It returns true and the case-sensitive ID if found, otherwise false and the original id. func IsActiveLicense(id string) (bool, string) { @@ -83,7 +89,7 @@ func GetLicensesMap() map[string]string { // GetLicenses returns a slice of active license IDs. func GetLicenses() []string { return []string{ -`) +`, licenseData.Version) for _, id := range activeLicenseIDs { getLicensesContents = append(getLicensesContents, ` "`+id+`", `...) diff --git a/spdxexp/spdxlicenses/get_licenses.go b/spdxexp/spdxlicenses/get_licenses.go index 9a071fd..85b3c25 100644 --- a/spdxexp/spdxlicenses/get_licenses.go +++ b/spdxexp/spdxlicenses/get_licenses.go @@ -5,6 +5,12 @@ package spdxlicenses import "strings" +// LicenseListVersion returns the SPDX License List version from which the +// generated license IDs were sourced. +func LicenseListVersion() string { + return "3.29.0" +} + // IsActiveLicense does a case-insensitive lookup for the license id in the active licenses map. // It returns true and the case-sensitive ID if found, otherwise false and the original id. func IsActiveLicense(id string) (bool, string) {