Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions .github/licenses.tmpl
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
# GitHub CLI dependencies
GitHub CLI third-party dependencies
====================================

The following open source dependencies are used to build the [cli/cli][] GitHub CLI.
The following open source dependencies are used to build the GitHub CLI.

## Go Packages

Some packages may only be included on certain architectures or operating systems.

{{ range . }}
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }}

[cli/cli]: https://github.com/cli/cli
{{ range . -}}
{{.Name}} ({{.Version}}) - {{.LicenseName}} - {{.LicenseURL}}
{{ end }}
3 changes: 0 additions & 3 deletions .github/secret_scanning.yml

This file was deleted.

12 changes: 6 additions & 6 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ on:
- go.mod
- go.sum
- ".github/licenses.tmpl"
- "script/licenses*"
- "script/licenses"
pull_request:
paths:
- "**.go"
- go.mod
- go.sum
- ".github/licenses.tmpl"
- "script/licenses*"
- "script/licenses"
permissions:
contents: read
jobs:
Expand Down Expand Up @@ -50,16 +50,16 @@ jobs:
with:
version: v2.6.0

# Verify that license generation succeeds for all release platforms (GOOS/GOARCH).
# This catches issues like new dependencies with unrecognized licenses before release time.
#
# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
# which causes go-licenses to raise "Package ... does not have module info" errors.
# For more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633
#
# go-licenses has been pinned for automation use.
- name: Check licenses
- name: Verify license generation
run: |
export GOROOT=$(go env GOROOT)
export PATH=${GOROOT}/bin:$PATH
go install github.com/google/go-licenses/v2@3e084b0caf710f7bfead967567539214f598c0a2 # v2.0.1
make licenses-check

# Discover vulnerabilities within Go standard libraries used to build GitHub CLI using govulncheck.
Expand Down
10 changes: 10 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ builds:
goos: [darwin]
goarch: [amd64, arm64]
hooks:
pre:
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
output: true
post:
- cmd: ./script/sign '{{ .Path }}'
output: true
Expand All @@ -33,6 +36,10 @@ builds:
goarch: ["386", arm, amd64, arm64]
env:
- CGO_ENABLED=0
hooks:
pre:
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
output: true
binary: bin/gh
main: ./cmd/gh
ldflags:
Expand All @@ -42,6 +49,9 @@ builds:
goos: [windows]
goarch: ["386", amd64, arm64]
hooks:
pre:
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
output: true
post:
- cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
output: true
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ endif

.PHONY: licenses
licenses:
./script/licenses
./script/licenses $$(go env GOOS) $$(go env GOARCH)

.PHONY: licenses-check
licenses-check:
./script/licenses-check
./script/licenses --check
46 changes: 17 additions & 29 deletions docs/license-compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,31 @@ GitHub CLI complies with the software licenses of its dependencies. This documen

## Overview

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:
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.

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

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

The following files contain license information:

- `third-party-licenses.darwin.md` - License information for macOS dependencies
- `third-party-licenses.linux.md` - License information for Linux dependencies
- `third-party-licenses.windows.md` - License information for Windows dependencies
- `third-party/` - Directory containing source code and license files that require redistribution

## Updating License Information

When dependencies change, you need to update the license information:

1. Update license information for all platforms:
```shell
gh licenses
```

```shell
make licenses
```
This opens a pager displaying all Go dependencies and their licenses, with links to the source code of each dependency.

2. Commit the changes:
## How It Works

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

## Checking License Compliance
## Local Development

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

```sh
make licenses-check
```shell
make licenses
```

If the check fails, follow the instructions to update the license information.
This runs `go-licenses report` for your host GOOS/GOARCH and writes the output to the embed path.
1 change: 1 addition & 0 deletions internal/licenses/embed/report.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
License information is only available in official release builds.
1 change: 1 addition & 0 deletions internal/licenses/embed/third-party/PLACEHOLDER
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
placeholder
84 changes: 84 additions & 0 deletions internal/licenses/licenses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package licenses

import (
"embed"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
)

//go:embed embed/report.txt
var report string

//go:embed all:embed/third-party
var thirdParty embed.FS

func Content() string {
return content(report, thirdParty, "embed/third-party")
}

func content(report string, thirdPartyFS fs.ReadFileFS, root string) string {
var b strings.Builder

b.WriteString(report)
b.WriteString("\n")

// Walk the third-party directory and output each license/notice file
// grouped by module path.
type moduleFiles struct {
path string
files []string
}

modules := map[string]*moduleFiles{}
fs.WalkDir(thirdPartyFS, root, func(filePath string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("failed to read embedded file %s: %w", filePath, err)
}

if d.IsDir() {
return nil
}

name := d.Name()
if name == "PLACEHOLDER" {
return nil
}

// Module path is the directory relative to root
dir := filepath.Dir(filepath.FromSlash(filePath))
rel, _ := filepath.Rel(filepath.FromSlash(root), dir)
if _, ok := modules[rel]; !ok {
modules[rel] = &moduleFiles{path: rel}
}
modules[rel].files = append(modules[rel].files, filePath)
return nil
})

// Sort modules by path for deterministic output
sorted := make([]string, 0, len(modules))
for k := range modules {
sorted = append(sorted, k)
}
sort.Strings(sorted)

for _, modPath := range sorted {
mod := modules[modPath]
b.WriteString("================================================================================\n")
fmt.Fprintf(&b, "%s\n", mod.path)
b.WriteString("================================================================================\n\n")

for _, filePath := range mod.files {
data, err := thirdPartyFS.ReadFile(filePath)
if err != nil {
continue
}
b.Write(data)
b.WriteString("\n\n")
}
}

return b.String()
}
85 changes: 85 additions & 0 deletions internal/licenses/licenses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package licenses

import (
"path/filepath"
"strings"
"testing"
"testing/fstest"

"github.com/stretchr/testify/require"
)

func TestContent_reportOnly(t *testing.T) {
report := "dep1 (v1.0.0) - MIT - https://example.com\n"
fsys := fstest.MapFS{
"third-party/PLACEHOLDER": &fstest.MapFile{Data: []byte("placeholder")},
}

actualContent := content(report, fsys, "third-party")

require.True(t, strings.HasPrefix(actualContent, report), "expected output to start with report")
require.NotContains(t, actualContent, "PLACEHOLDER")
require.NotContains(t, actualContent, "====")
}

func TestContent_singleModule(t *testing.T) {
report := "example.com/mod (v1.0.0) - MIT - https://example.com\n"
fsys := fstest.MapFS{
"third-party/example.com/mod/LICENSE": &fstest.MapFile{
Data: []byte("MIT License\n\nCopyright (c) 2024"),
},
}

actualContent := content(report, fsys, "third-party")

require.Contains(t, actualContent, filepath.FromSlash("example.com/mod"))
require.Contains(t, actualContent, "MIT License")
}

func TestContent_multipleModulesSortedAlphabetically(t *testing.T) {
report := "header\n"
fsys := fstest.MapFS{
"third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{
Data: []byte("ZZZ License"),
},
"third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{
Data: []byte("AAA License"),
},
}

actualContent := content(report, fsys, "third-party")

aIdx := strings.Index(actualContent, filepath.FromSlash("github.com/aaa/pkg"))
zIdx := strings.Index(actualContent, filepath.FromSlash("github.com/zzz/pkg"))
require.NotEqual(t, -1, aIdx, "expected aaa module in output")
require.NotEqual(t, -1, zIdx, "expected zzz module in output")
require.Less(t, aIdx, zIdx, "expected modules to be sorted alphabetically")
}

func TestContent_licenseAndNoticeFiles(t *testing.T) {
report := "header\n"
fsys := fstest.MapFS{
"third-party/example.com/mod/LICENSE": &fstest.MapFile{
Data: []byte("Apache License 2.0"),
},
"third-party/example.com/mod/NOTICE": &fstest.MapFile{
Data: []byte("Copyright 2024 Example Corp"),
},
}

actualContent := content(report, fsys, "third-party")

require.Contains(t, actualContent, "Apache License 2.0")
require.Contains(t, actualContent, "Copyright 2024 Example Corp")
}

func TestContent_emptyThirdPartyDir(t *testing.T) {
report := "header\n"
fsys := fstest.MapFS{
"third-party/empty": &fstest.MapFile{Data: []byte("")},
}

actualContent := content(report, fsys, "third-party")

require.True(t, strings.HasPrefix(actualContent, "header\n"), "expected output to start with report header")
}
29 changes: 29 additions & 0 deletions pkg/cmd/licenses/licenses.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package licenses

import (
"fmt"

"github.com/cli/cli/v2/internal/licenses"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)

func NewCmdLicenses(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "licenses",
Short: "View third-party license information",
Long: "View license information for third-party libraries used in this build of the GitHub CLI.",
RunE: func(cmd *cobra.Command, args []string) error {
io := f.IOStreams
if err := io.StartPager(); err == nil {
defer io.StopPager()
}
_, err := fmt.Fprint(io.Out, licenses.Content())
return err
},
}

cmdutil.DisableAuthCheck(cmd)

return cmd
}
2 changes: 1 addition & 1 deletion pkg/cmd/project/item-list/item_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func runList(config listConfig) error {
return err
}
if !features.ProjectItemQuery {
return fmt.Errorf("the `--query` flag is not supported on this GitHub host; most likely you are targeting a version of GHES that does not yet have the query field available")
return fmt.Errorf("the `--query` flag is not supported on this GitHub host")
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/project/item-list/item_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -732,5 +732,5 @@ func TestRunList_QueryUnsupported(t *testing.T) {
}

err := runList(config)
assert.EqualError(t, err, "the `--query` flag is not supported on this GitHub host; most likely you are targeting a version of GHES that does not yet have the query field available")
assert.EqualError(t, err, "the `--query` flag is not supported on this GitHub host")
}
Loading
Loading