Skip to content
Merged
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
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@

- `url`: **Required** - Mastodon instance URL.
- `access-token`: **Required** - Mastodon access token for authentication. Use secrets to protect your access token.
- `message`: **Required** - The content of the toot to be posted.
- `message`: **Required** (optional if `media-paths` is provided) - The content of the toot to be posted.
- `visibility`: Optional - Visibility of the toot (`public`, `unlisted`, `private`, `direct`). Defaults to `public`.
- `sensitive`: Optional - Mark the toot and attached media as sensitive. Accepts `true` or `false`. Defaults to `false`.
- `spoiler-text`: Optional - Text to be shown as a warning before the actual content, used when `sensitive` is `true`.
- `language`: Optional - ISO 639 language code for the toot, helping to categorize the post by language.
- `scheduled-at`: Optional - ISO 8601 Datetime when the toot should be posted. Must be at least 5 minutes in the future.
- `media-paths`: Optional - Comma-separated list of media file paths to attach to the toot (max 4). Supports images, video, and audio.
- `media-descriptions`: Optional - Comma-separated list of alt text descriptions for the media files. If only one value is provided, it is used for all attachments.

## Outputs

Expand Down Expand Up @@ -84,6 +86,46 @@ Advanced usage:
echo "Scheduled at: ${{ steps.mastodon_toot.outputs.scheduled_at }}"
```

### Posting with Media Attachments

Attach a single image with alt text:

```yaml
- name: Send toot with image
uses: cbrgm/mastodon-github-action@v2
with:
access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }}
url: ${{ secrets.MASTODON_URL }}
message: "Check out this screenshot!"
media-paths: "screenshot.png"
media-descriptions: "A screenshot of the new dashboard"
```

Attach multiple images (up to 4):

```yaml
- name: Send toot with multiple images
uses: cbrgm/mastodon-github-action@v2
with:
access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }}
url: ${{ secrets.MASTODON_URL }}
message: "Latest photos from the event"
media-paths: "photos/img1.jpg, photos/img2.jpg, photos/img3.jpg"
media-descriptions: "First image description, Second image description, Third image description"
```

Post media without text (media-only toot):

```yaml
- name: Send media-only toot
uses: cbrgm/mastodon-github-action@v2
with:
access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }}
url: ${{ secrets.MASTODON_URL }}
media-paths: "artifact.png"
media-descriptions: "Build artifact from CI"
```

You can find more usage examples in the [./example-workflows](./example-workflows/) subfolder.

#### About message `visibility` types
Expand Down
14 changes: 12 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ inputs:
description: 'Mastodon access token for authentication'
required: true
message:
description: 'The content of the toot'
required: true
description: 'The content of the toot. Optional if media-paths is provided.'
required: false
visibility:
description: 'Visibility of the toot (public, unlisted, private, direct)'
required: false
Expand All @@ -29,6 +29,12 @@ inputs:
scheduled-at:
description: 'ISO 8601 Datetime to schedule the toot. Must be at least 5 minutes in the future'
required: false
media-paths:
description: 'Comma-separated list of media file paths to attach (max 4). Supported: images, video, audio.'
required: false
media-descriptions:
description: 'Comma-separated list of alt text descriptions for media files. If one value is provided, it applies to all.'
required: false

outputs:
id:
Expand Down Expand Up @@ -57,6 +63,10 @@ runs:
- ${{ inputs.language }}
- --scheduled-at
- ${{ inputs.scheduled-at }}
- --media-paths
- ${{ inputs.media-paths }}
- --media-descriptions
- ${{ inputs.media-descriptions }}

branding:
icon: edit
Expand Down
231 changes: 208 additions & 23 deletions cmd/mastodon-github-action/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -41,24 +44,27 @@ func (vt VisibilityType) IsValid() bool {

// MastodonStatus defines the structure for status messages to be sent to Mastodon.
type MastodonStatus struct {
Status string `json:"status"`
Visibility string `json:"visibility"`
Sensitive bool `json:"sensitive,omitempty"`
SpoilerText string `json:"spoiler_text,omitempty"`
Language string `json:"language,omitempty"`
ScheduledAt string `json:"scheduled_at,omitempty"`
Status string `json:"status"`
Visibility string `json:"visibility"`
Sensitive bool `json:"sensitive,omitempty"`
SpoilerText string `json:"spoiler_text,omitempty"`
Language string `json:"language,omitempty"`
ScheduledAt string `json:"scheduled_at,omitempty"`
MediaIDs []string `json:"media_ids,omitempty"`
}

// ActionInputs collects all user inputs required for posting a status.
type ActionInputs struct {
URL string `arg:"--url,required, env:MASTODON_URL"` // Mastodon instance URL.
AccessToken string `arg:"--access-token,required, env:MASTODON_ACCESS_TOKEN"` // User access token for authentication.
Message string `arg:"--message,required, env:MASTODON_MESSAGE"` // The status message content.
Visibility string `arg:"--visibility, env:MASTODON_VISIBILITY"` // Visibility of the status.
Sensitive bool `arg:"--sensitive, env:MASTODON_SENSITIVE"` // Flag to mark status as sensitive.
SpoilerText string `arg:"--spoiler-text, env:MASTODON_SPOILER_TEXT"` // Additional content warning text.
Language string `arg:"--language, env:MASTODON_LANGUAGE"` // Language of the status.
ScheduledAt string `arg:"--scheduled-at, env:MASTODON_SCHEDULED_AT"` // Time to schedule the status.
URL string `arg:"--url,required, env:MASTODON_URL"` // Mastodon instance URL.
AccessToken string `arg:"--access-token,required, env:MASTODON_ACCESS_TOKEN"` // User access token for authentication.
Message string `arg:"--message, env:MASTODON_MESSAGE"` // The status message content.
Visibility string `arg:"--visibility, env:MASTODON_VISIBILITY"` // Visibility of the status.
Sensitive bool `arg:"--sensitive, env:MASTODON_SENSITIVE"` // Flag to mark status as sensitive.
SpoilerText string `arg:"--spoiler-text, env:MASTODON_SPOILER_TEXT"` // Additional content warning text.
Language string `arg:"--language, env:MASTODON_LANGUAGE"` // Language of the status.
ScheduledAt string `arg:"--scheduled-at, env:MASTODON_SCHEDULED_AT"` // Time to schedule the status.
MediaPaths string `arg:"--media-paths, env:MASTODON_MEDIA_PATHS"` // Comma-separated media file paths.
MediaDescriptions string `arg:"--media-descriptions, env:MASTODON_MEDIA_DESCRIPTIONS"` // Comma-separated alt text descriptions.
}

// StatusResponse models the response returned by Mastodon after posting a status.
Expand All @@ -76,39 +82,58 @@ type ScheduledStatusResponse struct {
ScheduledAt time.Time `json:"scheduled_at"`
}

const maxMediaAttachments = 4

func main() {
var args ActionInputs
arg.MustParse(&args) // Parses and validates command-line arguments.
arg.MustParse(&args)

mediaPaths := parseMediaPaths(args.MediaPaths)
hasMedia := len(mediaPaths) > 0

// Ensures the status message is not empty.
if strings.TrimSpace(args.Message) == "" {
log.Fatal("Status message cannot be empty")
if strings.TrimSpace(args.Message) == "" && !hasMedia {
log.Fatal("Status message cannot be empty when no media is attached")
}

if len(mediaPaths) > maxMediaAttachments {
log.Fatalf("Too many media attachments: %d (maximum is %d)", len(mediaPaths), maxMediaAttachments)
}

// Sets default visibility to "public" if not specified.
if args.Visibility == "" {
args.Visibility = string(VisibilityPublic)
}

// Validates the provided visibility against Mastodon's accepted values.
if !VisibilityType(args.Visibility).IsValid() {
log.Fatalf("Invalid visibility: %s", args.Visibility)
}

// Parse and validate the scheduled time, if provided.
scheduledAt, err := parseScheduledAt(args.ScheduledAt)
if err != nil {
log.Fatalf("Scheduled at error: %v", err)
}

// Constructs the status payload and sends it to Mastodon.
var mediaIDs []string
if hasMedia {
descriptions := parseMediaDescriptions(args.MediaDescriptions)
for i, path := range mediaPaths {
desc := resolveDescription(i, descriptions)
id, err := uploadMedia(args.URL, args.AccessToken, path, desc)
if err != nil {
log.Fatalf("Error uploading media %q: %v", path, err)
}
mediaIDs = append(mediaIDs, id)
log.Printf("Uploaded media %q: ID %s", path, id)
}
}

status := MastodonStatus{
Status: args.Message,
Visibility: args.Visibility,
Sensitive: args.Sensitive,
SpoilerText: args.SpoilerText,
Language: args.Language,
ScheduledAt: scheduledAt,
MediaIDs: mediaIDs,
}

if err := postStatus(args.URL, args.AccessToken, status); err != nil {
Expand Down Expand Up @@ -181,7 +206,7 @@ func postStatus(url, accessToken string, status MastodonStatus) error {
// validating that it is at least 5 minutes in the future.
func parseScheduledAt(input string) (string, error) {
if input == "" {
return "", nil // No scheduling requested
return "", nil
}

t, err := time.Parse("2006-01-02 15:04", input)
Expand All @@ -195,3 +220,163 @@ func parseScheduledAt(input string) (string, error) {

return t.Format(time.RFC3339), nil
}

// parseMediaPaths splits a comma-separated list of file paths into a slice,
// trimming whitespace and discarding empty entries.
func parseMediaPaths(input string) []string {
if strings.TrimSpace(input) == "" {
return nil
}
parts := strings.Split(input, ",")
var paths []string
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
paths = append(paths, trimmed)
}
}
return paths
}

// parseMediaDescriptions splits a comma-separated list of descriptions.
func parseMediaDescriptions(input string) []string {
if strings.TrimSpace(input) == "" {
return nil
}
parts := strings.Split(input, ",")
var descs []string
for _, p := range parts {
descs = append(descs, strings.TrimSpace(p))
}
return descs
}

// resolveDescription returns the alt text for a media item at the given index.
// If a specific description exists for that index, it is used. If only one
// description is provided, it is used for all items. Otherwise returns empty string.
func resolveDescription(index int, descriptions []string) string {
if index < len(descriptions) && descriptions[index] != "" {
return descriptions[index]
}
if len(descriptions) == 1 && descriptions[0] != "" {
return descriptions[0]
}
return ""
}

// MediaAttachmentResponse represents the response from the Mastodon media upload API.
type MediaAttachmentResponse struct {
ID string `json:"id"`
Type string `json:"type"`
URL *string `json:"url"`
}

// uploadMedia uploads a media file to the Mastodon instance and returns the attachment ID.
// It uses the v2 media endpoint which may return 202 for async processing.
func uploadMedia(instanceURL, accessToken, filePath, description string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("opening file: %w", err)
}
//nolint: errcheck
defer file.Close()

body := &bytes.Buffer{}
writer := multipart.NewWriter(body)

part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return "", fmt.Errorf("creating form file: %w", err)
}

if _, err := io.Copy(part, file); err != nil {
return "", fmt.Errorf("copying file content: %w", err)
}

if description != "" {
if err := writer.WriteField("description", description); err != nil {
return "", fmt.Errorf("writing description field: %w", err)
}
}

if err := writer.Close(); err != nil {
return "", fmt.Errorf("closing multipart writer: %w", err)
}

apiURL := fmt.Sprintf("%s/api/v2/media", instanceURL)
req, err := http.NewRequest("POST", apiURL, body)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", writer.FormDataContentType())

client := http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("API call: %w", err)
}
//nolint: errcheck
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return "", fmt.Errorf("API response: %s, Body: %s", resp.Status, string(respBody))
}

var attachment MediaAttachmentResponse
if err := json.Unmarshal(respBody, &attachment); err != nil {
return "", fmt.Errorf("unmarshaling response: %w", err)
}

// 202 means async processing — poll until ready
if resp.StatusCode == http.StatusAccepted {
if err := pollMediaProcessing(instanceURL, accessToken, attachment.ID); err != nil {
return "", err
}
}

return attachment.ID, nil
}

// pollMediaProcessing polls GET /api/v1/media/:id until the media is processed.
func pollMediaProcessing(instanceURL, accessToken, mediaID string) error {
apiURL := fmt.Sprintf("%s/api/v1/media/%s", instanceURL, mediaID)
client := http.Client{Timeout: 10 * time.Second}

for attempts := 0; attempts < 30; attempts++ {
time.Sleep(2 * time.Second)

req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return fmt.Errorf("creating poll request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("polling media status: %w", err)
}

body, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint: errcheck

if resp.StatusCode == http.StatusOK {
var attachment MediaAttachmentResponse
if err := json.Unmarshal(body, &attachment); err != nil {
return fmt.Errorf("unmarshaling poll response: %w", err)
}
if attachment.URL != nil {
return nil
}
} else if resp.StatusCode != http.StatusPartialContent {
return fmt.Errorf("unexpected poll response: %s, Body: %s", resp.Status, string(body))
}
}
return fmt.Errorf("media processing timed out for ID %s", mediaID)
}
Loading