From 0428df81978057662634ea69e285f00e0838d98e Mon Sep 17 00:00:00 2001 From: Markus Waldheim Date: Mon, 6 Jul 2026 10:20:41 +0200 Subject: [PATCH] feat: migrate template to subprocess plugin model Replace the stale gRPC provider scaffold with a generic subprocess example plugin so newly scaffolded SemRels plugins match the current org-wide runtime contract. Add a thin env-driven main wrapper, pure internal plugin logic, tests, updated template docs, and semrel self-release configuration. Refs: semrel#224 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/semrel-release.yaml | 107 ++++++++++++++++++++++++++ .reuse/dep5 | 14 ++-- .semrel.yaml | 22 ++++++ Dockerfile | 30 +++++++- README.md | 79 +++++++++++++------ cmd/plugin/main.go | 29 ++++--- cmd/plugin/main_test.go | 74 ++++++++++++++++++ internal/grpc/server.go | 32 -------- internal/plugin/example.go | 21 +++++ internal/plugin/example_test.go | 60 +++++++++++++++ internal/plugin/provider.go | 33 -------- internal/plugin/provider_test.go | 28 ------- proto/v1 | 1 - 13 files changed, 391 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/semrel-release.yaml create mode 100644 .semrel.yaml create mode 100644 cmd/plugin/main_test.go delete mode 100644 internal/grpc/server.go create mode 100644 internal/plugin/example.go create mode 100644 internal/plugin/example_test.go delete mode 100644 internal/plugin/provider.go delete mode 100644 internal/plugin/provider_test.go delete mode 120000 proto/v1 diff --git a/.github/workflows/semrel-release.yaml b/.github/workflows/semrel-release.yaml new file mode 100644 index 0000000..00d9de5 --- /dev/null +++ b/.github/workflows/semrel-release.yaml @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The plugin-template Authors +# +# semrel-release — uses semrel to version this plugin. +# Flow: push to main → semrel creates tag → release.yml builds binaries + publishes release +# +# condition-github-actions is built from source until its binary releases are stable. +name: Semrel Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: semrel-release + cancel-in-progress: false + +permissions: + contents: write + actions: write + +jobs: + release: + name: Run semrel + runs-on: ubuntu-latest + outputs: + released: ${{ steps.semrel.outputs.released }} + tag: ${{ steps.semrel.outputs.tag }} + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: stable + cache: false + + - name: Download semrel binary + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION=$(gh release view --repo SemRels/semrel --json tagName -q .tagName) + gh release download "$VERSION" --repo SemRels/semrel \ + --pattern "semrel_*_linux_amd64.tar.gz" --dir /tmp + tar -xzf /tmp/semrel_*_linux_amd64.tar.gz -C /tmp + chmod +x /tmp/semrel + echo "semrel $(/tmp/semrel --version 2>/dev/null || /tmp/semrel version 2>/dev/null || echo installed)" + + - name: Install plugins from source + # Build condition-github-actions from source (main branch) until binary + # releases use the subprocess interface expected by semrel's plugin runner. + # See: semrel/.semrel.yaml comment about plugin sourcing strategy. + run: | + set -euo pipefail + PLUGIN_DIR="$HOME/.semrel/plugins" + mkdir -p "$PLUGIN_DIR" + go install github.com/SemRels/condition-github-actions/cmd/plugin@main + mv "$(go env GOPATH)/bin/plugin" "$PLUGIN_DIR/semrel-plugin-condition-github-actions" + echo "Installed plugins:" + ls -la "$PLUGIN_DIR" + + - name: Configure git identity + run: | + git config user.name "semrel-bot" + git config user.email "semrel-bot@users.noreply.github.com" + + - name: Run semrel release + id: semrel + run: /tmp/semrel release --github-output + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Push tag to GitHub + if: steps.semrel.outputs.released == 'true' + run: | + TAG="${{ steps.semrel.outputs.tag }}" + if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + echo "Tag $TAG already exists on remote — skipping (idempotent)." + else + git push origin "refs/tags/$TAG" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger binary build + if: steps.semrel.outputs.released == 'true' + run: | + TAG="${{ steps.semrel.outputs.tag }}" + echo "Triggering release.yml for tag $TAG" + gh workflow run release.yml --ref "$TAG" --repo "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + if [ "${{ steps.semrel.outputs.released }}" = "true" ]; then + echo "### Released ${{ steps.semrel.outputs.tag }} 🚀" >> $GITHUB_STEP_SUMMARY + echo "**Bump:** ${{ steps.semrel.outputs.bump }}" >> $GITHUB_STEP_SUMMARY + else + echo "### No release — no releasable commits found." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.reuse/dep5 b/.reuse/dep5 index f8b9c8f..6c1088c 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -1,14 +1,14 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: {{PROJECT_NAME}} -Upstream-Contact: The {{PROJECT_NAME}} Authors -Source: {{REPO_URL}} +Upstream-Name: plugin-template +Upstream-Contact: The plugin-template Authors +Source: https://github.com/SemRels/plugin-template # ---------------------------------------------------------------------------- # Documentation and community health files # ---------------------------------------------------------------------------- Files: *.md .github/**/*.md -Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors +Copyright: 2026 The plugin-template Authors License: Apache-2.0 # ---------------------------------------------------------------------------- @@ -20,6 +20,7 @@ Files: Makefile go.mod go.sum .golangci.yml + .semrel.yaml *.template .gitignore .gitignore.template @@ -30,8 +31,7 @@ Files: Makefile .reuse/dep5 cmd/**/*.go internal/**/*.go - proto/** -Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors +Copyright: 2026 The plugin-template Authors License: Apache-2.0 # ---------------------------------------------------------------------------- @@ -40,5 +40,5 @@ License: Apache-2.0 Files: LICENSE LICENSES/Apache-2.0.txt NOTICE -Copyright: {{COPYRIGHT_YEAR}} The {{PROJECT_NAME}} Authors +Copyright: 2026 The plugin-template Authors License: Apache-2.0 diff --git a/.semrel.yaml b/.semrel.yaml new file mode 100644 index 0000000..44ba03e --- /dev/null +++ b/.semrel.yaml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The plugin-template Authors +# yaml-language-server: $schema=https://semrel.io/schema/v1.json +# +# semrel uses itself to manage this plugin's releases. +# version_ceiling keeps releases in 0.x.x until deliberately promoted to 1.0.0. +# To release 1.0.0: remove version_ceiling and commit. + +tagPrefix: "v" +version_ceiling: "1.0.0" +ceiling_strategy: clamp +commit_changelog: false +tag_exists_strategy: update-changelog + +branches: + - name: main + +plugins: + # condition-github-actions verifies we are running inside a legitimate + # GitHub Actions CI environment before any git work is done. + - uses: condition-github-actions + phase: condition diff --git a/Dockerfile b/Dockerfile index e4e5c1c..1cae47f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,38 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 The plugin-template Authors -FROM golang:1.25-alpine AS build +# ── build stage ──────────────────────────────────────────────────────────────── +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build + +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev + WORKDIR /src RUN apk add --no-cache ca-certificates git COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/plugin ./cmd/plugin +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /out/plugin ./cmd/plugin + +# ── distroless release image ─────────────────────────────────────────────────── +FROM gcr.io/distroless/static-debian12:nonroot + +ARG VERSION=dev +ARG BUILD_DATE +ARG VCS_REF + +LABEL org.opencontainers.image.title="semrel-plugin-plugin-template" \ + org.opencontainers.image.description="semrel plugin template subprocess example" \ + org.opencontainers.image.url="https://semrel.io" \ + org.opencontainers.image.source="https://github.com/SemRels/plugin-template" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.vendor="SemRels" -FROM gcr.io/distroless/static-debian12 COPY --from=build /out/plugin /usr/local/bin/plugin +USER nonroot ENTRYPOINT ["/usr/local/bin/plugin"] diff --git a/README.md b/README.md index d525aa2..f8b6302 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,72 @@ -# {{PLUGIN_NAME}} +# plugin-template -[![Latest Release](https://img.shields.io/github/v/release/SemRels/{{PLUGIN_NAME}}?label=version&color=blue)](https://github.com/SemRels/{{PLUGIN_NAME}}/releases/latest) +[![Latest Release](https://img.shields.io/github/v/release/SemRels/plugin-template?label=version&color=blue)](https://github.com/SemRels/plugin-template/releases/latest) +[![CI](https://github.com/SemRels/plugin-template/actions/workflows/ci.yml/badge.svg)](https://github.com/SemRels/plugin-template/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/SemRels/plugin-template)](LICENSE) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/SemRels/plugin-template/badge)](https://scorecard.dev/viewer/?uri=github.com/SemRels/plugin-template) +[![REUSE status](https://api.reuse.software/badge/github.com/SemRels/plugin-template)](https://api.reuse.software/info/github.com/SemRels/plugin-template) -> Replace this description with what your SemRel plugin does. +Template repository for SemRels plugins built as standalone Go subprocess binaries. -This repository is based on the `SemRels/plugin-template` GitHub template and provides a clean starting point for provider, analyzer, generator, updater, or hook plugins. +This template reflects the current SemRels plugin architecture: semrel executes a plain Go CLI binary as a subprocess, passes plugin configuration through `SEMREL_PLUGIN_*` variables, passes release context through `SEMREL_*` variables, reads plugin output from standard output, and treats exit code `0` as success and any non-zero exit code as failure. There is no gRPC transport, no network listener, and no protobuf plumbing to wire up. -## Repository Layout +## What the example plugin does + +The scaffolded example is intentionally phase-agnostic. It reads `SEMREL_VERSION` and `SEMREL_DRY_RUN`, writes the required schema handshake to standard error, and prints a confirmation message to standard output: ```text -cmd/plugin/ Plugin entry point -internal/plugin/ Business logic scaffold -internal/grpc/ gRPC transport scaffold -proto/v1 Symlink to the SemRel protobuf contract -.github/workflows/ CI, release, and security automation +stderr: plugin_schema_version=1 +stdout: example plugin invoked for version 1.2.3 (dry-run: false) ``` -## Installation +## Plugin contract + +| Item | Behavior | +| --- | --- | +| `plugin_schema_version=1` | The **first line written to stderr** must be `plugin_schema_version=1`. | +| `SEMREL_VERSION` | Required release version for this example plugin. | +| `SEMREL_DRY_RUN` | Optional boolean (`true`/`false`) indicating dry-run mode. | +| `SEMREL_PLUGIN_*` | Reserved for your phase-specific plugin configuration. | +| stdout | Write the plugin's functional output here. | +| stderr | Write diagnostics, validation errors, and logs here. | +| exit code | `0` means success; any non-zero code means failure. | + +## Repository layout -Published binaries are distributed through releases and synchronized to `registry.semrel.io`. +```text +cmd/plugin/ Thin environment-variable wrapper and process entry point +internal/plugin/ Pure, testable plugin logic with no os/env dependencies +.github/workflows/ CI, release, security, template-sync, and semrel self-release automation +.semrel.yaml semrel dogfooding configuration for this repository +``` -## Development +## Use this template for a real plugin + +1. Create a new repository from this template or copy it into your plugin repository. +2. Rename the Go module in `go.mod` to `github.com/SemRels/`. +3. Rename the binary/package descriptions from `plugin-template` to your plugin name. +4. Implement your phase-specific logic in `internal/plugin/` as pure Go. +5. Keep `cmd/plugin/main.go` thin: read `SEMREL_*` / `SEMREL_PLUGIN_*` with `os.Getenv` (or an injected `getenv` in tests), translate them into a config struct, then call the internal package. +6. Extend the README tables with every `SEMREL_PLUGIN_*` and `SEMREL_*` variable your plugin consumes. +7. Update `.semrel.yaml` and replace the example phase assignment comments/configuration with the plugins and phases your real plugin release process needs. + +## Local development ```bash -go build ./cmd/plugin +go build ./... go test ./... ``` -## Configuration +## Example semrel configuration -See the SemRel documentation for plugin configuration and runtime integration details: - -- https://github.com/SemRels/semrel -- https://registry.semrel.io +```yaml +plugins: + - name: example-plugin + path: ~/.semrel/plugins/semrel-plugin-example + env: + SEMREL_PLUGIN_SAMPLE_OPTION: "value" +``` -## Next Steps +## License -1. Replace all `{{...}}` placeholders. -2. Rename the module path in `go.mod`. -3. Implement your plugin logic in `internal/plugin/`. -4. Wire generated protobuf bindings into `internal/grpc/`. -5. Create your first tagged release with `v*.*.*`. +Apache-2.0 diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index b002fa2..d8da7d3 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -4,22 +4,31 @@ package main import ( - "context" - "log" + "fmt" + "io" "os" - grpcserver "github.com/SemRels/plugin-template/internal/grpc" - semrelplugin "github.com/SemRels/plugin-template/internal/plugin" + plugin "github.com/SemRels/plugin-template/internal/plugin" ) +const pluginSchemaVersion = 1 + func main() { - provider := semrelplugin.NewProvider("replace-me") - server := grpcserver.NewProviderServer(provider) + os.Exit(run(os.Stdout, os.Stderr, os.Getenv)) +} + +func run(stdout, stderr io.Writer, getenv func(string) string) int { + _, _ = fmt.Fprintf(stderr, "plugin_schema_version=%d\n", pluginSchemaVersion) - if _, err := server.Health(context.Background()); err != nil { - log.Printf("plugin health check failed: %v", err) - os.Exit(1) + message, err := plugin.Message(plugin.Config{ + Version: getenv("SEMREL_VERSION"), + DryRun: getenv("SEMREL_DRY_RUN") == "true", + }) + if err != nil { + fmt.Fprintln(stderr, "plugin-template:", err) + return 1 } - log.Printf("%s plugin template is ready", provider.Name()) + fmt.Fprintln(stdout, message) + return 0 } diff --git a/cmd/plugin/main_test.go b/cmd/plugin/main_test.go new file mode 100644 index 0000000..18f03c2 --- /dev/null +++ b/cmd/plugin/main_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The plugin-template Authors + +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env map[string]string + wantCode int + wantStdout string + wantStderr string + }{ + { + name: "success", + env: map[string]string{ + "SEMREL_VERSION": "1.2.3", + }, + wantCode: 0, + wantStdout: "example plugin invoked for version 1.2.3 (dry-run: false)\n", + wantStderr: "plugin_schema_version=1\n", + }, + { + name: "dry run", + env: map[string]string{ + "SEMREL_VERSION": "1.2.3", + "SEMREL_DRY_RUN": "true", + }, + wantCode: 0, + wantStdout: "example plugin invoked for version 1.2.3 (dry-run: true)\n", + wantStderr: "plugin_schema_version=1\n", + }, + { + name: "missing version", + env: map[string]string{}, + wantCode: 1, + wantStdout: "", + wantStderr: "plugin_schema_version=1\nplugin-template: SEMREL_VERSION is required\n", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := run(&stdout, &stderr, envMap(tt.env)) + + require.Equal(t, tt.wantCode, code) + require.Equal(t, tt.wantStdout, stdout.String()) + require.Equal(t, tt.wantStderr, stderr.String()) + require.True(t, strings.HasPrefix(stderr.String(), "plugin_schema_version=1\n")) + }) + } +} + +func envMap(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} diff --git a/internal/grpc/server.go b/internal/grpc/server.go deleted file mode 100644 index c4a1af9..0000000 --- a/internal/grpc/server.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package grpc - -import ( - "context" - - semrelplugin "github.com/SemRels/plugin-template/internal/plugin" -) - -// HealthResponse is a lightweight stand-in until generated protobuf bindings are wired in. -type HealthResponse struct { - Name string -} - -// ProviderServer adapts a provider implementation for the future gRPC transport layer. -type ProviderServer struct { - provider semrelplugin.Provider -} - -func NewProviderServer(provider semrelplugin.Provider) *ProviderServer { - return &ProviderServer{provider: provider} -} - -func (s *ProviderServer) Health(ctx context.Context) (*HealthResponse, error) { - if err := s.provider.HealthCheck(ctx); err != nil { - return nil, err - } - - return &HealthResponse{Name: s.provider.Name()}, nil -} diff --git a/internal/plugin/example.go b/internal/plugin/example.go new file mode 100644 index 0000000..b0fcac6 --- /dev/null +++ b/internal/plugin/example.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The plugin-template Authors + +package plugin + +import "fmt" + +// Config contains the semrel release context used by the example plugin. +type Config struct { + Version string + DryRun bool +} + +// Message returns the example plugin output for the provided release context. +func Message(cfg Config) (string, error) { + if cfg.Version == "" { + return "", fmt.Errorf("SEMREL_VERSION is required") + } + + return fmt.Sprintf("example plugin invoked for version %s (dry-run: %t)", cfg.Version, cfg.DryRun), nil +} diff --git a/internal/plugin/example_test.go b/internal/plugin/example_test.go new file mode 100644 index 0000000..fe8536f --- /dev/null +++ b/internal/plugin/example_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The plugin-template Authors + +package plugin + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + want string + wantErr string + }{ + { + name: "success", + cfg: Config{ + Version: "1.2.3", + DryRun: false, + }, + want: "example plugin invoked for version 1.2.3 (dry-run: false)", + }, + { + name: "dry run", + cfg: Config{ + Version: "v2.0.0", + DryRun: true, + }, + want: "example plugin invoked for version v2.0.0 (dry-run: true)", + }, + { + name: "missing version", + cfg: Config{}, + wantErr: "SEMREL_VERSION is required", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := Message(tt.cfg) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Empty(t, got) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/plugin/provider.go b/internal/plugin/provider.go deleted file mode 100644 index 71a5e01..0000000 --- a/internal/plugin/provider.go +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import "context" - -// Provider defines the minimal contract a SemRel provider plugin should implement. -type Provider interface { - Name() string - HealthCheck(context.Context) error -} - -// ProviderPlugin is a small default implementation that can be extended or replaced. -type ProviderPlugin struct { - name string -} - -func NewProvider(name string) *ProviderPlugin { - if name == "" { - name = "replace-me" - } - - return &ProviderPlugin{name: name} -} - -func (p *ProviderPlugin) Name() string { - return p.name -} - -func (p *ProviderPlugin) HealthCheck(context.Context) error { - return nil -} diff --git a/internal/plugin/provider_test.go b/internal/plugin/provider_test.go deleted file mode 100644 index 537286b..0000000 --- a/internal/plugin/provider_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewProviderDefaultsName(t *testing.T) { - t.Parallel() - - provider := NewProvider("") - - require.Equal(t, "replace-me", provider.Name()) - require.NoError(t, provider.HealthCheck(context.Background())) -} - -func TestNewProviderUsesProvidedName(t *testing.T) { - t.Parallel() - - provider := NewProvider("provider-example") - - require.Equal(t, "provider-example", provider.Name()) -} diff --git a/proto/v1 b/proto/v1 deleted file mode 120000 index b6e09a2..0000000 --- a/proto/v1 +++ /dev/null @@ -1 +0,0 @@ -..\semrel\api\proto\v1 \ No newline at end of file