From 641230de8bfee6b37cd2d176fe05a697405db0c6 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Thu, 21 May 2026 19:01:52 +0200 Subject: [PATCH] Add CI dogfood workflow --- .github/workflows/ci.yaml | 105 ++++++++++++++++++++++++ .github/workflows/release.yaml | 2 +- Makefile | 9 ++- README.md | 4 + actions/artifact.go | 61 ++++++++++++++ actions/changelog.go | 96 ++++++++++++++++++++++ actions/checksum.go | 74 +++++++++++++++++ actions/git.go | 95 ++++++++++++++++++++++ docs/COMMANDS.md | 116 ++++++++++++++++++++++++++ docs/EXAMPLES.md | 19 +++++ integration/integration_test.go | 67 ++++++++++++++++ main.go | 4 + services/artifact_service.go | 102 +++++++++++++++++++++++ services/artifact_service_test.go | 43 ++++++++++ services/changelog_service.go | 104 ++++++++++++++++++++++++ services/changelog_service_test.go | 38 +++++++++ services/checksum_service.go | 125 +++++++++++++++++++++++++++++ services/checksum_service_test.go | 54 +++++++++++++ services/git_service.go | 70 ++++++++++++++++ services/git_service_test.go | 98 ++++++++++++++++++++++ 20 files changed, 1283 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 actions/artifact.go create mode 100644 actions/changelog.go create mode 100644 actions/checksum.go create mode 100644 actions/git.go create mode 100644 services/artifact_service.go create mode 100644 services/artifact_service_test.go create mode 100644 services/changelog_service.go create mode 100644 services/changelog_service_test.go create mode 100644 services/checksum_service.go create mode 100644 services/checksum_service_test.go create mode 100644 services/git_service.go create mode 100644 services/git_service_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..019ea84 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,105 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Unit tests + run: go test ./... + + - name: Build pipekit + run: make build + + - name: Integration tests + run: go test ./integration/... -v + + - name: Export env from JSON + run: | + ./dist/pipekit env from-json --flatten --uppercase-keys --to-github <<'JSON' + { + "name": "pipekit", + "ci": { + "platform": "github-actions", + "purpose": "dogfood" + } + } + JSON + + - name: Assert exported env in later step + run: | + ./dist/pipekit assert env-exists NAME CI_PLATFORM CI_PURPOSE + test "$NAME" = "pipekit" + test "$CI_PLATFORM" = "github-actions" + test "$CI_PURPOSE" = "dogfood" + + - name: Export outputs from JSON + id: json_outputs + run: | + ./dist/pipekit env from-json --uppercase-keys --to-github-output <<'JSON' + { + "artifact": "pipekit", + "channel": "ci" + } + JSON + + - name: Assert exported outputs in later step + env: + ARTIFACT: ${{ steps.json_outputs.outputs.ARTIFACT }} + CHANNEL: ${{ steps.json_outputs.outputs.CHANNEL }} + run: | + ./dist/pipekit assert env-exists ARTIFACT CHANNEL + test "$ARTIFACT" = "pipekit" + test "$CHANNEL" = "ci" + + - name: Export cache key + id: cache_key + run: ./dist/pipekit cache-key from-files go.sum --prefix "go-" --to-github-output cache_key + + - name: Assert cache key output in later step + env: + CACHE_KEY: ${{ steps.cache_key.outputs.cache_key }} + run: | + ./dist/pipekit assert env-exists CACHE_KEY + case "$CACHE_KEY" in + go-*) ;; + *) echo "cache key missing go- prefix: $CACHE_KEY"; exit 1 ;; + esac + + - name: Dogfood JSON, mask, exec, and summary + run: | + printf '{"module":"github.com/AxeForging/pipekit","kind":"ci"}\n' > pipekit-ci.json + test "$(./dist/pipekit json get pipekit-ci.json --path '.module' --raw)" = "github.com/AxeForging/pipekit" + ./dist/pipekit assert json-path --file pipekit-ci.json --path '.kind' --expected "ci" + ./dist/pipekit git sha --short --to-github-output git_sha + ./dist/pipekit git ref --slug --to-github-output ref_slug + ./dist/pipekit checksum files dist/pipekit --output pipekit-checksums.txt + ./dist/pipekit checksum verify pipekit-checksums.txt + ./dist/pipekit artifact assert dist/pipekit pipekit-checksums.txt + ./dist/pipekit artifact manifest dist/pipekit pipekit-checksums.txt --pretty --output pipekit-artifacts.json + ./dist/pipekit changelog generate --from origin/main --conventional --output pipekit-changelog.md + ./dist/pipekit mask github "secret-ci-value" + ./dist/pipekit exec --attempts 2 --delay 1s --mask "secret-[a-z-]+" --tee pipekit-ci.log -- sh -c 'echo token=secret-ci-value' + ./dist/pipekit summary badge --label "pipekit" --status success --to-github-summary + ./dist/pipekit summary section --title "pipekit artifacts" --to-github-summary < pipekit-artifacts.json + ./dist/pipekit summary section --title "pipekit CI log" --to-github-summary < pipekit-ci.log diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6a55f04..a939433 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,7 @@ jobs: go-version: '1.24' - name: Run tests - run: go test ./... + run: make test-integration - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/Makefile b/Makefile index 22147a6..2567a70 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build clean test lint tidy version release-check +.PHONY: all build clean test test-integration lint tidy version release-check GOOS_ARCH := linux/amd64 linux/arm64 linux/386 linux/arm darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 windows/386 DIST_DIR := dist @@ -45,6 +45,11 @@ test: go test ./... -v @echo "All tests passed." +test-integration: build + @echo "Running integration tests against dist/pipekit..." + go test ./integration/... -v + @echo "Integration tests passed." + lint: @echo "Running linter..." golangci-lint run --timeout=5m @@ -71,7 +76,7 @@ tag: git tag -a $(VERSION) -m "Release $(VERSION)" @echo "Tag created. Push with: git push origin $(VERSION)" -release-check: build +release-check: test-integration @echo "Running tests..." go test ./... @echo "All tests passed. Ready for release $(VERSION)" diff --git a/README.md b/README.md index 210ec50..825b966 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `version` | Get / bump / compare versions across `package.json`, `Cargo.toml`, `Chart.yaml`, etc. | [↗](docs/COMMANDS.md#version) | | `retry` | Run any command with attempt count, delay, backoff, exit-code filtering | [↗](docs/COMMANDS.md#retry) | | `cache-key` | Deterministic SHA256 cache keys from files / globs / composite parts | [↗](docs/COMMANDS.md#cache-key) | +| `checksum` | Generate / verify release checksums for artifact files | [↗](docs/COMMANDS.md#checksum) | +| `artifact` | Assert artifacts exist and generate size/SHA256 manifests | [↗](docs/COMMANDS.md#artifact) | +| `git` | CI-friendly git metadata: ref, SHA, tags, dirty state | [↗](docs/COMMANDS.md#git) | +| `changelog` | Generate release notes from git commit ranges | [↗](docs/COMMANDS.md#changelog) | | `config` | Resolve env-specific config maps; map branches to environments | [↗](docs/COMMANDS.md#config) | | `parse` | Pull fenced code blocks / YAML / frontmatter out of issue bodies, PR comments, markdown | [↗](docs/COMMANDS.md#parse) | | `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [↗](docs/COMMANDS.md#json) | diff --git a/actions/artifact.go b/actions/artifact.go new file mode 100644 index 0000000..1ca3681 --- /dev/null +++ b/actions/artifact.go @@ -0,0 +1,61 @@ +package actions + +import ( + "fmt" + "os" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// ArtifactCommand returns CI artifact helpers. +func ArtifactCommand() cli.Command { + return cli.Command{ + Name: "artifact", + Usage: "inspect and validate release artifacts", + Subcommands: []cli.Command{ + { + Name: "manifest", + Usage: "generate a JSON manifest for files or globs", + ArgsUsage: "PATH_OR_GLOB...", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "pretty", Usage: "pretty-print JSON"}, + cli.StringFlag{Name: "output, o", Usage: "write manifest to this file"}, + }, + Action: func(c *cli.Context) error { + patterns, err := argsOrErr(c, "artifact path or glob") + if err != nil { + return err + } + entries, err := services.ArtifactManifest(patterns) + if err != nil { + return err + } + out, err := services.FormatArtifactManifestJSON(entries, c.Bool("pretty")) + if err != nil { + return err + } + out += "\n" + if path := c.String("output"); path != "" { + return os.WriteFile(path, []byte(out), 0644) + } + fmt.Print(out) + return nil + }, + }, + { + Name: "assert", + Usage: "fail unless each artifact path or glob resolves", + ArgsUsage: "PATH_OR_GLOB...", + Action: func(c *cli.Context) error { + patterns, err := argsOrErr(c, "artifact path or glob") + if err != nil { + return err + } + return services.AssertArtifacts(patterns) + }, + }, + }, + } +} diff --git a/actions/changelog.go b/actions/changelog.go new file mode 100644 index 0000000..4b09cc0 --- /dev/null +++ b/actions/changelog.go @@ -0,0 +1,96 @@ +package actions + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// ChangelogCommand returns release-note generation helpers. +func ChangelogCommand() cli.Command { + return cli.Command{ + Name: "changelog", + Usage: "generate release notes from git commits", + Subcommands: []cli.Command{ + { + Name: "generate", + Usage: "generate changelog markdown for a git range", + Flags: []cli.Flag{ + cli.StringFlag{Name: "from", Usage: "start ref (exclusive)"}, + cli.StringFlag{Name: "to", Value: "HEAD", Usage: "end ref (inclusive)"}, + cli.BoolFlag{Name: "conventional", Usage: "group conventional commits"}, + cli.StringFlag{Name: "format, f", Value: "markdown", Usage: "markdown or json"}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + markdown, entries, err := services.GenerateChangelog(services.ChangelogOptions{ + From: c.String("from"), + To: c.String("to"), + Conventional: c.Bool("conventional"), + }) + if err != nil { + return err + } + + out := markdown + switch c.String("format") { + case "markdown", "": + case "json": + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + out = string(data) + "\n" + default: + return cli.NewExitError("unknown format: "+c.String("format"), 1) + } + + if outputKey := c.String("to-github-output"); outputKey != "" { + return services.WriteToGitHubOutputValue(outputKey, out) + } + if path := c.String("output"); path != "" { + return os.WriteFile(path, []byte(out), 0644) + } + fmt.Print(out) + return nil + }, + }, + { + Name: "since-tag", + Usage: "generate changelog markdown since the latest reachable tag", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "conventional", Usage: "group conventional commits"}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + tag, err := services.GitPreviousTag() + if err != nil { + return err + } + markdown, _, err := services.GenerateChangelog(services.ChangelogOptions{ + From: tag, + To: "HEAD", + Conventional: c.Bool("conventional"), + }) + if err != nil { + return err + } + if outputKey := c.String("to-github-output"); outputKey != "" { + return services.WriteToGitHubOutputValue(outputKey, markdown) + } + if path := c.String("output"); path != "" { + return os.WriteFile(path, []byte(markdown), 0644) + } + fmt.Print(markdown) + return nil + }, + }, + }, + } +} diff --git a/actions/checksum.go b/actions/checksum.go new file mode 100644 index 0000000..a0ee5ab --- /dev/null +++ b/actions/checksum.go @@ -0,0 +1,74 @@ +package actions + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// ChecksumCommand returns checksum helpers for release artifacts. +func ChecksumCommand() cli.Command { + return cli.Command{ + Name: "checksum", + Usage: "generate and verify file checksums", + Subcommands: []cli.Command{ + { + Name: "files", + Usage: "hash one or more files independently", + ArgsUsage: "FILE...", + Flags: []cli.Flag{ + cli.StringFlag{Name: "algorithm, a", Value: "sha256", Usage: "sha256, sha1, or md5"}, + cli.StringFlag{Name: "format, f", Value: "text", Usage: "text or json"}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + }, + Action: func(c *cli.Context) error { + files, err := argsOrErr(c, "file") + if err != nil { + return err + } + sums, err := services.ChecksumFiles(files, c.String("algorithm")) + if err != nil { + return err + } + var out string + switch c.String("format") { + case "json": + data, err := json.MarshalIndent(sums, "", " ") + if err != nil { + return err + } + out = string(data) + "\n" + case "text", "": + out = services.FormatChecksums(sums) + default: + return cli.NewExitError("unknown format: "+c.String("format"), 1) + } + if path := c.String("output"); path != "" { + return os.WriteFile(path, []byte(out), 0644) + } + fmt.Print(out) + return nil + }, + }, + { + Name: "verify", + Usage: "verify a checksum file", + ArgsUsage: "CHECKSUM_FILE", + Flags: []cli.Flag{ + cli.StringFlag{Name: "algorithm, a", Value: "sha256", Usage: "sha256, sha1, or md5"}, + }, + Action: func(c *cli.Context) error { + path, err := firstArgOrErr(c, "CHECKSUM_FILE") + if err != nil { + return err + } + return services.VerifyChecksums(path, c.String("algorithm")) + }, + }, + }, + } +} diff --git a/actions/git.go b/actions/git.go new file mode 100644 index 0000000..9f0de01 --- /dev/null +++ b/actions/git.go @@ -0,0 +1,95 @@ +package actions + +import ( + "fmt" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// GitCommand returns git metadata helpers for CI. +func GitCommand() cli.Command { + return cli.Command{ + Name: "git", + Usage: "read git metadata in CI-friendly formats", + Subcommands: []cli.Command{ + { + Name: "sha", + Usage: "print the current commit SHA", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "short, s", Usage: "print short SHA"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + sha, err := services.GitSHA(c.Bool("short")) + if err != nil { + return err + } + return emitString(c, sha) + }, + }, + { + Name: "ref", + Usage: "print the current branch or tag name", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "slug", Usage: "slugify the ref for tags, image names, and preview envs"}, + cli.IntFlag{Name: "max-length", Usage: "truncate slug to this length"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + ref, err := services.GitRef(c.Bool("slug"), c.Int("max-length")) + if err != nil { + return err + } + return emitString(c, ref) + }, + }, + { + Name: "current-tag", + Usage: "print the tag pointing at HEAD", + Flags: []cli.Flag{outputKeyFlag()}, + Action: func(c *cli.Context) error { + tag, err := services.GitCurrentTag() + if err != nil { + return err + } + return emitString(c, tag) + }, + }, + { + Name: "previous-tag", + Usage: "print the latest reachable tag", + Flags: []cli.Flag{outputKeyFlag()}, + Action: func(c *cli.Context) error { + tag, err := services.GitPreviousTag() + if err != nil { + return err + } + return emitString(c, tag) + }, + }, + { + Name: "is-dirty", + Usage: "exit 0 when the working tree is dirty, 1 when clean", + Flags: []cli.Flag{ + cli.BoolFlag{Name: "print", Usage: "print true or false instead of using only the exit code"}, + outputKeyFlag(), + }, + Action: func(c *cli.Context) error { + dirty, err := services.GitIsDirty() + if err != nil { + return err + } + if c.Bool("print") || c.String("to-github-output") != "" { + return emitString(c, fmt.Sprintf("%t", dirty)) + } + if dirty { + return nil + } + return cli.NewExitError("working tree is clean", 1) + }, + }, + }, + } +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 714e921..13362b1 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -16,6 +16,10 @@ Full reference for every pipekit command and flag. For end-to-end pipeline recip - [`version`](#version) — version management - [`retry`](#retry) — command retry - [`cache-key`](#cache-key) — deterministic cache keys +- [`checksum`](#checksum) — release checksums +- [`artifact`](#artifact) — artifact manifests and assertions +- [`git`](#git) — CI-friendly git metadata +- [`changelog`](#changelog) — release notes from git history - [`config`](#config) — environment configuration - [`parse`](#parse) — structured data extraction - [`json` / `yaml`](#json) — read · query · mutate · merge · convert · pretty · table @@ -563,6 +567,118 @@ pipekit cache-key composite linux amd64 "$(pipekit transform hash --file go.sum) --- +## checksum + +Generate and verify release checksums without shell loops. + +
+Examples + +```sh +# Write a standard sha256 checksum file +pipekit checksum files dist/* --output dist/checksums.txt + +# JSON output for manifests or summaries +pipekit checksum files dist/* --format json + +# Verify checksums before uploading artifacts +pipekit checksum verify dist/checksums.txt + +# Alternate algorithms +pipekit checksum files dist/* --algorithm sha1 +``` + +| Subcommand | Description | +|---|---| +| `checksum files FILE...` | Hash each file independently | +| `checksum verify CHECKSUM_FILE` | Verify ` ` lines | + +Flags: `--algorithm sha256|sha1|md5`, `--format text|json`, `--output`. + +
+ +--- + +## artifact + +Validate and describe CI artifacts before upload or release. + +
+Examples + +```sh +# Fail early if expected build outputs are missing +pipekit artifact assert "dist/pipekit-linux-*" "dist/checksums.txt" + +# Generate a JSON manifest with path, size, and sha256 +pipekit artifact manifest "dist/pipekit-*" --pretty --output dist/artifacts.json +``` + +| Subcommand | Description | +|---|---| +| `artifact assert PATH_OR_GLOB...` | Fail unless each path/glob resolves to at least one file | +| `artifact manifest PATH_OR_GLOB...` | Emit JSON artifact metadata | + +
+ +--- + +## git + +Read git metadata in formats that are easy to pass between CI steps. + +
+Examples + +```sh +pipekit git sha --short --to-github-output git_sha +pipekit git ref --slug --max-length 40 --to-github-output ref_slug +pipekit git current-tag +pipekit git previous-tag +pipekit git is-dirty --print +``` + +| Subcommand | Description | +|---|---| +| `git sha` | Print current commit SHA (`--short` supported) | +| `git ref` | Print current branch/tag, honoring GitHub Actions env vars | +| `git current-tag` | Print tag pointing at `HEAD` | +| `git previous-tag` | Print latest reachable tag | +| `git is-dirty` | Detect uncommitted tracked/untracked changes | + +
+ +--- + +## changelog + +Generate markdown release notes from git commits. + +
+Examples + +```sh +# Commits since a tag +pipekit changelog generate --from v1.2.0 --to HEAD + +# Group conventional commits into Features / Fixes / Maintenance +pipekit changelog generate --from v1.2.0 --conventional --output RELEASE_NOTES.md + +# Use the latest reachable tag as the start point +pipekit changelog since-tag --conventional --to-github-output release_notes +``` + +| Subcommand | Description | +|---|---| +| `changelog generate` | Generate notes for `--from..--to` | +| `changelog since-tag` | Generate notes since the latest reachable tag | + +Flags: `--from`, `--to`, `--conventional`, `--format markdown|json`, `--output`, `--to-github-output`. + +
+ +--- + ## config Resolve environment-specific configuration from structured maps. Replaces ~80 lines of bash that typically maps `dev/staging/prod` to project IDs, region names, etc. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 30ad7c7..b91ac57 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -499,6 +499,25 @@ pipekit diff dirs --base origin/main \ +
+Release artifacts → checksums → manifest → notes + +```sh +make build-all + +pipekit artifact assert "dist/pipekit-*" +pipekit checksum files dist/pipekit-* --output dist/checksums.txt +pipekit checksum verify dist/checksums.txt +pipekit artifact manifest "dist/pipekit-*" "dist/checksums.txt" \ + --pretty --output dist/artifacts.json + +pipekit git sha --short --to-github-output git_sha +pipekit git ref --slug --to-github-output ref_slug +pipekit changelog since-tag --conventional --output RELEASE_NOTES.md +``` + +
+ --- **See also:** [Commands](COMMANDS.md) · [Requirements](REQUIREMENTS.md) · [Install](INSTALL.md) · [← README](../README.md) diff --git a/integration/integration_test.go b/integration/integration_test.go index fee9728..879beb8 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -346,6 +346,73 @@ func TestE2E_CacheKeyWithEnv(t *testing.T) { } } +func TestE2E_ChecksumAndArtifact(t *testing.T) { + dir := t.TempDir() + dist := filepath.Join(dir, "dist") + os.MkdirAll(dist, 0755) + bin := filepath.Join(dist, "pipekit-linux-amd64") + os.WriteFile(bin, []byte("binary"), 0644) + + checksums := filepath.Join(dist, "checksums.txt") + stdout, _, code := runPipekit(t, + []string{"checksum", "files", bin, "--output", checksums}, "") + if code != 0 { + t.Fatalf("checksum files exit %d stdout=%s", code, stdout) + } + if _, err := os.Stat(checksums); err != nil { + t.Fatalf("checksums not written: %v", err) + } + if _, _, code := runPipekit(t, []string{"checksum", "verify", checksums}, ""); code != 0 { + t.Fatalf("checksum verify exit %d", code) + } + + stdout, _, code = runPipekit(t, + []string{"artifact", "manifest", filepath.Join(dist, "pipekit-*"), "--pretty"}, "") + if code != 0 { + t.Fatalf("artifact manifest exit %d", code) + } + expectAll(t, stdout, `"path"`, `"size"`, `"sha256"`) +} + +func TestE2E_GitAndChangelog(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + dir := t.TempDir() + mustRunIn(t, dir, "git", "init", "-q") + mustRunIn(t, dir, "git", "config", "user.email", "test@example.com") + mustRunIn(t, dir, "git", "config", "user.name", "test") + mustRunIn(t, dir, "git", "config", "commit.gpgsign", "false") + + os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0644) + mustRunIn(t, dir, "git", "add", ".") + mustRunIn(t, dir, "git", "commit", "-q", "-m", "feat: initial") + mustRunIn(t, dir, "git", "tag", "v0.1.0") + os.WriteFile(filepath.Join(dir, "fix.txt"), []byte("fix"), 0644) + mustRunIn(t, dir, "git", "add", ".") + mustRunIn(t, dir, "git", "commit", "-q", "-m", "fix: release artifact path") + + cmd := exec.Command(binaryPath(t), "git", "sha", "--short") + cmd.Dir = dir + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + t.Fatalf("git sha: %v", err) + } + if len(strings.TrimSpace(stdout.String())) < 7 { + t.Fatalf("unexpected short sha: %q", stdout.String()) + } + + cmd = exec.Command(binaryPath(t), "changelog", "generate", "--from", "v0.1.0", "--conventional") + cmd.Dir = dir + stdout.Reset() + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + t.Fatalf("changelog: %v", err) + } + expectAll(t, stdout.String(), "### Fixes", "fix: release artifact path") +} + func TestE2E_ParseFrontmatter(t *testing.T) { input := `--- title: My Post diff --git a/main.go b/main.go index 9a002a5..1934e29 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,10 @@ func main() { actions.VersionCommand(), actions.RetryCommand(), actions.CacheKeyCommand(), + actions.ChecksumCommand(), + actions.ArtifactCommand(), + actions.GitCommand(), + actions.ChangelogCommand(), actions.ConfigCommand(), actions.ParseCommand(), actions.JSONCommand(), diff --git a/services/artifact_service.go b/services/artifact_service.go new file mode 100644 index 0000000..7bf236c --- /dev/null +++ b/services/artifact_service.go @@ -0,0 +1,102 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/bmatcuk/doublestar/v4" +) + +// ArtifactEntry describes one file selected for CI artifact collection. +type ArtifactEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// ArtifactManifest expands glob patterns and returns deterministic file metadata. +func ArtifactManifest(patterns []string) ([]ArtifactEntry, error) { + files, err := ExpandArtifactPatterns(patterns) + if err != nil { + return nil, err + } + var entries []ArtifactEntry + for _, path := range files { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + continue + } + sum, err := ChecksumFile(path, "sha256") + if err != nil { + return nil, err + } + entries = append(entries, ArtifactEntry{ + Path: filepath.ToSlash(path), + Size: info.Size(), + SHA256: sum, + }) + } + if len(entries) == 0 { + return nil, fmt.Errorf("no artifact files matched") + } + return entries, nil +} + +// ExpandArtifactPatterns expands doublestar patterns and returns sorted files. +func ExpandArtifactPatterns(patterns []string) ([]string, error) { + if len(patterns) == 0 { + return nil, fmt.Errorf("at least one artifact path or glob required") + } + seen := make(map[string]bool) + for _, pattern := range patterns { + matches, err := doublestar.FilepathGlob(pattern) + if err != nil { + return nil, fmt.Errorf("invalid glob %q: %w", pattern, err) + } + if len(matches) == 0 { + return nil, fmt.Errorf("no files matched %q", pattern) + } + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", match, err) + } + if !info.IsDir() { + seen[match] = true + } + } + } + files := make([]string, 0, len(seen)) + for path := range seen { + files = append(files, path) + } + sort.Strings(files) + return files, nil +} + +// AssertArtifacts verifies each path or glob resolves to at least one file. +func AssertArtifacts(patterns []string) error { + _, err := ExpandArtifactPatterns(patterns) + return err +} + +// FormatArtifactManifestJSON renders a manifest as pretty JSON. +func FormatArtifactManifestJSON(entries []ArtifactEntry, pretty bool) (string, error) { + var data []byte + var err error + if pretty { + data, err = json.MarshalIndent(entries, "", " ") + } else { + data, err = json.Marshal(entries) + } + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/services/artifact_service_test.go b/services/artifact_service_test.go new file mode 100644 index 0000000..c9fc766 --- /dev/null +++ b/services/artifact_service_test.go @@ -0,0 +1,43 @@ +package services + +import ( + "os" + "path/filepath" + "testing" +) + +func TestArtifactManifest(t *testing.T) { + dir := t.TempDir() + dist := filepath.Join(dir, "dist") + if err := os.MkdirAll(dist, 0755); err != nil { + t.Fatal(err) + } + a := filepath.Join(dist, "app-linux-amd64") + b := filepath.Join(dist, "app-darwin-arm64") + if err := os.WriteFile(a, []byte("linux"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, []byte("darwin"), 0644); err != nil { + t.Fatal(err) + } + + entries, err := ArtifactManifest([]string{filepath.Join(dist, "app-*")}) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Path > entries[1].Path { + t.Fatalf("entries not sorted: %#v", entries) + } + if entries[0].SHA256 == "" || entries[0].Size == 0 { + t.Fatalf("missing metadata: %#v", entries[0]) + } +} + +func TestAssertArtifactsNoMatch(t *testing.T) { + if err := AssertArtifacts([]string{filepath.Join(t.TempDir(), "*.missing")}); err == nil { + t.Fatal("expected no-match error") + } +} diff --git a/services/changelog_service.go b/services/changelog_service.go new file mode 100644 index 0000000..baa4bc0 --- /dev/null +++ b/services/changelog_service.go @@ -0,0 +1,104 @@ +package services + +import ( + "fmt" + "os/exec" + "strings" +) + +// ChangelogOptions configures changelog generation. +type ChangelogOptions struct { + From string + To string + Conventional bool +} + +// ChangelogEntry is one git commit line used for release notes. +type ChangelogEntry struct { + SHA string `json:"sha"` + Subject string `json:"subject"` + Group string `json:"group"` +} + +// GenerateChangelog returns markdown release notes for a git range. +func GenerateChangelog(opts ChangelogOptions) (string, []ChangelogEntry, error) { + if opts.To == "" { + opts.To = "HEAD" + } + rangeArg := opts.To + if opts.From != "" { + rangeArg = opts.From + ".." + opts.To + } + out, err := exec.Command("git", "log", "--pretty=format:%h%x09%s", rangeArg).Output() + if err != nil { + return "", nil, fmt.Errorf("git log failed: %w", err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + var entries []ChangelogEntry + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + continue + } + entry := ChangelogEntry{SHA: parts[0], Subject: parts[1], Group: "Other"} + if opts.Conventional { + entry.Group = conventionalGroup(parts[1]) + } + entries = append(entries, entry) + } + return FormatChangelogMarkdown(entries, opts.Conventional), entries, nil +} + +// FormatChangelogMarkdown renders entries as release-note markdown. +func FormatChangelogMarkdown(entries []ChangelogEntry, grouped bool) string { + if len(entries) == 0 { + return "No changes.\n" + } + if !grouped { + var b strings.Builder + for _, e := range entries { + fmt.Fprintf(&b, "- %s (%s)\n", e.Subject, e.SHA) + } + return b.String() + } + order := []string{"Breaking Changes", "Features", "Fixes", "Documentation", "Maintenance", "Other"} + byGroup := make(map[string][]ChangelogEntry) + for _, e := range entries { + byGroup[e.Group] = append(byGroup[e.Group], e) + } + var b strings.Builder + for _, group := range order { + groupEntries := byGroup[group] + if len(groupEntries) == 0 { + continue + } + fmt.Fprintf(&b, "### %s\n\n", group) + for _, e := range groupEntries { + fmt.Fprintf(&b, "- %s (%s)\n", e.Subject, e.SHA) + } + b.WriteString("\n") + } + return b.String() +} + +func conventionalGroup(subject string) string { + lower := strings.ToLower(subject) + if strings.Contains(lower, "!:") || strings.Contains(lower, "breaking change") { + return "Breaking Changes" + } + switch { + case strings.HasPrefix(lower, "feat:") || strings.HasPrefix(lower, "feat("): + return "Features" + case strings.HasPrefix(lower, "fix:") || strings.HasPrefix(lower, "fix("): + return "Fixes" + case strings.HasPrefix(lower, "docs:") || strings.HasPrefix(lower, "docs("): + return "Documentation" + case strings.HasPrefix(lower, "chore:") || strings.HasPrefix(lower, "ci:") || strings.HasPrefix(lower, "test:") || strings.HasPrefix(lower, "refactor:"): + return "Maintenance" + default: + return "Other" + } +} diff --git a/services/changelog_service_test.go b/services/changelog_service_test.go new file mode 100644 index 0000000..898be14 --- /dev/null +++ b/services/changelog_service_test.go @@ -0,0 +1,38 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateChangelogConventional(t *testing.T) { + dir := initGitRepo(t) + oldWd, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWd) + + if err := os.WriteFile(filepath.Join(dir, "fix.txt"), []byte("fix"), 0644); err != nil { + t.Fatal(err) + } + mustGit(t, dir, "add", ".") + mustGit(t, dir, "commit", "-q", "-m", "fix: correct release path") + + markdown, entries, err := GenerateChangelog(ChangelogOptions{ + From: "v0.1.0", + To: "HEAD", + Conventional: true, + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + if !strings.Contains(markdown, "### Fixes") || !strings.Contains(markdown, "fix: correct release path") { + t.Fatalf("unexpected changelog:\n%s", markdown) + } +} diff --git a/services/checksum_service.go b/services/checksum_service.go new file mode 100644 index 0000000..8174c92 --- /dev/null +++ b/services/checksum_service.go @@ -0,0 +1,125 @@ +package services + +import ( + "bufio" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "strings" +) + +// FileChecksum holds one checksum result. +type FileChecksum struct { + Path string `json:"path"` + Checksum string `json:"checksum"` + Algorithm string `json:"algorithm"` +} + +// ChecksumFiles hashes each file independently. +func ChecksumFiles(files []string, algorithm string) ([]FileChecksum, error) { + if len(files) == 0 { + return nil, fmt.Errorf("at least one file required") + } + algorithm = normalizeChecksumAlgorithm(algorithm) + var sums []FileChecksum + for _, path := range files { + sum, err := ChecksumFile(path, algorithm) + if err != nil { + return nil, err + } + sums = append(sums, FileChecksum{Path: filepath.ToSlash(path), Checksum: sum, Algorithm: algorithm}) + } + return sums, nil +} + +// ChecksumFile hashes a single file with the requested algorithm. +func ChecksumFile(path, algorithm string) (string, error) { + h, algorithm, err := newChecksumHash(algorithm) + if err != nil { + return "", err + } + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("opening %s: %w", path, err) + } + defer f.Close() + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("hashing %s: %w", path, err) + } + _ = algorithm + return hex.EncodeToString(h.Sum(nil)), nil +} + +// FormatChecksums formats checksum output in common checksum-file form: +// " ". +func FormatChecksums(sums []FileChecksum) string { + var b strings.Builder + for _, sum := range sums { + fmt.Fprintf(&b, "%s %s\n", sum.Checksum, sum.Path) + } + return b.String() +} + +// VerifyChecksums verifies a checksum file in " " form. +func VerifyChecksums(manifestPath, algorithm string) error { + f, err := os.Open(manifestPath) + if err != nil { + return fmt.Errorf("opening %s: %w", manifestPath, err) + } + defer f.Close() + + base := filepath.Dir(manifestPath) + scanner := bufio.NewScanner(f) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return fmt.Errorf("%s:%d: expected ' '", manifestPath, lineNo) + } + want := fields[0] + path := strings.Join(fields[1:], " ") + if !filepath.IsAbs(path) { + path = filepath.Join(base, path) + } + got, err := ChecksumFile(path, algorithm) + if err != nil { + return err + } + if !strings.EqualFold(got, want) { + return fmt.Errorf("%s: checksum mismatch: got %s, want %s", path, got, want) + } + } + return scanner.Err() +} + +func newChecksumHash(algorithm string) (hash.Hash, string, error) { + algorithm = normalizeChecksumAlgorithm(algorithm) + switch algorithm { + case "sha256": + return sha256.New(), algorithm, nil + case "sha1": + return sha1.New(), algorithm, nil + case "md5": + return md5.New(), algorithm, nil + default: + return nil, "", fmt.Errorf("unknown checksum algorithm %q (valid: sha256, sha1, md5)", algorithm) + } +} + +func normalizeChecksumAlgorithm(algorithm string) string { + if algorithm == "" { + return "sha256" + } + return strings.ToLower(strings.TrimSpace(algorithm)) +} diff --git a/services/checksum_service_test.go b/services/checksum_service_test.go new file mode 100644 index 0000000..8721588 --- /dev/null +++ b/services/checksum_service_test.go @@ -0,0 +1,54 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestChecksumFilesAndVerify(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "artifact.txt") + if err := os.WriteFile(path, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + sums, err := ChecksumFiles([]string{path}, "sha256") + if err != nil { + t.Fatal(err) + } + if len(sums) != 1 { + t.Fatalf("expected one checksum, got %d", len(sums)) + } + if sums[0].Checksum != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" { + t.Fatalf("unexpected checksum: %s", sums[0].Checksum) + } + + manifest := filepath.Join(dir, "checksums.txt") + relLine := strings.ReplaceAll(FormatChecksums([]FileChecksum{{ + Path: "artifact.txt", + Checksum: sums[0].Checksum, + }}), "\\", "/") + if err := os.WriteFile(manifest, []byte(relLine), 0644); err != nil { + t.Fatal(err) + } + if err := VerifyChecksums(manifest, "sha256"); err != nil { + t.Fatal(err) + } +} + +func TestChecksumVerifyMismatch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "artifact.txt") + if err := os.WriteFile(path, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + manifest := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(manifest, []byte("deadbeef artifact.txt\n"), 0644); err != nil { + t.Fatal(err) + } + if err := VerifyChecksums(manifest, "sha256"); err == nil { + t.Fatal("expected mismatch error") + } +} diff --git a/services/git_service.go b/services/git_service.go new file mode 100644 index 0000000..8958d7b --- /dev/null +++ b/services/git_service.go @@ -0,0 +1,70 @@ +package services + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// GitSHA returns the current commit SHA. +func GitSHA(short bool) (string, error) { + args := []string{"rev-parse", "HEAD"} + if short { + args = []string{"rev-parse", "--short", "HEAD"} + } + return gitOutput(args...) +} + +// GitRef returns a useful branch or tag ref name, honoring GitHub Actions env first. +func GitRef(slug bool, maxLen int) (string, error) { + ref := os.Getenv("GITHUB_HEAD_REF") + if ref == "" { + ref = os.Getenv("GITHUB_REF_NAME") + } + if ref == "" { + if out, err := gitOutput("branch", "--show-current"); err == nil && out != "" { + ref = out + } + } + if ref == "" { + if out, err := gitOutput("describe", "--tags", "--exact-match"); err == nil && out != "" { + ref = out + } + } + if ref == "" { + return "", fmt.Errorf("could not determine git ref") + } + if slug { + ref = Slugify(ref, maxLen) + } + return ref, nil +} + +// GitCurrentTag returns the tag pointing at HEAD. +func GitCurrentTag() (string, error) { + return gitOutput("describe", "--tags", "--exact-match") +} + +// GitPreviousTag returns the previous reachable tag. +func GitPreviousTag() (string, error) { + return gitOutput("describe", "--tags", "--abbrev=0") +} + +// GitIsDirty reports whether tracked or untracked files are present. +func GitIsDirty() (bool, error) { + out, err := gitOutput("status", "--porcelain") + if err != nil { + return false, err + } + return out != "", nil +} + +func gitOutput(args ...string) (string, error) { + cmd := exec.Command("git", args...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/services/git_service_test.go b/services/git_service_test.go new file mode 100644 index 0000000..0ae29dd --- /dev/null +++ b/services/git_service_test.go @@ -0,0 +1,98 @@ +package services + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestGitMetadata(t *testing.T) { + dir := initGitRepo(t) + oldWd, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWd) + + sha, err := GitSHA(true) + if err != nil { + t.Fatal(err) + } + if len(sha) < 7 { + t.Fatalf("short sha too short: %q", sha) + } + + ref, err := GitRef(true, 63) + if err != nil { + t.Fatal(err) + } + if ref == "" { + t.Fatal("empty ref") + } + + dirty, err := GitIsDirty() + if err != nil { + t.Fatal(err) + } + if dirty { + t.Fatal("new repo should be clean") + } + + if err := os.WriteFile(filepath.Join(dir, "dirty.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + dirty, err = GitIsDirty() + if err != nil { + t.Fatal(err) + } + if !dirty { + t.Fatal("expected dirty repo") + } +} + +func TestGitRefUsesGitHubEnv(t *testing.T) { + t.Setenv("GITHUB_HEAD_REF", "Feature/My_Branch") + ref, err := GitRef(true, 20) + if err != nil { + t.Fatal(err) + } + if ref != "feature-my-branch" { + t.Fatalf("unexpected ref slug: %q", ref) + } +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + mustGit(t, dir, "init", "-q") + mustGit(t, dir, "config", "user.email", "test@example.com") + mustGit(t, dir, "config", "user.name", "test") + mustGit(t, dir, "config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0644); err != nil { + t.Fatal(err) + } + mustGit(t, dir, "add", ".") + mustGit(t, dir, "commit", "-q", "-m", "feat: initial") + mustGit(t, dir, "tag", "v0.1.0") + return dir +} + +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", + "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@t", + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) + } +}