diff --git a/cmd/simplegit/main.go b/cmd/simplegit/main.go
index c9179e5..9aa2456 100644
--- a/cmd/simplegit/main.go
+++ b/cmd/simplegit/main.go
@@ -4,11 +4,16 @@ package main
import (
"bufio"
+ "bytes"
"database/sql"
+ "encoding/json"
"errors"
"flag"
"fmt"
"io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -35,6 +40,8 @@ func main() {
err = runServe(os.Args[2:])
case "adduser":
err = runAddUser(os.Args[2:])
+ case "release":
+ err = runRelease(os.Args[2:])
case "hook":
err = runHook(os.Args[2:])
case "-h", "--help", "help":
@@ -58,6 +65,7 @@ func usage() {
commands:
serve run the HTTP server
adduser create the account
+ release publish a release (owner, via the server's API)
hook post-receive callback (invoked by git, not by hand)
`)
}
@@ -171,6 +179,111 @@ func promptPassword() (string, error) {
return string(first), nil
}
+// runRelease publishes a release from the work machine, no SSH: it posts the
+// owner's git token (or password) over the server's HTTP API with the tag,
+// title, notes, and any asset files. The token may come from
+// SIMPLEGIT_TOKEN, --token, or the SIMPLEGIT_TOKEN env var set in the shell.
+func runRelease(args []string) error {
+ fs := flag.NewFlagSet("release", flag.ExitOnError)
+ baseURL := fs.String("base", "", "server base URL, e.g. https://git.example.com")
+ token := fs.String("token", "", "owner's git token (default $SIMPLEGIT_TOKEN)")
+ repoFlag := fs.String("repo", "", "owner/repo to publish into")
+ tagFlag := fs.String("tag", "", "tag the release is attached to (must exist)")
+ title := fs.String("title", "", "release title (default: the tag name)")
+ notesFile := fs.String("notes", "", "markdown release notes file (default: none)")
+ fs.Usage = func() {
+ fmt.Fprintf(fs.Output(), "usage: simplegit release -base URL -repo owner/repo -tag TAG [-title T] [-notes notes.md] [file ...]\n")
+ fs.PrintDefaults()
+ }
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if *baseURL == "" {
+ return errors.New("-base URL is required")
+ }
+ if *repoFlag == "" || *tagFlag == "" {
+ return errors.New("-repo owner/repo and -tag TAG are required")
+ }
+ if _, _, ok := strings.Cut(*repoFlag, "/"); !ok {
+ return errors.New("-repo must be owner/repo")
+ }
+ secret := strings.TrimSpace(*token)
+ if secret == "" {
+ secret = strings.TrimSpace(os.Getenv("SIMPLEGIT_TOKEN"))
+ }
+ if secret == "" {
+ return errors.New("a git token is required: pass -token or set SIMPLEGIT_TOKEN")
+ }
+ notes := ""
+ if *notesFile != "" {
+ b, err := os.ReadFile(*notesFile)
+ if err != nil {
+ return fmt.Errorf("read notes: %w", err)
+ }
+ notes = string(b)
+ }
+
+ // Build the multipart body.
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ writeField := func(name, value string) {
+ if value != "" {
+ _ = mw.WriteField(name, value)
+ }
+ }
+ writeField("tag", *tagFlag)
+ writeField("title", *title)
+ writeField("notes", notes)
+ for _, path := range fs.Args() {
+ f, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", path, err)
+ }
+ part, err := mw.CreateFormFile("assets", filepath.Base(path))
+ if err != nil {
+ f.Close()
+ return fmt.Errorf("multipart %s: %w", path, err)
+ }
+ if _, err := io.Copy(part, f); err != nil {
+ f.Close()
+ return fmt.Errorf("stream %s: %w", path, err)
+ }
+ f.Close()
+ }
+ if err := mw.Close(); err != nil {
+ return fmt.Errorf("close multipart: %w", err)
+ }
+
+ base, _ := url.Parse(strings.TrimSuffix(*baseURL, "/"))
+ endpoint := base.String() + "/" + *repoFlag + "/releases"
+ req, err := http.NewRequest(http.MethodPost, endpoint, &body)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.SetBasicAuth(repoUser(*repoFlag), secret)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("POST %s: %w", endpoint, err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("create release: %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
+ }
+ var out struct{ URL, Title string }
+ _ = json.Unmarshal(respBody, &out)
+ fmt.Printf("release %s published: %s\n", *tagFlag, out.URL)
+ return nil
+}
+
+// repoUser extracts the owner segment from an owner/repo reference.
+func repoUser(ref string) string {
+ user, _, _ := strings.Cut(ref, "/")
+ return user
+}
+
func runHook(args []string) error {
fs := flag.NewFlagSet("hook", flag.ExitOnError)
repoPath := fs.String("repo", "", "path to the bare repository (set by the installed hook)")
@@ -191,7 +304,7 @@ func runHook(args []string) error {
}
defer database.Close()
- branches, err := pushedBranches(os.Stdin)
+ branches, tags, err := pushRefs(os.Stdin)
if err != nil {
return err
}
@@ -207,7 +320,49 @@ func runHook(args []string) error {
}
}
}
- return db.RecordPush(database, owner, name, time.Now().Unix(), defaultBranch)
+ if err := db.RecordPush(database, owner, name, time.Now().Unix(), defaultBranch); err != nil {
+ return err
+ }
+ return ensureReleases(database, *repoPath, owner, name, tags)
+}
+
+// ensureReleases creates a release for each tag this push created that does
+// not already have one, deriving the title and notes from the tag message.
+// A repo with no metadata row (an out-of-band push) is skipped.
+func ensureReleases(database *sql.DB, repoPath, owner, name string, tags []string) error {
+ if len(tags) == 0 {
+ return nil
+ }
+ repo, err := db.GetRepoByName(database, owner, name)
+ if errors.Is(err, db.ErrNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ ownerUser, err := db.GetUserByName(database, owner)
+ if err != nil {
+ return err
+ }
+ for _, tag := range tags {
+ _, err := db.GetReleaseByTag(database, repo.ID, tag)
+ if err == nil {
+ continue
+ }
+ if !errors.Is(err, db.ErrNotFound) {
+ return err
+ }
+ title, notes := tag, ""
+ if subject, body, found, err := git.TagMessage(repoPath, tag); err != nil {
+ return err
+ } else if found {
+ title, notes = subject, body
+ }
+ if _, err := db.CreateRelease(database, repo.ID, tag, title, notes, ownerUser.ID); err != nil && !errors.Is(err, db.ErrReleaseExists) {
+ return err
+ }
+ }
+ return nil
}
// splitRepoPath maps <dataDir>/repos/<owner>/<name>.git back to its parts.
@@ -225,20 +380,28 @@ func splitRepoPath(repoPath string) (owner, name, dataDir string, err error) {
return owner, name, dataDir, nil
}
-// pushedBranches returns the branch names in post-receive stdin updates.
-func pushedBranches(r io.Reader) ([]string, error) {
- var branches []string
+// pushRefs returns the branch and tag names this push created, from the
+// post-receive stdin updates ("old new ref"). A zero old SHA marks a newly
+// created ref; deletions and moves are ignored (releases are add-only).
+func pushRefs(r io.Reader) (branches, tags []string, err error) {
+ zero := strings.Repeat("0", 40)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
- if branch, ok := strings.CutPrefix(fields[2], "refs/heads/"); ok {
- branches = append(branches, branch)
+ if fields[0] != zero {
+ continue
+ }
+ switch {
+ case strings.HasPrefix(fields[2], "refs/heads/"):
+ branches = append(branches, strings.TrimPrefix(fields[2], "refs/heads/"))
+ case strings.HasPrefix(fields[2], "refs/tags/"):
+ tags = append(tags, strings.TrimPrefix(fields[2], "refs/tags/"))
}
}
- return branches, scanner.Err()
+ return branches, tags, scanner.Err()
}
// openStore loads the config, creates the runtime directory layout, and
diff --git a/cmd/simplegit/main_test.go b/cmd/simplegit/main_test.go
index 6932934..84cd880 100644
--- a/cmd/simplegit/main_test.go
+++ b/cmd/simplegit/main_test.go
@@ -157,3 +157,60 @@ func TestSplitRepoPath(t *testing.T) {
t.Error("splitRepoPath accepted a non-.git path")
}
}
+
+// newTagFixture is newHookFixture plus a commit and an annotated tag on the
+// bare repo, so a tag-push can be simulated.
+func newTagFixture(t *testing.T) (string, *sql.DB) {
+ t.Helper()
+ repoPath, database := newHookFixture(t)
+ commitToBranch(t, repoPath, "main")
+ commitToBranch(t, repoPath, "main")
+ runGit(t, repoPath, "-c", "user.email=t@t", "-c", "user.name=t",
+ "tag", "-a", "v1.0.0", "-m", "v1.0.0", "-m", "the first release", "refs/heads/main")
+ return repoPath, database
+}
+
+func TestRunHookTagPushCreatesRelease(t *testing.T) {
+ repoPath, database := newTagFixture(t)
+ zero := strings.Repeat("0", 40)
+ sha, err := git.ResolveCommit(repoPath, "v1.0.0")
+ if err != nil {
+ t.Fatalf("ResolveCommit: %v", err)
+ }
+ stdin := zero + " " + sha + " refs/tags/v1.0.0\n"
+ if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+ t.Fatalf("runHook: %v", err)
+ }
+
+ repo, err := db.GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ release, err := db.GetReleaseByTag(database, repo.ID, "v1.0.0")
+ if err != nil {
+ t.Fatalf("no release created for tag push: %v", err)
+ }
+ if release.Title != "v1.0.0" || release.Notes != "the first release" {
+ t.Errorf("release = %q / %q, want title v1.0.0 and the tag body", release.Title, release.Notes)
+ }
+}
+
+func TestRunHookTagPushIdempotent(t *testing.T) {
+ repoPath, database := newTagFixture(t)
+ zero := strings.Repeat("0", 40)
+ sha, _ := git.ResolveCommit(repoPath, "v1.0.0")
+ stdin := zero + " " + sha + " refs/tags/v1.0.0\n"
+ for i := 0; i < 2; i++ {
+ if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+ t.Fatalf("runHook %d: %v", i, err)
+ }
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "demo")
+ releases, err := db.ListReleases(database, repo.ID)
+ if err != nil {
+ t.Fatalf("ListReleases: %v", err)
+ }
+ if len(releases) != 1 {
+ t.Errorf("releases after two tag pushes = %d, want 1", len(releases))
+ }
+}
diff --git a/cmd/simplegit/release_test.go b/cmd/simplegit/release_test.go
new file mode 100644
index 0000000..a1cb3e2
--- /dev/null
+++ b/cmd/simplegit/release_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRunReleasePostsMultipart(t *testing.T) {
+ var gotAuthUser, gotAuthToken, gotContentType string
+ var fields map[string]string
+ var assetNames []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/josie/simplegit/releases" {
+ t.Errorf("got %s %s, want POST /josie/simplegit/releases", r.Method, r.URL.Path)
+ }
+ gotAuthUser, gotAuthToken, _ = r.BasicAuth()
+ gotContentType = r.Header.Get("Content-Type")
+ mr := multipart.NewReader(r.Body, parseBoundary(t, r))
+ fields = map[string]string{}
+ for {
+ p, err := mr.NextPart()
+ if err != nil {
+ break
+ }
+ if p.FormName() == "assets" {
+ assetNames = append(assetNames, p.FileName())
+ } else {
+ fields[p.FormName()] = readPart(t, p)
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"url":"https://git.example.com/josie/simplegit/releases/v1.0.0"}`))
+ }))
+ defer server.Close()
+
+ notes := filepath.Join(t.TempDir(), "notes.md")
+ os.WriteFile(notes, []byte("the notes"), 0o644)
+ asset := filepath.Join(t.TempDir(), "simplegit.tar.xz")
+ os.WriteFile(asset, []byte("BINARY"), 0o644)
+
+ if err := runRelease([]string{
+ "-base", server.URL, "-repo", "josie/simplegit", "-tag", "v1.0.0",
+ "-title", "v1.0.0", "-notes", notes, "-token", "sg_test", asset,
+ }); err != nil {
+ t.Fatalf("runRelease: %v", err)
+ }
+ if gotAuthUser != "josie" || gotAuthToken != "sg_test" {
+ t.Errorf("basic auth = %q / %q, want josie / sg_test", gotAuthUser, gotAuthToken)
+ }
+ if !strings.HasPrefix(gotContentType, "multipart/form-data") {
+ t.Errorf("Content-Type = %q, want multipart", gotContentType)
+ }
+ if fields["tag"] != "v1.0.0" || fields["title"] != "v1.0.0" || fields["notes"] != "the notes" {
+ t.Errorf("fields = %v", fields)
+ }
+ if len(assetNames) != 1 || assetNames[0] != "simplegit.tar.xz" {
+ t.Errorf("assets = %v, want [simplegit.tar.xz]", assetNames)
+ }
+}
+
+func TestRunReleaseRequiresToken(t *testing.T) {
+ os.Unsetenv("SIMPLEGIT_TOKEN")
+ if err := runRelease([]string{"-base", "http://x", "-repo", "a/b", "-tag", "v1"}); err == nil ||
+ !strings.Contains(err.Error(), "token") {
+ t.Errorf("expected a token error, got %v", err)
+ }
+}
+
+func parseBoundary(t *testing.T, r *http.Request) string {
+ t.Helper()
+ ct := r.Header.Get("Content-Type")
+ if !strings.HasPrefix(ct, "multipart/form-data; boundary=") {
+ t.Fatalf("no multipart boundary in %q", ct)
+ }
+ return strings.TrimPrefix(ct, "multipart/form-data; boundary=")
+}
+
+func readPart(t *testing.T, p *multipart.Part) string {
+ t.Helper()
+ b, err := io.ReadAll(p)
+ if err != nil {
+ t.Fatalf("read part: %v", err)
+ }
+ return string(b)
+}
diff --git a/docs/self-host.md b/docs/self-host.md
index fc682e0..ea9cc71 100644
--- a/docs/self-host.md
+++ b/docs/self-host.md
@@ -205,11 +205,26 @@ Apache-specific gotchas, all load-bearing:
# create the repo in the web UI, then:
git remote add origin https://git.example.com/<user>/simplegit.git
git push -u origin main
-git tag v1.0.0 && git push origin v1.0.0
+git tag -a v1.0.0 -m "v1.0.0" && git push origin v1.0.0
```
-Then on the repo page: file an issue, open a pull request from a branch, and
-publish a release (Releases → new release, pick the tag, attach a binary).
+Pushing an annotated tag publishes a release automatically: the title is the
+tag message's first line, the notes the rest. Attach binaries the same way
+you'd do it on any git host — no SSH, from the work machine:
+
+```sh
+git push -u origin main
+git tag -a v1.0.0 -m "v1.0.0
+the notes for this release"
+git push origin v1.0.0
+SIMPLEGIT_TOKEN=sg_... simplegit release -base https://git.example.com \
+ -repo <user>/simplegit -tag v1.0.0 simplegit-v1.0.0.tar.xz
+```
+
+`simplegit release` posts the owner's git token (see Account settings →
+tokens) over the server's release API with any number of asset files;
+re-running it for the same tag re-attaches/updates assets in place. A
+lightweight tag gets a release with the tag name as title and no notes.
## 7. Local UAT (single machine, no proxy)
diff --git a/internal/db/releases.go b/internal/db/releases.go
index 4592775..e3d8c1c 100644
--- a/internal/db/releases.go
+++ b/internal/db/releases.go
@@ -4,11 +4,30 @@ import (
"database/sql"
"errors"
"fmt"
+ "unicode/utf8"
)
// ErrReleaseExists marks a tag that already has a release in the repo.
var ErrReleaseExists = errors.New("release already exists for tag")
+// Release title/notes caps, shared by every release-creation path (the
+// push hook, the API, and future UI).
+const (
+ ReleaseTitleMax = 300
+ ReleaseNotesMax = 64 << 10
+)
+
+// releaseTruncate cuts s to max bytes without splitting a UTF-8 rune.
+func releaseTruncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ for max > 0 && !utf8.RuneStart(s[max]) {
+ max--
+ }
+ return s[:max]
+}
+
// Release is a row in the releases table: a title and markdown notes
// attached to a tag that already exists in the repository.
type Release struct {
@@ -36,10 +55,11 @@ const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, create
// CreateRelease stores a release for repoID's tag. A tag may have at most
// one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists.
+// Title and notes are capped to the shared limits.
func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) {
id, err := execLastID(database,
`INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`,
- repoID, tag, title, notes, nullableID(authorID))
+ repoID, tag, releaseTruncate(title, ReleaseTitleMax), releaseTruncate(notes, ReleaseNotesMax), nullableID(authorID))
if err != nil {
if isUniqueViolation(err) {
return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists)
@@ -141,6 +161,25 @@ func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, e
WHERE a.id = ? AND r.repo_id = ?`, id, repoID)
}
+// GetReleaseAssetByFilename looks up a release's asset by its display name,
+// for replacing an asset re-uploaded under the same name.
+func GetReleaseAssetByFilename(database *sql.DB, releaseID int64, filename string) (ReleaseAsset, error) {
+ return getReleaseAsset(database, releaseAssetColumns+`release_id = ? AND filename = ?`, releaseID, filename)
+}
+
+// ReplaceReleaseAsset updates an asset's stored name and size in place (the
+// display name is unchanged), so a re-upload can swap its file without a
+// delete+insert pair.
+func ReplaceReleaseAsset(database *sql.DB, releaseID, id int64, storedName string, size int64) error {
+ _, err := database.Exec(
+ `UPDATE release_assets SET stored_name = ?, size = ? WHERE id = ? AND release_id = ?`,
+ storedName, size, id, releaseID)
+ if err != nil {
+ return fmt.Errorf("replace asset %d: %w", id, err)
+ }
+ return nil
+}
+
// DeleteReleaseAsset removes an asset, scoped to its release.
func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error {
return execScoped(database, "delete release asset",
diff --git a/internal/git/tags.go b/internal/git/tags.go
index 993d697..b82bbf8 100644
--- a/internal/git/tags.go
+++ b/internal/git/tags.go
@@ -6,6 +6,10 @@ import (
"strings"
)
+// tagMessageLimit bounds the tag object read; tag messages are small, and
+// the cap exists to keep a corrupted or hostile tag from eating memory.
+const tagMessageLimit = 64 << 10
+
// Tag is a tag ref in the repository. Commit is the commit the tag points
// at (dereferenced for an annotated tag); Annotated reports whether the tag
// is an annotated tag object rather than a lightweight ref.
@@ -50,3 +54,61 @@ func Tags(repoPath string) ([]Tag, error) {
}
return tags, nil
}
+
+// TagMessage returns an annotated tag's subject (first message line) and
+// body (the remaining lines, trimmed). For a missing or lightweight tag —
+// or a tag with an empty message — found is false and there is no error.
+// The tag object is read bounded so a hostile tag cannot exhaust memory.
+func TagMessage(repoPath, name string) (subject, body string, found bool, err error) {
+ exists, err := RefExists(repoPath, "refs/tags/"+name)
+ if err != nil {
+ return "", "", false, err
+ }
+ if !exists {
+ return "", "", false, nil
+ }
+
+ cmd := gitCommand(repoPath, "cat-file", "-t", "refs/tags/"+name)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ objType, err := cmd.Output()
+ if err != nil {
+ return "", "", false, fmt.Errorf("git cat-file -t %s in %s: %w: %s", name, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ if strings.TrimSpace(string(objType)) != "tag" {
+ return "", "", false, nil // lightweight tag: no tag object, no message
+ }
+
+ cmd = gitCommand(repoPath, "cat-file", "tag", "refs/tags/"+name)
+ stderr.Reset()
+ cmd.Stderr = &stderr
+ out, oversized, err := runBounded(cmd, tagMessageLimit)
+ if err != nil {
+ return "", "", false, fmt.Errorf("git cat-file tag %s in %s: %w: %s", name, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ if oversized {
+ return "", "", false, fmt.Errorf("git cat-file tag %s in %s: tag object is too large", name, repoPath)
+ }
+ // A tag object is a header, a blank line, then the message. Everything
+ // after the first blank line is the message.
+ lines := strings.Split(string(out), "\n")
+ start := -1
+ for i, line := range lines {
+ if line == "" {
+ start = i + 1
+ break
+ }
+ }
+ if start < 0 || start >= len(lines) {
+ return "", "", false, nil
+ }
+ message := strings.TrimRight(strings.Join(lines[start:], "\n"), "\n")
+ if message == "" {
+ return "", "", false, nil
+ }
+ subject = message
+ if idx := strings.IndexByte(message, '\n'); idx >= 0 {
+ subject, body = message[:idx], strings.TrimSpace(message[idx+1:])
+ }
+ return subject, body, true, nil
+}
diff --git a/internal/git/tags_test.go b/internal/git/tags_test.go
index 88a137a..4ce2050 100644
--- a/internal/git/tags_test.go
+++ b/internal/git/tags_test.go
@@ -55,3 +55,34 @@ func TestTagsEmptyRepo(t *testing.T) {
t.Errorf("tags in empty repo = %+v, want none", tags)
}
}
+
+func TestTagMessage(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+ runGit(t, path, "tag", "lw", "main")
+ runGit(t, path, "-c", "user.email=t@t", "-c", "user.name=t",
+ "tag", "-a", "one-line", "-m", "just a subject", "main")
+ // Two -m flags make two message paragraphs; the first line is the
+ // subject, the rest the body.
+ runGit(t, path, "-c", "user.email=t@t", "-c", "user.name=t",
+ "tag", "-a", "multi", "-m", "subject line", "-m", "body para one", "main")
+
+ subject, body, found, err := TagMessage(path, "lw")
+ if err != nil || found || subject != "" || body != "" {
+ t.Errorf("lightweight = %q %q found=%v err=%v, want all empty + found=false", subject, body, found, err)
+ }
+ subject, body, found, err = TagMessage(path, "one-line")
+ if err != nil || !found || subject != "just a subject" || body != "" {
+ t.Errorf("one-line = %q %q found=%v err=%v, want subject only", subject, body, found, err)
+ }
+ subject, body, found, err = TagMessage(path, "multi")
+ if err != nil || !found || subject != "subject line" || body != "body para one" {
+ t.Errorf("multi = %q %q found=%v err=%v, want subject + body", subject, body, found, err)
+ }
+ if _, _, found, err := TagMessage(path, "nope"); err != nil || found {
+ t.Errorf("missing tag = found=%v err=%v, want found=false, no error", found, err)
+ }
+}
diff --git a/internal/web/releases.go b/internal/web/releases.go
index 70bf356..465d565 100644
--- a/internal/web/releases.go
+++ b/internal/web/releases.go
@@ -1,17 +1,23 @@
package web
import (
+ "encoding/json"
"errors"
"fmt"
"html/template"
+ "io"
"log"
"mime"
+ "mime/multipart"
"net/http"
"net/url"
"os"
+ "path"
"path/filepath"
"strconv"
+ "strings"
+ "git.josie-c.com/josie/simplegit/internal/auth"
"git.josie-c.com/josie/simplegit/internal/db"
"git.josie-c.com/josie/simplegit/internal/git"
)
@@ -20,6 +26,15 @@ func releaseBasePath(r *http.Request) string {
return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/releases"
}
+// API release creation limits: the whole multipart body, each stored asset,
+// and the per-release file count.
+const (
+ releaseUploadMax = 50 << 20
+ maxAssetSize = 25 << 20
+ maxReleaseAssets = 10
+ maxAssetNameLen = 200
+)
+
func (s *Server) releaseUploadDir(releaseID int64) string {
return filepath.Join(s.cfg.DataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10))
}
@@ -274,3 +289,245 @@ func humanSize(n int64) string {
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+
+// handleCreateRelease is the API path for publishing a release: the owner
+// (browser session or git token over basic auth) posts a tag and optional
+// title/notes plus any asset files. It mirrors the removed creation GUI —
+// same limits, same rollback — so releases can be cut from the CLI with no
+// SSH. Assets land in uploads/releases/<id>/ under opaque names, exactly as
+// the download route expects.
+//
+// It is idempotent: a tag already has a release (e.g. created by the push
+// hook from the tag message) is reused, and an asset re-uploaded under the
+// same name replaces the stored file rather than erroring.
+func (s *Server) handleCreateRelease(w http.ResponseWriter, r *http.Request) {
+ owner := r.PathValue("user")
+ repo, err := db.GetRepoByName(s.database, owner, r.PathValue("repo"))
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ // Authentication: a session or a git token (the same secret check pushes
+ // use), then ownership — the same gate as the git push path.
+ user := currentUser(r)
+ if user == nil {
+ username, secret, supplied := r.BasicAuth()
+ if !supplied {
+ s.gitChallenge(w)
+ return
+ }
+ var ok bool
+ user, ok = s.authenticateSecret(username, secret)
+ if !ok {
+ if !s.logins.allow(clientIP(r)) {
+ http.Error(w, "too many failed authentications; try again later", http.StatusTooManyRequests)
+ return
+ }
+ s.gitChallenge(w)
+ return
+ }
+ s.logins.reset(clientIP(r))
+ }
+ if !isOwner(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, releaseUploadMax)
+ if err := r.ParseMultipartForm(8 << 20); err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ http.Error(w, "upload is too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+
+ tag := strings.TrimSpace(r.FormValue("tag"))
+ title := truncate(strings.TrimSpace(r.FormValue("title")), db.ReleaseTitleMax)
+ notes := truncate(strings.TrimSpace(r.FormValue("notes")), db.ReleaseNotesMax)
+
+ if tag == "" {
+ http.Error(w, "a tag is required", http.StatusUnprocessableEntity)
+ return
+ }
+ repoPath := s.repoPathFor(owner, repo)
+ tags, err := git.Tags(repoPath)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ tagOK := false
+ for _, t := range tags {
+ if t.Name == tag {
+ tagOK = true
+ break
+ }
+ }
+ if !tagOK {
+ http.Error(w, "choose a tag that exists in the repository", http.StatusUnprocessableEntity)
+ return
+ }
+ // Get-or-create the release: a tag push may already have created a
+ // notes-only release from the tag message, in which case we attach to it
+ // rather than failing on the UNIQUE(repo_id, tag) constraint.
+ release, err := db.GetReleaseByTag(s.database, repo.ID, tag)
+ isCreated := false
+ if errors.Is(err, db.ErrNotFound) {
+ isCreated = true
+ if title == "" {
+ title = tag
+ }
+ release, err = db.CreateRelease(s.database, repo.ID, tag, title, notes, user.ID)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ } else if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+
+ dir := s.releaseUploadDir(release.ID)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ s.cleanupRelease(repo, release.ID, dir, isCreated)
+ log.Printf("web: create release dir %s: %v", dir, err)
+ http.Error(w, "could not store the uploaded files", http.StatusInternalServerError)
+ return
+ }
+ if r.MultipartForm != nil {
+ if err := s.saveReleaseAssets(r, release, dir); err != nil {
+ s.cleanupRelease(repo, release.ID, dir, isCreated)
+ http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "url": s.cfg.BaseURL + releaseBasePath(r) + "/" + url.PathEscape(release.Tag),
+ "tag": release.Tag,
+ "title": release.Title,
+ })
+}
+
+// cleanupRelease tears down a release operation that could not be finished:
+// the per-release directory (always created for this operation) and, only
+// when this request created it, the release row — so an existing release and
+// its pre-existing assets are never removed by a failed attach.
+func (s *Server) cleanupRelease(repo db.Repo, releaseID int64, dir string, isCreated bool) {
+ if err := os.RemoveAll(dir); err != nil {
+ log.Printf("web: cleanup release %d dir: %v", releaseID, err)
+ }
+ if isCreated {
+ if err := db.DeleteRelease(s.database, repo.ID, releaseID); err != nil {
+ log.Printf("web: cleanup release %d row: %v", releaseID, err)
+ }
+ }
+}
+
+// saveReleaseAssets writes each uploaded file to dir and records its row.
+// Any failure leaves the whole release to be rolled back by the caller.
+func (s *Server) saveReleaseAssets(r *http.Request, release db.Release, dir string) error {
+ files := r.MultipartForm.File["assets"]
+ if len(files) > maxReleaseAssets {
+ return fmt.Errorf("at most %d files per release", maxReleaseAssets)
+ }
+ for _, header := range files {
+ if err := s.saveAsset(r, release, dir, header); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// saveAsset stores one uploaded file under an opaque name and records it.
+// An existing asset with the same display name is replaced in place: the new
+// file is written first, the row updated, and only then the old file
+// removed — so a failure never drops an asset the request didn't replace.
+func (s *Server) saveAsset(r *http.Request, release db.Release, dir string, header *multipart.FileHeader) error {
+ name := safeAssetName(header.Filename)
+ if name == "" {
+ return fmt.Errorf("a file has an invalid name")
+ }
+ stored, err := auth.NewToken()
+ if err != nil {
+ return err
+ }
+ size, err := saveUploadedFile(header, filepath.Join(dir, stored))
+ if err != nil {
+ return err
+ }
+ existing, err := db.GetReleaseAssetByFilename(s.database, release.ID, name)
+ switch {
+ case err == nil:
+ if err := db.ReplaceReleaseAsset(s.database, release.ID, existing.ID, stored, size); err != nil {
+ _ = os.Remove(filepath.Join(dir, stored))
+ return err
+ }
+ _ = os.Remove(filepath.Join(dir, existing.StoredName))
+ case errors.Is(err, db.ErrNotFound):
+ if _, err := db.CreateReleaseAsset(s.database, release.ID, name, stored, size); err != nil {
+ _ = os.Remove(filepath.Join(dir, stored))
+ return err
+ }
+ default:
+ _ = os.Remove(filepath.Join(dir, stored))
+ return err
+ }
+ return nil
+}
+
+// saveUploadedFile streams one uploaded file to dst, capping its size, and
+// returns the number of bytes written.
+func saveUploadedFile(header *multipart.FileHeader, dst string) (int64, error) {
+ src, err := header.Open()
+ if err != nil {
+ return 0, fmt.Errorf("open upload %s: %w", header.Filename, err)
+ }
+ defer src.Close()
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return 0, fmt.Errorf("store upload %s: %w", header.Filename, err)
+ }
+ written, copyErr := io.Copy(out, io.LimitReader(src, maxAssetSize+1))
+ closeErr := out.Close()
+ if copyErr != nil {
+ _ = os.Remove(dst)
+ return 0, fmt.Errorf("store upload %s: %w", header.Filename, copyErr)
+ }
+ if closeErr != nil {
+ _ = os.Remove(dst)
+ return 0, fmt.Errorf("store upload %s: %w", header.Filename, closeErr)
+ }
+ if written > maxAssetSize {
+ _ = os.Remove(dst)
+ return 0, fmt.Errorf("file %s is larger than %d MB", header.Filename, maxAssetSize>>20)
+ }
+ return written, nil
+}
+
+// safeAssetName reduces a client-supplied filename to a single boring path
+// segment; it returns "" when nothing usable remains.
+func safeAssetName(raw string) string {
+ name := path.Base(strings.ReplaceAll(raw, "\\", "/"))
+ name = strings.Map(func(c rune) rune {
+ if c < 0x20 || c == 0x7f {
+ return -1
+ }
+ return c
+ }, name)
+ name = strings.TrimSpace(name)
+ if name == "" || name == "." || name == ".." || name == "/" {
+ return ""
+ }
+ if len(name) > maxAssetNameLen {
+ name = name[:maxAssetNameLen]
+ }
+ return name
+}
diff --git a/internal/web/releases_test.go b/internal/web/releases_test.go
index 9e54d77..48c4fec 100644
--- a/internal/web/releases_test.go
+++ b/internal/web/releases_test.go
@@ -1,8 +1,10 @@
package web
import (
+ "bytes"
"database/sql"
"errors"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -10,6 +12,7 @@ import (
"strings"
"testing"
+ "git.josie-c.com/josie/simplegit/internal/auth"
"git.josie-c.com/josie/simplegit/internal/db"
)
@@ -186,3 +189,217 @@ func TestReleaseDownloadPrivateNeedsOwner(t *testing.T) {
t.Errorf("anon private download = %d, want 404 (no existence oracle)", download.StatusCode)
}
}
+
+// newAPIToken mints a git token for user directly in the DB and returns the
+// plaintext, so the API tests can exercise the token path without the UI.
+func newAPIToken(t *testing.T, database *sql.DB, username string) string {
+ t.Helper()
+ user, err := db.GetUserByName(database, username)
+ if err != nil {
+ t.Fatalf("GetUserByName: %v", err)
+ }
+ plaintext, err := auth.NewAPIToken()
+ if err != nil {
+ t.Fatalf("NewAPIToken: %v", err)
+ }
+ if _, err := db.CreateToken(database, user.ID, "test", auth.TokenHint(plaintext), auth.HashToken(plaintext)); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ return plaintext
+}
+
+// postRelease builds a multipart POST to the create-release API with basic
+// auth and returns the response.
+func postRelease(t *testing.T, url, user, secret, tag, title, notes string, files map[string]string) *http.Response {
+ t.Helper()
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ for _, field := range []struct{ name, value string }{
+ {"tag", tag}, {"title", title}, {"notes", notes},
+ } {
+ if field.value != "" {
+ if err := mw.WriteField(field.name, field.value); err != nil {
+ t.Fatalf("writefield %s: %v", field.name, err)
+ }
+ }
+ }
+ for name, content := range files {
+ fw, err := mw.CreateFormFile("assets", name)
+ if err != nil {
+ t.Fatalf("createformfile: %v", err)
+ }
+ if _, err := fw.Write([]byte(content)); err != nil {
+ t.Fatalf("write asset: %v", err)
+ }
+ }
+ if err := mw.Close(); err != nil {
+ t.Fatalf("close multipart: %v", err)
+ }
+ req, err := http.NewRequest(http.MethodPost, url, &body)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.SetBasicAuth(user, secret)
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("POST release: %v", err)
+ }
+ return resp
+}
+
+func TestCreateReleaseBasicAuthWithAsset(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ seedTag(t, work, "v1.0.0")
+
+ resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2",
+ "v1.0.0", "v1.0.0", "first release notes", map[string]string{"artifact.tar.xz": "TARBYTES"})
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("create release = %d, want 200: %s", resp.StatusCode, body)
+ }
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ release, err := db.GetReleaseByTag(database, repo.ID, "v1.0.0")
+ if err != nil {
+ t.Fatalf("release not created: %v", err)
+ }
+ if release.Title != "v1.0.0" || release.Notes != "first release notes" {
+ t.Errorf("release = %q / %q", release.Title, release.Notes)
+ }
+ assets, _ := db.ListReleaseAssets(database, release.ID)
+ if len(assets) != 1 || assets[0].Filename != "artifact.tar.xz" {
+ t.Fatalf("assets = %+v, want one artifact.tar.xz", assets)
+ }
+ if _, err := os.Stat(filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(release.ID, 10), assets[0].StoredName)); err != nil {
+ t.Errorf("stored asset missing: %v", err)
+ }
+
+ view, err := owner.Get(httpServer.URL + "/josie/pub/releases/v1.0.0")
+ if err != nil {
+ t.Fatalf("GET release: %v", err)
+ }
+ viewBody := readAll(t, view)
+ if !strings.Contains(viewBody, "artifact.tar.xz") {
+ t.Errorf("release view missing the asset: %s", viewBody)
+ }
+}
+
+func TestCreateReleaseAPITokenAuth(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ seedTag(t, work, "v2")
+
+ token := newAPIToken(t, database, "josie")
+ resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", token, "v2", "", "", nil)
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("token create release = %d, want 200: %s", resp.StatusCode, body)
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ release, err := db.GetReleaseByTag(database, repo.ID, "v2")
+ if err != nil {
+ t.Fatalf("release not created: %v", err)
+ }
+ if release.Title != "v2" {
+ t.Errorf("title = %q, want the tag name as default", release.Title)
+ }
+}
+
+func TestCreateReleaseNonOwnerForbidden(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ seedTag(t, work, "v1")
+
+ addUser(t, database, "mallory", "pw")
+ resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "mallory", "pw", "v1", "x", "", nil)
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner create = %d, want 404", resp.StatusCode)
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ if _, err := db.GetReleaseByTag(database, repo.ID, "v1"); !errors.Is(err, db.ErrNotFound) {
+ t.Errorf("a release was created for a non-owner")
+ }
+}
+
+func TestCreateReleaseRequiresAuth(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ req, _ := http.NewRequest(http.MethodPost, httpServer.URL+"/josie/pub/releases", strings.NewReader(""))
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("unauthenticated create = %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestCreateReleaseUnknownTagAndDuplicate(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ seedTag(t, work, "v1")
+
+ resp := postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2", "nope", "x", "", nil)
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnprocessableEntity {
+ t.Errorf("unknown tag = %d, want 422", resp.StatusCode)
+ }
+
+ resp = postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2", "v1", "first", "", nil)
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("first create = %d, want 200", resp.StatusCode)
+ }
+ // A repeat post for the same tag reuses the existing release (it is not
+ // dropped and recreated): the title/notes set at creation are preserved,
+ // and an asset uploaded now is added to it.
+ resp = postRelease(t, httpServer.URL+"/josie/pub/releases", "josie", "hunter2",
+ "v1", "ignored-title", "ignored-notes", map[string]string{"extra.bin": "more"})
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("repeat post = %d, want 200 (idempotent): %s", resp.StatusCode, body)
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ release, _ := db.GetReleaseByTag(database, repo.ID, "v1")
+ if release.Title != "first" {
+ t.Errorf("title after repeat post = %q, want preserved %q", release.Title, "first")
+ }
+ assets, _ := db.ListReleaseAssets(database, release.ID)
+ if len(assets) != 1 || assets[0].Filename != "extra.bin" {
+ t.Errorf("assets after repeat post = %+v, want [extra.bin]", assets)
+ }
+ releases, _ := db.ListReleases(database, repo.ID)
+ if len(releases) != 1 {
+ t.Errorf("releases = %d, want 1", len(releases))
+ }
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index 05965ca..5e5f7e4 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -153,6 +153,7 @@ func (s *Server) Handler() http.Handler {
s.handleModeratePullComment(w, r, false)
})
mux.HandleFunc("GET /{user}/{repo}/releases", s.handleReleases)
+ mux.HandleFunc("POST /{user}/{repo}/releases", s.handleCreateRelease)
mux.HandleFunc("GET /{user}/{repo}/releases/download/{id}/{filename}", s.handleReleaseDownload)
mux.HandleFunc("GET /{user}/{repo}/releases/{tag}", s.handleReleaseView)
mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/delete", s.handleDeleteRelease)
releases: publish from CLI — tag-push auto-release + asset upload API
Releases are now cut entirely from the work machine, no SSH:
- Pushing a newly created tag fires the post-receive hook, which creates
a notes-only release from the tag message (title = first line, notes =
the rest; lightweight tag -> tag name, no notes). Idempotent via
UNIQUE(repo_id, tag); branch-only and out-of-band pushes unaffected.
New git.TagMessage reads the tag object bounded (64KB cap).
- simplegit release (new subcommand) posts the owner's git token over
basic auth to the new POST /{user}/{repo}/releases endpoint with
multipart tag/title/notes/assets — the same auth path git pushes use
(authenticateSecret), owner-gated, sameOrigin-clean for non-browser
clients. It is the re-homed creation GUI: same caps (50MB body,
25MB/asset, 10 assets), same opaque-name storage under
uploads/releases/<id>/, with rollback that never drops a pre-existing
release. Get-or-create semantics (gh release upload style): pushing
the tag first, then uploading, just works; re-uploading a same-name
asset replaces it in place (db.ReplaceReleaseAsset: write new file,
update row, delete old file) so a failure never loses a live asset.
Self-host docs' dogfood section now shows the tag-push + upload flow.
Memory files updated.
Tests: +10 (TagMessage, hook tag-push create/idempotency, API
basic-auth/token auth/non-owner/unauthenticated/unknown-tag/idempotent
repeat, CLI multipart + token-required). 145 total, vet+gofmt clean.
LLM Contributor: qwen/qwen3.8-27b
574bc91c2e459aa8afd9f4c77698b3417ce47883
cjosie <administrator@josie-c.com> · 2026-10-05T13:57 ·
browse files at this commit
parents:
8edadb5