Skip to content

Commit 7ea88b1

Browse files
Bundle licenses at release time (cli#12625)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent dd9ca9b commit 7ea88b1

205 files changed

Lines changed: 318 additions & 17385 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/licenses.tmpl

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
# GitHub CLI dependencies
1+
GitHub CLI third-party dependencies
2+
====================================
23

3-
The following open source dependencies are used to build the [cli/cli][] GitHub CLI.
4+
The following open source dependencies are used to build the GitHub CLI.
45

5-
## Go Packages
6-
7-
Some packages may only be included on certain architectures or operating systems.
8-
9-
{{ range . }}
10-
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
11-
{{- end }}
12-
13-
[cli/cli]: https://github.com/cli/cli
6+
{{ range . -}}
7+
{{.Name}} ({{.Version}}) - {{.LicenseName}} - {{.LicenseURL}}
8+
{{ end }}

.github/secret_scanning.yml

Lines changed: 0 additions & 3 deletions
This file was deleted.

.github/workflows/lint.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ on:
88
- go.mod
99
- go.sum
1010
- ".github/licenses.tmpl"
11-
- "script/licenses*"
11+
- "script/licenses"
1212
pull_request:
1313
paths:
1414
- "**.go"
1515
- go.mod
1616
- go.sum
1717
- ".github/licenses.tmpl"
18-
- "script/licenses*"
18+
- "script/licenses"
1919
permissions:
2020
contents: read
2121
jobs:
@@ -50,16 +50,16 @@ jobs:
5050
with:
5151
version: v2.6.0
5252

53+
# Verify that license generation succeeds for all release platforms (GOOS/GOARCH).
54+
# This catches issues like new dependencies with unrecognized licenses before release time.
55+
#
5356
# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
5457
# which causes go-licenses to raise "Package ... does not have module info" errors.
5558
# For more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633
56-
#
57-
# go-licenses has been pinned for automation use.
58-
- name: Check licenses
59+
- name: Verify license generation
5960
run: |
6061
export GOROOT=$(go env GOROOT)
6162
export PATH=${GOROOT}/bin:$PATH
62-
go install github.com/google/go-licenses/v2@3e084b0caf710f7bfead967567539214f598c0a2 # v2.0.1
6363
make licenses-check
6464
6565
# Discover vulnerabilities within Go standard libraries used to build GitHub CLI using govulncheck.

.goreleaser.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ builds:
2020
goos: [darwin]
2121
goarch: [amd64, arm64]
2222
hooks:
23+
pre:
24+
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
25+
output: true
2326
post:
2427
- cmd: ./script/sign '{{ .Path }}'
2528
output: true
@@ -33,6 +36,10 @@ builds:
3336
goarch: ["386", arm, amd64, arm64]
3437
env:
3538
- CGO_ENABLED=0
39+
hooks:
40+
pre:
41+
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
42+
output: true
3643
binary: bin/gh
3744
main: ./cmd/gh
3845
ldflags:
@@ -42,6 +49,9 @@ builds:
4249
goos: [windows]
4350
goarch: ["386", amd64, arm64]
4451
hooks:
52+
pre:
53+
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
54+
output: true
4555
post:
4656
- cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
4757
output: true

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ endif
109109

110110
.PHONY: licenses
111111
licenses:
112-
./script/licenses
112+
./script/licenses $$(go env GOOS) $$(go env GOARCH)
113113

114114
.PHONY: licenses-check
115115
licenses-check:
116-
./script/licenses-check
116+
./script/licenses --check

docs/license-compliance.md

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,31 @@ GitHub CLI complies with the software licenses of its dependencies. This documen
44

55
## Overview
66

7-
When a dependency is added or updated, the license information needs to be updated. We use the [`google/go-licenses`](https://github.com/google/go-licenses) tool to:
7+
Third-party license information is embedded into the `gh` binary at build time using [`google/go-licenses`](https://github.com/google/go-licenses). Each release binary contains the correct license listing for its target platform (GOOS/GOARCH), since the set of dependencies can vary by platform.
88

9-
1. Generate markdown documentation listing all Go dependencies and their licenses
10-
2. Copy license files for dependencies that require redistribution
9+
## Viewing License Information
1110

12-
## License Files
11+
Users can view the third-party license information for their installed binary:
1312

14-
The following files contain license information:
15-
16-
- `third-party-licenses.darwin.md` - License information for macOS dependencies
17-
- `third-party-licenses.linux.md` - License information for Linux dependencies
18-
- `third-party-licenses.windows.md` - License information for Windows dependencies
19-
- `third-party/` - Directory containing source code and license files that require redistribution
20-
21-
## Updating License Information
22-
23-
When dependencies change, you need to update the license information:
24-
25-
1. Update license information for all platforms:
13+
```shell
14+
gh licenses
15+
```
2616

27-
```shell
28-
make licenses
29-
```
17+
This opens a pager displaying all Go dependencies and their licenses, with links to the source code of each dependency.
3018

31-
2. Commit the changes:
19+
## How It Works
3220

33-
```shell
34-
git add third-party-licenses.*.md third-party/
35-
git commit -m "Update third-party license information"
36-
```
21+
1. The `script/licenses` script accepts a GOOS and GOARCH and generates a license report using `go-licenses report`
22+
2. The report is written to `internal/licenses/embed/third-party-licenses.md`
23+
3. This file is embedded into the binary via `go:embed` in `internal/licenses/licenses.go`
24+
4. Goreleaser pre-build hooks call `script/licenses` with the correct platform before each build
3725

38-
## Checking License Compliance
26+
## Local Development
3927

40-
The CI workflow checks if license information is up to date. To check locally:
28+
During local development (`go build`), the embedded file contains a placeholder message. To generate real license information for your current platform:
4129

42-
```sh
43-
make licenses-check
30+
```shell
31+
make licenses
4432
```
4533

46-
If the check fails, follow the instructions to update the license information.
34+
This runs `go-licenses report` for your host GOOS/GOARCH and writes the output to the embed path.

internal/licenses/embed/report.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
License information is only available in official release builds.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
placeholder

internal/licenses/licenses.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package licenses
2+
3+
import (
4+
"embed"
5+
"fmt"
6+
"io/fs"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
)
11+
12+
//go:embed embed/report.txt
13+
var report string
14+
15+
//go:embed all:embed/third-party
16+
var thirdParty embed.FS
17+
18+
func Content() string {
19+
return content(report, thirdParty, "embed/third-party")
20+
}
21+
22+
func content(report string, thirdPartyFS fs.ReadFileFS, root string) string {
23+
var b strings.Builder
24+
25+
b.WriteString(report)
26+
b.WriteString("\n")
27+
28+
// Walk the third-party directory and output each license/notice file
29+
// grouped by module path.
30+
type moduleFiles struct {
31+
path string
32+
files []string
33+
}
34+
35+
modules := map[string]*moduleFiles{}
36+
fs.WalkDir(thirdPartyFS, root, func(filePath string, d fs.DirEntry, err error) error {
37+
if err != nil {
38+
return fmt.Errorf("failed to read embedded file %s: %w", filePath, err)
39+
}
40+
41+
if d.IsDir() {
42+
return nil
43+
}
44+
45+
name := d.Name()
46+
if name == "PLACEHOLDER" {
47+
return nil
48+
}
49+
50+
// Module path is the directory relative to root
51+
dir := filepath.Dir(filepath.FromSlash(filePath))
52+
rel, _ := filepath.Rel(filepath.FromSlash(root), dir)
53+
if _, ok := modules[rel]; !ok {
54+
modules[rel] = &moduleFiles{path: rel}
55+
}
56+
modules[rel].files = append(modules[rel].files, filePath)
57+
return nil
58+
})
59+
60+
// Sort modules by path for deterministic output
61+
sorted := make([]string, 0, len(modules))
62+
for k := range modules {
63+
sorted = append(sorted, k)
64+
}
65+
sort.Strings(sorted)
66+
67+
for _, modPath := range sorted {
68+
mod := modules[modPath]
69+
b.WriteString("================================================================================\n")
70+
fmt.Fprintf(&b, "%s\n", mod.path)
71+
b.WriteString("================================================================================\n\n")
72+
73+
for _, filePath := range mod.files {
74+
data, err := thirdPartyFS.ReadFile(filePath)
75+
if err != nil {
76+
continue
77+
}
78+
b.Write(data)
79+
b.WriteString("\n\n")
80+
}
81+
}
82+
83+
return b.String()
84+
}

internal/licenses/licenses_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package licenses
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
"testing/fstest"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestContent_reportOnly(t *testing.T) {
13+
report := "dep1 (v1.0.0) - MIT - https://example.com\n"
14+
fsys := fstest.MapFS{
15+
"third-party/PLACEHOLDER": &fstest.MapFile{Data: []byte("placeholder")},
16+
}
17+
18+
actualContent := content(report, fsys, "third-party")
19+
20+
require.True(t, strings.HasPrefix(actualContent, report), "expected output to start with report")
21+
require.NotContains(t, actualContent, "PLACEHOLDER")
22+
require.NotContains(t, actualContent, "====")
23+
}
24+
25+
func TestContent_singleModule(t *testing.T) {
26+
report := "example.com/mod (v1.0.0) - MIT - https://example.com\n"
27+
fsys := fstest.MapFS{
28+
"third-party/example.com/mod/LICENSE": &fstest.MapFile{
29+
Data: []byte("MIT License\n\nCopyright (c) 2024"),
30+
},
31+
}
32+
33+
actualContent := content(report, fsys, "third-party")
34+
35+
require.Contains(t, actualContent, filepath.FromSlash("example.com/mod"))
36+
require.Contains(t, actualContent, "MIT License")
37+
}
38+
39+
func TestContent_multipleModulesSortedAlphabetically(t *testing.T) {
40+
report := "header\n"
41+
fsys := fstest.MapFS{
42+
"third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{
43+
Data: []byte("ZZZ License"),
44+
},
45+
"third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{
46+
Data: []byte("AAA License"),
47+
},
48+
}
49+
50+
actualContent := content(report, fsys, "third-party")
51+
52+
aIdx := strings.Index(actualContent, filepath.FromSlash("github.com/aaa/pkg"))
53+
zIdx := strings.Index(actualContent, filepath.FromSlash("github.com/zzz/pkg"))
54+
require.NotEqual(t, -1, aIdx, "expected aaa module in output")
55+
require.NotEqual(t, -1, zIdx, "expected zzz module in output")
56+
require.Less(t, aIdx, zIdx, "expected modules to be sorted alphabetically")
57+
}
58+
59+
func TestContent_licenseAndNoticeFiles(t *testing.T) {
60+
report := "header\n"
61+
fsys := fstest.MapFS{
62+
"third-party/example.com/mod/LICENSE": &fstest.MapFile{
63+
Data: []byte("Apache License 2.0"),
64+
},
65+
"third-party/example.com/mod/NOTICE": &fstest.MapFile{
66+
Data: []byte("Copyright 2024 Example Corp"),
67+
},
68+
}
69+
70+
actualContent := content(report, fsys, "third-party")
71+
72+
require.Contains(t, actualContent, "Apache License 2.0")
73+
require.Contains(t, actualContent, "Copyright 2024 Example Corp")
74+
}
75+
76+
func TestContent_emptyThirdPartyDir(t *testing.T) {
77+
report := "header\n"
78+
fsys := fstest.MapFS{
79+
"third-party/empty": &fstest.MapFile{Data: []byte("")},
80+
}
81+
82+
actualContent := content(report, fsys, "third-party")
83+
84+
require.True(t, strings.HasPrefix(actualContent, "header\n"), "expected output to start with report header")
85+
}

0 commit comments

Comments
 (0)