feat: releases

This commit is contained in:
Romain Bertrand 2026-01-05 12:57:37 +01:00
parent a23a4803ce
commit 36a473a711
2 changed files with 469 additions and 0 deletions

View file

@ -155,6 +155,25 @@ fgj repo clone owner/repo -p ssh
fgj repo fork owner/repo
```
### Releases
```bash
# List releases
fgj release list
# View a release (or use "latest")
fgj release view v1.2.3
# Create a release with notes and optional assets
fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
# Upload assets to an existing release
fgj release upload v1.2.3 ./dist/app.tar.gz --clobber
# Delete a release (keeps the Git tag)
fgj release delete v1.2.3
```
### Forgejo Actions
```bash

450
cmd/release.go Normal file
View file

@ -0,0 +1,450 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
)
var releaseCmd = &cobra.Command{
Use: "release",
Aliases: []string{"releases"},
Short: "Manage releases",
Long: "Create, view, list, upload assets, and delete releases.",
}
var releaseListCmd = &cobra.Command{
Use: "list",
Short: "List releases",
Long: "List releases in a repository.",
RunE: runReleaseList,
}
var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>",
Short: "View a release",
Long: "Display detailed information about a release.",
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
}
var releaseCreateCmd = &cobra.Command{
Use: "create <tag> [files...]",
Short: "Create a release",
Long: "Create a new release and optionally upload assets.",
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
}
var releaseUploadCmd = &cobra.Command{
Use: "upload <tag|latest> <files...>",
Short: "Upload release assets",
Long: "Upload assets to an existing release.",
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDeleteCmd = &cobra.Command{
Use: "delete <tag|latest>",
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
}
func init() {
rootCmd.AddCommand(releaseCmd)
releaseCmd.AddCommand(releaseListCmd)
releaseCmd.AddCommand(releaseViewCmd)
releaseCmd.AddCommand(releaseCreateCmd)
releaseCmd.AddCommand(releaseUploadCmd)
releaseCmd.AddCommand(releaseDeleteCmd)
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
releaseCreateCmd.Flags().StringP("notes", "n", "", "Release notes")
releaseCreateCmd.Flags().StringP("notes-file", "F", "", "Read release notes from file")
releaseCreateCmd.Flags().Bool("draft", false, "Create a draft release")
releaseCreateCmd.Flags().Bool("prerelease", false, "Mark the release as prerelease")
releaseCreateCmd.Flags().String("target", "", "Target commitish (branch or SHA)")
releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name")
releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
}
func runReleaseList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
limit, _ := cmd.Flags().GetInt("limit")
draftValue, _ := cmd.Flags().GetBool("draft")
prereleaseValue, _ := cmd.Flags().GetBool("prerelease")
draftSet := cmd.Flags().Changed("draft")
prereleaseSet := cmd.Flags().Changed("prerelease")
if limit <= 0 {
return fmt.Errorf("limit must be greater than 0")
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "")
if err != nil {
return err
}
pageSize := limit
if pageSize > 50 {
pageSize = 50
}
opts := gitea.ListReleasesOptions{}
if draftSet {
opts.IsDraft = &draftValue
}
if prereleaseSet {
opts.IsPreRelease = &prereleaseValue
}
var releases []*gitea.Release
for page := 1; len(releases) < limit; page++ {
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
batch, _, err := client.ListReleases(owner, name, opts)
if err != nil {
return fmt.Errorf("failed to list releases: %w", err)
}
if len(batch) == 0 {
break
}
releases = append(releases, batch...)
}
if len(releases) > limit {
releases = releases[:limit]
}
if len(releases) == 0 {
fmt.Printf("No releases in %s/%s\n", owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
for _, rel := range releases {
published := releaseTimestamp(rel).Format("2006-01-02")
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
}
_ = w.Flush()
return nil
}
func runReleaseView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "")
if err != nil {
return err
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release))
if release.Target != "" {
fmt.Printf("Target: %s\n", release.Target)
}
if release.Publisher != nil {
fmt.Printf("Author: %s\n", release.Publisher.UserName)
}
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
if !release.PublishedAt.IsZero() {
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
}
if release.HTMLURL != "" {
fmt.Printf("URL: %s\n", release.HTMLURL)
}
if release.Note != "" {
fmt.Printf("\n%s\n", release.Note)
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments))
for _, asset := range attachments {
fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
}
}
return nil
}
func runReleaseCreate(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
notes, _ := cmd.Flags().GetString("notes")
notesFile, _ := cmd.Flags().GetString("notes-file")
draft, _ := cmd.Flags().GetBool("draft")
prerelease, _ := cmd.Flags().GetBool("prerelease")
target, _ := cmd.Flags().GetString("target")
if notes != "" && notesFile != "" {
return fmt.Errorf("use either --notes or --notes-file, not both")
}
tag := args[0]
files := args[1:]
if notesFile != "" {
content, err := os.ReadFile(notesFile)
if err != nil {
return fmt.Errorf("failed to read notes file: %w", err)
}
notes = string(content)
}
if title == "" {
title = tag
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "")
if err != nil {
return err
}
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag,
Target: target,
Title: title,
Note: notes,
IsDraft: draft,
IsPrerelease: prerelease,
})
if err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
fmt.Printf("Release created: %s\n", release.TagName)
if release.HTMLURL != "" {
fmt.Printf("View at: %s\n", release.HTMLURL)
}
if len(files) == 0 {
return nil
}
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
return err
}
fmt.Printf("Uploaded %d asset(s)\n", len(files))
return nil
}
func runReleaseUpload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
clobber, _ := cmd.Flags().GetBool("clobber")
tag := args[0]
files := args[1:]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "")
if err != nil {
return err
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
return err
}
fmt.Printf("Uploaded %d asset(s)\n", len(files))
return nil
}
func runReleaseDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "")
if err != nil {
return err
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
return fmt.Errorf("failed to delete release: %w", err)
}
fmt.Printf("Release %s deleted\n", release.TagName)
return nil
}
func getReleaseByTagOrLatest(client *api.Client, owner, name, tag string) (*gitea.Release, error) {
if strings.EqualFold(tag, "latest") {
release, _, err := client.GetLatestRelease(owner, name)
if err != nil {
return nil, fmt.Errorf("failed to get latest release: %w", err)
}
return release, nil
}
release, _, err := client.GetReleaseByTag(owner, name, tag)
if err != nil {
return nil, fmt.Errorf("failed to get release: %w", err)
}
return release, nil
}
func uploadReleaseAssets(client *api.Client, owner, name string, releaseID int64, files []string, clobber bool) error {
existing := map[string]int64{}
if clobber {
attachments, err := listReleaseAttachments(client, owner, name, releaseID)
if err != nil {
return err
}
for _, attachment := range attachments {
existing[attachment.Name] = attachment.ID
}
}
for _, file := range files {
filename := filepath.Base(file)
if clobber {
if attachmentID, ok := existing[filename]; ok {
if _, err := client.DeleteReleaseAttachment(owner, name, releaseID, attachmentID); err != nil {
return fmt.Errorf("failed to delete existing asset %s: %w", filename, err)
}
}
}
handle, err := os.Open(file)
if err != nil {
return fmt.Errorf("failed to open %s: %w", file, err)
}
_, _, err = client.CreateReleaseAttachment(owner, name, releaseID, handle, filename)
closeErr := handle.Close()
if err != nil {
return fmt.Errorf("failed to upload %s: %w", file, err)
}
if closeErr != nil {
return fmt.Errorf("failed to close %s: %w", file, closeErr)
}
}
return nil
}
func listReleaseAttachments(client *api.Client, owner, name string, releaseID int64) ([]*gitea.Attachment, error) {
var all []*gitea.Attachment
for page := 1; ; page++ {
attachments, _, err := client.ListReleaseAttachments(owner, name, releaseID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, fmt.Errorf("failed to list release assets: %w", err)
}
if len(attachments) == 0 {
break
}
all = append(all, attachments...)
}
return all, nil
}
func releaseType(release *gitea.Release) string {
if release.IsDraft {
return "draft"
}
if release.IsPrerelease {
return "prerelease"
}
return "release"
}
func releaseTimestamp(release *gitea.Release) time.Time {
if !release.PublishedAt.IsZero() {
return release.PublishedAt
}
return release.CreatedAt
}