diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a553b..2290cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -366,6 +366,14 @@ field out. prints `TLS 1.0 ! Supported (Deprecated)` and `legend: ✓ met, ◐ partial, ○ in progress, ✗ not met, - not yet required`. The words and colours are unchanged. +- **The README's quick start starts with the release binary.** It opened with + `git clone`, `go build` and `sudo mv`, which need Go 1.25+ and `sudo`, and + left the pre-built archives to a link. The first block now downloads the + latest release's archive for macOS on Apple silicon and its `checksums.txt` + with `curl`, unpacks the archive only after `shasum` reports it `OK`, and runs + `./tlsanalyzer version`. Building from source comes second and runs the + binary where it was built, without `sudo`. `docs/install.md` names the + archive for each platform. ## [0.4.1] - 2026-08-05 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd749b1..80aef5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,6 +172,15 @@ Releases are managed by maintainers: 3. Create annotated tag: `git tag -a v0.x.0 -m "Release v0.x.0"` 4. Push tag: `git push origin v0.x.0` 5. GitHub Actions builds and publishes release +6. Render the Homebrew formula from the release's published `checksums.txt`, and + commit the output to [csnp/homebrew-tap](https://github.com/csnp/homebrew-tap) + as `Formula/tlsanalyzer.rb`: + + ```bash + dir=$(mktemp -d) + curl -fsSL -o "$dir/checksums.txt" https://github.com/csnp/tls-analyzer/releases/download/v0.x.0/checksums.txt + go run ./packaging/brewformula -version 0.x.0 -checksums "$dir/checksums.txt" > "$dir/tlsanalyzer.rb" + ``` ## Getting Help diff --git a/README.md b/README.md index cefbecd..fa38927 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,27 @@ Part of the [QRAMM (Quantum Readiness Assurance Maturity Model)](https://qramm.o ## Quick Start -### Option 1: Build from Source +### Option 1: Download a binary -Requires Go 1.25+ ([install Go](https://go.dev/doc/install)), which is what `go.mod` declares +This needs no Go toolchain and no `sudo`. On macOS with Apple silicon, copy and +paste this block. It downloads the latest release's archive and its +`checksums.txt`, checks the archive against that file, and unpacks the +`tlsanalyzer` binary into the current directory: + +```bash +curl -fsSLO https://github.com/csnp/tls-analyzer/releases/latest/download/tlsanalyzer-darwin-arm64.tar.gz +curl -fsSLO https://github.com/csnp/tls-analyzer/releases/latest/download/checksums.txt +shasum -a 256 -c --ignore-missing checksums.txt && tar -xzf tlsanalyzer-darwin-arm64.tar.gz +./tlsanalyzer version +``` + +`tar` runs only when the check prints `tlsanalyzer-darwin-arm64.tar.gz: OK`. For +an Intel Mac, Linux or Windows, [docs/install.md](docs/install.md) names the +archive to download. + +### Option 2: Build from source + +Requires Go 1.25+ ([install Go](https://go.dev/doc/install)), which is what `go.mod` declares. Copy and paste this entire block: @@ -41,21 +59,16 @@ Copy and paste this entire block: git clone https://github.com/csnp/qramm-tls-analyzer.git cd qramm-tls-analyzer go build -o tlsanalyzer ./cmd/tlsanalyzer -sudo mv tlsanalyzer /usr/local/bin/ -cd .. -tlsanalyzer version +./tlsanalyzer version ``` -### Option 2: Download Binary - -Download pre-built binaries from [Releases](https://github.com/csnp/qramm-tls-analyzer/releases). -To check a download against the release's `checksums.txt`, follow [docs/install.md](docs/install.md). +### Run your first scan -### Run Your First Scan +From the directory that holds `tlsanalyzer`: ```bash # Scan a domain you own or have permission to test -tlsanalyzer yourdomain.com +./tlsanalyzer yourdomain.com ``` Expected output: Security grade, quantum risk score, CNSA 2.0 timeline. diff --git a/cmd/tlsanalyzer/readme_quick_start_test.go b/cmd/tlsanalyzer/readme_quick_start_test.go new file mode 100644 index 0000000..bd81e8d --- /dev/null +++ b/cmd/tlsanalyzer/readme_quick_start_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// TestReadmeQuickStartLeadsWithTheReleaseBinary keeps the README's first way +// to install the tool one that needs no Go toolchain and no sudo. +// +// The quick start opened with git clone, go build and sudo mv, so a first scan +// needed Go 1.25+ and sudo, while each release from 0.4.0 publishes archives +// for macOS, Linux and Windows and a checksums.txt to check them against. The +// first bash block of the quick start has to download a release archive and +// checksums.txt by names the release workflow publishes, and no command in the +// quick start may run sudo. +func TestReadmeQuickStartLeadsWithTheReleaseBinary(t *testing.T) { + path := filepath.Join("..", "..", "README.md") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + + type command struct { + line int // line number in README.md + block int // 0 for the quick start's first bash block + text string + } + var commands []command + inQuickStart, inBash, block := false, false, -1 + for i, line := range strings.Split(string(body), "\n") { + if line == "## Quick Start" { + inQuickStart = true + continue + } + if !inQuickStart { + continue + } + if strings.HasPrefix(line, "## ") { + break + } + fence := strings.TrimSpace(line) + switch { + case inBash && fence == "```": + inBash = false + case inBash: + commands = append(commands, command{i + 1, block, strings.TrimSpace(line)}) + case fence == "```bash": + inBash, block = true, block+1 + } + } + if len(commands) == 0 { + t.Fatalf("%s has no bash block under \"## Quick Start\"", path) + } + + published := releaseAssets(t) + download := regexp.MustCompile(`releases/latest/download/(\S+)`) + var downloads []string + for _, c := range commands { + if strings.HasPrefix(c.text, "sudo ") { + t.Errorf("%s:%d: the quick start runs sudo: %s", path, c.line, c.text) + } + if c.block != 0 { + continue + } + for _, tool := range []string{"git clone", "go build", "go install"} { + if strings.Contains(c.text, tool) { + t.Errorf("%s:%d: the quick start's first bash block runs %s, a step of building from source; "+ + "lead with the release binary, which needs no Go toolchain: %s", path, c.line, tool, c.text) + } + } + for _, m := range download.FindAllStringSubmatch(c.text, -1) { + downloads = append(downloads, m[1]) + if !published[m[1]] { + t.Errorf("%s:%d: downloads %s, which the release workflow does not publish", path, c.line, m[1]) + } + } + } + archive := false + checksums := false + for _, name := range downloads { + archive = archive || strings.HasPrefix(name, "tlsanalyzer-") + checksums = checksums || name == "checksums.txt" + } + if !archive || !checksums { + t.Errorf("%s: the quick start's first bash block downloads %q from the latest release; "+ + "want a release archive and checksums.txt", path, downloads) + } +} + +// releaseAssets returns the names .github/workflows/release.yml publishes: an +// archive per matrix suffix, a zip for Windows and a tar.gz for the others, as +// its "Create archive" step builds them, and checksums.txt. +func releaseAssets(t *testing.T) map[string]bool { + t.Helper() + path := filepath.Join("..", "..", ".github", "workflows", "release.yml") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + assets := map[string]bool{"checksums.txt": true} + for _, m := range regexp.MustCompile(`(?m)^\s*suffix:\s*(\S+)\s*$`).FindAllStringSubmatch(string(body), -1) { + if strings.HasPrefix(m[1], "windows-") { + assets["tlsanalyzer-"+m[1]+".zip"] = true + } else { + assets["tlsanalyzer-"+m[1]+".tar.gz"] = true + } + } + if len(assets) == 1 { + t.Fatalf("%s has no matrix suffix lines to name its archives by", path) + } + return assets +} diff --git a/docs/install.md b/docs/install.md index da1b3ff..2e9181f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,25 @@ # Installing QRAMM TLS Analyzer -The README's quick start builds the tool from source. This page covers the -other way in: a pre-built binary, and how to check it before you run it. +The README's quick start downloads the pre-built binary for macOS on Apple +silicon and checks it before unpacking it. This page names the archive for each +platform and explains the check. ## Download Binary -Download pre-built binaries from [Releases](https://github.com/csnp/qramm-tls-analyzer/releases). +Every release from 0.4.0 publishes one archive per platform: + +| Platform | Archive | +|---|---| +| macOS on Apple silicon | `tlsanalyzer-darwin-arm64.tar.gz` | +| macOS on Intel | `tlsanalyzer-darwin-amd64.tar.gz` | +| Linux on x86-64 | `tlsanalyzer-linux-amd64.tar.gz` | +| Linux on ARM64 | `tlsanalyzer-linux-arm64.tar.gz` | +| Windows on x86-64 | `tlsanalyzer-windows-amd64.zip` | + +The latest release's files download from +`https://github.com/csnp/tls-analyzer/releases/latest/download/` followed by the +file name, which is how the README's block fetches them. Every release is also +listed on [Releases](https://github.com/csnp/qramm-tls-analyzer/releases). Every release from 0.4.0 also publishes `checksums.txt`. Download it into the same directory as the archive and verify before extracting: diff --git a/packaging/brewformula/main.go b/packaging/brewformula/main.go new file mode 100644 index 0000000..0109ee9 --- /dev/null +++ b/packaging/brewformula/main.go @@ -0,0 +1,187 @@ +// Command brewformula prints the Homebrew formula for a tls-analyzer release, +// with each sha256 taken from that release's checksums.txt. +// +// The release workflow publishes checksums.txt beside the archives. Reading it +// here means the digest on each sha256 line is the one published for the +// archive on the url line above it, instead of one copied by hand. From the +// repository root: +// +// dir=$(mktemp -d) +// curl -fsSL -o "$dir/checksums.txt" https://github.com/csnp/tls-analyzer/releases/download/v0.4.1/checksums.txt +// go run ./packaging/brewformula -version 0.4.1 -checksums "$dir/checksums.txt" > "$dir/tlsanalyzer.rb" +// +// CONTRIBUTING.md says where the output goes. +// +// The formula installs the release binary for macOS and Linux, each on arm64 +// and amd64, so it needs no Go toolchain and runs no build. Homebrew does not +// run on Windows, so the Windows zip is not in it. +package main + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "os" + "regexp" + "strings" + "text/template" +) + +// archiveSuffixes names the archives the formula installs by the release +// workflow's matrix suffix. The workflow publishes each one as +// tlsanalyzer-.tar.gz with the tlsanalyzer binary at its top level. +var archiveSuffixes = []string{"darwin-arm64", "darwin-amd64", "linux-arm64", "linux-amd64"} + +// downloadURL is where a release's assets are published, up to the version. +const downloadURL = "https://github.com/csnp/tls-analyzer/releases/download/v" + +var ( + releaseVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + sha256Digest = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +func archiveName(suffix string) string { + return "tlsanalyzer-" + suffix + ".tar.gz" +} + +// release is what the formula template reads. +type release struct { + Version string + digests map[string]string // archive name to its sha256 digest +} + +// URL is the download address of the archive with the given suffix. +func (r release) URL(suffix string) string { + return downloadURL + r.Version + "/" + archiveName(suffix) +} + +// SHA256 is the digest checksums.txt gives the archive with the given suffix. +func (r release) SHA256(suffix string) (string, error) { + digest, ok := r.digests[archiveName(suffix)] + if !ok { + return "", fmt.Errorf("checksums.txt has no digest for %s", archiveName(suffix)) + } + return digest, nil +} + +// formulaTemplate has no version line: Homebrew reads the version from the +// /download/v/ part of each url. +var formulaTemplate = template.Must(template.New("tlsanalyzer.rb").Parse(`# Rendered by packaging/brewformula in github.com/csnp/tls-analyzer from the +# checksums.txt of release v{{.Version}}. For a new release, render it again +# instead of editing a url or sha256 line by hand. +class Tlsanalyzer < Formula + desc "TLS security assessment with post-quantum readiness checks" + homepage "https://github.com/csnp/tls-analyzer" + license "MIT" + + on_macos do + on_arm do + url "{{.URL "darwin-arm64"}}" + sha256 "{{.SHA256 "darwin-arm64"}}" + end + on_intel do + url "{{.URL "darwin-amd64"}}" + sha256 "{{.SHA256 "darwin-amd64"}}" + end + end + + on_linux do + on_arm do + url "{{.URL "linux-arm64"}}" + sha256 "{{.SHA256 "linux-arm64"}}" + end + on_intel do + url "{{.URL "linux-amd64"}}" + sha256 "{{.SHA256 "linux-amd64"}}" + end + end + + def install + bin.install "tlsanalyzer" + end + + test do + assert_match "qramm-tls-analyzer #{version}", shell_output("#{bin}/tlsanalyzer version") + end +end +`)) + +func main() { + version := flag.String("version", "", "release `version` the formula installs, such as 0.4.1") + checksums := flag.String("checksums", "", "`path` to that release's checksums.txt") + flag.Parse() + if *version == "" || *checksums == "" || flag.NArg() > 0 { + flag.Usage() + os.Exit(2) + } + + sums, err := os.ReadFile(*checksums) + if err == nil { + var out []byte + if out, err = formula(*version, sums); err == nil { + _, err = os.Stdout.Write(out) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, "brewformula:", err) + os.Exit(1) + } +} + +// formula renders the formula for version from the contents of that release's +// checksums.txt. A leading "v" on version is dropped. It returns an error, and +// no formula, for a version that is not X.Y.Z, a line that is not a sha256 +// digest and a file name, an archive given two different digests, and a +// checksums.txt without a digest for each archive the formula installs. +func formula(version string, checksums []byte) ([]byte, error) { + version = strings.TrimPrefix(version, "v") + if !releaseVersion.MatchString(version) { + return nil, fmt.Errorf("version %q is not a release version such as 0.4.1", version) + } + digests, err := parseChecksums(checksums) + if err != nil { + return nil, err + } + var missing []string + for _, suffix := range archiveSuffixes { + if _, ok := digests[archiveName(suffix)]; !ok { + missing = append(missing, archiveName(suffix)) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("checksums.txt has no digest for %s", strings.Join(missing, ", ")) + } + + var out bytes.Buffer + if err := formulaTemplate.Execute(&out, release{Version: version, digests: digests}); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +// parseChecksums reads sha256sum output: a digest, white space and a file +// name, which sha256sum marks with a leading "*" in binary mode. +func parseChecksums(checksums []byte) (map[string]string, error) { + digests := make(map[string]string) + lines := bufio.NewScanner(bytes.NewReader(checksums)) + for n := 1; lines.Scan(); n++ { + line := strings.TrimSpace(lines.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 || !sha256Digest.MatchString(fields[0]) { + return nil, fmt.Errorf("checksums.txt line %d is not a sha256 digest and a file name: %q", n, line) + } + name := strings.TrimPrefix(fields[1], "*") + if digest, ok := digests[name]; ok && digest != fields[0] { + return nil, fmt.Errorf("checksums.txt gives %s two different digests", name) + } + digests[name] = fields[0] + } + if err := lines.Err(); err != nil { + return nil, err + } + return digests, nil +} diff --git a/packaging/brewformula/main_test.go b/packaging/brewformula/main_test.go new file mode 100644 index 0000000..92a4334 --- /dev/null +++ b/packaging/brewformula/main_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// stubDigest stands in for a sha256 digest: 64 copies of one hex digit, so +// each archive in a test checksums.txt has a digest that is easy to tell apart. +func stubDigest(digit string) string { + return strings.Repeat(digit, 64) +} + +// publishedChecksums is a checksums.txt in the shape the release workflow +// writes: sha256sum output for the five archives, sorted by name. +func publishedChecksums() string { + return stubDigest("1") + " tlsanalyzer-darwin-amd64.tar.gz\n" + + stubDigest("2") + " tlsanalyzer-darwin-arm64.tar.gz\n" + + stubDigest("3") + " tlsanalyzer-linux-amd64.tar.gz\n" + + stubDigest("4") + " tlsanalyzer-linux-arm64.tar.gz\n" + + stubDigest("5") + " tlsanalyzer-windows-amd64.zip\n" +} + +// TestFormulaPairsEachURLWithItsArchiveDigest checks every url line of the +// formula: it sits in the on_ and on_ blocks of the platform its +// archive was built for, it names that archive under the release's version, +// and the line after it carries the digest checksums.txt gives that archive. +// Homebrew installs whatever the url names on the platform whose block holds +// it, and refuses a download whose digest is not the formula's. +func TestFormulaPairsEachURLWithItsArchiveDigest(t *testing.T) { + out, err := formula("0.4.1", []byte(publishedChecksums())) + if err != nil { + t.Fatalf("formula: %v", err) + } + + wantDigest := map[string]string{ + "darwin-amd64": stubDigest("1"), + "darwin-arm64": stubDigest("2"), + "linux-amd64": stubDigest("3"), + "linux-arm64": stubDigest("4"), + } + blockOS := map[string]string{"on_macos do": "darwin", "on_linux do": "linux"} + blockArch := map[string]string{"on_arm do": "arm64", "on_intel do": "amd64"} + urlLine := regexp.MustCompile(`^url "(.*)"$`) + + lines := strings.Split(string(out), "\n") + var goos, goarch string + urls := map[string]int{} + for i, raw := range lines { + line := strings.TrimSpace(raw) + if v, ok := blockOS[line]; ok { + goos, goarch = v, "" + continue + } + if v, ok := blockArch[line]; ok { + goarch = v + continue + } + m := urlLine.FindStringSubmatch(line) + if m == nil { + continue + } + + platform := goos + "-" + goarch + urls[platform]++ + want := "https://github.com/csnp/tls-analyzer/releases/download/v0.4.1/tlsanalyzer-" + platform + ".tar.gz" + if m[1] != want { + t.Errorf("formula line %d, in the block for %s: url %q, want %q", i+1, platform, m[1], want) + } + next := "" + if i+1 < len(lines) { + next = strings.TrimSpace(lines[i+1]) + } + if wantLine := `sha256 "` + wantDigest[platform] + `"`; next != wantLine { + t.Errorf("formula line %d, after the url for %s: %q, want %q", i+2, platform, next, wantLine) + } + } + for platform := range wantDigest { + if urls[platform] != 1 { + t.Errorf("formula has %d url lines for %s, want 1", urls[platform], platform) + } + } + if len(urls) != len(wantDigest) { + t.Errorf("formula has url lines for %v, want one for each of %v", urls, archiveSuffixes) + } + if strings.Contains(string(out), stubDigest("5")) { + t.Errorf("formula carries the digest of the Windows zip, which Homebrew cannot install:\n%s", out) + } +} + +// TestFormulaAcceptsTheTagAndBinaryModeNames covers two inputs a maintainer is +// likely to pass: the release tag, v0.4.1, as the version, and a checksums.txt +// written by sha256sum in binary mode, which puts "*" before each file name. +func TestFormulaAcceptsTheTagAndBinaryModeNames(t *testing.T) { + binaryMode := strings.ReplaceAll(publishedChecksums(), " tlsanalyzer-", " *tlsanalyzer-") + out, err := formula("v0.4.1", []byte(binaryMode)) + if err != nil { + t.Fatalf("formula: %v", err) + } + for _, want := range []string{ + "checksums.txt of release v0.4.1.", + `url "https://github.com/csnp/tls-analyzer/releases/download/v0.4.1/tlsanalyzer-darwin-arm64.tar.gz"`, + `sha256 "` + stubDigest("2") + `"`, + } { + if !strings.Contains(string(out), want) { + t.Errorf("formula does not contain %q:\n%s", want, out) + } + } +} + +// TestFormulaRefusesInputItCannotPairWithAnArchive checks that each kind of +// bad input gives an error and no formula, rather than a formula with an empty +// or misplaced sha256 line. +func TestFormulaRefusesInputItCannotPairWithAnArchive(t *testing.T) { + published := publishedChecksums() + tests := []struct { + name string + version string + checksums string + wantErr string + }{ + { + name: "an archive missing", + version: "0.4.1", + checksums: strings.Replace(published, stubDigest("4")+" tlsanalyzer-linux-arm64.tar.gz\n", "", 1), + wantErr: "checksums.txt has no digest for tlsanalyzer-linux-arm64.tar.gz", + }, + { + name: "an empty file", + version: "0.4.1", + checksums: "", + wantErr: "checksums.txt has no digest for tlsanalyzer-darwin-arm64.tar.gz, " + + "tlsanalyzer-darwin-amd64.tar.gz, tlsanalyzer-linux-arm64.tar.gz, tlsanalyzer-linux-amd64.tar.gz", + }, + { + name: "a digest one digit short", + version: "0.4.1", + checksums: stubDigest("6")[1:] + " tlsanalyzer-darwin-amd64.tar.gz\n" + published, + wantErr: "checksums.txt line 1 is not a sha256 digest and a file name", + }, + { + name: "an upper-case digest", + version: "0.4.1", + checksums: published + strings.ToUpper(stubDigest("a")) + " tlsanalyzer-darwin-amd64.tar.gz\n", + wantErr: "checksums.txt line 6 is not a sha256 digest and a file name", + }, + { + name: "a digest without a file name", + version: "0.4.1", + checksums: stubDigest("6") + "\n" + published, + wantErr: "checksums.txt line 1 is not a sha256 digest and a file name", + }, + { + name: "an archive given two digests", + version: "0.4.1", + checksums: published + stubDigest("6") + " tlsanalyzer-darwin-arm64.tar.gz\n", + wantErr: "checksums.txt gives tlsanalyzer-darwin-arm64.tar.gz two different digests", + }, + { + name: "a version without a patch number", + version: "0.4", + checksums: published, + wantErr: `version "0.4" is not a release version such as 0.4.1`, + }, + { + name: "a pre-release version", + version: "0.5.0-rc.1", + checksums: published, + wantErr: `version "0.5.0-rc.1" is not a release version such as 0.4.1`, + }, + { + name: "a word for a version", + version: "latest", + checksums: published, + wantErr: `version "latest" is not a release version such as 0.4.1`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := formula(tt.version, []byte(tt.checksums)) + if err == nil { + t.Fatalf("formula returned no error, and this formula:\n%s", out) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q, want it to contain %q", err, tt.wantErr) + } + if out != nil { + t.Errorf("formula returned %d bytes along with its error", len(out)) + } + }) + } +} + +// TestArchivesAreTheOnesTheReleaseWorkflowPublishes ties the formula to the +// workflow that builds the archives it downloads. The formula names each +// archive by a matrix suffix of that workflow and installs the file +// tlsanalyzer from the top level of the archive, so renaming a suffix, the +// archive or the binary there breaks the formula, and this test says so before +// a release does. +func TestArchivesAreTheOnesTheReleaseWorkflowPublishes(t *testing.T) { + path := filepath.Join("..", "..", ".github", "workflows", "release.yml") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + workflow := string(body) + + suffixes := map[string]bool{} + for _, m := range regexp.MustCompile(`(?m)^\s*suffix:\s*(\S+)\s*$`).FindAllStringSubmatch(workflow, -1) { + suffixes[m[1]] = true + } + for _, suffix := range archiveSuffixes { + if !suffixes[suffix] { + t.Errorf("%s builds no archive with the suffix %s, which the formula downloads", path, suffix) + } + } + + archive := "tar -czvf tlsanalyzer-${{ matrix.suffix }}.tar.gz tlsanalyzer${{ matrix.ext }}" + if !strings.Contains(workflow, archive) { + t.Errorf("%s does not run %q; the formula downloads tlsanalyzer-.tar.gz and installs "+ + "the tlsanalyzer at its top level, so update packaging/brewformula to match the archives", path, archive) + } +}