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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
/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
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
Expand Down
30 changes: 27 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -27,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.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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-<version>-<os>-<arch>/`, 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`.

Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand All @@ -30,15 +30,15 @@ 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

```
tfscli <area> <resource> <action> [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):

Expand All @@ -54,9 +54,9 @@ 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.
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.

Expand Down
36 changes: 25 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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 |

`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
Expand Down Expand Up @@ -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` 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

Expand Down Expand Up @@ -205,9 +208,17 @@ 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`. The result is six archives — linux,
windows, darwin × amd64, arm64 — plus `checksums.txt`, attached to a draft release titled
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/<os>-<arch>/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.
Expand All @@ -222,8 +233,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

Expand All @@ -232,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
Expand Down
23 changes: 16 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,17 +44,26 @@ test:
go tool cover -func=coverage.out
go run tools/check-coverage.go 85

# 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 tools/check-licenses.go

# Everything the project verifies, in one target. This is what CI runs.
check: build lint test
check: build lint test licenses

# ---------------------------------------------------------------------------
# Clean
# ---------------------------------------------------------------------------

# 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
Loading
Loading