From 3aacdb3ace2ebff41af1b352a09fc4cb6e285993 Mon Sep 17 00:00:00 2001
From: Ondrej Belusky <zlymeda@gmail.com>
Date: Wed, 29 Jul 2026 00:16:01 +0200
Subject: [PATCH] nats-upload: add Gitea integration for cleaning feature
 branches

 Conflicts:
	go.mod
	go.sum
---
 action.yml |  18 ++++-
 go.mod     |  27 ++++----
 go.sum     |  85 +++++++++++-------------
 main.go    | 190 +++++++++++++++++++++++++++++++++++++++++------------
 4 files changed, 216 insertions(+), 104 deletions(-)

diff --git a/action.yml b/action.yml
index 995274e..f112595 100644
--- a/action.yml
+++ b/action.yml
@@ -37,7 +37,21 @@ inputs:
     description: 'Cleanup all binaries, not just current one'
     required: false
     default: 'false'
+  gitea_url:
+    description: 'Gitea server URL (for cleaning feature branches)'
+    required: false
+    default: ''
+  gitea_token:
+    description: 'Gitea API token (for cleaning feature branches)'
+  repository:
+    description: 'Repository path (owner/repo) for Gitea branch API (defaults to GITHUB_REPOSITORY env)'
+    required: false
+    default: ''
+  cleanup_features:
+    description: 'Cleanup stale feature-branch binaries whose branches no longer exist in Gitea'
+    required: false
+    default: 'false'
+
 runs:
   # noinspection YAMLSchemaValidation
-  using: 'go'
-  main: 'main.go'
+  using: 'go'
\ No newline at end of file
diff --git a/go.mod b/go.mod
index c81d285..c959b66 100644
--- a/go.mod
+++ b/go.mod
@@ -1,30 +1,29 @@
 module lab.zlymeda.dynu.net/actions/nats-upload
 
-go 1.26
+go 1.26.5
 
 require (
 	github.com/nats-io/nats.go v1.52.0
 	github.com/spf13/cobra v1.10.2
-	github.com/spf13/pflag v1.0.10
 	github.com/spf13/viper v1.21.0
-	golang.org/x/mod v0.37.0
+	golang.org/x/mod v0.38.0
 )
 
 require (
-	github.com/fsnotify/fsnotify v1.9.0 // indirect
-	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+	github.com/fsnotify/fsnotify v1.10.1 // indirect
+	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
-	github.com/klauspost/compress v1.18.5 // indirect
-	github.com/nats-io/nkeys v0.4.15 // indirect
+	github.com/klauspost/compress v1.19.1 // indirect
+	github.com/nats-io/nkeys v0.4.16 // indirect
 	github.com/nats-io/nuid v1.0.1 // indirect
-	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
-	github.com/sagikazarmark/locafero v0.11.0 // indirect
-	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+	github.com/pelletier/go-toml/v2 v2.4.3 // indirect
+	github.com/sagikazarmark/locafero v0.12.0 // indirect
 	github.com/spf13/afero v1.15.0 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
-	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/crypto v0.49.0 // indirect
-	golang.org/x/sys v0.42.0 // indirect
-	golang.org/x/text v0.35.0 // indirect
+	go.yaml.in/yaml/v3 v3.0.5 // indirect
+	golang.org/x/crypto v0.54.0 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+	golang.org/x/text v0.40.0 // indirect
 )
diff --git a/go.sum b/go.sum
index b6bb883..f7013ec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,33 +1,37 @@
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
-github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
-github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
-github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
-github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
-github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds=
-github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
-github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
-github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
+github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
+github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
 github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
-github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
-github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
+github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
+github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
 github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
-github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
-github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
-github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
 github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
 github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
 github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -39,30 +43,21 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
-golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
-golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
-golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
 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=
diff --git a/main.go b/main.go
index 9fdacd2..290aa20 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"log"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -14,21 +15,24 @@ import (
 	"github.com/nats-io/nats.go"
 	"github.com/nats-io/nats.go/jetstream"
 	"github.com/spf13/cobra"
-	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
 	"golang.org/x/mod/semver"
 )
 
 type Config struct {
-	NatsURL     string `mapstructure:"nats"`
-	BucketName  string `mapstructure:"bucket"`
-	Directory   string `mapstructure:"dir"`
-	Prefix      string `mapstructure:"prefix"`
-	BinaryName  string `mapstructure:"binary"`
-	NotifyTopic string `mapstructure:"notify"`
-	SkipNotify  bool   `mapstructure:"skip-notify"`
-	Cleanup     int    `mapstructure:"cleanup"`
-	CleanupAll  bool   `mapstructure:"cleanup-all"`
+	NatsURL         string `mapstructure:"nats"`
+	BucketName      string `mapstructure:"bucket"`
+	Directory       string `mapstructure:"dir"`
+	Prefix          string `mapstructure:"prefix"`
+	BinaryName      string `mapstructure:"binary"`
+	NotifyTopic     string `mapstructure:"notify"`
+	SkipNotify      bool   `mapstructure:"skip-notify"`
+	Cleanup         int    `mapstructure:"cleanup"`
+	CleanupAll      bool   `mapstructure:"cleanup-all"`
+	GiteaURL        string `mapstructure:"gitea-url"`
+	GiteaToken      string `mapstructure:"gitea-token"`
+	CleanupFeatures bool   `mapstructure:"cleanup-features"`
+	Repository      string `mapstructure:"repository"`
 }
 
 var rootCmd = &cobra.Command{
@@ -39,8 +43,8 @@ var rootCmd = &cobra.Command{
 		if err := viper.Unmarshal(&cfg); err != nil {
 			return fmt.Errorf("failed to unmarshal config: %w", err)
 		}
-		if cfg.Directory == "" && cfg.Cleanup == 0 {
-			return errors.New("directory path is required or cleanup must be enabled")
+		if cfg.Directory == "" && cfg.Cleanup == 0 && !cfg.CleanupFeatures {
+			return errors.New("directory path is required, or cleanup/cleanup-features must be enabled")
 		}
 		return runUploadAndCleanup(cmd.Context(), &cfg)
 	},
@@ -61,6 +65,18 @@ var cleanCmd = &cobra.Command{
 	},
 }
 
+var cleanFeaturesCmd = &cobra.Command{
+	Use:   "clean-features",
+	Short: "Delete feature-branch binaries whose branches no longer exist in Gitea",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		var cfg Config
+		if err := viper.Unmarshal(&cfg); err != nil {
+			return fmt.Errorf("failed to unmarshal config: %w", err)
+		}
+		return runCleanupFeatures(cmd.Context(), &cfg)
+	},
+}
+
 func init() {
 	cobra.OnInitialize(initConfig)
 
@@ -70,35 +86,31 @@ func init() {
 	rootCmd.PersistentFlags().Int("cleanup", 2, "Keep only N most recent versions (0 disables cleanup)")
 	rootCmd.PersistentFlags().Bool("cleanup-all", false, "Cleanup all binaries, not just current one")
 	rootCmd.PersistentFlags().Bool("clean-all", false, "Alias for --cleanup-all")
+	rootCmd.PersistentFlags().String("gitea-url", "", "Gitea server URL (for cleaning feature branches)")
+	rootCmd.PersistentFlags().String("gitea-token", "", "Gitea API token (for cleaning feature branches)")
+	rootCmd.PersistentFlags().Bool("cleanup-features", false, "Cleanup stale feature-branch binaries")
+	rootCmd.PersistentFlags().String("repository", "", "Repository path (owner/repo) for Gitea branch API")
 
 	rootCmd.Flags().String("dir", "upload", "Directory containing binaries to upload")
 	rootCmd.Flags().String("prefix", "", "Prefix to strip from paths (like 'upload/')")
 	rootCmd.Flags().String("notify", "binaries.update", "NATS topic to publish update notification")
 	rootCmd.Flags().Bool("skip-notify", false, "Skip publishing update notification")
-}
 
-func bindPFlag(fs *pflag.FlagSet, key string, flagNames ...string) {
-	name := key
-	if len(flagNames) > 0 {
-		name = flagNames[0]
-	}
-	if err := viper.BindPFlag(key, fs.Lookup(name)); err != nil {
-		log.Fatalf("error binding %s flag: %v", key, err)
-	}
-}
-
-func init() {
 	rootPersistentFlags := rootCmd.PersistentFlags()
-	for _, name := range []string{"nats", "bucket", "binary", "cleanup", "cleanup-all"} {
-		bindPFlag(rootPersistentFlags, name)
+	for _, name := range []string{"nats", "bucket", "binary", "cleanup", "cleanup-all", "gitea-url", "gitea-token", "cleanup-features", "repository"} {
+		if err := viper.BindPFlag(name, rootPersistentFlags.Lookup(name)); err != nil {
+			log.Fatalf("error binding %s flag: %v", name, err)
+		}
 	}
 
 	rootFlags := rootCmd.Flags()
 	for _, name := range []string{"dir", "prefix", "notify", "skip-notify"} {
-		bindPFlag(rootFlags, name)
+		if err := viper.BindPFlag(name, rootFlags.Lookup(name)); err != nil {
+			log.Fatalf("error binding %s flag: %v", name, err)
+		}
 	}
 
-	rootCmd.AddCommand(cleanCmd)
+	rootCmd.AddCommand(cleanCmd, cleanFeaturesCmd)
 }
 
 func initConfig() {
@@ -111,12 +123,20 @@ func initConfig() {
 	viper.RegisterAlias("strip_prefix", "prefix")
 	viper.RegisterAlias("notify_topic", "notify")
 	viper.RegisterAlias("clean_all", "cleanup-all")
+	viper.RegisterAlias("gitea_url", "gitea-url")
+	viper.RegisterAlias("gitea_token", "gitea-token")
+	viper.RegisterAlias("cleanup_features", "cleanup-features")
+	viper.RegisterAlias("repository", "repository")
 
 	_ = viper.BindEnv("nats", "INPUT_NATS", "INPUT_NATS_URL")
 	_ = viper.BindEnv("dir", "INPUT_DIR", "INPUT_SOURCE")
 	_ = viper.BindEnv("prefix", "INPUT_PREFIX", "INPUT_STRIP_PREFIX")
 	_ = viper.BindEnv("notify", "INPUT_NOTIFY", "INPUT_NOTIFY_TOPIC")
 	_ = viper.BindEnv("cleanup-all", "INPUT_CLEANUP_ALL", "INPUT_CLEAN_ALL")
+	_ = viper.BindEnv("gitea-url", "INPUT_GITEA_URL", "INPUT_GITEA_URL")
+	_ = viper.BindEnv("gitea-token", "INPUT_GITEA_TOKEN", "INPUT_GITEA_TOKEN")
+	_ = viper.BindEnv("cleanup-features", "INPUT_CLEANUP_FEATURES", "INPUT_CLEANUP_FEATURES")
+	_ = viper.BindEnv("repository", "INPUT_REPOSITORY", "INPUT_REPOSITORY", "GITHUB_REPOSITORY")
 }
 
 type NATSClient struct {
@@ -224,6 +244,13 @@ func runUploadAndCleanup(ctx context.Context, cfg *Config) error {
 		}
 	}
 
+	if cfg.CleanupFeatures {
+		log.Printf("Cleaning up stale feature branch binaries...")
+		if err := runCleanupFeatures(ctx, cfg); err != nil {
+			return fmt.Errorf("failed to cleanup feature branches: %w", err)
+		}
+	}
+
 	if !cfg.SkipNotify && cfg.NotifyTopic != "" {
 		log.Printf("Publishing update notification to topic: %s", cfg.NotifyTopic)
 
@@ -233,7 +260,6 @@ func runUploadAndCleanup(ctx context.Context, cfg *Config) error {
 			return fmt.Errorf("failed to publish notification: %w", err)
 		}
 
-		// Flush to ensure message is sent
 		err = client.Conn.Flush()
 		if err != nil {
 			return fmt.Errorf("failed to flush notification: %w", err)
@@ -259,20 +285,108 @@ func runCleanupOnly(ctx context.Context, cfg *Config) error {
 	return nil
 }
 
+func runCleanupFeatures(ctx context.Context, cfg *Config) error {
+	client, err := getNATSConnection(ctx, cfg)
+	if err != nil {
+		return err
+	}
+	defer client.Conn.Close()
+
+	log.Printf("Cleaning up stale feature branch binaries from bucket %s", cfg.BucketName)
+
+	objects, err := client.Store.List(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to list objects: %w", err)
+	}
+
+	type featureTag struct {
+		binary    string
+		objectKey string
+		version   string
+	}
+	var featureObjects []featureTag
+
+	for _, obj := range objects {
+		parts := strings.Split(obj.Name, "/")
+		if len(parts) < 3 {
+			continue
+		}
+
+		version := parts[len(parts)-1]
+		if !strings.HasPrefix(version, "feature-") {
+			continue
+		}
+
+		featureObjects = append(featureObjects, featureTag{
+			binary:    parts[0],
+			objectKey: obj.Name,
+			version:   version,
+		})
+	}
+
+	if len(featureObjects) == 0 {
+		log.Printf("No feature branch objects found")
+		return nil
+	}
+
+	log.Printf("Found %d feature branch objects, checking active feature branches via git...", len(featureObjects))
+
+	// Use git ls-remote to list remote branches (no token needed)
+	cmd := exec.CommandContext(ctx, "git", "ls-remote", "--heads", "origin")
+	output, err := cmd.Output()
+	if err != nil {
+		return fmt.Errorf("failed to list remote branches via git: %w", err)
+	}
+
+	activeBranches := make(map[string]bool)
+	for _, line := range strings.Split(string(output), "\n") {
+		// Format: "<sha>\trefs/heads/<branch>"
+		parts := strings.Split(line, "\t")
+		if len(parts) < 2 {
+			continue
+		}
+		ref := parts[len(parts)-1]
+		branchName := strings.TrimPrefix(ref, "refs/heads/")
+		if strings.HasPrefix(branchName, "feature/") {
+			activeBranches[branchName] = true
+		}
+	}
+
+	log.Printf("Active feature branches: %d", len(activeBranches))
+	for branch := range activeBranches {
+		log.Printf("  - %s", branch)
+	}
+
+	var deleted int
+	for _, fo := range featureObjects {
+		branchPart := strings.TrimPrefix(fo.version, "feature-")
+		branchName := "feature/" + strings.ReplaceAll(branchPart, "--", "/")
+
+		if !activeBranches[branchName] {
+			log.Printf("Deleting stale: %s (branch %s no longer exists)", fo.objectKey, branchName)
+			err := client.Store.Delete(ctx, fo.objectKey)
+			if err != nil {
+				log.Printf("Failed to delete %s: %v", fo.objectKey, err)
+			} else {
+				deleted++
+			}
+		}
+	}
+
+	log.Printf("Cleanup complete: deleted %d stale feature branch objects", deleted)
+	return nil
+}
 func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, currentBinary string, keepCount int, cleanAll bool) error {
 	objects, err := store.List(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to list objects: %w", err)
 	}
 
-	// Group objects by binary/architecture path
-	// Expected structure: binary/arch/version
 	versionsByPath := make(map[string][]*jetstream.ObjectInfo)
 
 	for _, obj := range objects {
 		parts := strings.Split(obj.Name, "/")
 		if len(parts) < 3 {
-			// Not a version path, skip
 			continue
 		}
 
@@ -280,7 +394,6 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
 		arch := parts[1]
 		pathKey := binName + "/" + arch
 
-		// If not cleaning all and this isn't the current binary, skip
 		if !cleanAll && currentBinary != "" && binName != currentBinary {
 			continue
 		}
@@ -288,20 +401,16 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
 		versionsByPath[pathKey] = append(versionsByPath[pathKey], obj)
 	}
 
-	// For each binary/arch combination, keep only the most recent N versions
 	for pathKey, versions := range versionsByPath {
 		if len(versions) <= keepCount {
 			log.Printf("Path %s has %d versions, keeping all", pathKey, len(versions))
 			continue
 		}
 
-		// Sort by semantic version (newest first)
 		sort.Slice(versions, func(i, j int) bool {
-			// Extract version from path: binary/arch/version
 			versionI := filepath.Base(versions[i].Name)
 			versionJ := filepath.Base(versions[j].Name)
 
-			// Ensure versions start with 'v' for semver.Compare
 			if !strings.HasPrefix(versionI, "v") {
 				versionI = "v" + versionI
 			}
@@ -309,18 +418,14 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
 				versionJ = "v" + versionJ
 			}
 
-			// semver.Compare returns -1, 0, or 1
-			// We want newest first, so reverse the comparison
 			return semver.Compare(versionI, versionJ) > 0
 		})
 
-		// Delete old versions (everything after keepCount)
 		toDelete := versions[keepCount:]
 		log.Printf("Path %s has %d versions, deleting %d old versions", pathKey, len(versions), len(toDelete))
 
 		for _, obj := range toDelete {
-			version := filepath.Base(obj.Name)
-			log.Printf("Deleting old version: %s (version: %s)", obj.Name, version)
+			log.Printf("Deleting old version: %s", obj.Name)
 			err := store.Delete(ctx, obj.Name)
 			if err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
 				return fmt.Errorf("failed to delete %s: %w", obj.Name, err)
@@ -331,7 +436,6 @@ func cleanupOldVersions(ctx context.Context, store jetstream.ObjectStore, curren
 
 	return nil
 }
-
 func main() {
 	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
 	defer cancel()
