Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ plugins:
SEMREL_PLUGIN_DRAFT: "false"
SEMREL_PLUGIN_PRERELEASE: "false"
SEMREL_PLUGIN_ASSETS: "dist/*.tar.gz,dist/*.zip,build/myapp"
SEMREL_PLUGIN_COMMENT_ON_PRS: "true"
```

## `SEMREL_PLUGIN_*` variables
Expand All @@ -56,6 +57,7 @@ plugins:
| `SEMREL_PLUGIN_DRAFT` | Optional | Create the release as a draft. | false |
| `SEMREL_PLUGIN_PRERELEASE` | Optional | Mark the release as a prerelease. | false |
| `SEMREL_PLUGIN_ASSETS` | Optional | Comma-separated file paths or glob patterns to upload as GitHub Release assets. | None |
| `SEMREL_PLUGIN_COMMENT_ON_PRS` | Optional | Add an idempotent comment to pull requests included in the release. Defaults to true; set to `false` to disable. The token needs pull-request read and issue-comment write permissions. | true |

## `SEMREL_*` release context used

Expand All @@ -69,7 +71,7 @@ plugins:

## Example behavior

The plugin creates a GitHub release for the current tag, publishes the changelog as release notes, and can upload matching assets listed in `SEMREL_PLUGIN_ASSETS`.
The plugin creates a GitHub release for the current tag, publishes the changelog as release notes, and can upload matching assets listed in `SEMREL_PLUGIN_ASSETS`. By default it finds pull requests associated with commits since the previous release and comments with a link to the new release. Set `SEMREL_PLUGIN_COMMENT_ON_PRS` to `false` to disable comments. Re-running the same release does not duplicate comments.

Examples:

Expand Down
5 changes: 5 additions & 0 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The provider-github Authors
// This code has been partially generated by an AI.

package main

Expand Down Expand Up @@ -42,6 +43,10 @@ func run(
}

uploadAssets(ctx, cfg, release, stderr)
if err := plugin.CommentOnPullRequests(ctx, cfg, release); err != nil {
_, _ = fmt.Fprintln(stderr, "provider-github:", err)
return 1
}
_, _ = fmt.Fprintf(stdout, "provider-github: created %s for %s/%s (id=%d) %s\n", cfg.TagName, cfg.Owner, cfg.Repo, release.ID, release.URL)
return 0
}
Expand Down
195 changes: 173 additions & 22 deletions internal/plugin/releases.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The provider-github Authors
// This code has been partially generated by an AI.

package plugin

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
Expand All @@ -22,17 +24,19 @@ const defaultBaseURL = "https://api.github.com"

// Config contains the release request derived from the SemRel environment.
type Config struct {
Token string
Owner string
Repo string
BaseURL string
TagName string
Name string
Body string
Draft bool
Prerelease bool
DryRun bool
Assets string
Token string
Owner string
Repo string
BaseURL string
TagName string
Name string
Body string
Draft bool
Prerelease bool
DryRun bool
Assets string
CommentOnPRs bool
CommitHashes []string
}

// Release is the minimal GitHub release response used by the subprocess entrypoint.
Expand Down Expand Up @@ -83,19 +87,36 @@ func ConfigFromEnv(getenv func(string) string) (Config, error) {
if !hasPrerelease {
prerelease = strings.Contains(tagName, "-")
}
commentOnPRs := true
if raw := strings.TrimSpace(getenv("SEMREL_PLUGIN_COMMENT_ON_PRS")); raw != "" {
value, ok := parseBool(raw)
if !ok {
return Config{}, fmt.Errorf("SEMREL_PLUGIN_COMMENT_ON_PRS must be a boolean")
}
commentOnPRs = value
}

cfg := Config{
Token: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_TOKEN"), getenv("GITHUB_TOKEN"))),
Owner: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_OWNER"), repoOwner)),
Repo: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_REPO"), repoName)),
BaseURL: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_BASE_URL"), getenv("SEMREL_PLUGIN_API_URL"), defaultBaseURL)),
TagName: tagName,
Name: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_NAME"), tagName)),
Body: getenv("SEMREL_CHANGELOG"),
Draft: parseBoolValue(getenv("SEMREL_PLUGIN_DRAFT")),
Prerelease: prerelease,
DryRun: parseBoolValue(getenv("SEMREL_DRY_RUN")),
Assets: getenv("SEMREL_PLUGIN_ASSETS"),
Token: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_TOKEN"), getenv("GITHUB_TOKEN"))),
Owner: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_OWNER"), repoOwner)),
Repo: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_REPO"), repoName)),
BaseURL: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_BASE_URL"), getenv("SEMREL_PLUGIN_API_URL"), defaultBaseURL)),
TagName: tagName,
Name: strings.TrimSpace(coalesce(getenv("SEMREL_PLUGIN_NAME"), tagName)),
Body: getenv("SEMREL_CHANGELOG"),
Draft: parseBoolValue(getenv("SEMREL_PLUGIN_DRAFT")),
Prerelease: prerelease,
DryRun: parseBoolValue(getenv("SEMREL_DRY_RUN")),
Assets: getenv("SEMREL_PLUGIN_ASSETS"),
CommentOnPRs: commentOnPRs,
}
if cfg.CommentOnPRs {
hashes := strings.TrimSpace(getenv("SEMREL_COMMIT_HASHES"))
if hashes != "" {
if err := json.Unmarshal([]byte(hashes), &cfg.CommitHashes); err != nil {
return Config{}, fmt.Errorf("parse SEMREL_COMMIT_HASHES: %w", err)
}
}
}

if err := validateConfig(cfg); err != nil {
Expand All @@ -109,6 +130,12 @@ func CreateRelease(ctx context.Context, cfg Config) (*Release, error) {
return New(nil).CreateRelease(ctx, cfg)
}

// CommentOnPullRequests posts a release comment on every pull request associated
// with a commit in the release range.
func CommentOnPullRequests(ctx context.Context, cfg Config, release *Release) error {
return defaultHTTPClient().CommentOnPullRequests(ctx, cfg, release)
}

// UploadReleaseAssets uploads any configured assets for the provided release.
func UploadReleaseAssets(ctx context.Context, cfg Config, release *Release, stderr io.Writer) {
defaultHTTPClient().UploadReleaseAssets(ctx, cfg, release, stderr)
Expand Down Expand Up @@ -189,6 +216,130 @@ func (c *client) CreateRelease(ctx context.Context, cfg Config) (*Release, error
return &Release{ID: parsed.ID, URL: parsed.HTMLURL, UploadURL: parsed.UploadURL}, nil
}

func (c *client) CommentOnPullRequests(ctx context.Context, cfg Config, release *Release) error {
if !cfg.CommentOnPRs || cfg.DryRun || len(cfg.CommitHashes) == 0 {
return nil
}
if release == nil || strings.TrimSpace(release.URL) == "" {
return errors.New("release URL is required to comment on pull requests")
}
baseURL, err := url.Parse(strings.TrimRight(cfg.BaseURL, "/") + "/")
if err != nil {
return fmt.Errorf("parse github base url: %w", err)
}

pullRequests := make(map[int64]struct{})
for _, hash := range cfg.CommitHashes {
hash = strings.TrimSpace(hash)
if hash == "" {
continue
}
endpoint, err := baseURL.Parse(fmt.Sprintf("repos/%s/%s/commits/%s/pulls",
url.PathEscape(cfg.Owner), url.PathEscape(cfg.Repo), url.PathEscape(hash)))
if err != nil {
return fmt.Errorf("build pull requests endpoint for commit %q: %w", hash, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return fmt.Errorf("build pull requests request for commit %q: %w", hash, err)
}
setGitHubHeaders(req, cfg.Token, "application/json", baseURL)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("list pull requests for commit %q: %w", hash, err)
}
var associated []struct {
Number int64 `json:"number"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&associated)
closeErr := resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("list pull requests for commit %q failed: %s", hash, resp.Status)
}
if decodeErr != nil {
return fmt.Errorf("decode pull requests for commit %q: %w", hash, decodeErr)
}
if closeErr != nil {
return fmt.Errorf("close pull requests response for commit %q: %w", hash, closeErr)
}
for _, pullRequest := range associated {
if pullRequest.Number > 0 {
pullRequests[pullRequest.Number] = struct{}{}
}
}
}

marker := "<!-- semrel-release:" + cfg.TagName + " -->"
comment := fmt.Sprintf("%s\nThis pull request has been included in release [%s](%s).", marker, cfg.TagName, release.URL)
for number := range pullRequests {
if err := c.commentOnPullRequest(ctx, cfg, baseURL, number, marker, comment); err != nil {
return err
}
}
return nil
}

func (c *client) commentOnPullRequest(ctx context.Context, cfg Config, baseURL *url.URL, number int64, marker, comment string) error {
commentsURL, err := baseURL.Parse(fmt.Sprintf("repos/%s/%s/issues/%d/comments",
url.PathEscape(cfg.Owner), url.PathEscape(cfg.Repo), number))
if err != nil {
return fmt.Errorf("build comments endpoint for pull request #%d: %w", number, err)
}
listReq, err := http.NewRequestWithContext(ctx, http.MethodGet, commentsURL.String()+"?per_page=100", nil)
if err != nil {
return fmt.Errorf("build comments request for pull request #%d: %w", number, err)
}
setGitHubHeaders(listReq, cfg.Token, "application/json", baseURL)
resp, err := c.httpClient.Do(listReq)
if err != nil {
return fmt.Errorf("list comments for pull request #%d: %w", number, err)
}
var commentsList []struct {
Body string `json:"body"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&commentsList)
closeErr := resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("list comments for pull request #%d failed: %s", number, resp.Status)
}
if decodeErr != nil {
return fmt.Errorf("decode comments for pull request #%d: %w", number, decodeErr)
}
if closeErr != nil {
return fmt.Errorf("close comments response for pull request #%d: %w", number, closeErr)
}
for _, existing := range commentsList {
if strings.Contains(existing.Body, marker) {
return nil
}
}

payload, err := json.Marshal(struct {
Body string `json:"body"`
}{Body: comment})
if err != nil {
return fmt.Errorf("marshal release comment for pull request #%d: %w", number, err)
}
postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, commentsURL.String(), bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build release comment request for pull request #%d: %w", number, err)
}
setGitHubHeaders(postReq, cfg.Token, "application/json", baseURL)
resp, err = c.httpClient.Do(postReq)
if err != nil {
return fmt.Errorf("post release comment to pull request #%d: %w", number, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
message := readMessage(resp.Body)
if message == "" {
message = resp.Status
}
return fmt.Errorf("post release comment to pull request #%d failed: %s", number, message)
}
return nil
}

func (c *client) UploadReleaseAssets(ctx context.Context, cfg Config, release *Release, stderr io.Writer) {
if cfg.DryRun || strings.TrimSpace(cfg.Assets) == "" || release == nil {
return
Expand Down
Loading
Loading