diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c9d3b2..86c6cea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,11 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g., v0.2.0)' + description: "Tag to release (e.g., v0.2.0)" required: true type: string @@ -15,32 +15,301 @@ permissions: contents: write jobs: - goreleaser: + quality: runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Check out release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout ${{ inputs.tag }} + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Run Go tests + run: go test ./... + + - name: Run go vet + run: go vet ./... + + verify-native-binary: + name: Verify native binary (${{ matrix.goos }}) + needs: quality + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + goos: linux + binary_name: tfccli + - runner: macos-latest + goos: darwin + binary_name: tfccli + - runner: windows-latest + goos: windows + binary_name: tfccli.exe + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Check out release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout ${{ inputs.tag }} + + - name: Resolve release metadata + id: release_meta + shell: bash + run: | + release_tag="${GITHUB_REF_NAME}" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + release_tag="${{ inputs.tag }}" + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Build native binary + shell: bash + run: | + native_goarch="$(go env GOARCH)" + env GOOS="${{ matrix.goos }}" GOARCH="$native_goarch" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=${{ steps.release_meta.outputs.release_tag }}" \ + -o "$RUNNER_TEMP/${{ matrix.binary_name }}" ./cmd/tfc + + - name: Verify native binary + shell: bash + run: | + go run ./tools/releaseverify \ + --binary "$RUNNER_TEMP/${{ matrix.binary_name }}" \ + --version "${{ steps.release_meta.outputs.release_tag }}" + + build-artifacts: + name: Build release archive (${{ matrix.goos }}/${{ matrix.goarch }}) + needs: quality + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + binary_name: tfccli + archive_ext: tar.gz + - goos: linux + goarch: arm64 + binary_name: tfccli + archive_ext: tar.gz + - goos: darwin + goarch: amd64 + binary_name: tfccli + archive_ext: tar.gz + - goos: darwin + goarch: arm64 + binary_name: tfccli + archive_ext: tar.gz + - goos: windows + goarch: amd64 + binary_name: tfccli.exe + archive_ext: zip + - goos: windows + goarch: arm64 + binary_name: tfccli.exe + archive_ext: zip + steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - name: Setup Go - uses: actions/setup-go@v5 + - name: Check out release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout ${{ inputs.tag }} + + - name: Resolve release metadata + id: release_meta + shell: bash + run: | + release_tag="${GITHUB_REF_NAME}" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + release_tag="${{ inputs.tag }}" + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version-file: go.mod cache: true - - name: Stash GoReleaser config - run: cp .goreleaser.yaml /tmp/.goreleaser.yaml + - name: Build release binary + shell: bash + run: | + mkdir -p dist/bin + env GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=${{ steps.release_meta.outputs.release_tag }}" \ + -o "dist/bin/${{ matrix.binary_name }}" ./cmd/tfc + + - name: Package release archive + id: package + shell: bash + run: | + asset_base="tfccli_${{ steps.release_meta.outputs.release_version }}_${{ matrix.goos }}_${{ matrix.goarch }}" + stage_dir="$RUNNER_TEMP/$asset_base" + mkdir -p "$stage_dir" + + cp "dist/bin/${{ matrix.binary_name }}" "$stage_dir/${{ matrix.binary_name }}" + cp README.md LICENSE "$stage_dir/" + + if [ "${{ matrix.archive_ext }}" = "zip" ]; then + ( + cd "$stage_dir" + zip -q -r "$RUNNER_TEMP/$asset_base.zip" . + ) + echo "asset_path=$RUNNER_TEMP/$asset_base.zip" >> "$GITHUB_OUTPUT" + else + tar -C "$stage_dir" -czf "$RUNNER_TEMP/$asset_base.tar.gz" . + echo "asset_path=$RUNNER_TEMP/$asset_base.tar.gz" >> "$GITHUB_OUTPUT" + fi + + - name: Upload release archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ steps.package.outputs.asset_path }} + if-no-files-found: error + + publish-release: + name: Publish GitHub Release + needs: + - verify-native-binary + - build-artifacts + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - - name: Checkout release tag + - name: Check out release tag if: ${{ github.event_name == 'workflow_dispatch' }} run: git checkout ${{ inputs.tag }} - - name: GoReleaser - uses: goreleaser/goreleaser-action@v6 + - name: Resolve release metadata + id: release_meta + shell: bash + run: | + release_tag="${GITHUB_REF_NAME}" + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + release_tag="${{ inputs.tag }}" + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Download release archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - distribution: goreleaser - version: latest - args: release --clean --config /tmp/.goreleaser.yaml + path: dist/release + pattern: release-* + merge-multiple: true + + - name: Generate checksums + shell: bash + run: | + ( + cd dist/release + shasum -a 256 * > checksums.txt + ) + + - name: Publish or update GitHub Release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + mapfile -d '' files < <(find dist/release -type f -print0 | sort -z) + if [ "${#files[@]}" -eq 0 ]; then + echo "no release assets found" >&2 + exit 1 + fi + + if gh release view "${{ steps.release_meta.outputs.release_tag }}" >/dev/null 2>&1; then + gh release upload "${{ steps.release_meta.outputs.release_tag }}" "${files[@]}" --clobber + else + gh release create "${{ steps.release_meta.outputs.release_tag }}" "${files[@]}" --title "${{ steps.release_meta.outputs.release_tag }}" --generate-notes + fi + + - name: Require Homebrew tap token + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + shell: bash + run: | + if [ -z "${HOMEBREW_TAP_TOKEN}" ]; then + echo "HOMEBREW_TAP_TOKEN secret is required to update richclement/homebrew-tap" >&2 + exit 1 + fi + + - name: Check out Homebrew tap + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: richclement/homebrew-tap + ref: main + path: dist/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Generate Homebrew formula update + shell: bash + run: | + go run ./tools/homebrewtap \ + --version "${{ steps.release_meta.outputs.release_version }}" \ + --checksums-file "dist/release/checksums.txt" \ + --tap-dir "dist/homebrew-tap" \ + --formula-name "tfccli" \ + --source-repo "${GITHUB_REPOSITORY}" \ + --pr-body-file "$RUNNER_TEMP/tfccli-homebrew-pr-body.md" + + - name: Create Homebrew tap pull request + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: dist/homebrew-tap + commit-message: "tfccli: update to ${{ steps.release_meta.outputs.release_tag }}" + title: "tfccli: update to ${{ steps.release_meta.outputs.release_tag }}" + body-path: ${{ runner.temp }}/tfccli-homebrew-pr-body.md + branch: tfccli-release-${{ steps.release_meta.outputs.release_tag }} + base: main + add-paths: | + Formula/tfccli.rb diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index f5dd3ec..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -version: 2 - -project_name: tfccli - -before: - hooks: - - go mod tidy - -builds: - - id: tfccli - main: ./cmd/tfc - binary: tfccli - env: - - CGO_ENABLED=0 - goos: - - darwin - - linux - goarch: - - amd64 - - arm64 - ldflags: - - -s -w - - -X main.version={{.Version}} - - -X main.commit={{.ShortCommit}} - - -X main.date={{.Date}} - -archives: - - id: default - format: tar.gz - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - files: - - LICENSE* - - README* - -checksum: - name_template: "checksums.txt" - algorithm: sha256 - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - - "^ci:" - - "^chore:" - - Merge pull request - - Merge branch - -release: - github: - owner: richclement - name: tfccli - prerelease: auto - draft: false diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 172c803..fab7038 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,24 +4,23 @@ summary: "Release checklist for tfccli (GitHub release + Homebrew tap)" # Releasing `tfccli` -Always do **all** steps below (CI + changelog + tag + GitHub release artifacts + tap update + Homebrew sanity install). No partial releases. - -Shortcut scripts (preferred, keep notes non-empty): -```sh -scripts/release.sh X.Y.Z -scripts/verify-release.sh X.Y.Z -``` +Always do all steps below: CI, changelog, tag, GitHub release assets, Homebrew tap PR, and final install verification. Assumptions: - Repo: `richclement/tfccli` -- Tap repo: `../homebrew-tap` (tap: `richclement/tap`) -- Homebrew formula name: `tfccli` (installs the `tfc` binary) +- Tap repo: `richclement/homebrew-tap` (tap: `richclement/tap`) +- Homebrew formula name: `tfccli` (installs the `tfccli` binary) ## 0) Prereqs - Clean working tree on `main`. - Go toolchain installed (Go version comes from `go.mod`). - `make` works locally. -- Access to the tap repo (e.g. `richclement/homebrew-tap`). +- GitHub Actions release workflow has access to the `HOMEBREW_TAP_TOKEN` secret. + +Recommended token scope: +- fine-grained PAT scoped to `richclement/homebrew-tap` +- `Contents: Read and write` +- `Pull requests: Read and write` ## 1) Verify build is green ```sh @@ -52,44 +51,44 @@ git push origin main --tags ``` ## 4) Verify GitHub release artifacts -The tag push triggers `.github/workflows/release.yml` (GoReleaser). Ensure it completes successfully and the release has assets. +The tag push triggers `.github/workflows/release.yml`. Ensure all release jobs complete successfully and the GitHub Release has assets. ```sh gh run list -L 5 --workflow release.yml gh release view vX.Y.Z ``` -Ensure GitHub release notes are not empty (mirror the changelog section). +Expected assets: +- `tfccli_X.Y.Z_darwin_amd64.tar.gz` +- `tfccli_X.Y.Z_darwin_arm64.tar.gz` +- `tfccli_X.Y.Z_linux_amd64.tar.gz` +- `tfccli_X.Y.Z_linux_arm64.tar.gz` +- `tfccli_X.Y.Z_windows_amd64.zip` +- `tfccli_X.Y.Z_windows_arm64.zip` +- `checksums.txt` + +Ensure GitHub release notes are not empty. If the workflow needs a rerun: ```sh gh workflow run release.yml -f tag=vX.Y.Z ``` -## 5) Update (or add) the Homebrew formula -In the tap repo (assumed sibling at `../homebrew-tap`), create/update `Formula/tfccli.rb`. +## 5) Verify the Homebrew tap PR +The release workflow checks out `richclement/homebrew-tap`, regenerates `Formula/tfccli.rb`, and opens or updates a PR automatically. -Recommended formula shape (build-from-source, no binary assets needed): -- `version "X.Y.Z"` -- `url "https://github.com/richclement/tfccli/archive/refs/tags/vX.Y.Z.tar.gz"` -- `sha256 ""` -- `depends_on "go" => :build` -- Build: - - `system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}"), "./cmd/tfc"` +Verify: +- the release workflow did not fail on the tap update steps +- a PR exists in `richclement/homebrew-tap` +- only `Formula/tfccli.rb` changed +- the formula URLs and SHA256s match the release assets -Compute the SHA256 for the tag tarball: +You can inspect PRs with: ```sh -curl -L -o /tmp/tfccli.tar.gz https://github.com/richclement/tfccli/archive/refs/tags/vX.Y.Z.tar.gz -shasum -a 256 /tmp/tfccli.tar.gz +gh pr list --repo richclement/homebrew-tap --search "tfccli: update to vX.Y.Z" ``` -Commit + push in the tap repo: -```sh -cd ../homebrew-tap -git add Formula/tfccli.rb -git commit -m "tfccli vX.Y.Z" -git push origin main -``` +Merge the tap PR after review. ## 6) Sanity-check install from tap ```sh @@ -105,4 +104,5 @@ tfccli --version ## Notes - The `tfccli --version` command displays the version string. -- Use tags + changelog as the source of truth for release history. +- Release notes are generated by GitHub during `gh release create --generate-notes`. +- Use tags plus `CHANGELOG.md` as the source of truth for release history. diff --git a/internal/homebrewtap/homebrewtap.go b/internal/homebrewtap/homebrewtap.go new file mode 100644 index 0000000..3858f0f --- /dev/null +++ b/internal/homebrewtap/homebrewtap.go @@ -0,0 +1,283 @@ +package homebrewtap + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +const ( + defaultDescription = "Terraform Cloud CLI tool" + defaultLicense = "MIT" +) + +type AssetChecksum struct { + Name string + SHA256 string +} + +type FormulaInput struct { + FormulaName string + ClassName string + Description string + Homepage string + Version string + License string + SourceRepo string + + DarwinAMD64 AssetChecksum + DarwinARM64 AssetChecksum + LinuxAMD64 AssetChecksum + LinuxARM64 AssetChecksum +} + +type RenderResult struct { + Formula []byte + PRBody []byte +} + +type WriteOptions struct { + TapDir string + PRBodyPath string +} + +var formulaTemplate = template.Must(template.New("formula").Parse(`class {{ .ClassName }} < Formula + desc "{{ .Description }}" + homepage "{{ .Homepage }}" + version "{{ .Version }}" + license "{{ .License }}" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/{{ .SourceRepo }}/releases/download/v#{version}/{{ .FormulaName }}_#{version}_darwin_arm64.tar.gz" + sha256 "{{ .DarwinARM64.SHA256 }}" + else + url "https://github.com/{{ .SourceRepo }}/releases/download/v#{version}/{{ .FormulaName }}_#{version}_darwin_amd64.tar.gz" + sha256 "{{ .DarwinAMD64.SHA256 }}" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/{{ .SourceRepo }}/releases/download/v#{version}/{{ .FormulaName }}_#{version}_linux_arm64.tar.gz" + sha256 "{{ .LinuxARM64.SHA256 }}" + else + url "https://github.com/{{ .SourceRepo }}/releases/download/v#{version}/{{ .FormulaName }}_#{version}_linux_amd64.tar.gz" + sha256 "{{ .LinuxAMD64.SHA256 }}" + end + end + + def install + bin.install "{{ .FormulaName }}" + end + + test do + assert_match version.to_s, shell_output("#{bin}/{{ .FormulaName }} --version") + end +end +`)) + +var prBodyTemplate = template.Must(template.New("pr-body").Parse(`## Homebrew update for {{ .FormulaName }} + +- Release: [v{{ .Version }}](https://github.com/{{ .SourceRepo }}/releases/tag/v{{ .Version }}) +- Formula: ` + "`Formula/{{ .FormulaName }}.rb`" + ` +- Version bump: ` + "`{{ .Version }}`" + ` + +| Platform | Asset | SHA256 | +| --- | --- | --- | +| macOS amd64 | ` + "`{{ .DarwinAMD64.Name }}`" + ` | ` + "`{{ .DarwinAMD64.SHA256 }}`" + ` | +| macOS arm64 | ` + "`{{ .DarwinARM64.Name }}`" + ` | ` + "`{{ .DarwinARM64.SHA256 }}`" + ` | +| Linux amd64 | ` + "`{{ .LinuxAMD64.Name }}`" + ` | ` + "`{{ .LinuxAMD64.SHA256 }}`" + ` | +| Linux arm64 | ` + "`{{ .LinuxARM64.Name }}`" + ` | ` + "`{{ .LinuxARM64.SHA256 }}`" + ` | +`)) + +func Generate(version string, sourceRepo string, formulaName string, checksums []byte) (RenderResult, error) { + assets, err := ParseChecksums(checksums) + if err != nil { + return RenderResult{}, err + } + + input, err := BuildFormulaInput(version, sourceRepo, formulaName, assets) + if err != nil { + return RenderResult{}, err + } + + formula, err := RenderFormula(input) + if err != nil { + return RenderResult{}, err + } + + prBody, err := RenderPRBody(input) + if err != nil { + return RenderResult{}, err + } + + return RenderResult{ + Formula: formula, + PRBody: prBody, + }, nil +} + +func ParseChecksums(checksums []byte) (map[string]AssetChecksum, error) { + scanner := bufio.NewScanner(bytes.NewReader(checksums)) + assets := make(map[string]AssetChecksum) + lineNumber := 0 + + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) != 2 { + return nil, fmt.Errorf("parse checksums line %d: expected 2 fields, got %d", lineNumber, len(fields)) + } + + asset := AssetChecksum{ + SHA256: fields[0], + Name: fields[1], + } + + if _, exists := assets[asset.Name]; exists { + return nil, fmt.Errorf("parse checksums line %d: duplicate asset %q", lineNumber, asset.Name) + } + + assets[asset.Name] = asset + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan checksums: %w", err) + } + + return assets, nil +} + +func BuildFormulaInput(version string, sourceRepo string, formulaName string, assets map[string]AssetChecksum) (FormulaInput, error) { + if version == "" { + return FormulaInput{}, errors.New("version is required") + } + if sourceRepo == "" { + return FormulaInput{}, errors.New("source repo is required") + } + if formulaName == "" { + return FormulaInput{}, errors.New("formula name is required") + } + + required := map[string]string{ + "darwin_amd64": fmt.Sprintf("%s_%s_darwin_amd64.tar.gz", formulaName, version), + "darwin_arm64": fmt.Sprintf("%s_%s_darwin_arm64.tar.gz", formulaName, version), + "linux_amd64": fmt.Sprintf("%s_%s_linux_amd64.tar.gz", formulaName, version), + "linux_arm64": fmt.Sprintf("%s_%s_linux_arm64.tar.gz", formulaName, version), + } + + resolve := func(key string) (AssetChecksum, error) { + name := required[key] + asset, ok := assets[name] + if !ok { + return AssetChecksum{}, fmt.Errorf("required asset %q missing from checksums", name) + } + return asset, nil + } + + darwinAMD64, err := resolve("darwin_amd64") + if err != nil { + return FormulaInput{}, err + } + darwinARM64, err := resolve("darwin_arm64") + if err != nil { + return FormulaInput{}, err + } + linuxAMD64, err := resolve("linux_amd64") + if err != nil { + return FormulaInput{}, err + } + linuxARM64, err := resolve("linux_arm64") + if err != nil { + return FormulaInput{}, err + } + + return FormulaInput{ + FormulaName: formulaName, + ClassName: formulaClassName(formulaName), + Description: defaultDescription, + Homepage: "https://github.com/" + sourceRepo, + Version: version, + License: defaultLicense, + SourceRepo: sourceRepo, + DarwinAMD64: darwinAMD64, + DarwinARM64: darwinARM64, + LinuxAMD64: linuxAMD64, + LinuxARM64: linuxARM64, + }, nil +} + +func RenderFormula(input FormulaInput) ([]byte, error) { + var out bytes.Buffer + if err := formulaTemplate.Execute(&out, input); err != nil { + return nil, fmt.Errorf("render formula: %w", err) + } + return out.Bytes(), nil +} + +func RenderPRBody(input FormulaInput) ([]byte, error) { + var out bytes.Buffer + if err := prBodyTemplate.Execute(&out, input); err != nil { + return nil, fmt.Errorf("render pr body: %w", err) + } + return out.Bytes(), nil +} + +func WriteFiles(options WriteOptions, formulaName string, result RenderResult) error { + if options.TapDir == "" { + return errors.New("tap dir is required") + } + if options.PRBodyPath == "" { + return errors.New("pr body path is required") + } + if formulaName == "" { + return errors.New("formula name is required") + } + + formulaPath := filepath.Join(options.TapDir, "Formula", formulaName+".rb") + if err := os.MkdirAll(filepath.Dir(formulaPath), 0o755); err != nil { + return fmt.Errorf("create formula directory: %w", err) + } + if err := os.WriteFile(formulaPath, result.Formula, 0o644); err != nil { + return fmt.Errorf("write formula: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(options.PRBodyPath), 0o755); err != nil { + return fmt.Errorf("create pr body directory: %w", err) + } + if err := os.WriteFile(options.PRBodyPath, result.PRBody, 0o644); err != nil { + return fmt.Errorf("write pr body: %w", err) + } + + return nil +} + +func formulaClassName(formulaName string) string { + parts := strings.FieldsFunc(formulaName, func(r rune) bool { + return r == '-' || r == '_' + }) + + var builder strings.Builder + for _, part := range parts { + if part == "" { + continue + } + builder.WriteString(strings.ToUpper(part[:1])) + if len(part) > 1 { + builder.WriteString(part[1:]) + } + } + + return builder.String() +} diff --git a/internal/homebrewtap/homebrewtap_test.go b/internal/homebrewtap/homebrewtap_test.go new file mode 100644 index 0000000..42091e1 --- /dev/null +++ b/internal/homebrewtap/homebrewtap_test.go @@ -0,0 +1,142 @@ +package homebrewtap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateGoldenFiles(t *testing.T) { + t.Parallel() + + result, err := Generate("0.12.0", "richclement/tfccli", "tfccli", []byte(sampleChecksums)) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if got, want := string(result.Formula), string(readGoldenFile(t, "formula.golden")); got != want { + t.Fatalf("formula golden mismatch\nwant:\n%s\ngot:\n%s", want, got) + } + + if got, want := string(result.PRBody), string(readGoldenFile(t, "pr-body.golden")); got != want { + t.Fatalf("pr body golden mismatch\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestGenerateMissingAsset(t *testing.T) { + t.Parallel() + + _, err := Generate("0.12.0", "richclement/tfccli", "tfccli", []byte(strings.ReplaceAll(sampleChecksums, "tfccli_0.12.0_linux_arm64.tar.gz", "tfccli_0.12.0_linux_arm64-missing.tar.gz"))) + if err == nil { + t.Fatal("expected error for missing asset") + } + if !strings.Contains(err.Error(), `required asset "tfccli_0.12.0_linux_arm64.tar.gz" missing`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseChecksumsMalformedLine(t *testing.T) { + t.Parallel() + + _, err := ParseChecksums([]byte("abc only-one-field extra\n")) + if err == nil { + t.Fatal("expected malformed line error") + } + if !strings.Contains(err.Error(), "expected 2 fields") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseChecksumsDuplicateAsset(t *testing.T) { + t.Parallel() + + _, err := ParseChecksums([]byte(strings.Join([]string{ + "111 tfccli_0.12.0_darwin_amd64.tar.gz", + "222 tfccli_0.12.0_darwin_amd64.tar.gz", + }, "\n"))) + if err == nil { + t.Fatal("expected duplicate asset error") + } + if !strings.Contains(err.Error(), `duplicate asset "tfccli_0.12.0_darwin_amd64.tar.gz"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildFormulaInputIgnoresWindowsAssets(t *testing.T) { + t.Parallel() + + assets, err := ParseChecksums([]byte(sampleChecksums)) + if err != nil { + t.Fatalf("ParseChecksums returned error: %v", err) + } + + input, err := BuildFormulaInput("0.12.0", "richclement/tfccli", "tfccli", assets) + if err != nil { + t.Fatalf("BuildFormulaInput returned error: %v", err) + } + + if input.ClassName != "Tfccli" { + t.Fatalf("unexpected class name: %q", input.ClassName) + } + if input.DarwinAMD64.Name != "tfccli_0.12.0_darwin_amd64.tar.gz" { + t.Fatalf("unexpected darwin amd64 asset: %+v", input.DarwinAMD64) + } + if input.LinuxARM64.Name != "tfccli_0.12.0_linux_arm64.tar.gz" { + t.Fatalf("unexpected linux arm64 asset: %+v", input.LinuxARM64) + } +} + +func TestWriteFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + result, err := Generate("0.12.0", "richclement/tfccli", "tfccli", []byte(sampleChecksums)) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + prBodyPath := filepath.Join(dir, "artifacts", "tfccli-homebrew-pr-body.md") + if err := WriteFiles(WriteOptions{ + TapDir: filepath.Join(dir, "tap"), + PRBodyPath: prBodyPath, + }, "tfccli", result); err != nil { + t.Fatalf("WriteFiles returned error: %v", err) + } + + formulaPath := filepath.Join(dir, "tap", "Formula", "tfccli.rb") + formula, err := os.ReadFile(formulaPath) + if err != nil { + t.Fatalf("read formula: %v", err) + } + if string(formula) != string(result.Formula) { + t.Fatalf("formula mismatch\nwant:\n%s\ngot:\n%s", result.Formula, formula) + } + + prBody, err := os.ReadFile(prBodyPath) + if err != nil { + t.Fatalf("read pr body: %v", err) + } + if string(prBody) != string(result.PRBody) { + t.Fatalf("pr body mismatch\nwant:\n%s\ngot:\n%s", result.PRBody, prBody) + } +} + +func readGoldenFile(t *testing.T, name string) []byte { + t.Helper() + + path := filepath.Join("testdata", name) + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden file %s: %v", name, err) + } + + return contents +} + +const sampleChecksums = `1111111111111111111111111111111111111111111111111111111111111111 tfccli_0.12.0_darwin_amd64.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 tfccli_0.12.0_darwin_arm64.tar.gz +3333333333333333333333333333333333333333333333333333333333333333 tfccli_0.12.0_linux_amd64.tar.gz +4444444444444444444444444444444444444444444444444444444444444444 tfccli_0.12.0_linux_arm64.tar.gz +5555555555555555555555555555555555555555555555555555555555555555 tfccli_0.12.0_windows_amd64.zip +6666666666666666666666666666666666666666666666666666666666666666 tfccli_0.12.0_windows_arm64.zip` diff --git a/internal/homebrewtap/testdata/formula.golden b/internal/homebrewtap/testdata/formula.golden new file mode 100644 index 0000000..11d96cd --- /dev/null +++ b/internal/homebrewtap/testdata/formula.golden @@ -0,0 +1,34 @@ +class Tfccli < Formula + desc "Terraform Cloud CLI tool" + homepage "https://github.com/richclement/tfccli" + version "0.12.0" + license "MIT" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/richclement/tfccli/releases/download/v#{version}/tfccli_#{version}_darwin_arm64.tar.gz" + sha256 "2222222222222222222222222222222222222222222222222222222222222222" + else + url "https://github.com/richclement/tfccli/releases/download/v#{version}/tfccli_#{version}_darwin_amd64.tar.gz" + sha256 "1111111111111111111111111111111111111111111111111111111111111111" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/richclement/tfccli/releases/download/v#{version}/tfccli_#{version}_linux_arm64.tar.gz" + sha256 "4444444444444444444444444444444444444444444444444444444444444444" + else + url "https://github.com/richclement/tfccli/releases/download/v#{version}/tfccli_#{version}_linux_amd64.tar.gz" + sha256 "3333333333333333333333333333333333333333333333333333333333333333" + end + end + + def install + bin.install "tfccli" + end + + test do + assert_match version.to_s, shell_output("#{bin}/tfccli --version") + end +end diff --git a/internal/homebrewtap/testdata/pr-body.golden b/internal/homebrewtap/testdata/pr-body.golden new file mode 100644 index 0000000..4e4a386 --- /dev/null +++ b/internal/homebrewtap/testdata/pr-body.golden @@ -0,0 +1,12 @@ +## Homebrew update for tfccli + +- Release: [v0.12.0](https://github.com/richclement/tfccli/releases/tag/v0.12.0) +- Formula: `Formula/tfccli.rb` +- Version bump: `0.12.0` + +| Platform | Asset | SHA256 | +| --- | --- | --- | +| macOS amd64 | `tfccli_0.12.0_darwin_amd64.tar.gz` | `1111111111111111111111111111111111111111111111111111111111111111` | +| macOS arm64 | `tfccli_0.12.0_darwin_arm64.tar.gz` | `2222222222222222222222222222222222222222222222222222222222222222` | +| Linux amd64 | `tfccli_0.12.0_linux_amd64.tar.gz` | `3333333333333333333333333333333333333333333333333333333333333333` | +| Linux arm64 | `tfccli_0.12.0_linux_arm64.tar.gz` | `4444444444444444444444444444444444444444444444444444444444444444` | diff --git a/internal/releaseverify/releaseverify.go b/internal/releaseverify/releaseverify.go new file mode 100644 index 0000000..bd39250 --- /dev/null +++ b/internal/releaseverify/releaseverify.go @@ -0,0 +1,205 @@ +package releaseverify + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +type commandResult struct { + exitCode int + stdout string + stderr string +} + +type commandCase struct { + name string + args []string + cwd string + home string +} + +func Verify(binaryPath string, version string) error { + repoRoot, err := repoRoot() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + + binaryAbs, err := filepath.Abs(binaryPath) + if err != nil { + return fmt.Errorf("resolve binary path: %w", err) + } + + tempDir, err := os.MkdirTemp("", "tfccli-release-verify-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + referenceBinary := filepath.Join(tempDir, "tfccli-reference"+filepath.Ext(binaryAbs)) + if err := buildReferenceBinary(repoRoot, referenceBinary, version); err != nil { + return err + } + + emptyHome := filepath.Join(tempDir, "empty-home") + if err := os.MkdirAll(emptyHome, 0o755); err != nil { + return fmt.Errorf("create empty home: %w", err) + } + + fixtureHome := filepath.Join(tempDir, "fixture-home") + if err := writeFixtureHome(fixtureHome); err != nil { + return fmt.Errorf("write fixture home: %w", err) + } + + cases := []commandCase{ + {name: "version", args: []string{"--version"}, cwd: repoRoot, home: emptyHome}, + {name: "help", args: []string{"--help"}, cwd: repoRoot, home: emptyHome}, + {name: "invalid-usage", args: []string{"not-a-command"}, cwd: repoRoot, home: emptyHome}, + {name: "contexts-list-empty", args: []string{"contexts", "list"}, cwd: repoRoot, home: emptyHome}, + {name: "contexts-list-empty-json", args: []string{"--output-format=json", "contexts", "list"}, cwd: repoRoot, home: emptyHome}, + {name: "contexts-show", args: []string{"contexts", "show"}, cwd: repoRoot, home: fixtureHome}, + {name: "contexts-show-json", args: []string{"--output-format=json", "contexts", "show"}, cwd: repoRoot, home: fixtureHome}, + } + + for _, currentCase := range cases { + expected, err := runCommand(referenceBinary, currentCase) + if err != nil { + return fmt.Errorf("run reference binary for %s: %w", currentCase.name, err) + } + + actual, err := runCommand(binaryAbs, currentCase) + if err != nil { + return fmt.Errorf("run release binary for %s: %w", currentCase.name, err) + } + + if err := compareResults(currentCase.name, expected, actual); err != nil { + return err + } + } + + return nil +} + +func repoRoot() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")), nil +} + +func buildReferenceBinary(repoRoot string, outputPath string, version string) error { + args := []string{"build", "-trimpath", "-o", outputPath} + if version != "" { + args = append(args, "-ldflags", "-X main.version="+version) + } + args = append(args, "./cmd/tfc") + + cmd := exec.Command("go", args...) + cmd.Dir = repoRoot + + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("build reference binary: %w: %s", err, stderr.String()) + } + + return nil +} + +func writeFixtureHome(homeDir string) error { + settingsPath := filepath.Join(homeDir, ".tfccli", "settings.json") + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + return err + } + + settings := []byte(`{ + "current_context": "default", + "contexts": { + "default": { + "address": "app.terraform.io", + "default_org": "acme", + "log_level": "info" + } + } +} +`) + + return os.WriteFile(settingsPath, settings, 0o600) +} + +func runCommand(binary string, currentCase commandCase) (commandResult, error) { + cmd := exec.Command(binary, currentCase.args...) + cmd.Dir = currentCase.cwd + cmd.Env = envWithHome(currentCase.home) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result := commandResult{ + exitCode: 0, + stdout: stdout.String(), + stderr: stderr.String(), + } + if err == nil { + return result, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.exitCode = exitErr.ExitCode() + return result, nil + } + + return commandResult{}, err +} + +func envWithHome(home string) []string { + env := make([]string, 0, len(os.Environ())+2) + for _, item := range os.Environ() { + if hasEnvKey(item, "HOME") || hasEnvKey(item, "USERPROFILE") || hasEnvKey(item, "HOMEDRIVE") || hasEnvKey(item, "HOMEPATH") { + continue + } + env = append(env, item) + } + + volume := filepath.VolumeName(home) + homePath := strings.TrimPrefix(home, volume) + if homePath == "" { + homePath = string(os.PathSeparator) + } + + env = append(env, + "HOME="+home, + "USERPROFILE="+home, + "HOMEDRIVE="+volume, + "HOMEPATH="+homePath, + ) + return env +} + +func hasEnvKey(item string, key string) bool { + return len(item) > len(key) && item[:len(key)+1] == key+"=" +} + +func compareResults(name string, expected commandResult, actual commandResult) error { + if expected.exitCode != actual.exitCode { + return fmt.Errorf("%s exit code mismatch: expected %d, got %d", name, expected.exitCode, actual.exitCode) + } + if expected.stdout != actual.stdout { + return fmt.Errorf("%s stdout mismatch\nexpected:\n%s\nactual:\n%s", name, expected.stdout, actual.stdout) + } + if expected.stderr != actual.stderr { + return fmt.Errorf("%s stderr mismatch\nexpected:\n%s\nactual:\n%s", name, expected.stderr, actual.stderr) + } + + return nil +} diff --git a/internal/releaseverify/releaseverify_test.go b/internal/releaseverify/releaseverify_test.go new file mode 100644 index 0000000..4f56da1 --- /dev/null +++ b/internal/releaseverify/releaseverify_test.go @@ -0,0 +1,72 @@ +package releaseverify + +import ( + "bytes" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestVerifyBuiltBinary(t *testing.T) { + repoRoot, err := repoRoot() + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + + binaryPath := filepath.Join(t.TempDir(), "tfccli-test-binary") + cmd := exec.Command("go", "build", "-trimpath", "-ldflags", "-X main.version=v0.0.0-test", "-o", binaryPath, "./cmd/tfc") + cmd.Dir = repoRoot + + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("build test binary: %v: %s", err, stderr.String()) + } + + if err := Verify(binaryPath, "v0.0.0-test"); err != nil { + t.Fatalf("verify built binary: %v", err) + } +} + +func TestCompareResultsMismatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantErr string + left commandResult + right commandResult + }{ + { + name: "exit code mismatch", + wantErr: "exit code mismatch", + left: commandResult{exitCode: 0, stdout: "ok", stderr: ""}, + right: commandResult{exitCode: 1, stdout: "ok", stderr: ""}, + }, + { + name: "stdout mismatch", + wantErr: "stdout mismatch", + left: commandResult{exitCode: 0, stdout: "expected", stderr: ""}, + right: commandResult{exitCode: 0, stdout: "actual", stderr: ""}, + }, + { + name: "stderr mismatch", + wantErr: "stderr mismatch", + left: commandResult{exitCode: 0, stdout: "ok", stderr: "expected"}, + right: commandResult{exitCode: 0, stdout: "ok", stderr: "actual"}, + }, + } + + for _, current := range tests { + t.Run(current.name, func(t *testing.T) { + err := compareResults("test-case", current.left, current.right) + if err == nil { + t.Fatal("expected compareResults to report a mismatch") + } + if !strings.Contains(err.Error(), current.wantErr) { + t.Fatalf("expected error to contain %q, got %q", current.wantErr, err.Error()) + } + }) + } +} diff --git a/tools/homebrewtap/main.go b/tools/homebrewtap/main.go new file mode 100644 index 0000000..c906a50 --- /dev/null +++ b/tools/homebrewtap/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/richclement/tfccli/internal/homebrewtap" +) + +func main() { + var version string + var checksumsPath string + var tapDir string + var formulaName string + var sourceRepo string + var prBodyPath string + + flag.StringVar(&version, "version", "", "release version without the leading v") + flag.StringVar(&checksumsPath, "checksums-file", "", "path to release checksums.txt") + flag.StringVar(&tapDir, "tap-dir", "", "path to the checked out homebrew tap") + flag.StringVar(&formulaName, "formula-name", "", "formula name to update") + flag.StringVar(&sourceRepo, "source-repo", "", "GitHub owner/repo for release URLs") + flag.StringVar(&prBodyPath, "pr-body-file", "", "path to write the pull request body markdown") + flag.Parse() + + if version == "" || checksumsPath == "" || tapDir == "" || formulaName == "" || sourceRepo == "" || prBodyPath == "" { + fmt.Fprintln(os.Stderr, "error: -version, -checksums-file, -tap-dir, -formula-name, -source-repo, and -pr-body-file are required") + os.Exit(2) + } + + checksums, err := os.ReadFile(checksumsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: read checksums: %v\n", err) + os.Exit(1) + } + + result, err := homebrewtap.Generate(version, sourceRepo, formulaName, checksums) + if err != nil { + fmt.Fprintf(os.Stderr, "error: generate homebrew files: %v\n", err) + os.Exit(1) + } + + if err := homebrewtap.WriteFiles(homebrewtap.WriteOptions{ + TapDir: tapDir, + PRBodyPath: prBodyPath, + }, formulaName, result); err != nil { + fmt.Fprintf(os.Stderr, "error: write homebrew files: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/releaseverify/main.go b/tools/releaseverify/main.go new file mode 100644 index 0000000..dc86732 --- /dev/null +++ b/tools/releaseverify/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/richclement/tfccli/internal/releaseverify" +) + +func main() { + var binaryPath string + var version string + + flag.StringVar(&binaryPath, "binary", "", "path to the built tfccli binary to verify") + flag.StringVar(&version, "version", "dev", "version string expected from the built binary") + flag.Parse() + + if binaryPath == "" { + fmt.Fprintln(os.Stderr, "error: -binary is required") + os.Exit(2) + } + + if err := releaseverify.Verify(binaryPath, version); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +}