From 4ed4a80a57269dd36bd6a541c9a876ce4d031d53 Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:21:43 +0300 Subject: [PATCH 1/8] Plan third-party licenses and the archive contents The binary statically contains third-party modules and the Go standard library, whose licenses require their texts to accompany every binary copy, and the archives carry only the project's own LICENSE. The plan embeds the texts in the binary, as GitHub CLI does, so that they travel with the binary itself, and trims the archive to what a user needs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- docs/2026-10-06-tasks-third-party-licenses.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/2026-10-06-tasks-third-party-licenses.md diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/2026-10-06-tasks-third-party-licenses.md new file mode 100644 index 0000000..1adb679 --- /dev/null +++ b/docs/2026-10-06-tasks-third-party-licenses.md @@ -0,0 +1,126 @@ +# 2026-10-06-tasks-third-party-licenses.md + +**Status:** Active + +## Context + +Goal: every tfscli binary carries the license texts its distribution requires, +and the release archive holds only what a user needs. + +The binary statically contains nine third-party modules — MIT +(`JohannesKaufmann/html-to-markdown/v2`, `JohannesKaufmann/dom`), BSD-3-Clause +(`spf13/pflag`, `golang.org/x/term`, `x/sys`, `x/net`), Apache-2.0 +(`spf13/cobra`, and `inconshreveable/mousetrap` on Windows only) — and the Go +standard library and runtime (BSD-3-Clause). Each of these licenses requires +its text, and for MIT and BSD the copyright notice, to accompany a binary +copy. No module carries a NOTICE file. Today the archives ship only the +project's own `LICENSE`. + +Decisions already made: + +- The license texts are embedded in the binary and printed by + `tfscli licenses`, so that they reach every copy of the binary, including + one taken out of the archive. This follows GitHub CLI (`cli/cli`: + `script/licenses`, `internal/licenses`, `gh licenses`). +- `tfscli licenses` takes no flags and no arguments, prints to stdout, needs + no credential and no configuration, and exits 0. Its output is a list of + components, the license of the Go standard library, and the texts of the + third-party licenses. The list is built from the directory paths of the + embedded set: `go-licenses report` writes to stdout, and a goreleaser hook + cannot redirect it without a shell. The license name is visible at the top + of each text. +- A binary not built by the release (`go build`, `go install`, `make check`) + prints the Go license and a note that the third-party texts are only in + release builds. +- The set is generated per platform, `GOOS`/`GOARCH`, by `go-licenses` v2.0.1 + (`github.com/google/go-licenses/v2`), a build tool that is never linked into + the binary and does not enter `go.mod`. Each binary embeds only the set of + its own platform. +- Generation is configured in `.goreleaser.yaml` alone, with no script: a + `before` hook runs `go install github.com/google/go-licenses/v2@v2.0.1`, and + a build pre-hook runs `go-licenses save ./cmd/tfscli` with `GOOS` and + `GOARCH` set from the target, into + `internal/licenses/embed/-/third-party`, + `--ignore github.com/dpleshakov/tfscli --force`. A goreleaser hook runs + without a shell and accepts templated `env`; a separate directory per target + keeps the parallel builds from overwriting each other. `go run …@v2.0.1` + cannot be used in the pre-hook: with the target's `GOOS` it builds + go-licenses for that platform and cannot execute it. +- The generated `third-party/` directories are ignored by git. Each + `embed/-/` holds a committed placeholder so that `go:embed` + compiles without a set; the placeholder sits outside `third-party/`, which + `--force` deletes. +- The Go license is a committed copy, `internal/licenses/go.LICENSE`, embedded + in every build; a test compares it with `$(go env GOROOT)/LICENSE`. +- `make check` gains a `licenses` target: + `go run github.com/google/go-licenses/v2@v2.0.1 check ./cmd/tfscli + --allowed_licenses=MIT,BSD-3-Clause,Apache-2.0`. `go run` needs nothing + installed beforehand, so the tools required for `make check` do not change. + The check covers the platform it runs on. +- The archive holds the binary, `LICENSE`, and `skills/`, wrapped in a + directory named like the archive (`wrap_in_directory`). `README.md`, + `CHANGELOG.md`, and `config.example.json` leave the archive; + `config.example.json` is deleted from the repository, since README shows the + same example. +- `docs/project-brief.md` does not change: the command naming rule covers + REST API operations only, and `licenses` is not one. + +--- + +### TASK-01 `licenses-package` +**Description:** Add the package `internal/licenses`: a placeholder in +`embed/-/` for each of the six release platforms (linux, windows, +darwin × amd64, arm64), one build-tagged file per platform embedding its +directory and one for every other platform embedding nothing, the committed +`go.LICENSE`, and a function assembling the output described in Context. +Ignore the generated `third-party/` directories in `.gitignore`, and make +`make clean` remove them. +**Definition of done:** Tests cover the output with a set, without a set, and +the comparison of `go.LICENSE` with the toolchain's `LICENSE`; `make check` is +green with coverage of `internal/...` at 85% or above. +**Status:** Pending + +### TASK-02 `licenses-command` +**Description:** Add `tfscli licenses` to `internal/cli`, printing the output +of `internal/licenses` to stdout. +**Definition of done:** Tests show that the command prints the output, runs +without a credential or a config file, and refuses arguments; `make check` is +green. +**Status:** Pending + +### TASK-03 `release-generation` +**Description:** Add the `before` hook installing go-licenses and the build +pre-hook generating the set to `.goreleaser.yaml`. +**Definition of done:** `goreleaser release --snapshot --clean` succeeds; +`tfscli licenses` of the windows/amd64 binary lists all nine components and +prints their texts; `git status` is clean after the snapshot. +**Status:** Pending + +### TASK-04 `archive-contents` +**Description:** Reduce the archive files to `LICENSE` and `skills`, enable +`wrap_in_directory`, and delete `config.example.json` from the repository, +rewording its mentions in `README.md` and `.gitignore`. +**Definition of done:** The snapshot archives unpack into +`tfscli---/` holding exactly the binary, `LICENSE`, and +`skills/tfscli/SKILL.md`; no file of the repository mentions +`config.example.json` outside `docs/archive/`. +**Status:** Pending + +### TASK-05 `licenses-check` +**Description:** Add the `licenses` target to the `Makefile` and include it in +`check`. +**Definition of done:** `make check` is green and runs the target; with a +license removed from `--allowed_licenses`, the target fails. +**Status:** Pending + +### TASK-06 `docs` +**Description:** Document the change: `README.md` (the `licenses` command, the +archive contents and layout, installation), `CHANGELOG.md` (Added: the +command; Changed: the archive contents and layout), `docs/architecture.md` +(the package, go-licenses as a build tool), `CONTRIBUTING.md` (the `licenses` +target, go-licenses in the release and the snapshot, `$(go env GOPATH)/bin` on +`PATH` for a snapshot), `skills/tfscli/SKILL.md` (the command), and +`CLAUDE.md` where it summarises any of these. +**Definition of done:** The documents describe the new state; `make check` is +green. +**Status:** Pending From a79896b64299f9fe73533ea6a7a6a22efdc09def Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:27:09 +0300 Subject: [PATCH 2/8] Embed license texts and add tfscli licenses (TASK-01, TASK-02) The binary statically contains the Go standard library and third-party modules whose licenses require their texts to accompany every binary copy. The texts are now embedded in the binary itself, as GitHub CLI does, so that they travel with it even when it is taken out of the archive, and tfscli licenses prints them. The Go license is a committed copy, embedded in every build and kept equal to the toolchain's by a test. The third-party set differs by platform, so each release platform embeds its own directory through a file named for it; the release build fills that directory, and a committed placeholder beside the generated third-party/ keeps go:embed compiling everywhere else. Such a build says that the third-party texts are only in the release builds. The list of components comes from the directory names, since the set is generated without a shell to capture a report. The command reads neither the credential nor the config file. The two tasks are committed together because the package alone would be unused. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- .gitignore | 4 + Makefile | 11 +- docs/2026-10-06-tasks-third-party-licenses.md | 4 +- internal/cli/cli.go | 1 + internal/cli/licenses.go | 26 ++++ internal/cli/licenses_test.go | 54 ++++++++ internal/licenses/doc.go | 5 + .../licenses/embed/darwin-amd64/PLACEHOLDER | 3 + .../licenses/embed/darwin-arm64/PLACEHOLDER | 3 + .../licenses/embed/linux-amd64/PLACEHOLDER | 3 + .../licenses/embed/linux-arm64/PLACEHOLDER | 3 + .../licenses/embed/windows-amd64/PLACEHOLDER | 3 + .../licenses/embed/windows-arm64/PLACEHOLDER | 3 + internal/licenses/embed_darwin_amd64.go | 12 ++ internal/licenses/embed_darwin_arm64.go | 12 ++ internal/licenses/embed_linux_amd64.go | 12 ++ internal/licenses/embed_linux_arm64.go | 12 ++ internal/licenses/embed_other.go | 12 ++ internal/licenses/embed_windows_amd64.go | 12 ++ internal/licenses/embed_windows_arm64.go | 12 ++ internal/licenses/go.LICENSE | 27 ++++ internal/licenses/licenses.go | 110 ++++++++++++++++ internal/licenses/licenses_test.go | 120 ++++++++++++++++++ 23 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 internal/cli/licenses.go create mode 100644 internal/cli/licenses_test.go create mode 100644 internal/licenses/doc.go create mode 100644 internal/licenses/embed/darwin-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/darwin-arm64/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/linux-arm64/PLACEHOLDER create mode 100644 internal/licenses/embed/windows-amd64/PLACEHOLDER create mode 100644 internal/licenses/embed/windows-arm64/PLACEHOLDER create mode 100644 internal/licenses/embed_darwin_amd64.go create mode 100644 internal/licenses/embed_darwin_arm64.go create mode 100644 internal/licenses/embed_linux_amd64.go create mode 100644 internal/licenses/embed_linux_arm64.go create mode 100644 internal/licenses/embed_other.go create mode 100644 internal/licenses/embed_windows_amd64.go create mode 100644 internal/licenses/embed_windows_arm64.go create mode 100644 internal/licenses/go.LICENSE create mode 100644 internal/licenses/licenses.go create mode 100644 internal/licenses/licenses_test.go diff --git a/.gitignore b/.gitignore index 3645f92..9076576 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /tfscli.exe /dist/ +# The third-party licenses the release build generates for each platform; the +# PLACEHOLDER beside each third-party/ is committed. +/internal/licenses/embed/*/third-party/ + # Test and coverage artifacts *.test *.out diff --git a/Makefile b/Makefile index 30c170a..64b9c0f 100644 --- a/Makefile +++ b/Makefile @@ -52,9 +52,10 @@ check: build lint test # --------------------------------------------------------------------------- # Remove every build artifact — the binary, the coverage profile, and the dist/ -# a local goreleaser snapshot leaves — leaving the working tree as git sees it. -# -X limits git clean to ignored files, -d lets it remove dist/, and the paths -# restrict it to exactly these artifacts, so a local config.json, IDE settings, -# and untracked work are never touched. +# and the third-party licenses a local goreleaser snapshot leaves — leaving the +# working tree as git sees it. -X limits git clean to ignored files, so the +# committed placeholders under internal/licenses/embed stay; -d lets it remove +# directories; and the paths restrict it to exactly these artifacts, so a local +# config.json, IDE settings, and untracked work are never touched. clean: - git clean -fdX -- tfscli tfscli.exe coverage.out dist + git clean -fdX -- tfscli tfscli.exe coverage.out dist internal/licenses/embed diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/2026-10-06-tasks-third-party-licenses.md index 1adb679..84969f4 100644 --- a/docs/2026-10-06-tasks-third-party-licenses.md +++ b/docs/2026-10-06-tasks-third-party-licenses.md @@ -78,7 +78,7 @@ Ignore the generated `third-party/` directories in `.gitignore`, and make **Definition of done:** Tests cover the output with a set, without a set, and the comparison of `go.LICENSE` with the toolchain's `LICENSE`; `make check` is green with coverage of `internal/...` at 85% or above. -**Status:** Pending +**Status:** Done ### TASK-02 `licenses-command` **Description:** Add `tfscli licenses` to `internal/cli`, printing the output @@ -86,7 +86,7 @@ of `internal/licenses` to stdout. **Definition of done:** Tests show that the command prints the output, runs without a credential or a config file, and refuses arguments; `make check` is green. -**Status:** Pending +**Status:** Done ### TASK-03 `release-generation` **Description:** Add the `before` hook installing go-licenses and the build diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 51d6bd5..521f428 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -96,6 +96,7 @@ func newRoot(b build, stdin prompter, stdout, stderr io.Writer) *cobra.Command { root.AddCommand(newAuthCmd(g)) root.AddCommand(newWitCmd(g)) + root.AddCommand(newLicensesCmd(g)) return root } diff --git a/internal/cli/licenses.go b/internal/cli/licenses.go new file mode 100644 index 0000000..12e7601 --- /dev/null +++ b/internal/cli/licenses.go @@ -0,0 +1,26 @@ +package cli + +import ( + "io" + + "github.com/spf13/cobra" + + "github.com/dpleshakov/tfscli/internal/licenses" +) + +// newLicensesCmd builds "tfscli licenses", a local command like auth login: it +// reads neither the credential nor the config file and calls no server. +func newLicensesCmd(g *globals) *cobra.Command { + return &cobra.Command{ + Use: "licenses", + Short: "Print the licenses of the code included in tfscli", + Long: "Print the components included in this binary — the Go standard library\n" + + "and the third-party modules — followed by the license text of each. The\n" + + "third-party texts are embedded only in the release builds.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + _, err := io.WriteString(g.stdout, licenses.Text()) + return err + }, + } +} diff --git a/internal/cli/licenses_test.go b/internal/cli/licenses_test.go new file mode 100644 index 0000000..9fb6818 --- /dev/null +++ b/internal/cli/licenses_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/dpleshakov/tfscli/internal/licenses" +) + +func TestLicensesPrintsTheLicenses(t *testing.T) { + stdout, stderr, code := execute(t, nil, "licenses") + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if stderr != "" { + t.Errorf("stderr = %q, want nothing", stderr) + } + if stdout != licenses.Text() { + t.Errorf("stdout = %q, want the text of internal/licenses", stdout) + } +} + +// TestLicensesNeedsNoSettings runs the command with no credential and a config +// file that cannot be parsed: it reads neither. +func TestLicensesNeedsNoSettings(t *testing.T) { + isolate(t) + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + writeTestFile(t, filepath.Join(cfgHome, "tfscli", "config.json"), "{not json") + + var out, errOut bytes.Buffer + code := run(testBuild, []string{"licenses"}, noTerminal{}, &out, &errOut) + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String()) + } + if !strings.HasPrefix(out.String(), "tfscli includes the following components:") { + t.Errorf("stdout = %q, want the licenses", out.String()) + } +} + +func TestLicensesRefusesArguments(t *testing.T) { + stdout, stderr, code := execute(t, nil, "licenses", "extra") + + if code == 0 { + t.Fatalf("exit code = 0, want non-zero (stdout: %s)", stdout) + } + if !strings.HasPrefix(stderr, "Error [config]: ") { + t.Errorf("stderr = %q, want a config error", stderr) + } +} diff --git a/internal/licenses/doc.go b/internal/licenses/doc.go new file mode 100644 index 0000000..694ba6b --- /dev/null +++ b/internal/licenses/doc.go @@ -0,0 +1,5 @@ +// Package licenses holds the license texts of the code linked into the +// binary: the Go standard library, always, and the third-party modules of +// the platform the binary was built for, which the release build generates +// with go-licenses and embeds. Text assembles them for "tfscli licenses". +package licenses diff --git a/internal/licenses/embed/darwin-amd64/PLACEHOLDER b/internal/licenses/embed/darwin-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/darwin-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/darwin-arm64/PLACEHOLDER b/internal/licenses/embed/darwin-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/darwin-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/linux-amd64/PLACEHOLDER b/internal/licenses/embed/linux-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/linux-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/linux-arm64/PLACEHOLDER b/internal/licenses/embed/linux-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/linux-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/windows-amd64/PLACEHOLDER b/internal/licenses/embed/windows-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/windows-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/windows-arm64/PLACEHOLDER b/internal/licenses/embed/windows-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/windows-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed_darwin_amd64.go b/internal/licenses/embed_darwin_amd64.go new file mode 100644 index 0000000..13f8851 --- /dev/null +++ b/internal/licenses/embed_darwin_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of darwin/amd64, written into +// embed/darwin-amd64/third-party by the release build. +// +//go:embed embed/darwin-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/darwin-amd64" diff --git a/internal/licenses/embed_darwin_arm64.go b/internal/licenses/embed_darwin_arm64.go new file mode 100644 index 0000000..fe10a10 --- /dev/null +++ b/internal/licenses/embed_darwin_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of darwin/arm64, written into +// embed/darwin-arm64/third-party by the release build. +// +//go:embed embed/darwin-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/darwin-arm64" diff --git a/internal/licenses/embed_linux_amd64.go b/internal/licenses/embed_linux_amd64.go new file mode 100644 index 0000000..058644a --- /dev/null +++ b/internal/licenses/embed_linux_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of linux/amd64, written into +// embed/linux-amd64/third-party by the release build. +// +//go:embed embed/linux-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/linux-amd64" diff --git a/internal/licenses/embed_linux_arm64.go b/internal/licenses/embed_linux_arm64.go new file mode 100644 index 0000000..af94298 --- /dev/null +++ b/internal/licenses/embed_linux_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of linux/arm64, written into +// embed/linux-arm64/third-party by the release build. +// +//go:embed embed/linux-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/linux-arm64" diff --git a/internal/licenses/embed_other.go b/internal/licenses/embed_other.go new file mode 100644 index 0000000..39bd1a0 --- /dev/null +++ b/internal/licenses/embed_other.go @@ -0,0 +1,12 @@ +//go:build !((linux || windows || darwin) && (amd64 || arm64)) + +package licenses + +import "embed" + +// platformFS is empty on a platform the release does not build: no set of +// third-party licenses is ever generated for it. +var platformFS embed.FS + +// platformDir names no directory of the empty platformFS. +const platformDir = "embed/other" diff --git a/internal/licenses/embed_windows_amd64.go b/internal/licenses/embed_windows_amd64.go new file mode 100644 index 0000000..1b2a464 --- /dev/null +++ b/internal/licenses/embed_windows_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of windows/amd64, written into +// embed/windows-amd64/third-party by the release build. +// +//go:embed embed/windows-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/windows-amd64" diff --git a/internal/licenses/embed_windows_arm64.go b/internal/licenses/embed_windows_arm64.go new file mode 100644 index 0000000..85e9a8f --- /dev/null +++ b/internal/licenses/embed_windows_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of windows/arm64, written into +// embed/windows-arm64/third-party by the release build. +// +//go:embed embed/windows-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/windows-arm64" diff --git a/internal/licenses/go.LICENSE b/internal/licenses/go.LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/internal/licenses/go.LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/licenses/licenses.go b/internal/licenses/licenses.go new file mode 100644 index 0000000..d5d82f6 --- /dev/null +++ b/internal/licenses/licenses.go @@ -0,0 +1,110 @@ +package licenses + +import ( + _ "embed" // for go:embed of the Go license + "io/fs" + "path" + "sort" + "strings" +) + +// goLicense is the license of the Go standard library and runtime, a copy of +// LICENSE from the Go distribution. +// +//go:embed go.LICENSE +var goLicense string + +// goComponent names the Go standard library and runtime in the output. +const goComponent = "Go standard library and runtime" + +// separator frames the name of each component above its license texts. +var separator = strings.Repeat("=", 80) + +// Text returns the license information of this binary: the list of the +// components it includes, followed by the license texts of each. +func Text() string { + set, err := fs.Sub(platformFS, path.Join(platformDir, "third-party")) + if err != nil { + set = nil + } + return text(goLicense, set) +} + +// component is one entry of the output: a name and its license files. +type component struct { + name string + files []string +} + +// text assembles the output from the Go license and set, the third-party +// licenses saved by go-licenses: one directory per package path, holding the +// license files of its module. A nil or empty set means a build the release +// did not make. +func text(goLicense string, set fs.FS) string { + third := thirdParty(set) + + var b strings.Builder + b.WriteString("tfscli includes the following components:\n\n") + b.WriteString(" " + goComponent + "\n") + for _, c := range third { + b.WriteString(" " + c.name + "\n") + } + if len(third) == 0 { + b.WriteString("\nThis build does not carry the licenses of the third-party modules it\n" + + "includes: they are embedded only in the release builds, available at\n" + + "https://github.com/dpleshakov/tfscli/releases.\n") + } + + writeComponent(&b, goComponent, goLicense) + for _, c := range third { + var texts []string + for _, f := range c.files { + data, err := fs.ReadFile(set, f) + if err != nil { + continue + } + texts = append(texts, string(data)) + } + writeComponent(&b, c.name, strings.Join(texts, "\n")) + } + return b.String() +} + +// thirdParty lists the components of set, sorted by name, with their files. +// A set that cannot be read yields none. +func thirdParty(set fs.FS) []component { + if set == nil { + return nil + } + byDir := map[string]*component{} + err := fs.WalkDir(set, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + dir := path.Dir(p) + if byDir[dir] == nil { + byDir[dir] = &component{name: dir} + } + byDir[dir].files = append(byDir[dir].files, p) + return nil + }) + if err != nil { + return nil + } + + list := make([]component, 0, len(byDir)) + for _, c := range byDir { + list = append(list, *c) + } + sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name }) + return list +} + +// writeComponent appends the framed name of a component and its license text. +func writeComponent(b *strings.Builder, name, license string) { + b.WriteString("\n" + separator + "\n" + name + "\n" + separator + "\n\n") + b.WriteString(strings.TrimRight(license, "\n") + "\n") +} diff --git a/internal/licenses/licenses_test.go b/internal/licenses/licenses_test.go new file mode 100644 index 0000000..a8a5d7c --- /dev/null +++ b/internal/licenses/licenses_test.go @@ -0,0 +1,120 @@ +package licenses + +import ( + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestTextWithSet(t *testing.T) { + set := fstest.MapFS{ + "github.com/spf13/cobra/LICENSE.txt": {Data: []byte("Apache License\n")}, + "golang.org/x/term/LICENSE": {Data: []byte("Copyright 2009 The Go Authors.\n")}, + "github.com/example/notice/LICENSE": {Data: []byte("MIT License\n")}, + "github.com/example/notice/NOTICE": {Data: []byte("Notice text\n")}, + "github.com/JohannesKaufmann/dom/LICENSE": {Data: []byte("MIT License\n\nCopyright (c) 2024\n")}, + "github.com/JohannesKaufmann/dom/sub/LICENSE": {Data: []byte("Nested\n")}, + "github.com/JohannesKaufmann/dom/LICENSE.more": {Data: []byte("More\n")}, + } + + got := text("GO LICENSE\n", set) + + wantList := "tfscli includes the following components:\n\n" + + " Go standard library and runtime\n" + + " github.com/JohannesKaufmann/dom\n" + + " github.com/JohannesKaufmann/dom/sub\n" + + " github.com/example/notice\n" + + " github.com/spf13/cobra\n" + + " golang.org/x/term\n" + if !strings.HasPrefix(got, wantList) { + t.Errorf("text() does not start with the component list:\n%s", got) + } + if strings.Contains(got, "only in the release builds") { + t.Errorf("text() with a set says the set is missing:\n%s", got) + } + + framed := func(name, body string) string { + return "\n" + separator + "\n" + name + "\n" + separator + "\n\n" + body + } + for _, want := range []string{ + framed("Go standard library and runtime", "GO LICENSE\n"), + framed("github.com/spf13/cobra", "Apache License\n"), + framed("github.com/example/notice", "MIT License\n\nNotice text\n"), + framed("github.com/JohannesKaufmann/dom", "MIT License\n\nCopyright (c) 2024\n\nMore\n"), + } { + if !strings.Contains(got, want) { + t.Errorf("text() lacks %q:\n%s", want, got) + } + } + if strings.Index(got, framed("Go standard library and runtime", "")) > + strings.Index(got, framed("github.com/JohannesKaufmann/dom", "")) { + t.Errorf("text() does not put the Go license first:\n%s", got) + } +} + +func TestTextWithoutSet(t *testing.T) { + for name, set := range map[string]fs.FS{ + "nil": nil, + "empty": fstest.MapFS{}, + "missing": sub(t, fstest.MapFS{"PLACEHOLDER": {}}, "third-party"), + } { + t.Run(name, func(t *testing.T) { + got := text("GO LICENSE\n", set) + want := "tfscli includes the following components:\n\n" + + " Go standard library and runtime\n\n" + + "This build does not carry the licenses of the third-party modules it\n" + + "includes: they are embedded only in the release builds, available at\n" + + "https://github.com/dpleshakov/tfscli/releases.\n" + + "\n" + separator + "\nGo standard library and runtime\n" + separator + "\n\n" + + "GO LICENSE\n" + if got != want { + t.Errorf("text() =\n%s\nwant\n%s", got, want) + } + }) + } +} + +// sub is fs.Sub that fails the test on an error, which only an invalid dir +// can cause. +func sub(t *testing.T, fsys fs.FS, dir string) fs.FS { + t.Helper() + s, err := fs.Sub(fsys, dir) + if err != nil { + t.Fatalf("fs.Sub(%q): %v", dir, err) + } + return s +} + +// TestTextOfThisBuild covers Text over the embedded set of the platform the +// tests run on. The set is usually absent, but a local goreleaser snapshot +// leaves one behind until make clean, so the test holds either way. +func TestTextOfThisBuild(t *testing.T) { + got := Text() + if want := text(goLicense, sub(t, platformFS, platformDir+"/third-party")); got != want { + t.Errorf("Text() =\n%s\nwant\n%s", got, want) + } + if !strings.Contains(got, goLicense) { + t.Errorf("Text() lacks the Go license:\n%s", got) + } +} + +// TestGoLicenseMatchesToolchain keeps the committed copy of the Go license +// equal to the one of the toolchain that builds tfscli. +func TestGoLicenseMatchesToolchain(t *testing.T) { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + t.Fatalf("go env GOROOT: %v", err) + } + want, err := os.ReadFile(filepath.Join(strings.TrimSpace(string(out)), "LICENSE")) + if err != nil { + t.Fatalf("reading the toolchain license: %v", err) + } + if goLicense != string(want) { + t.Errorf("internal/licenses/go.LICENSE differs from LICENSE of the Go toolchain; " + + "copy $(go env GOROOT)/LICENSE over it") + } +} From 7a4fb49f0e322a9766b9a776d946ccca86f6586e Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:27:59 +0300 Subject: [PATCH 3/8] Generate the third-party licenses in the release build (TASK-03) goreleaser now installs go-licenses v2.0.1 before the builds, and each build saves the licenses of its target's dependencies into the directory internal/licenses embeds for that target. GOOS and GOARCH are set on the hook, since the dependencies differ by platform: mousetrap is linked only on Windows. Each target writes to its own directory because the builds run in parallel. The tool is installed in a before hook rather than run with go run in the build hook, which would build it for the target's platform and fail to execute it. The tasks file now counts eight third-party modules: the earlier nine included tfscli itself. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- .goreleaser.yaml | 21 +++++++++++++++++++ docs/2026-10-06-tasks-third-party-licenses.md | 9 ++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0517c4d..7c73d96 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,14 @@ version: 2 project_name: tfscli +before: + hooks: + # go-licenses generates the third-party licenses each binary embeds (see + # the build pre-hook). It is installed for the platform goreleaser runs on: + # `go run` in the pre-hook would build it for the target's GOOS instead, + # and a binary for another platform cannot be executed. + - go install github.com/google/go-licenses/v2@v2.0.1 + builds: - id: tfscli main: ./cmd/tfscli @@ -12,6 +20,19 @@ builds: - CGO_ENABLED=0 ldflags: - -X main.version={{ .Version }} -X main.commit={{ .Commit }} + # The licenses of the third-party modules linked into this target, saved + # where internal/licenses embeds them for it. GOOS and GOARCH make + # go-licenses follow the target's dependencies, which differ by platform. + # The targets build in parallel, and each writes to a directory of its + # own; --force replaces what an earlier build left there. A hook runs + # without a shell, so nothing here may rely on one. + hooks: + pre: + - cmd: go-licenses save ./cmd/tfscli --save_path=internal/licenses/embed/{{ .Os }}-{{ .Arch }}/third-party --ignore github.com/dpleshakov/tfscli --force + env: + - "GOOS={{ .Os }}" + - "GOARCH={{ .Arch }}" + output: true goos: - linux - windows diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/2026-10-06-tasks-third-party-licenses.md index 84969f4..0712b7a 100644 --- a/docs/2026-10-06-tasks-third-party-licenses.md +++ b/docs/2026-10-06-tasks-third-party-licenses.md @@ -7,7 +7,7 @@ Goal: every tfscli binary carries the license texts its distribution requires, and the release archive holds only what a user needs. -The binary statically contains nine third-party modules — MIT +The binary statically contains eight third-party modules — MIT (`JohannesKaufmann/html-to-markdown/v2`, `JohannesKaufmann/dom`), BSD-3-Clause (`spf13/pflag`, `golang.org/x/term`, `x/sys`, `x/net`), Apache-2.0 (`spf13/cobra`, and `inconshreveable/mousetrap` on Windows only) — and the Go @@ -92,9 +92,10 @@ green. **Description:** Add the `before` hook installing go-licenses and the build pre-hook generating the set to `.goreleaser.yaml`. **Definition of done:** `goreleaser release --snapshot --clean` succeeds; -`tfscli licenses` of the windows/amd64 binary lists all nine components and -prints their texts; `git status` is clean after the snapshot. -**Status:** Pending +`tfscli licenses` of the windows/amd64 binary lists the Go standard library +and the eight third-party modules and prints their texts; `git status` is +clean after the snapshot. +**Status:** Done ### TASK-04 `archive-contents` **Description:** Reduce the archive files to `LICENSE` and `skills`, enable From 90822a88263e72e17c46565f4c8122beed091dc6 Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:28:38 +0300 Subject: [PATCH 4/8] Ship only the binary, LICENSE, and the skill (TASK-04) The archive scattered six entries into whatever directory it was unpacked in. It now unpacks into a directory named like the archive and holds only what a user needs beside the binary: the project's LICENSE and the agent skill, which the README tells the user to copy from it. The README and the changelog are read on GitHub and on the release page, and the third-party licenses travel inside the binary. config.example.json is deleted rather than only dropped from the archive: it held the same three lines the README shows, so nothing else needs it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- .gitignore | 3 +-- .goreleaser.yaml | 9 ++++++--- README.md | 4 ++-- config.example.json | 3 --- docs/2026-10-06-tasks-third-party-licenses.md | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 config.example.json diff --git a/.gitignore b/.gitignore index 9076576..bfd7ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ *.out coverage.html -# A local config made from config.example.json — personal settings, never -# committed. Credentials are not kept here; they live in auth.json. +# A local config file — personal settings, never committed. Credentials are not kept here; they live in auth.json. config.json # IDE diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7c73d96..ad73ff1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -48,11 +48,14 @@ archives: format_overrides: - goos: windows formats: [zip] + # The archive unpacks into a directory named like it, rather than + # scattering its files into the directory it is unpacked in. + wrap_in_directory: true + # Only what a user needs beside the binary. The documentation lives on + # GitHub, and the release page carries the changelog section; the + # third-party licenses are embedded in the binary (tfscli licenses). files: - LICENSE - - README.md - - CHANGELOG.md - - config.example.json # The agent skill ships with the binary so that an AI agent that unpacks # the archive can be told how to use tfscli without a second download. # The directory is kept whole: it is copied as-is into a skills path. diff --git a/README.md b/README.md index 85fd932..84e5209 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Download an archive from the [releases page](https://github.com/dpleshakov/tfscl | Windows | `windows-amd64.zip` | `windows-arm64.zip` | | macOS | `darwin-amd64.tar.gz` | `darwin-arm64.tar.gz` | -Every archive holds the binary together with `LICENSE`, `README.md`, `CHANGELOG.md`, `config.example.json`, and the agent skill in `skills/tfscli/SKILL.md`. SHA-256 sums for all six archives are in `checksums.txt`, attached to the same release. +Every archive unpacks into a directory of the same name, `tfscli---/`, holding the binary, `LICENSE`, and the agent skill in `skills/tfscli/SKILL.md`. SHA-256 sums for all six archives are in `checksums.txt`, attached to the same release. Verify a download before using it: @@ -173,7 +173,7 @@ The settings come from four sources. Later sources override earlier ones: 3. Environment variables 4. Command-line flags -The config file is optional: every setting it holds, except the TLS ones, can be given by an environment variable or a flag instead. To use one, copy `config.example.json` to that location and fill in the values: +The config file is optional: every setting it holds, except the TLS ones, can be given by an environment variable or a flag instead. To use one, create it at that location; for example: ```json { diff --git a/config.example.json b/config.example.json deleted file mode 100644 index a9ec917..0000000 --- a/config.example.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "project": "MyProject" -} diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/2026-10-06-tasks-third-party-licenses.md index 0712b7a..6e6cfb3 100644 --- a/docs/2026-10-06-tasks-third-party-licenses.md +++ b/docs/2026-10-06-tasks-third-party-licenses.md @@ -105,7 +105,7 @@ rewording its mentions in `README.md` and `.gitignore`. `tfscli---/` holding exactly the binary, `LICENSE`, and `skills/tfscli/SKILL.md`; no file of the repository mentions `config.example.json` outside `docs/archive/`. -**Status:** Pending +**Status:** Done ### TASK-05 `licenses-check` **Description:** Add the `licenses` target to the `Makefile` and include it in From f2bf19418ae94af597b25125f7ee81ba38284118 Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:29:23 +0300 Subject: [PATCH 5/8] Check the dependency licenses in make check (TASK-05) make check now fails when a dependency linked into the binary is under a license other than MIT, BSD-3-Clause, or Apache-2.0, so that a new license is noticed on the pull request that brings it rather than at the release. go-licenses runs through go run with a pinned version, which needs nothing installed and leaves go.mod alone, so the tools make check requires stay the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- Makefile | 12 ++++++++++-- docs/2026-10-06-tasks-third-party-licenses.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 64b9c0f..52b8c49 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ # # Prerequisites: Go, and — for `lint` — golangci-lint v2 on PATH. -.PHONY: build lint test check clean +.PHONY: build lint test licenses check clean # --------------------------------------------------------------------------- # Build @@ -44,8 +44,16 @@ test: go tool cover -func=coverage.out go run tools/check-coverage.go 85 +# Fail when a dependency linked into the binary is under a license not on the +# list. Every license here allows a binary distribution that carries its text, +# which tfscli licenses does; a new one is a decision, not an edit of the list. +# `go run` with a version needs nothing installed and leaves go.mod alone. The +# check follows the dependencies of the platform it runs on. +licenses: + go run github.com/google/go-licenses/v2@v2.0.1 check ./cmd/tfscli --allowed_licenses=MIT,BSD-3-Clause,Apache-2.0 + # Everything the project verifies, in one target. This is what CI runs. -check: build lint test +check: build lint test licenses # --------------------------------------------------------------------------- # Clean diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/2026-10-06-tasks-third-party-licenses.md index 6e6cfb3..4fab701 100644 --- a/docs/2026-10-06-tasks-third-party-licenses.md +++ b/docs/2026-10-06-tasks-third-party-licenses.md @@ -112,7 +112,7 @@ rewording its mentions in `README.md` and `.gitignore`. `check`. **Definition of done:** `make check` is green and runs the target; with a license removed from `--allowed_licenses`, the target fails. -**Status:** Pending +**Status:** Done ### TASK-06 `docs` **Description:** Document the change: `README.md` (the `licenses` command, the From 4dc9c6f64c107ccc7a90b798090fab875463c6c3 Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:32:13 +0300 Subject: [PATCH 6/8] Document the licenses command and the new archive (TASK-06) The README describes tfscli licenses and what a binary built from source prints for it, and the archive section names the new contents and the directory it unpacks into. The changelog records the command and the change to the archives for users. The architecture records go-licenses as a build tool, with the reasons the texts are embedded per platform rather than shipped beside the binary or committed, and adds the licenses package. CONTRIBUTING covers the new target and what the release and a snapshot now do; the agent skill and CLAUDE.md name the command. Closes docs/archive/2026-10-06-tasks-third-party-licenses.md Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- CHANGELOG.md | 4 +++ CLAUDE.md | 8 +++--- CONTRIBUTING.md | 28 +++++++++++++------ README.md | 6 ++-- docs/architecture.md | 25 +++++++++++++++-- .../2026-10-06-tasks-third-party-licenses.md | 4 +-- skills/tfscli/SKILL.md | 8 ++++-- 7 files changed, 60 insertions(+), 23 deletions(-) rename docs/{ => archive}/2026-10-06-tasks-third-party-licenses.md (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c295ba..d86aeb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `tfscli licenses` prints the components included in the binary and the license text of each: the Go standard library and, in the release builds, the third-party modules. + ### Changed +- Release archives now unpack into a directory of their own name, `tfscli---/`, holding only the binary, `LICENSE`, and the agent skill; `README.md`, `CHANGELOG.md`, and `config.example.json` are no longer included. - Every command except `tfscli auth login`, when no API version is configured, now first asks the server with an `OPTIONS` request which version its resource supports and sends that version, so `wit work-items get-batch` and `wit wiql query-by-wiql` no longer fail with "No api-version was supplied" on servers that require one. - A request sent without an API version because the server did not answer that question is now noted under `--verbose`, and if the server refuses it for the missing version, the `config` error names `apiVersion`, `TFSCLI_API_VERSION`, and `--api-version`. diff --git a/CLAUDE.md b/CLAUDE.md index 31d6b0a..19ac1ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The workflow is defined by four process skills kept in the repository under `.cl ## Project state -The MVP is implemented and released: `tfscli auth login` stores the credential, `tfscli wit work-items get` reads one work item, `tfscli wit work-items list` and `tfscli wit work-items get-batch` read several work items by id, `tfscli wit wiql query-by-wiql` finds work item ids with a WIQL query, and the module has one package per architecture module (see the "Project Structure" section of `docs/architecture.md`). Known defects are listed in the "Known limitations" section of `README.md`. Accepted work that has not started is listed in `docs/backlog.md`; work in progress goes through tasks files per the `tasks` skill. +The MVP is implemented and released: `tfscli auth login` stores the credential, `tfscli wit work-items get` reads one work item, `tfscli wit work-items list` and `tfscli wit work-items get-batch` read several work items by id, `tfscli wit wiql query-by-wiql` finds work item ids with a WIQL query, `tfscli licenses` prints the license texts embedded in the binary, and the module has one package per architecture module (see the "Project Structure" section of `docs/architecture.md`). Known defects are listed in the "Known limitations" section of `README.md`. Accepted work that has not started is listed in `docs/backlog.md`; work in progress goes through tasks files per the `tasks` skill. Read these before the corresponding kind of change (the full list of project documents is in "Project documents" in `CONTRIBUTING.md`): @@ -30,7 +30,7 @@ These come from `project-brief.md` and override casual feature requests. If a ch - **Target is on-prem TFS / Azure DevOps Server; the API version is negotiated.** Without a configured version, each request is preceded by `OPTIONS {url}/{collection}/_apis` and carries the version the server lists for its resource; when the negotiation fails, the request goes out without a version, as before. A configured version is sent as is. `auth login` never sends one. Cloud Azure DevOps Services may work incidentally but is not tested or supported. - **Do not narrow server compatibility.** A change must not stop tfscli from working with a TFS / Azure DevOps Server version it worked with before, unless that is an explicitly discussed and recorded decision (see "Server compatibility is preserved" in `project-brief.md`). - **Works out of the box; errors lead the way.** Assume the user has read nothing and configured nothing. Do not add a required setting where a working default exists, and do not guess where the answer is ambiguous — ask the user instead. Every error a first-time user can hit names the concrete next step, and says when only a person can take it (see "Works out of the box; errors lead the way" in `project-brief.md`). -- **Minimal dependencies.** Total external deps are intentionally three: `cobra`, `html-to-markdown/v2`, and `golang.org/x/term` (hidden PAT input for `auth login`). HTTP via `net/http`, JSON via `encoding/json`, config via `encoding/json`. Don't pull in `viper`, `resty`, retry libraries, or YAML/TOML parsers without a strong reason and explicit discussion. +- **Minimal dependencies.** Total external deps are intentionally three: `cobra`, `html-to-markdown/v2`, and `golang.org/x/term` (hidden PAT input for `auth login`). HTTP via `net/http`, JSON via `encoding/json`, config via `encoding/json`. go-licenses, which generates the license notices at release time, is a build tool and not a dependency. Don't pull in `viper`, `resty`, retry libraries, or YAML/TOML parsers without a strong reason and explicit discussion. ## Command shape @@ -38,7 +38,7 @@ These come from `project-brief.md` and override casual feature requests. If a ch tfscli [flags] [arguments] ``` -The area and the resource are the segments of the operation's page path in the REST API reference (`.../wit/work-items/get-work-item` is `wit work-items get`); the action is the operation name without the resource name. `tfscli auth login` is a local command and lies outside this scheme. The full rule is in "Follow TFS API structure" in `project-brief.md`. +The area and the resource are the segments of the operation's page path in the REST API reference (`.../wit/work-items/get-work-item` is `wit work-items get`); the action is the operation name without the resource name. `tfscli auth login` and `tfscli licenses` are local commands and lie outside this scheme. The full rule is in "Follow TFS API structure" in `project-brief.md`. Commands (from the brief): @@ -54,7 +54,7 @@ tfscli wit wiql query-by-wiql -p MyProject --query "SELECT [System.Id] FROM Work ## Build / test commands -Everything the project verifies runs through the `Makefile` — `build`, `lint`, `test`, `check`, `clean` — and CI runs `make check` verbatim, so a green `make check` locally is the whole gate. A single test is `go test -run TestName ./path/to/pkg`. +Everything the project verifies runs through the `Makefile` — `build`, `lint`, `test`, `licenses`, `check`, `clean` — and CI runs `make check` verbatim, so a green `make check` locally is the whole gate. A single test is `go test -run TestName ./path/to/pkg`. Recipes never assume a Unix shell: file removal goes through `git clean`, and the coverage check and the changelog handling live in Go programs under `tools/`, tagged `//go:build ignore` so that `go build ./...`, `go vet ./...`, and `go test ./...` do not see them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ce57cc..4cdc248 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,9 @@ The developer handbook for this repository. `README.md` documents the tool for i | `make build` | `go vet`, `go test`, then builds the binary into the repository root | While working on the code | | `make lint` | `go mod tidy -diff`, which fails if `go.mod` or `go.sum` drifted from the imports, and `golangci-lint run` | Before committing | | `make test` | Measures coverage of `internal/...` and fails below 85% | While working on tests | -| `make check` | `build`, `lint`, and `test` | Before pushing — CI runs exactly this | -| `make clean` | Removes the binary, `coverage.out`, and `dist/` through `git clean`, touching nothing else | Any time | +| `make licenses` | `go-licenses check`, which fails if a dependency linked into the binary is under a license other than MIT, BSD-3-Clause, or Apache-2.0 | Before committing a dependency change | +| `make check` | `build`, `lint`, `test`, and `licenses` | Before pushing — CI runs exactly this | +| `make clean` | Removes the binary, `coverage.out`, `dist/`, and the license sets a snapshot generates, through `git clean`, touching nothing else | Any time | `make check` is the whole gate: `.github/workflows/ci.yml` installs the linter, runs `make check`, and does nothing else, so a green check locally is a green CI by @@ -41,9 +42,11 @@ GOOS=windows GOARCH=amd64 go build ./cmd/tfscli time it was cut; `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2` builds it with the Go you have. CI pins v2.13 against Go 1.26. -Every other target needs Go alone. [`goreleaser`](https://goreleaser.com) v2 is needed -only to preview a release locally (see "Releasing"); the release itself installs it in -the workflow. +Every other target needs Go alone; `make licenses` fetches go-licenses through +`go run`. [`goreleaser`](https://goreleaser.com) v2 is needed only to preview a release +locally (see "Releasing"); the release itself installs it in the workflow. The preview +installs go-licenses with `go install` and runs it by name, so Go's install directory — +`$(go env GOBIN)`, or `$(go env GOPATH)/bin` when that is unset — must be on `PATH`. ## Code rules @@ -206,8 +209,13 @@ What the job does, in order: `make check` on the commit it is about to tag; then `[Unreleased]` is left empty; then `tools/release-notes.go`, which reads that section back from the rewritten changelog into a file outside the repository and prints it to the log; then the commit `Release X.Y.Z`, the tag, and the push; then goreleaser, run directly by -its action rather than through the `Makefile`. The result is six archives — linux, -windows, darwin × amd64, arm64 — plus `checksums.txt`, attached to a draft release titled +its action rather than through the `Makefile`. goreleaser first installs go-licenses +v2.0.1, which it finds because `actions/setup-go` puts `$(go env GOPATH)/bin` on `PATH`; +each build then saves the licenses of its target's dependencies into +`internal/licenses/embed/-/third-party/`, which the binary embeds and +`tfscli licenses` prints. The result is six archives — linux, windows, darwin × amd64, +arm64 — each unpacking into a directory of its own name and holding the binary, +`LICENSE`, and `skills/`, plus `checksums.txt`, attached to a draft release titled `tfscli vX.Y.Z`. Its body is the extracted section, passed as `--release-notes`, followed by `release.footer` from `.goreleaser.yaml`. What goes into each archive is also `.goreleaser.yaml`; that the release is a draft is `draft: true` there. @@ -222,8 +230,10 @@ field, never into a flag. The body of the next release is the `[Unreleased]` section as it stands, followed by the footer; `go run tools/release-notes.go Unreleased` prints the section. To see what the archives would contain without making a release, run `goreleaser release --snapshot ---clean`, which builds them into `dist/` and publishes nothing. A snapshot renders no -release body: goreleaser skips the changelog step entirely for it. +--clean`, which builds them into `dist/` and publishes nothing. It also leaves the +generated license sets in `internal/licenses/embed/`, which git ignores and `make clean` +removes. A snapshot renders no release body: goreleaser skips the changelog step +entirely for it. ### When it goes wrong diff --git a/README.md b/README.md index 84e5209..c726c4d 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Cross-compilation follows the usual Go pattern: GOOS=windows GOARCH=amd64 go build -o tfscli.exe ./cmd/tfscli ``` -A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. +A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. Its `tfscli licenses` prints the Go license alone: the third-party license texts are generated and embedded by the release build as well. ## Authentication @@ -211,9 +211,9 @@ The full mapping is under "API and TFS version mapping" in the [REST API referen tfscli [flags] [arguments] ``` -Commands are named after the REST API reference: the area and the resource are the segments of the operation's page path, so Get Work Item, documented under `.../wit/work-items/get-work-item`, is `wit work-items get`. `auth login` is local to tfscli and has no counterpart in the API. +Commands are named after the REST API reference: the area and the resource are the segments of the operation's page path, so Get Work Item, documented under `.../wit/work-items/get-work-item`, is `wit work-items get`. `auth login` and `licenses` are local to tfscli and have no counterpart in the API. -`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. +`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. `tfscli licenses` prints the components built into the binary — the Go standard library and the third-party modules — followed by the license text of each. ### `wit work-items get` diff --git a/docs/architecture.md b/docs/architecture.md index 502aed5..8f58db0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,6 +110,26 @@ This document records the technology stack, the architecture, and the project st --- +### License notices: `github.com/google/go-licenses/v2` (build tool) + +**Rationale:** +- The binary links the Go standard library and the third-party modules statically, and their licenses (MIT, BSD-3-Clause, Apache-2.0) require the license text to accompany every binary copy. The texts are embedded in the binary and printed by `tfscli licenses`, so they travel with the binary even when it is taken out of the archive. GitHub CLI does the same (`gh licenses`). +- go-licenses finds the license file of each dependency, including one kept at the root of a repository above the module, classifies it, and saves it per package path. It runs at build time only: it is never linked into the binary and does not enter `go.mod`, so it does not count against the dependency budget above. +- The dependencies differ by platform (`inconshreveable/mousetrap` is linked on Windows only), so the set is generated per target, by a build pre-hook in `.goreleaser.yaml` with the target's `GOOS` and `GOARCH`, and each binary embeds the set of its own platform. The hook runs without a shell. go-licenses is installed by a `before` hook for the platform goreleaser runs on; `go run` in the build hook would build it for the target platform instead. +- go-licenses skips the standard library, so its license is a committed copy, `internal/licenses/go.LICENSE`, which a test keeps equal to the toolchain's. +- `make check` runs `go-licenses check` with the allowed licenses, so a dependency under another license fails the pull request that brings it. + +**Considered alternatives:** + +| Alternative | Why rejected | +|---|---| +| License files in the release archive | Satisfies the licenses only while the binary stays beside them; a binary copied out of the archive carries no notice. | +| One set for every platform | go-licenses cannot merge sets, so a union needs a merging program; a per-platform set needs none, and each binary carries exactly what it links. | +| A set committed to the repository | A committed copy has to be regenerated and checked for drift on every dependency change; generating it at release time cannot drift. | +| A hand-written collector over `go list -deps` and the module cache | Misses license files outside the module directory and cannot classify a license, which `go-licenses check` needs. | + +--- + ### Summary | Component | Choice | External dependency? | @@ -122,7 +142,7 @@ This document records the technology stack, the architecture, and the project st | JSON parsing | `encoding/json` | No (stdlib) | | Config format | JSON via `encoding/json` | No (stdlib) | -Total external dependencies: **3** (cobra, html-to-markdown, x/term). Minimal dependency footprint for a CLI tool. +Total external dependencies: **3** (cobra, html-to-markdown, x/term). Minimal dependency footprint for a CLI tool. go-licenses, which generates the license notices, is a build tool and not among them. --- @@ -181,6 +201,7 @@ This section describes the conceptual module structure, their responsibilities, - **htmlmd** — thin wrapper over `github.com/JohannesKaufmann/html-to-markdown/v2`. One method: `Convert(html string) (string, error)`. v1 uses library defaults only; the wrapper is the extension point where TFS-specific rules will be registered once real HTML samples have been collected (see the `html-quirks` entry in `docs/backlog.md`). - **tfserr** — typed error `Error{Category, Message, HTTPStatus, Cause}` with the stable category set from the brief. Provides `Print(err, w)` writing `Error [category]: message (HTTP status)` and `ExitCode(err)` mapping to a non-zero process exit code (v1: 1 for every category; the API leaves room for per-category exit codes later without breaking the contract). - **log** — `Logger` interface with two implementations: `noop` (default) and `stderr` (selected by `--verbose` / `TFSCLI_VERBOSE=1`). Methods: `LogRequest(method, url, status, dur)`, `Warn(msg)` for a condition worth reporting, and `Info(msg)` for what tfscli did on its own, such as sending a request without a version after a failed negotiation. The logger interface accepts only these four request fields — PAT and the `Authorization` header are never passed in, so they cannot leak through the logger by construction. +- **licenses** — the license texts of the code linked into the binary, for `tfscli licenses`. Always embeds `go.LICENSE`, the license of the Go standard library; on each release platform also embeds `embed/-/third-party/`, which the release build fills with go-licenses and git ignores. A committed `PLACEHOLDER` beside it keeps `go:embed` compiling in every other build, whose output then says that the third-party texts are only in the release builds. Depends on nothing else in the module. ### Key dependency interfaces @@ -334,7 +355,7 @@ Go module path: `github.com/dpleshakov/tfscli`. ### Layout principle -Standard Go CLI layout: a single binary entry point under `cmd/`, all implementation under `internal/`. Package boundaries are exactly the module boundaries from the Architecture section — one Go package per module (cli, config, apiclient, workitem, wiql, htmlmd, tfserr, log) — so the dependency rules stated there are visible in import lists and enforced by the compiler rather than by convention. Everything lives under `internal/` because tfscli is a CLI tool, not a library: a zero public API surface keeps full freedom to refactor between releases. +Standard Go CLI layout: a single binary entry point under `cmd/`, all implementation under `internal/`. Package boundaries are exactly the module boundaries from the Architecture section — one Go package per module (cli, config, apiclient, workitem, wiql, htmlmd, tfserr, log, licenses) — so the dependency rules stated there are visible in import lists and enforced by the compiler rather than by convention. Everything lives under `internal/` because tfscli is a CLI tool, not a library: a zero public API surface keeps full freedom to refactor between releases. ### Top-level directories diff --git a/docs/2026-10-06-tasks-third-party-licenses.md b/docs/archive/2026-10-06-tasks-third-party-licenses.md similarity index 99% rename from docs/2026-10-06-tasks-third-party-licenses.md rename to docs/archive/2026-10-06-tasks-third-party-licenses.md index 4fab701..8f287a1 100644 --- a/docs/2026-10-06-tasks-third-party-licenses.md +++ b/docs/archive/2026-10-06-tasks-third-party-licenses.md @@ -1,6 +1,6 @@ # 2026-10-06-tasks-third-party-licenses.md -**Status:** Active +**Status:** Archived ## Context @@ -124,4 +124,4 @@ target, go-licenses in the release and the snapshot, `$(go env GOPATH)/bin` on `CLAUDE.md` where it summarises any of these. **Definition of done:** The documents describe the new state; `make check` is green. -**Status:** Pending +**Status:** Done diff --git a/skills/tfscli/SKILL.md b/skills/tfscli/SKILL.md index a8af938..6c875d8 100644 --- a/skills/tfscli/SKILL.md +++ b/skills/tfscli/SKILL.md @@ -37,9 +37,11 @@ name. Output is markdown on stdout; errors are one line on stderr. ## When not to use it -Apart from `auth login`, which is the user's to run, the tool covers four -commands: `wit work-items get`, `wit work-items list`, -`wit work-items get-batch`, and `wit wiql query-by-wiql`. Do not attempt anything below; none of it exists, +Apart from `auth login`, which is the user's to run, and `licenses`, which +prints the license texts of the code built into the binary and reads nothing +from the server, the tool covers four commands: `wit work-items get`, +`wit work-items list`, `wit work-items get-batch`, and +`wit wiql query-by-wiql`. Do not attempt anything below; none of it exists, and inventing a flag or a subcommand produces an error, not a result. - **No writes.** Nothing creates, updates, or comments on a work item. If the From 829e2022c15e2815028e71f095689b7da697bf2b Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:44:03 +0300 Subject: [PATCH 7/8] Include tfscli's own license in tfscli licenses The project owes no notice to itself, but whoever passes the binary on owes one for tfscli, and now finds it in the binary like every other notice. The license is a committed copy, which a test keeps equal to LICENSE, rather than part of the generated set: every build carries it, and it is not listed among the third-party modules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- CHANGELOG.md | 2 +- README.md | 4 +-- docs/architecture.md | 3 +- internal/cli/licenses.go | 6 ++-- internal/cli/licenses_test.go | 2 +- internal/licenses/doc.go | 7 +++-- internal/licenses/licenses.go | 48 ++++++++++++++++++++++-------- internal/licenses/licenses_test.go | 47 ++++++++++++++++++++++------- internal/licenses/tfscli.LICENSE | 21 +++++++++++++ 9 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 internal/licenses/tfscli.LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md index d86aeb7..b0f2024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `tfscli licenses` prints the components included in the binary and the license text of each: the Go standard library and, in the release builds, the third-party modules. +- `tfscli licenses` prints the components included in the binary and the license text of each: tfscli itself, the Go standard library, and, in the release builds, the third-party modules. ### Changed - Release archives now unpack into a directory of their own name, `tfscli---/`, holding only the binary, `LICENSE`, and the agent skill; `README.md`, `CHANGELOG.md`, and `config.example.json` are no longer included. diff --git a/README.md b/README.md index c726c4d..0c92fe4 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Cross-compilation follows the usual Go pattern: GOOS=windows GOARCH=amd64 go build -o tfscli.exe ./cmd/tfscli ``` -A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. Its `tfscli licenses` prints the Go license alone: the third-party license texts are generated and embedded by the release build as well. +A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. Its `tfscli licenses` prints the licenses of tfscli and Go alone: the third-party license texts are generated and embedded by the release build as well. ## Authentication @@ -213,7 +213,7 @@ tfscli [flags] [arguments] Commands are named after the REST API reference: the area and the resource are the segments of the operation's page path, so Get Work Item, documented under `.../wit/work-items/get-work-item`, is `wit work-items get`. `auth login` and `licenses` are local to tfscli and have no counterpart in the API. -`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. `tfscli licenses` prints the components built into the binary — the Go standard library and the third-party modules — followed by the license text of each. +`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. `tfscli licenses` prints the components built into the binary — tfscli itself, the Go standard library, and the third-party modules — followed by the license text of each. ### `wit work-items get` diff --git a/docs/architecture.md b/docs/architecture.md index 8f58db0..ea1878c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,6 +117,7 @@ This document records the technology stack, the architecture, and the project st - go-licenses finds the license file of each dependency, including one kept at the root of a repository above the module, classifies it, and saves it per package path. It runs at build time only: it is never linked into the binary and does not enter `go.mod`, so it does not count against the dependency budget above. - The dependencies differ by platform (`inconshreveable/mousetrap` is linked on Windows only), so the set is generated per target, by a build pre-hook in `.goreleaser.yaml` with the target's `GOOS` and `GOARCH`, and each binary embeds the set of its own platform. The hook runs without a shell. go-licenses is installed by a `before` hook for the platform goreleaser runs on; `go run` in the build hook would build it for the target platform instead. - go-licenses skips the standard library, so its license is a committed copy, `internal/licenses/go.LICENSE`, which a test keeps equal to the toolchain's. +- tfscli's own license is embedded too, as `internal/licenses/tfscli.LICENSE`, which a test keeps equal to `LICENSE`. The project owes no notice to itself, but whoever passes the binary on owes one, and finds it in the binary. It is a committed copy rather than part of the generated set, so that every build carries it and it is not listed among the third-party modules; the generated set excludes tfscli with `--ignore`. - `make check` runs `go-licenses check` with the allowed licenses, so a dependency under another license fails the pull request that brings it. **Considered alternatives:** @@ -201,7 +202,7 @@ This section describes the conceptual module structure, their responsibilities, - **htmlmd** — thin wrapper over `github.com/JohannesKaufmann/html-to-markdown/v2`. One method: `Convert(html string) (string, error)`. v1 uses library defaults only; the wrapper is the extension point where TFS-specific rules will be registered once real HTML samples have been collected (see the `html-quirks` entry in `docs/backlog.md`). - **tfserr** — typed error `Error{Category, Message, HTTPStatus, Cause}` with the stable category set from the brief. Provides `Print(err, w)` writing `Error [category]: message (HTTP status)` and `ExitCode(err)` mapping to a non-zero process exit code (v1: 1 for every category; the API leaves room for per-category exit codes later without breaking the contract). - **log** — `Logger` interface with two implementations: `noop` (default) and `stderr` (selected by `--verbose` / `TFSCLI_VERBOSE=1`). Methods: `LogRequest(method, url, status, dur)`, `Warn(msg)` for a condition worth reporting, and `Info(msg)` for what tfscli did on its own, such as sending a request without a version after a failed negotiation. The logger interface accepts only these four request fields — PAT and the `Authorization` header are never passed in, so they cannot leak through the logger by construction. -- **licenses** — the license texts of the code linked into the binary, for `tfscli licenses`. Always embeds `go.LICENSE`, the license of the Go standard library; on each release platform also embeds `embed/-/third-party/`, which the release build fills with go-licenses and git ignores. A committed `PLACEHOLDER` beside it keeps `go:embed` compiling in every other build, whose output then says that the third-party texts are only in the release builds. Depends on nothing else in the module. +- **licenses** — the license texts of the code linked into the binary, for `tfscli licenses`. Always embeds `tfscli.LICENSE`, a copy of the project's `LICENSE`, and `go.LICENSE`, the license of the Go standard library; on each release platform also embeds `embed/-/third-party/`, which the release build fills with go-licenses and git ignores. A committed `PLACEHOLDER` beside it keeps `go:embed` compiling in every other build, whose output then says that the third-party texts are only in the release builds. Depends on nothing else in the module. ### Key dependency interfaces diff --git a/internal/cli/licenses.go b/internal/cli/licenses.go index 12e7601..bbbb712 100644 --- a/internal/cli/licenses.go +++ b/internal/cli/licenses.go @@ -14,9 +14,9 @@ func newLicensesCmd(g *globals) *cobra.Command { return &cobra.Command{ Use: "licenses", Short: "Print the licenses of the code included in tfscli", - Long: "Print the components included in this binary — the Go standard library\n" + - "and the third-party modules — followed by the license text of each. The\n" + - "third-party texts are embedded only in the release builds.", + Long: "Print the components included in this binary — tfscli itself, the Go\n" + + "standard library, and the third-party modules — followed by the license text\n" + + "of each. The third-party texts are embedded only in the release builds.", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { _, err := io.WriteString(g.stdout, licenses.Text()) diff --git a/internal/cli/licenses_test.go b/internal/cli/licenses_test.go index 9fb6818..8fb2395 100644 --- a/internal/cli/licenses_test.go +++ b/internal/cli/licenses_test.go @@ -37,7 +37,7 @@ func TestLicensesNeedsNoSettings(t *testing.T) { if code != 0 { t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String()) } - if !strings.HasPrefix(out.String(), "tfscli includes the following components:") { + if !strings.HasPrefix(out.String(), "The tfscli binary contains the following components:") { t.Errorf("stdout = %q, want the licenses", out.String()) } } diff --git a/internal/licenses/doc.go b/internal/licenses/doc.go index 694ba6b..1b688d4 100644 --- a/internal/licenses/doc.go +++ b/internal/licenses/doc.go @@ -1,5 +1,6 @@ // Package licenses holds the license texts of the code linked into the -// binary: the Go standard library, always, and the third-party modules of -// the platform the binary was built for, which the release build generates -// with go-licenses and embeds. Text assembles them for "tfscli licenses". +// binary: tfscli itself and the Go standard library, always, and the +// third-party modules of the platform the binary was built for, which the +// release build generates with go-licenses and embeds. Text assembles them +// for "tfscli licenses". package licenses diff --git a/internal/licenses/licenses.go b/internal/licenses/licenses.go index d5d82f6..d98ed58 100644 --- a/internal/licenses/licenses.go +++ b/internal/licenses/licenses.go @@ -1,22 +1,25 @@ package licenses import ( - _ "embed" // for go:embed of the Go license + _ "embed" // for go:embed of the tfscli and Go licenses "io/fs" "path" "sort" "strings" ) +// ownLicense is the license of tfscli itself, a copy of LICENSE at the root of +// the repository. +// +//go:embed tfscli.LICENSE +var ownLicense string + // goLicense is the license of the Go standard library and runtime, a copy of // LICENSE from the Go distribution. // //go:embed go.LICENSE var goLicense string -// goComponent names the Go standard library and runtime in the output. -const goComponent = "Go standard library and runtime" - // separator frames the name of each component above its license texts. var separator = strings.Repeat("=", 80) @@ -27,7 +30,22 @@ func Text() string { if err != nil { set = nil } - return text(goLicense, set) + return text(embedded(), set) +} + +// embedded lists the components whose licenses every build carries, in the +// order they are printed. +func embedded() []license { + return []license{ + {name: "tfscli", text: ownLicense}, + {name: "Go standard library and runtime", text: goLicense}, + } +} + +// license is a component whose license text is embedded in every build. +type license struct { + name string + text string } // component is one entry of the output: a name and its license files. @@ -36,16 +54,18 @@ type component struct { files []string } -// text assembles the output from the Go license and set, the third-party -// licenses saved by go-licenses: one directory per package path, holding the -// license files of its module. A nil or empty set means a build the release -// did not make. -func text(goLicense string, set fs.FS) string { +// text assembles the output from fixed, the components every build carries, +// and set, the third-party licenses saved by go-licenses: one directory per +// package path, holding the license files of its module. A nil or empty set +// means a build the release did not make. +func text(fixed []license, set fs.FS) string { third := thirdParty(set) var b strings.Builder - b.WriteString("tfscli includes the following components:\n\n") - b.WriteString(" " + goComponent + "\n") + b.WriteString("The tfscli binary contains the following components:\n\n") + for _, l := range fixed { + b.WriteString(" " + l.name + "\n") + } for _, c := range third { b.WriteString(" " + c.name + "\n") } @@ -55,7 +75,9 @@ func text(goLicense string, set fs.FS) string { "https://github.com/dpleshakov/tfscli/releases.\n") } - writeComponent(&b, goComponent, goLicense) + for _, l := range fixed { + writeComponent(&b, l.name, l.text) + } for _, c := range third { var texts []string for _, f := range c.files { diff --git a/internal/licenses/licenses_test.go b/internal/licenses/licenses_test.go index a8a5d7c..b274398 100644 --- a/internal/licenses/licenses_test.go +++ b/internal/licenses/licenses_test.go @@ -10,6 +10,12 @@ import ( "testing/fstest" ) +// fixed stands in for the licenses every build carries. +var fixed = []license{ + {name: "tfscli", text: "OWN LICENSE\n"}, + {name: "Go standard library and runtime", text: "GO LICENSE\n"}, +} + func TestTextWithSet(t *testing.T) { set := fstest.MapFS{ "github.com/spf13/cobra/LICENSE.txt": {Data: []byte("Apache License\n")}, @@ -21,9 +27,10 @@ func TestTextWithSet(t *testing.T) { "github.com/JohannesKaufmann/dom/LICENSE.more": {Data: []byte("More\n")}, } - got := text("GO LICENSE\n", set) + got := text(fixed, set) - wantList := "tfscli includes the following components:\n\n" + + wantList := "The tfscli binary contains the following components:\n\n" + + " tfscli\n" + " Go standard library and runtime\n" + " github.com/JohannesKaufmann/dom\n" + " github.com/JohannesKaufmann/dom/sub\n" + @@ -41,6 +48,7 @@ func TestTextWithSet(t *testing.T) { return "\n" + separator + "\n" + name + "\n" + separator + "\n\n" + body } for _, want := range []string{ + framed("tfscli", "OWN LICENSE\n"), framed("Go standard library and runtime", "GO LICENSE\n"), framed("github.com/spf13/cobra", "Apache License\n"), framed("github.com/example/notice", "MIT License\n\nNotice text\n"), @@ -50,9 +58,11 @@ func TestTextWithSet(t *testing.T) { t.Errorf("text() lacks %q:\n%s", want, got) } } - if strings.Index(got, framed("Go standard library and runtime", "")) > - strings.Index(got, framed("github.com/JohannesKaufmann/dom", "")) { - t.Errorf("text() does not put the Go license first:\n%s", got) + own := strings.Index(got, framed("tfscli", "")) + goLic := strings.Index(got, framed("Go standard library and runtime", "")) + dom := strings.Index(got, framed("github.com/JohannesKaufmann/dom", "")) + if own > goLic || goLic > dom { + t.Errorf("text() does not put tfscli, then Go, before the third-party licenses:\n%s", got) } } @@ -63,12 +73,15 @@ func TestTextWithoutSet(t *testing.T) { "missing": sub(t, fstest.MapFS{"PLACEHOLDER": {}}, "third-party"), } { t.Run(name, func(t *testing.T) { - got := text("GO LICENSE\n", set) - want := "tfscli includes the following components:\n\n" + + got := text(fixed, set) + want := "The tfscli binary contains the following components:\n\n" + + " tfscli\n" + " Go standard library and runtime\n\n" + "This build does not carry the licenses of the third-party modules it\n" + "includes: they are embedded only in the release builds, available at\n" + "https://github.com/dpleshakov/tfscli/releases.\n" + + "\n" + separator + "\ntfscli\n" + separator + "\n\n" + + "OWN LICENSE\n" + "\n" + separator + "\nGo standard library and runtime\n" + separator + "\n\n" + "GO LICENSE\n" if got != want { @@ -94,11 +107,13 @@ func sub(t *testing.T, fsys fs.FS, dir string) fs.FS { // leaves one behind until make clean, so the test holds either way. func TestTextOfThisBuild(t *testing.T) { got := Text() - if want := text(goLicense, sub(t, platformFS, platformDir+"/third-party")); got != want { + if want := text(embedded(), sub(t, platformFS, platformDir+"/third-party")); got != want { t.Errorf("Text() =\n%s\nwant\n%s", got, want) } - if !strings.Contains(got, goLicense) { - t.Errorf("Text() lacks the Go license:\n%s", got) + for name, lic := range map[string]string{"tfscli": ownLicense, "Go": goLicense} { + if !strings.Contains(got, lic) { + t.Errorf("Text() lacks the %s license:\n%s", name, got) + } } } @@ -118,3 +133,15 @@ func TestGoLicenseMatchesToolchain(t *testing.T) { "copy $(go env GOROOT)/LICENSE over it") } } + +// TestOwnLicenseMatchesTheRepository keeps the embedded copy of the tfscli +// license equal to LICENSE at the root of the repository. +func TestOwnLicenseMatchesTheRepository(t *testing.T) { + want, err := os.ReadFile(filepath.Join("..", "..", "LICENSE")) + if err != nil { + t.Fatalf("reading LICENSE: %v", err) + } + if ownLicense != string(want) { + t.Errorf("internal/licenses/tfscli.LICENSE differs from LICENSE; copy LICENSE over it") + } +} diff --git a/internal/licenses/tfscli.LICENSE b/internal/licenses/tfscli.LICENSE new file mode 100644 index 0000000..f7c66ad --- /dev/null +++ b/internal/licenses/tfscli.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dmitry Pleshakov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 41a3b916eef9cf8e246d22b0e3d33c0f8f4d9405 Mon Sep 17 00:00:00 2001 From: Dmitry Pleshakov Date: Tue, 6 Oct 2026 09:51:42 +0300 Subject: [PATCH 8/8] Check licenses on every platform and in the release binaries make licenses checked only the platform it ran on, so a dependency linked on Windows or macOS alone could bring a license outside the allowed list unnoticed. tools/check-licenses.go now runs go-licenses check for each of the six release platforms. It installs go-licenses for the platform it runs on first: go run with a target's GOOS would build the tool for that target, and setting GOOS in a make recipe would need a Unix shell. Nothing verified that a release binary actually carries its licenses: a build hook that generated nothing would have shipped binaries saying the texts exist only in release builds. The release workflow now builds a snapshot before it pushes, checks that every platform's set holds spf13/cobra, which every platform links, and that the linux/amd64 binary prints it, so such a failure stops the release before the tag. The real release builds the same commit with the same tools and modules. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015w3YJNX9AzYUZq25WyQFDp --- .github/workflows/release.yml | 24 ++++++++ CLAUDE.md | 2 +- CONTRIBUTING.md | 24 ++++---- Makefile | 12 ++-- docs/architecture.md | 3 +- tools/check-licenses.go | 106 ++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 tools/check-licenses.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d10fcf0..13afe1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,30 @@ jobs: go run tools/release-notes.go "$VERSION" CHANGELOG.md "$RUNNER_TEMP/release-notes.md" cat "$RUNNER_TEMP/release-notes.md" + # Build every binary once before anything is pushed, so that the license + # check below runs while a failure still leaves the repository untouched. + # The third-party licenses are generated by a build hook, and nothing + # else would notice a hook that left a binary without them. A snapshot + # publishes nothing; the release below builds the same commit with the + # same pinned go-licenses from the same immutable modules. + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: build --snapshot --clean + + # spf13/cobra is linked on every platform, so a set without it is a set + # the hook failed to generate. The linux/amd64 binary is the one this + # runner can execute; the others embed their sets the same way. + - name: Check the embedded licenses + run: | + for dir in internal/licenses/embed/*/; do + if [ ! -d "${dir}third-party/github.com/spf13/cobra" ]; then + echo "no third-party licenses generated in $dir" >&2 + exit 1 + fi + done + dist/tfscli_linux_amd64*/tfscli licenses | grep -qx ' github.com/spf13/cobra' + # A push made with GITHUB_TOKEN does not start another workflow, so the # release commit will not set off a redundant CI run. - name: Commit, tag, and push diff --git a/CLAUDE.md b/CLAUDE.md index 19ac1ad..3157e46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ tfscli wit wiql query-by-wiql -p MyProject --query "SELECT [System.Id] FROM Work Everything the project verifies runs through the `Makefile` — `build`, `lint`, `test`, `licenses`, `check`, `clean` — and CI runs `make check` verbatim, so a green `make check` locally is the whole gate. A single test is `go test -run TestName ./path/to/pkg`. -Recipes never assume a Unix shell: file removal goes through `git clean`, and the coverage check and the changelog handling live in Go programs under `tools/`, tagged `//go:build ignore` so that `go build ./...`, `go vet ./...`, and `go test ./...` do not see them. +Recipes never assume a Unix shell: file removal goes through `git clean`, and the coverage check, the license check, and the changelog handling live in Go programs under `tools/`, tagged `//go:build ignore` so that `go build ./...`, `go vet ./...`, and `go test ./...` do not see them. A release is made by running the `Release` workflow from the Actions tab with a version number; it verifies, rewrites `CHANGELOG.md`, commits, tags, pushes, and builds a draft release with goreleaser, which is run by the workflow and not through the `Makefile`. Nothing about a release is done by hand except pressing Publish. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cdc248..ccb487f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ The developer handbook for this repository. `README.md` documents the tool for i | `make build` | `go vet`, `go test`, then builds the binary into the repository root | While working on the code | | `make lint` | `go mod tidy -diff`, which fails if `go.mod` or `go.sum` drifted from the imports, and `golangci-lint run` | Before committing | | `make test` | Measures coverage of `internal/...` and fails below 85% | While working on tests | -| `make licenses` | `go-licenses check`, which fails if a dependency linked into the binary is under a license other than MIT, BSD-3-Clause, or Apache-2.0 | Before committing a dependency change | +| `make licenses` | `go-licenses check` for each of the six release platforms (`tools/check-licenses.go`), which fails if a dependency linked into the binary is under a license other than MIT, BSD-3-Clause, or Apache-2.0 | Before committing a dependency change | | `make check` | `build`, `lint`, `test`, and `licenses` | Before pushing — CI runs exactly this | | `make clean` | Removes the binary, `coverage.out`, `dist/`, and the license sets a snapshot generates, through `git clean`, touching nothing else | Any time | @@ -42,10 +42,10 @@ GOOS=windows GOARCH=amd64 go build ./cmd/tfscli time it was cut; `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2` builds it with the Go you have. CI pins v2.13 against Go 1.26. -Every other target needs Go alone; `make licenses` fetches go-licenses through -`go run`. [`goreleaser`](https://goreleaser.com) v2 is needed only to preview a release -locally (see "Releasing"); the release itself installs it in the workflow. The preview -installs go-licenses with `go install` and runs it by name, so Go's install directory — +Every other target needs Go alone; `make licenses` installs go-licenses into a +temporary directory itself. [`goreleaser`](https://goreleaser.com) v2 is needed only to +preview a release locally (see "Releasing"); the release itself installs it in the +workflow. The preview installs go-licenses with `go install` and runs it by name, so Go's install directory — `$(go env GOBIN)`, or `$(go env GOPATH)/bin` when that is unset — must be on `PATH`. ## Code rules @@ -208,10 +208,13 @@ What the job does, in order: `make check` on the commit it is about to tag; then `## [Unreleased]`, so that the unreleased entries become the new version and `[Unreleased]` is left empty; then `tools/release-notes.go`, which reads that section back from the rewritten changelog into a file outside the repository and prints it to the log; -then the commit `Release X.Y.Z`, the tag, and the push; then goreleaser, run directly by -its action rather than through the `Makefile`. goreleaser first installs go-licenses -v2.0.1, which it finds because `actions/setup-go` puts `$(go env GOPATH)/bin` on `PATH`; -each build then saves the licenses of its target's dependencies into +then `goreleaser build --snapshot`, which builds the six binaries and publishes nothing, +and a check that each platform's license set was generated and that the linux/amd64 +binary prints it; then the commit `Release X.Y.Z`, the tag, and the push; then +goreleaser, run directly by its action rather than through the `Makefile`. Both +goreleaser runs first install go-licenses v2.0.1, which they find because +`actions/setup-go` puts `$(go env GOPATH)/bin` on `PATH`; each build then saves the +licenses of its target's dependencies into `internal/licenses/embed/-/third-party/`, which the binary embeds and `tfscli licenses` prints. The result is six archives — linux, windows, darwin × amd64, arm64 — each unpacking into a directory of its own name and holding the binary, @@ -242,7 +245,8 @@ repository exactly as it was — fix it through a pull request and run the workf - `make check` is red on the commit being released; - the version is not of the form `X.Y.Z`, or the changelog already has a section for it; -- `[Unreleased]` has no entries, so there is nothing to release. +- `[Unreleased]` has no entries, so there is nothing to release; +- the snapshot build fails, or a binary would ship without its third-party licenses. After the push the failure to expect is a release body that is wrong rather than unusable — the wrong entries under `[Unreleased]`, say. The release is a draft, so delete diff --git a/Makefile b/Makefile index 52b8c49..65e6cba 100644 --- a/Makefile +++ b/Makefile @@ -44,13 +44,13 @@ test: go tool cover -func=coverage.out go run tools/check-coverage.go 85 -# Fail when a dependency linked into the binary is under a license not on the -# list. Every license here allows a binary distribution that carries its text, -# which tfscli licenses does; a new one is a decision, not an edit of the list. -# `go run` with a version needs nothing installed and leaves go.mod alone. The -# check follows the dependencies of the platform it runs on. +# Fail when a dependency linked into the binary on any release platform is +# under a license outside the allowed list, which tools/check-licenses.go +# holds. It runs go-licenses once per platform, since the dependencies differ +# by platform, and installs it in a temporary directory, so nothing is needed +# beforehand and go.mod is left alone. licenses: - go run github.com/google/go-licenses/v2@v2.0.1 check ./cmd/tfscli --allowed_licenses=MIT,BSD-3-Clause,Apache-2.0 + go run tools/check-licenses.go # Everything the project verifies, in one target. This is what CI runs. check: build lint test licenses diff --git a/docs/architecture.md b/docs/architecture.md index ea1878c..0db7ab5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,7 +118,8 @@ This document records the technology stack, the architecture, and the project st - The dependencies differ by platform (`inconshreveable/mousetrap` is linked on Windows only), so the set is generated per target, by a build pre-hook in `.goreleaser.yaml` with the target's `GOOS` and `GOARCH`, and each binary embeds the set of its own platform. The hook runs without a shell. go-licenses is installed by a `before` hook for the platform goreleaser runs on; `go run` in the build hook would build it for the target platform instead. - go-licenses skips the standard library, so its license is a committed copy, `internal/licenses/go.LICENSE`, which a test keeps equal to the toolchain's. - tfscli's own license is embedded too, as `internal/licenses/tfscli.LICENSE`, which a test keeps equal to `LICENSE`. The project owes no notice to itself, but whoever passes the binary on owes one, and finds it in the binary. It is a committed copy rather than part of the generated set, so that every build carries it and it is not listed among the third-party modules; the generated set excludes tfscli with `--ignore`. -- `make check` runs `go-licenses check` with the allowed licenses, so a dependency under another license fails the pull request that brings it. +- `make check` runs `go-licenses check` with the allowed licenses for each release platform, through `tools/check-licenses.go`, so a dependency under another license fails the pull request that brings it, whichever platform links it. The program installs go-licenses for the platform it runs on before setting each target's `GOOS` and `GOARCH`, for the same reason as the `before` hook. +- The release workflow builds a snapshot before it pushes anything and checks that every platform's set was generated and that the linux/amd64 binary prints it, so that a hook that left a binary without its licenses stops the release before the tag. **Considered alternatives:** diff --git a/tools/check-licenses.go b/tools/check-licenses.go new file mode 100644 index 0000000..a6d4de9 --- /dev/null +++ b/tools/check-licenses.go @@ -0,0 +1,106 @@ +//go:build ignore + +// Command check-licenses fails the build when a dependency linked into tfscli +// on any release platform is under a license outside the allowed list. It runs +// `go-licenses check` once per platform, because the dependencies differ by +// platform: inconshreveable/mousetrap is linked on Windows only. +// +// go-licenses is installed into a temporary directory for the platform this +// program runs on, and only then run with each platform's GOOS and GOARCH. +// `go run` with those variables set would build go-licenses for the target +// platform, which this one cannot execute; setting them in a make recipe +// would need a Unix shell. +// +// Usage: +// +// go run tools/check-licenses.go +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// goLicenses is the go-licenses release used here; .goreleaser.yaml installs +// the same one to generate the license sets, and the two change together. +const goLicenses = "github.com/google/go-licenses/v2@v2.0.1" + +// allowed lists the licenses a dependency may be under. Each allows a binary +// distribution that carries its text, which tfscli licenses does; adding one +// is a decision, not an edit of the list. +const allowed = "MIT,BSD-3-Clause,Apache-2.0" + +// platforms are the release targets, as builds in .goreleaser.yaml lists them. +var platforms = []string{ + "linux/amd64", "linux/arm64", + "windows/amd64", "windows/arm64", + "darwin/amd64", "darwin/arm64", +} + +func main() { + os.Exit(run()) +} + +func run() int { + bin, err := os.MkdirTemp("", "check-licenses") + if err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: %v\n", err) + return 1 + } + defer os.RemoveAll(bin) + + install := exec.Command("go", "install", goLicenses) + install.Env = withEnv("GOBIN="+bin, "GOOS=", "GOARCH=") + if out, err := install.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: go install %s: %v\n%s", goLicenses, err, out) + return 1 + } + tool := filepath.Join(bin, "go-licenses") + if runtime.GOOS == "windows" { + tool += ".exe" + } + + failed := 0 + for _, p := range platforms { + goos, goarch, _ := strings.Cut(p, "/") + check := exec.Command(tool, "check", "./cmd/tfscli", "--allowed_licenses="+allowed) + check.Env = withEnv("GOOS="+goos, "GOARCH="+goarch) + // go-licenses logs warnings about assembly files it cannot inspect on + // every run; they are shown only when the check fails. + if out, err := check.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: %s: %v\n%s", p, err, out) + failed++ + continue + } + fmt.Printf("check-licenses: %s: every license is allowed\n", p) + } + if failed > 0 { + fmt.Fprintf(os.Stderr, "check-licenses: the check failed on %d of %d platforms; allowed: %s\n", failed, len(platforms), allowed) + return 1 + } + return 0 +} + +// withEnv returns the environment of this process with the given NAME=value +// pairs replacing any variables of the same names. +func withEnv(pairs ...string) []string { + env := make([]string, 0, len(os.Environ())+len(pairs)) + for _, kv := range os.Environ() { + name, _, _ := strings.Cut(kv, "=") + replaced := false + for _, p := range pairs { + if pn, _, _ := strings.Cut(p, "="); strings.EqualFold(pn, name) { + replaced = true + break + } + } + if !replaced { + env = append(env, kv) + } + } + return append(env, pairs...) +}