First commit

This commit is contained in:
GRMrGecko 2024-10-07 23:49:47 -05:00
commit 886da1fce3
32 changed files with 1472 additions and 0 deletions

29
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,29 @@
on:
release:
types: [created]
permissions:
contents: write
packages: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v4
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

21
.github/workflows/test_golang.yaml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Go package
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
goreleaser-http-repo-builder

26
.goreleaser.yaml Normal file
View File

@ -0,0 +1,26 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}"
wrap_in_directory: true

19
LICENSE.txt Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# goreleaser-http-repo-builder
This tool was written out of the need to build a release repository compatible with [go-selfupdate](https://github.com/creativeprojects/go-selfupdate) with releases built by [goreleaser](https://goreleaser.com/).
## Example Usage
The command has extensive help available, the following is an example of building a release and adding it to a new repo.
```bash
goreleaser release --snapshot --skip=publish
mkdir repo
goreleaser-http-repo-builder add-release --repo=repo/ --release=dist/
```
After adding a release, you can copy the repo to your web server for update distrobution.
## Example Goreleaser Config
While there is good [documentation available](https://goreleaser.com/customization/) that I'd recommend reading, the following provides some examples that may be helpful in generating a release that is compatible with go-selfupdate.
- The checksums file name defaults to preappend the project name, which is not compatible if you wish to use the checksums to verify an update.
- If you're signing releases with an ECDSA key, this is what I found works best.
- If you need to specify the version manually, you can edit the version template. By default, goreleaser will use the git tag to determine the version.
```yaml
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
wrap_in_directory: true
checksum:
name_template: "checksums.txt"
signs:
- artifacts: all
cmd: openssl
args:
- dgst
- -sha256
- -sign
- "signing.key"
- -out
- ${signature}
- ${artifact}
snapshot:
version_template: "v0.1.2"
```

187
add_release_cmd.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
)
type AddReleaseCmd struct {
Release string `help:"Path to goreleaser dist folder." required:"" type:"existingdir"`
Notes string `help:"Notes about this release."`
Draft bool `help:"Is this release a draft?"`
Prerelease bool `help:"Is this a prelease?"`
IncludeBinary bool `help:"Include binary artifacts."`
Force bool `help:"Force add, removing existing if needed."`
PublishedAt time.Time `help:"Specify exact time for release."`
PublishedAtNow bool `help:"Use the current time for published at instead of the metadata date."`
}
// Adds a release to a repo.
func (a *AddReleaseCmd) Run() error {
// Read existing manifest for repo.
manifestFile := filepath.Join(app.flags.Repo, "manifest.yaml")
manifest, err := readManifestFile(manifestFile)
if os.IsNotExist(err) {
err = os.MkdirAll(app.flags.Repo, 0755)
}
if err != nil {
return err
}
// Read metadata from goreleaser.
metadata, err := readMetadataFile(filepath.Join(a.Release, "metadata.json"))
if err != nil {
return err
}
versionPath := filepath.Join(app.flags.Repo, metadata.Version)
// Read the artifcats to ensure we have a valid release.
artifacts, err := readArtifactFile(filepath.Join(a.Release, "artifacts.json"))
if err != nil {
return err
}
if len(artifacts) == 0 {
return errors.New("no artifacts in release")
}
// Validate the base dir for artifacts. It could be one dir up, or 2 dirs up.
artifcatBase := a.Release
artifactLayers := 0
if _, serr := os.Stat(filepath.Join(artifcatBase, artifacts[0].Path)); serr != nil {
artifcatBase = filepath.Dir(artifcatBase)
artifactLayers = 1
if _, serr := os.Stat(filepath.Join(artifcatBase, artifacts[0].Path)); serr != nil {
artifcatBase = filepath.Dir(artifcatBase)
artifactLayers = 2
if _, serr := os.Stat(filepath.Join(artifcatBase, artifacts[0].Path)); serr != nil {
return errors.New("unable to determine artificate base path")
}
}
}
// Check if the version already exists.
existingIndex := -1
for i, release := range manifest.Releases {
if release.TagName == metadata.Version {
existingIndex = i
break
}
}
// If the version already exists, ask about replacing.
if existingIndex != -1 {
if !a.Force {
ans := askForConfirmation("This release already exists, should we replace?")
// If we don't want to replace, we should stop here.
if !ans {
return errors.New("version already exists")
}
}
// We need to replace the release, so remove it.
manifest.Releases = append(manifest.Releases[:existingIndex], manifest.Releases[existingIndex+1:]...)
// Remove the version directory.
os.RemoveAll(versionPath)
}
// Make the release.
manifest.LastReleaseID++
release := &HttpRelease{
ReleaseID: manifest.LastReleaseID,
Name: metadata.Name,
TagName: metadata.Version,
URL: metadata.Version,
Draft: a.Draft,
Prerelease: a.Prerelease,
PublishedAt: metadata.Date,
ReleaseNotes: a.Notes,
}
// If the publish date provided is valid, override.
if !a.PublishedAt.IsZero() {
release.PublishedAt = a.PublishedAt
}
// If published at is requested to be now, override.
if a.PublishedAtNow {
release.PublishedAt = app.now
}
// Make the directory for the release.
err = os.Mkdir(versionPath, 0755)
if err != nil && !os.IsExist(err) {
return fmt.Errorf("Error making release directory: %s", err)
}
// Add artifacts.
for _, artifact := range artifacts {
// Skip binaries if not included.
if artifact.Type == "Binary" && !a.IncludeBinary {
continue
}
// Get the file path and confirm it exists and get its stat for file size.
path := filepath.Join(artifcatBase, artifact.Path)
stat, serr := os.Stat(path)
if serr != nil {
log.Println("Ignoring artifact", artifact.Name, "as its file does not exist.")
continue
}
// Determine relative path.
s := strings.Split(artifact.Path, "/")
relativePath := filepath.Join(s[artifactLayers:]...)
// Determine if artifact is in its own sub dir, make sure it exists.
dir := filepath.Dir(relativePath)
if dir != "." {
os.MkdirAll(filepath.Join(versionPath, dir), 0755)
}
// Copy artifact to repo.
err = copyFile(path, filepath.Join(versionPath, relativePath))
if err != nil {
log.Printf("Failed to copy artifact, skipping it: %s", err)
continue
}
// Make asset.
manifest.LastAssetID++
asset := &HttpAsset{
ID: manifest.LastAssetID,
Name: artifact.Name,
Size: int(stat.Size()),
URL: filepath.Join(metadata.Version, relativePath),
}
// Add to the release.
release.Assets = append(release.Assets, asset)
}
// Add release to manifest.
manifest.Releases = append(manifest.Releases, release)
// Write the manifest.
err = writeManifestFile(manifestFile, manifest)
if err != nil {
return err
}
// If not a draft or prerelease, link latest to this release.
if !a.Draft && !a.Prerelease {
latestPath := filepath.Join(app.flags.Repo, "latest")
os.Remove(latestPath)
os.Symlink(metadata.Version, latestPath)
}
log.Println("Added release", metadata.Version, "for", metadata.Name, "to the repo", app.flags.Repo)
return nil
}

39
flags.go Normal file
View File

@ -0,0 +1,39 @@
package main
import (
"fmt"
"github.com/alecthomas/kong"
)
type VersionFlag bool
func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil }
func (v VersionFlag) IsBool() bool { return true }
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
fmt.Println(appName + ": " + appVersion)
app.Exit(0)
return nil
}
// Flags supplied to cli.
type Flags struct {
Version VersionFlag `name:"version" help:"Print version information and quit"`
Repo string `help:"The path to a repo" required:"" type:"existingdir"`
AddRelease AddReleaseCmd `cmd:"" help:"Add an release to the repo"`
Prune PruneCmd `cmd:"" help:"Prune releases from repo."`
}
// Parse the supplied flags.
func (a *App) ParseFlags() *kong.Context {
app.flags = &Flags{}
ctx := kong.Parse(app.flags,
kong.Name(appName),
kong.Description(appDescription),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
}),
)
return ctx
}

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module github.com/grmrgecko/goreleaser-http-repo-builder
go 1.23
require (
github.com/alecthomas/kong v1.2.1
gopkg.in/yaml.v3 v3.0.1
)

12
go.sum Normal file
View File

@ -0,0 +1,12 @@
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.2.1 h1:E8jH4Tsgv6wCRX2nGrdPyHDUCSG83WH2qE4XLACD33Q=
github.com/alecthomas/kong v1.2.1/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

57
goreleaser.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"encoding/json"
"os"
"time"
)
// The metadata needed from goreleaser.
type Metadata struct {
Name string `json:"project_name"`
Version string `json:"version"`
Date time.Time `json:"date"`
}
// Read and parse metadata file
func readMetadataFile(metadataFile string) (*Metadata, error) {
// Read file, if error return the error.
jsonFile, err := os.Open(metadataFile)
if err != nil {
return nil, err
}
// Attempt to decode the file.
metadata := new(Metadata)
decoder := json.NewDecoder(jsonFile)
err = decoder.Decode(metadata)
jsonFile.Close()
// Return the metadata and if any error occurred.
return metadata, err
}
// Artifcat map.
type Artifact struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
// Read and parse metadata file
func readArtifactFile(artifactFile string) ([]*Artifact, error) {
// Read file, if error return the error.
jsonFile, err := os.Open(artifactFile)
if err != nil {
return nil, err
}
// Attempt to decode the file.
var artifacts []*Artifact
decoder := json.NewDecoder(jsonFile)
err = decoder.Decode(&artifacts)
jsonFile.Close()
// Return the metadata and if any error occurred.
return artifacts, err
}

28
main.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"time"
)
const (
appName = "goreleaser-http-repo-builder"
appDescription = "Builds a repo for use with go-selfupdate"
appVersion = "0.1.0"
)
// App is the global application structure for communicating between servers and storing information.
type App struct {
flags *Flags
now time.Time
}
var app *App
func main() {
app = new(App)
app.now = time.Now()
ctx := app.ParseFlags()
err := ctx.Run()
ctx.FatalIfErrorf(err)
}

224
main_test.go Normal file
View File

@ -0,0 +1,224 @@
package main
import (
"crypto/md5"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
// Test the add release option.
func TestAppFunctionality(t *testing.T) {
// Make temp directory to build repo.
dname, err := os.MkdirTemp("", "goreleaser-http-repo-builder")
if err != nil {
t.Errorf("error making tempdir: %s", err)
}
// Get the tests dir with test files.
testsDir, err := filepath.Abs("tests")
if err != nil {
t.Errorf("error finding tests dir: %s", err)
}
// Now date for app defines.
now, _ := time.Parse(time.DateOnly, "2024-10-08")
// Test adding a release of v0.1.
os.Args = []string{"test", "--repo", dname, "add-release", "--notes", "This is a test.", "--release", filepath.Join(testsDir, "v0.1")}
app = new(App)
app.now = now
ctx := app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Test adding a release of v0.1.1.
os.Args = []string{"test", "--repo", dname, "add-release", "--draft", "--release", filepath.Join(testsDir, "v0.1.1")}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Test adding a release of v0.1.2.
os.Args = []string{"test", "--repo", dname, "add-release", "--prerelease", "--published-at", "2024-10-05T22:15:21.731224367-05:00", "--release", filepath.Join(testsDir, "v0.1.2")}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Confirm the latest release is v0.1.0.
latestPath, err := os.Readlink(filepath.Join(dname, "latest"))
if err != nil {
t.Errorf("error reading link to latest: %s", err)
}
if latestPath != "v0.1.0" {
t.Error("the latest link isn't correctly linked")
}
// Hash the manifest file without the published_at dates.
hfun := md5.New()
d, err := os.ReadFile(filepath.Join(dname, "manifest.yaml"))
if err != nil {
t.Errorf("error reading manifest file: %s", err)
}
// Hash the result and confirm.
hfun.Write(d)
sum := hfun.Sum(nil)
hash := hex.EncodeToString(sum)
if hash != "01240af1d189ea540418903e15eb3068" {
t.Errorf("hash isn't valid for manifest file: %s", hash)
}
// Binaries are not included.
if _, serr := os.Stat(filepath.Join(dname, "v0.1.0/example_linux_amd64/example")); !os.IsNotExist(serr) {
t.Error("v0.1.0 binary exists, when it shouldn't exist.")
}
// Confirm the asset was copied correctly.
d, err = os.ReadFile(filepath.Join(dname, "v0.1.0/example_linux_amd64.tar.gz"))
if err != nil {
t.Errorf("error reading test file: %s", err)
}
hfun.Reset()
hfun.Write(d)
sum = hfun.Sum(nil)
hash = hex.EncodeToString(sum)
if hash != "9cffcbe826ae684db1c8a08ff9216f34" {
t.Errorf("hash isn't valid for test file: %s", hash)
}
// Confirm pruning of max releases works.
os.Args = []string{"test", "--repo", dname, "prune", "--max-age=216h"}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Confirm pruned state.
if _, serr := os.Stat(filepath.Join(dname, "v0.1.0/example_linux_amd64.tar.gz")); !os.IsNotExist(serr) {
t.Error("v0.1.0 exists, when it shouldn't exist.")
}
if _, serr := os.Stat(filepath.Join(dname, "v0.1.1/example_linux_amd64.tar.gz")); os.IsNotExist(serr) {
t.Error("v0.1.1 does not exists, when it should.")
}
if _, serr := os.Stat(filepath.Join(dname, "v0.1.2/example_linux_amd64.tar.gz")); os.IsNotExist(serr) {
t.Error("v0.1.2 does not exists, when it should.")
}
// Delete all files, and reset.
os.RemoveAll(dname)
os.Mkdir(dname, 0755)
// Test adding a release of v0.1.
os.Args = []string{"test", "--repo", dname, "add-release", "--include-binary", "--release", filepath.Join(testsDir, "v0.1")}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Test adding a release of v0.1.1.
os.Args = []string{"test", "--repo", dname, "add-release", "--release", filepath.Join(testsDir, "v0.1.1")}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Test adding a release of v0.1.2.
os.Args = []string{"test", "--repo", dname, "add-release", "--published-at-now", "--release", filepath.Join(testsDir, "v0.1.2")}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Confirm the latest release is v0.1.2.
latestPath, err = os.Readlink(filepath.Join(dname, "latest"))
if err != nil {
t.Errorf("error reading link to latest: %s", err)
}
if latestPath != "v0.1.2" {
t.Error("the latest link isn't correctly linked")
}
// Hash the manifest file without the published_at dates.
hfun.Reset()
d, err = os.ReadFile(filepath.Join(dname, "manifest.yaml"))
if err != nil {
t.Errorf("error reading manifest file: %s", err)
}
// Hash the result and confirm.
hfun.Write(d)
sum = hfun.Sum(nil)
hash = hex.EncodeToString(sum)
if hash != "dfac4ec2fc35bb04c8f5f79e057dfbe9" {
t.Errorf("hash isn't valid for manifest file: %s", hash)
}
// Binaries are not included.
if _, serr := os.Stat(filepath.Join(dname, "v0.1.0/example_linux_amd64/example")); os.IsNotExist(serr) {
t.Error("v0.1.0 binary does not exists, when it shouldn.")
}
// Confirm pruning of max releases works.
os.Args = []string{"test", "--repo", dname, "prune", "--max-releases=1"}
app = new(App)
app.now = now
ctx = app.ParseFlags()
// Run the command.
err = ctx.Run()
if err != nil {
t.Errorf("error running the app: %s", err)
}
// Confirm pruned state.
if _, serr := os.Stat(filepath.Join(dname, "v0.1.0/example_linux_amd64.tar.gz")); !os.IsNotExist(serr) {
t.Error("v0.1.0 exists, when it shouldn't exist.")
}
if _, serr := os.Stat(filepath.Join(dname, "v0.1.1/example_linux_amd64.tar.gz")); !os.IsNotExist(serr) {
t.Error("v0.1.1 exists, when it shouldn't exist.")
}
if _, serr := os.Stat(filepath.Join(dname, "v0.1.2/example_linux_amd64.tar.gz")); os.IsNotExist(serr) {
t.Error("v0.1.2 does not exists, when it should.")
}
// Cleanup.
os.RemoveAll(dname)
}

71
manifest.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"os"
"time"
"gopkg.in/yaml.v3"
)
// An individual asset.
type HttpAsset struct {
ID int64 `yaml:"id"`
Name string `yaml:"name"`
Size int `yaml:"size"`
URL string `yaml:"url"`
}
// An individual release.
type HttpRelease struct {
ReleaseID int64 `yaml:"release_id"`
Name string `yaml:"name"`
TagName string `yaml:"tag_name"`
URL string `yaml:"url"`
Draft bool `yaml:"draft"`
Prerelease bool `yaml:"prerelease"`
PublishedAt time.Time `yaml:"published_at"`
ReleaseNotes string `yaml:"release_notes"`
Assets []*HttpAsset `yaml:"assets"`
}
// The manifest file structure.
type HttpManifest struct {
LastReleaseID int64 `yaml:"last_release_id"`
LastAssetID int64 `yaml:"last_asset_id"`
Releases []*HttpRelease `yaml:"releases"`
}
// Read and parse manifest file.
func readManifestFile(manifestFile string) (*HttpManifest, error) {
// We always want a manifest incase repo just needs to start from scratch.
manifest := new(HttpManifest)
// Read file, if error return the error.
yamlFile, err := os.Open(manifestFile)
if err != nil {
return manifest, err
}
// Attempt to decode the file.
decoder := yaml.NewDecoder(yamlFile)
err = decoder.Decode(manifest)
yamlFile.Close()
// Return the manifest and if any error occurred.
return manifest, err
}
// Write manifest file.
func writeManifestFile(manifestFile string, manifest *HttpManifest) error {
// Open the file for write.
yamlFile, err := os.OpenFile(manifestFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer yamlFile.Close()
// Encode data.
encoder := yaml.NewEncoder(yamlFile)
err = encoder.Encode(manifest)
return err
}

125
prune_cmd.go Normal file
View File

@ -0,0 +1,125 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
type PruneCmd struct {
MaxAge time.Duration `help:"Delete releases older than."`
MaxReleases int `help:"Maximum number of releases to keep."`
DryRun bool `help:"Just log the result without actually pruning."`
}
// Extra help to explain you can't set 2 prune stratages.
func (a *PruneCmd) Help() string {
return "You cannot use both max-age and max-releases, only set one."
}
// Verify the options provided to the command.
func (a *PruneCmd) AfterApply() error {
// If both stratages are defined, we don't allow that.
if a.MaxAge > time.Duration(0) && a.MaxReleases > 0 {
return errors.New("must only provide one prune argument")
}
// If no stratages are defined, we don't allow that.
if a.MaxAge <= time.Duration(0) && a.MaxReleases <= 0 {
return errors.New("must provide one prune argument")
}
return nil
}
// Adds a release to a repo.
func (a *PruneCmd) Run() error {
// Read existing manifest for repo.
manifestFile := filepath.Join(app.flags.Repo, "manifest.yaml")
manifest, err := readManifestFile(manifestFile)
if err != nil {
return err
}
// Keep reference of number of pruned releases.
releasesPruned := 0
n := len(manifest.Releases)
// If max releases defined and is less than number of releases, look for items to prune.
if a.MaxReleases > 0 && n > a.MaxReleases {
// Loop starting at max releases.
for i := a.MaxReleases; i < n; i++ {
// Get the current release.
// We want pull from the top of the stack downward to keep newer releases.
version := manifest.Releases[n-(i+1)].TagName
log.Println("Removing release:", version)
// If this isn't a dry run, remove the version directory.
if !a.DryRun {
err = os.RemoveAll(filepath.Join(app.flags.Repo, version))
if err != nil {
return fmt.Errorf("untable to remove release files: %s", err)
}
}
// Count the number pruned.
releasesPruned++
}
// Remove releases from the slice.
manifest.Releases = manifest.Releases[n-a.MaxReleases:]
}
// If we are pruning based on duration, do so.
if a.MaxAge > time.Duration(0) {
// Loop through the releases, and find old releases.
for i := 0; i < n; i++ {
// If n is 1, we removed too many entries and need to stop.
if n == 1 {
log.Println("The repo has only 1 release remaining, ending the prune here to keep 1 release.")
break
}
// Get the current release, and confirm its age.
release := manifest.Releases[i]
if app.now.Sub(release.PublishedAt) >= a.MaxAge {
// This release is too old, so we need to remove it.
version := release.TagName
log.Println("Removing release:", version)
// If this isn't a dry run, remove the version files.
if !a.DryRun {
err = os.RemoveAll(filepath.Join(app.flags.Repo, version))
if err != nil {
return fmt.Errorf("untable to remove release files: %s", err)
}
}
// Remove the release from the slice.
manifest.Releases = append(manifest.Releases[:i], manifest.Releases[i+1:]...)
// Back up one on the index and number of releases as one release was deleted.
i--
n--
// Count number of releases pruned.
releasesPruned++
}
}
}
// Write the manifest if this isn't a dry run.
if !a.DryRun {
err = writeManifestFile(manifestFile, manifest)
if err != nil {
return err
}
}
// Provide details on what's been pruned.
log.Println("Pruned", releasesPruned, "release from the repo.")
return nil
}

View File

@ -0,0 +1,34 @@
[
{
"name": "metadata.json",
"path": "v0.1.1/metadata.json",
"internal_type": 30,
"type": "Metadata"
},
{
"name": "example_linux_amd64.tar.gz",
"path": "v0.1.1/example_linux_amd64.tar.gz",
"goos": "linux",
"goarch": "amd64",
"goamd64": "v1",
"internal_type": 1,
"type": "Archive",
"extra": {
"Binaries": [
"example"
],
"Checksum": "sha256:9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3",
"Format": "tar.gz",
"ID": "default",
"Replaces": null,
"WrappedIn": "example_linux_amd64"
}
},
{
"name": "checksums.txt",
"path": "v0.1.1/checksums.txt",
"internal_type": 12,
"type": "Checksum",
"extra": {}
}
]

View File

@ -0,0 +1 @@
9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3 example_linux_amd64.tar.gz

113
tests/v0.1.1/config.yaml Normal file
View File

@ -0,0 +1,113 @@
version: 2
project_name: example
release:
github:
owner: GRMrGecko
name: goreleaser-http-repo-builder
name_template: '{{.Tag}}'
builds:
- id: example
goos:
- linux
goarch:
- amd64
- arm64
goarm:
- "6"
gomips:
- hardfloat
goamd64:
- v1
targets:
- linux_amd64_v1
- linux_arm64
dir: .
main: .
binary: example
builder: go
gobinary: go
command: build
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser
env:
- CGO_ENABLED=0
archives:
- id: default
name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}'
format: tar.gz
wrap_in_directory: "true"
files:
- src: license*
- src: LICENSE*
- src: readme*
- src: README*
- src: changelog*
- src: CHANGELOG*
snapshot:
version_template: v0.1.0
checksum:
name_template: checksums.txt
algorithm: sha256
changelog:
format: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'
dist: dist
env_files:
github_token: ~/.config/goreleaser/github_token
gitlab_token: ~/.config/goreleaser/gitlab_token
gitea_token: ~/.config/goreleaser/gitea_token
before:
hooks:
- go mod tidy
source:
name_template: '{{ .ProjectName }}-{{ .Version }}'
format: tar.gz
gomod:
gobinary: go
announce:
twitter:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
mastodon:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
server: ""
reddit:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
url_template: '{{ .ReleaseURL }}'
slack:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
username: GoReleaser
discord:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
author: GoReleaser
color: "3888754"
icon_url: https://goreleaser.com/static/avatar.png
teams:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
color: '#2D313E'
icon_url: https://goreleaser.com/static/avatar.png
smtp:
subject_template: '{{ .ProjectName }} {{ .Tag }} is out!'
body_template: 'You can view details from: {{ .ReleaseURL }}'
mattermost:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
username: GoReleaser
linkedin:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
telegram:
message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}'
parse_mode: MarkdownV2
webhook:
message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}'
content_type: application/json; charset=utf-8
opencollective:
title_template: '{{ .Tag }}'
message_template: '{{ .ProjectName }} {{ .Tag }} is out!<br/>Check it out at <a href="{{ .ReleaseURL }}">{{ .ReleaseURL }}</a>'
bluesky:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
git:
tag_sort: -version:refname
github_urls:
download: https://github.com
gitlab_urls:
download: https://gitlab.com

View File

@ -0,0 +1,2 @@
ŽíõÞ}Î<>Iƒ`É<>|ÀGéNÙãðFñttV—hÉtÍËËõ€ÕØÙŸ¥øF

View File

@ -0,0 +1,12 @@
{
"project_name": "example",
"tag": "v0.0.0",
"previous_tag": "",
"version": "v0.1.1",
"commit": "521be63afb85d785ce36b4bd0d7412664593ac1d",
"date": "2024-09-30T09:26:01.612178185-05:00",
"runtime": {
"goos": "linux",
"goarch": "amd64"
}
}

View File

@ -0,0 +1,34 @@
[
{
"name": "metadata.json",
"path": "v0.1.2/metadata.json",
"internal_type": 30,
"type": "Metadata"
},
{
"name": "example_linux_amd64.tar.gz",
"path": "v0.1.2/example_linux_amd64.tar.gz",
"goos": "linux",
"goarch": "amd64",
"goamd64": "v1",
"internal_type": 1,
"type": "Archive",
"extra": {
"Binaries": [
"example"
],
"Checksum": "sha256:9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3",
"Format": "tar.gz",
"ID": "default",
"Replaces": null,
"WrappedIn": "example_linux_amd64"
}
},
{
"name": "checksums.txt",
"path": "v0.1.2/checksums.txt",
"internal_type": 12,
"type": "Checksum",
"extra": {}
}
]

View File

@ -0,0 +1 @@
9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3 example_linux_amd64.tar.gz

113
tests/v0.1.2/config.yaml Normal file
View File

@ -0,0 +1,113 @@
version: 2
project_name: example
release:
github:
owner: GRMrGecko
name: goreleaser-http-repo-builder
name_template: '{{.Tag}}'
builds:
- id: example
goos:
- linux
goarch:
- amd64
- arm64
goarm:
- "6"
gomips:
- hardfloat
goamd64:
- v1
targets:
- linux_amd64_v1
- linux_arm64
dir: .
main: .
binary: example
builder: go
gobinary: go
command: build
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser
env:
- CGO_ENABLED=0
archives:
- id: default
name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}'
format: tar.gz
wrap_in_directory: "true"
files:
- src: license*
- src: LICENSE*
- src: readme*
- src: README*
- src: changelog*
- src: CHANGELOG*
snapshot:
version_template: v0.1.0
checksum:
name_template: checksums.txt
algorithm: sha256
changelog:
format: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'
dist: dist
env_files:
github_token: ~/.config/goreleaser/github_token
gitlab_token: ~/.config/goreleaser/gitlab_token
gitea_token: ~/.config/goreleaser/gitea_token
before:
hooks:
- go mod tidy
source:
name_template: '{{ .ProjectName }}-{{ .Version }}'
format: tar.gz
gomod:
gobinary: go
announce:
twitter:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
mastodon:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
server: ""
reddit:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
url_template: '{{ .ReleaseURL }}'
slack:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
username: GoReleaser
discord:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
author: GoReleaser
color: "3888754"
icon_url: https://goreleaser.com/static/avatar.png
teams:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
color: '#2D313E'
icon_url: https://goreleaser.com/static/avatar.png
smtp:
subject_template: '{{ .ProjectName }} {{ .Tag }} is out!'
body_template: 'You can view details from: {{ .ReleaseURL }}'
mattermost:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
username: GoReleaser
linkedin:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
telegram:
message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}'
parse_mode: MarkdownV2
webhook:
message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}'
content_type: application/json; charset=utf-8
opencollective:
title_template: '{{ .Tag }}'
message_template: '{{ .ProjectName }} {{ .Tag }} is out!<br/>Check it out at <a href="{{ .ReleaseURL }}">{{ .ReleaseURL }}</a>'
bluesky:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
git:
tag_sort: -version:refname
github_urls:
download: https://github.com
gitlab_urls:
download: https://gitlab.com

View File

@ -0,0 +1,2 @@
ŽíõÞ}Î<>Iƒ`É<>|ÀGéNÙãðFñttV—hÉtÍËËõ€ÕØÙŸ¥øF

View File

@ -0,0 +1,12 @@
{
"project_name": "example",
"tag": "v0.0.0",
"previous_tag": "",
"version": "v0.1.2",
"commit": "94bb85eb32ea07f33d627ce0dee905e29d8d1c96",
"date": "2024-10-07T22:15:21.731224367-05:00",
"runtime": {
"goos": "linux",
"goarch": "amd64"
}
}

47
tests/v0.1/artifacts.json Normal file
View File

@ -0,0 +1,47 @@
[
{
"name": "metadata.json",
"path": "v0.1/metadata.json",
"internal_type": 30,
"type": "Metadata"
},
{
"name": "example",
"path": "v0.1/example_linux_amd64/example",
"goos": "linux",
"goarch": "amd64",
"internal_type": 4,
"type": "Binary",
"extra": {
"Binary": "example",
"Ext": "",
"ID": "example"
}
},
{
"name": "example_linux_amd64.tar.gz",
"path": "v0.1/example_linux_amd64.tar.gz",
"goos": "linux",
"goarch": "amd64",
"goamd64": "v1",
"internal_type": 1,
"type": "Archive",
"extra": {
"Binaries": [
"example"
],
"Checksum": "sha256:9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3",
"Format": "tar.gz",
"ID": "default",
"Replaces": null,
"WrappedIn": "example_linux_amd64"
}
},
{
"name": "checksums.txt",
"path": "v0.1/checksums.txt",
"internal_type": 12,
"type": "Checksum",
"extra": {}
}
]

1
tests/v0.1/checksums.txt Normal file
View File

@ -0,0 +1 @@
9208c58af1265438c6894499847355bd5e77f93d04b201393baf41297d4680a3 example_linux_amd64.tar.gz

113
tests/v0.1/config.yaml Normal file
View File

@ -0,0 +1,113 @@
version: 2
project_name: example
release:
github:
owner: GRMrGecko
name: goreleaser-http-repo-builder
name_template: '{{.Tag}}'
builds:
- id: example
goos:
- linux
goarch:
- amd64
- arm64
goarm:
- "6"
gomips:
- hardfloat
goamd64:
- v1
targets:
- linux_amd64_v1
- linux_arm64
dir: .
main: .
binary: example
builder: go
gobinary: go
command: build
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser
env:
- CGO_ENABLED=0
archives:
- id: default
name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}'
format: tar.gz
wrap_in_directory: "true"
files:
- src: license*
- src: LICENSE*
- src: readme*
- src: README*
- src: changelog*
- src: CHANGELOG*
snapshot:
version_template: v0.1.0
checksum:
name_template: checksums.txt
algorithm: sha256
changelog:
format: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'
dist: dist
env_files:
github_token: ~/.config/goreleaser/github_token
gitlab_token: ~/.config/goreleaser/gitlab_token
gitea_token: ~/.config/goreleaser/gitea_token
before:
hooks:
- go mod tidy
source:
name_template: '{{ .ProjectName }}-{{ .Version }}'
format: tar.gz
gomod:
gobinary: go
announce:
twitter:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
mastodon:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
server: ""
reddit:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
url_template: '{{ .ReleaseURL }}'
slack:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
username: GoReleaser
discord:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
author: GoReleaser
color: "3888754"
icon_url: https://goreleaser.com/static/avatar.png
teams:
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
color: '#2D313E'
icon_url: https://goreleaser.com/static/avatar.png
smtp:
subject_template: '{{ .ProjectName }} {{ .Tag }} is out!'
body_template: 'You can view details from: {{ .ReleaseURL }}'
mattermost:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
title_template: '{{ .ProjectName }} {{ .Tag }} is out!'
username: GoReleaser
linkedin:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
telegram:
message_template: '{{ mdv2escape .ProjectName }} {{ mdv2escape .Tag }} is out{{ mdv2escape "!" }} Check it out at {{ mdv2escape .ReleaseURL }}'
parse_mode: MarkdownV2
webhook:
message_template: '{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}'
content_type: application/json; charset=utf-8
opencollective:
title_template: '{{ .Tag }}'
message_template: '{{ .ProjectName }} {{ .Tag }} is out!<br/>Check it out at <a href="{{ .ReleaseURL }}">{{ .ReleaseURL }}</a>'
bluesky:
message_template: '{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}'
git:
tag_sort: -version:refname
github_urls:
download: https://github.com
gitlab_urls:
download: https://gitlab.com

View File

@ -0,0 +1,2 @@
ŽíõÞ}Î<>Iƒ`É<>|ÀGéNÙãðFñttV—hÉtÍËËõ€ÕØÙŸ¥øF

View File

@ -0,0 +1,2 @@
ŽíõÞ}Î<>Iƒ`É<>|ÀGéNÙãðFñttV—hÉtÍËËõ€ÕØÙŸ¥øF

12
tests/v0.1/metadata.json Normal file
View File

@ -0,0 +1,12 @@
{
"project_name": "example",
"tag": "v0.0.0",
"previous_tag": "",
"version": "v0.1.0",
"commit": "1869b4455d572d3e3c7e00114586b47f184a2e35",
"date": "2024-09-27T12:10:11.314135185-05:00",
"runtime": {
"goos": "linux",
"goarch": "amd64"
}
}

61
util.go Normal file
View File

@ -0,0 +1,61 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
// Helper for CLI to ask for confirmation.
func askForConfirmation(message string) bool {
// Read stdanrd input for each new line.
scanner := bufio.NewScanner(os.Stdin)
// Loop the question until answered.
for {
fmt.Printf("%s [y/n]: ", message)
// Get next line.
scanner.Scan()
resp := strings.ToLower(strings.TrimSpace(scanner.Text()))
// Check if yes or no.
switch resp {
case "y", "yes":
return true
case "n", "no":
return false
default:
fmt.Println("Invalid answer.")
}
}
}
// Helper for copying files.
func copyFile(srcFile, dstFile string) (err error) {
// Open the source file.
f, err := os.Open(srcFile)
if err != nil {
return
}
defer f.Close()
// Open the destination file.
d, err := os.Create(dstFile)
if err != nil {
return
}
defer d.Close()
// Copy the data to the new file.
_, err = io.Copy(d, f)
if err != nil {
return
}
// Ensure new file is fully written.
err = d.Sync()
return
}