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())
+ }
+}